FSD m10 · 系统管理与审计(user + audit + infra/notify)
本册覆盖代码域
internal/domain/user、横切包internal/audit与internal/infra/notify。全局规则见 00-总则 §2(双日志轨 §2.6)。
m10 功能节目录
| ID | 名称 | 路由/入口 |
|---|---|---|
| F10.01 | 用户管理(CRUD/禁用/改角色/重密) | GET/POST /api/users、`POST /api/users/:id/disable |
| F10.02 | TOTP 2FA 管理(三态机+重置) | `POST /api/auth/totp/setup |
| F10.03 | 生命周期时间线查询 | GET /api/lifecycle/:type/:id |
| F10.04 | 操作日志审计页 | GET /api/operation-logs |
| F10.05 | INBOX 待办与邮件对账重发 | GET /api/inbox、POST /api/inbox/:id/read、GET /api/notifications/failed、POST /api/notifications/:id/retry |
| F10.06 | outbox 投递与重试 | (dispatcher 定时任务) |
F10.01 用户管理(CRUD/禁用/改角色/重密)
对应 SRS:F-ADMIN-002 | 实现落点:
internal/domain/user/service.go:110(Create)/:159(SetStatus)/:176(SetRole)/:196(ResetPassword)/user/in_tx.go:19(SetUserStatusInTx)/:53(SetUserRoleInTx)/main.go:248-254(路由) | 操作入口:web/admin/users用户管理页
1 功能定义
ADMIN 管理 B 端账号(B 端三角色+患者查看):建号(bcrypt 真算+邮箱唯一)、列表/详情(敏感列零透出——password_hash/totp_secret 不在 SELECT 列集)、禁用/启用(ACTIVE⇄DISABLED 双向白名单机)、改角色(「权限设置」=改 role,RBAC 仍 role 级不建每用户覆盖表)、重置密码(reason 必填——敏感操作审计纪律)。
2 触发条件与前置状态
user:manage(仅 ADMIN);目标账号存在(404)。
3 输入与校验
| 端点 | 字段 | 校验 |
|---|---|---|
| POST /api/users | role/name/email/password/phone?/hospital_id?/region? | ValidateCreateInput 纯函数(role 白名单/名字非空/邮箱格式/密码强度);邮箱唯一(409 ErrEmailTaken) |
| GET /api/users | role/status/分页 | 过滤可选 |
| POST /api/users/:id/disable | reason 必填 | status 谓词 CAS+version 双谓词(SetUserStatusInTx) |
| POST /api/users/:id/enable | reason 必填 | 同上 |
| POST /api/users/:id/role | role+reason 必填 | role 白名单;version CAS(SetUserRoleInTx) |
| POST /api/users/:id/password | new_password+reason 必填 | 密码强度校验;version 服务端内部查询 CAS |
4 处理流程
5 输出与结果状态
201 {id} / 列表/详情 JSON(敏感列零透出) / 204;被禁用账号下次登录 401(F1.01 三条件之一)。
6 状态流转
user_account.status: ACTIVE⇄DISABLED(SetUserStatusInTx AST 白名单函数);role 变更经 SetUserRoleInTx(lifecycle event=ROLE_CHANGE,from/to 填旧/新角色);SetStatus 拆 Disable/Enable 双 handler(去 c.Path() 路由字面量耦合——M8 核查 B6)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 邮箱已存在 | 409 ErrEmailTaken |
| 非法角色/弱密码 | 400 |
| 并发管理操作 | 409(status+version 双谓词) |
| 重置密码无 reason | 400(M9 ⑤ A1 收口——敏感操作审计纪律对齐 disable) |
| > ⚠ DIF-F10-1(观察项) | 自禁/自改角色现状未拦(无自我保护谓词,user/service.go:159/176)——登记 99-附录 B 观察项 |
8 权限与数据规则
user:manage(ADMIN);全端点 op_log 带 actor/reason;密码 bcrypt;GDPR 抹除走 F4.05(患者数据链与员工停用链分流——ErrNotPatient 409)。
F10.02 TOTP 2FA 管理(三态机+重置)
对应 SRS:§4.2(后台 2FA 机制细化) | 实现落点:
internal/domain/auth/service.go(TOTPSetup/TOTPEnable/TOTPDisable)/internal/domain/user/service.go:242(ResetTOTP)/main.go:24-26(自助三端点)/main.go:254(ADMIN 重置) | 操作入口:web/admin/settings安全设置(自助绑定/解绑);用户管理页「重置 2FA」
1 功能定义
B 端员工自助管理 TOTP:setup 生成密钥(落 PENDING,返 secret+otpauth_url 供 Google Authenticator 扫码)→ enable 验首码转正(ENABLED)→ disable 验现码解绑(回 NONE)。ADMIN 可重置他人(清 secret+NONE+op_log——无法验码正是重置语义)。
2 触发条件与前置状态
- 自助三端点:bo JWT 登录即可(无权限码)。
- setup 前置:
totp_status='NONE'(已 ENABLED 拒绝 409);enable 前置:PENDING。 - 三态机:
NONE → PENDING → ENABLED;PENDING 态不触发两段式登录(NeedsMFA 唯一判据totp_status=='ENABLED'——否则 setup 与登录互相锁死,⚠ DIF-M8 ①)。
3 输入与校验
| 端点 | 字段 | 校验 |
|---|---|---|
| POST /api/auth/totp/setup | 无 | 状态谓词(NONE 才可) |
| POST /api/auth/totp/enable | code(6 位) | 长度 400;TOTPValidate ±1 窗;PENDING 谓词 |
| POST /api/auth/totp/disable | code(6 位) | ENABLED 谓词;验现码 |
| POST /api/users/:id/totp/reset | reason 必填 | user:manage;version 服务端内部查询 CAS |
4 处理流程
setup 生成 20 字节随机密钥 → base32 无 padding 且长度 %8==0(双端约束,⚠ DIF-M5 ⑦)→ AES-256-GCM 加密落库(master_key)→ 返回明文 secret+otpauth URL(仅此一次可见)。enable 校验通过后状态谓词 CAS 转正。
5 输出与结果状态
setup 200 {secret, otpauth_url};enable/disable 204;reset 204;ENABLED 后该账号 F1.01 登录走两段式。
6 状态流转
见 §4 三态图;totp_status 列非 AST 守卫词边界命中(\bstatus\b 不匹配 totp_status——⚠ DIF-M8 ⑥ 扩面注记);RESET 同时清 totp_secret(NULL=未启用哨兵,not_null_guard 白名单第 1 条)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 已 ENABLED 再 setup | 409 |
| PENDING 直接 enable 未 setup | 409(状态谓词) |
| 码错误 | 401/400(按端点语义) |
| master_key 未配置 | 503(2FA 不可用降级可见) |
| ADMIN 重置后 | 该账号回到 NONE,需重新 setup(原密钥作废不可恢复) |
8 权限与数据规则
自助=登录即可(本人 uid);重置=user:manage;密文存储 AES-GCM(master_key 生产必换);op_log user.totp_reset 留痕。
F10.03 生命周期时间线查询
对应 SRS:F-TICK-003(后台详细日志) | 实现落点:
internal/audit/lifecycle_query.go:11(ListByEntity)/audit/handler.go:20/main.go:210(路由挂 ticket:read) | 操作入口:web 工单详情时间线 tab;web 审计页
1 功能定义
统一生命周期端点:按 (entity_type, entity_id) 查 entity_lifecycle_event 全时间线(ORDER BY id)。entity_type 值域=ticket / payment_order / trip / hospital / hospital_dept / hospital_expert / user_account(lifecycle 入口覆盖率守卫:对象类型 ⊆ 已挂时间线入口的类型 ∪ 白名单)。
2 触发条件与前置状态
ticket:read(CONSULTANT/DOCTOR/ADMIN 均可——统一端点设计);A/C 两轴事件同表靠值区分(stage 值与状态枚举天然不重叠——DIF-M7 ①)。
3 输入与校验
| 参数 | 校验 |
|---|---|
| :type | 实体类型(白名单外空结果) |
| :id | 正整数 |
4 处理流程
5 输出与结果状态
事件数组(创建/流转/分配/阶段/文档上传替换全轨迹);工单详情内嵌同数据源单点(Detail.Timeline 与本端点同调 ListByEntity)。
6 状态流转
只读;写入路径全部在 transition 封装内(迁移-日志同路径——无旁路日志)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 类型/id 无事件 | 空数组 |
| 跨类型查询 | 按 type 隔离(不跨实体聚合) |
| 挂点覆盖 | web vitest lifecycle 入口覆盖守卫(对象类型 ⊆ 挂线类型 ∪ 白名单) |
8 权限与数据规则
ticket:read;actor_id 语义(0=系统)+ GDPR 匿名自动传播(F4.05)。
F10.04 操作日志审计页
对应 SRS:§4.2(审计日志) | 实现落点:
internal/audit/operation_log.go:29(Append)/operation_log_query.go:21(QueryOperationLogs)/audit/handler.go:35/main.go:212(ops:read) | 操作入口:web/admin审计页(op_log 倒序)
1 功能定义
操作日志查询(审计页数据源):/api 全部写方法经中间件统一记录(actor_id/actor_role/action/entity/reason/trace_id/changes JSON/at,标准列)+ 关键操作 service 内显式补记;查询端点参数化(entity/actor/action)倒序。
2 触发条件与前置状态
ops:read(CONSULTANT+ADMIN);中间件豁免注册表(读与心跳豁免——AST 守卫检查全部写 handler 命中审计路由组)。
3 输入与校验
| 参数 | 说明 |
|---|---|
| entity_type/entity_id | 过滤 |
| actor_id / action | 过滤 |
| 分页 | 缺省倒序 |
4 处理流程
5 输出与结果状态
日志数组;changes JSON 载字段级 diff(Update 带 audit.Diff——如分配 id 变化/scrubbed 条数)。
6 状态流转
只读;operation_log append-only 永不 UPDATE(GDPR 匿名传播设计依赖 id 引用而非名字)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| op_log 写失败(非事务路径) | 仅 Warn 不回滚业务(尽力而为——transition 路径例外同事务强一致) |
| trace_id | 中间件注入(链路追踪衔接) |
| 回调报文 | payment 路径 reason 含报文截断 512(F7.02) |
8 权限与数据规则
ops:read;日志含敏感操作全留痕(gdpr.erase/user.totp_reset/ticket.assign/payment.transition…)。
F10.05 INBOX 待办与邮件对账重发
对应 SRS:F-REG-003(自动通知顾问,扩展) | 实现落点:
internal/infra/notify/inbox.go:20(ListInbox)/:40(MarkInboxRead)/outbox.go:48(RetryFailed)/:60(ListFailedEmails)/notify/handler.go/main.go:238-243(路由) | 操作入口:web 待办铃铛(INBOX 列表/标读);看板对账 tab(FAILED 邮件重发)
1 功能定义
INBOX 站内待办:按 recipient='role:<ROLE>' 角色谓词隔离列表(register_notify/doctor_rejected 等),已读=回写 read_at(CAS 谓词防重,status 恒 PENDING——投递语义与已读语义分离,⚠ DIF-M5 ⑥)。邮件对账:FAILED 列表查询(ops:read)+ 人工重发(ops:manage:重置 PENDING/retry_count=0,dispatcher 30s 内再投)。
2 触发条件与前置状态
- INBOX:登录即可(角色谓词隔离,无权限码——通知消费不需要权限码)。
- 对账/重发:ops:read / ops:manage 双码分层。
3 输入与校验
| 端点 | 输入 | 校验 |
|---|---|---|
| GET /api/inbox | unread_only/limit | recipient=claims role 谓词 |
| POST /api/inbox/:id/read | :id | read_at IS NULL CAS(幂等) |
| GET /api/notifications/failed | 无 | channel=EMAIL+status=FAILED |
| POST /api/notifications/:id/retry | :id | 须 EMAIL+FAILED(qRetryFailed 谓词,否则空操作) |
4 处理流程
5 输出与结果状态
待办数组/标读条数/FAILED 列表/重发 204;重发后 dispatcher 投递 SENT(dev/e2e log 档日志即送达)。
6 状态流转
INBOX:status 恒 PENDING(dispatcher 只扫 EMAIL 零改动——退避/终态语义不被 INBOX 借用);EMAIL:FAILED→PENDING→SENT。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 重复标读 | 幂等(CAS 命中 0 行) |
| 重发非 FAILED 单 | 空操作(谓词不命中) |
| 重发再失败 | 退避重试 3 败再 FAILED(可再重发) |
8 权限与数据规则
角色谓词=天然行级隔离;ops 双码与 F7.05 同纪律(读写分离)。
F10.06 outbox 投递与重试
对应 SRS:—(基础设施) | 实现落点:
internal/infra/notify/dispatcher.go:45-123(Dispatcher/Tick/dispatchOne)/outbox.go:26(EnqueueInTx)/pure.go(NextRetryAt/Exhausted)/sendgrid.go| 操作入口:—(30s 定时任务,main.go 装配 Start/Stop)
1 功能定义
EMAIL 通道投递器:dispatcher 每 30s 扫描到期 PENDING 单 → Mailer 投递(provider 分流:log=日志即送达 dev/e2e 恒 log;sendgrid=HTTP API,缺 api_key/from 装配期显式失败)→ 发送后回写 CAS(WHERE status='PENDING'——防回写互相覆盖,不防双实例重复投递,单实例边界注释申报——⚠ DIF-M4 ⑭③);失败指数退避(NextRetryAt 纯函数:30s 起翻倍)3 败 FAILED。
2 触发条件与前置状态
服务启动 disp.Start();优雅停机排空(停机序:HTTP 停 → worker 排空 → dispatcher → hub → redis)。
3 输入与校验
扫描谓词:channel='EMAIL' AND status='PENDING' AND next_retry_at<=now;Exhausted(retry_count, max_retries) 纯函数判定终态。
4 处理流程
5 输出与结果状态
outbox 行终态 SENT/FAILED;last_error 截断留痕。
6 状态流转
PENDING → SENT / FAILED(retry_count 随退避递增);INBOX 行不进此路径(channel 过滤)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| SendGrid 未配 api_key/from | 装配期 fatal(配置错误启动即暴露——沿 payment.provider 先例) |
| 缺省(log 档) | 日志即送达(dev/e2e) |
| 双实例部署 | 不防重复投递(单实例边界申报)——回写 CAS 只防互相覆盖 |
| 发送后回写前崩溃 | PENDING 残留下轮重投(at-least-once;收件方幂等由业务语义保证) |
8 权限与数据规则
系统任务(无 actor);按患者语言选模板 i18n(tech-design §10);payload JSON 透传(各业务节点构造)。