cloud пре 1 недеља
родитељ
комит
220a63bd21

+ 3 - 1
server/.env.example

@@ -56,12 +56,14 @@ MINI_WECHAT_APPID = ''
 MINI_WECHAT_SECRET = ''
 
 # 短信登录(短信宝)。手机号必须先在后台绑定到已有小程序账号才会真正下发短信。
-# MINI_SMS_PASSWORD 为 32 位十六进制时,短信宝按 MD5 密码处理,这里原样透传,不要自行解密。
+# MINI_SMS_PASSWORD 填写短信宝密码的32位MD5值或API Key,原样透传,不填原始登录密码。
 # 可选: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_SIGNATURE = ''
 MINI_SMS_CODE_PEPPER = ''
 # Fixed test phone login is controlled by this switch, independently of APP_ENV and client IP.
 # When enabled, anyone with a listed test number and fixed code can access that test account.

+ 34 - 11
server/app/miniapi/service/SmsbaoSender.php

@@ -5,12 +5,12 @@ declare(strict_types=1);
 namespace app\miniapi\service;
 
 use app\miniapi\exception\ApiException;
+use app\miniapi\http\ApiResponse;
 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.
+ * The provider credential is an MD5 password or API key, passed through unchanged.
+ * Provider status codes are logged without exposing credentials or message contents.
  */
 final class SmsbaoSender implements SmsSender
 {
@@ -19,19 +19,31 @@ final class SmsbaoSender implements SmsSender
         '-2' => '账户或密码错误',
         '-3' => '账户余额不足',
         '-4' => '账户余额已冻结',
-        '30' => '验证码间隔太短',
+        '30' => '账户密码错误',
         '40' => '账户不存在',
-        '41' => '内容包含敏感词',
-        '42' => '账户余额不足',
+        '41' => '账户余额不足',
+        '43' => '来源IP受限',
+        '50' => '内容包含敏感词',
+        '51' => '手机号码不正确',
     ];
 
     public function send(string $phone, string $content): void
     {
+        $signature = trim((string) config('mini_api.sms_signature', ''));
+        if ($signature === '') {
+            throw new ApiException(50301, '短信签名未配置,请联系管理员', 503);
+        }
+        if (!str_starts_with($signature, '【') || !str_ends_with($signature, '】')) {
+            $signature = '【' . $signature . '】';
+        }
+        $request = \request();
+        $requestId = $request ? ApiResponse::requestId($request) : null;
+        $credential = (string) config('mini_api.sms_password', '');
         $url = (string) config('mini_api.sms_endpoint', '') . '?' . http_build_query([
             'u' => (string) config('mini_api.sms_user', ''),
-            'p' => (string) config('mini_api.sms_password', ''),
+            'p' => $credential,
             'm' => $phone,
-            'c' => $content,
+            'c' => $signature . $content,
         ]);
         $ch = curl_init($url);
         curl_setopt_array($ch, [
@@ -44,10 +56,17 @@ final class SmsbaoSender implements SmsSender
         ]);
         $raw = curl_exec($ch);
         $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $errorNumber = curl_errno($ch);
         $error = curl_error($ch);
         curl_close($ch);
         if ($raw === false || $status !== 200) {
-            Log::warning('mini_api.sms_transport', ['http' => $status, 'transport_error' => $error]);
+            $redactions = array_filter([$url, rawurlencode($credential), $credential]);
+            Log::warning('mini_api.sms_transport', [
+                'request_id' => $requestId,
+                'http' => $status,
+                'curl_errno' => $errorNumber,
+                'transport_error' => str_replace($redactions, '[redacted]', $error),
+            ]);
 
             throw new ApiException(50302, '短信服务暂时不可用,请稍后再试', 503);
         }
@@ -56,7 +75,11 @@ final class SmsbaoSender implements SmsSender
             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']);
+        Log::warning('mini_api.sms_rejected', [
+            'request_id' => $requestId,
+            'status' => preg_match('/^-?\d+$/D', $code) ? $code : 'unexpected_response',
+            'reason' => self::FAILURES[$code] ?? 'unknown',
+        ]);
         throw new ApiException(50302, '短信发送失败,请稍后再试', 503);
     }
-}
+}

+ 1 - 0
server/config/mini_api.php

@@ -22,6 +22,7 @@ return [
     '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'),
+    'sms_signature' => (string) env('MINI_SMS_SIGNATURE', ''),
     // 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' => 120,

+ 11 - 2
server/docs/WATER_MINI_API.md

@@ -101,11 +101,20 @@ routes/mini_api.php
 ```dotenv
 MINI_SMS_LOGIN = true
 MINI_SMS_USER = '短信宝用户号'
-MINI_SMS_PASSWORD = '短信宝密码'      # 32 位十六进制时短信宝按 MD5 处理,原样透传
+MINI_SMS_PASSWORD = '32位MD5密码或API Key' # 原样透传,不填原始登录密码,也不再次MD5
 MINI_SMS_ENDPOINT = 'https://api.smsbao.com/sms'
+MINI_SMS_SIGNATURE = '短信宝登记的公司签名' # 不含【】也可以,发送时自动补上
 MINI_SMS_CODE_PEPPER = ''             # 可选,留空回退用 MINI_SMS_PASSWORD
 ```
 
+短信宝发送正文须包含 `【公司签名】`。签名从 `MINI_SMS_SIGNATURE` 读取;留空时返回 `50301 / 短信签名未配置,请联系管理员`,不会发起网关请求。支持填写纯名称或含完整 `【】` 的签名。`MINI_SMS_ENDPOINT` 只填接口地址,不拼接含账号密码的完整查询 URL;账号、凭据、接收手机号和正文分别编码成 `u/p/m/c` 参数。官方参数和错误码见 [短信宝国内短信 API](https://www.smsbao.com/openapi/213.html),签名要求见 [开发引导](https://www.smsbao.com/openapi)。
+
+线上排查先区分接口返回:`50001` 是程序内部异常,需要按 `request_id` 查 `mini_api.error`;`50302 / 短信服务暂时不可用` 对应 `mini_api.sms_transport`,包含 HTTP 状态、`curl_errno` 与错误原因;`50302 / 短信发送失败` 对应 `mini_api.sms_rejected`,包含网关业务错误码。两类短信日志都带 `request_id`,不记录网关请求 URL、短信正文或凭据。
+
+网关业务码 `30` 为密码错误、`40` 为账号不存在、`41` 为余额不足、`43` 为来源 IP 受限、`50` 为内容含敏感词、`51` 为手机号不正确。传输错误与业务拒绝分别记录,不能把所有发送失败都归为 CA 证书或账号密码错误。`tests/mini_sms_sender_test.php` 使用内存传输验证签名、凭据原样传递、错误码和日志脱敏,不会发送短信。
+
+使用 Webman 实际启动时的 PHP 可执行文件,在服务器 `server` 目录执行 `php scripts/check_mini_sms.php --gateway`,可只读检查有效配置、PHP 扩展、验证码表字段及短信宝余额查询;输出不包含账号密码、手机号或验证码,不发送短信,也不写数据库。`ok: true` 仅代表这些检查通过,不代表短信模板/签名已经由平台认可,也不代表真实短信已送达。该脚本会显示所加载的 PHP ini 文件,便于核对命令行 PHP 与常驻 Worker 的环境是否一致。
+
 迁移 `scripts/migrate_water_mini_sms.php` 新增 `sa_water_mini_sms_code`(验证码)与 `sa_water_mini_user.phone`(唯一索引,仅登录绑定用),均为追加式、可重复执行,不改任何已有账号、授权或流水。MYSQL 唯一索引不约束 NULL,因此未绑定手机号的存量账号保持有效。
 
 后台由超级管理员绑定,列表与返回值一律打码:
@@ -126,7 +135,7 @@ POST /core/water/mini-user-phone
 
 **验证码**:6 位、`random_int` 生成、2 分钟(120秒)过期、**只存 HMAC-SHA256 摘要**(密钥为 `MINI_SMS_CODE_PEPPER`,留空时用 `MINI_SMS_PASSWORD`)。明文只出现在发往用户的那一条短信里,既不写库也不写日志。6 位码空间很小,加盐哈希是必需的——裸 SHA-256 可被离线穷举。单次有效,消费即作废;重发时作废该号码此前的验证码,校验只认最近一次下发的记录,最新码已使用或过期不会回退验证旧码。旧策略发出的5分钟验证码也受创建后120秒上限约束。并发提交靠行锁串行,同一个码不会被用两次。连续错误 5 次直接作废该验证码。
 
-**限流**(判定全部发生在发短信之前,被限流时不会有短信产生):同号 60 秒内不可重发、单号每日 10 条、单 IP 每小时 30 条,外加与其他登录入口共用的 `LoginThrottle`。短信宝自身还有「间隔太短」限制,返回码统一转 `50302`,不透传给前端。
+**限流**(判定全部发生在发短信之前,被限流时不会有短信产生):同号 60 秒内不可重发、单号每日 10 条、单 IP 每小时 30 条,外加与其他登录入口共用的 `LoginThrottle`。短信宝网关的业务拒绝统一转 `50302`,具体状态只记入服务端日志。
 
 **失败处理**:短信下发失败时立即删除该验证码,未送达的码不会被猜中或事后使用。`SmsCode::consume()` 在事务内决定结果、在事务提交后才抛异常,否则一次输错会把刚记上的失败次数一起回滚,锁定逻辑就失效了。
 

+ 101 - 0
server/scripts/check_mini_sms.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+/** Read-only SMS checks; --gateway queries the account quota without sending a message. */
+require dirname(__DIR__) . '/vendor/autoload.php';
+\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
+\support\App::loadAllConfig(['route']);
+
+use app\miniapi\auth\SmsCode;
+use app\miniapi\model\MiniSmsCode;
+use app\miniapi\model\MiniUser;
+
+$endpoint = (string) config('mini_api.sms_endpoint', '');
+$host = parse_url($endpoint, PHP_URL_HOST);
+$signature = trim((string) config('mini_api.sms_signature', ''));
+$checks = [
+    'curl' => extension_loaded('curl'),
+    'mbstring' => extension_loaded('mbstring'),
+    'pdo_mysql' => extension_loaded('pdo_mysql'),
+    'sms_enabled_with_credentials' => SmsCode::enabled(),
+    'signature_configured' => $signature !== '',
+    'https_endpoint' => parse_url($endpoint, PHP_URL_SCHEME) === 'https'
+        && in_array($host, ['api.smsbao.com', 'www.smsbao.com'], true)
+        && parse_url($endpoint, PHP_URL_PATH) === '/sms'
+        && parse_url($endpoint, PHP_URL_QUERY) === null,
+];
+$report = [
+    'php_binary' => PHP_BINARY,
+    'php_ini' => php_ini_loaded_file(),
+    'additional_ini' => php_ini_scanned_files() ?: null,
+    'curl_cainfo' => ini_get('curl.cainfo') ?: null,
+    'openssl_cafile' => ini_get('openssl.cafile') ?: null,
+    'sms_host' => $host,
+    'signature' => $signature !== '' ? $signature : null,
+];
+
+foreach ([
+    'mini_user_phone_schema' => [new MiniUser(), ['id', 'phone', 'status']],
+    'sms_code_schema' => [new MiniSmsCode(), ['id', 'phone', 'purpose', 'code_hash', 'attempts', 'expires_at', 'consumed_at', 'request_ip', 'created_at']],
+] as $name => [$model, $columns]) {
+    try {
+        $model->newQuery()->select($columns)->limit(1)->get();
+        $checks[$name] = true;
+    } catch (\Throwable $exception) {
+        $checks[$name] = false;
+        $report['database_errors'][$name] = [
+            'type' => get_class($exception),
+            'code' => $exception->getCode(),
+        ];
+    }
+}
+
+if (in_array('--gateway', $argv, true)) {
+    if (!$checks['curl'] || !$checks['sms_enabled_with_credentials'] || !$checks['https_endpoint']) {
+        $checks['gateway'] = false;
+        $report['gateway'] = ['skipped' => 'Fix the extension, account or endpoint checks first.'];
+    } else {
+        $username = (string) config('mini_api.sms_user', '');
+        $credential = (string) config('mini_api.sms_password', '');
+        $url = 'https://' . $host . '/query?' . http_build_query(['u' => $username, 'p' => $credential]);
+        $curl = curl_init($url);
+        curl_setopt_array($curl, [
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_CONNECTTIMEOUT => 5,
+            CURLOPT_TIMEOUT => 10,
+            CURLOPT_SSL_VERIFYPEER => true,
+            CURLOPT_SSL_VERIFYHOST => 2,
+            CURLOPT_FOLLOWLOCATION => false,
+        ]);
+        $raw = curl_exec($curl);
+        $httpStatus = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+        $errorNumber = curl_errno($curl);
+        $error = curl_error($curl);
+        curl_close($curl);
+        $lines = $raw === false ? [] : preg_split('/\r?\n/', trim($raw));
+        $status = $lines[0] ?? '';
+        $checks['gateway'] = $httpStatus === 200 && $status === '0';
+        $report['gateway'] = [
+            'http_status' => $httpStatus,
+            'curl_errno' => $errorNumber,
+            'provider_status' => preg_match('/^-?\d+$/D', $status) ? $status : null,
+        ];
+        if ($error !== '') {
+            $report['gateway']['transport_error'] = str_replace(
+                [$url, rawurlencode($credential), $credential, $username],
+                '[redacted]',
+                $error
+            );
+        }
+        if ($checks['gateway'] && preg_match('/^\d+,\d+$/D', $lines[1] ?? '')) {
+            [$sent, $remaining] = explode(',', $lines[1]);
+            $report['gateway']['remaining_sms'] = (int) $remaining;
+        }
+    }
+}
+
+$report['checks'] = $checks;
+$report['ok'] = !in_array(false, $checks, true);
+echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+exit($report['ok'] ? 0 : 1);

+ 170 - 0
server/tests/mini_sms_sender_test.php

@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+/** Exercise the provider adapter with an in-memory HTTP transport. No SMS is sent. */
+
+namespace app\miniapi\service {
+    final class SmsTransportFixture
+    {
+        public static string $url = '';
+        public static array $options = [];
+        public static string|false $response = '0';
+        public static int $status = 200;
+        public static int $errorNumber = 0;
+        public static string $error = '';
+    }
+
+    function curl_init(string $url): \stdClass
+    {
+        SmsTransportFixture::$url = $url;
+
+        return new \stdClass();
+    }
+
+    function curl_setopt_array(\stdClass $handle, array $options): bool
+    {
+        SmsTransportFixture::$options = $options;
+
+        return true;
+    }
+
+    function curl_exec(\stdClass $handle): string|false
+    {
+        return SmsTransportFixture::$response;
+    }
+
+    function curl_getinfo(\stdClass $handle, int $option): int
+    {
+        return SmsTransportFixture::$status;
+    }
+
+    function curl_errno(\stdClass $handle): int
+    {
+        return SmsTransportFixture::$errorNumber;
+    }
+
+    function curl_error(\stdClass $handle): string
+    {
+        return SmsTransportFixture::$error;
+    }
+
+    function curl_close(\stdClass $handle): void
+    {
+    }
+}
+
+namespace {
+    require dirname(__DIR__) . '/vendor/autoload.php';
+    \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
+    \support\App::loadAllConfig(['route']);
+
+    use app\miniapi\exception\ApiException;
+    use app\miniapi\service\SmsbaoSender;
+    use app\miniapi\service\SmsTransportFixture;
+    use Monolog\Handler\TestHandler;
+    use support\Log;
+
+    $checks = 0;
+    function check(bool $condition, string $label): void
+    {
+        global $checks;
+        $checks++;
+        if (!$condition) {
+            throw new RuntimeException($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, []);
+    }
+
+    function rejects(callable $action, int $expectedCode): void
+    {
+        try {
+            $action();
+        } catch (ApiException $exception) {
+            check($exception->getCode() === $expectedCode, 'Expected API error ' . $expectedCode);
+
+            return;
+        }
+        throw new RuntimeException('Expected API rejection');
+    }
+
+    $original = config('mini_api');
+    $handler = new TestHandler(\Monolog\Logger::DEBUG, false);
+    $logger = Log::channel();
+    $logger->pushHandler($handler);
+    try {
+        $sender = new SmsbaoSender();
+        $password = md5('fixture-password');
+        settings([
+            'sms_endpoint' => 'https://api.smsbao.com/sms',
+            'sms_user' => 'fixture-user',
+            'sms_password' => $password,
+            'sms_signature' => '',
+        ]);
+        rejects(fn () => $sender->send('13800138000', '验证码123456'), 50301);
+        check(SmsTransportFixture::$url === '', 'Missing signature prevents the provider request');
+
+        settings(['sms_signature' => '云泉智配']);
+        $sender->send('13800138000', '验证码123456');
+        parse_str((string) parse_url(SmsTransportFixture::$url, PHP_URL_QUERY), $query);
+        check($query['p'] === $password, 'Configured MD5 password is not hashed twice');
+        check($query['c'] === '【云泉智配】验证码123456', 'Content includes the configured company signature');
+        check($query['m'] === '13800138000' && $query['u'] === 'fixture-user', 'Provider receives the intended account and recipient');
+        check(SmsTransportFixture::$options[CURLOPT_SSL_VERIFYPEER] === true, 'TLS peer verification stays enabled');
+        check(SmsTransportFixture::$options[CURLOPT_SSL_VERIFYHOST] === 2, 'TLS hostname verification stays enabled');
+
+        settings(['sms_signature' => '【云泉智配】', 'sms_password' => 'fixture-api-key']);
+        $sender->send('13800138000', '验证码123456');
+        parse_str((string) parse_url(SmsTransportFixture::$url, PHP_URL_QUERY), $query);
+        check($query['c'] === '【云泉智配】验证码123456', 'Bracketed signatures are not duplicated');
+        check($query['p'] === 'fixture-api-key', 'API keys pass through unchanged');
+
+        foreach ([
+            '30' => '账户密码错误',
+            '40' => '账户不存在',
+            '41' => '账户余额不足',
+            '43' => '来源IP受限',
+            '50' => '内容包含敏感词',
+            '51' => '手机号码不正确',
+        ] as $status => $reason) {
+            $handler->clear();
+            SmsTransportFixture::$response = (string) $status;
+            rejects(fn () => $sender->send('13800138000', '验证码123456'), 50302);
+            $record = $handler->getRecords()[0];
+            check($record['context']['reason'] === $reason, 'Official provider status is interpreted correctly: ' . $status);
+            check(array_key_exists('request_id', $record['context']), 'Provider rejection supports request correlation');
+        }
+
+        $handler->clear();
+        SmsTransportFixture::$response = false;
+        SmsTransportFixture::$status = 0;
+        SmsTransportFixture::$errorNumber = 28;
+        SmsTransportFixture::$error = 'Timeout accessing ' . SmsTransportFixture::$url;
+        rejects(fn () => $sender->send('13800138000', '验证码123456'), 50302);
+        $record = $handler->getRecords()[0];
+        check($record['context']['curl_errno'] === 28, 'Transport errno is available for diagnosis');
+        check(!str_contains(json_encode($record), 'fixture-api-key'), 'Transport logs redact provider credentials');
+        check(!str_contains(json_encode($record), '13800138000'), 'Transport logs do not contain a request URL or recipient');
+
+        $handler->clear();
+        SmsTransportFixture::$response = 'Unexpected HTML response containing fixture-api-key';
+        SmsTransportFixture::$status = 200;
+        rejects(fn () => $sender->send('13800138000', '验证码123456'), 50302);
+        $record = $handler->getRecords()[0];
+        check($record['context']['status'] === 'unexpected_response', 'Unexpected provider response is not logged verbatim');
+        check(!str_contains(json_encode($record), 'fixture-api-key'), 'Unexpected response cannot expose credentials');
+    } finally {
+        settings($original);
+        $logger->popHandler();
+    }
+
+    echo "PASS: $checks SMS provider checks; no network request or SMS sent.\n";
+}