Просмотр исходного кода

1.新增验签中间件
2.设计签名算法及salt

lh 7 часов назад
Родитель
Сommit
fb8919f6f1
5 измененных файлов с 600 добавлено и 35 удалено
  1. 3 0
      app/Cache/CacheKeys.php
  2. 1 0
      app/Consts/ErrorConst.php
  3. 175 35
      app/Http/Middleware/CheckSign.php
  4. 43 0
      config/sign.php
  5. 378 0
      docs/frontend-integration.md

+ 3 - 0
app/Cache/CacheKeys.php

@@ -55,5 +55,8 @@ class CacheKeys
         'captcha'   => [
             'code' => 'captcha_code_%s', // %s为验证码key
         ],
+        'sign'      => [
+            'nonce' => 'sign_nonce_%s', // %s为nonce的md5(防重放,时间窗内一次性)
+        ],
     ];
 }

+ 1 - 0
app/Consts/ErrorConst.php

@@ -16,6 +16,7 @@ class ErrorConst
     const NOT_OPENED                        = '1009:功能暂未开放';
     const DB_INVALID                        = '1010:信息更新失败,请联系管理员';
     const STOP_TRANSACTION                  = '1011:事务异常,中止事务';
+    const SIGN_ERROR                        = '1002:签名异常';
     const ACCOUNT_NOT_MATCH_DOMAIN          = '1012:账号与域名不匹配';
     const REDIS_KEY_NOT_EXIST               = '1013:the redis key is not found';
     const REPORT_FAILED                     = '1014:回传上报错误';

+ 175 - 35
app/Http/Middleware/CheckSign.php

@@ -3,17 +3,28 @@
 
 namespace App\Http\Middleware;
 
-use App\Cache\UserCache;
 use App\Consts\ErrorConst;
 use App\Libs\Utils;
-use App\Models\Channel\Channel;
 use Closure;
 use App\Exceptions\ApiException;
-use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redis;
+use App\Facade\Site;
 
 class CheckSign
 {
     /**
+     * 接口验签
+     *
+     * 新版签名(推荐):
+     *   X-Time  : 当前时间戳(秒)
+     *   X-Nonce : 前端生成的随机串(时间窗内一次有效)
+     *   X-Sign  : HMAC-SHA256( METHOD|PATH|X-Time|X-Nonce|d-token=xxx, SIGN_SALT ),小写 hex
+     *
+     * 签名串示例:POST|/api/anime/detail|1737280000|a1b2c3d4e5|d-token=3f2a...
+     *
+     * 旧版签名(过渡期兼容,可由 sign.allow_legacy 关闭):
+     *   sign / nonce / timestamp 请求头 + md5(strtoupper(http_build_query(...)) . '&key=SALT')
+     *
      * @param         $request
      * @param Closure $next
      * @return mixed
@@ -22,46 +33,175 @@ class CheckSign
     public function handle($request, Closure $next)
     {
         $token = $request->header('d-token', '');
-        $param_sign = $request->header('sign', '');
-        $nonce = $request->header('nonce', '');
-        $timestamp = $request->header('timestamp', '');
         if (!$token) {
             $token = $request->input('d_token', '');
             if (!$token) Utils::throwError(ErrorConst::NOT_LOGIN);
         }
 
-        $referer_url = $request->input('_url', '');
-        // 先验签(非本地模式需要验签)
-        if (env('CHECK_SIGN')) {
-            $check_params = [
-                'd-token'   => $token,
-                'nonce' => $nonce,
-                'timestamp' => $timestamp,
-            ];
-            $params = $check_params;
-            $params['_url'] = $referer_url;
-            if (!$nonce || !$timestamp) {
-                dLog('checkSign')->info('验签失败, 请求参数不正确;传参: '.json_encode($params, 256));
-                Utils::throwError('1002:签名异常');
-            }
-            if (time() - $timestamp > 300) {
-                dLog('checkSign')->info('验签失败, 签名5分钟内有效;传参: '.json_encode($params, 256));
-                Utils::throwError('1002:签名异常');
-            }
+        // 非本地模式需要验签
+        if (!config('sign.enabled')) {
+            return $next($request);
+        }
 
-            ksort($check_params);
-            $str = strtoupper(http_build_query($check_params));
-            $sign = md5($str.'&key='.env('SIGN_SALT'));
-            if ($param_sign != $sign) {
-                $params['_url'] = $referer_url;
-                $params['sign'] = $param_sign;
-                $params['check_sign'] = $sign;
-                $params['check_str'] = $str.'&key='.env('SIGN_SALT');
-                dLog('checkSign')->info('验签失败, 签名不正确;传参: '.json_encode($params, 256));
-                Utils::throwError('1002:签名异常');
-            }
+        // 白名单:指定的 uid / cpid 跳过验签
+        if ($this->inSkipList()) {
+            return $next($request);
+        }
+
+        $time  = trim((string)$request->header('X-Time', ''));
+        $nonce = trim((string)$request->header('X-Nonce', ''));
+        $sign  = trim((string)$request->header('X-Sign', ''));
+
+        if ($time !== '' && $nonce !== '' && $sign !== '') {
+            $this->checkSign($request, $time, $nonce, $sign, $token);
+        } else {
+            $this->fail('缺少签名请求头', ['path' => $request->path()]);
         }
 
         return $next($request);
     }
+
+    /**
+     * 新版签名校验:HMAC-SHA256 + 时间窗 + nonce 一次性
+     *
+     * @param         $request
+     * @param string  $time
+     * @param string  $nonce
+     * @param string  $sign
+     * @param string  $token
+     * @return void
+     * @throws ApiException
+     */
+    private function checkSign($request, string $time, string $nonce, string $sign, string $token): void
+    {
+        $ttl = (int)config('sign.ttl', 120);
+
+        if (!ctype_digit($time)) {
+            $this->fail('X-Time 格式不正确', ['time' => $time]);
+        }
+
+        // 允许轻微时钟偏差,因此取绝对值
+        if (abs(time() - (int)$time) > $ttl) {
+            $this->fail('签名已过期', ['time' => $time, 'ttl' => $ttl]);
+        }
+
+        $str      = $this->buildSignString($request, $time, $nonce, $token);
+        $expected = hash_hmac((string)config('sign.algo', 'sha256'), $str, (string)config('sign.salt'));
+
+        if (!hash_equals($expected, strtolower($sign))) {
+            $this->fail('签名不正确', [
+                'check_str' => $str,
+                'sign'      => $sign,
+                'expected'  => $expected,
+            ]);
+        }
+
+        // 防重放:签名通过后再占用 nonce,避免无效请求刷满缓存
+        if (config('sign.nonce_unique')) {
+            $nonceKey = Utils::getCacheKey('sign.nonce', [md5($nonce)]);
+            $ok       = Redis::set($nonceKey, 1, 'EX', max($ttl, 60), 'NX');
+            if (!$ok) {
+                $this->fail('请求重复(nonce 已使用)', ['nonce' => $nonce]);
+            }
+        }
+    }
+
+    /**
+     * 构造签名串:METHOD|PATH|X-Time|X-Nonce|d-token=xxx
+     *
+     * PATH 已归一化为以 / 开头、不含域名与 query string 的形式,例如 /api/anime/detail
+     *
+     * @param        $request
+     * @param string $time
+     * @param string $nonce
+     * @param string $token
+     * @return string
+     */
+    private function buildSignString($request, string $time, string $nonce, string $token): string
+    {
+        $method = strtoupper($request->getMethod());
+        $path   = '/' . ltrim($request->path(), '/');
+
+        return $method . '|' . $path . '|' . $time . '|' . $nonce . '|d-token=' . $token;
+    }
+
+    /**
+     * 是否命中免验签白名单(按 uid 或 cpid)
+     *
+     * @return bool
+     */
+    private function inSkipList(): bool
+    {
+        $uid  = (int)Site::getUid();
+        $cpid = (int)Site::getCpid();
+
+        if ($uid > 0 && in_array($uid, (array)config('sign.skip_uids', []), true)) {
+            return true;
+        }
+
+        if ($cpid > 0 && in_array($cpid, (array)config('sign.skip_cpids', []), true)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * 旧版签名校验:md5(strtoupper(http_build_query(d-token,nonce,timestamp)) . '&key=SALT')
+     *
+     * 仅用于前端升级过渡期,全部切换完成后可关闭 sign.allow_legacy
+     *
+     * @param        $request
+     * @param string $token
+     * @return void
+     * @throws ApiException
+     */
+    private function checkLegacySign($request, string $token): void
+    {
+        $param_sign = $request->header('sign', '');
+        $nonce      = $request->header('nonce', '');
+        $timestamp  = $request->header('timestamp', '');
+        $refererUrl = $request->input('_url', '');
+
+        $checkParams = [
+            'd-token'   => $token,
+            'nonce'     => $nonce,
+            'timestamp' => $timestamp,
+        ];
+
+        if (!$nonce || !$timestamp) {
+            $this->fail('请求参数不正确', $checkParams + ['_url' => $refererUrl]);
+        }
+
+        if (time() - (int)$timestamp > 300) {
+            $this->fail('签名5分钟内有效', $checkParams + ['_url' => $refererUrl]);
+        }
+
+        ksort($checkParams);
+        $str  = strtoupper(http_build_query($checkParams));
+        $sign = md5($str . '&key=' . (string)config('sign.salt'));
+
+        if ($param_sign != $sign) {
+            $this->fail('签名不正确', $checkParams + [
+                '_url'       => $refererUrl,
+                'sign'       => $param_sign,
+                'check_sign' => $sign,
+                'check_str'  => $str . '&key=' . (string)config('sign.salt'),
+            ]);
+        }
+    }
+
+    /**
+     * 验签失败统一处理:记日志并抛出异常
+     *
+     * @param string $msg
+     * @param array  $context
+     * @return void
+     * @throws ApiException
+     */
+    private function fail(string $msg, array $context = []): void
+    {
+        dLog('checkSign')->info('验签失败, ' . $msg . ';传参: ' . json_encode($context, 256));
+        Utils::throwError(ErrorConst::SIGN_ERROR);
+    }
 }

+ 43 - 0
config/sign.php

@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * 接口签名(防篡改 / 防重放)配置
+ *
+ * 签名串格式:
+ *     {METHOD}|{PATH}|{X-Time}|{X-Nonce}|d-token={d-token}
+ * 示例:
+ *     POST|/api/anime/detail|1737280000|a1b2c3d4e5|d-token=3f2a...
+ *
+ * 签名算法:HMAC-SHA256(签名串, SIGN_SALT)(输出小写 hex,64 位)
+ * 请求头  :X-Time / X-Nonce / X-Sign
+ *
+ * 注意:salt 会下发到前端,因此它不是真正的秘密。该签名的价值在于
+ *      防参数篡改、防重放(配合 nonce 去重与时间窗)以及抬高脚本调用门槛,
+ *      接口的最终权限仍由 token 鉴权与资源归属校验保证。
+ */
+return [
+
+    // 总开关(对应 .env 中的 CHECK_SIGN)
+    'enabled' => (bool)env('CHECK_SIGN', false),
+
+    // 签名盐
+    'salt' => (string)env('SIGN_SALT', '352bb5074767ca21fb7204bf47e5dba5fb4302a322d62308da6c7a06badd18d9'),
+
+    // 时间窗(秒):请求时间与本机时间的最大允许偏差
+    'ttl' => (int)env('SIGN_TTL', 180),
+
+    // 是否对 nonce 去重(防重放):同一 nonce 在时间窗内只允许使用一次,依赖 Redis
+    'nonce_unique' => (bool)env('SIGN_NONCE_UNIQUE', true),
+
+    /*
+    | 免验签白名单:命中的用户 / 公司跳过验签(用于内部账号灰度或线上排障)
+    | 多个用逗号分隔,例如 SIGN_SKIP_UIDS=1,2  SIGN_SKIP_CPIDS=1
+    | 留空表示不跳过任何账号
+    */
+    'skip_uids'  => array_values(array_filter(array_map('intval', explode(',', (string)env('SIGN_SKIP_UIDS', ''))))),
+    'skip_cpids' => array_values(array_filter(array_map('intval', explode(',', (string)env('SIGN_SKIP_CPIDS', ''))))),
+
+    // 签名算法(hash_hmac 支持的算法)
+    'algo' => (string)env('SIGN_ALGO', 'sha256'),
+
+];

+ 378 - 0
docs/frontend-integration.md

@@ -0,0 +1,378 @@
+# 前端对接文档
+
+> 适用平台:有声合成平台 / 掌维 AI 短剧
+> 后端:Laravel,接口统一前缀 `/api`
+> 本文档覆盖前端需要改造对接的两块内容:**接口签名**与**登录图片验证码**,以及通用约定与错误码。
+
+---
+
+## 一、通用约定
+
+### 1.1 接口地址与鉴权
+
+- 所有业务接口以 `/api` 开头,例如 `POST /api/anime/detail`。
+- 登录后,除少量免登录接口外,均需在请求头携带登录令牌:
+
+  ```
+  d-token: <登录接口返回的 token>
+  ```
+- 少数导出类接口服务端也兼容从参数 `d_token` 读取令牌,**新代码请统一使用请求头**。
+- 免登录接口(不需要 `d-token`):`GET /api/login`、`GET /api/captcha`、`ANY /api/video/seedanceCallback`。
+
+### 1.2 响应格式
+
+成功:
+
+```json
+{ "msg": "", "code": 0, "data": { } }
+```
+
+失败(业务异常):
+
+```json
+{ "code": 1002, "msg": "签名异常", "data": { } }
+```
+
+> 约定:`code === 0` 表示成功,其余为业务错误码,前端按 `code` 判断并提示 `msg`。
+> 业务异常统一返回 HTTP 200;个别接口由框架做参数校验时可能返回 HTTP 422,建议在响应拦截器里一并处理。
+
+### 1.3 常用错误码
+
+| code  | msg                  | 说明                                      |
+| ----- | -------------------- | ----------------------------------------- |
+| 0     | -                    | 成功                                      |
+| 1002  | 签名异常             | 签名不正确 / 过期 / 缺少签名头 / 请求重复 |
+| 1003  | 请确认填写的数据无误 | 参数错误                                  |
+| 1005  | 没有权限             | 无权限访问该资源(含跨公司访问)          |
+| 1008  | 请先登录             | 未登录或 token 失效                       |
+| 10021 | 验证码错误           | 图片验证码错误或已过期                    |
+
+---
+
+## 二、接口签名
+
+### 2.1 适用范围
+
+服务端在以下接口组启用了验签:
+
+- 剧本管理:`/api/deepseek/*`(`scriptList`、`scriptInfo`、`saveEpisodeContent` 等)
+- AI 生成:`/api/AIGeneration/*`
+- 动漫管理:`/api/anime/*`
+- 画布模式:`/api/canvas/*`
+- 音频素材上传:`/api/book/uploadAudioEffect`、`/api/book/uploadBgm`
+
+**推荐做法:前端统一给所有请求加签名头,不必区分接口。** 服务端对未开启验签的接口不会校验,多传请求头没有任何副作用,这样可以避免后续接口调整归属时前端漏改。
+
+> 注意:服务端有开关,未开启验签时签名头会被忽略,接口照常返回。因此前端上线后如果发现一切正常,不代表签名已经生效,需要和后端确认开关状态。
+
+### 2.2 签名规则
+
+三个请求头:
+
+| 请求头      | 内容                                         |
+| ----------- | -------------------------------------------- |
+| `X-Time`  | 当前时间戳(**秒**,10 位)            |
+| `X-Nonce` | 前端生成的随机串,同一账号在时间窗内不可重复 |
+| `X-Sign`  | 签名结果,**小写十六进制**(64 位)    |
+
+签名串按固定顺序拼接,字段间用 `|` 分隔:
+
+```
+{请求方法大写}|{接口路径}|{X-Time}|{X-Nonce}|d-token={d-token}
+```
+
+签名算法:
+
+```
+X-Sign = HMAC-SHA256(签名串, SIGN_SALT)    // 结果转小写 hex
+```
+
+其中 `SIGN_SALT` 由后端提供,需在打包时注入前端配置。
+
+**接口路径(PATH)的规则**,这是最容易算错的地方:
+
+- 以 `/` 开头
+- 不含协议和域名
+- **不含 query string**(`?a=1&b=2` 要丢掉)
+- 与后端路由注册的路径一致,例如 `/api/anime/detail`
+
+示例:
+
+```
+请求:POST /api/anime/detail?anime_id=123
+X-Time  = 1737280000
+X-Nonce = abc123
+d-token = 0123456789abcdef0123456789abcdef
+
+签名串 = POST|/api/anime/detail|1737280000|abc123|d-token=0123456789abcdef0123456789abcdef
+X-Sign = HMAC-SHA256(签名串, SIGN_SALT)
+```
+
+**有效期与重放保护**:
+
+- `X-Time` 与本机时间偏差超过 **120 秒**(可由后端配置)判定为过期;
+- 同一 `X-Nonce` 在时间窗内只能使用一次,重复请求会返回 1002。
+
+### 2.3 自检用例(用于验证前端实现是否正确)
+
+用下面的固定输入自测,输出的 `X-Sign` 必须与表中一致,否则说明拼接规则或编码有误。
+
+> 注意:用例中的时间戳是固定值(已过期),**仅用于校验算法实现,不能用于真实请求**。真实请求必须使用当前时间。
+
+| 项               | 用例一                                                               | 用例二                                                               |
+| ---------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
+| 请求方法         | `POST`                                                             | `GET`                                                              |
+| 接口路径         | `/api/anime/detail`                                                | `/api/canvas/list`                                                 |
+| X-Time           | `1737280000`                                                       | `1737280000`                                                       |
+| X-Nonce          | `abc123`                                                           | `nonce9876`                                                        |
+| d-token          | `0123456789abcdef0123456789abcdef`                                 | `fedcba9876543210fedcba9876543210`                                 |
+| SIGN_SALT        | `example_salt`                                                     | `example_salt`                                                     |
+| **X-Sign** | `f9a6c3cc7cf1b7f4b390fc08d91c5070445d60aadeba4b823509ea3092d7ae4b` | `3bc9b788a618dc44f06dba1aa2b0e5ebb703166b7f2b496eef49233a6461c4eb` |
+
+可以直接用 Node 跑一遍验证:
+
+```js
+// node sign-selfcheck.js
+const crypto = require('crypto')
+
+function sign(method, path, time, nonce, token, salt) {
+  const str = `${method}|${path}|${time}|${nonce}|d-token=${token}`
+  return crypto.createHmac('sha256', salt).update(str).digest('hex')
+}
+
+console.log(sign('POST', '/api/anime/detail', '1737280000', 'abc123',
+  '0123456789abcdef0123456789abcdef', 'example_salt'))
+// 期望:f9a6c3cc7cf1b7f4b390fc08d91c5070445d60aadeba4b823509ea3092d7ae4b
+
+console.log(sign('GET', '/api/canvas/list', '1737280000', 'nonce9876',
+  'fedcba9876543210fedcba9876543210', 'example_salt'))
+// 期望:3bc9b788a618dc44f06dba1aa2b0e5ebb703166b7f2b496eef49233a6461c4eb
+```
+
+两条都输出一致,说明签名算法实现正确;剩下只需要保证 `path`、`token` 与真实请求对齐。
+
+### 2.4 前端实现
+
+#### 方案 A:使用 crypto-js(推荐,改动最小)
+
+```bash
+npm install crypto-js
+```
+
+```js
+// src/utils/sign.js
+import HmacSHA256 from 'crypto-js/hmac-sha256'
+import Hex from 'crypto-js/enc-hex'
+
+const SIGN_SALT = process.env.VUE_APP_SIGN_SALT
+
+/**
+ * 生成接口签名头
+ * @param {string} method  请求方法
+ * @param {string} path    接口路径(以 / 开头,不含域名与 query)
+ * @param {string} token   登录令牌
+ */
+export function buildSignHeaders(method, path, token) {
+  const time  = Math.floor(Date.now() / 1000).toString()
+  const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
+  const str   = `${method.toUpperCase()}|${path}|${time}|${nonce}|d-token=${token}`
+
+  return {
+    'X-Time':  time,
+    'X-Nonce': nonce,
+    'X-Sign':  HmacSHA256(str, SIGN_SALT).toString(Hex)
+  }
+}
+```
+
+#### 方案 B:使用浏览器原生 Web Crypto(不新增依赖)
+
+```js
+// src/utils/sign.js
+const SIGN_SALT = process.env.VUE_APP_SIGN_SALT
+
+const enc = new TextEncoder()
+let cachedKey = null
+
+async function getKey() {
+  if (!cachedKey) {
+    cachedKey = crypto.subtle.importKey(
+      'raw', enc.encode(SIGN_SALT), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
+    )
+  }
+  return cachedKey
+}
+
+export async function buildSignHeaders(method, path, token) {
+  const time  = Math.floor(Date.now() / 1000).toString()
+  const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
+  const str   = `${method.toUpperCase()}|${path}|${time}|${nonce}|d-token=${token}`
+
+  const sig = await crypto.subtle.sign('HMAC', await getKey(), enc.encode(str))
+  const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('')
+
+  return { 'X-Time': time, 'X-Nonce': nonce, 'X-Sign': hex }
+}
+```
+
+> Web Crypto 的 `crypto.subtle` 只在 HTTPS 或 localhost 下可用,若前端部署在 http 环境请使用方案 A。
+
+#### 挂到 axios 拦截器
+
+```js
+import { buildSignHeaders } from '@/utils/sign'
+import store from '@/store'
+
+service.interceptors.request.use(async (config) => {
+  if (config.skipSign) return config          // 个别请求可跳过
+
+  const token = store.getters.token || ''
+  // 关键:path 必须是含 /api 前缀的真实路径,且不含 query
+  const rawUrl = String(config.url || '')
+  const path   = '/' + rawUrl.split('?')[0].replace(/^\/+/, '')
+  const method = (config.method || 'get').toUpperCase()
+
+  const headers = await buildSignHeaders(method, path, token)
+  Object.assign(config.headers, headers)
+
+  return config
+}, (error) => Promise.reject(error))
+```
+
+### 2.5 签名相关常见错误
+
+| 现象                   | 排查方向                                                                                                                          |
+| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| 一直 1002              | 先跑第 2.3 节自检用例;再打印前端实际拼出的`path`,与后端 `$request->path()` 对比                                             |
+| 偶发 1002              | 服务器与客户端时钟偏差超过 120 秒,检查服务器 NTP                                                                                 |
+| 1002 且提示 nonce 重复 | 同一个`X-Nonce` 被复用(例如请求重试时复用了旧头),重试需重新生成                                                              |
+| `path` 对不上        | 注意`axios` 的 `baseURL`:若 `/api` 写在 `baseURL` 里、`url` 只写了 `/anime/detail`,需手动补成 `/api/anime/detail` |
+| token 对不上           | 签名里的`d-token` 必须与请求头 `d-token` 完全一致(含大小写),若 token 做过 trim 或转换需保持一致                            |
+
+---
+
+## 三、登录图片验证码
+
+### 3.1 获取验证码
+
+```
+GET /api/captcha
+```
+
+免登录,无需签名。返回:
+
+```json
+{
+  "msg": "",
+  "code": 0,
+  "data": {
+    "captcha_key": "8f14e45fceea167a5a36dedd4bea2543",
+    "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+  }
+}
+```
+
+- captcha_key:本次验证码的唯一标识,登录时回传;
+- `image`:完整的 data URI,可直接作为 `<img>` 的 `src`,无需额外处理。
+
+服务端对该接口有 **20 次/分钟/IP** 的限流。
+
+### 3.2 登录
+
+```
+GET /api/login?account=<账号>&passwd=<密码>&captcha_key=<key>&captcha_code=<用户输入>
+```
+
+| 参数             | 必填                 | 说明                           |
+| ---------------- | -------------------- | ------------------------------ |
+| `account`      | 是                   | 账号                           |
+| `passwd`       | 是                   | 密码                           |
+| `captcha_key`  | 后端开启验证码时必填 | 获取验证码接口返回的`key`    |
+| `captcha_code` | 后端开启验证码时必填 | 用户输入的验证码,大小写不敏感 |
+
+成功返回:
+
+```json
+{
+  "msg": "",
+  "code": 0,
+  "data": {
+    "uid": 12,
+    "nickname": "张三",
+    "token": "f3a8c1e0b7d94a2f8e6c5b1d0a9f3e72",
+    "role": "admin"
+  }
+}
+```
+
+`role` 取值:`superadmin`(平台)、`admin`(公司管理员)、`user`(组员)。
+
+### 3.3 验证码的关键行为
+
+- **有效期 5 分钟**,过期后需重新获取;
+- **一次性消费**:无论校验成功还是失败,`key` 立即失效,所以用户输错后必须自动刷新一张新验证码;
+- 大小写不敏感;
+- 错误返回 `code = 10021`。
+
+> 服务端有总开关。开关未开启时,不传 `captcha_key` / `captcha_code` 也能正常登录,因此前端可以先上线,等后端开启后再生效。
+
+### 3.4 前端实现要点
+
+登录页:
+
+1. 进入页面时调用 `GET /api/captcha`,把 `data.image` 绑到 `<img :src="captcha.image">`;
+2. 点击图片重新获取(同时清空输入框);
+3. 提交登录时带上 `captcha_key` 与 `captcha_code`;
+4. **登录返回 `code === 10021` 时,自动重新拉取验证码并提示用户重新输入**;
+5. 其它失败(如密码错误)也建议刷新验证码,避免用户拿到已消费的 `key` 反复提交。
+
+---
+
+## 四、其它常用接口
+
+### 4.1 获取当前用户信息
+
+```
+GET /api/userInfo
+```
+
+需登录。返回 `uid`、`nickname`、`token`、`role`、`points` 等字段。
+
+### 4.2 退出登录
+
+```
+GET /api/logout
+```
+
+---
+
+## 五、灰度与上线说明
+
+本文档涉及的两项能力都由后端开关控制,前端可以先行上线,行为与现在完全一致:
+
+| 能力       | 后端开关            | 未开启时的表现             |
+| ---------- | ------------------- | -------------------------- |
+| 接口签名   | `CHECK_SIGN`      | 签名头被忽略,按原逻辑放行 |
+| 登录验证码 | `CAPTCHA_ENABLED` | 登录不校验验证码           |
+
+**建议的上线顺序**
+
+1. 后端部署(两个开关均为关闭状态,线上零影响);
+2. 前端发布带签名与验证码的版本;此时功能表现不变,可确认页面无异常;
+3. 后端依次开启开关并清理配置缓存(`php artisan config:clear`):
+   - 先开 `CHECK_SIGN`,观察日志是否出现验签失败;
+   - 再开 `CAPTCHA_ENABLED`,确认登录流程正常。
+
+**需要向后端确认的信息**
+
+- `SIGN_SALT` 的正式值(需与前端打包配置一致,改动需两端同步);
+- `CHECK_SIGN` 与 `CAPTCHA_ENABLED` 的开启时间点;
+- 是否存在验签白名单账号(白名单账号不校验签名,表现为"签名错误也能调用成功",属正常现象,不要据此判断前端实现有误)。
+
+---
+
+## 六、变更记录
+
+| 日期       | 内容                                                         |
+| ---------- | ------------------------------------------------------------ |
+| 2026-09-21 | 初版:接口签名(HMAC-SHA256 + nonce 防重放)、登录图片验证码 |