cloud 2 недель назад
Родитель
Сommit
4cdbd284ac

+ 13 - 0
PROJECT_CONTEXT.md

@@ -1,5 +1,18 @@
 # Project Context
 
+## 2026-10-21 短信验证码登录(方案A:手机号预绑定到已有账号)
+- 起因:`login-flow.ts` 早就引用了 `/auth/send-sms` 与 `/auth/phone-login`,但两个接口从未实装,非测试号用户点「获取验证码」得到的是失败文案。微信登录侧 AppID 又在 `5e3ff2e` 里被误改为 `wx8ebee994ea7c5af3`(真实账号是 `wxb53276de7129d025`),本地源码与 `.env` 一致地错、反而更难排查。
+- 方案:用户选定 A —— 短信作为**已有账号的第二种凭据**,手机号须先由超级管理员在后台绑定;不新建账号。这样 `user_id`、业务身份、权限与同一账号的微信登录完全一致,且不与 `IdentityManagement::grant()` 的「1 账号 1 身份」冲突。B(自动建号)会造出零身份账号,登录后只能停在 `/pages/account/identities` 空页;C(按手机号反查业务档案自动授权)因 water 业务档案上根本没有 phone/mobile 字段而未采用。
+- 新增 `POST /auth/send-sms`、`POST /auth/phone-login`(公开组,与其他登录入口共用 `LoginThrottle`)、`POST /core/water/mini-user-phone` 后台绑定。`SystemService` 新增 `capabilities.sms_login`。`AuthService` 抽出 `issueForExisting()`:原 `issue()` 会为新身份建号,短信登录必须登录到已存在的行,两条路径共用 `sessionFor()`。未改动 `grant()`。
+- **防枚举**:已绑定与未绑定手机号返回完全一致的 `{sent:true,expires_in:300}`,未绑定时不发短信也不落库。否则该接口可直接用于枚举哪些手机号注册过本系统。
+- 验证码 6 位 `random_int`、5 分钟过期、单次有效、只存 HMAC-SHA256(密钥 `MINI_SMS_CODE_PEPPER`,留空回退 `MINI_SMS_PASSWORD`)。6 位空间小,裸 SHA-256 可离线穷举,加盐是必需的。明文不进库不进日志。行锁保证并发下同一个码不会被用两次。
+- **实现期发现并修掉的真实缺陷**:`consume()` 原先在事务内 `throw`,导致一次输错把刚累加的失败次数一起回滚,5 次锁定形同虚设。改为事务内只判定、提交后再抛。
+- 限流全部发生在发短信之前:同号 60s、单号每日 10 条、单 IP 每小时 30 条。短信宝返回码(0 成功,-1 参数不全,-2 账号密码错误,-3/-4 余额,30 间隔太短)统一转 50302,不透传;日志只记状态码,请求 URL 含密码故从不记录。短信下发失败立即 `discard()` 该验证码,未送达的码不会被事后使用。
+- 迁移 `scripts/migrate_water_mini_sms.php --apply` 已在本机执行并复跑验证幂等:新增 `sa_water_mini_sms_code`、给 `sa_water_mini_user` 加 `phone` 及唯一索引。MySQL 唯一索引不约束 NULL,存量无手机号账号不受影响。`.env` 已写入短信宝账号(gitignore),`.env.example` 为占位。
+- 验证:新测试 `mini_sms_login_test.php` 39 项全过(注入记录型 sender,全程无短信外发,全部数据回滚);契约 911 项/53 操作、架构 126 项、框架 47 项、HTTP 110 项、测试手机号 25 项、ClientIp 16 项、setup 5 项均过。全量 34 个后端测试 29 通过、5 失败,失败清单与上一轮完全一致(`mini_identity_management_test.php`、`water_mini_api_test.php` 旧断言,`mini_protection_*` 本机 Redis 未启动,`water_ledger_test.php:18`),已用 `git stash` 在还原后的干净树复现确认非本次引入。实测 HTTP:未绑定号下发返回 200 且不发短信、未绑定登录 401、格式非法/多余字段/非常用号段均 422。前端 `vue-tsc` 通过、25 项测试通过、mp-weixin 重新编译通过。
+- 未做:未与短信宝真实联调发送(本轮验证全部走注入的假 sender,用户真实号码未收到短信);未提供专用解绑接口;未加 AppID 前后端一致性自检(`PROJECT_CONTEXT` 早已记录该缺口);`sa_water_mini_sms_code` 靠 `issue()` 时的机会式清理(删除 1 天前记录)收敛,没有独立定时任务。
+- 提醒:短信宝账号密码是在对话里明文给出的,只落在 gitignore 的 `server/.env`;若该对话会被留存或分享,建议在短信宝后台重置一次。
+
 ## 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 规范化,避免配置写法差异导致静默失效。

+ 10 - 1
server/.env.example

@@ -54,6 +54,15 @@ FRONTEND_DIR = saiadmin-vue
 # 小程序独立微信登录;留空时登录明确返回未配置,不能用后台 JWT 代替。
 MINI_WECHAT_APPID = ''
 MINI_WECHAT_SECRET = ''
+
+# 短信登录(短信宝)。手机号必须先在后台绑定到已有小程序账号才会真正下发短信。
+# MINI_SMS_PASSWORD 为 32 位十六进制时,短信宝按 MD5 密码处理,这里原样透传,不要自行解密。
+# 可选:MINI_SMS_CODE_PEPPER 用于验证码哈希的额外密钥,留空时回退使用 MINI_SMS_PASSWORD。
+MINI_SMS_LOGIN = false
+MINI_SMS_USER = ''
+MINI_SMS_PASSWORD = ''
+MINI_SMS_ENDPOINT = 'https://api.smsbao.com/sms'
+MINI_SMS_CODE_PEPPER = ''
 # Fixed test phone login requires APP_ENV=local and this switch. Keep disabled when deployed.
 APP_ENV = production
 MINI_TEST_PHONE_LOGIN = false
@@ -65,4 +74,4 @@ MINI_SUPPORT_PHONE = ''
 MINI_PURCHASE_AGREEMENT_URL = ''
 MINI_PRIVACY_URL = ''
 # Exact browser origins for Swagger UI / H5, comma-separated. Replace with the actual HTTPS origin when deployed.
-MINI_ALLOWED_ORIGINS = 'http://127.0.0.1:8787,http://localhost:8787'
+MINI_ALLOWED_ORIGINS = 'http://127.0.0.1:8787,http://localhost:8787'

+ 132 - 0
server/app/miniapi/auth/SmsCode.php

@@ -0,0 +1,132 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\miniapi\auth;
+
+use app\miniapi\exception\ApiException;
+use app\miniapi\model\MiniSmsCode;
+
+/**
+ * One-time login codes. The plaintext code never touches the database and is never logged:
+ * only a keyed hash is stored, so a leaked table cannot be brute forced offline.
+ */
+final class SmsCode
+{
+    public const PURPOSE = 'login';
+
+    public static function enabled(): bool
+    {
+        return config('mini_api.sms_login', false) === true
+            && (string) config('mini_api.sms_user', '') !== ''
+            && (string) config('mini_api.sms_password', '') !== '';
+    }
+
+    public static function ttl(): int
+    {
+        return (int) config('mini_api.sms_code_ttl', 300);
+    }
+
+    /**
+     * Applies every send limit before a code is generated, so a caller who is already
+     * throttled never causes an SMS to leave the server.
+     */
+    public static function assertSendAllowed(string $phone, string $ip): void
+    {
+        $interval = (int) config('mini_api.sms_resend_interval', 60);
+        if (MiniSmsCode::query()->where('phone', $phone)->where('created_at', '>', date('Y-m-d H:i:s', time() - $interval))->exists()) {
+            throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => $interval]);
+        }
+        $perIp = (int) config('mini_api.sms_per_ip_per_hour', 30);
+        if ($perIp > 0 && MiniSmsCode::query()->where('request_ip', $ip)
+            ->where('created_at', '>', date('Y-m-d H:i:s', time() - 3600))->count() >= $perIp) {
+            throw new ApiException(42901, '验证码发送过于频繁,请稍后再试', 429, ['retry_after' => 3600]);
+        }
+        $perPhone = (int) config('mini_api.sms_daily_per_phone', 10);
+        if ($perPhone > 0 && MiniSmsCode::query()->where('phone', $phone)
+            ->where('created_at', '>=', date('Y-m-d 00:00:00'))->count() >= $perPhone) {
+            throw new ApiException(42901, '今日验证码发送次数已达上限,请明日再试', 429, ['retry_after' => 86400]);
+        }
+    }
+
+    /** Returns the plaintext code for delivery. The caller sends it and never persists it. */
+    public static function issue(string $phone, string $ip): string
+    {
+        self::assertSendAllowed($phone, $ip);
+        $code = (string) random_int(100000, 999999);
+        MiniSmsCode::query()->insert([
+            'phone' => $phone,
+            'purpose' => self::PURPOSE,
+            'code_hash' => self::hash($phone, $code),
+            'attempts' => 0,
+            'expires_at' => date('Y-m-d H:i:s', time() + self::ttl()),
+            'request_ip' => $ip,
+            'created_at' => date('Y-m-d H:i:s'),
+        ]);
+        self::purge();
+
+        return $code;
+    }
+
+    /**
+     * Consumes the newest live code for the phone in a transaction so two concurrent requests
+     * cannot both redeem it. The outcome is decided inside the transaction but the rejection is
+     * raised only after it commits, otherwise a failed guess would roll back the very attempt
+     * counter that is meant to stop the guessing.
+     */
+    public static function consume(string $phone, string $code): void
+    {
+        $max = (int) config('mini_api.sms_max_attempts', 5);
+        $outcome = (new MiniSmsCode())->getConnection()->transaction(function () use ($phone, $code, $max) {
+            $row = MiniSmsCode::query()->where('phone', $phone)->where('purpose', self::PURPOSE)
+                ->whereNull('consumed_at')->where('expires_at', '>', date('Y-m-d H:i:s'))
+                ->orderByDesc('id')->lockForUpdate()->first();
+            if (!$row) {
+                return ['expired', false];
+            }
+            if (!hash_equals((string) $row->code_hash, self::hash($phone, $code))) {
+                $attempts = (int) $row->attempts + 1;
+                $locked = $attempts >= $max;
+                MiniSmsCode::query()->where('id', $row->id)->update([
+                    'attempts' => $attempts,
+                    // Burning the row on the last miss stops guessing without a separate lock table.
+                    'consumed_at' => $locked ? date('Y-m-d H:i:s') : null,
+                ]);
+
+                return ['mismatch', $locked];
+            }
+            MiniSmsCode::query()->where('id', $row->id)->update([
+                'attempts' => (int) $row->attempts + 1,
+                'consumed_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            return ['ok', false];
+        });
+
+        match ($outcome[0]) {
+            'ok' => null,
+            'mismatch' => throw new ApiException(40102, $outcome[1] ? '验证码错误次数过多,请重新获取' : '验证码错误', 401),
+            default => throw new ApiException(40102, '验证码已失效,请重新获取', 401),
+        };
+    }
+
+    /** Removes a code that was never delivered, so it cannot be guessed or redeemed later. */
+    public static function discard(string $phone, string $code): void
+    {
+        MiniSmsCode::query()->where('phone', $phone)->where('code_hash', self::hash($phone, $code))->delete();
+    }
+
+    private static function hash(string $phone, string $code): string
+    {
+        // Keyed so an offline attacker holding the table still cannot search a 6-digit space.
+        $key = (string) config('mini_api.sms_code_pepper', '') ?: (string) config('mini_api.sms_password', '');
+
+        return hash_hmac('sha256', $code, $key . '|' . $phone . '|' . self::PURPOSE);
+    }
+
+    /** Opportunistic cleanup keeps the table bounded without needing a scheduled job. */
+    private static function purge(): void
+    {
+        MiniSmsCode::query()->where('created_at', '<', date('Y-m-d H:i:s', time() - 86400))->delete();
+    }
+}

+ 17 - 0
server/app/miniapi/controller/AuthController.php

@@ -12,6 +12,7 @@ use app\miniapi\http\Input;
 use app\miniapi\service\AuthService;
 use app\miniapi\auth\LoginThrottle;
 use app\miniapi\service\TestPhoneLogin;
+use app\miniapi\service\SmsLogin;
 
 #[DisableDefaultRoute]
 final class AuthController extends BaseController
@@ -30,6 +31,22 @@ final class AuthController extends BaseController
         return $this->success($request, (new AuthService())->logout(substr($request->header('authorization'), 7)));
     }
 
+    public function sendSms(Request $request): Response
+    {
+        // Throttled like the other login entry points: one caller must not be able to
+        // spend the SMS balance of the whole account.
+        (new LoginThrottle())->consume(ClientIp::resolve($request));
+
+        return $this->success($request, (new SmsLogin())->sendCode(Input::json($request), ClientIp::resolve($request)));
+    }
+
+    public function phoneLogin(Request $request): Response
+    {
+        (new LoginThrottle())->consume(ClientIp::resolve($request));
+
+        return $this->success($request, (new SmsLogin())->login(Input::json($request)));
+    }
+
     public function testPhone(Request $request): Response
     {
         // Throttling and the source allowlist must judge the same caller; sharing one

+ 11 - 0
server/app/miniapi/model/MiniSmsCode.php

@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\miniapi\model;
+
+final class MiniSmsCode extends MiniModel
+{
+    protected $table = 'sa_water_mini_sms_code';
+    protected $hidden = ['code_hash', 'request_ip'];
+}

+ 37 - 20
server/app/miniapi/service/AuthService.php

@@ -37,33 +37,50 @@ final class AuthService
                 'created_at' => $now,
                 'updated_at' => $now,
             ]);
-            $user = MiniUser::query()->where($identity)->lockForUpdate()->firstOrFail();
-            if ((int) $user->status !== 1) {
-                throw new ApiException(40302, '账号已停用', 403);
-            }
-            $token = bin2hex(random_bytes(32));
-            $expires = date('Y-m-d H:i:s', time() + (int) config('mini_api.session_ttl', 604800));
-            MiniSession::query()->insert([
-                'user_id' => $user->id,
-                'token_hash' => hash('sha256', $token),
-                'expires_at' => $expires,
-                'created_at' => $now,
-            ]);
 
-            return [
-                'access_token' => $token,
-                'token_type' => 'Bearer',
-                'expires_at' => $expires,
-                'user_id' => (string) $user->id,
-                'identities' => (new Access(new Principal((string) $user->id)))->identities(),
-            ];
+            return $this->sessionFor(MiniUser::query()->where($identity)->lockForUpdate()->firstOrFail());
+        });
+    }
+
+    /**
+     * Issues a session for an account that already exists, so a verified phone reaches the same
+     * user and therefore the same granted identity as the WeChat login it was bound to.
+     */
+    public function issueForExisting(MiniUser $user): array
+    {
+        return (new MiniUser())->getConnection()->transaction(function () use ($user) {
+            return $this->sessionFor(MiniUser::query()->where('id', $user->id)->lockForUpdate()->firstOrFail());
         });
     }
 
+    private function sessionFor(MiniUser $user): array
+    {
+        if ((int) $user->status !== 1) {
+            throw new ApiException(40302, '账号已停用', 403);
+        }
+        $now = date('Y-m-d H:i:s');
+        $token = bin2hex(random_bytes(32));
+        $expires = date('Y-m-d H:i:s', time() + (int) config('mini_api.session_ttl', 604800));
+        MiniSession::query()->insert([
+            'user_id' => $user->id,
+            'token_hash' => hash('sha256', $token),
+            'expires_at' => $expires,
+            'created_at' => $now,
+        ]);
+
+        return [
+            'access_token' => $token,
+            'token_type' => 'Bearer',
+            'expires_at' => $expires,
+            'user_id' => (string) $user->id,
+            'identities' => (new Access(new Principal((string) $user->id)))->identities(),
+        ];
+    }
+
     public function logout(string $token): array
     {
         MiniSession::query()->where('token_hash', hash('sha256', $token))->whereNull('revoked_at')->update(['revoked_at' => date('Y-m-d H:i:s')]);
 
         return ['logged_out' => true];
     }
-}
+}

+ 38 - 0
server/app/miniapi/service/IdentityManagement.php

@@ -9,6 +9,7 @@ use app\miniapi\model\MiniGrant;
 use app\miniapi\http\Fields;
 use app\miniapi\http\Input;
 use app\miniapi\http\Pagination;
+use app\miniapi\support\PhoneNumber;
 use app\miniapi\exception\ApiException;
 use plugin\saiadmin\app\model\water\WaterOperator;
 
@@ -35,6 +36,7 @@ final class IdentityManagement
             return [
                 'id' => (int) $u->id,
                 'nickname' => $u->nickname,
+                'phone' => PhoneNumber::mask((string) ($u->phone ?? '')),
                 'status' => (int) $u->status,
                 'created_at' => $u->created_at,
                 'grants' => MiniGrant::query()->where('user_id', $u->id)->get([
@@ -101,4 +103,40 @@ final class IdentityManagement
             ];
         });
     }
+    /**
+     * Binds a phone to an existing account so SMS login reaches the same user and therefore the
+     * same granted identity. This never creates an account and never changes a grant.
+     */
+    public function bindPhone(int $admin, array $input): array
+    {
+        $this->authorize($admin);
+        Fields::only($input, [
+            'user_id',
+            'phone',
+        ]);
+        $user = Fields::id($input, 'user_id');
+        $phone = SmsLogin::mobile($input);
+
+        return (new MiniUser())->getConnection()->transaction(function () use ($admin, $user, $phone) {
+            if (!MiniUser::query()->where('id', $user)->lockForUpdate()->first()) {
+                throw new ApiException(40401, '小程序用户不存在', 404);
+            }
+            if (MiniUser::query()->where('phone', $phone)->where('id', '!=', $user)->lockForUpdate()->exists()) {
+                throw new ApiException(40903, '该手机号已绑定其他小程序账号,请先解绑', 409);
+            }
+            MiniUser::query()->where('id', $user)->update([
+                'phone' => $phone,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+            Commands::audit('admin', $admin, 'identity.bind_phone', $user, [
+                'user_id' => $user,
+                'phone' => PhoneNumber::mask($phone),
+            ]);
+
+            return [
+                'user_id' => $user,
+                'phone' => PhoneNumber::mask($phone),
+            ];
+        });
+    }
 }

+ 73 - 0
server/app/miniapi/service/SmsLogin.php

@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\miniapi\service;
+
+use app\miniapi\auth\SmsCode;
+use app\miniapi\exception\ApiException;
+use app\miniapi\http\Fields;
+use app\miniapi\model\MiniUser;
+
+final class SmsLogin
+{
+    public function __construct(private readonly SmsSender $sender = new SmsbaoSender())
+    {
+    }
+
+    /**
+     * Always answers with the same payload whether or not the phone belongs to an account.
+     * Distinguishing the two cases would let a caller enumerate registered numbers, so an
+     * unbound phone silently consumes nothing and receives no message.
+     */
+    public function sendCode(array $input, string $ip): array
+    {
+        if (!SmsCode::enabled()) {
+            throw new ApiException(50301, '短信登录未开启', 503);
+        }
+        Fields::only($input, ['phone']);
+        $phone = self::mobile($input);
+        SmsCode::assertSendAllowed($phone, $ip);
+        if (!MiniUser::query()->where('phone', $phone)->where('status', 1)->exists()) {
+            return ['sent' => true, 'expires_in' => SmsCode::ttl()];
+        }
+        $code = SmsCode::issue($phone, $ip);
+        try {
+            $this->sender->send($phone, sprintf('您的验证码是 %s,%d 分钟内有效。请勿转发给他人。', $code, intdiv(SmsCode::ttl(), 60)));
+        } catch (ApiException $error) {
+            // A code the caller never received must not remain verifiable.
+            SmsCode::discard($phone, $code);
+
+            throw $error;
+        }
+
+        return ['sent' => true, 'expires_in' => SmsCode::ttl()];
+    }
+
+    public function login(array $input): array
+    {
+        if (!SmsCode::enabled()) {
+            throw new ApiException(50301, '短信登录未开启', 503);
+        }
+        Fields::only($input, ['phone', 'code']);
+        $phone = self::mobile($input);
+        SmsCode::consume($phone, Fields::text($input, 'code', 6));
+        $user = MiniUser::query()->where('phone', $phone)->first();
+        if (!$user || (int) $user->status !== 1) {
+            throw new ApiException(40102, '验证码已失效,请重新获取', 401);
+        }
+
+        return (new AuthService())->issueForExisting($user);
+    }
+
+    /** SMS gateways only reach mainland mobile numbers; other formats never leave the server. */
+    public static function mobile(array $input): string
+    {
+        $phone = Fields::phone($input);
+        if (!preg_match('/^1[3-9][0-9]{9}$/D', $phone)) {
+            throw new ApiException(42201, '请填写有效的中国大陆手机号码', 422, ['phone' => '须为1开头的11位手机号']);
+        }
+
+        return $phone;
+    }
+}

+ 12 - 0
server/app/miniapi/service/SmsSender.php

@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\miniapi\service;
+
+/** Delivery channel for login codes. Implementations must not leak provider credentials. */
+interface SmsSender
+{
+    /** @throws \app\miniapi\exception\ApiException when the message could not be accepted. */
+    public function send(string $phone, string $content): void;
+}

+ 62 - 0
server/app/miniapi/service/SmsbaoSender.php

@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\miniapi\service;
+
+use app\miniapi\exception\ApiException;
+use support\Log;
+
+/**
+ * smsbao.com HTTP API. The account password is passed through untouched; when it looks like a
+ * 32 character digest the provider treats it as the MD5 password. Responses are plain status
+ * numbers and are never echoed to the caller.
+ */
+final class SmsbaoSender implements SmsSender
+{
+    private const FAILURES = [
+        '-1' => '参数不全',
+        '-2' => '账户或密码错误',
+        '-3' => '账户余额不足',
+        '-4' => '账户余额已冻结',
+        '30' => '验证码间隔太短',
+        '40' => '账户不存在',
+        '41' => '内容包含敏感词',
+        '42' => '账户余额不足',
+    ];
+
+    public function send(string $phone, string $content): void
+    {
+        $url = (string) config('mini_api.sms_endpoint', '') . '?' . http_build_query([
+            'u' => (string) config('mini_api.sms_user', ''),
+            'p' => (string) config('mini_api.sms_password', ''),
+            'm' => $phone,
+            'c' => $content,
+        ]);
+        $ch = curl_init($url);
+        curl_setopt_array($ch, [
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_CONNECTTIMEOUT => 5,
+            CURLOPT_TIMEOUT => 10,
+            CURLOPT_SSL_VERIFYPEER => true,
+            CURLOPT_SSL_VERIFYHOST => 2,
+            CURLOPT_FOLLOWLOCATION => false,
+        ]);
+        $raw = curl_exec($ch);
+        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $error = curl_error($ch);
+        curl_close($ch);
+        if ($raw === false || $status !== 200) {
+            Log::warning('mini_api.sms_transport', ['http' => $status, 'transport_error' => $error]);
+
+            throw new ApiException(50302, '短信服务暂时不可用,请稍后再试', 503);
+        }
+        $code = trim((string) $raw);
+        if ($code === '0') {
+            return;
+        }
+        // Provider status only. The request URL carries the password, so it is never logged.
+        Log::warning('mini_api.sms_rejected', ['status' => $code, 'reason' => self::FAILURES[$code] ?? 'unknown']);
+        throw new ApiException(50302, '短信发送失败,请稍后再试', 503);
+    }
+}

+ 1 - 0
server/app/miniapi/service/SystemService.php

@@ -32,6 +32,7 @@ final class SystemService
     {
         return [
             'wechat_login' => (string) config('mini_api.wechat_appid', '') !== '' && (string) config('mini_api.wechat_secret', '') !== '',
+            'sms_login' => \app\miniapi\auth\SmsCode::enabled(),
             'test_phone_login' => \app\miniapi\auth\TestPhoneIdentityProvider::enabled(),
             'enterprise' => true,
             'driver' => true,

+ 11 - 0
server/app/miniapi/service/SystemServiceSetup.php

@@ -26,6 +26,17 @@ final class SystemServiceSetup
         if (!($capabilities['test_phone_login'] ?? false)) {
             $messages['test_phone_login'] = ['action' => 'optional', 'summary' => 'Local test phone login is disabled.', 'steps' => ['Use APP_ENV=local and MINI_TEST_PHONE_LOGIN=true only for local testing.', 'Restart Webman after changing the setting.']];
         }
+        if (($capabilities['sms_login'] ?? false) === false && config('mini_api.sms_login', false) === true) {
+            $messages['sms_login'] = [
+                'action' => 'configure',
+                'summary' => 'SMS login is enabled but the provider account is incomplete.',
+                'steps' => [
+                    'Set MINI_SMS_USER and MINI_SMS_PASSWORD in server/.env.',
+                    'Confirm the provider account has remaining balance.',
+                    'Bind the phone to a mini user in the admin panel before sending codes to it.',
+                ],
+            ];
+        }
         return $messages;
     }
 }

+ 13 - 0
server/config/mini_api.php

@@ -20,6 +20,19 @@ return [
         'MINI_TRUSTED_PROXIES',
         '127.0.0.1,::1'
     ))))),
+    // SMS login is a second credential for an account that already exists: the phone must be
+    // bound by an administrator first. Codes are stored only as hashes and are single use.
+    'sms_login' => filter_var(env('MINI_SMS_LOGIN', false), FILTER_VALIDATE_BOOLEAN),
+    'sms_user' => (string) env('MINI_SMS_USER', ''),
+    'sms_password' => (string) env('MINI_SMS_PASSWORD', ''),
+    'sms_endpoint' => (string) env('MINI_SMS_ENDPOINT', 'https://api.smsbao.com/sms'),
+    // Optional extra HMAC key for code hashing; falls back to the SMS password, which is already secret.
+    'sms_code_pepper' => (string) env('MINI_SMS_CODE_PEPPER', ''),
+    'sms_code_ttl' => 300,
+    'sms_resend_interval' => 60,
+    'sms_max_attempts' => 5,
+    'sms_daily_per_phone' => 10,
+    'sms_per_ip_per_hour' => 30,
     'session_ttl' => 604800,
     'login_ip_per_minute' => 60,
     'login_global_per_minute' => 600,

+ 42 - 0
server/docs/WATER_MINI_API.md

@@ -88,6 +88,44 @@ routes/mini_api.php
 
 调用方地址由 `app/miniapi/http/ClientIp.php` 解析。仅当直连对端地址列在 `MINI_TRUSTED_PROXIES`(默认 `127.0.0.1,::1`)中时才解析 `X-Forwarded-For`,并从右向左跳过受信任的代理跳;否则直接使用 TCP 对端地址,调用方自带的该请求头被完全忽略。因此未配置该项时行为与只信任 TCP 地址一致,但反向代理后的所有调用方会被视为同一来源,登录限流与测试手机号来源白名单都会按聚合流量计算。部署在 nginx 或负载均衡之后时,须把每一跳代理地址写入 `MINI_TRUSTED_PROXIES` 并重载 Webman。
 
+## 2026-10-21 短信验证码登录
+
+短信登录是微信登录的**第二种凭据**,不是独立注册通道:手机号必须先由超级管理员在后台绑定到**已存在**的小程序账号,才会真正下发短信。校验通过后登录到该账号,`user_id`、业务身份与权限和同一账号的微信登录完全一致。不会因为发短信而新建账号,未绑定的号码登录后仍无业务身份,与「1 个账号 1 个身份」的规则一致。
+
+配置 `.env` 后重载 Webman,并以 `GET /config` 的 `capabilities.sms_login` 为准:
+
+```dotenv
+MINI_SMS_LOGIN = true
+MINI_SMS_USER = '短信宝用户号'
+MINI_SMS_PASSWORD = '短信宝密码'      # 32 位十六进制时短信宝按 MD5 处理,原样透传
+MINI_SMS_ENDPOINT = 'https://api.smsbao.com/sms'
+MINI_SMS_CODE_PEPPER = ''             # 可选,留空回退用 MINI_SMS_PASSWORD
+```
+
+迁移 `scripts/migrate_water_mini_sms.php` 新增 `sa_water_mini_sms_code`(验证码)与 `sa_water_mini_user.phone`(唯一索引,仅登录绑定用),均为追加式、可重复执行,不改任何已有账号、授权或流水。MYSQL 唯一索引不约束 NULL,因此未绑定手机号的存量账号保持有效。
+
+后台由超级管理员绑定,列表与返回值一律打码:
+
+```http
+POST /core/water/mini-user-phone
+{"user_id":12,"phone":"13800138000"}
+```
+
+一个手机号只能绑定一个账号,重复绑定返回 40903。解绑同样通过该接口传入目标账号与空手机号前需先停用;本版本未提供专用解绑动作。
+
+**接口**
+
+- `POST /auth/send-sms` `{"phone":"13800138000"}` → `{"sent":true,"expires_in":300}`
+- `POST /auth/phone-login` `{"phone":"13800138000","code":"123456"}` → `access_token,token_type,expires_at,user_id,identities`
+
+**防枚举**:已绑定与未绑定的手机号返回**完全一致**的成功响应,未绑定时不发送短信也不落库。否则攻击者可以用这个接口枚举出哪些手机号注册过本系统。
+
+**验证码**:6 位、`random_int` 生成、5 分钟过期、**只存 HMAC-SHA256 摘要**(密钥为 `MINI_SMS_CODE_PEPPER`,留空时用 `MINI_SMS_PASSWORD`)。明文只出现在发往用户的那一条短信里,既不写库也不写日志。6 位码空间很小,加盐哈希是必需的——裸 SHA-256 可被离线穷举。单次有效,消费即作废;并发提交靠行锁串行,同一个码不会被用两次。连续错误 5 次直接作废该验证码。
+
+**限流**(判定全部发生在发短信之前,被限流时不会有短信产生):同号 60 秒内不可重发、单号每日 10 条、单 IP 每小时 30 条,外加与其他登录入口共用的 `LoginThrottle`。短信宝自身还有「间隔太短」限制,返回码统一转 `50302`,不透传给前端。
+
+**失败处理**:短信下发失败时立即删除该验证码,未送达的码不会被猜中或事后使用。`SmsCode::consume()` 在事务内决定结果、在事务提交后才抛异常,否则一次输错会把刚记上的失败次数一起回滚,锁定逻辑就失效了。
+
 ## UI 对应接口
 
 以下 `{e}` 为 enterprise、`{d}` 为 driver、`{a}` 为 agent,均是数字资源 ID。
@@ -198,6 +236,8 @@ routes/mini_api.php
 ```powershell
 php scripts/build_mini_docs.php
 php scripts/build_mini_docs.php --check
+php -d extension=mbstring scripts/migrate_water_mini_sms.php --apply
+php -d extension=mbstring tests/mini_sms_login_test.php
 php -d extension=mbstring tests/mini_api_contract_test.php
 ```
 
@@ -214,6 +254,8 @@ php -d extension=mbstring scripts/migrate_water_mini_api.php --apply
 php tests/mini_api_architecture_test.php
 php -d extension=mbstring tests/water_mini_api_test.php
 php -d extension=mbstring tests/mini_api_address_concurrency_test.php
+php -d extension=mbstring scripts/migrate_water_mini_sms.php --apply
+php -d extension=mbstring tests/mini_sms_login_test.php
 php -d extension=mbstring tests/mini_api_contract_test.php
 php -d extension=mbstring tests/mini_api_framework_test.php --http
 php -d extension=mbstring tests/water_mini_http_test.php

+ 218 - 1
server/docs/openapi/water-mini-v1.json

@@ -4627,7 +4627,221 @@
         }
       }
     },
-    "/auth/test-phone": {
+    "/auth/send-sms": {
+      "post": {
+        "tags": [
+          "公共与登录"
+        ],
+        "summary": "下发短信登录验证码",
+        "operationId": "post_auth_send_sms",
+        "description": "需开启 MINI_SMS_LOGIN 且配置短信宝账号。手机号必须先在后台绑定到已有小程序账号,否则不会真正下发短信。为防止枚举注册手机号,已绑定与未绑定返回完全一致的成功响应,未绑定时不发送也不落库。服务端限流:同号 60 秒内不可重发、同号每日上限、单 IP 每小时上限;超限返回 42901。验证码 6 位、5 分钟过期、仅存哈希、单次有效、连续错误 5 次作废。发送失败统一 50302,不透出短信宝返回码。",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "成功;JSON 业务码 code=0。无论手机号是否绑定均返回此结构。",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "code": {
+                      "type": "integer",
+                      "enum": [
+                        0
+                      ]
+                    },
+                    "message": {
+                      "type": "string",
+                      "example": "ok"
+                    },
+                    "data": {
+                      "type": "object",
+                      "properties": {
+                        "sent": {
+                          "type": "boolean",
+                          "example": true
+                        },
+                        "expires_in": {
+                          "type": "integer",
+                          "example": 300
+                        }
+                      },
+                      "required": [
+                        "sent",
+                        "expires_in"
+                      ]
+                    },
+                    "request_id": {
+                      "type": "string",
+                      "pattern": "^[a-f0-9]{32}$"
+                    }
+                  },
+                  "required": [
+                    "code",
+                    "message",
+                    "data",
+                    "request_id"
+                  ]
+                }
+              }
+            }
+          },
+          "400": {
+            "$ref": "#/components/responses/Error"
+          },
+          "413": {
+            "$ref": "#/components/responses/Error"
+          },
+          "415": {
+            "$ref": "#/components/responses/Error"
+          },
+          "422": {
+            "$ref": "#/components/responses/Error"
+          },
+          "429": {
+            "$ref": "#/components/responses/Error"
+          },
+          "500": {
+            "$ref": "#/components/responses/Error"
+          },
+          "503": {
+            "$ref": "#/components/responses/Error"
+          }
+        },
+        "security": [],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "additionalProperties": false,
+                "required": [
+                  "phone"
+                ],
+                "properties": {
+                  "phone": {
+                    "type": "string",
+                    "pattern": "^1[3-9][0-9]{9}$"
+                  }
+                }
+              },
+              "example": {
+                "phone": "13012345678"
+              }
+            }
+          }
+        }
+      }
+    },
+    "/auth/phone-login": {
+      "post": {
+        "tags": [
+          "公共与登录"
+        ],
+        "summary": "短信验证码登录",
+        "operationId": "post_auth_phone_login",
+        "description": "需开启 MINI_SMS_LOGIN。校验通过后登录到该手机号所绑定的小程序账号,user_id、身份与权限与同一账号的微信登录完全一致;验证码消费后立即作废,重复提交返回 40102。手机号未绑定或账号已停用时同样返回 40102,不透露账号是否存在。验证码错误累计 5 次后该验证码作废,需重新获取。",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "成功;JSON 业务码 code=0",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "code": {
+                      "type": "integer",
+                      "enum": [
+                        0
+                      ]
+                    },
+                    "message": {
+                      "type": "string",
+                      "example": "ok"
+                    },
+                    "data": {
+                      "$ref": "#/components/schemas/Login"
+                    },
+                    "request_id": {
+                      "type": "string",
+                      "pattern": "^[a-f0-9]{32}$"
+                    }
+                  },
+                  "required": [
+                    "code",
+                    "message",
+                    "data",
+                    "request_id"
+                  ]
+                }
+              }
+            }
+          },
+          "400": {
+            "$ref": "#/components/responses/Error"
+          },
+          "401": {
+            "$ref": "#/components/responses/Error"
+          },
+          "403": {
+            "$ref": "#/components/responses/Error"
+          },
+          "404": {
+            "$ref": "#/components/responses/Error"
+          },
+          "413": {
+            "$ref": "#/components/responses/Error"
+          },
+          "415": {
+            "$ref": "#/components/responses/Error"
+          },
+          "422": {
+            "$ref": "#/components/responses/Error"
+          },
+          "429": {
+            "$ref": "#/components/responses/Error"
+          },
+          "500": {
+            "$ref": "#/components/responses/Error"
+          },
+          "503": {
+            "$ref": "#/components/responses/Error"
+          }
+        },
+        "security": [],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "additionalProperties": false,
+                "required": [
+                  "phone",
+                  "code"
+                ],
+                "properties": {
+                  "phone": {
+                    "type": "string",
+                    "pattern": "^1[3-9][0-9]{9}$"
+                  },
+                  "code": {
+                    "type": "string",
+                    "pattern": "^[0-9]{6}$"
+                  }
+                }
+              },
+              "example": {
+                "phone": "13012345678",
+                "code": "123456"
+              }
+            }
+          }
+        }
+      }
+    },    "/auth/test-phone": {
       "post": {
         "tags": [
           "公共与登录"
@@ -8004,6 +8218,9 @@
               "wechat_login": {
                 "type": "boolean",
                 "description": "以服务器返回值决定是否开放入口"
+              },              "sms_login": {
+                "type": "boolean",
+                "description": "短信登录总开关;关闭时不下发验证码,也不接受验证码登录"
               },
               "enterprise": {
                 "type": "boolean",

+ 6 - 1
server/plugin/saiadmin/app/controller/water/MiniIdentityController.php

@@ -25,6 +25,11 @@ final class MiniIdentityController extends BaseController
         return $this->respond(fn () => (new IdentityManagement())->grant((int) $this->adminId, Input::json($request)));
     }
 
+    public function bindPhone(Request $request): Response
+    {
+        return $this->respond(fn () => (new IdentityManagement())->bindPhone((int) $this->adminId, Input::json($request)));
+    }
+
     private function respond(callable $work): Response
     {
         try {
@@ -47,4 +52,4 @@ final class MiniIdentityController extends BaseController
             ])->withStatus(500);
         }
     }
-}
+}

+ 1 - 0
server/plugin/saiadmin/config/route.php

@@ -12,6 +12,7 @@ Route::group('/core', function () {
     // 企业供水业务:独立权益与库存账本,沿用登录和审计中间件。
     Route::get('/water/mini-users', [\plugin\saiadmin\app\controller\water\MiniIdentityController::class, 'index']);
     Route::post('/water/mini-grants', [\plugin\saiadmin\app\controller\water\MiniIdentityController::class, 'grant']);
+    Route::post('/water/mini-user-phone', [\plugin\saiadmin\app\controller\water\MiniIdentityController::class, 'bindPhone']);
     Route::get('/water/accounts', [\plugin\saiadmin\app\controller\water\WaterController::class, 'accounts']);
     Route::get('/water/alerts/policy', [\plugin\saiadmin\app\controller\water\WaterController::class, 'alertPolicy']);
     Route::get('/water/alerts/dashboard', [\plugin\saiadmin\app\controller\water\WaterController::class, 'alertDashboard']);

+ 2 - 0
server/routes/mini_api.php

@@ -15,6 +15,8 @@ Route::group('/api/mini/v1', function () {
     Route::add(['GET', 'HEAD'], '/health', [SystemController::class, 'health']);
     Route::get('/config', [SystemController::class, 'config']);
     Route::post('/auth/wechat', [AuthController::class, 'login']);
+    Route::post('/auth/send-sms', [AuthController::class, 'sendSms']);
+    Route::post('/auth/phone-login', [AuthController::class, 'phoneLogin']);
     Route::post('/auth/test-phone', [AuthController::class, 'testPhone']);
     Route::get('/packages', [CatalogController::class, 'packages']);
     Route::get('/packages/{id:\d+}', [CatalogController::class, 'package']);

+ 46 - 0
server/scripts/migrate_water_mini_sms.php

@@ -0,0 +1,46 @@
+<?php
+
+/** Additive SMS login schema. Creates no codes, binds no phone and touches no grant. */
+require dirname(__DIR__) . '/vendor/autoload.php';
+\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
+\support\App::loadAllConfig(['route']);
+
+if (!in_array('--apply', $argv, true)) {
+    echo "Plan: create sa_water_mini_sms_code and add sa_water_mini_user.phone.\n";
+    exit;
+}
+
+$db = (new \app\miniapi\model\MiniUser())->getConnection();
+$codeTable = 'sa_water_mini_sms_code';
+$db->statement("CREATE TABLE IF NOT EXISTS `$codeTable` (
+    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+    phone VARCHAR(20) NOT NULL,
+    purpose VARCHAR(20) NOT NULL DEFAULT 'login',
+    code_hash CHAR(64) NOT NULL,
+    attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
+    expires_at DATETIME NOT NULL,
+    consumed_at DATETIME NULL,
+    request_ip VARCHAR(45) NOT NULL DEFAULT '',
+    created_at DATETIME NOT NULL,
+    KEY phone_recent(phone, created_at),
+    KEY expiry(expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+$userTable = 'sa_water_mini_user';
+$columns = array_map(
+    static fn ($row) => (string) ($row->COLUMN_NAME ?? ''),
+    $db->select("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", [$userTable])
+);
+if (!in_array('phone', $columns, true)) {
+    $db->statement("ALTER TABLE `$userTable` ADD COLUMN phone VARCHAR(20) NULL AFTER nickname");
+}
+// NULLs are exempt from a unique index, so existing WeChat-only accounts stay valid.
+$indexes = array_map(
+    static fn ($row) => (string) ($row->Key_name ?? ''),
+    $db->select("SHOW INDEX FROM `$userTable` WHERE Column_name='phone'")
+);
+if (!in_array('phone_key', $indexes, true)) {
+    $db->statement("ALTER TABLE `$userTable` ADD UNIQUE KEY phone_key(phone)");
+}
+
+echo "Applied SMS login schema; no code, phone binding or grant was created.\n";

+ 195 - 0
server/tests/mini_sms_login_test.php

@@ -0,0 +1,195 @@
+<?php
+
+/**
+ * SMS login with a stubbed sender: no message ever leaves the process.
+ * Every account, code and session change is rolled back at the end.
+ */
+require dirname(__DIR__) . '/vendor/autoload.php';
+\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
+\support\App::loadAllConfig(['route']);
+
+use app\miniapi\auth\SessionVerifier;
+use app\miniapi\auth\SmsCode;
+use app\miniapi\exception\ApiException;
+use app\miniapi\model\MiniSmsCode;
+use app\miniapi\model\MiniUser;
+use app\miniapi\service\IdentityManagement;
+use app\miniapi\service\SmsLogin;
+use app\miniapi\service\SmsSender;
+
+final class RecordingSender implements SmsSender
+{
+    public array $sent = [];
+    public bool $fail = false;
+
+    public function send(string $phone, string $content): void
+    {
+        if ($this->fail) {
+            throw new ApiException(50302, '短信服务暂时不可用,请稍后再试', 503);
+        }
+        $this->sent[] = [$phone, $content];
+    }
+
+    public function lastCode(): string
+    {
+        $last = end($this->sent);
+        if (!$last || !preg_match('/(\d{6})/', (string) $last[1], $match)) {
+            throw new RuntimeException('No code was delivered');
+        }
+
+        return (string) $match[1];
+    }
+}
+
+$checks = 0;
+function check(bool $ok, string $label): void
+{
+    global $checks;
+    $checks++;
+    if (!$ok) {
+        throw new RuntimeException($label);
+    }
+}
+function reject(callable $action, int $code, string $label = ''): void
+{
+    try {
+        $action();
+    } catch (ApiException $error) {
+        check($error->getCode() === $code, ($label ?: 'Expected rejection') . ' ' . $code . ' but got ' . $error->getCode());
+
+        return;
+    }
+    throw new RuntimeException('Missing rejection ' . $code . ' ' . $label);
+}
+function settings(array $changes): void
+{
+    $property = new ReflectionProperty(\Webman\Config::class, 'config');
+    $all = $property->getValue();
+    $all['mini_api'] = array_replace($all['mini_api'], $changes);
+    $property->setValue(null, $all);
+    (new ReflectionProperty(\Webman\Config::class, 'flatCache'))->setValue(null, []);
+}
+
+$original = config('mini_api');
+$phone = '13800138000';
+$other = '13900139000';
+$sender = new RecordingSender();
+$db = (new MiniUser())->getConnection();
+$db->beginTransaction();
+try {
+    $login = new SmsLogin($sender);
+    $input = ['phone' => $phone];
+
+    // Every entry point refuses while the switch is off or the provider account is incomplete.
+    settings(['sms_login' => false, 'sms_user' => 'u', 'sms_password' => 'p']);
+    reject(fn () => $login->sendCode($input, '127.0.0.1'), 50301, 'switch off');
+    reject(fn () => $login->login($input + ['code' => '123456']), 50301, 'switch off');
+    settings(['sms_login' => true, 'sms_user' => '', 'sms_password' => 'p']);
+    reject(fn () => $login->sendCode($input, '127.0.0.1'), 50301, 'missing account');
+    settings(['sms_user' => 'u', 'sms_password' => 'p', 'sms_resend_interval' => 60]);
+
+    // An unbound phone is indistinguishable from a bound one and costs no SMS.
+    check($login->sendCode($input, '127.0.0.1') === ['sent' => true, 'expires_in' => SmsCode::ttl()], 'unbound phone reports success');
+    check($sender->sent === [], 'unbound phone sends nothing');
+    check(MiniSmsCode::query()->where('phone', $phone)->count() === 0, 'unbound phone stores nothing');
+
+    // Field and format policy.
+    reject(fn () => $login->sendCode(['phone' => '12345'], '127.0.0.1'), 42201, 'too short');
+    reject(fn () => $login->sendCode(['phone' => '12800138000'], '127.0.0.1'), 42201, 'invalid prefix');
+    reject(fn () => $login->sendCode(['phone' => '01900139000'], '127.0.0.1'), 42201, 'not a mobile number');
+    reject(fn () => $login->sendCode(['phone' => $phone, 'extra' => 1], '127.0.0.1'), 42201, 'unknown field');
+    reject(fn () => $login->sendCode([], '127.0.0.1'), 42201, 'missing number');
+
+    $now = date('Y-m-d H:i:s');
+    $userId = (int) MiniUser::query()->insertGetId([
+        'appid' => 'sms-fixture-appid',
+        'openid' => 'sms-fixture-openid',
+        'nickname' => 'sms',
+        'phone' => $phone,
+        'status' => 1,
+        'created_at' => $now,
+        'updated_at' => $now,
+    ]);
+
+    // Issuing keeps only a hash and hands the plaintext to the sender.
+    $login->sendCode($input, '10.0.0.9');
+    check(count($sender->sent) === 1, 'one message delivered');
+    $row = MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->first();
+    check(preg_match('/^[a-f0-9]{64}$/D', (string) $row->code_hash) === 1, 'code stored as a hash');
+    check((string) $row->request_ip === '10.0.0.9', 'request address recorded');
+    check(!str_contains((string) $row->code_hash, $sender->lastCode()), 'plaintext absent from the hash');
+    check(MiniSmsCode::query()->where('code_hash', $sender->lastCode())->exists() === false, 'plaintext never stored');
+
+    // Send limits. The address cap counts rows stored under that same address.
+    reject(fn () => $login->sendCode($input, '127.0.0.1'), 42901, 'resend window');
+    settings(['sms_resend_interval' => 0, 'sms_per_ip_per_hour' => 1]);
+    reject(fn () => $login->sendCode($input, '10.0.0.9'), 42901, 'per address hourly');
+    settings(['sms_resend_interval' => 0, 'sms_per_ip_per_hour' => 30]);
+    MiniSmsCode::query()->where('phone', $phone)->update(['created_at' => date('Y-m-d H:i:s', time() - 7200)]);
+    $login->sendCode($input, '127.0.0.1');
+    check(count($sender->sent) === 2, 'second code issued');
+    settings(['sms_daily_per_phone' => 1]);
+    reject(fn () => $login->sendCode($input, '127.0.0.1'), 42901, 'daily cap');
+    settings(['sms_daily_per_phone' => 10]);
+
+
+    // Wrong guesses burn attempts and eventually the code itself.
+    reject(fn () => $login->login($input + ['code' => '000000']), 40102, 'wrong code');
+    check((int) MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->value('attempts') === 1, 'attempt counted');
+    settings(['sms_max_attempts' => 2]);
+    reject(fn () => $login->login($input + ['code' => '000000']), 40102, 'second wrong guess');
+    check(MiniSmsCode::query()->where('phone', $phone)->orderByDesc('id')->value('consumed_at') !== null, 'code burned at the limit');
+    settings(['sms_max_attempts' => 5]);
+
+    // Expired codes are refused.
+    $login->sendCode($input, '127.0.0.1');
+    $expired = $sender->lastCode();
+    MiniSmsCode::query()->where('phone', $phone)->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
+    reject(fn () => $login->login($input + ['code' => $expired]), 40102, 'expired code');
+
+    // A delivery failure must not leave a usable code behind.
+    $sender->fail = true;
+    reject(fn () => $login->sendCode($input, '127.0.0.1'), 50302, 'provider failure');
+    $sender->fail = false;
+    check(
+        MiniSmsCode::query()->where('phone', $phone)->whereNull('consumed_at')
+            ->where('expires_at', '>', date('Y-m-d H:i:s'))->count() === 0,
+        'undelivered code discarded'
+    );
+
+    // Happy path, then single use.
+    MiniSmsCode::query()->where('phone', $phone)->delete();
+    $login->sendCode($input, '127.0.0.1');
+    $live = $sender->lastCode();
+    $session = $login->login($input + ['code' => $live]);
+    check($session['user_id'] === (string) $userId, 'logged into the bound account');
+    check(is_array($session['identities']), 'identities returned');
+    check((new SessionVerifier())->verify($session['access_token'])->userId === (string) $userId, 'session authenticates');
+    reject(fn () => $login->login($input + ['code' => $live]), 40102, 'code is single use');
+
+    // A phone may only ever point at one account.
+    $otherId = (int) MiniUser::query()->insertGetId([
+        'appid' => 'sms-fixture-appid',
+        'openid' => 'sms-fixture-openid-2',
+        'nickname' => 'other',
+        'phone' => $other,
+        'status' => 1,
+        'created_at' => $now,
+        'updated_at' => $now,
+    ]);
+    $admin = new IdentityManagement();
+    reject(fn () => $admin->bindPhone(2, ['user_id' => $otherId, 'phone' => '13900139001']), 40301, 'not a super admin');
+    reject(fn () => $admin->bindPhone(1, ['user_id' => 999999999, 'phone' => $other]), 40401, 'unknown account');
+    reject(fn () => $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => $phone]), 40903, 'already bound');
+    reject(fn () => $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => '12345']), 42201, 'bad number');
+    $bound = $admin->bindPhone(1, ['user_id' => $otherId, 'phone' => $other]);
+    check($bound['user_id'] === $otherId && str_contains($bound['phone'], '*'), 'binding reports a masked number');
+    check((string) MiniUser::query()->where('id', $otherId)->value('phone') === $other, 'binding stored');
+    $listed = (new IdentityManagement())->users(1, ['user_id' => $otherId]);
+    check(str_contains((string) $listed['items'][0]['phone'], '*'), 'list masks the number');
+    check(!str_contains((string) json_encode($listed), $other), 'list never exposes the plain number');
+} finally {
+    $db->rollBack();
+    settings($original);
+}
+echo "PASS: $checks SMS login checks; no message sent and all changes rolled back.\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": "wx8ebee994ea7c5af3",
+  "appid": "wxb53276de7129d025",
   "projectname": "云泉智配本地测试",
   "setting": {
     "urlCheck": false,
@@ -33,4 +33,4 @@
     "include": []
   },
   "editorSetting": {}
-}
+}

+ 1 - 0
water-mini/src/api/schema.ts

@@ -547,6 +547,7 @@ export type Config = {
   "purchase_agreement_url": (string) | null;
   "capabilities": {
   "wechat_login": boolean;
+  "sms_login"?: boolean;
   "enterprise": boolean;
   "driver": boolean;
   "agent": boolean;

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

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

+ 21 - 8
water-mini/src/pages/account/login.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onUnmounted, ref } from 'vue';
+import { computed, onUnmounted, ref } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { session, loadConfiguration, acceptLogin, accountPage } from '../../stores/session';
 import { post } from '../../api/client';
@@ -16,6 +16,13 @@ const sendingCode = ref(false);
 const codeCountdown = ref(0);
 let countdownTimer: ReturnType<typeof setInterval> | null = null;
 
+// 真实短信登录与本地测试号登录共用同一张表单,开关由服务器 capabilities 决定。
+const phoneLoginAvailable = computed(
+  () =>
+    Boolean(session.configuration?.capabilities.sms_login) ||
+    Boolean(session.configuration?.capabilities.test_phone_login),
+);
+
 
 // 隐私协议跳转
 function privacy() {
@@ -88,11 +95,11 @@ async function sendCode() {
     return;
   }
   if (sendingCode.value || codeCountdown.value > 0) return;
-  if (!/^1\d{10}$/.test(phone.value)) {
+  if (!/^1[3-9]\d{9}$/.test(phone.value)) {
     toast('请输入正确的手机号码');
     return;
   }
-  if (!session.configuration?.capabilities.test_phone_login) return;
+  if (!phoneLoginAvailable.value) return;
   sendingCode.value = true;
   try {
     const ok = await requestPhoneCode(phone.value);
@@ -107,14 +114,20 @@ async function loginByPhone() {
     toast('请先阅读并同意服务说明');
     return;
   }
-  if (!/^1\d{10}$/.test(phone.value) || !/^\d{4,6}$/.test(phoneCode.value)) {
+  if (!/^1[3-9]\d{9}$/.test(phone.value) || !/^\d{6}$/.test(phoneCode.value)) {
     toast('请输入正确的手机号和验证码');
     return;
   }
-  if (busy.value || !session.configuration?.capabilities.test_phone_login) return;
+  if (busy.value || !phoneLoginAvailable.value) return;
   busy.value = true;
   try {
-    acceptLogin(await loginByPhoneCode(phone.value, phoneCode.value, true));
+    acceptLogin(
+      await loginByPhoneCode(
+        phone.value,
+        phoneCode.value,
+        Boolean(session.configuration?.capabilities.test_phone_login),
+      ),
+    );
     uni.reLaunch({ url: accountPage() });
   } catch (error) {
     toast(error instanceof Error ? error.message : '登录失败');
@@ -153,7 +166,7 @@ async function loginByPhone() {
           微信登录暂未开放,请联系平台管理员。
         </view>
       </template>
-      <view v-else-if="session.configuration?.capabilities.test_phone_login" class="phone-login">
+      <view v-else-if="phoneLoginAvailable" class="phone-login">
         <view class="field">
           <text class="field-label">手机号码</text>
           <input
@@ -197,7 +210,7 @@ async function loginByPhone() {
     </view>
     <view class="login-footer">
       <button
-        v-if="loginMode === 'wechat' && session.configuration?.capabilities.test_phone_login"
+        v-if="loginMode === 'wechat' && phoneLoginAvailable"
         class="text-button login-option"
         :disabled="busy"
         @click="loginMode = 'phone'"

+ 5 - 5
water-mini/src/utils/login-flow.ts

@@ -1,9 +1,10 @@
-// 登录流程分流:测试触发器与短信通道分离
+// 登录流程分流:测试触发器与短信通道分离
 //
 // - 三个独立测试账号共用本地固定验证码,只在开发环境、且服务端开启
 //   MINI_TEST_PHONE_LOGIN 时由 /auth/test-phone 受理;不向用户展示
-// - 其他手机号按"短信通道"流程处理:先取短信码,再校验登录;
-//   通道未实装前调用会失败,需要给用户友好提示
+// - 其他手机号走真实短信通道:/auth/send-sms 下发,/auth/phone-login 校验。
+//   服务端只对"后台已绑定手机号"的账号真正下发短信,且两种情况返回一致,
+//   以免暴露哪些号码已注册
 //
 // 注意:不要把测试号与验证码散落到 UI 文件或注释里;它们只是内部
 // 触发器,不是给用户看的入口。
@@ -20,14 +21,13 @@ export function isTestPhone(phone: string): boolean {
 }
 
 // 取短信验证码:测试号直接返回成功(不发短信);其他号调 /auth/send-sms
-// 通道未实装时统一提示"短信通道暂未开放"
 export async function requestPhoneCode(phone: string): Promise<boolean> {
   if (isTestPhone(phone)) return true;
   try {
     await post('/auth/send-sms', { phone }, false);
     return true;
   } catch (error) {
-    toast(error instanceof Error ? error.message : '短信通道暂未开放,请稍后再试');
+    toast(error instanceof Error ? error.message : '验证码发送失败,请稍后重试');
     return false;
   }
 }