cloud 2 недель назад
Родитель
Сommit
5e3ff2edb6

+ 14 - 0
PROJECT_CONTEXT.md

@@ -1,5 +1,19 @@
 # Project Context
 
+## 2026-10-21 小程序登录加固:代理来源、多身份锁死、微信 errcode
+- 起因:`AuthController` 与 `RequestProfile::ip()` 都直接用 `$request->getRemoteIp()`。已确认 webman 该方法只返回 TCP 对端地址、完全不识别反向代理(`webman-framework/src/Http/Request.php:180`)。本机 8787 前若挂 nginx/宝塔网关,所有调用方会共用一个来源:登录限流按单一 60 次/分钟 桶计算;更严重的是 `TestPhoneLogin::assertSourceAllowed()` 会拿代理 IP 比对,而回环 `127.0.0.1` 正是默认白名单,等于对所有来源放行本地测试手机号登录。
+- 新增 `app/miniapi/http/ClientIp.php`:`from(peer, forwardedFor, trustedProxies)` 是纯函数以便单测,`resolve(Request)` 读配置后委托。只有直连对端在白名单内才解析 `X-Forwarded-For`,并从右向左跳过受信任的代理跳;不可解析、缺头或整链皆为受信任地址时回落到对端地址。兼容 `1.2.3.4:port` 与 `[::1]:port`,配置项与请求值统一经 inet_pton/ntop 规范化,避免配置写法差异导致静默失效。
+- 新增配置 `MINI_TRUSTED_PROXIES`,默认 `127.0.0.1,::1`(回环不可从网络到达)。不配置等价于维持原行为;反向代理部署须列出每一跳代理地址,否则聚合限流与测试来源放行问题依旧存在。`server/.env.example` 已补说明。
+- `AuthController::login`、`AuthController::testPhone`、`RequestProfile::ip()` 改用 `ClientIp::resolve()`。testPhone 中限流与来源白名单共用同一个解析结果,避免两者判定不一致。登录不再信任任意来源的 `X-Forwarded-For` 这一原有前提保持不变。
+- `WechatIdentityProvider`:微信对凭据类问题返回 HTTP 200 + errcode,原实现只判 `openid` 是否存在,导致 code 过期(40029)、code 已用(40163)、频率限制(40226/45009/45011)、AppID 无效(40013/40125) 全部塌缩为同一句 40102 且日志无痕。现按 errcode 分流,并以 `Log::warning('mini_api.wechat_login', ['errcode'=>...])` 留痕;只记录错误码,不记录 session_key/unionid/errmsg。复用既有 40102/42901/50301,未新增错误码,OpenAPI 与前端类型无需改动。响应体无法解析时由原来的 40102 改为更准确的 50302。
+- 多身份锁死:`Access::identities()` 在多个生效身份时抛 40903,该调用位于 `AuthService::issue()` 事务内会整体回滚,用户既拿不到 Token 也没有自助恢复路径(后台可逐条停用,但无盘点手段,且存量数据无清理入口)。新增 `scripts/audit_mini_identity_conflict.php`:默认只读,复用 `Access::resourceModel()` 复核授权指向的企业/司机/代理是否仍启用,区分「指向已删除或停用档案(Access 不计入,不影响登录)」与「真正冲突」;`--apply` 在事务内停用除最新一条外的生效授权并打印被停用编号,回滚方式为把这些编号的 status 改回 1。不删除记录,不改动用户与业务档案。
+- 验证:`php -l` 6 个改动文件全部通过。新增 `tests/mini_client_ip_test.php` 16 项全过,覆盖非信任对端忽略伪造头、可信代理解析、多跳从右向左、端口与 IPv6 括号形式、不可解析回落、配置项规范化。`mini_api_architecture_test` 118 项、`mini_api_contract_test` 883 项 51 操作、`mini_test_phone_test` 25 项、`water_mini_http_test` 110 项实测 HTTP 均通过。全量 33 个后端测试 28 通过、5 失败。
+- 5 项失败全部为改动前既有失败,已用 `git stash` 在干净树上逐一复现确认:`mini_identity_management_test.php`、`water_mini_api_test.php`(PROJECT_CONTEXT 已记录的「同一账号可绑定多身份」旧断言)、`mini_protection_test.php` 与 `mini_protection_http_test.php`(本机 Redis 未启动、保护开关未启用)、`water_ledger_test.php:18`。本轮未修改认证规则、未掩盖这些失败。
+- 本机当前数据为 3 条生效授权、0 冲突、0 失效引用,无需执行 `--apply`。php-cs-fixer 未安装在本环境,格式按 `.php-cs-fixer.mini.php` 规则人工核对(PSR12、concat_space、blank_line_before_statement return、no_unused_imports 等)。
+- 未做:`sa_water_mini_session` 无过期清理,长期累积;Token 无滑动续期,7 天硬过期后前端静默清会话;`LoginThrottle` 仍是文件锁单机限流,多机部署需换共享限流器;`water-mini/src/utils/login-flow.ts` 引用的 `/auth/send-sms`、`/auth/phone-login` 仍未实装,非测试号用户点「获取验证码」得到的是 404 类文案;前后端 appid 仍是两份文件无一致性校验。均未在本轮处理。
+- 文档同步:`server/docs/WATER_MINI_API.md` 更新调用方地址解析规则、测试手机号来源白名单的同源判定,以及新增「微信登录失败分流」小节(含 errcode 对照表)。
+- 提醒:`server/.env` 的 `MINI_WECHAT_SECRET` 明文曾在排查过程中输出到会话记录,需在微信公众平台重置该 AppSecret 并同步更新 `server/.env`。
+
 ## 2026-10-19 企业邀请推广赠水第一版
 - 用户选择先实现推广奖励;按企业邀请企业、首购后签收、财务人工核实后赠水权益实现。奖励桶数/规格可配置,默认关闭,无现金发放、无自动送水。
 - 新增 `server/plugin/saiadmin/app/service/promotion/`,三张 `sa_water_promotion_*` 表、本机已执行可复跑的增量迁移;没有回填历史奖励。平台规则在绑定时快照;禁止自邀、共用管理员及循环绑定,首购前绑定一次。购水共用入口 `WaterPurchaseReceipt` 生成待审核奖励,组合结算按整笔金额判断,首购前已有配送不能领奖。审核发放与权益、流水同事务,重复请求不重复发奖。

+ 3 - 0
server/.env.example

@@ -58,6 +58,9 @@ MINI_WECHAT_SECRET = ''
 APP_ENV = production
 MINI_TEST_PHONE_LOGIN = false
 MINI_TEST_PHONE_ALLOWED_IPS = '127.0.0.1,::1'
+# 只有这些对端地址可以设置 X-Forwarded-For。默认信任回环,nginx/负载均衡部署时需列出每一跳代理地址;
+# 保持默认时反向代理后的调用方都会被视为代理自身,登录限流与测试手机号来源白名单都会按单一来源计算。
+MINI_TRUSTED_PROXIES = '127.0.0.1,::1'
 MINI_SUPPORT_PHONE = ''
 MINI_PURCHASE_AGREEMENT_URL = ''
 MINI_PRIVACY_URL = ''

+ 25 - 3
server/app/miniapi/auth/WechatIdentityProvider.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace app\miniapi\auth;
 
 use app\miniapi\exception\ApiException;
+use support\Log;
 
 final class WechatIdentityProvider implements IdentityProvider
 {
@@ -37,14 +38,35 @@ final class WechatIdentityProvider implements IdentityProvider
             throw new ApiException(50302, '微信登录服务暂时不可用', 503);
         }
         $data = json_decode($raw, true);
-        if (!is_array($data) || empty($data['openid']) || !is_string($data['openid']) || strlen($data['openid']) > 128) {
-            throw new ApiException(40102, '微信登录凭据无效,请重新登录', 401);
+        if (!is_array($data)) {
+            throw new ApiException(50302, '微信登录服务暂时不可用', 503);
+        }
+        $openid = $data['openid'] ?? null;
+        if (!is_string($openid) || $openid === '' || strlen($openid) > 128) {
+            throw $this->failure((int) ($data['errcode'] ?? 0));
         }
 
         // session_key and unionid are deliberately neither returned nor logged.
         return [
             'appid' => $appid,
-            'openid' => $data['openid'],
+            'openid' => $openid,
         ];
     }
+
+    /**
+     * WeChat reports credential problems with HTTP 200 plus errcode, so an expired code, a
+     * reused code and an over-quota app would otherwise all collapse into one message and
+     * leave nothing to search for in the logs.
+     */
+    private function failure(int $errcode): ApiException
+    {
+        Log::warning('mini_api.wechat_login', ['errcode' => $errcode]);
+
+        return match ($errcode) {
+            40029, 40163 => new ApiException(40102, '微信登录凭据已失效,请重新登录', 401),
+            40226, 45009, 45011 => new ApiException(42901, '微信登录请求过于频繁,请稍后再试', 429, ['retry_after' => 60]),
+            40013, 40125 => new ApiException(50301, '微信登录配置无效,请联系平台管理员', 503),
+            default => new ApiException(40102, '微信登录凭据无效,请重新登录', 401),
+        };
+    }
 }

+ 9 - 4
server/app/miniapi/controller/AuthController.php

@@ -7,6 +7,7 @@ namespace app\miniapi\controller;
 use support\Request;
 use support\Response;
 use support\annotation\route\DisableDefaultRoute;
+use app\miniapi\http\ClientIp;
 use app\miniapi\http\Input;
 use app\miniapi\service\AuthService;
 use app\miniapi\auth\LoginThrottle;
@@ -17,8 +18,9 @@ final class AuthController extends BaseController
 {
     public function login(Request $request): Response
     {
-        // Do not trust arbitrary X-Forwarded-For headers to bypass login throttling.
-        (new LoginThrottle())->consume($request->getRemoteIp());
+        // ClientIp ignores the header unless the TCP peer is a configured proxy, so an
+        // untrusted caller still cannot forge an address to bypass login throttling.
+        (new LoginThrottle())->consume(ClientIp::resolve($request));
 
         return $this->success($request, (new AuthService())->login(Input::json($request)));
     }
@@ -30,9 +32,12 @@ final class AuthController extends BaseController
 
     public function testPhone(Request $request): Response
     {
-        (new LoginThrottle())->consume($request->getRemoteIp());
+        // Throttling and the source allowlist must judge the same caller; sharing one
+        // resolved address here is what keeps a local proxy from opening this endpoint.
+        $source = ClientIp::resolve($request);
+        (new LoginThrottle())->consume($source);
         $login = new TestPhoneLogin();
-        $login->assertSourceAllowed($request->getRemoteIp());
+        $login->assertSourceAllowed($source);
 
         return $this->success($request, $login->login(Input::json($request)));
     }

+ 71 - 0
server/app/miniapi/http/ClientIp.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\miniapi\http;
+
+use support\Request;
+
+/**
+ * Webman's getRemoteIp() only reports the TCP peer, so a reverse proxy collapses every caller
+ * onto one address: login throttling then treats the whole internet as a single client, and a
+ * loopback proxy satisfies the local test phone allowlist for anyone. Forwarded headers are read
+ * only when the direct peer is a configured proxy; otherwise the peer address stands and the
+ * caller-supplied header is ignored.
+ */
+final class ClientIp
+{
+    public static function resolve(Request $request): string
+    {
+        return self::from(
+            $request->getRemoteIp(),
+            (string) $request->header('x-forwarded-for', ''),
+            (array) config('mini_api.trusted_proxies', [])
+        );
+    }
+
+    /**
+     * @param list<string> $trustedProxies peers allowed to speak for the caller
+     */
+    public static function from(string $peer, string $forwardedFor, array $trustedProxies): string
+    {
+        $proxies = [];
+        foreach ($trustedProxies as $entry) {
+            $ip = self::normalize(trim((string) $entry));
+            if ($ip !== null) {
+                $proxies[] = $ip;
+            }
+        }
+        $peerIp = self::normalize($peer);
+        if ($peerIp === null || !in_array($peerIp, $proxies, true)) {
+            return $peer;
+        }
+
+        // Walk the chain right to left and take the first hop that is not another proxy.
+        foreach (array_reverse(explode(',', $forwardedFor)) as $candidate) {
+            $ip = self::normalize(trim($candidate));
+            if ($ip !== null && !in_array($ip, $proxies, true)) {
+                return $ip;
+            }
+        }
+
+        return $peer;
+    }
+
+    /** Accepts bare addresses plus the [::1]:port and 1.2.3.4:port forms proxies emit. */
+    private static function normalize(string $value): ?string
+    {
+        if ($value === '') {
+            return null;
+        }
+        if (str_starts_with($value, '[')) {
+            $end = strpos($value, ']');
+            $value = $end === false ? '' : substr($value, 1, $end - 1);
+        } elseif (substr_count($value, ':') === 1) {
+            $value = explode(':', $value, 2)[0];
+        }
+        $packed = @inet_pton($value);
+
+        return $packed === false ? null : (string) inet_ntop($packed);
+    }
+}

+ 4 - 2
server/app/miniapi/protection/RequestProfile.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace app\miniapi\protection;
 
 use Webman\Http\Request;
+use app\miniapi\http\ClientIp;
 use app\miniapi\http\Pagination;
 
 /** Only registered routes create endpoint buckets; IDs and query filters share quotas. */
@@ -47,8 +48,9 @@ final class RequestProfile
 
     public static function ip(Request $request): string
     {
-        // Never trust caller-supplied X-Forwarded-For or X-Real-IP.
-        $ip = $request->getRemoteIp();
+        // Resolved through the proxy allowlist so a deployment behind Nginx keeps per-caller
+        // buckets; an untrusted peer's own header is still ignored.
+        $ip = ClientIp::resolve($request);
         $packed = @inet_pton($ip);
         if ($packed === false) {
             return 'unknown';

+ 8 - 0
server/config/mini_api.php

@@ -12,6 +12,14 @@ return [
         'MINI_TEST_PHONE_ALLOWED_IPS',
         '127.0.0.1,::1'
     ))))),
+    // Only these peers may set X-Forwarded-For. Behind a reverse proxy every caller otherwise
+    // shares one address, which merges login throttling and the test phone allowlist.
+    // Loopback is trusted by default because it is unreachable from the network; list every
+    // proxy hop explicitly when deploying behind Nginx or a load balancer.
+    'trusted_proxies' => array_values(array_filter(array_map('trim', explode(',', (string) env(
+        'MINI_TRUSTED_PROXIES',
+        '127.0.0.1,::1'
+    ))))),
     'session_ttl' => 604800,
     'login_ip_per_minute' => 60,
     'login_global_per_minute' => 600,

+ 18 - 1
server/docs/WATER_MINI_API.md

@@ -8,6 +8,21 @@ UI 依据:`D:\水后台UI\mobile\M01.png` 至 `M18.png`。后端接口持续
 
 本地测试入口 `POST /auth/test-phone` 使用三个独立测试号:企业 `13012345678`、司机 `13012345679`、代理 `13012345680`,固定验证码均为 `66666`。返回与微信登录相同的 Login 结构。仅 `APP_ENV=local` 且 `MINI_TEST_PHONE_LOGIN=true` 时启用,默认只接受 `127.0.0.1` 和 `::1` 来源。局域网设备调试时,可在 `MINI_TEST_PHONE_ALLOWED_IPS` 中显式添加设备 IP(逗号分隔),并重载 Webman。`GET /config` 的 `capabilities.test_phone_login` 控制前端入口;该能力标志不表示当前客户端 IP 已获准。正式短信登录尚未接入,此接口不会验证手机号所有权或关联同号微信账号。
 
+该来源白名单与登录限流使用同一个解析后的调用方地址,因此本机默认的 `127.0.0.1,::1` 不会因为前面挂了回环代理而对所有来源放行;实际放行仍取决于调用方解析出的真实地址。
+
+## 2026-10-21 微信登录失败分流
+
+微信对凭据类问题返回 HTTP 200 并在 body 中带 `errcode`,此前只判断 `openid` 是否存在,导致所有失败都返回同一句 40102 且日志无记录。现在按 `errcode` 分流,并把 `errcode` 记入 `mini_api.wechat_login` 日志,只记录错误码本身,不记录 `session_key`、`unionid` 或 `errmsg`。
+
+| errcode                    | 含义                       | 返回              |
+| -------------------------- | -------------------------- | ----------------- |
+| 40029 / 40163              | code 无效或已被使用        | 40102 凭据已失效  |
+| 40226 / 45009 / 45011      | 频率限制或配额超限         | 42901 稍后重试    |
+| 40013 / 40125              | AppID 或凭据配置无效       | 50301 联系管理员  |
+| 其他                       | 未识别的凭据错误           | 40102 凭据无效    |
+
+未复用既有的 40102、42901、50301,未新增错误码。
+
 先执行 `php -d extension=mbstring scripts/seed_mini_test_phone.php --apply` 初始化三个独立的 `local-test-phone` 账号及对应测试档案权限,同时停用原企业测试号上的司机和代理授权。脚本复跑不覆盖账号/授权停用状态,不创建业务收款、库存或订单。会话仍随机生成并只保存哈希,沿用账号停用、权限校验、限流与退出撤销。关闭开关并重新加载后端配置后,既有测试会话不能继续访问。
 
 `tests/mini_test_phone_test.php` 在事务回滚中覆盖固定凭据、非本机来源、生产环境、关闭开关、账号停用、独立会话与注销。本机实际 HTTP 已验证登录后读取三个身份及无权限资源被拒绝。前端运行、微信开发者工具导入目录和 API 地址配置见 `water-mini/README.md`。
@@ -69,7 +84,9 @@ routes/mini_api.php
 5. `GET /me` 读取当前账号的业务身份;一个小程序账号只能有一个有效身份,登录后直接进入对应页面。服务器每次重新校验资源归属。
 6. `GET /session` 查看登录主体,`POST /auth/logout` 退出。令牌默认 7 天有效,数据库只存 SHA-256 摘要。退出、过期、账号停用均拒绝访问。
 
-登录限流以 TCP 来源地址计算,不信任任意 `X-Forwarded-For`:单 IP 60 次/分钟,全机 600 次/分钟。文件锁在单机多进程间共享;多机部署前改为共享限流设施。反向代理部署时需根据同一代理地址聚合的实际流量配置限额。
+登录限流以真实调用方地址计算:单 IP 60 次/分钟,全机 600 次/分钟。文件锁在单机多进程间共享;多机部署前改为共享限流设施。
+
+调用方地址由 `app/miniapi/http/ClientIp.php` 解析。仅当直连对端地址列在 `MINI_TRUSTED_PROXIES`(默认 `127.0.0.1,::1`)中时才解析 `X-Forwarded-For`,并从右向左跳过受信任的代理跳;否则直接使用 TCP 对端地址,调用方自带的该请求头被完全忽略。因此未配置该项时行为与只信任 TCP 地址一致,但反向代理后的所有调用方会被视为同一来源,登录限流与测试手机号来源白名单都会按聚合流量计算。部署在 nginx 或负载均衡之后时,须把每一跳代理地址写入 `MINI_TRUSTED_PROXIES` 并重载 Webman。
 
 ## UI 对应接口
 

+ 85 - 0
server/scripts/audit_mini_identity_conflict.php

@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * 小程序身份冲突盘点。
+ *
+ * 一个账号只能绑定一个业务身份。Access::identities() 在识别到多个生效身份时抛出 40903,
+ * 而该调用位于 AuthService::issue() 的事务内,异常会把本次登录整体回滚——这样的账号既拿不到
+ * Token,也没有自助恢复路径,只能由管理员逐条停用旧授权。
+ *
+ * 默认只读输出。--apply 在事务内停用同一账号除最新一条以外的生效授权,并打印被停用的授权编号,
+ * 回滚方式是把这些编号的 status 改回 1。脚本不删除任何记录,也不改动用户和业务档案。
+ */
+require dirname(__DIR__) . '/vendor/autoload.php';
+\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
+\support\App::loadAllConfig(['route']);
+
+use app\miniapi\model\MiniGrant;
+use app\miniapi\service\Access;
+
+$apply = in_array('--apply', $argv, true);
+$active = MiniGrant::query()->where('status', 1)->orderBy('user_id')->orderBy('id')->get();
+
+$live = [];
+$stale = [];
+foreach ($active as $grant) {
+    $model = Access::resourceModel($grant->role);
+    $resource = $model === null
+        ? null
+        : $model::query()->where('id', $grant->resource_id)->where('status', 1)->first();
+    if ($resource === null) {
+        $stale[] = $grant;
+        continue;
+    }
+    $live[(int) $grant->user_id][] = $grant;
+}
+
+$conflicts = array_filter($live, static fn (array $rows): bool => count($rows) > 1);
+
+printf("生效授权 %d 条,其中指向已删除或已停用档案的 %d 条(Access 不计入,不影响登录)。\n", $active->count(), count($stale));
+printf("身份冲突账号 %d 个。\n", count($conflicts));
+
+foreach ($conflicts as $userId => $rows) {
+    printf("  user_id=%d 生效身份 %d 个:", $userId, count($rows));
+    foreach ($rows as $grant) {
+        printf('grant#%d %s/%d ', $grant->id, $grant->role, $grant->resource_id);
+    }
+    printf("(保留最新 grant#%d)\n", end($rows)->id);
+}
+
+if (!$conflicts) {
+    echo "无需处理。\n";
+    exit;
+}
+
+if (!$apply) {
+    echo "计划:如需停用多余身份,执行 php scripts/audit_mini_identity_conflict.php --apply\n";
+    exit;
+}
+
+$disabled = [];
+(new MiniGrant())->getConnection()->transaction(function () use ($conflicts, &$disabled): void {
+    foreach ($conflicts as $userId => $rows) {
+        $keep = end($rows);
+        foreach ($rows as $grant) {
+            if ((int) $grant->id === (int) $keep->id) {
+                continue;
+            }
+            MiniGrant::query()->where('id', $grant->id)->update([
+                'status' => 0,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+            $disabled[] = [
+                'grant_id' => (int) $grant->id,
+                'role' => $grant->role,
+                'resource_id' => (int) $grant->resource_id,
+                'kept_grant_id' => (int) $keep->id,
+            ];
+        }
+    }
+});
+
+printf("已停用 %d 条多余授权。受影响的账号可重新登录;如判断有误,将下列授权的 status 改回 1 即可恢复。\n", count($disabled));
+echo json_encode($disabled, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;

+ 58 - 0
server/tests/mini_client_ip_test.php

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+/** Proxy-aware caller address resolution. No database or network access. */
+require dirname(__DIR__) . '/vendor/autoload.php';
+\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
+\support\App::loadAllConfig(['route']);
+
+use app\miniapi\http\ClientIp;
+
+$checks = 0;
+function check(bool $ok, string $label): void
+{
+    global $checks;
+    $checks++;
+    if (!$ok) {
+        throw new RuntimeException($label);
+    }
+}
+
+$loopback = ['127.0.0.1', '::1'];
+
+// An untrusted peer keeps its own address and its header is ignored outright.
+check(ClientIp::from('203.0.113.9', '1.2.3.4', $loopback) === '203.0.113.9', 'Untrusted peer ignores forwarded header');
+check(ClientIp::from('203.0.113.9', '', []) === '203.0.113.9', 'Empty allowlist ignores forwarded header');
+
+// Behind a trusted proxy the caller address comes from the chain.
+check(ClientIp::from('127.0.0.1', '203.0.113.9', $loopback) === '203.0.113.9', 'Trusted loopback proxy resolves caller');
+check(ClientIp::from('::1', '203.0.113.9', $loopback) === '203.0.113.9', 'Trusted IPv6 loopback proxy resolves caller');
+
+// Multi-hop chains are walked right to left, skipping every trusted hop.
+check(
+    ClientIp::from('127.0.0.1', '1.2.3.4, 203.0.113.9, 127.0.0.1', $loopback) === '203.0.113.9',
+    'Rightmost untrusted hop wins'
+);
+check(
+    ClientIp::from('127.0.0.1', '1.2.3.4, 203.0.113.9, 10.0.0.1', ['127.0.0.1', '10.0.0.1']) === '203.0.113.9',
+    'Configured proxy hops are skipped'
+);
+
+// A proxy that sends no usable header falls back to the peer rather than trusting junk.
+check(ClientIp::from('127.0.0.1', '', $loopback) === '127.0.0.1', 'Missing header falls back to peer');
+check(ClientIp::from('127.0.0.1', '   ', $loopback) === '127.0.0.1', 'Blank header falls back to peer');
+check(ClientIp::from('127.0.0.1', 'not-an-ip, bogus', $loopback) === '127.0.0.1', 'Unparsable chain falls back to peer');
+check(ClientIp::from('127.0.0.1', '127.0.0.1, ::1', $loopback) === '127.0.0.1', 'Fully trusted chain falls back to peer');
+
+// Proxy formats: host:port for IPv4 and the bracketed form for IPv6.
+check(ClientIp::from('127.0.0.1', '203.0.113.9:54321', $loopback) === '203.0.113.9', 'IPv4 port suffix stripped');
+check(ClientIp::from('127.0.0.1', '[2001:db8::1]:443', $loopback) === '2001:db8::1', 'Bracketed IPv6 port stripped');
+check(ClientIp::from('[::1]:8080', '203.0.113.9', ['::1']) === '203.0.113.9', 'Peer given in bracketed form');
+check(ClientIp::from('::1', '2001:db8::1', ['::1']) === '2001:db8::1', 'Bare IPv6 chain entry kept intact');
+
+// Allowlist entries are canonicalised too, so configuration spelling cannot silently diverge.
+check(ClientIp::from('127.0.0.1', '203.0.113.9', [' 127.0.0.1 ', '::1', '', 'garbage']) === '203.0.113.9', 'Allowlist entries trimmed');
+check(ClientIp::from('', '203.0.113.9', $loopback) === '', 'Empty peer stays empty');
+
+echo "PASS: $checks client address checks.\n";

+ 2 - 2
water-mini/project.config.json

@@ -2,7 +2,7 @@
   "description": "uni-app 本地开发入口,请先运行 npm run build:mp-weixin",
   "miniprogramRoot": "dist/build/mp-weixin/",
   "compileType": "miniprogram",
-  "appid": "wxb53276de7129d025",
+  "appid": "wx8ebee994ea7c5af3",
   "projectname": "云泉智配本地测试",
   "setting": {
     "urlCheck": false,
@@ -33,4 +33,4 @@
     "include": []
   },
   "editorSetting": {}
-}
+}

+ 1 - 1
water-mini/src/manifest.json

@@ -12,7 +12,7 @@
     "router": { "mode": "hash", "base": "/water-mini/" }
   },
   "mp-weixin": {
-    "appid": "wxb53276de7129d025",
+    "appid": "wx8ebee994ea7c5af3",
     "setting": { "urlCheck": false, "es6": true, "minified": true },
     "usingComponents": true,
     "permission": {