@blod-nm/user-kit 0.0.0-stage → 0.1.0

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/README.md CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # @blod-nm/user-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CloudBase 用户中心能力套件:登录 / 注册 / 会话 + 用户建档,挂在统一的能力总线上(与网关能力共用同一个 `ctx`)。
4
+
5
+ ```js
6
+ import { createKit } from '@blod-nm/user-kit'
7
+
8
+ const kit = createKit({
9
+ envId: 'cloud1-xxxx',
10
+ apiKey: '网关 API Key',
11
+ wxAppid: 'wx1234567890',
12
+ wxSecret: '微信小程序 secret',
13
+ tcbAuthCredentials: '自定义登录私钥(JSON 文案或文件路径)',
14
+ env: 'prod',
15
+ })
16
+
17
+ await kit.ctx.run('user.ensureSchema', {}) // 新环境初始化一次
18
+ const { uid, access_token } = await kit.ctx.run('auth.loginByWxcode', { code })
19
+ ```
20
+
21
+ ## 安装
22
+
23
+ ```bash
24
+ bun add @blod-nm/user-kit
25
+ # 依赖:@blod-nm/base-cb、@blod-nm/base-ctx、@cloudbase/node-sdk
26
+ ```
27
+
28
+ ## 配置
29
+
30
+ | 字段 | 必填 | 默认 | 说明 |
31
+ | --- | --- | --- | --- |
32
+ | `envId` | ✓ | — | CloudBase 环境 ID |
33
+ | `apiKey` | ✓ | — | 网关 API Key(用于数据库能力,建议管理员权限) |
34
+ | `wxAppid` | ✓ | — | 微信小程序 appid |
35
+ | `wxSecret` | ✓ | — | 微信小程序 secret |
36
+ | `tcbAuthCredentials` | ✓ | — | 自定义登录私钥:JSON 文案或文件路径 |
37
+ | `env` | — | `'prod'` | 写入建档标签 `from_env:<env>` |
38
+ | `domain` | — | `api.tcloudbasegateway.com` | 网关域名 |
39
+ | `proxy` / `proxyBase` | — | `false` / — | 是否经代理访问网关 |
40
+ | `timeoutMs` | — | — | 请求超时(毫秒) |
41
+
42
+ ## 能力
43
+
44
+ ### 登录与注册
45
+
46
+ | 方法 | 参数 | 返回 |
47
+ | --- | --- | --- |
48
+ | `auth.loginByWxcode` | `{ code }` | `{ uid, openid, unionid, access_token, refresh_token, expires_in, config }` |
49
+ | `auth.loginByUnionid` | `{ unionid }` | `{ uid, unionid, access_token, refresh_token, expires_in }` |
50
+ | `auth.loginByPassword` | `{ username, password }` | 网关响应 + `uid` |
51
+ | `auth.loginBySms` | `{ verification_token }` | 网关响应 + `uid` |
52
+ | `auth.loginByEmail` | `{ verification_token }` | 网关响应 + `uid` |
53
+ | `auth.signup` | `{ phone_number \| email, verification_token, username?, password? }` | 网关响应 + `uid` |
54
+
55
+ - 自定义登录只有两个入口:`loginByWxcode`(配置的小程序凭据换 code)与 `loginByUnionid`(外部传入的唯一 id)。
56
+ `unionid` 是调用方的唯一用户 id,与微信的 openid/unionid 字段无关。
57
+ - 登录成功的返回里,`uid` 是本系统内的唯一标识;后续用户能力都用它定位档案。
58
+
59
+ ### 验证码 / 会话
60
+
61
+ | 方法 | 参数 | 说明 |
62
+ | --- | --- | --- |
63
+ | `auth.sendVerification` | `{ phone \| email, email_redirect_to? }` | 发送验证码(`auth.sendSmsCode` / `auth.sendEmailCode` 为别名) |
64
+ | `auth.verifyCode` | `{ verification_id, verification_code }` | 校验验证码,返回 `verification_token` |
65
+ | `auth.refresh` | `{ refresh_token }` | 返回 `{ access_token, refresh_token, expires_in }` |
66
+ | `auth.reauthenticate` | `{ access_token, verify_opt }` | 敏感操作二次验证(`phone_code` \| `email_code`) |
67
+ | `auth.introspect` | `{ access_token }` | 校验令牌,返回含 `sub` |
68
+ | `auth.signOut` | `{ access_token }` | 退出登录 |
69
+
70
+ ### 用户中心
71
+
72
+ | 方法 | 参数 | 说明 |
73
+ | --- | --- | --- |
74
+ | `user.me` | `{ access_token }` | 当前用户信息 |
75
+ | `user.updateBasic` | `{ access_token, nickname?, username?, phone?, email?, avatar_url?, gender?, description?, verification_token? }` | 修改资料;**添加/修改手机号、邮箱用这条** |
76
+ | `user.changePassword` | `{ access_token, ... }` | 修改密码(需先 `reauthenticate`) |
77
+ | `user.deleteMe` | `{ access_token, ... }` | 注销账号(需先 `reauthenticate`) |
78
+ | `user.get` | `{ uid }` | 读档案,无记录返回 `null` |
79
+ | `user.upsert` | `{ uid, profile? }` | 建档 / 合并档案(一般由登录自动调用) |
80
+ | `user.patchProfile` | `{ uid, patch }` | 局部合并用户档案 |
81
+ | `user.schemaStatus` | `{}` | 查看建档表与索引状态 |
82
+ | `user.ensureSchema` | `{}` | 初始化建档表与索引(幂等,新环境跑一次) |
83
+
84
+ ## 行为约定
85
+
86
+ - **一次登录自动建档**:登录/注册成功后自动写入用户档案;重复登录只合并身份字段,不会覆盖昵称、手机号等资料。
87
+ - **档案更新**:`user.updateBasic` 成功后自动同步档案;手机号/邮箱变更需按平台要求先获取验证码。
88
+ - **唯一性**:每个用户一条档案(`uid` 唯一,逻辑删除的行不占用唯一性)。
89
+ - **自定义登录**:`loginByWxcode` 与 `loginByUnionid` 最终走同一条自定义令牌链路;前者多一步"用 code 换唯一 id"。
90
+ - **不做**:第三方 OAuth 登录/绑定、人机验证码、匿名登录、企业 SSO。
91
+
92
+ ## 错误
93
+
94
+ 错误对象形如 `Error { message, code, payload? }`。
95
+
96
+ | code | 场景 |
97
+ | --- | --- |
98
+ | 400 | 参数缺失 / `access_token` 无效 / 网关参数错误(`payload` 为网关原始响应) |
99
+ | 401 | 未登录或令牌失效(网关原样返回) |
100
+ | 403 | 用户不可用(档案被禁用或已删除) |
101
+ | 500 | 建档失败(`用户注册失败`) |
102
+ | 502 | 网络异常(`登录请求失败` / `自定义登录请求失败` / `令牌刷新请求失败`) |
103
+
104
+ 网关返回的非 2xx 一律原样透传 `{ code: 网关状态码, payload }`,不改写网关错误码。
105
+
106
+ ## 示例
107
+
108
+ ```js
109
+ // 小程序登录
110
+ const r = await kit.ctx.run('auth.loginByWxcode', { code })
111
+
112
+ // 开放登录(合作方唯一 id)
113
+ const r2 = await kit.ctx.run('auth.loginByUnionid', { unionid: 'partner-42' })
114
+
115
+ // 手机号验证码登录
116
+ const { verification_id } = await kit.ctx.run('auth.sendVerification', { phone: '+86 13800000000' })
117
+ const { verification_token } = await kit.ctx.run('auth.verifyCode', { verification_id, verification_code: '000000' })
118
+ await kit.ctx.run('auth.loginBySms', { verification_token })
119
+
120
+ // 用户中心
121
+ const me = await kit.ctx.run('user.me', { access_token: r.access_token })
122
+ await kit.ctx.run('user.updateBasic', { access_token: r.access_token, nickname: '张三' })
123
+ const archive = await kit.ctx.run('user.get', { uid: r.uid })
124
+ ```
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@blod-nm/user-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "CloudBase user-center kit: auth (wechat code / unionid / password / sms / email / signup) + crm_user_detail archive on @blod-nm/base-cb.",
7
+ "license": "MIT",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "cloudbase",
20
+ "wechat",
21
+ "auth",
22
+ "capability",
23
+ "kit"
24
+ ],
25
+ "dependencies": {
26
+ "@blod-nm/base-ctx": "^0.1.1",
27
+ "@blod-nm/base-cb": "^0.2.0",
28
+ "@cloudbase/node-sdk": "^3"
29
+ },
30
+ "scripts": {
31
+ "test": "bun test test/",
32
+ "test:e2e": "bun test e2e/",
33
+ "test:e2e:proxy": "CBKIT_E2E=proxy bun test e2e/"
34
+ }
35
+ }
package/src/auth.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * auth.js — auth 域能力目录(8 条)+ 编排(登录链路/错误翻译 · spec §3.1–3.5 · §3.7)
3
+ *
4
+ * 端点(主场实测确认,{origin}=https://<envId>.api.tcloudbasegateway.com):
5
+ * POST /auth/v1/verification { phone | email } → 发验证码,返回 verification_token(响应原样)
6
+ * POST /auth/v1/signin { username,password | verification_token } → 基础登录(响应原样 + 补 uid + 建档)
7
+ * POST /auth/v1/signin/custom { provider_id:'custom', ticket } → 自定义登录(uid 链收尾)
8
+ * POST /auth/v1/token { grant_type:'refresh_token', refresh_token, client_id:envId }
9
+ * POST /auth/v1/verification/verify { verification_id, verification_code } → verification_token
10
+ * POST /auth/v1/user/reauthenticate(Bearer);GET /auth/v1/token/introspect(Bearer)
11
+ *
12
+ * 语义:
13
+ * · 透传三种(password/sms/email):body 原样、响应原样返回(不裁剪不翻译,只补 uid)+ 成功后建档。
14
+ * uid 提取:payload.user.id 优先,否则 decodeJWT(access_token).sub(不验签,token 刚由网关签发);
15
+ * 都取不到 → 500「用户注册失败」。建档失败 → 同 500。
16
+ * · 网关非 2xx:原样透传 { code: 网关状态, payload },不改写错误码(INVALID_USERNAME_OR_PASSWORD 原样可见);
17
+ * 网络异常(无 status)→ 502「登录请求失败」。
18
+ * · 自定义链路:customSignin 非 2xx → 502「自定义登录请求失败」;code2Session errcode 非零 → 400{errcode,errmsg}。
19
+ * · loginByWxcode:uid=唯一 id(wxResolveUid 缺省 identity:uid=openid),unionid 字段=微信原始值|null,不可互换。
20
+ *
21
+ * §3.12 补全: auth.signup(手机/邮箱验证码注册,响应原样+补 uid+建档)/ auth.signOut(不写档)。
22
+ * 自定义登录只有两个入口(钉死):loginByWxcode(配置的微信小程序 appid/secret,code→openid→唯一 id→ticket→signin/custom)
23
+ * 与 loginByUnionid(外部传唯一 id→ticket→signin/custom);二者都必须建档;unionid=我们的唯一 id,与微信 openid/unionid 语义无关。
24
+ *
25
+ * §3.13 uid 提取顺序(统一所有登录/注册类): payload.sub → payload.user.id → decodeJWT(access_token).sub →
26
+ * 无则登录/注册类 500「用户注册失败」(token 类走 uidFromToken 400「access_token 无效」)。
27
+ *
28
+ * §3.8 增补(4 条): auth.sendVerification(phone/email 二选一;sendSmsCode/sendEmailCode 保留薄别名)/
29
+ * auth.verifyCode / auth.reauthenticate(verify_opt ∈ phone_code|email_code)/ auth.introspect ——
30
+ * 返回一律网关原样;错误走 throwGatewayError(非 2xx 原样 / 网络 502「登录请求失败」)。
31
+ *
32
+ * 注入面(9 个,缺省=真实现,单测零网络): sendVerification / signinRequest / code2Session / wxResolveUid /
33
+ * createTicket / customSignin / refreshTokenRequest / fetchPrefixConfig / gateway(method, path, opts)。
34
+ * @cloudbase/node-sdk(ticket 签发,Node 侧)只在本文件 import(A3 grep 白名单)。
35
+ */
36
+ import cloudbase from '@cloudbase/node-sdk'
37
+ import { code2Session as wxCode2Session } from './wechat.js'
38
+ import { decodeJWT, throwGatewayError, makeGateway, uidFromToken } from './user.js'
39
+
40
+ export { decodeJWT } // 供消费方/测试从包主入口取(真身在 user.js)
41
+
42
+ const DEFAULT_DOMAIN = 'api.tcloudbasegateway.com'
43
+ const TICKET_TTL = { refresh: 7 * 24 * 3600 * 1000, expire: 30 * 24 * 3600 * 1000 }
44
+ const err = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra })
45
+
46
+ /* —— tcb 私钥/票(懒加载缓存;私钥只在内存解析,不落日志)—— */
47
+
48
+ export async function loadCredentials(raw, envId) {
49
+ const value = String(raw || '').trim()
50
+ if (!value) throw new Error('缺少自定义登录私钥:tcbAuthCredentials(JSON 文案或文件路径,按 { 开头自动识别)')
51
+ // 文件路径形态用动态 import 取 fs(保持静态 import 面干净;spec 允许两种形态)
52
+ const text = value.startsWith('{') ? value : await (await import('node:fs/promises')).readFile(value, 'utf-8')
53
+ const credentials = JSON.parse(text)
54
+ // 控制台下载的私钥字段名 private_key_id → SDK 需要 key_id;短形态缺 env_id 段用 cfg.envId 补
55
+ if ('private_key_id' in credentials && !('key_id' in credentials)) credentials.key_id = credentials.private_key_id
56
+ if (!credentials.key_id || !credentials.private_key) throw new Error('credentials must include key_id and private_key')
57
+ if (!credentials.env_id) credentials.env_id = envId
58
+ return credentials
59
+ }
60
+
61
+ let tcbAppCache = null
62
+ async function tcbCreateTicket(cfg, uid) {
63
+ if (!tcbAppCache) {
64
+ const credentials = await loadCredentials(cfg.tcbAuthCredentials, cfg.envId)
65
+ tcbAppCache = cloudbase.init({ env: credentials.env_id, credentials })
66
+ }
67
+ // ★ TTL 与现网一字不差(refresh 7d / expire 30d,相对毫秒;gateway 对这两个值有兜底解释,改了就变)
68
+ return tcbAppCache.auth().createTicket(uid, TICKET_TTL)
69
+ }
70
+
71
+ /* —— 能力目录 + 编排(ability 闭包持有 cfg/inject/kit)—— */
72
+
73
+ export function createAuthAbilities(cfg, kit) {
74
+ const inject = cfg.inject || {}
75
+ const origin = `https://${cfg.envId}.${cfg.domain || DEFAULT_DOMAIN}`
76
+ const sendVerification = inject.sendVerification || (body => kit.req(`${origin}/auth/v1/verification`, { method: 'POST', body }))
77
+ const signinRequest = inject.signinRequest || (body => kit.req(`${origin}/auth/v1/signin`, { method: 'POST', body }))
78
+ const customSignin = inject.customSignin || (ticket => kit.req(`${origin}/auth/v1/signin/custom`, { method: 'POST', body: { provider_id: 'custom', ticket } }))
79
+ const code2Session = inject.code2Session || (code => wxCode2Session(cfg, code))
80
+ const wxResolveUid = inject.wxResolveUid || (wx => wx.openid) // 缺省 identity:uid=openid(现网语义)
81
+ const createTicket = inject.createTicket || (uid => tcbCreateTicket(cfg, uid))
82
+ const refreshTokenRequest = inject.refreshTokenRequest || (body => kit.req(`${origin}/auth/v1/token`, { method: 'POST', body }))
83
+ const fetchPrefixConfig = inject.fetchPrefixConfig || (() => kit.req(`${origin}/v1/storages/object/store/public/wx_mp_conf/prefix_config.json`, { headers: { 'Cache-Control': 'no-cache' } }))
84
+ const gateway = makeGateway(cfg, kit)
85
+ const gw = async (method, path, opts) => { try { return await gateway(method, path, opts) } catch (e) { throwGatewayError(e) } }
86
+ // verification 端点:旧注入口优先(sendSmsCode/sendEmailCode/sendVerification 共用)
87
+ const postVerification = body => inject.sendVerification ? inject.sendVerification(body) : gw('POST', '/auth/v1/verification', { body })
88
+
89
+ // 自定义链收尾(wxcode / unionid 共用):createTicket → signin/custom
90
+ async function finishLogin(uid) {
91
+ const ticket = await createTicket(uid)
92
+ let res
93
+ try { res = await customSignin(ticket) } catch { throw err(502, '自定义登录请求失败') }
94
+ return { access_token: res.access_token, refresh_token: res.refresh_token, expires_in: res.expires_in }
95
+ }
96
+
97
+ // 建档统一翻 500「用户注册失败」
98
+ async function upsertOr500(args) {
99
+ try { await kit.ctx.run('user.upsert', args) } catch { throw err(500, '用户注册失败') }
100
+ }
101
+
102
+ // uid 提取四级顺序(§3.13):payload.sub → payload.user.id → decodeJWT(access_token).sub
103
+ const uidOfPayload = p => p?.sub || p?.user?.id || decodeJWT(p?.access_token).sub || null
104
+
105
+ // 透传三种共用:body 原样发网关,响应原样返回(只补 uid)+ 成功后建档
106
+ async function passthroughLogin(body, required) {
107
+ for (const field of required) {
108
+ if (!body?.[field]) throw err(400, `${field} 必填`)
109
+ }
110
+ let payload
111
+ try { payload = await signinRequest(body) } catch (e) { throwGatewayError(e) }
112
+ const uid = uidOfPayload(payload)
113
+ if (!uid) throw err(500, '用户注册失败')
114
+ await upsertOr500({ uid, profile: {} })
115
+ return { ...payload, uid }
116
+ }
117
+
118
+ return [
119
+ ['auth.sendVerification', { describe: '发验证码(phone/email 二选一):POST /auth/v1/verification,网关原样(含 verification_token)', run: async (args = {}) => {
120
+ const body = {}
121
+ for (const k of ['phone', 'email', 'email_redirect_to']) if (args[k] !== undefined) body[k] = args[k]
122
+ if (!body.phone && !body.email) throw err(400, 'phone 或 email 二选一必填')
123
+ return postVerification(body)
124
+ } }],
125
+
126
+ ['auth.sendSmsCode', { describe: '发短信验证码:sendVerification 薄别名(phone 必填)', run: async ({ phone } = {}) => {
127
+ if (!phone) throw err(400, 'phone 必填')
128
+ return postVerification({ phone })
129
+ } }],
130
+
131
+ ['auth.sendEmailCode', { describe: '发邮箱验证码:sendVerification 薄别名(email 必填)', run: async ({ email } = {}) => {
132
+ if (!email) throw err(400, 'email 必填')
133
+ return postVerification({ email })
134
+ } }],
135
+
136
+ ['auth.verifyCode', { describe: '验证码校验:POST /auth/v1/verification/verify,网关原样(含 verification_token)', run: async ({ verification_id, verification_code } = {}) => {
137
+ if (!verification_id) throw err(400, 'verification_id 必填')
138
+ if (!verification_code) throw err(400, 'verification_code 必填')
139
+ return gw('POST', '/auth/v1/verification/verify', { body: { verification_id, verification_code } })
140
+ } }],
141
+
142
+ ['auth.reauthenticate', { describe: '重认证发码:POST /auth/v1/user/reauthenticate(Bearer;verify_opt ∈ phone_code|email_code)', run: async (args = {}) => {
143
+ const { access_token, verify_opt } = args
144
+ uidFromToken(access_token)
145
+ if (!['phone_code', 'email_code'].includes(verify_opt)) throw err(400, 'verify_opt 必填(phone_code|email_code)')
146
+ const { access_token: _t, ...rest } = args
147
+ return gw('POST', '/auth/v1/user/reauthenticate', { body: rest, token: access_token })
148
+ } }],
149
+
150
+ ['auth.introspect', { describe: 'token 校验:GET /auth/v1/token/introspect(Bearer),网关原样(含 sub)', run: async ({ access_token } = {}) => {
151
+ uidFromToken(access_token)
152
+ return gw('GET', '/auth/v1/token/introspect', { token: access_token })
153
+ } }],
154
+
155
+ ['auth.loginByPassword', { describe: '账号密码登录(透传+建档):POST /auth/v1/signin { username, password },响应原样补 uid', run: async (args = {}) => passthroughLogin({ username: args.username, password: args.password }, ['username', 'password']) }],
156
+
157
+ ['auth.loginBySms', { describe: '短信验证码登录(透传+建档):POST /auth/v1/signin { verification_token }', run: async (args = {}) => passthroughLogin({ verification_token: args.verification_token }, ['verification_token']) }],
158
+
159
+ ['auth.loginByEmail', { describe: '邮箱验证码登录(与 SMS 同端点同实现,命名只表达调用意图)', run: async (args = {}) => passthroughLogin({ verification_token: args.verification_token }, ['verification_token']) }],
160
+
161
+ ['auth.loginByWxcode', { describe: '微信 code 登录全链:建档+自定义令牌+prefix_config(7 字段裸对象)', run: async (args = {}) => {
162
+ const { code } = args
163
+ if (!code || typeof code !== 'string') throw err(400, 'user-kit: 参数 code 必填')
164
+ let wx
165
+ try { wx = await code2Session(code) } catch { throw err(502, 'code2Session 请求失败') }
166
+ if (wx.errcode) throw err(400, wx.errmsg || 'code2Session 失败', { errcode: wx.errcode, errmsg: wx.errmsg || 'code2Session 失败' })
167
+ const uid = await wxResolveUid(wx)
168
+ await upsertOr500({ uid, profile: { openid: wx.openid, unionid: wx.unionid || null } })
169
+ return { uid, openid: wx.openid, unionid: wx.unionid || null, ...(await finishLogin(uid)), config: await fetchPrefixConfig() }
170
+ } }],
171
+
172
+ ['auth.loginByUnionid', { describe: '开放登录:唯一 id 注册登录(建档→ticket→signin/custom;5 字段,不含 config)', run: async (args = {}) => {
173
+ const { unionid } = args
174
+ if (!unionid) throw err(400, '缺少 unionid')
175
+ await upsertOr500({ uid: unionid, profile: {} })
176
+ return { uid: unionid, unionid, ...(await finishLogin(unionid)) }
177
+ } }],
178
+
179
+ ['auth.signup', { describe: '注册(手机/邮箱/provider_token 三形态):POST /auth/v1/signup,响应原样+补 uid+建档', run: async (args = {}) => {
180
+ if (!args?.verification_token) throw err(400, 'verification_token 必填')
181
+ if (!args?.phone_number && !args?.email && !args?.provider_token) throw err(400, 'phone_number/email/provider_token 必填其一')
182
+ const payload = await gw('POST', '/auth/v1/signup', { body: args })
183
+ const uid = uidOfPayload(payload)
184
+ if (!uid) throw err(500, '用户注册失败')
185
+ const profile = {} // 建档只放传入的可入档字段(username/email/phone 有则带),不编造
186
+ if (args.username !== undefined) profile.username = args.username
187
+ if (args.email !== undefined) profile.email = args.email
188
+ if (args.phone_number !== undefined) profile.phone = args.phone_number
189
+ await upsertOr500({ uid, profile })
190
+ return { ...payload, uid }
191
+ } }],
192
+
193
+ ['auth.signOut', { describe: '登出:POST /auth/v1/user/signout(Bearer),不写档', run: async (args = {}) => {
194
+ const { access_token, ...rest } = args ?? {}
195
+ uidFromToken(access_token)
196
+ return gw('POST', '/auth/v1/user/signout', { body: rest, token: access_token })
197
+ } }],
198
+
199
+ ['auth.refresh', { describe: 'refresh_token 换新令牌(3 字段裸对象)', run: async (args = {}) => {
200
+ const { refresh_token } = args
201
+ if (!refresh_token) throw err(400, '缺少 refresh_token')
202
+ let res
203
+ try { res = await refreshTokenRequest({ grant_type: 'refresh_token', refresh_token, client_id: cfg.envId }) } catch { throw err(502, '令牌刷新请求失败') }
204
+ return { access_token: res.access_token, refresh_token: res.refresh_token, expires_in: res.expires_in }
205
+ } }],
206
+ ]
207
+ }
package/src/index.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * user-kit — user/auth 域能力套件(base-cb 装载叠加 → Ctx 总线)
3
+ *
4
+ * 主出口(应用型):
5
+ * import { createKit } from '@blod-nm/user-kit'
6
+ * const kit = createKit({ envId, apiKey, wxAppid, wxSecret, tcbAuthCredentials, env?, inject?, signinProviders?, …透传 base-cb })
7
+ * await kit.ctx.run('pg.exec', { sql: 'select 1' }) // base-cb 网关 14 能力原样可用
8
+ * await kit.ctx.run('auth.loginByWxcode', { code }) // user 域与网关能力同总线
9
+ * kit.describe() // 能力元数据(14 + 21)
10
+ *
11
+ * 数据流: cfg fail-fast → createBaseKit(cfg)(base-cb req/gate/ctx)→ 同一 ctx register
12
+ * auth 域 12 条(含 2 别名)+ user 域 9 条(闭包持有 cfg/inject/kit)→ kit { ctx, req, cfg, describe }
13
+ *
14
+ * cfg 约定(spec §3.0):
15
+ * 必填 envId / apiKey / wxAppid / wxSecret / tcbAuthCredentials(缺一即抛,fail-fast)
16
+ * env 缺省 'prod'(建档 tag from_env);inject.* 九口(含 gateway)原样存 kit.cfg(缺省 = 真实现)
17
+ * domain/proxy/proxyBase/timeoutMs/role 透传 base-cb
18
+ */
19
+ import { createKit as createBaseKit } from '@blod-nm/base-cb'
20
+ import { createAuthAbilities } from './auth.js'
21
+ import { createUserAbilities } from './user.js'
22
+
23
+ const REQUIRED = ['envId', 'apiKey', 'wxAppid', 'wxSecret', 'tcbAuthCredentials']
24
+
25
+ export function createKit(cfg = {}) {
26
+ for (const field of REQUIRED) {
27
+ if (!cfg?.[field]) throw new Error(`user-kit: cfg 缺 ${field}`)
28
+ }
29
+ const finalCfg = cfg.env ? cfg : { ...cfg, env: 'prod' }
30
+ const base = createBaseKit(finalCfg)
31
+ for (const [key, ability] of [...createAuthAbilities(finalCfg, base), ...createUserAbilities(finalCfg, base)]) {
32
+ base.ctx.register(key, ability)
33
+ }
34
+ return { ctx: base.ctx, req: base.req, cfg: finalCfg, describe: () => base.ctx.describe() }
35
+ }
36
+
37
+ export default createKit
package/src/user.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * user.js — user 域能力目录(10 条)+ SQL 纯函数 + token/网关共用件(spec §3.6 · §3.9 · §3.10)
3
+ *
4
+ * 建档面(本地 pg):
5
+ * user.upsert { uid, profile?, env? } — 建档/刷 updated_at(ns 参数化 ARRAY['uid:'||$4]::text[],ISSUE-01)
6
+ * user.get { uid } — 查档(不过滤 status/deleted_at,403 判定留调用方;无行 → null)
7
+ * user.patchProfile { uid, patch } — 档案段更新(jsonb_set 浅合并 profile 段);0 行 → upsert 建档 →
8
+ * 重查 → active 未删重试一次;仍不可用 → 403「用户不可用」(tag/status/ns 形状同 upsert,不新增表)。
9
+ *
10
+ * schema 面(§3.11,幂等初始化;不在 createKit/登录链路隐式执行,调用方显式调):
11
+ * user.schemaStatus — 只读探测(表/四索引/主键);user.ensureSchema — 缺什么补什么(DDL 全 if not exists,
12
+ * 每条单独 pg.exec(网关不支持多语句,实测 DATABASE_42601);顺序:建表→主键→唯一→gin→owner/identity_type;
13
+ * 主键仅缺失时补;DDL 失败原样抛 { code: 网关状态, payload },不静默降级)。
14
+ *
15
+ * token 面(4 条,网关透传不裁剪;inject.gateway(method, path, { body, token }) 统一覆盖):
16
+ * user.me — GET;user.updateBasic — POST /auth/v1/user/basic/edit(body 只发传入字段,
17
+ * 成功 → patchProfile({ uid: JWT.sub, patch: 可入档字段 }),verification_token 不入档);
18
+ * user.changePassword PATCH /auth/v1/user/password;user.deleteMe DELETE /auth/v1/user/me;user.signOut 不写档。
19
+ *
20
+ * 硬规则(§3.9/§3.10): token 类能力 uid 一律从 access_token 的 JWT.sub 取(不验签),**不接收调用方传 uid**
21
+ * 防越权;解码失败 → 400「access_token 无效」。changePassword/deleteMe/signOut 不自动写档(身份面与业务档解耦)。
22
+ *
23
+ * 数据流: 入参(uid 校验/token 解码)→ gateway 网关原样 / build*Query → ctx.run('pg.exec',
24
+ * { sql, parameters, role:'cloudbase_postgres' });网关非 2xx → { code: 网关状态, payload } 原样,
25
+ * 网络异常 → 502「登录请求失败」。
26
+ * 下期留白(不实现):user.updateSection 旧接口(patchProfile 取代)/ user.bindPhone(crm_cache access_token)。
27
+ */
28
+
29
+ const DEFAULT_DOMAIN = 'api.tcloudbasegateway.com'
30
+
31
+ /* —— 共用件:JWT 不验签解码 / token 身份硬规则 / 网关错误透传 / gateway 注入缺省 —— */
32
+ export function decodeJWT(token) {
33
+ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')) } catch { return {} }
34
+ }
35
+
36
+ // token 类能力身份来源:uid 只认 JWT.sub(防越权);解码失败 → 400「access_token 无效」
37
+ export function uidFromToken(access_token) {
38
+ const sub = decodeJWT(access_token)?.sub
39
+ if (!sub) throw Object.assign(new Error('access_token 无效'), { code: 400 })
40
+ return sub
41
+ }
42
+
43
+ // 网关错误透传:非 2xx → { code: 网关状态, payload } 原样(不改写错误码);无 status = 网络层 → 502
44
+ export const throwGatewayError = e => {
45
+ throw e?.status
46
+ ? Object.assign(new Error(e.message), { code: e.status, payload: e.payload })
47
+ : Object.assign(new Error('登录请求失败'), { code: 502 })
48
+ }
49
+
50
+ // inject.gateway(method, path, { body, token }) — §3.8/§3.9 全部网关调用的统一注入口(缺省 kit.req + Bearer)
51
+ export function makeGateway(cfg, kit) {
52
+ return cfg.inject?.gateway || ((method, path, { body, token } = {}) =>
53
+ kit.req(`https://${cfg.envId}.${cfg.domain || DEFAULT_DOMAIN}${path}`, {
54
+ method,
55
+ ...(body !== undefined ? { body } : {}),
56
+ ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
57
+ }))
58
+ }
59
+
60
+ /* —— uid 校验(建档面入口;ISSUE-01)—— */
61
+ export const assertUid = uid => {
62
+ if (typeof uid !== 'string' || uid === '') throw Object.assign(new Error('缺少 uid'), { code: 400 })
63
+ if (uid.length > 128 || /[\u0000-\u001f\u007f]/.test(uid)) throw Object.assign(new Error('缺少 uid'), { code: 400 })
64
+ return uid
65
+ }
66
+
67
+ /* —— SQL 纯函数(逐字对齐现网形状)—— */
68
+ export function buildUpsertUserQuery(uid, profile, env) {
69
+ return {
70
+ // 只 upsert data:冲突分支逐字照抄 spec §3.6(主场已实库验证;t = 本表别名)
71
+ sql: `INSERT INTO public.crm_user_detail AS t (ns, data, ext, tag, status)
72
+ VALUES (ARRAY['uid:' || $4]::text[], $1::jsonb, NULL, $2::text[] || ARRAY['from_env:'||$3]::text[], 'active')
73
+ ON CONFLICT (ns) WHERE (deleted_at IS NULL)
74
+ DO UPDATE SET
75
+ data = CASE WHEN EXCLUDED.data ? 'profile' THEN
76
+ (COALESCE(t.data,'{}'::jsonb) || EXCLUDED.data)
77
+ || jsonb_build_object('profile', COALESCE(t.data->'profile','{}'::jsonb) || COALESCE(EXCLUDED.data->'profile','{}'::jsonb))
78
+ ELSE COALESCE(t.data,'{}'::jsonb) || EXCLUDED.data END,
79
+ updated_at = NOW()
80
+ RETURNING id, data`,
81
+ parameters: [JSON.stringify({ profile: profile ?? {} }), [], env || 'prod', uid],
82
+ }
83
+ }
84
+
85
+ export function buildSelectUserQuery(uid) {
86
+ return {
87
+ sql: `SELECT id, data, status, deleted_at
88
+ FROM public.crm_user_detail
89
+ WHERE $1 = ANY(ns)
90
+ ORDER BY created_at ASC
91
+ LIMIT 1`,
92
+ // uid 是绑定参数值(=ANY(ns) 比较),无注入面;拼接避免模板字面量(ISSUE-01 grep 白名单)
93
+ parameters: ['uid:' + uid],
94
+ }
95
+ }
96
+
97
+ // patchProfile:profile 段内浅合并(对齐 nuxt-layer-api-user updateUserSection 语义)
98
+ export function buildPatchProfileQuery(uid, patch) {
99
+ return {
100
+ sql: `UPDATE public.crm_user_detail
101
+ SET data = jsonb_set(
102
+ COALESCE(data, '{}'::jsonb),
103
+ ARRAY['profile'],
104
+ COALESCE(data->'profile', '{}'::jsonb) || $2::jsonb,
105
+ true
106
+ ),
107
+ updated_at = NOW()
108
+ WHERE $1 = ANY(ns)
109
+ AND status = 'active'
110
+ AND deleted_at IS NULL
111
+ RETURNING id, data`,
112
+ parameters: ['uid:' + uid, JSON.stringify(patch ?? {})],
113
+ }
114
+ }
115
+
116
+ /* —— schema 面(§3.11 · DDL 逐字来源 apps/wx-mp-fn/db/schema.full.sql L164/L536/L540,全 if not exists)—— */
117
+ const PROBE_TABLE = `select to_regclass('public.crm_user_detail') is not null as "exists"`
118
+ const PROBE_INDEXES = `select indexname from pg_indexes where schemaname='public' and tablename='crm_user_detail'`
119
+ const PROBE_PKEY = `select 1 from pg_constraint where conrelid='public.crm_user_detail'::regclass and contype='p'`
120
+ const DDL_TABLE = `create table if not exists public.crm_user_detail (id uuid default gen_random_uuid() not null, ns text[] not null, data jsonb default '{}'::jsonb not null, ext jsonb, tag text[] default '{}'::text[], status text default 'active' not null, created_at timestamptz default now() not null, updated_at timestamptz default now() not null, deleted_at timestamptz)`
121
+ const DDL_PKEY = `alter table public.crm_user_detail add constraint crm_user_detail_pkey primary key (id)` // 建表不带 pk(与 schema.full.sql 一致),仅缺失时单独补
122
+ const DDL_INDEXES = {
123
+ idx_crm_user_detail_ns_unique: `create unique index if not exists idx_crm_user_detail_ns_unique on public.crm_user_detail using btree (ns) where (deleted_at is null)`, // upsert 的 ON CONFLICT 依赖它,必须有
124
+ idx_crm_user_detail_ns: `create index if not exists idx_crm_user_detail_ns on public.crm_user_detail using gin (ns)`,
125
+ idx_crm_user_detail_owner: `create index if not exists idx_crm_user_detail_owner on public.crm_user_detail using btree (((data #>> '{identity,owner_uid}'::text[]))) where (deleted_at is null)`,
126
+ idx_crm_user_detail_identity_type: `create index if not exists idx_crm_user_detail_identity_type on public.crm_user_detail using btree (((data #>> '{identity,type}'::text[]))) where (deleted_at is null)`,
127
+ }
128
+ const INDEX_ORDER = ['idx_crm_user_detail_ns_unique', 'idx_crm_user_detail_ns', 'idx_crm_user_detail_owner', 'idx_crm_user_detail_identity_type']
129
+
130
+ /* —— user 域 token 面字段表 —— */
131
+ const EDIT_BODY = ['nickname', 'username', 'phone', 'email', 'avatar_url', 'gender', 'description', 'verification_token']
132
+ const ARCHIVABLE = ['nickname', 'username', 'phone', 'email', 'avatar_url', 'gender', 'description']
133
+
134
+ export function createUserAbilities(cfg, kit) {
135
+ const gateway = makeGateway(cfg, kit)
136
+ // 网关调用统一翻错(非 2xx 原样 / 网络 502)
137
+ const gw = async (method, path, opts) => { try { return await gateway(method, path, opts) } catch (e) { throwGatewayError(e) } }
138
+ const pg = q => kit.ctx.run('pg.exec', { sql: q.sql, parameters: q.parameters, role: 'cloudbase_postgres' })
139
+ // 探测:表在不在 / 四索引在不在 / 主键在不在(只读,三次 pg.exec)
140
+ const probe = async () => {
141
+ const [t, i, k] = [await pg({ sql: PROBE_TABLE, parameters: [] }), await pg({ sql: PROBE_INDEXES, parameters: [] }), await pg({ sql: PROBE_PKEY, parameters: [] })]
142
+ const names = new Set(i.map(r => r.indexname))
143
+ return { table: !!t[0]?.exists, indexes: Object.fromEntries(INDEX_ORDER.map(n => [n, names.has(n)])), primaryKey: k.length > 0 }
144
+ }
145
+
146
+ return [
147
+ /* 建档面(本地 pg) */
148
+ ['user.upsert', { describe: '建档/刷 updated_at(crm_user_detail;写走 cloudbase_postgres)', run: async (args = {}) => {
149
+ const { uid, profile, env } = args
150
+ const rows = await pg(buildUpsertUserQuery(assertUid(uid), profile, env ?? cfg.env))
151
+ return rows?.[0] ?? null
152
+ } }],
153
+
154
+ ['user.get', { describe: '查档(不过滤 status/deleted_at;无行 → null)', run: async (args = {}) => {
155
+ const rows = await pg(buildSelectUserQuery(assertUid(args.uid)))
156
+ return rows?.[0] ?? null
157
+ } }],
158
+
159
+ ['user.patchProfile', { describe: '档案段更新(profile 浅合并;0 行→建档→重试一次→403 用户不可用)', run: async (args = {}) => {
160
+ const uid = assertUid(args.uid)
161
+ const rows = await pg(buildPatchProfileQuery(uid, args.patch))
162
+ if (rows?.length) return rows[0]
163
+ await kit.ctx.run('user.upsert', { uid }) // 0 行 → 先建档
164
+ const check = await kit.ctx.run('user.get', { uid }) // 重查
165
+ if (check?.status === 'active' && !check.deleted_at) {
166
+ const rows2 = await pg(buildPatchProfileQuery(uid, args.patch)) // active 未删 → 重试一次
167
+ if (rows2?.length) return rows2[0]
168
+ }
169
+ throw Object.assign(new Error('用户不可用'), { code: 403 })
170
+ } }],
171
+
172
+ /* token 面(网关透传;uid 只认 JWT.sub) */
173
+ ['user.me', { describe: '当前用户(资料/绑定/角色):GET /auth/v1/user/me,网关原样', run: async ({ access_token } = {}) => {
174
+ uidFromToken(access_token)
175
+ return gw('GET', '/auth/v1/user/me', { token: access_token })
176
+ } }],
177
+
178
+ ['user.updateBasic', { describe: '资料/手机/邮箱变更:POST /auth/v1/user/basic/edit(body 只发传入字段)→ 成功 patchProfile', run: async (args = {}) => {
179
+ const uid = uidFromToken(args.access_token)
180
+ const body = {}
181
+ for (const k of EDIT_BODY) if (args[k] !== undefined) body[k] = args[k]
182
+ const payload = await gw('POST', '/auth/v1/user/basic/edit', { body, token: args.access_token })
183
+ const patch = {}
184
+ for (const k of ARCHIVABLE) if (k in body) patch[k] = body[k] // verification_token 不入档
185
+ await kit.ctx.run('user.patchProfile', { uid, patch })
186
+ return payload
187
+ } }],
188
+
189
+ ['user.changePassword', { describe: '改密(前置 reauthenticate):PATCH /auth/v1/user/password,网关原样(不写档)', run: async (args = {}) => {
190
+ const { access_token, ...rest } = args
191
+ uidFromToken(access_token)
192
+ return gw('PATCH', '/auth/v1/user/password', { body: rest, token: access_token })
193
+ } }],
194
+
195
+ ['user.deleteMe', { describe: '删号(前置 reauthenticate):DELETE /auth/v1/user/me,网关原样(不自行标删/不写档)', run: async (args = {}) => {
196
+ const { access_token, ...rest } = args
197
+ uidFromToken(access_token)
198
+ return gw('DELETE', '/auth/v1/user/me', { body: rest, token: access_token })
199
+ } }],
200
+
201
+ /* schema 面(§3.11:探测只读;补齐逐条 DDL,失败原样透传不静默降级) */
202
+ ['user.schemaStatus', { describe: '建档表只读探测:表/四索引/主键在位状态', run: async () => probe() }],
203
+
204
+ ['user.ensureSchema', { describe: '建档表幂等初始化:缺什么补什么(DDL 逐条 pg.exec;调用方显式执行,登录不做 DDL)', run: async () => {
205
+ const before = await probe()
206
+ const plan = []
207
+ if (!before.table) plan.push(DDL_TABLE)
208
+ if (!before.primaryKey) plan.push(DDL_PKEY) // 主键仅缺失时补(alter 无 if not exists)
209
+ const missing = INDEX_ORDER.filter(n => !before.indexes[n])
210
+ for (const name of missing) plan.push(DDL_INDEXES[name])
211
+ for (const sql of plan) {
212
+ try { await pg({ sql, parameters: [] }) } catch (e) {
213
+ throw e?.status ? Object.assign(new Error(e.message), { code: e.status, payload: e.payload }) : e // 原样透传
214
+ }
215
+ }
216
+ return { table: before.table ? 'exists' : 'created', indexes: { created: missing, existing: INDEX_ORDER.filter(n => !missing.includes(n)) }, status: await probe() }
217
+ } }],
218
+ ]
219
+ }
package/src/wechat.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * wechat.js — 微信接口缺省实现(code2Session · 稳定叶,不在能力面待定范围)
3
+ *
4
+ * 职责: wx.login 的 code 换 openid/unionid/session_key(URLSearchParams 直连,不走 kit 代理)。
5
+ * 数据流: code + cfg.wxAppid/wxSecret → GET api.weixin.qq.com/sns/jscode2session
6
+ * → { openid, unionid?, errcode? };errcode 交给调用方翻译(auth 域 → {code:400, errcode, errmsg})
7
+ * 网络失败抛错由调用方兜底(与 nuxt-layer-api-cb-dependences/server/utils/wechat.ts 同语义)。
8
+ */
9
+
10
+ export async function code2Session(cfg, code) {
11
+ const params = new URLSearchParams({
12
+ appid: cfg.wxAppid,
13
+ secret: cfg.wxSecret,
14
+ js_code: code,
15
+ grant_type: 'authorization_code',
16
+ })
17
+ const res = await fetch(`https://api.weixin.qq.com/sns/jscode2session?${params}`)
18
+ if (!res.ok) throw new Error(`code2Session request failed with status ${res.status}`)
19
+ return await res.json()
20
+ }