@pylonts/dsl 1.1.16 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/wechat.md ADDED
@@ -0,0 +1,235 @@
1
+ # 微信小程序登录体系
2
+
3
+ > 状态:**定稿(2026-08-19)**
4
+ > 关联文档:[token.md](./token.md)(token 令牌体系,底座)、[gen-login.md](./gen-login.md)(登录链生成器统一设计)、[docs/third-party.md](../../docs/third-party.md)(第三方调用模式)
5
+ > 定位:token 令牌体系在微信小程序端的登录入口具体化——两种登录形态(用户端静默登录 / 商户端静默+账密登录),均走 app 级登录入口(token.md 决策 #14:入口即登录)
6
+
7
+ ## 1. 背景:小程序登录的两种形态
8
+
9
+ 微信小程序有两种业务身份,登录形态不同:
10
+
11
+ | 形态 | 用户 | 凭据 | 交互 |
12
+ |------|------|------|------|
13
+ | **形态 1:静默登录** | C 端用户 | `wx.login()` 的 code(自动获取) | 无感,无任何输入 |
14
+ | **形态 2:静默 + 账密登录** | B 端商户 | 用户名 + 密码 + code | 登录页输入账号密码 |
15
+
16
+ 两个形态共用同一套 token 登录链(token.md),差别只在**登录入口的凭据与校验逻辑**;code(静默凭证)两个形态都走 `wx.login()` 获取。
17
+
18
+ ## 2. 与 token 体系的关系
19
+
20
+ - **入口即登录(token.md 决策 #14)**:微信登录端点就是小程序 app 的登录入口——login 直接生成 token hash + secret + refreshToken + 附着身份,无公共签发接口;
21
+ - **会话凭据写账户表(决策 #13 硬约束)**:登录链的账户表必须含 `token` + `refresh_token` + `login_at` 三列;
22
+ - **单点确认(决策 #11)**:登录时查账户表旧 token → 删 Redis 旧对象 → 生成新 token + refreshToken 写回(后登录踢掉前登录);
23
+ - **refreshToken 7 天滑动窗口(决策 #9/#11)**:`login_at` 在 login 与每次 refresh 时更新;
24
+ - **refresh 每次轮换(决策 #11)**:refresh 重新生成 token + refreshToken 写回账户表,旧 refreshToken 立即作废不可重放;
25
+ - **wx 场景的推论**:小程序**每次进入都先登录**(形态 1 无感、形态 2 有态),refreshToken 始终是最新——7 天过期路径在 wx 端实际不可达,只在非 wx 客户端(admin/node)有意义。
26
+
27
+ ## 3. 小程序表标准(xx_wx)
28
+
29
+ ### 3.1 微信绑定表(xx_wx)标准
30
+
31
+ **职责**:微信 openid 绑定表——把"微信身份"(openid/unionid)与"业务身份"(业务账号)解耦。**不是身份表**:业务字段(余额/状态等)留在业务账号表,xx_wx 只做绑定。
32
+
33
+ **命名**:**`{app.name}_wx`**——xx = `project.config.ts` 的 **app name**(FrontAppSchema,kebab-case;表名 snake 化,如 app `mini-user` → 表 `mini_user_wx`、app `mini-verify` → 表 `mini_verify_wx`)。**一个小程序(app)只有一张 openid 表**——表与 app 一一对应,不按业务身份拆多张表。
34
+
35
+ **主键**:`(appid, openid)` **联合主键**——一张表一个主键,由两列组成(`PRIMARY KEY (appid, openid)`)。openid 唯一性以 appid 为命名空间:同一 appid 内唯一,跨 appid 微信不保证唯一,所以 appid 必须纳入主键。
36
+
37
+ | 列 | 类型 | 约束 | 说明 |
38
+ |----|------|------|------|
39
+ | `appid` | STRING(32) | **联合主键** | 微信小程序 appid(wx 开头)——openid 的唯一性以 appid 为命名空间;同一个小程序 app 可对应多个微信 appid(多环境/多入口) |
40
+ | `openid` | STRING(64) | **联合主键** | 微信身份锚点——**只在同一 appid 内唯一**(跨 appid 微信不保证唯一) |
41
+ | `{业务}_id` | 业务表 PK 类型 | 业务表 FK | 该 app 对应的业务身份,如 `user_id` / `merchant_id`;命名 = 业务表名单数 + `_id` |
42
+ | `unionid` | STRING(64) | 可空 | 跨小程序/公众号统一身份(绑定开放平台后才有)——多 appid 下同一自然人靠它关联 |
43
+ | `session_key` | STRING(255) | **可空** | 微信会话密钥,**加密落库**(见 3.3) |
44
+ | `nickname` | STRING(100) | 可空 | 微信昵称(最小化采集,可空) |
45
+ | `avatar_url` | STRING(500) | 可空 | 微信头像地址(最小化采集,可空) |
46
+ | `created_at` | DATETIME | 非空 | 创建时间 |
47
+ | `updated_at` | DATETIME | 非空 | 更新时间 |
48
+
49
+ **约定**:
50
+ - **一个小程序一张表**(表名 = app name + `_wx`)——即使该 app 下存在多个业务身份形态(如 C 端用户 + 平台运营同一个小程序),也收敛到一张 openid 表,用 `{业务}_id` 列区分/绑定;
51
+ - **主键 = `(appid, openid)` 联合**——openid 唯一性以 appid 为命名空间:同一 appid 内唯一,跨 appid 微信不保证(同一用户不同 appid 值不同);查 openid 必须带 appid 条件;同一个小程序 app 对应多个微信 appid 时(多环境/多入口),同一用户在每个 appid 下各自一行,按 unionid 关联同一自然人;
52
+ - 一个业务账号可有多行 openid(同一 user 多微信)?**否——默认一行一身份**(`(appid, openid)` 联合主键唯一,业务侧按需加唯一索引);
53
+ - 表必须 `paginated: true`(与其它业务表一致)。
54
+
55
+ ### 3.2 业务账号表三列硬约束(token 决策 #13)
56
+
57
+ 身份表(业务账号表,如 `user` / `merchant`)**必须包含**:
58
+
59
+ | 列 | 类型 | 说明 |
60
+ |----|------|------|
61
+ | `token` | STRING(64) | 当前会话 token hash(可空——未登录) |
62
+ | `refresh_token` | STRING(64) | 当前会话 refreshToken(可空) |
63
+ | `login_at` | DATETIME | 最近一次登录/刷新时间(7 天滑动窗口锚点,at 后缀命名约定) |
64
+
65
+ 定义期由 `defineToken` / `gen login` 硬校验(缺列报错)。
66
+
67
+ ### 3.3 session_key 加密落库(已决策)
68
+
69
+ - **落库**:微信官方建议不落库,但业务需要 getPhoneNumber 等开放数据解密时,session_key 是解密密钥——**必须落库**(否则每次 code2Session 换新值,解密时拿不到);
70
+ - **加密**:AES-256-GCM 加密后落库,**不落明文**(等保三级 8.1.4.8 存储加密要求);密钥由服务端配置管理;
71
+ - **不下发**:session_key 属于微信侧的敏感数据,**绝不返回前端**(微信官方约束,等保 8.1.4.10 最小化);前端只持有 code,解密在服务端完成;
72
+ - 每次登录更新(code2Session 每次返回新 session_key)——`updateSessionKey` 幂等写回。
73
+
74
+ ## 4. code2Session 标准库(@pylonts/wechat)
75
+
76
+ ### 4.1 定位
77
+
78
+ **纯微信对接库**,与本地业务、表、token 体系无关:
79
+
80
+ - 只对接微信服务端 API:`code2Session` + 开放数据解密(getPhoneNumber 等);
81
+ - 无 DSL schema、无生成器——直接可落地的 npm 包(src 直发模式,同 @pylonts/event / @pylonts/mock);
82
+ - 内部错误统一抛 `BusinessException`(与 @pylonts/fastify 桥接;ServiceException 保留给系统级重大问题,业务不抛)。
83
+
84
+ ### 4.2 API 面
85
+
86
+ ```ts
87
+ createWechatClient({ appId, appSecret, mock? }) → {
88
+ code2Session(code: string, appId?: string): Promise<{ openid: string; unionid: string | null; sessionKey: string }>;
89
+ // 多微信小程序 appid 时,code2Session 第二个参数覆盖默认 appId(表内 appid 列来自它)
90
+
91
+ // 手机号获取(扩展,非登录必需):POST /wxa/business/getuserphonenumber?access_token=...
92
+ getAccessToken(): Promise<string>; // 稳定版 stable_token,缓存 7200s
93
+ getPhoneNumber(code: string, openid?: string): Promise<{
94
+ phoneNumber: string; // 用户绑定的手机号(国外手机号带区号)
95
+ purePhoneNumber: string; // 无区号手机号
96
+ countryCode: string; // 区号(如 86)
97
+ }>; // code 一次性、5min 有效;openid 填了则校验 code 绑定关系
98
+
99
+ encryptSessionKey(plain: string, key: string): string; // session_key 落库加密辅助(AES-256-GCM,可选)
100
+ decryptSessionKey(enc: string, key: string): string;
101
+ }
102
+ ```
103
+
104
+ **getAccessToken 缓存设计(稳定版 stable_token)**:
105
+ - **接口**:`POST /cgi-bin/stable_token`(官方推荐——与旧 `GET /cgi-bin/token` 互相隔离、限频宽(1 万次/分钟、50 万次/天)、**有效期内重复调用不更新 token**);请求体 `{ grant_type: 'client_credential', appid, secret, force_refresh: false }`;
106
+ - **本地缓存(进程内 `Map<appId, { token, expiresAt }>`)**:命中未过期直接返回;未命中/过期才刷新——避免每次调用都走 HTTPS 往返 + 限频消耗;
107
+ - **不需要外部 store**:stable_token 有效期内重复调用不更新 token → 多实例各自本地缓存同一 token,天然不冲突(旧 getAccessToken 才需要中控/共享 store 防覆盖);
108
+ - **提前过期**:`expires_in - 300`(提前 5 分钟刷新,对齐官方"5 分钟内新旧 token 都可用");
109
+ - **并发去重**:single-flight——一个刷新进行中,其他调用等同一个 Promise;
110
+ - **多 appid**:缓存 key = appId(每个小程序一个 access_token);
111
+ - **force_refresh**:默认 false,暴露 `forceRefresh()` 可选(手动强制刷新场景,`force_refresh: true` 会使旧 token 失效)。
112
+
113
+ **TokenStore 接口**:不需要——stable_token 有效期内幂等,本地进程内 Map 缓存即可,多实例各自缓存同一 token 不冲突。
114
+
115
+ **手机号获取(getPhoneNumber)说明**:
116
+ - **服务器端调用**——前端 `<button open-type="getPhoneNumber">` 用户同意后拿 code,回传服务端换取手机号;不可前端直调微信接口;
117
+ - **不走解密**——新接口是 code + access_token 换取,**不需要 session_key / encryptedData 解密链路**(旧 `decryptPhoneNumber` 不做);
118
+ - **依赖 getAccessToken**(内部缓存 7200s);权限需非个人开发者 + 认证小程序,2023-08-28 起计费(0.03 元/次);
119
+ - 错误码:-1 重试 / 40013 appid 不匹配 / 40029 code 无效 / 45011 频率限制;
120
+
121
+ ### 4.3 错误码处理(吸收 discount-mall WechatClient 教训)
122
+
123
+ | errcode | 含义 | 处理 |
124
+ |---------|------|------|
125
+ | -1 | 系统繁忙 | 自动重试一次(再失败抛 BusinessException) |
126
+ | 40029 | code 无效/过期 | BusinessException(登录凭证无效或已过期) |
127
+ | 45011 | 频率限制 | BusinessException(请求过于频繁,请稍后重试) |
128
+ | 40125 | AppSecret 错误 | BusinessException(小程序 AppSecret 配置错误) |
129
+ | 其他 | — | BusinessException(微信登录失败: errmsg) |
130
+
131
+ - code 一次性 + 5 分钟有效(微信侧约束,库侧只需透传失败);
132
+ - `grant_type` 固定 `authorization_code`(库内部处理,调用方不感知)。
133
+
134
+ ### 4.4 mock 模式
135
+
136
+ `mock: true` 时 code2Session 返回确定性结果(`openid = 'mock_openid_' + code`、固定 sessionKey、unionid null),decrypt 返回可预测结构——本地开发/测试/CI 无真实 AppSecret 可用(同 discount-mall 现有实现,收编进库)。
137
+
138
+ ### 4.5 边界(不做)
139
+
140
+ - 不做旧开放数据解密(encryptedData + iv + session_key——微信已不推荐,新接口 code 换取不需要);
141
+ - 不做支付(@pylonts 支付能力独立评估);
142
+ - 不做云开发/云调用(绑定微信云托管,不符合自建服务器 + ts-libs 体系);
143
+ - 不定义 DSL schema(无生成器需求——调用方三行代码直连);
144
+ - 头像昵称:微信已取消静默获取(2022-10 后 getUserProfile 返回匿名),现行方案是前端 `open-type="chooseAvatar"` + `input type="nickname"` 用户主动填写——无服务端接口可对接,不在库范围内。
145
+
146
+ ## 5. 形态 1:C 端静默登录(定稿)
147
+
148
+ ### 流程
149
+
150
+ ```
151
+ 进入小程序(冷启动/onLoad)
152
+ → wx.login() 拿 code(一次性,5 分钟有效)
153
+ → POST /api/{app}/login/login { code } ← app 级登录入口(@LoginEntry('{app}'))
154
+ → 服务端:@pylonts/wechat code2Session(code, appId) 换 openid/session_key(appId 来自小程序配置)
155
+ → 按 openid + appid 查 {app}_wx(如 `mini_user_wx`)→ 无则创建业务账号 + openid 行(自动建档),有则更新 session_key(加密落库)
156
+ → 单点确认(查 user 表旧 token → 删 Redis 旧对象)
157
+ → 生成 token + secret + refreshToken → 写账户表(token/refresh_token/login_at)+ Redis
158
+ → 返回 { token, refreshToken, secret, user? }
159
+ ```
160
+
161
+ ### 凭据与身份
162
+
163
+ | 项 | 值 |
164
+ |----|----|
165
+ | 凭据 | code(微信侧一次性,5 分钟有效,不可重放) |
166
+ | 身份锚点 | openid({app}_wx PK,唯一) |
167
+ | 校验 | code2Session 失败 → 抛错(422);无用户 → 自动建档(静默注册) |
168
+
169
+ ### 会话生命周期
170
+
171
+ - **每次进入都登录** → refreshToken 恒新 → 7 天过期不可达;
172
+ - 运行中 token 30 分钟过期(Redis TTL)→ 自动 refresh(refreshToken 有效)→ 无感续期;
173
+ - refreshToken 失效(理论场景)→ 客户端 `onLoginRequired` → reLaunch 首页 → 重走静默登录 → 无感恢复,**不需要用户输入任何东西**。
174
+
175
+ ## 6. 形态 2:B 端静默 + 账密登录(定稿)
176
+
177
+ ### 流程
178
+
179
+ ```
180
+ 商户登录页:输入用户名 + 密码(页面 onLoad 同时 wx.login() 拿 code)
181
+ → POST /api/{app}/login/login { code, username, password }
182
+ → 服务端:@pylonts/wechat code2Session(code) 验证小程序环境 + 拿 openid
183
+ → 校验用户名 + 密码(bcrypt,复用 admin-login 链)
184
+ → 校验商户状态(非正常状态拒绝)
185
+ → openid 绑定 {app}_wx(如 `mini_verify_wx`)(bind 幂等:存在更新 session_key,不存在插入)
186
+ → 单点确认 → 生成 token + secret + refreshToken → 写账户表 + Redis
187
+ → 返回 { token, refreshToken, secret, user? }
188
+ ```
189
+
190
+ ### 凭据与身份
191
+
192
+ | 项 | 值 |
193
+ |----|----|
194
+ | 凭据 | 用户名 + 密码(主)+ code(环境验证 + openid 绑定) |
195
+ | 身份锚点 | 商户账号 id(商户表主键) |
196
+ | 校验 | 密码 bcrypt(失败锁定等保约束见 token.md §6a) |
197
+
198
+ ### code 的作用(已决策:环境验证 + 身份绑定)
199
+
200
+ - **环境验证**:code2Session 证明"请求来自微信小程序环境"(code 只能由 wx.login 在真机小程序环境产生);
201
+ - **身份绑定**:openid 写 merchant_wx 表(`openid` PK + `merchant_id` FK)——该微信与商户账号绑定,后续支持免密快捷登录。
202
+
203
+ ## 7. 登录入口与 refresh(定稿)
204
+
205
+ - **路径约定(app 前缀区分形态)**:`POST {contextPath}/api/{app}/login/login`(登录)+ `POST {contextPath}/api/{app}/login/refresh`(刷新)——app 即模块(`miniuser` / `miniverify`),C/B 端只是 app 不同,**路径模板同一**;
206
+ - **Controller**:`@Rpc('login')` + `@LoginEntry('{app}')`,两个方法 `login` / `refresh`(与 gen-admin-login 生成物同构——微信变体 = LoginRequest 增加 code 字段 + 登录服务前段插入 code2Session);
207
+ - **refresh 语义(token.md 决策 #11)**:请求体上送 refreshToken → 按 refreshToken 查账户表定位用户 → 校验账号状态 + 7 天滑动窗口 → 派生密钥验签 → 重新生成 token + refreshToken 写回 → 返回新会话;refresh 为客户端内部固定流程(api-client 自动处理,不暴露前端函数);
208
+ - **响应**:`{ token, refreshToken, secret, user? }`——三元组 + 业务数据(token.md 决策 #15:login 下发 secret,客户端持 secret 签名业务请求)。
209
+
210
+ ## 8. wx 客户端约束(进入必登录)
211
+
212
+ 1. **首页 onLoad / app onLaunch 固定走登录**(形态 1 无条件 wx.login → code → 登录端点;形态 2 无本地 session 时进登录页)——不能只在"本地无 session"时登录,否则 session 过期但被 store 保留时会拿旧 refreshToken 白试一次;
213
+ 2. **登录成功即整体替换会话**(token + refreshToken + secret 同构响应,extractSession 天然采纳);
214
+ 3. **`onLoginRequired` → reLaunch 首页** → 首页重走登录(wx 端是静默恢复,不是让用户输密码);
215
+ 4. **refreshToken 恒新假设成立的前提是"进入必登录"**——违反则 wx 端退化到 7 天过期路径;
216
+ 5. **会话存储**:storage 存三元组 `{ token, refreshToken, secret }`(业务请求 secret 签名 + token 上送;不再有旧 HMAC key/issue 凭证——token 体系无公共签发接口)。
217
+
218
+ ## 9. 与现有实现的差距(discount-mall 基线)
219
+
220
+ | 能力 | 现状(discount-mall) | 需要 |
221
+ |------|----------------------|------|
222
+ | code2Session 调用 | 手写 `WechatClient`(mock/错误码/重试已具备) | 收编为 `@pylonts/wechat`(标准库) |
223
+ | session_key 落库 | 明文(`session_key` 列) | AES-256-GCM 加密落库 |
224
+ | 登录入口 | `@Public()` + `signToken()`(JWT 旧体系) | `@LoginEntry('{app}')` + token 登录链(login/refresh 两方法,`gen login` 生成) |
225
+ | 登录响应 | `{ token }`(JWT 字符串) | `{ token, refreshToken, secret }` 三元组 |
226
+ | 前端会话 | `key/issue` + `key/refresh`(旧 HMAC 凭证)+ JWT token | 三元组存储 + secret 签名 + 自动 refresh |
227
+ | user/merchant 表 | 无三列 | 补 `token` + `refresh_token` + `login_at` |
228
+
229
+ ## 10. 开放问题(收敛后)
230
+
231
+ 1. ~~形态 2 的 code 用途~~ → **已决策:环境验证 + 身份绑定(选项 B)**;
232
+ 2. ~~形态 1 的身份表~~ → **已决策:xx_wx 只做绑定(表名 = app name + `_wx`,一个小程序一张),业务账号表是身份表**(user / merchant 等);
233
+ 3. ~~生成器形态~~ → **已决策:无独立 wx-login 生成器——`gen login` 升级为 `gen login`([gen-login.md](./gen-login.md)),按 app 类型分叉(admin 账密 / wxmini silent / wxmini password)**;
234
+ 4. ~~形态 2 的商户表~~ → **已决策:复用 admin-login 的账户表(加 code 校验)**,不独立商户登录表;
235
+ 5. ~~`@pylonts/wechat` 扩展范围~~ → **已决策:MVP 只做登录必需(code2Session);手机号获取(getAccessToken + getPhoneNumber)接口已确认(官方文档),列为扩展——实现排在登录之后**;头像昵称无服务端接口,不做。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,7 +41,7 @@
41
41
  "author": "",
42
42
  "license": "MIT",
43
43
  "dependencies": {
44
- "@pylonts/core": "^1.1.3"
44
+ "@pylonts/core": "^1.1.4"
45
45
  },
46
46
  "devDependencies": {
47
47
  "typescript": "^7.0.2",
package/src/convert.ts CHANGED
@@ -1,22 +1,22 @@
1
1
  import type { CollectionSchemaBase, SchemaBase } from './dsl.js';
2
2
  import type { DtoMessage } from './dto.js';
3
- import type { TableSchema } from './db.js';
4
3
  import type { EntitySchema } from './entity.js';
5
4
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
6
5
 
7
6
  // Schema-collection integration: multiple source collections combine into
8
- // one target collection (e.g. two entity tables into one dto, or entity
9
- // columns plus a dto into one third-party wire message).
7
+ // one target collection (e.g. two entities into one dto, or entity columns
8
+ // plus a dto into one third-party wire message).
10
9
  //
11
- // A convert file binds to ONE source identity — a table (internal mapping,
10
+ // A convert file binds to ONE source identity — an entity (internal mapping,
12
11
  // {Table}Convert.ts) or a third-party service (anti-corruption translation,
13
12
  // {third-service}.convert.ts) — and holds N methods keyed by name (same
14
- // shape as defineService / defineDao).
13
+ // shape as defineService / defineDao). Table schemas are not a convert
14
+ // source: tables must be projected into an entity (row contract) first.
15
15
 
16
- /** A source/target collection of a convert — dto, entity or table. Entity
17
- * sources may carry aggregate fields (aggField), e.g. an aggregate result
18
- * entity projected into a wire message. */
19
- export type ConvertSourceSchema = DtoMessage | TableSchema | EntitySchema;
16
+ /** A source/target collection of a convert — dto or entity. Entity sources
17
+ * may carry aggregate fields (aggField), e.g. an aggregate result entity
18
+ * projected into a wire message. */
19
+ export type ConvertSourceSchema = DtoMessage | EntitySchema;
20
20
 
21
21
  /** Method input for defineConvert: type/schema/name are set by the builder. */
22
22
  export type ConvertMethodDef = Omit<ConvertMethodSchema, 'type' | 'schema' | 'name'>;