Files
chis_osi/docs/03-目标架构设计.md
T

268 lines
16 KiB
Markdown
Raw Normal View History

# 03 · 目标架构设计(chis_osi)
本文给出 `chis_osi` 的整体架构、目录结构、数据流、分层职责与关键设计决策。
设计原则延续团队约定:**清晰分层、面向初级维护者、不过度抽象、优先标准库**。
---
## 1. 设计目标
1. **薄客户端**:OSI 调用收敛为「MD5 签名 + 统一信封 + HTTP 传输」三件事,一个 `Call` 贯穿。
2. **厚映射层**:把"PHIS 数据 → OSI 文档契约"的字段/字典映射做成**显式、表驱动、可单测**的独立层——这是新项目的核心资产。
3. **可靠投递**:继承旧项目的重试分类/幂等/熔断/批次报告,并用 `checkId` 升级幂等。
4. **无状态、弱依赖**:删除登录/Cookie/Redis 会话;Redis 仅用于幂等与报告缓存,且完全可选。
5. **可联调、可对账、可交接**:一套 report log + 快照,trace 贯穿 PHIS 任务号 / checkId。
---
## 2. 系统上下文
```
┌────────────┐ 拉取待上送任务/明细 ┌─────────────────────────────┐ POST /osi/api/... ┌──────────────┐
│ PHIS │ ───────────────────────▶ │ chis_osi │ ────签名+信封+JSON──▶ │ CHIS OSI │
│ 公卫上游 │ ◀─────────────────────── │ (本项目:转换 + 投递 + 对账) │ ◀──── code/data ───── │ 省基卫平台 │
└────────────┘ 回写状态(done/fail) └─────────────────────────────┘ └──────────────┘
│ ▲
字典缓存读写 │ │ 可观测/幂等
▼ │
┌──────────────┐
│ Redis(可选) │
│ + 本地文件 │
└──────────────┘
```
两种运行形态(同一份代码、子命令切换):
- **server**:对外提供同步 HTTP API(供其它系统直接调用投递),便于联调与按需触发。
- **deliver-worker**:定时从 PHIS 拉取 → 映射 → 投递 → 回写状态,是生产主力。
---
## 3. 目录结构
采用**扁平布局**(顶层平铺包、单 `main.go` + `-mode` 子命令),与参考项目 `chis_upload` 保持同款形状,降低团队接手成本。
与 `chis_upload` 的差别只在"内容"——无 `chis/`(逆向)、无 `middleware/`(cookie)、无 SM2,新增 `osi/`/`mapping/`/`pipeline/`。
```
chis_osi/
├── main.go 入口:-mode server|deliver(沿用 chis_upload 的单入口 + flag 风格)
├── config/ viper 配置(osi/phis/redis/proxy)
├── osi/ ★ 薄客户端:与平台的全部交互
│ ├── sign.go MD5 签名(ts + password)
│ ├── transport.go HTTP 发送、超时、SOCKS5
│ ├── client.go Call(ctx, serviceId, body, &out):注入头+信封+发送+判码
│ ├── codes.go 返回码常量与成功/可重试判定
│ ├── jkda.go 档案:Create/Update/Find/FindRqbj
│ ├── jktj.go 体检:Create/Update/Query/List/Last
│ ├── lnr.go 老年人:自理评估、中医体质辨识
│ ├── zyjkzd.go 中医健康指导
│ └── public.go 网格/责任医生/药品/机构 字典查询
├── contract/ ★ 校验后的接口契约(请求/响应结构体 + serviceId 常量)
│ ├── envelope.go 通用信封:{serviceId, uploadinfo|baseInfo, manageInfo}
│ └── jkda.go / jktj.go / lnr.go / ...
├── mapping/ ★ PHIS→OSI 映射(本项目核心)
│ ├── dict.go 码表:性别/民族/血型/职业/文化程度/婚姻/医保...(双向)
│ ├── health_record.go 档案字段映射 + 校验
│ ├── health_check.go 体检字段映射
│ ├── elderly.go 老年人映射
│ └── checkid.go checkId 生成(确定性,幂等键来源)
├── source/ PHIS 拉取客户端 + 任务模型 + 状态回写
├── pipeline/ 投递编排:校验→映射→调用→分类→重试→幂等→报告
│ ├── deliver.go 单条投递
│ ├── retry.go 重试与错误分类
│ ├── idempotency.go 基于 checkId 的去重存储(redis|file)
│ ├── circuit.go 熔断
│ └── report.go 批次报告
├── observ/ report log(redis 优先,文件降级)+ 快照 + trace
├── store/ redis 客户端封装(可选)+ 文件存储
├── handler/ HTTP handlers(server 模式对外接口)
├── router/ 路由
├── docs/
└── AGENTS.md / CLAUDE.md 协作规范
```
> 不使用 `internal/` 与 `cmd/`:团队 `AGENTS.md` 偏好扁平、避免过度抽象,且 `chis_upload` 已是单 `main.go`+`-mode` 形态,保持一致优先于"标准 Go 布局"。
---
## 4. 分层职责(自底向上)
### 4.1 `osi` —— 薄客户端
唯一与平台对话的层。核心是一个泛化调用:
```go
// 伪代码:统一调用。serviceId 固定常量;body 是已映射好的数据节点;out 反序列化目标。
func (c *Client) Call(ctx context.Context, serviceID string, body any, out any) (Result, error) {
ts := nowMillis() // 13 位毫秒
pwd := md5Hex("ts=" + ts + "&ask=" + c.ask) // 32 位小写
headers := map[string]string{
"Content-Type": "application/json",
"orgCode": c.orgCode,
"deviceSN": c.deviceSN,
"ts": ts,
"userName": c.userName,
"password": pwd,
}
payload := envelope{ServiceID: serviceID, Body: body} // 按 create/query 决定 uploadinfo|baseInfo
status, raw, err := c.transport.PostJSON(ctx, c.baseURL+"/osi/api"+pathOf(serviceID), payload, headers)
// 记录 report log;解析 {code,message,data};按 code 判定成功/超时(405)
...
}
```
要点:
- **签名只在这一层**,业务层完全无感。
- `code == "1"` 成功;`405` 超时(可重试);其它失败。统一在 `codes.go` 判定。
- 各 `jkda.go/jktj.go/...` 只是给 `Call` 包一层带类型的方法(如 `CreateHealthRecord(ctx, *contract.HealthRecordCreate)`)。
- 传输层从旧项目 `transport.go`/`http_client.go` 直接移植(去掉 cookiejar、去掉网页拟态头)。
### 4.2 `contract` —— 校验后的接口契约
按 01 文档第 7 节,**不照搬 docx**。这里维护一份经联调校准的结构体与常量,承担"文档坑点的唯一修正点"。
信封统一:
```go
type Envelope struct {
ServiceID string `json:"serviceId"`
UploadInfo any `json:"uploadinfo,omitempty"` // 创建/更新
BaseInfo any `json:"baseInfo,omitempty"` // 查询
}
type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
```
### 4.3 `mapping` —— PHIS→OSI 映射(核心)
把旧项目"对齐 Chrome"的隐式逻辑,重写为"对齐文档"的显式逻辑:
- **字典层 `dict.go`**:集中所有码表(性别 0/1/2/9、民族 56 项、血型、职业、文化程度、婚姻、医保支付方式、人群标记 personSign 等)。提供 `PHISToOSI` / `OSIToPHIS` 双向查表,未命中有明确报错而非静默丢值。
- **字段映射**:每个业务域一个文件,函数签名形如 `func MapHealthRecord(src phis.Record) (contract.HealthRecordCreate, []ValidationError)`。返回结构化校验错误,缺必填字段在投递前就拦截。
- **多选字段**:既往史/家族史/残疾等"逗号拼接多选"集中处理。
- **`checkId` 生成 `checkid.go`**:确定性生成(如 `源系统ID|业务类型|源记录主键` 哈希),保证同一源记录重试得到同一 checkId → 平台侧不重复建档。**这是幂等的根。**
> 完整度(completeLevel/perfection):默认**不本地计算**,按文档字段如实上送,依赖平台计算。
> 若联调发现平台要求接入方计算,再把旧项目 `health_record_complete_level.go`/`health_check_perfection.go` 移植进 `mapping/`(开放问题,见第 8 节)。
### 4.4 `source` —— 任务源
- 拉取待上送明细(替代旧项目的本地 mock 文件)。
- 任务模型:`{taskId, dataType, payload(PHIS原始), ...}`。
- 投递后回写状态 `done/retry/failed`(旧项目一直 TODO,新项目做实)。
- `trace_id` 建议直接用 PHIS 任务号,贯穿日志与报告。
### 4.5 `pipeline` —— 投递编排
单条投递流程(继承旧项目 worker 经验):
```
拉取任务 → 校验+按 dataType 分流 → mapping 映射(+校验) → 计算 checkId
→ 幂等检查(命中 success 则跳过) → osi.Call → 按 code 分类
→ 成功:记 success + 回写 PHIS done
→ 可重试失败:退避重试(最多 N 次) / 触发熔断
→ 不可重试失败:记 failed + 回写 PHIS failed
→ 全程写 report log,结束出批次报告
```
- **重试分类**(移植并适配 OSI):网络错误 / HTTP 5xx / 429 / `code==405`(超时) 可重试;参数错、权限错、映射校验错不可重试;退避 `2s*attempt`,最多 3 次。
- **幂等**:键 = `checkId`(替代旧项目报文哈希);存储 redis 优先、文件降级。
- **熔断**:连续网络失败达阈值则暂停(阈值/休眠秒可配)。
- **批次报告**:`total/success/failed/skipped/retry`、失败 Top、逐条明细(沿用旧项目格式)。
### 4.6 `observ` / `store` —— 可观测与存储
- 一套 report log(收敛旧项目的 apitrace/reportlog/snapshot 三套):记录每次 PHIS 输入、映射结果、OSI 请求/响应、最终判定。
- Redis 优先、本地 JSONL 降级;Redis 启动失败不阻断(沿用旧项目)。
---
## 5. 关键时序:一条体检记录的投递
```
deliver-worker source mapping osi.Client CHIS OSI
│ 拉取任务 │ │ │ │
│ ───────────────────▶│ │ │ │
│ ◀─ task(原始体检) │ │ │ │
│ MapHealthCheck ─────────────────────▶ │ │ │
│ ◀─ contract + 校验ok │ │ │ │
│ 计算 checkId / 幂等检查(未命中) │ │ │
│ Call(JKTJ00001) ───────────────────────────────────────▶ │ │
│ │ │ 签名+信封+POST ──────────────────▶ │
│ │ │ │ ◀─ {code:"1"} ── │
│ ◀── Result(success, phrId...) ──────────────────────────── │ │
│ 记 success + report │ │ │ │
│ 回写 PHIS done ────▶│ │ │ │
```
失败分支:`code!="1"` 且非超时 → 不重试,记 failed + 回写;网络错误/`405` → 退避重试,超次数计熔断。
---
## 6. 配置(对比旧项目,做减法)
```yaml
# === OSI 平台 ===
# 访问模式:OSI 主机在内网、不可直连,必须经 SOCKS5 代理(host+proxy 组合,与 chis_upload 一致)
osi_base_url: "http://<内网hostname:port>" # OSI 主机内网地址,不含 /osi/api
osi_org_code: "<机构社会信用代码>"
osi_user_name: "<平台分配用户名/DSFMC>"
osi_ask: "<平台下发密钥,仅参与签名>" # 敏感,建议走环境变量
osi_device_sn: "<设备序列号>"
osi_operate_user: "<默认责任医生ID>" # 可被任务覆盖
osi_timeout_sec: 20
socks5_proxy: "<ip:port>" # 必填:到达 OSI 内网主机的唯一通路,留空才直连
# === PHIS 任务源 ===
phis_base_url: "..."
phis_token: "..."
phis_region_code: "..."
phis_poll_interval_sec: 5
# === Redis(可选,仅幂等/报告缓存)===
redis_addr: "" # 留空=纯文件模式
redis_db: 0
report_log_file_dir: "logs"
# === 投递 ===
retry_max: 3
circuit_fail_threshold: 5
circuit_sleep_sec: 60
```
> **host + proxy 组合是固定访问方式,不是可选项**:参考 chis_upload 用 `chis_host=172.29.20.71:9002` + `socks5_proxy=192.168.3.148:11003`,OSI 主机同样位于内网、只能经 SOCKS5 代理访问。传输层(移植自 chis_upload 的 `http_client.go`)已内建 SOCKS5 拨号支持。
> 注意作用域:`socks5_proxy` **仅作用于 OSI 客户端**;PHIS 任务源走各自地址直连(chis_upload 里 PHIS 是公网 `163.177.185.170:4000`,不经代理),不要把 PHIS 流量也塞进该代理。
>
> **访问语义(固定,不做"智能回退")**:`socks5_proxy` 非空即强制走代理、**不因代理故障回退直连**;空才直连。初始化仅校验代理地址格式,不探测连通性;代理不通时请求按网络错误如实失败,交由重试/熔断处理,以暴露"代理故障"而非掩盖成"主机不可达"。
**已删除**(相比旧项目):`sm2_pub_key`、`cookie_ttl`、`chrome_payload_compat_enable`、`health_record_debug_log`、`api_trace_*`、登录账号密码、role hint。
> 敏感项 `osi_ask` 不写进仓库配置,按 AGENTS.md「不硬编码密钥」走环境变量或部署密文。
---
## 7. 与旧项目的映射对照(一图流)
| 旧项目 | 新项目 | 变化 |
| --- | --- | --- |
| `util/encrypt.go`(SM2) | `osi/sign.go`(MD5) | 重写,体量骤降 |
| `chis/login.go`+`middleware/cookie.go`+`RedisCookieAuth`+`validate_cookie.go` | — | 整体删除 |
| `chis/identity_chain.go` | — | 删除(用 idCard+checkId) |
| `chis/client.go`/`transport.go`/`http_client.go` | `osi/client.go`/`transport.go` | 移植简化(去 cookiejar/拟态头) |
| `chis/*_payload_normalizer.go`/`*_enricher.go` | `mapping/*.go` | 语义从"对齐网页"改为"对齐文档" |
| `chis/*_complete_level.go`/`*_perfection.go` | (默认删除,待联调) | 开放问题 |
| `worker/`(retry/idempotency/circuit/report) | `pipeline/` | 保留升级,checkId 做幂等键 |
| `util/reportlog.go`+`apitrace.go`+snapshot | `observ/` | 三套收敛为一套 |
| `model/` | `contract/` | 改为"校验后契约" |
---
## 8. 开放问题(需厂家/联调确认)
1. **完整度是否需接入方计算**?(`completeLevel`/`perfection` 是否由平台算;`isFillShhj` 之外有无完整度入参)
2. **列表/查询类接口的完整路径与 serviceId**(档案列表、自理/体质/中医指导的列表与查询,docx 缺漏)。
3. **中医健康指导(zyjkzd)的 serviceId 与字段表**。
4. **`405` 之外的错误码字典**(是否有更细的失败码用于重试/告警分级)。
5. **`checkId` 规则约束**(长度 20、是否要求全局唯一、平台是否以 checkId 做幂等去重——若是,则更新走 update 还是 create 幂等覆盖)。
6. **`deviceSN` 取值来源**(一机构一值还是一设备一值)。
7. **沙箱环境地址与一组真实可用账号**,用于回归样本采集。
> 这些问题不阻塞架构落地:薄客户端、契约、映射层、流水线均可先按现有文档搭骨架,再以联调样本校准。