Files
cmautobuy/docs/admin/08-顺运宝接口.md
T

561 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 08 顺运宝 ERP 接口契约
- 文档状态:**从 HAR 抓包还原**,未经官方文档核对
- 来源:`raw_data/shunyunbaoerp_*.har`(4 份,2026-07-28 抓)
和 `raw_data/shunyunbaoerp_single.py`(962 行的可运行示例)
- 基址:`https://www.shunyunbaoerp.com`
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
> `[必须]` **`raw_data/` 和 `admin/config.yaml` 里有真实账号密码,两者都已在
> `.gitignore` 里,绝不能进 Git,也不要随打包产物发给别人。**
> 本文档里的账号、单号、金额全部是脱敏或示例值。
---
## 1. 这份文档是怎么来的,可信度如何
全部结论来自抓包和示例脚本,**不是官方文档**。凡是只有一个样本支撑的判断,
下面都标了 `[待定]`,实现时要在真实数据上再确认一次。
已核对过的 4 份抓包:
| 文件 | 覆盖 |
|---|---|
| `shunyunbaoerp_login.har` | 验证码、登录 |
| `shunyunbaoerp_stock_list.har` | **按日期范围**列表查询 |
| `shunyunbaoerp_stock_query.har` | 按单号查询 + 货运明细 |
| `shunyunbaoerp_userinfo.har` | 个人信息(用来探测会话是否还有效) |
---
## 2. 统一响应信封
所有 `/am/**` 接口都是这个形状:
```json
{ "status": true, "msg": "获取成功", "data": <任意>, "code": null }
```
`[必须]` 判断成功**只看 `status === true`**,不要看 HTTP 状态码——
服务端在业务失败时也可能返回 200。
`[必须]` `data` 的类型随接口变:可能是对象、数组,也可能是**裸整数**
(`listTotal` 就返回 `"data": 1`)。不要假设它一定是对象。
`[必须]` 失败时把 `msg` 和 `code` 一起带进错误信息,否则排查时看不出原因。
---
## 3. 认证
### 3.1 认证靠 Cookie,不是 token
`[必须]` **登录响应里的 JWT 从来不参与后续请求。**
已核对示例脚本全文:`self.token` 只出现在「登录时赋值 / 存缓存 / 读缓存 /
算缓存有效期」四处,**从没被放进任何请求头**。认证完全靠
`requests.Session` 自动维护的 Cookie。
所以 Go 侧要持久化的是 **Cookie**,token 只用来算过期时间——
甚至可以不存 token,直接存算好的 `expires_at`。
### 3.2 登录流程
```text
① GET /api/p/code1?<毫秒时间戳> → image/jpeg,约 2.3KB,4 位字母数字
② POST /am/auth/login → {"username","password","code"}
→ {"status":true,"data":{"user":{...},"token":"<JWT>"}}
```
`[必须]` 三步必须用**同一个 HTTP 客户端**(同一个 Cookie Jar)——
验证码是和会话绑定的,换客户端拿到的验证码对不上。
`[必须]` 密码**明文提交**(走 HTTPS)。客户端不做哈希。
`[必须]` 时间戳参数是为了绕开缓存,每次取验证码都要换。
### 3.3 会话有效期正好 24 小时
实测 JWT 载荷:
```json
{"authLogin": false, "exp": 1785294742, "iat": 1785208342,
"jti": "<用户名>", "username": "<用户名>"}
```
`exp - iat = 86400` 秒,**整 24 小时**。
`[必须]` 缓存有效期取 `min(自定义上限, JWT 剩余时间)`,缓存不能活得比会话长。
### 3.4 没有滚动续期
`[必须]` 示例脚本里的 `_capture_refreshed_token()` 从响应头
`X-Requested-With` 读刷新后的 JWT,**这是死代码**。
实测 4 份 HAR 共 18 个接口响应,**带该响应头的:0 个**。
(`X-Requested-With` 本来就是**请求**头,脚本自己也设了 `XMLHttpRequest`。)
**不要把这段逻辑移植到 Go。** 会话就是 24 小时硬上限,到点重新登录。
### 3.5 怎么判断会话还活着
```text
GET /am/user/get?id=<登录响应里的 user.id>
```
`[必须]` **必须区分「明确未登录」和「网络故障」**:
| 情况 | 处理 |
|---|---|
| HTTP 401 / 403 | 判定未登录,清会话 |
| `msg` 含「未登录」「登录过期」,或 `code=-2` | 判定未登录,清会话 |
| 超时、5xx、响应格式错 | **抛错,不要判定未登录** |
理由:网络抖一下就判定登出的话,会触发重新登录,验证码弹个不停,
而且可能把本来有效的会话丢掉。示例脚本这一点做对了,照抄。
`[必须]` 还要核对返回的 `id` / `username` 与缓存的一致——
不一致说明串号了,同样清会话。
---
## 4. 货运单列表
两个接口配合,**payload 完全相同**:
```text
POST /am/stock/listTotal → data 是裸整数,总条数
POST /am/stock/list → data.list 是数组,data.total 是当前页条数
```
`[必须]` 不要把 `list.data.total` 当成筛选范围总数。实测 HAR 中范围总数为
3846 时,第一页返回 20 行且 `list.data.total = 20`;范围总数只能以
`listTotal.data` 为准。
### 4.1 请求体
```json
{
"history": 0,
"length": 20, // 每页条数
"start": 0, // 偏移
"pageTotal": 0,
"pageIndex": 1, // 从 1 开始
"store": false,
"columns": [ ... 72 个列定义 ... ],
"queries": [ ... 查询条件 ... ]
}
```
`[必须]` `columns` 是**要返回哪些列**的声明,72 项,每项形如:
```json
{"tableName":"t_stock","colName":"created","fieldName":"created",
"hasAlias":0,"tableAlias":"t"}
```
完整清单见示例脚本的 `COLUMN_SPECS`(第 51–124 行)。
`[建议]` 直接照搬,不要自己删减——服务端可能依赖这批列做联表。
### 4.2 两种查询条件
**按日期范围(同步用这个)** —— 出自 `shunyunbaoerp_stock_list.har`:
```json
{"dvalue": "2026-07-25,2026-07-28", "tableName": "t_stock",
"colName": "created", "op": 0, "type": 3, "tableAlias": "t", "optType": 0}
```
`[必须]` `dvalue` 是 `起始日期,结束日期`,逗号分隔,`YYYY-MM-DD`。
`type: 3` 表示日期类型,`op: 0` 表示范围。
**按单号(查单条用这个)** —— 出自 `shunyunbaoerp_stock_query.har`:
```json
{"dvalue": "<单号>", "tableName": "t_stock",
"colName": "allcode", "op": 6, "type": 0, "tableAlias": "t", "optType": 1}
```
`[必须]` `queries` 是**数组**,理论上可以组合多个条件。
示例脚本里那句 `if not order_number: raise ValueError` 是**脚本自己的限制**,
接口没有这个限制。
`[待定]` 空 `queries`(不加任何条件)能否拉全量未验证。同步用日期范围就够,
不需要冒这个险。
### 4.3 分页
`[必须]` 一个跨日范围要拆成逐日查询。先逐日调用 `listTotal` 做全范围容量
预检,确认合计不超限后,再按天以 `length = 20` 翻页调用 `list`。明细仍按
最多 100 个货运单 ID 一批读取。
`[必须]` **必须有单次同步的条数上限**,超了报错而不是硬拉。
Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整个日期范围的
逐日总数合计,不是每天各算一次。任何一天的列表或明细都不得在容量预检通过前
开始拉取,避免超限后已经产生部分写入。
`[必须]` 每一页 `list.data.total` 必须等于该页 `list` 数组长度。非最后一页
必须返回 `length` 条,最后一页必须返回预检总数对应的剩余条数。每天翻页结束后
再次调用 `listTotal`,前后总数必须一致;全部页去重后的货运单 ID 数还必须等于
预检总数。历史日期出现前后总数变化、短页、重复 ID 或唯一 ID 不足时立即失败,
不推进游标。
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
### 4.4 统一日期范围同步与覆盖游标
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
由操作员明确选择日期,单次仍不得超过 31 天。
`[必须]` 首次成功同步以所选结束日建立覆盖游标。已有游标时,只有日期范围从
游标当天或更早开始、并且结束日在游标之后,全部成功后才推进游标。局部历史补拉
或跳过缺口的范围只 upsert 数据,不动游标。
例如覆盖游标为 `2026-08-01`,同步 `2026-08-01 ~ 2026-08-09` 可以推进到
`2026-08-09`;只同步 `2026-08-07 ~ 2026-08-08` 不能推进,因为中间有缺口。
`[必须]` 日期格式固定 `YYYY-MM-DD`,按 UTC+8 解释;两端必须同时填写,
开始不得晚于结束,结束不得晚于 UTC+8 下的今天;闭区间最多 31 天。中途失败
或超过 `max_matches` 时不推进游标。
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
可以保留,但只有所有日期全部成功才推进游标。
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
输入验证码和 303 跳转时必须原样保留。
---
## 5. 货运单字段
`data.list[]` 每行 **77 个字段**。业务上要紧的:
| 字段 | 示例 | 说明 |
|---|---|---|
| `id` | `75104587` | **货运单主键**,取明细要用它 |
| `code` | `260728TB95MJTQ` | 单号(界面上搜的就是这个) |
| `created` | `2026-07-28 10:37:59` | 创建时间,日期范围筛的就是它 |
| `status` | `13` | 数字状态码 |
| `orderStatus` | `待出货` | 中文状态 |
| `purchaseStatus` | `0` | 采购状态 |
| `shopName` | `<店铺名>` | 蝦皮店铺 |
| `productName` | `純棉上衣` | **只有一个商品名**,一单多商品时不完整 |
| `orderQty` / `detailQty` | `2` / `2` | 商品件数 |
| `isCancel` | `0` | 是否取消 |
| `expCompany` | `蝦皮店到店` | 物流方式 |
| `receiver` / `receiverTel` / `receiverAddr` | — | **收件人信息,属个人数据** |
`[必须]` **收件人姓名、电话、地址属于个人信息**,落库要考虑是否必要。
不做采购决策用不到它们,`[建议]` 不入库,或只存脱敏后的。
### 5.1 金额单位不统一 —— 最容易算错钱的地方
`[必须]` 实测同一张单、同一行里,两个金额字段**单位不一样**:
| 字段 | `/am/stock/list` | `detail/listByStock` | 关系 |
|---|---|---|---|
| `amtOrder` | `61200` | `612.0` | list 是**分**,×100 |
| `escrowAmount` | `505` | `505.0` | list **不是分**,未 ×100 |
`61200 / 100 = 612` 对得上,`505 / 100 = 5.05` **对不上**。
`[必须]` **不要假设「列表接口的金额都是分」。** 逐个字段确认,
并且在代码里为每个金额字段写清它的单位。
`[建议]` 金额一律以 **`detail/listByStock` 的值为准**,那边是统一的元/TWD。
需要存整数分时自己 ×100,不要用列表接口的原值。
`[待定]` 只有一个样本。实现前**必须再取几张单核对**,尤其是
`escrowAmount` 不是整数元的情况。
### 5.2 金额合计对不上是正常的
```text
明细单价合计 239.0 + 439.0 = 678.0
amtOrder 612.0
```
差 66,应该是优惠。`[必须]` **不要用「明细合计 == amtOrder」做校验**,
会误报。
### 5.3 `created` 是 UTC+8,不是 UTC —— 日期范围查询最容易算错的地方
`[必须]` 实测 `raw_data/shunyunbaoerp_stock_query.har`:
```text
HAR 记录的抓包时刻 startedDateTime 2026-07-28T03:31:45Z (= 11:31:45 UTC+8)
同一次请求响应里的 created 2026-07-28 10:37:59
```
`10:37:59` 作为 **UTC+8** 讲得通(比抓包时刻早 54 分钟,正常)。
若把它当成 **UTC**,换算成 UTC+8 就是 18:37,比抓包时刻**晚 7 小时**——
订单创建于尚未发生的未来,不成立。所以 `created` 是 UTC+8,不是 UTC。
`[必须]` §4.2「按日期范围」的 `dvalue` 筛的就是这个 `created`,
所以**换算"今天是哪一天"也必须用 UTC+8**,不能用 UTC 或本机系统时区
(本机系统时区不一定是 UTC+8,取决于部署环境)。用 UTC 算的话,
在 UTC+8 的 00:00–08:00 这段时间会把"今天"算成昨天,当天早晨创建的单
这一轮同步拉不到——虽然下一轮的起始日期仍是"上次同步日",范围会覆盖
回来、不会永久丢单,但操作员当场点同步会以为同步坏了。
`[必须]` 代码里固定用 `time.FixedZone("UTC+8", 8*60*60)`,不要用
`time.LoadLocation("Asia/Shanghai")`——那个要读系统 tzdata,Windows 上
默认没有,打包成 exe 后会在运行时报错。
`[待定]` 只有一个样本(一次抓包)支撑这个结论,且没有拿到顺运宝官方
文档确认。以后如果日期范围附近出现"该有的单没同步到",先来这里核对
这条结论是否仍然成立。
---
## 6. 货运明细
```text
POST /am/stock/detail/listByStock?hist=0
{"ids": [75104587, ...]}
→ data.list[]
```
`[必须]` 一次最多传 **100 个 id**(示例脚本的分批大小),超了分批。
### 6.1 一单多商品是嵌套,不是多行
返回的每一行对应**一张货运单**,商品在嵌套的 `details[]` 里:
```json
{
"id": 75104587, // 与 stock.id 相同,可直接对应
"code": "260728TB95MJTQ",
"shopName": "<店铺名>",
"productName": "純棉上衣",
"amtOrder": 612.0,
"details": [
{
"id": 145306175,
"productId": 50209124255,
"productTitle": "蕾絲花邊拼接背心女 上衣 背心 無袖打底衫 …",
"detailProductName": "打底衫",
"productSpec": "白色,L【建議50-60公斤】",
"productQty": 1,
"productPrice": 239.0,
"productThumb": 190639637,
"shopId": 999611342,
"pruchaseId": 38884195
},
{ ... 第二个商品 ... }
]
}
```
`[必须]` 外层 `id` **就是** `stock.id`,可以直接按它把列表和明细对起来。
`[必须]` **一张货运单可以有多个商品**(示例这张就有 2 个)。
落库时必须一对多拆开,不能只取 `productName`——那个字段只有一个商品名。
### 6.2 `productId` 是蝦皮商品 ID,不是規格 ID
`[必须]` 这一条推翻了之前「货运单的规格 SKU 能直接对上蝦皮商品規格ID」的假设。
| | 位数 | 例 |
|---|---|---|
| 顺运宝 `productId` | 11 | `50209124255` |
| 蝦皮 `商品ID` | 11 | 实测导入的 5195 个**全是 11 位** |
| 蝦皮 `商品規格ID` | 12 | 实测 5742/6092 是 12 位 |
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
```text
蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】
顺运宝 productSpec 白色,L【建議50-60公斤】
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
```
`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。
第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。
### 6.4 由此推导出的匹配路径
```text
productId ──→ shopee_products.goods_id 商品级,直接相等
productSpec ──→ SpecKey() ──→ 稳定规格身份键
──→ 在该商品目录和人工映射中查找
```
`[必须]` 比对不上时**不要猜**,标成待人工匹配。蝦皮报表只含有销售成绩的
SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常。
---
## 7. 其他接口
| 接口 | 用途 | 备注 |
|---|---|---|
| `POST /am/store/listByUsed` | 仓库列表 | 请求体为空;返回 `[{"name":"京发仓","id":129}, …]` |
| `POST /am/wallet/showTip` | 登录后弹提示 | 同步不需要 |
| `GET /am/menu/curr` | 当前菜单 | 同步不需要 |
| `POST /am/notice/myList` | 通知列表 | 同步不需要 |
| `GET /am/user/checkNeedAgreement` | 是否需同意协议 | 同步不需要 |
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
Go 侧不用跟着调。
---
## 8. 实现时的固定约束
这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
不清理历史货运单。
`[必须]` **会话缓存存 Admin 的 MySQL,不引入 Redis。** 示例脚本用 Redis 是因为
它是反复启动的一次性脚本,进程间要传会话;Admin 是常驻进程,没有这个需求,
持久化只为重启后免登录,继续复用现有数据库即可。
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在工单 #47 里被推翻了,
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
> 原判断(工单 #46):会话 24 小时,一天登录一次。为省一次手工输验证码
> 而依赖 `127.0.0.1:8000` 不划算——多一个必须先启动的东西,而且 OCR
> 会失败(示例脚本自己写了 5 次重试),失败了照样要人工。界面上显示
> 验证码图片、操作员输一次即可。
`[必须]` **这条判断的前提是"本机服务 `127.0.0.1:8000`",托管服务不适用。**
工单 #47 里用户提供了托管地址 `https://ocr.ilapage.cn/ocr`:没有要启动的
东西,就是一次 HTTP 调用,"多一个必须先启动的东西"这条理由不成立了。
而"每天第一次同步都要人在场"这个代价是实打实的——会话 24 小时过期,
意味着做不了无人值守的定时同步。于是工单 #47 引入了 OCR 自动识别,
失败或服务不可达时**降级**到原有的手工输入弹窗(那条兜底路径没有变),
不是"失败了照样要人工"变成了"失败了才要人工"。详见下面「验证码自动识别」一节。
`[必须]` **凭据放 `admin/config.yaml`**,已在 `.gitignore` 里。
仓库里提供 `admin/config.example.yaml` 作为模板(不含真实凭据)。
`[必须]` **密码在任何日志里都要打码。** 日志可能被贴进工单排查问题。
`[必须]` YAML 里**密码要加引号**。纯数字密码不加引号会被 YAML 解析成整数,
反序列化到 `string` 字段直接报错:
```yaml
password: 0012345 # ✗ 解析成整数 12345,前导 0 也丢了
password: "0012345" # ✓
```
`[建议]` 依赖用 `github.com/goccy/go-yaml`,它已经在依赖树里(indirect),
不会引入新的传递依赖。
---
## 8.1 验证码自动识别(工单 #47)
### 响应格式(已实测)
```
POST https://ocr.ilapage.cn/ocr
Content-Type: multipart/form-data,字段名 file
→ 200 {"code":200,"message":"Success","data":"kycv"}
```
实测耗时约 1.4 秒。
`[必须]` **识别失败也是 `code:200`,这是最容易写错的地方。**
实测用一张无文字的图片探测,返回的是 `{"code":200,"message":"Success","data":""}`——
不是错误码,是 `code:200` 加空 `data`。判断这一次调用真正"识别出了点什么",
必须同时满足:HTTP 200、`code == 200`、**`data` 非空**。只看 `code` 会把
"没识别出来"当成功,拿空字符串去登录。
`[必须]` 顺运宝验证码固定 **4 位字母数字**(08 §3.2 实测)。识别结果过滤
空格标点后长度不是 4,说明识别错了,**不要拿去登录**——直接换一张图重试。
拿明知不对的验证码去登录白费一次尝试,而且频繁的错误登录可能触发对方风控。
### 重试与降级
```text
点同步 → 会话过期
├─ ocr_url 已配置
│ └─ 循环 ocr_max_attempts 次:取新验证码图 → OCR 识别 →
│ 校验(非空 && 4位字母数字) → 登录
│ ├─ 登录成功 ─────────────────→ 直接同步,无人值守
│ └─ 次数用完仍失败 ────────────┐
└─ ocr_url 未配置 / 请求本身失败 ────────┴─→ 弹手工输入框(#46 已有的兜底路径)
```
`[必须]` **OCR 不可达要降级,不是报错。** 外部服务挂了不该让整个同步功能
不可用——手工路径一直在,走它就是了。
`[必须]` 每次重试都要**重新取一张验证码图**。同一张图再识别一次结果一样,
纯属浪费;而且验证码可能已经被上一次失败的登录作废。
`[必须]` OCR 请求本身失败(连不上、超时、返回非法 JSON、`code != 200`)判定为
"服务不可用",**立即降级,不占用重试次数**——重试对"服务本身连不上"这种情况
没有意义。只有"HTTP 调用成功但识别结果不合格(空/长度不对)"才占用一次重试。
`[必须]` 调 OCR **不带顺运宝的 Cookie**,用独立的 `http.Client`(独立的
Cookie Jar、独立的超时),避免把顺运宝会话泄漏给另一个服务;也不共用
顺运宝请求的超时,OCR 慢不该拖垮整个登录流程(`[建议]` 10 秒)。
`[必须]` **验证码图片全程在内存里传字节,不写文件。** 参考实现
`raw_data/shunyunbaoerp_single.py` 把图片存成 `captcha.jpg` 是命令行脚本
的做法,Admin 是常驻进程,写文件只会在 `data/` 里堆垃圾。
`[必须]` **验证码图片会被发送到 `ocr_url` 配置的外部服务。**
当前 `https://ocr.ilapage.cn/ocr` 是用户自己的服务,不算交给第三方;
把 `ocr_url` 换成别人运营的服务前,必须重新评估这一点。
### 配置
```yaml
syb:
# 验证码自动识别服务地址。留空则只用手工输入弹窗,不报错。
ocr_url: https://ocr.ilapage.cn/ocr
ocr_max_attempts: 5
```
`[必须]` `ocr_url` 留空 = 禁用,直接走手工输入弹窗,**不报错**。
---
## 9. 已知未验证的部分
实现前应逐条确认,都只有单一样本支撑:
- [ ] `escrowAmount` 等金额字段在列表接口里的**单位**(见 §5.1)
- [ ] 空 `queries` 能否拉全量(同步用日期范围,可不验)
- [ ] 日期范围跨度很大时服务端是否限流或超时
- [ ] `status` 数字码与 `orderStatus` 中文的完整对应表
- [ ] 会话失效时服务端返回的**确切**形态(HTTP 码 / `code` / `msg` 文案)
- [ ] 同一账号多处登录是否互踢
- [ ] 验证码错误、密码错误分别返回什么,能否区分
- [ ] `created` 是 UTC+8 这一条(见 §5.3)只有一次抓包支撑,
没有官方文档确认,也没有跨夏令时/时区配置的验证
`[必须]` 最后两条影响错误提示的准确性:分不清「密码错」和「验证码错」的话,
操作员会一直重输密码。
---
## 10. 相关文档
- 数据落库:[03 数据模型](03-data-model.md)
- 界面:[05 界面规范](05-ui-specification.md) §6 顺运宝数据页
- 安全要求:[06 质量与安全](06-quality-security.md)
- 规格身份:`admin/spec/speckey.go` 的 `SpecKey()`