# FSD m10 · 系统管理与审计（user + audit + infra/notify）

LLMS 索引： [llms.txt](/llms.txt)

---

> 本册覆盖代码域 `internal/domain/user`、横切包 `internal/audit` 与 `internal/infra/notify`。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §2（双日志轨 §2.6）。

## m10 功能节目录

| ID | 名称 | 路由/入口 |
|---|---|---|
| F10.01 | 用户管理（CRUD/禁用/改角色/重密） | `GET/POST /api/users`、`POST /api/users/:id/disable|enable|role|password` |
| F10.02 | TOTP 2FA 管理（三态机+重置） | `POST /api/auth/totp/setup|enable|disable`、`POST /api/users/:id/totp/reset` |
| 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 处理流程

```mermaid
sequenceDiagram
    participant W as web 用户管理页
    participant H as user.Handler
    participant S as user.Service
    participant DB as MySQL
    W->>H: POST /api/users {role,name,email,password,…}
    H->>S: Create（ValidateCreateInput + 邮箱唯一 + bcrypt）
    S->>DB: INSERT user_account(ACTIVE, totp_status='NONE') + op_log(user.create)
    H-->>W: 201 {id}
    W->>H: POST /api/users/:id/disable {reason}
    H->>S: SetStatus(DISABLED)
    S->>DB: WithTx: SetUserStatusInTx（status+version 双谓词 CAS → lifecycle + op_log）
    H-->>W: 204 / 409
```

##### 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 处理流程

```mermaid
stateDiagram-v2
    NONE --> PENDING: setup 生成密钥（AES-GCM 落库）
    PENDING --> ENABLED: enable 验首码
    ENABLED --> NONE: disable 验现码 / ADMIN 重置
```

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 处理流程

```mermaid
flowchart LR
    A["GET /api/lifecycle/ticket/123"] --> B["SELECT * FROM entity_lifecycle_event<br/>WHERE entity_type+entity_id ORDER BY id"]
    B --> C["时间线数组（event/from_value/to_value/actor_id/at）"]
```

##### 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 处理流程

```mermaid
flowchart LR
    A["写请求 /api/*"] --> B["audit 中间件统一 Append<br/>（豁免注册表：读/心跳）"]
    C["关键操作 service 内"] --> D["显式 Append 带 reason/changes"]
    B --> E[(operation_log append-only)]
    D --> E
    F["GET /api/operation-logs"] --> G["QueryOperationLogs 参数化倒序"]
    E --> G
```

##### 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 处理流程

```mermaid
flowchart LR
    A["GET /api/inbox"] --> B["recipient='role:'+claims.role 谓词<br/>read_at IS NULL 可过滤"]
    C["POST /api/inbox/:id/read"] --> D["UPDATE read_at WHERE read_at IS NULL（幂等）"]
    E["GET /api/notifications/failed"] --> F["EMAIL+FAILED 列表"]
    G["POST /api/notifications/:id/retry"] --> H["重置 PENDING/retry_count=0 → dispatcher 30s 再投"]
```

##### 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 处理流程

```mermaid
flowchart LR
    A[每 30s Tick] --> B["SELECT 到期 PENDING（EMAIL）"]
    B --> C["dispatchOne：Mailer.Send"]
    C -->|成功| D["回写 CAS SENT（WHERE status='PENDING'）"]
    C -->|失败| E["retry_count+1 + next_retry_at=NextRetryAt（指数退避）"]
    E -->|Exhausted 3 败| F[FAILED（人工重发 F10.05）]
    E -->|未耗尽| B
```

##### 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 透传（各业务节点构造）。

---

反链：

- [功能规格说明书(FSD)](/prd/fsd-medilink/)
- [MediLink Global 功能规格说明书（FSD）v1.0 · 总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/)
