@lichidental/saas-cli 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,259 @@
1
+ # 预约相关接口说明(中文导读 + curl)
2
+
3
+ > **目标读者:后端工程师** — 需要用命令行(curl)手工调接口、或写后端对接代码的技术同学。
4
+ > 只使用现成 CLI 命令的同学请看 `CLI使用手册.md`,不需要本文件。
5
+ >
6
+ > 机器可读的权威版本是 [openapi.yaml](./openapi.yaml),本文件是它的中文导读。
7
+ > **两个文件如有出入,以 openapi.yaml 为准。**
8
+ > 全部接口于 2026-10-12 在 test 环境实测;真实请求/响应样本见 `docs/samples/`。
9
+
10
+ ## 通用约定
11
+
12
+ ### 环境地址
13
+
14
+ | 环境 | 地址 | 备注 |
15
+ |---|---|---|
16
+ | test(测试) | `https://test.saas.lichidental.com` | 样本采集环境,已实测 |
17
+ | pre(预发) | `https://pre.saas.lichidental.com` | 未实测 |
18
+ | prod(生产) | `https://saas.lichidental.com` | 未实测 |
19
+
20
+ 接口路径直接拼环境地址,例如 `POST https://test.saas.lichidental.com/user/saas/custom-mark/addCustomMark`。
21
+
22
+ ### 请求头
23
+
24
+ | 头 | 必填 | 说明 |
25
+ |---|---|---|
26
+ | `token` | 是 | 登录凭据(浏览器登录后 localStorage.userInfo.token) |
27
+ | `Content-Type` | 是 | `application/json;charset=UTF-8` |
28
+ | `Version` | 建议 | 前端构建版本号(如 `v26.09.2.0`);后端会做版本校验,缺失或过旧可能返回 status 999 |
29
+ | `channel` | 建议 | 固定 `html`(与 Version 一起用) |
30
+
31
+ 前端对 POST 请求不携带 clinicId 参数,诊所归属由 token 决定。
32
+
33
+ ### 响应格式
34
+
35
+ 所有接口 HTTP 状态码恒为 200,业务结果看响应体 `status` 字段。
36
+
37
+ 成功:
38
+
39
+ ```json
40
+ { "status": 200, "code": "SUCCESS", "message": "处理成功", "data": { } }
41
+ ```
42
+
43
+ 失败(示例为实测的错误 token 响应,注意 401 无 data 字段):
44
+
45
+ ```json
46
+ { "code": "UNAUTHORIZED", "message": "登录失效,请重新登录!", "status": 401 }
47
+ ```
48
+
49
+ | status | 含义 | 处理 |
50
+ |---|---|---|
51
+ | 200 | 成功 | - |
52
+ | 400 | 业务参数错误 | 按 message 修正 |
53
+ | 401 | token 失效 | 重新登录 / 更换 token |
54
+ | 403 | 无权限 | 联系管理员开权限 |
55
+ | 410 | 营销中心无短信余额(短信相关) | 充值或换账号 |
56
+ | 999 | 版本校验失败 | 检查 Version 头是否为当前构建版本 |
57
+ | 30002 | 短信发送失败(短信相关) | 见 message |
58
+
59
+ ### 时间格式(重要,两处不一样)
60
+
61
+ - **请求体里的时间**: `YYYY-MM-DD HH:mm:ss`(空格分隔,带秒)。预约短信接口例外: `YYYY-MM-DD HH:mm`(不带秒)。
62
+ - **响应体里的时间**: ISO 格式,形如 `2026-11-01T11:00:00`(**中间是 T**,读响应时注意转换)。
63
+
64
+ ---
65
+
66
+ ## 1. 医生列表
67
+
68
+ `POST /user/saas/t-lichi-user-store/getStoreUserListByParam`
69
+
70
+ 取"在职(0)+ 试用(2)、岗位=医生(1)"的员工,创建预约时用返回的 `userUid` 作为 `doctorId`。
71
+
72
+ | 字段 | 类型 | 必填 | 说明 | 示例 |
73
+ |---|---|---|---|---|
74
+ | delFlags | number[] | 是 | 在职状态:0=在职 1=离职 2=试用 | `[0,2]` |
75
+ | storeUserPostIds | number[] | 是 | 岗位:1=医生(其他:3=洁牙师 4=咨询师 6=护士 7=前台…) | `[1]` |
76
+
77
+ 响应:`data` 为员工数组,常用字段 `userUid`(字符串)、`storeUserName`(姓名)。完整字段见样本。
78
+
79
+ ```bash
80
+ curl -X POST "https://test.saas.lichidental.com/user/saas/t-lichi-user-store/getStoreUserListByParam" \
81
+ -H "Content-Type: application/json;charset=UTF-8" \
82
+ -H "token: <你的token>" -H "Version: v26.09.2.0" -H "channel: html" \
83
+ -d '{"delFlags":[0,2],"storeUserPostIds":[1]}'
84
+ ```
85
+
86
+ > 实测通过(2026-10-12,返回 19 条)。样本:[samples/getStoreUserListByParam.json](../samples/getStoreUserListByParam.json)
87
+
88
+ ## 2. 搜索患者
89
+
90
+ `POST /user/saas/custom/customParamPageList`
91
+
92
+ 按关键词搜患者(paramText 支持姓名 / 手机号 / 病历号)。创建预约时用返回的 `customCode`。
93
+
94
+ | 字段 | 类型 | 必填 | 说明 | 示例 |
95
+ |---|---|---|---|---|
96
+ | pageNum | number | 是 | 页码 | `1` |
97
+ | pageSize | number | 是 | 每页条数 | `20` |
98
+ | paramText | string | 否 | 搜索关键词;不传返回全部 | `"张"` |
99
+ | dateType | number | 否 | 前端固定传 3(其他取值未验证) | `3` |
100
+
101
+ 响应:`data.total` 为总数,`data.list` 为患者数组,常用字段 `customCode` / `customName` / `customPhone` / `codeNum`(病历号)。
102
+
103
+ > Windows(Git Bash)下 curl 直传中文会乱码,把 JSON 存成 UTF-8 文件再 `-d @file.json`(本示例即此方式)。
104
+
105
+ ```bash
106
+ # body.json 内容(UTF-8 编码):{"paramText":"张","pageNum":1,"pageSize":20,"dateType":3}
107
+ curl -X POST "https://test.saas.lichidental.com/user/saas/custom/customParamPageList" \
108
+ -H "Content-Type: application/json;charset=UTF-8" \
109
+ -H "token: <你的token>" -H "Version: v26.09.2.0" -H "channel: html" \
110
+ -d @body.json
111
+ ```
112
+
113
+ > 实测通过(搜"张"共 9 条)。样本:[samples/customParamPageList.json](../samples/customParamPageList.json)
114
+
115
+ ## 3. 预约项目列表
116
+
117
+ `POST /user/saas/treat-item/getTreatItemList`
118
+
119
+ 无参数(传 `{}` 即可)。返回**平铺数组**:`level=1` 为一级分类,`level=2` 为二级项目(用 `upTreatItemId` 关联一级分类的 `id`)。
120
+
121
+ | 字段(响应元素) | 说明 |
122
+ |---|---|
123
+ | id | 项目 id(二级项目组装 treatItemJson 时用) |
124
+ | treatItemName | 项目/分类名称 |
125
+ | upTreatItemId | 上级分类 id(一级分类行为 0) |
126
+ | level | 1=一级分类,2=二级项目 |
127
+
128
+ ```bash
129
+ curl -X POST "https://test.saas.lichidental.com/user/saas/treat-item/getTreatItemList" \
130
+ -H "Content-Type: application/json;charset=UTF-8" \
131
+ -H "token: <你的token>" -H "Version: v26.09.2.0" -H "channel: html" -d '{}'
132
+ ```
133
+
134
+ > 实测通过(共 42 个:10 个一级分类 + 32 个二级项目)。样本:[samples/getTreatItemList.json](../samples/getTreatItemList.json)
135
+
136
+ ## 4. 按患者识别就诊类型(初诊 / 复诊)
137
+
138
+ `POST /user/saas/custom-mark/customMarkInfoByCustom`
139
+
140
+ | 字段 | 类型 | 必填 | 说明 |
141
+ |---|---|---|---|
142
+ | customCode | string | 是 | 患者编码 |
143
+
144
+ 响应:`data.visitType` — **1=初诊,0=复诊**(数字);组装新建预约请求时需转成字符串 `"1"`/`"0"`。`data.customMarkDtos` 为患者历史记录(结构见样本,一般不用)。
145
+
146
+ ```bash
147
+ curl -X POST "https://test.saas.lichidental.com/user/saas/custom-mark/customMarkInfoByCustom" \
148
+ -H "Content-Type: application/json;charset=UTF-8" \
149
+ -H "token: <你的token>" -H "Version: v26.09.2.0" -H "channel: html" \
150
+ -d '{"customCode":"YONGCE26073100001"}'
151
+ ```
152
+
153
+ > 实测通过(返回 visitType=0)。样本:[samples/customMarkInfoByCustom.json](../samples/customMarkInfoByCustom.json)
154
+
155
+ ## 5. 新建预约(写操作)
156
+
157
+ `POST /user/saas/custom-mark/addCustomMark`
158
+
159
+ | 字段 | 类型 | 必填 | 说明 | 示例 |
160
+ |---|---|---|---|---|
161
+ | customCode | string | 是 | 患者编码 | `"YONGCE26073100001"` |
162
+ | doctorId | string | 是 | 医生 userUid(**字符串**,不要传数字) | `"26051131994"` |
163
+ | markStartTime | string | 是 | 开始时间 `YYYY-MM-DD HH:mm:ss`(勿用 ISO) | `"2026-11-01 11:00:00"` |
164
+ | markTimeLength | number | 是 | 时长(分钟)。前端可选值:15/30/45/60/75/90/105/120/135/150/165/180/195/210/225/240/300/360/420/480 | `30` |
165
+ | treatItemJson | string | 是 | 项目 **JSON 字符串**(见下) | 见下 |
166
+ | boolFirst | string | 否 | 就诊类型:`"1"`=初诊 `"0"`=复诊(字符串)。省略行为未验证,建议传 | `"0"` |
167
+ | definitionCreateTime | string | 否 | 创建时间,格式同上。前端与 CLI 均传当前时间 | `"2026-10-12 11:01:51"` |
168
+ | remark | string | 否 | 备注,可为空字符串 | `""` |
169
+
170
+ treatItemJson 字符串解析后的元素结构(每项 4 个字段,全部必有):
171
+
172
+ ```json
173
+ [{ "up_id": 84048, "up_title": "口腔", "id": 84050, "title": "口腔检查" }]
174
+ ```
175
+
176
+ - `up_id` / `up_title`:一级分类的 id / 名称(来自项目列表 level=1)
177
+ - `id` / `title`:二级项目的 id / 名称(来自项目列表 level=2)
178
+
179
+ 响应:`data.id` 为新建预约的 id(同时还有 `data.updateNum`,固定 1)。
180
+
181
+ ```bash
182
+ # body.json(UTF-8 编码):
183
+ # {"customCode":"YONGCE26073100001","doctorId":"26051131994","markStartTime":"2026-11-05 10:00:00",
184
+ # "markTimeLength":30,"treatItemJson":"[{\"up_id\":84048,\"up_title\":\"口腔\",\"id\":84050,\"title\":\"口腔检查\"}]",
185
+ # "boolFirst":"0","remark":""}
186
+ curl -X POST "https://test.saas.lichidental.com/user/saas/custom-mark/addCustomMark" \
187
+ -H "Content-Type: application/json;charset=UTF-8" \
188
+ -H "token: <你的token>" -H "Version: v26.09.2.0" -H "channel: html" \
189
+ -d @body.json
190
+ ```
191
+
192
+ > 实测通过(创建成功,返回 `{"status":200,...,"data":{"id":1260620,"updateNum":1}}`)。样本:[samples/addCustomMark.json](../samples/addCustomMark.json)
193
+
194
+ ## 6. 按患者 + 时间范围查询预约记录(列表展示,推荐)
195
+
196
+ `POST /user/saas/custom-mark/customMarkListByCustom`
197
+
198
+ **前端患者详情"就诊记录"与卡片"下次预约"tab 用的就是这个接口**,也是 CLI `reservation list` 的实现方式。按患者 + 时间范围查询,**服务端过滤生效**。
199
+
200
+ | 字段 | 类型 | 必填 | 说明 | 示例 |
201
+ |---|---|---|---|---|
202
+ | customCode | string | 是 | 患者编码(精确过滤) | `"YONGCE26073100001"` |
203
+ | startTime / endTime | string | 否 | 时间范围 `YYYY-MM-DD HH:mm:ss`(**实测服务端过滤生效**) | `"2026-10-15 00:00:00"` / `"2026-10-15 23:59:59"` |
204
+ | pageSize | number | 否 | 每页条数(前端用 10;取全部可用 999) | `999` |
205
+ | time | string | 否 | 分页游标:上一页最后一条的 markStartTime | - |
206
+
207
+ 响应:`data.cmList` 为预约数组,**已按 markStartTime 降序排序**(前端不排序,依赖后端顺序)。关键字段:
208
+
209
+ | 字段 | 说明 |
210
+ |---|---|
211
+ | id | 预约 id |
212
+ | customCode / doctorId | 患者编码 / 医生 userUid(字符串) |
213
+ | status / visitStatus | 预约状态数字 / 就诊状态(显示规则见下表;主键是 visitStatus) |
214
+ | isMark / isRegister | 是否预约 / 是否挂号(前端按 `!(isMark===0 && isRegister===1)` 剔除"仅挂号"记录) |
215
+ | markDate / markStartTime / markEndTime | 预约日期与起止时间(**ISO 格式带 T**) |
216
+ | markTimeLength | 时长(分钟) |
217
+ | treatItemJson / remark | 项目 JSON 字符串 / 备注 |
218
+
219
+ **状态显示规则**(前端看板的逻辑:以 `visitStatus` 为主键,不是直接用 `status`;CLI `list` 已照此实现):
220
+
221
+ | visitStatus | status | 显示 |
222
+ |---|---|---|
223
+ | 1(预约未到) | 1 | 已预约 |
224
+ | 1(预约未到) | 9 或其他 | 已确认 |
225
+ | 2 | - | 已到店 |
226
+ | 3 | - | 治疗中 |
227
+ | 4(待收费) | 5 或 2 | 治疗中 |
228
+ | 4(待收费) | 其他 | 待收费 |
229
+ | 5 | - | 治疗完成 |
230
+ | 6 | - | 已离店 |
231
+
232
+ ```bash
233
+ curl -X POST "https://test.saas.lichidental.com/user/saas/custom-mark/customMarkListByCustom" \
234
+ -H "Content-Type: application/json;charset=UTF-8" \
235
+ -H "token: <你的token>" -H "Version: v26.09.2.0" -H "channel: html" \
236
+ -d '{"customCode":"YONGCE26073100001","startTime":"2026-10-15 00:00:00","endTime":"2026-10-15 23:59:59","pageSize":999}'
237
+ ```
238
+
239
+ > 实测通过(当天只返回当天记录;不传时间范围返回全部 14 条且降序)。样本:[samples/customMarkListByCustom.json](../samples/customMarkListByCustom.json)
240
+
241
+ ## 7. 发送预约短信(未实测)
242
+
243
+ `POST /user/saas/custom-mark/sendCustomMarkSms` ⚠️ **写操作,会给患者手机发短信**
244
+
245
+ | 字段 | 类型 | 必填 | 说明 |
246
+ |---|---|---|---|
247
+ | customCode | string | 是 | 患者编码 |
248
+ | markStartTime | string | 是 | 开始时间 `YYYY-MM-DD HH:mm`(**注意:此接口不带秒**,与 addCustomMark 不同) |
249
+
250
+ > **未实测**:为避免给真实号码发短信,本接口未做实测。参数与前端调用一致(来源:前端 `src/api/reservation.ts` 的 `sendCustomMarkSmsApi`)。失败码:30002=短信发送失败,410=短信余额不足。
251
+
252
+ ---
253
+
254
+ ## 附:Windows 下 curl 中文乱码
255
+
256
+ Git Bash 里 `curl -d '{"paramText":"张"}'` 会把中文按本地编码(GBK)发送,导致服务端存到乱码。解决方式:
257
+
258
+ 1. 把 JSON 存成 **UTF-8 无 BOM** 文件,用 `-d @body.json`(本文件示例均如此)
259
+ 2. 或直接用本 CLI 工具 / Postman / Apifox 等,无此问题
@@ -0,0 +1,176 @@
1
+ # 开发指南:如何新增一个 CLI 命令
2
+
3
+ > **目标读者:维护 / 扩展本工具的开发者** — 在现有工程里新增命令时,按本指南的模板与 checklist 操作。
4
+ > 普通使用者不需要阅读本文件。
5
+
6
+ 本工具后续会不断加命令(其他业务域的查询、造数等)。新增命令请按本指南的模板与 checklist 做,保持分层与输出契约一致。
7
+
8
+ ## 目录结构与分层规则
9
+
10
+ ```
11
+ cli.mjs 入口:扫描 commands/**/*.mjs 自动注册;统一错误输出与退出码
12
+ lib/ 基础设施层(领域无关):http 传输 / errors 退出码 / output 输出 / config 配置 / match 名称匹配 / time 时间 / options 通用参数
13
+ api/ 接口 SDK 层:每个后端接口一个函数,按域分文件(与前端 src/api/*.ts 同名对应)
14
+ commands/ 命令层:一命令一文件,子目录=主题命名空间;文件名以 _ 开头的为内部模块(不注册)
15
+ test/ node:test 测试(纯函数 + CLI 冒烟,不依赖网络)
16
+ docs/ 文档(见文末"文档要求")
17
+ ```
18
+
19
+ **依赖方向固定为 `commands → api → lib`,禁止反向或跨层:**
20
+
21
+ - `commands/*` 只做:解析参数 → 调 `api/*` → 用 `ctx.out` 输出。不直接 import `lib/http.mjs`,不写 fetch
22
+ - `api/*` 只描述接口(路径 / 请求体 / 返回),不打印输出、不读配置;签名统一 `(http, params)`
23
+ - `lib/*` 不包含任何具体业务接口
24
+
25
+ ## 命令执行链路示例(以 `saas-cli reservation doctors` 为例)
26
+
27
+ 先看"流水线"(各层怎么接力),再看"每一步具体做了什么"。
28
+
29
+ ### 流水线(分层视角)
30
+
31
+ ```
32
+ 命令行输入
33
+ │ saas-cli reservation doctors
34
+ ▼
35
+ cli.mjs ────────────────────── 入口:认出"reservation doctors"是哪条命令
36
+ ▼
37
+ commands/reservation/doctors.mjs ── 命令逻辑:查医生
38
+ ▼
39
+ lib/context.mjs ────────────── 准备:找 token、按 --env 选服务器地址
40
+ ▼
41
+ api/employee.mjs ───────────── 说清"调哪个接口、传什么参数"
42
+ ▼
43
+ lib/http.mjs ───────────────── 真正发请求、检查返回
44
+ ▼
45
+ 后端服务器 ──────────────────── 返回数据
46
+ ▼
47
+ 数据原路返回,打印到屏幕
48
+ ```
49
+
50
+ ### 每一步具体做了什么
51
+
52
+ ```
53
+ $ saas-cli reservation doctors
54
+ │
55
+ ├─ 1. cli.mjs(入口)
56
+ │ ├─ 创建命令行程序框架(名称/版本/--help)
57
+ │ ├─ 扫描 commands/ 目录,自动发现 reservation、config 两组命令
58
+ │ ├─ 加载 reservation/ 下的命令文件(doctors.mjs 等各自注册自己)
59
+ │ └─ 解析参数 → 命中"reservation 组下的 doctors 命令"
60
+ │
61
+ ├─ 2. doctors 命令开始执行(commands/reservation/doctors.mjs)
62
+ │ ├─ lib/context.mjs 准备运行环境:
63
+ │ │ ├─ 读选项:--env(默认 test)、--token、--json、--verbose
64
+ │ │ ├─ 找 token,顺序是:命令行 --token → SAAS_TOKEN → 配置文件
65
+ │ │ ├─ 没找到?→ 直接报错退出(提示 config set-token)
66
+ │ │ └─ 找到 → 按 --env 选地址:test → test.saas.lichidental.com
67
+ │ └─ 调 api/employee.mjs 的 listDoctors(),参数:{delFlags:[0,2], storeUserPostIds:[1]}
68
+ │ = "在职+试用的、岗位是医生的员工"
69
+ │
70
+ ├─ 3. 发请求(lib/http.mjs)
71
+ │ ├─ 先取版本号:GET 该环境的 index.html,解析出 v26.09.2.0
72
+ │ ├─ 拼请求:POST /user/saas/t-lichi-user-store/getStoreUserListByParam
73
+ │ │ 请求头带上:token(身份)、Version、Content-Type
74
+ │ ├─ 发出请求(15 秒超时)
75
+ │ └─ 检查返回的 status:
76
+ │ ├─ 200 → 继续 ├─ 401 → 报"token 失效"(退出码 3)
77
+ │ └─ 其它 → 原样报后端错误信息(退出码 1)
78
+ │
79
+ └─ 4. 输出结果(回到 doctors.mjs)
80
+ ├─ 默认:stdout 打印 "共 N 位医生: 1. 姓名 userUid:xxx ..."
81
+ ├─ 加 --json:stdout 打印 {ok:true, env, command, data:{doctors:[...]}}
82
+ └─ 结束后退出码 0;任何一步出错 → 1/2/3/4 并给出原因和解决办法
83
+ ```
84
+
85
+ 新增命令时,你的命令走同样形状的链路:`commands`(编排)→ `api`(接口)→ `lib/http`(传输),区别只在第 2 步调用的 api 函数与参数。
86
+
87
+ ## 新增命令 checklist
88
+
89
+ 1. **查 `api/`**:所需接口是否已封装?有则直接用;没有则在对应域文件加函数(见下"接口层新增")
90
+ 2. **写命令文件** `commands/<主题>/<命令名>.mjs`:导出 `register(topic)`,用模板(见下)
91
+ 3. **本地验证**:`saas-cli <主题> <命令> --help` 看帮助;`--dry-run`(写操作)预览;真实跑通一次
92
+ 4. **补测试**:纯逻辑(参数解析/数据变换)加到 `test/`;`npm test` 全绿
93
+ 5. **补文档**:更新 `AGENTS.md` 命令清单与正文 + `docs/CLI使用手册.md`;新接口补进 `docs/api/openapi.yaml` 与 `docs/api/接口说明.md`,并采集样本到 `docs/samples/`
94
+ 6. **确认输出契约**:成功走 `ctx.out.success(...)`,失败直接 `throw CliError`,不要自行 `console.log` 数据
95
+
96
+ ## 命令模板(可直接复制)
97
+
98
+ ```js
99
+ // commands/<主题>/<命令名>.mjs
100
+ import { addCommonOptions } from "../../lib/options.mjs"
101
+ import { buildContext } from "../../lib/context.mjs"
102
+ import { CliError, EXIT_CODES } from "../../lib/errors.mjs"
103
+ import { someApi } from "../../api/xxx.mjs"
104
+
105
+ export function register(topic) {
106
+ addCommonOptions(
107
+ topic
108
+ .command("命令名")
109
+ .description("一句话描述(会出现在 --help)")
110
+ .requiredOption("--必填参数 <值>", "说明")
111
+ .option("--可选参数 <值>", "说明")
112
+ ).action(async function () {
113
+ const opts = this.opts() // 命令级 + 通用参数
114
+ const { env, out, http } = buildContext(this) // 无网络请求的命令用 buildContext(this, { needHttp: false })
115
+
116
+ // 1) 参数校验(尽早抛出,避免发出不必要的请求)
117
+ if (/**(非法条件)**/) {
118
+ throw new CliError("哪里错了", { exitCode: EXIT_CODES.USAGE, hint: "怎么改" })
119
+ }
120
+
121
+ // 2) 调 api
122
+ const res = await someApi(http, { ... })
123
+
124
+ // 3) 输出:data 给 --json;humanText 给人读
125
+ out.success("命令名", env, { /* 结构化数据 */ }, "人读摘要(多行字符串)")
126
+ })
127
+ }
128
+ ```
129
+
130
+ ## 接口层(api/)新增方法
131
+
132
+ ```js
133
+ /**
134
+ * 一句话说明
135
+ * @returns data: { ... }(按实测响应写)
136
+ */
137
+ export function someApi(http, { paramA }) {
138
+ return http.post("/user/saas/xxx/yyy", { paramA })
139
+ }
140
+ ```
141
+
142
+ - 路径写全(以 `/user/` 开头);请求体按前端调用 + 实测校准,**不要写猜测的字段**
143
+ - 时间格式、必填性以实测为准;不确定的在注释里明确写"未验证"
144
+
145
+ ## 约定
146
+
147
+ - **输出**:stdout 只放数据(人读摘要或 `--json`);日志/进度走 stderr(`ctx.out.log` / `ctx.out.verboseLog`)
148
+ - **退出码**(lib/errors.mjs):0 成功 / 1 业务失败 / 2 用法错误 / 3 认证失败 / 4 网络错误
149
+ - **错误**:抛 `CliError(message, { exitCode, hint })`;`hint` 写"怎么解决"(例:提示用哪个参数替代)。入口会统一转成人读或 `--json` 输出
150
+ - **不加交互**:所有输入靠参数,禁止 prompt / 等待输入(AI 与脚本要能无人值守跑)
151
+ - **写操作防护**:写命令必须支持 `--dry-run`;生产环境加 `--yes` 门槛(参考 `commands/reservation/create.mjs`)
152
+ - **重名/歧义**:用 `lib/match.mjs` 的 `pickOne`(唯一命中才通过,多条列候选报错)
153
+ - **JSDoc 类型**:公共函数(`lib/`、`api/`)与命令 `register` 都带 JSDoc 类型注释,核心数据结构集中定义在 `lib/types.mjs`;新增函数请照此补齐——IDE 的跳转 / 悬停 / 重命名提示依赖它
154
+ - **命名**:命令文件用 kebab-case;api 文件名与前端 `src/api/*.ts` 保持一致
155
+
156
+ ## 测试与文档要求
157
+
158
+ - `npm test` 必须全绿;新增纯逻辑函数应有对应用例(参考 `test/match.test.mjs`)
159
+ - CLI 冒烟用例(无需网络):验证帮助输出、退出码、`--json` 错误结构(参考 `test/cli.test.mjs`)
160
+ - 文档更新点(缺一不可):
161
+ - `AGENTS.md`(AI 入口):命令清单表格 + 命令详情 + recipe(如有新流程)
162
+ - `docs/CLI使用手册.md`:命令详解 + 常见问题
163
+ - `docs/api/openapi.yaml` + `docs/api/接口说明.md` + `docs/samples/`(涉及新接口时)
164
+
165
+ ## 接口样本采集方法(更新文档用)
166
+
167
+ 1. 用 `--verbose` 跑一次命令,stderr 会包含每个请求的 URL、请求体、原始响应:
168
+ ```bash
169
+ saas-cli <命令...> --verbose --json > out.json 2> err.txt
170
+ ```
171
+ 2. 从 `err.txt` 提取:以 `→ POST <url>` 开头的是请求,` 请求体: {...}` 是请求体,`← HTTP 200 {...}` 是响应
172
+ 3. 按 `docs/samples/` 现有格式写入样本文件:
173
+ ```json
174
+ { "endpoint": "POST /user/saas/...", "capturedAt": "YYYY-MM-DD", "env": "test", "note": "说明", "requestBody": { }, "response": { } }
175
+ ```
176
+ 4. **脱敏**:样本中不得包含 token;同时更新 `docs/api/openapi.yaml` 中对应 schema(字段类型/必填/示例都要与样本一致)
package/lib/config.mjs ADDED
@@ -0,0 +1,109 @@
1
+ // lib/config.mjs — token 配置持久化(平台配置目录)
2
+ // 新路径:Windows %APPDATA%\saas-cli\config.json / 其他 $XDG_CONFIG_HOME/saas-cli/config.json
3
+ // 旧路径(create-saas-cli)保留只读兼容:自动沿用旧配置,下次写入时迁移到新路径
4
+ import fs from "node:fs"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+
8
+ const APP_DIR = "saas-cli"
9
+ const LEGACY_APP_DIR = "create-saas-cli"
10
+
11
+ /** 平台配置目录 @param {string} name @returns {string} */
12
+ function configDir(name) {
13
+ const base =
14
+ process.platform === "win32"
15
+ ? process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming")
16
+ : process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
17
+ return path.join(base, name)
18
+ }
19
+
20
+ /** 配置文件路径(平台感知:Windows 用 %APPDATA%,其他用 ~/.config) @returns {string} */
21
+ export function getConfigPath() {
22
+ return path.join(configDir(APP_DIR), "config.json")
23
+ }
24
+
25
+ /** 旧版配置文件路径(仅兼容读取) @returns {string} */
26
+ export function getLegacyConfigPath() {
27
+ return path.join(configDir(LEGACY_APP_DIR), "config.json")
28
+ }
29
+
30
+ /** 读取 JSON 配置文件,失败返回 null @param {string} file @returns {object|null} */
31
+ function tryReadConfig(file) {
32
+ try {
33
+ const cfg = JSON.parse(fs.readFileSync(file, "utf8"))
34
+ return cfg && typeof cfg === "object" ? cfg : null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ let legacyNotified = false
41
+
42
+ /**
43
+ * 读取配置;文件不存在或损坏时返回空配置。
44
+ * 新路径缺失时兼容读取旧版(create-saas-cli)目录,读到后原样返回(下次写入自然迁移)。
45
+ * @returns {{profiles: Record<string, {token?: string, updatedAt?: string}>}}
46
+ */
47
+ export function readConfig() {
48
+ const cfg = tryReadConfig(getConfigPath())
49
+ if (cfg) return cfg
50
+ const legacy = tryReadConfig(getLegacyConfigPath())
51
+ if (legacy) {
52
+ if (!legacyNotified) {
53
+ legacyNotified = true
54
+ console.error("(提示)检测到旧版配置目录 create-saas-cli,已自动沿用;下次写入将迁移到 saas-cli")
55
+ }
56
+ return legacy
57
+ }
58
+ return { profiles: {} }
59
+ }
60
+
61
+ /** 写入配置(文件权限 600) @param {{profiles: Record<string, {token?: string, updatedAt?: string}>}} config @returns {string} 写入的文件路径 */
62
+ export function writeConfig(config) {
63
+ const file = getConfigPath()
64
+ fs.mkdirSync(path.dirname(file), { recursive: true })
65
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 })
66
+ try {
67
+ fs.chmodSync(file, 0o600)
68
+ } catch {
69
+ // Windows 上 chmod 语义有限,忽略
70
+ }
71
+ return file
72
+ }
73
+
74
+ /**
75
+ * token 优先级:命令行 --token > 环境变量 SAAS_TOKEN > 配置文件当前环境
76
+ * @param {{cliToken?: string, env: string}} options
77
+ * @returns {{token: string|null, source: string|null}}
78
+ */
79
+ export function resolveToken({ cliToken, env }) {
80
+ if (cliToken) return { token: cliToken, source: "命令行 --token" }
81
+ if (process.env.SAAS_TOKEN) return { token: process.env.SAAS_TOKEN, source: "环境变量 SAAS_TOKEN" }
82
+ const cfg = readConfig()
83
+ const token = cfg?.profiles?.[env]?.token
84
+ if (token) return { token, source: `配置文件(profile: ${env})` }
85
+ return { token: null, source: null }
86
+ }
87
+
88
+ /** 保存某环境的 token @param {string} env @param {string} token @returns {string} 写入的文件路径 */
89
+ export function setToken(env, token) {
90
+ const cfg = readConfig()
91
+ if (!cfg.profiles || typeof cfg.profiles !== "object") cfg.profiles = {}
92
+ cfg.profiles[env] = { ...(cfg.profiles[env] || {}), token, updatedAt: new Date().toISOString() }
93
+ return writeConfig(cfg)
94
+ }
95
+
96
+ /** 删除某环境的 token @param {string} env @returns {string|null} 删除后写入的文件路径;该环境未配置时返回 null */
97
+ export function removeToken(env) {
98
+ const cfg = readConfig()
99
+ if (!cfg?.profiles?.[env]) return null
100
+ delete cfg.profiles[env]
101
+ return writeConfig(cfg)
102
+ }
103
+
104
+ /** token 打码展示 @param {string|undefined|null} token @returns {string} */
105
+ export function maskToken(token) {
106
+ if (!token) return "(未配置)"
107
+ if (token.length <= 8) return "****"
108
+ return `${token.slice(0, 4)}****${token.slice(-4)}`
109
+ }
@@ -0,0 +1,49 @@
1
+ // lib/context.mjs — 命令运行上下文:全局选项 → 环境 / token / 输出 / HTTP 请求器
2
+ import { CliError, EXIT_CODES } from "./errors.mjs"
3
+ import { createOutput } from "./output.mjs"
4
+ import { createHttp } from "./http.mjs"
5
+ import { resolveToken } from "./config.mjs"
6
+
7
+ /**
8
+ * 在命令 action 中构建运行上下文
9
+ * @param {import("commander").Command} cmd 当前命令
10
+ * @param {{needHttp?: boolean}} [options]
11
+ * @returns {{env: string, opts: any, out: import("./types.mjs").Output, http: import("./types.mjs").Http|null}}
12
+ */
13
+ export function buildContext(cmd, { needHttp = true } = {}) {
14
+ const opts = cmd.optsWithGlobals()
15
+ const env = opts.env
16
+ const out = createOutput({ json: Boolean(opts.json), verbose: Boolean(opts.verbose) })
17
+
18
+ let http = null
19
+ if (needHttp) {
20
+ const { token, source } = resolveToken({ cliToken: opts.token, env })
21
+ if (!token) {
22
+ throw new CliError(`环境 ${env} 未配置 token`, {
23
+ exitCode: EXIT_CODES.USAGE,
24
+ hint:
25
+ `配置方式(任选其一):\n` +
26
+ ` 1) saas-cli auth login --env ${env} --token <token>\n` +
27
+ ` 2) 设置环境变量 SAAS_TOKEN=<token>\n` +
28
+ ` 3) 命令后加 --token <token>`,
29
+ })
30
+ }
31
+ out.verboseLog(`token 来源:${source}`)
32
+ http = createHttp({ env, token, verbose: Boolean(opts.verbose), log: out.verboseLog })
33
+ }
34
+ return { env, opts, out, http }
35
+ }
36
+
37
+ /**
38
+ * 用显式指定的 token 构建上下文(auth 登录/验证场景:token 尚未保存或需要单独验证)
39
+ * @param {import("commander").Command} cmd 当前命令
40
+ * @param {string} token 显式 token(不查配置文件)
41
+ * @returns {{env: string, opts: any, out: import("./types.mjs").Output, http: import("./types.mjs").Http}}
42
+ */
43
+ export function buildContextWithToken(cmd, token) {
44
+ const opts = cmd.optsWithGlobals()
45
+ const env = opts.env
46
+ const out = createOutput({ json: Boolean(opts.json), verbose: Boolean(opts.verbose) })
47
+ const http = createHttp({ env, token, verbose: Boolean(opts.verbose), log: out.verboseLog })
48
+ return { env, opts, out, http }
49
+ }
package/lib/errors.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // lib/errors.mjs — 错误类型与退出码约定
2
+ // 退出码:0 成功 / 1 业务失败 / 2 用法错误 / 3 认证失败 / 4 网络错误
3
+
4
+ /**
5
+ * 退出码约定
6
+ * @typedef {{OK: number, BUSINESS: number, USAGE: number, AUTH: number, NETWORK: number}} ExitCodes
7
+ */
8
+
9
+ /** @type {ExitCodes} */
10
+ export const EXIT_CODES = {
11
+ OK: 0,
12
+ BUSINESS: 1,
13
+ USAGE: 2,
14
+ AUTH: 3,
15
+ NETWORK: 4,
16
+ }
17
+
18
+ export class CliError extends Error {
19
+ /**
20
+ * @param {string} message 面向使用者的错误描述
21
+ * @param {{code?: string|number, exitCode?: number, hint?: string}} [options]
22
+ */
23
+ constructor(message, { code = "ERROR", exitCode = EXIT_CODES.BUSINESS, hint } = {}) {
24
+ super(message)
25
+ this.name = "CliError"
26
+ this.code = code
27
+ this.exitCode = exitCode
28
+ this.hint = hint
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 把后端统一响应 {status, code, message, data} 中 status!=200 的情况转成 CliError
34
+ * @param {import("./types.mjs").ApiResponse} payload
35
+ * @returns {CliError}
36
+ */
37
+ export function mapApiError(payload) {
38
+ const status = payload?.status
39
+ const msg = payload?.message || `接口返回错误(status=${status})`
40
+ if (status === 401) {
41
+ return new CliError(`认证失败:${msg}`, {
42
+ code: 401,
43
+ exitCode: EXIT_CODES.AUTH,
44
+ hint: "token 已失效,请重新登录:saas-cli auth login --env <环境> --token <新token>",
45
+ })
46
+ }
47
+ if (status === 999) {
48
+ return new CliError(`版本校验失败:${msg}`, {
49
+ code: 999,
50
+ hint: "工具会自动读取目标环境的最新版本号;若持续出现,请确认本机能访问 <环境地址>/index.html",
51
+ })
52
+ }
53
+ if (status === 403) {
54
+ return new CliError(`无权限:${msg}`, {
55
+ code: 403,
56
+ exitCode: EXIT_CODES.AUTH,
57
+ hint: "当前 token 对应账号没有该操作权限,请联系管理员开通",
58
+ })
59
+ }
60
+ return new CliError(msg, { code: status ?? "ERROR" })
61
+ }