# Token 令牌体系 DSL 扩展提案

> 状态：**提案（已定稿，待实现）**
> 关联代码：`pylon/src/types.ts`（User interface）、`pylon-fastify/src/common/auth/*`（签名/JWT 鉴权策略）、`pylon-sign-db-driver`/`pylon-sign-redis-driver`（密钥解析）、`dsl/src/dto.ts`（DTO）、`dsl/src/service.ts`（service）
> 背景：BLE 案例 Customer 无处安放 + `@pylonts/core` 的 User 概念过弱
> **定位（已决策）：推翻现有的"HMAC 签名 + JWT 登录"双层鉴权体系，合并为 token 令牌体系**——签名/加密材料与身份数据统一由同一个两态对象承载

## 1. 背景：Customer 的身份问题

BLE 案例（business-ble）中，Java 侧 `Customer.java` 是 **POS 服务器端映射对象**——它由服务器上下文注入（请求进来时从卡/设备上下文解析出的客户），不是数据库表、不是 DTO 消息、不是实体行对象。pylon 迁移时它被表达成三份互相独立的重复：

| 位置 | 表达方式 | 问题 |
|------|---------|------|
| `utils_schema/ble-api/ble-wx/utils/ble.utils.ts` | `objectField({ properties: {...} })` 内联为 `getBsId/getOpId` 的参数 | 内联，无身份、无复用 |
| `dto_schema/ble-wx/ble-charge.dto.ts` | `Customer` 是一个 `buildInput` DTO | 它根本不是消息契约，是身份对象 |
| 同上 | `SubmitCpuRequest.customer` 内联 `dtoObjectField` 重复同一份结构 | 与 Customer DTO 结构重复、无单一事实来源 |

三份结构必须手工保持一致，字段变了要改三处。**根本原因：pylon 没有"服务器端映射对象"这个概念**。

## 2. 安全定级依据：等保 2.0 三级

涉及银联/支付宝支付的 app（商城、收单平台、支付小程序），定级逻辑（GB/T 22240-2020）：涉及资金交易 + 大规模个人信息，受破坏后"对社会秩序和公共利益造成严重损害" → **第三级（监督保护级）**。金融行业惯例：支付清算核心系统四级；支付类平台/app 三级。银联/支付宝/微信支付的合作准入（收单外包、服务商尽调）均以等保三级为门槛（案例：地铁支付宝小程序、收钱吧）。三级义务：**每年至少一次等级测评 + 渗透测试**，近 300 项要求、73 类测评分类。

三级对应用/会话层的硬性要求（GB/T 22239-2019）与本设计的映射：

| 条款 | 要求 | 对 token 令牌体系的意义 |
|------|------|-------------------------------|
| 8.1.4.1 身份鉴别 | 身份标识唯一；**两种以上鉴别技术组合**（即双因子）；登录失败锁定（≤5 次/≥30 分钟）；**会话空闲超时断开** | token 固定 30 分钟过期（Redis TTL）+ refreshToken 刷新；删 token = 终止会话；双因子是登录链要补的能力 |
| 8.1.4.3 安全审计 | 审计覆盖每个用户和重要操作（支付/退款/核销），记录防篡改，**保存 ≥6 个月** | Token 的 `identity` 段正是审计"谁在什么时间做了什么"的锚点；服务端 Redis 对象比 JWT payload 更便于审计追踪 |
| 8.1.4.7/8.1.4.8 数据完整性/保密性 | 传输加密（TLS）、**存储加密**（银行卡号等敏感数据） | 卡号/账户字段在 Token、表、DTO 全链路要有敏感字段标记（加密落库/脱敏展示） |
| 8.1.4.10 个人信息保护 | 仅采集必要信息、去标识化 | Token 字段最小化（identity + 必要业务字段） |
| 移动互联扩展（附录 A2） | 移动应用加固、会话管理、防逆向 | 客户端只存 token（纯 hash）而非完整身份信息 |

结论对提案的支撑：

1. **token 令牌体系与等保三级同向**：Redis 存对象 + token 纯 hash = 会话可终止、可审计、空闲超时可控、对象实时更新（改密后旧会话即时失效）；JWT 反序列化模式在"会话终止"和"审计"上是短板。
2. **双因子认证**进入落地范围（登录链生成时考虑口令 + 短信/OTP 组合）。
3. **敏感字段标记**（卡号等）是 Token 设计要考虑的维度——等保要求存储加密，字段级敏感标记驱动加解密与脱敏。

### 2.1 token 体系等保三级合规性审查

逐条对照三级要求审查 token 体系是否成立：

| 条款 | 要求 | token 体系是否满足 | 结论 |
|------|------|-------------------|------|
| 8.1.4.1 身份鉴别 | 身份标识唯一 | token 随机 hash 唯一（Redis key） | ✅ |
| 8.1.4.1 身份鉴别 | 两种以上鉴别技术组合（双因子） | 登录 = 口令 + 签名密钥（密码技术因子，token 体系已保证客户端持有）——可论证成立；**但落地必须禁止纯口令登录** | ⚠️ 落地约束 |
| 8.1.4.1 身份鉴别 | 登录失败锁定（≤5 次/≥30 分钟） | 未设计 | ⚠️ 落地必须补 |
| 8.1.4.1 身份鉴别 | 会话空闲超时断开 / 终止会话 | Redis TTL + 删 token | ✅ |
| 8.1.4.3 安全审计 | 每用户、重要操作审计，防篡改，≥6 个月 | identity 段作审计锚点；**Redis 不承担审计——审计日志独立持久化** | ⚠️ 边界注明 |
| 8.1.4.4 入侵防范 | 防重放攻击 | 签名含 timestamp + nonce（**不退役**）；token 不可解析防篡改 | ✅ |
| 8.1.4.7 数据完整性 | 传输完整性保护 | 每请求签名，覆盖 body + token | ✅ |
| 8.1.4.8 数据保密性 | 传输加密、存储加密 | TLS + 可选加密密钥；**Redis 中的签名密钥需加密存储 + Redis 自身按等保加固**（密码认证、访问控制、超时断开） | ⚠️ 落地约束 |
| 8.1.4.10 个人信息保护 | 最小化、去标识化 | identity 段字段最小化（身份主键 + 必要业务字段） | ✅ |
| 附录 A2 移动互联 | 移动应用加固、会话管理 | 客户端只存纯 hash token + 密钥，无身份数据 | ✅ |

**审查结论：体系成立**，前提是落地时补四个硬约束：

1. **防重放不退役**：签名机制保留 timestamp + nonce（退役的只是独立密钥解析体系）；
2. **双因子落地**：登录 = 口令 + 签名密钥，禁止纯口令登录（等保要求两种鉴别技术组合）；
3. **登录失败锁定**：≤5 次 / 锁 ≥30 分钟；
4. **Redis 安全加固**：签名密钥加密存储（不落明文）、Redis 密码认证 + 访问控制 + 超时断开（等保对 Redis 有专项测评）、审计日志独立持久化（Redis 不作审计载体）。

## 3. 现状：签名 + 登录双层体系，两者独立

现有鉴权是**两个互不相关的层**（现状明文："HMAC 证明'请求来自合法客户端'，JWT 证明'用户是谁'——两者独立，互不相关"）：

| 层 | 机制 | 服务端载体 | 密钥/凭证 | 吊销 |
|----|------|-----------|----------|------|
| HMAC 签名层 | 客户端对每请求签名（x-app-key/x-timestamp/x-nonce/x-signature） | `SignatureStrategy` + SignUtils，密钥由 sign-db-driver / sign-redis-driver 按需查表解析 | appKey + secret（sign/issue 签发） | nonce 防重放窗口 |
| JWT 登录层 | 登录后 getToken() 附 Authorization header | `JwtStrategy`：`jwtVerify()` 后 `request.user as User` 反序列化 payload | jwt secret + payload 对象 | 可选 blacklist 检查 |

双层体系的问题：

1. **身份与通信材料分离**：验签走密钥存储、认证走 token payload，两条路径、两套过期与吊销管理；
2. **每请求两轮验证**：先验签、再验 token；
3. **User 弱**：`@pylonts/core` 手写 interface `{ id; role?; type? }`，三处各自为政构建（login 手写字面量 / HMAC `{ id: appKey }` / JWT `as User` 强转），无声明、无校验、无生成，controller 全是 `_user: User` 弃用参数；
4. **DTO 注入只映射一个 id**：`__inject` 适配器（`InjectFn = (body, user) => void`）已有设计但 User 只有 id，多属性映射无能力；
5. **安全状态不达标**：JWT 反序列化在"会话终止/审计"上是短板（§2 等保三级要求），blacklist 是补丁式吊销。

## 4. 提案：TokenSchema —— 用户身份对象

**定义**：Token 是服务器端映射的用户身份对象（登录主体、客户身份），由服务器上下文注入，具有**声明式定义、唯一身份、类型生成**三个一等公民属性。**没有多来源**：每个模块（api + app）一份身份，归属确定。

**口径约定**：大写 **Token** = 服务端两态对象（本 DSL 声明的对象）；小写 **token** = 客户端持有的随机 hash 字符串（纯引用，不可解析）。

**与现有概念的边界**：

| 概念 | 回答的问题 | 数据来源 |
|------|-----------|---------|
| TableSchema | 数据存在哪张表 | 数据库 DDL |
| DtoMessage | 消息契约长什么样 | 请求/响应体 |
| EntitySchema | DAO 行对象长什么样 | 表行 |
| **TokenSchema** | **"我是谁"（用户身份）** | **服务器上下文注入** |

### 4.1 DSL 声明

```ts
// token_schema/api/admin/token/admin-user.token.ts
export const adminUserToken = defineToken({
  name: 'AdminUser',
  description: 'admin 后台登录主体',
  api: api,                              // 归属后端（模块双定：api + app）
  app: admin,
  security: {                          // 安全材料段：内建字段（未登录即有）
    secret: dtoField(stringField({ minLength: 32, maxLength: 64, label: '签名密钥' })),  // 必选：签名
    cipher: dtoField(stringField({ optional: true, label: '加密密钥' })),                // 可选：通道加密
  },
  identity: {                          // 身份段：登录后才有（表必须含 token + refresh_token + login_at 列，硬约束）
    ...from(adminUserTable, [adminUserTable.columns.id, adminUserTable.columns.username]),
  },
});
```

**字段承载（已决策：TokenSchema 内部装 dtoField）**：两段都是 `Record<string, DtoField>`，但来源不同——**identity 段字段必须 `from(表, 列)` 投影**（复用 DTO 的 `from()`，返回的正是 DtoField；身份数据都有表落点，`from` 天然继承类型/校验/语义）；**security 段是内建字段，不挂钩表**——签名密钥/加密密钥是获取 token 接口生成的随机材料，只存在于 Redis 对象，没有表落点。token 字段因此天然拥有 DTO 字段的全部语义：类型/约束继承，description/optional/pattern 可字段级覆盖。

**两段结构（已决策）**：Token 与一般身份对象不同，它承载**两类数据**，且两类数据的**存在时机不同**。两段是**声明期组织**（决定字段何时存在），**运行时对象是平面结构**——所有字段合并为一个平面对象，不分段：

| 段 | 内容 | 存在时机 | 作用 |
|----|------|---------|------|
| `security` 安全材料段 | **内建字段**：`secret`（签名密钥，必选）+ `cipher`（加密密钥，可选）——服务端生成、不挂钩表 | **未登录即有**（获取 token 时生成） | HMAC 验签、通道加解密 |
| `identity` 身份段 | 身份数据（id/username/bsId 等），**必须 `from()` 表列** | **登录后才有** | 业务消费"我是谁" |

```
未登录态：Token = { secret, cipher }                    （平面对象，只有安全材料）
登录态：  Token = { secret, cipher, id, account, ... }  （身份字段附着到同一平面对象）
```

**运行时形态（已决策：平面）**：Redis 存储、注入函数、controller 消费全部按**平面对象**访问——`body.id = token.id`、`token.secret`，不存在 `token.security.xxx` / `token.identity.xxx` 嵌套。两段信息只决定字段何时存在，不决定存储/访问形态。

**与 JWT 的本质区别（已决策）**：JWT 是"登录后才签发身份凭证"——无登录无 token；Token 是**两态对象**——未登录就有对象（通信材料），登录只是给对象**附着身份**。token 始终只是对象引用，不携带任何数据。

**声明规则**：

| 规则 | 含义 |
|------|------|
| `api` + `app` 双必填 | Token 归属模块，一个后端一个身份，与 service/dao 的归属规则一致 |
| **identity 段全部来源于表**（已决策） | identity 段字段必须 `from()` 表列，**可以是多张表**；不允许内联字段——身份数据不是凭空声明的，都有表落点（`from` 天然继承类型/校验/语义） |
| **security 段内建**（已决策） | security 段固定两个内建字段：`secret`（签名密钥，必选）+ `cipher`（加密密钥，可选）——获取 token 接口生成、存 Redis 对象，**不挂钩表**（签名材料是服务端随机产物，无表落点） |
| **身份表硬约束**（已决策） | identity 段 from 的表（账户表）**必须包含 `token` + `refresh_token` + `login_at` 三列**——token 体系运行时把会话凭据（token + refreshToken + 登录时间）写账户表（决策 #11），表缺列则体系不成立，定义期报错 |

**身份有效数据的确定时机（已决策）：login 时**——登录成功时从表读取 identity 段字段，附着到 Redis 已有对象（security 段在获取 token 时已存在）；此后身份有效数据以对象为准（不再回查表）。对象失效（删 token）即重新登录。

### 4.2 token 令牌体系（已决策）

**token 形态（已决策）**：token 是 **xx 位随机数（hash 值）**——只有令牌信息、无任何其他数据、**不可解析**。它只是一个引用，无 payload、无 exp（过期完全由 Redis 键 TTL 管理），**JWT 完全退役**。

**客户端持有（已决策）**：

| 材料 | 必须性 | 获取时机 |
|------|--------|---------|
| token（随机 hash） | 必须 | 调用"获取 token"接口后 |
| 签名密钥 | 必须 | 调用"获取 token"接口后 |
| 加密密钥 | 可选 | 调用"获取 token"接口后 |
| 业务数据（身份数据） | 不一定需要 | 调用 login 后 |

**客户端使用规则（已决策）**：

- token **每次请求必须上送**——**唯一例外：获取 token 接口**（首个请求，无 token 可上送）；
- **所有接口都需要签名**（包括获取 token 接口——初始密钥见下方"bootstrap 已决策：方案 2"）；无 token 的请求（获取 token、未携带 token 的 login）用初始密钥签名；
- 签名密钥对请求签名（token 在签名范围内），加密密钥用于可选通道加密。
- **refresh 是客户端内部固定流程（已决策）**：refresh 不暴露为客户端公共 API——api-client（web/wx）内部自动处理（会话过期自动执行 refresh，body 上送 refreshToken + 派生密钥签名）；前端不生成 refresh 调用函数。

**接口清单（已决策）**：

| 接口 | 场景 | 是否上送 token | 签名材料 | 返回 |
|------|------|--------------|---------|------|
| sign（获取签名，MVP 2026-08-19） | 未登录浏览（匿名签发 security-only token） | 无（首个请求） | 初始密钥（方案 2） | token + **secret（签名密钥）**（无 refreshToken，决策 #11；cipher 存对象不外发） |
| login | 所有场景必选（**app 级入口**） | 无（登录前无 token） | 初始密钥（方案 2） | token + refreshToken + **secret（下发密钥）+** 业务数据? |
| refresh | 所有场景必选 | 否（**请求体上送 refreshToken**） | **refreshToken 派生密钥**（`HMAC(refreshToken, 固定盐)`，两端可算，已决策） | 新 token |

**业务请求签名材料（已决策 #15：暂下发密钥）**：login 响应**直接下发 secret**——客户端持有 secret 对业务请求签名，服务端按 `{app_name}.{token}` 还原对象取 secret 验签（与对象内字段一致，零额外查询）。**派生密钥（业务请求签名改由 `HMAC(token, 固定盐)` 等派生，不下发 secret）为后续工作**——当前体系不做，本决策是过渡实现。

**签发接口是 app 级的（已决策）**：token 与系统（app）强相关，签发接口不在公共层做——每个 app 定义自己的登录入口（admin 登录接口 / 微信小程序登录接口），login 直接生成 token hash + 安全材料（secret/cipher）+ 附着身份 + 返回 token/refreshToken。**"获取签名"匿名接口（未登录浏览场景）已实现（2026-08-19 MVP，gen-login sign 形态）**——签发 security-only token（无身份、无 refreshToken），客户端拿到 secret 即可签名调业务接口；后续真实登录在此 token 上升级（rotateToken + attachIdentity）。**登录入口 controller 用 `@LoginEntry(app?)` 标记**（login/refresh/sign 入口方法，auth 链据此识别入口 + 按 body 是否携带 refreshToken 区分验证模式）；**`@Login` 装饰器语义不变**（业务接口的登录校验装饰器，module 参数照旧）；module_name 在各登录入口定义（= 所属 app 名），**兼作 Redis key 前缀**（`{app_name}.{token}`），auth 链校验按 token 的 app 归属（key 前缀）而非 user.type 过滤。

**服务端存储（已决策）**：**Redis 按 token 存储平面对象**——两段字段合并（安全材料 + 登录后附着的身份数据），不分段。**Redis key = `{app_name}.{token}`**——token 强绑定 app，不同 app 的 token 命名空间隔离（app 前缀防串）。**token TTL 固定 30 分钟**，过期后经 refreshToken 重新生成。

**生命周期**：

```
登录：    客户端签名请求（上送凭据；app 级入口）
           → 验签（无 token 用初始密钥）
           → 凭据校验 → **单点确认**（查用户表旧 token → 删 Redis 旧对象）
           → 生成 token（随机 hash）+ 签名密钥（+ 可选加密密钥）+ refreshToken
           → 存用户表（token + refresh_token + login_at 列）
           → 按 identity 段声明从表读身份数据
           → Redis 存 {app_name}.{token} → { secret, cipher, id, ... }（平面对象）
           → 返回客户端 { token, refreshToken, 业务数据? }
业务请求：客户端上送 token + 签名 → 服务端按 {app_name}.{token} 查 Redis → 一次还原平面对象
          （secret 验签/加解密，身份字段供业务——原两轮验证合成一步）
          → 查不到 = 401（删 token 即吊销，blacklist 退役；防重放仍靠签名 timestamp + nonce）
刷新：    请求体上送 refreshToken → 按 refreshToken 查账户表定位用户
          → 校验账号状态（禁用拒绝）→ 校验最近一次登录/刷新未超 7 天
          → 查表取 refreshToken 派生密钥验签（HMAC(refreshToken, 固定盐)）
          → 重新生成 token（新 hash）+ refreshToken（新 hash）→ 更新 Redis 映射 + 账户表 → 返回新 token + 新 refreshToken
退出：    Redis 删除 token（服务器端主动失效）
```

**刷新机制（已决策）**：

- **token 有效期 30 分钟**（Redis 键 TTL），过期后可刷新；
- **refreshToken 只在 login 时返回**（获取 token 接口不返回），用 refreshToken 重新生成 token（延长有效期）；
- **token + refreshToken 都存储于本系统账户表**（账户表 token 列 + refresh_token 列，一用户一行）；
- **login 时单点确认**：查询用户表已有 token → **删除旧 token 对应的数据**（Redis 旧对象删除，旧会话立即失效）→ 生成新 token + refreshToken 写回用户表——后登录踢掉前登录；
- **login 时直接生成 token + refreshToken 存用户表，并记录登录时间（login_at 列；命名约定：精确时间列统一用 at 后缀）**；
- **refreshToken 过期时间 7 天（可配置，默认 7 天），滑动窗口**（行业惯例 7~30 天：微信 30 天 / 支付宝 40 天 / Auth0 30 天；支付自建账户体系普遍取 7 天下限）——**login_at 在 login 与每次 refresh 时更新（knex.fn.now()），7 天从最近一次登录/刷新时间起算**；到期即失效，必须重新 login；
- **轮换策略（已决策：每次刷新轮换）**：refresh 时重新生成 refreshToken（新随机 hash）写账户表——旧 refreshToken 立即作废（不可重放，泄露窗口缩短）；token 与 refreshToken 每次 refresh 全部更换，客户端从 refresh 响应提取新会话（login 响应同构）；**login_at 随 refresh 更新（7 天滑动窗口，活跃用户持续续期，长期不用 7 天后强制重新 login）**；
- refreshToken **仅刷新接口上送，且只在请求体中上送**（`{ refreshToken }`）——不经任何 header；login 只上送 username/password，业务请求不上送；
- **refresh 同样校验账号状态（已决策）**：status 非正常的禁用账号即使 refreshToken 未过期也拒绝刷新——状态门禁 login/refresh 共用；
- **refresh 查表不取 password（已决策）**：按 refresh_token 查账户表的查询列不含 password（refresh 不做口令校验）、也不含 refresh_token 列（仅作 where 键）——行类型与登录行分离（RefreshRow 无 password / LoginRow 有）。

**refresh 接口签名（已决策：refreshToken 派生密钥）**：refresh 的签名密钥由 refreshToken 派生（`HMAC(refreshToken, 固定盐)`）——**能正确签名 == 持有 refreshToken**，签名与凭据一体、每用户独立；不依赖全局初始密钥（初始密钥泄露不波及 refresh）。服务端验签天然同源：按 refreshToken 查账户表的结果即派生密钥输入，零额外查询。

**单点确认与刷新流程**：

```
login 单点：查询用户表已有 token → 删除 Redis 中旧 token 对象（旧会话立即失效）
           → 生成新 token + refreshToken → 存用户表（token + refresh_token + login_at 列）+ Redis
刷新：    请求体上送 refreshToken → 按 refreshToken 查账户表定位用户 + 校验账号状态（禁用拒绝）+ 校验最近一次登录/刷新未超 7 天
          → 派生密钥验签（HMAC(refreshToken, 固定盐)）
          → 重新生成 token（新 hash）+ refreshToken（新 hash）→ 更新用户表 token + refresh_token + login_at 列 + Redis 映射 → 返回新 token + 新 refreshToken
```

**双层 → 单层对照**：

| 维度 | 现状（签名 + 登录双层） | 目标（token 令牌单层） |
|------|------------------------|----------------------|
| 层数 | HMAC 签名层 + JWT 登录层，独立互不相关 | 一个 token → 一个两态对象 |
| token 形态 | JWT：payload 塞 user 对象，客户端可解析 | **纯随机 hash**：不可解析、无数据 |
| 匿名态 | 只有验签（无对象） | **有对象**：平面对象只有安全材料（secret/cipher） |
| 登录 | 签发携带身份的新凭证 | **身份附着到已有对象**（两态演进） |
| 验证次数 | 每请求两轮（先验签、再验 token） | 一次还原（验签材料与身份同对象） |
| 密钥管理 | appKey+secret / jwt secret 两套 | secret/cipher 统一承载（Redis 平面对象随 token 存） |
| 吊销 | blacklist 补丁 | 删 token 即吊销，blacklist 退役；**防重放保留**（签名含 timestamp + nonce） |
| 过期 | JWT exp + blacklist 双轨 | **token 固定 30 分钟**（Redis TTL）+ refreshToken 刷新重新生成 |
| 对象更新 | 旧 token 携带旧数据 | Redis 对象实时 |
| 信息暴露 | 用户信息在客户端可解 | 客户端只见 hash |
| 存储 | 密钥表按需查 + 无身份存储 | **Redis：token → { 通讯信息 + 身份信息 }** |

**与现有 HMAC 体系的关系**：客户端签名行为保留（所有请求都签名），验签所需的签名密钥由 Redis 平面对象的 secret 字段提供——获取 token 后即存在。**获取 token 接口本身也要签名**（推翻现有"唯一例外"）。

#### 初始密钥（bootstrap，已决策：方案 2）

获取 token 接口要签名，但客户端此时还没有签名密钥——需要**初始密钥**保障两端一致，两个候选方案：

**方案 1：deviceId 即初始密钥**

- 客户端固定自己的 deviceId（native app 读设备标识；**H5 只能是随机数，客户端保存**）；
- 获取 token 时同时上送 deviceId，作为初始化签名密钥——服务端按此验证首次签名，随后签发真密钥。

**方案 2：约定算法推导（固定密钥 + 时间窗口）**

- 双方内置固定密钥（客户端包内 + 服务端配置）；
- 双方约定算法按时间窗口计算一个动态密钥（如 `HMAC(固定密钥, 时间窗口)`）——**两端独立计算，结果一样**，首次请求用它签名。

| 维度 | 方案 1：deviceId | 方案 2：约定算法 |
|------|-----------------|----------------|
| 两端一致性 | 客户端生成 → 上送服务端（事后一致） | 双方独立计算（事前一致） |
| 服务端可验证性 | 弱：服务端无法预知 deviceId，首次请求本质是"信任上送" | 强：服务端独立验算，无需信任首次请求 |
| 密钥保密性 | 弱：deviceId 是公开标识，非秘密；明文上送 | 强：密钥不经过网络 |
| 防重放 | 无（固定 deviceId 签名可重放） | 时间窗口天然限制，窗口过期失效 |
| 密钥轮换 | 无 | 动态轮换（窗口粒度） |
| 泄露风险 | deviceId 可伪造/复制；H5 随机数清缓存即失、可被拷走 | 固定密钥被逆向提取（native 加固 / H5 混淆缓解有限）→ 全局失效，需服务端可更换 + 客户端更新 |
| 实现复杂度 | 低（无预置密钥管理） | 中（时钟同步、窗口容忍、密钥管理） |

**风险与缓解**：两方案都需要 TLS 保护传输。方案 1 的安全强度依赖"首次信任"，适合低风险通道；方案 2 强度更高（服务端可预验证、密钥动态），但固定密钥泄露是全局性风险，需内置密钥可轮换机制。**已决策：方案 2（约定算法推导：固定密钥 + 时间窗口）**——服务端可独立验算、密钥不经过网络、时间窗口天然防重放；落地约束：时钟同步（服务端容忍 ±1 窗口）、固定密钥服务端可轮换 + 客户端可更新。

### 4.3 消费方改造

| 消费方 | 现状 | 改造后 |
|--------|------|--------|
| `SignatureStrategy`（HMAC 验签） | 验签后查密钥表，`{ id: appKey }` | **并入对象还原**：验签材料取自 Redis 平面对象 secret/cipher 字段 |
| `JwtStrategy` | `request.user as User` + 可选 blacklist | 退役——token 是 hash 不可解析，按 token 查 Redis 还原对象，查不到 401 |
| 获取 token 接口 | sign/issue 签发 appKey+secret（唯一免签名 @Public 接口） | 生成 token hash + 签名密钥（+可选加密密钥）存 Redis；**接口本身也要签名**（初始密钥方案 2：固定密钥 + 时间窗口） |
| 登录入口 controller（login/refresh） | 无专门标记；login 走 JWT 签发 | **`@LoginEntry(app?)` 标记** login/refresh 入口方法——auth 链按入口 + body 是否携带 refreshToken 区分验证模式（业务 x-token / login 初始密钥 / refresh 派生密钥），refreshToken 在 refresh 请求体中上送 |
| `@Public()` 装饰器 | 标记免签名接口（sign/issue 唯一使用） | **保留——原语义不变**：标记的接口不检查签名 |
| `signToken/verifyToken` | `(user: User)` 弱类型 | 退役——无 JWT 签发/验证 |
| 登录服务 | `signToken({ id: String(row.id), type: 'admin' })` 手写 | 身份附着到 Redis 已有对象（机器校验 identity 段字段齐全），返回业务数据（可选） |
| controller handler | `(body, _user: User)` | `(body, token: AdminUserToken)`，按模块类型化 |
| 黑名单 checker | `(user: User)` | 机制退役（删 token 替代） |
| **DTO 字段注入** | `__inject` 适配器 `(body, user) => void`，只映射一个 id | **`fromToken(token, [...]): DTO 字段引用 Token 字段（ref 机制），自动标记服务器注入**（客户端不传、运行时填充）。与 `from(表)` 共享 Field 实例不同，`fromToken` 为每个字段**新建 DtoField 并 `setRef(token 字段)`**——复用 token 字段的类型/约束，字段实例独立（buildMessage 反写 name/schema 不污染 token 字段），ref 链渲染时递归展开（防环） |
| utils/service/flow 方法参数 | `objectField` 内联（Customer 现状） | `args: { customer: customerToken.fields }` 或直接引用 Token |

### 4.4 Customer 落地（本案例的落点，已决策：打散 + 注入）

1. `schema/customer.table.ts` 保留——Customer 对应一张用户表；
2. `customerToken = defineToken({ name: 'Customer', api: bleApi, app: bleWx, security: { secret: dtoField(stringField(...)), cipher: dtoField(stringField({ optional: true })) }, identity: { ...from(customer, [id, account, device, channel, phone]) } })`——POS 服务器映射的客户身份，单一事实来源；
3. **SubmitCpuRequest.customer 打散**：嵌套对象拆为顶层字段，从 Token 映射——`fromToken(customerToken, [...])` 展开 id/account/device/channel/phone 进 DTO，字段自动标记为服务器注入（客户端不传，运行时由 Token 填充，复用现有 `__inject` 机制、从"一个 id"扩展为"多个属性"）。**引用方式是 ref 而非共享实例**：每个字段 = 新建 `dtoField(...).setRef(customerToken.fields.xxx)`，DTO 层可再覆盖 optional/description，类型/约束继承 token 字段：
```ts
// dto_schema/ble-wx/ble-charge.dto.ts
export const SubmitCpuRequest = buildInput('SubmitCpuRequest', {
  ...fromToken(customerToken, [id, account, device, channel, phone]),  // 注入字段
  fee: dtoField(intField({ optional: false, label: '充值金额（分）' })),
  devId: dtoField(stringField({ maxLength: 32, optional: false, label: '设备号' })),
  // ...其余 POS 参数
});
```

4. `ble.utils.ts` 的 `customerParam` 内联删除，`getBsId/getOpId` 参数改引用 Token 字段；
5. `Customer` 这个独立 buildInput DTO 删除（无消费者，字段已并入 SubmitCpuRequest）。

### 4.5 目录与校验

- 存放：`token_schema/{api.name}/{app.name}/token/{name}.token.ts`——与 service_schema/dao_schema 同布局。
- **生成物（`pylonts gen token`）**：`{api.name}/src/modules/{app.name}/token/{name}Token.ts`——TypeBox schema + `Static` 类型（平面结构，identity 字段全 Optional），落 api 侧模块目录（auth 链 / 登录链生成器 import 它）；前端只持有 hash，不需要 token 类型。
- lint（`loadTokens` + `pylonts lint token`）：目录规则、一文件一 Token、**字段承载规则（两段字段必须是 dtoField；identity 段必须全部 `from()` 表列，可跨多张表，禁止内联字段；security 段固定内建 `secret`/`cipher`）**、**身份表硬约束（identity 段 from 的表必须含 `token` + `refresh_token` + `login_at` 列）**、security/identity 两段区分校验（identity 段字段不能出现在 security 段）、api+app 归属校验（app ∈ api.apps）。

## 5. 落地步骤

| 步骤 | 内容 | 依赖 |
|------|------|------|
| 1 | `dsl/src/token.ts`：`TokenSchema` + `defineToken`（api+app 归属、identity 段 `Record<string, DtoField>` 全 from 表列可跨表、security 段内建 secret/cipher、**身份表硬约束：identity 段 from 的表必须含 `token` + `refresh_token` + `login_at` 列**）+ 定义期校验 | 无 |
| 2 | `dsl/src/dto.ts`：**新增 `fromToken(token, fields)`**（不复用 `from`——机制不同：共享实例 vs ref 引用；每个字段新建 DtoField + `setRef(token 字段)` + 标记服务器注入）；`typebox-driver` 补 ref 渲染（沿 ref 链递归展开类型/约束，防环）+ 渲染注入元数据 | 1 |
| 3 | `pylon/src/validation.ts`：`InjectFn` 从 `(body, user)` 扩展为 `(body, token)`，支持多属性映射；生成物消费 | 1 |
| 4 | 类型生成：TypeBox schema + TS 类型——生成物落 **api 侧** `{api}/src/modules/{app}/token/{name}Token.ts`（`pylonts gen token`，平面结构：security 按声明、identity 全 Optional；auth 链/登录链消费；前端只持有 hash，不需要 token 类型） | 1、2 |
| 5 | Redis 存储：token → **平面对象**（**key = `{app_name}.{token}`** app 级命名空间，两段字段合并，TTL 30 分钟 + refreshToken 刷新） | 无 |
| 6 | `pylon-fastify` auth 链**合并改造**：**app 级登录入口**（admin 登录 / 微信登录，入口即登录：login 生成 hash + secret/cipher + 身份附着，无公共签发接口）/ **`@LoginEntry(app?)` 标记登录入口 controller**（login/refresh 方法），auth 链按入口 + body 是否携带 refreshToken 区分验证模式（业务 x-token + Redis secret / login 初始密钥 / refresh 派生密钥）/ **`@Login` 装饰器不变**，module_name 在登录入口定义（= app 名），auth 链校验按 token 的 app 归属（key 前缀）/ SignatureStrategy 并入对象还原（验签材料取自平面对象 secret/cipher，timestamp + nonce 防重放保留；无 token 请求用初始密钥）/ login 按声明读表附着身份字段 + 单点确认 / refresh 接口（body 上送 refreshToken，派生密钥验签）/ JwtStrategy + signToken + blacklist 退役；**`@Public` 保留**（原语义：标记的接口不检查签名） | 1、5 |
| 6a | **等保三硬约束落地**：① 双因子登录（口令 + 签名密钥，禁止纯口令）；② 登录失败锁定（≤5 次 / 锁 ≥30 分钟）；③ Redis 安全加固（签名密钥加密存储、密码认证、访问控制、超时断开）；审计日志独立持久化（Redis 不作审计载体） | 6 |
| 7 | 登录链生成（cli admin-login / sign）产出 Token 构造代码 | 4、6 |
| 8 | 存储规则 + lint：`loadTokens` + `pylonts lint token` | 1 |
| 9 | BLE Customer 落地（标准验收用例）：customer 表 + CustomerToken + SubmitCpuRequest 打散注入，删除 utils/DTO 重复 | 1、2、8 |

## 6. 决策状态

| # | 决策点 | 状态 |
|---|--------|------|
| 1 | **体系定位** | **已决策：推翻"HMAC 签名 + JWT 登录"双层体系，合并为 token 令牌单层**——签名/加密材料与身份统一由两态对象承载 |
| 2 | Token 字段来源 | **已决策：identity 段全部来源于表，可以是多张表**（禁止内联字段）；**security 段内建固定字段（不挂钩表）**——`secret`（签名密钥，必选）+ `cipher`（加密密钥，可选），获取 token 接口生成的随机材料，存 Redis 对象无表落点 |
| 3 | **两段结构** | **已决策：`security` 段（签名/加密数据，未登录即有）+ `identity` 段（身份数据，登录后附着）**——两态对象，token 仅对象引用；**两段是声明期组织，运行时对象为平面结构**（所有字段合并，`token.secret` / `token.id` 直接访问，无嵌套） |
| 4 | **token 形态** | **已决策：纯随机 hash**——只有令牌信息、不可解析、无 payload/exp，JWT 完全退役 |
| 5 | 多来源（jwt/hmac/third） | **已决策：不需要**——Token 就是用户身份，单一概念，归属模块确定 |
| 6 | 身份有效数据确定时机 | **已决策：login 时**——登录时从表读取 identity 段字段附着到 Redis 已有对象；login 返回业务数据（不一定需要） |
| 7 | 安全材料字段 | **已决策：security 段内建 `secret`（签名密钥，必须）+ `cipher`（加密密钥，可选），不挂钩表**——获取 token 时生成（随机材料，存 Redis 对象），验签/加解密消费对象 |
| 8 | **客户端使用规则** | **已决策：token 每次上送（唯一例外：获取 token 接口）；业务接口默认都签名，`@Public` 保留**（原语义：标记的接口不检查签名；获取 token 等无 token 请求用**初始密钥方案 2**：固定密钥 + 时间窗口约定算法，两端独立计算） |
| 9 | `SubmitCpuRequest.customer` 处理 | **已决策：打散融入 DTO + 服务端变量注入**（`fromToken(token, [...])` 多属性映射，复用/扩展现有 `__inject` 机制） |
| 10 | **存储选型** | **已决策：Redis**（token → 通讯信息 + 身份信息） |
| 11 | **刷新机制** | **已决策：token 有效期 30 分钟；refreshToken 只在 login 时返回**（获取 token 接口不返回）；**标准接口 = login + refresh，未登录浏览场景加获取 token 接口共 3 个**；**token + refreshToken + 登录时间（login_at）都存账户表；login 时单点确认——查用户表旧 token、删除对应 Redis 数据、直接生成新 token + refreshToken 写回（上送匿名 token 则一并删除）**；**refreshToken 有效期 7 天（可配置，默认 7 天），滑动窗口——login_at 在 login 与每次 refresh 时更新，7 天从最近一次登录/刷新起算，过期即失效必须重新 login**；**轮换策略已决策：每次刷新轮换**——refresh 重新生成 token + refreshToken 写回账户表（旧 refreshToken 立即作废），客户端以 refresh 响应更新会话；refresh 同时更新 login_at（7 天滑动窗口）；**refreshToken 上送载体 = refresh 请求体**（`{ refreshToken }`，不经 header，login 只上送 username/password）；**refresh 校验账号状态（禁用拒绝）+ 查表不取 password**（RefreshRow 与 LoginRow 分离）；**客户端 refresh 为内部固定流程**——不暴露公共 refresh 方法、不生成前端 refresh 函数（api-client 内部自动刷新） |
| 12 | **Token 字段承载与 DTO 引用方式** | **已决策：TokenSchema 内部装 dtoField**——两段 `Record<string, DtoField>`；**identity 段用 `from(表, 列)` 投影**（禁止裸内联 Field），**security 段内建 `secret`/`cipher` 不挂钩表**；**DTO 引用 token 字段走 `fromToken(token, [...])`（ref 机制）**——为每个字段新建 DtoField + `setRef(token 字段)`（复用类型/约束、实例独立，buildMessage 反写不污染 token 字段），typebox-driver 沿 ref 链递归展开渲染（防环），字段自动标记服务器注入（复用/扩展 `__inject`）；**注入访问走平面对象**——`body.id = token.id`，两段字段同一平面，不分段访问 |
| 13 | **身份表硬约束** | **已决策：identity 段 from 的表（账户表）必须包含 `token` + `refresh_token` + `login_at` 三列（硬约束，定义期报错）**——token 体系运行时把会话凭据（token + refreshToken + 登录时间）写账户表，表缺列则体系不成立；`login_at` 是 refreshToken 过期（7 天）起算的运行时依赖；与决策 #11 配套 |
| 14 | **签发范围与 Redis 键** | **已决策：token 与 app 强相关，签发接口 app 级**——每个 app 定义自己的登录入口（admin/微信入口即登录），login 直接生成 hash + 安全材料 + 身份附着；**匿名"获取签名"接口已实现（2026-08-19 MVP，gen-login sign 形态）**——签发无身份 token（security-only，无 refreshToken，响应只外发 secret），未登录浏览场景；后续真实登录升级此 token（rotateToken + attachIdentity）；**Redis key = `{app_name}.{token}`**（app 命名空间隔离，防不同 app token 串扰）；**登录入口 controller 用 `@LoginEntry(app?)` 标记**（login/refresh/sign 入口方法）；**`@Login` 装饰器语义不变**（业务接口登录校验），module_name 在各登录入口定义（= app 名），兼作 Redis key 前缀 |
| 15 | **业务请求签名材料** | **已决策（过渡）：login 响应下发 secret**——客户端持 secret 签名业务请求，服务端按 `{app_name}.{token}` 还原对象取 secret 验签；**派生密钥（HMAC(token, 固定盐) 等，不下发 secret）为后续工作**，当前体系不做 |