19 KiB
03 · 目标架构设计(chis_osi)
本文给出 chis_osi 的整体架构、目录结构、数据流、分层职责与关键设计决策。
设计原则延续团队约定:清晰分层、面向初级维护者、不过度抽象、优先标准库。
1. 设计目标
- 薄客户端:OSI 调用收敛为「MD5 签名 + 统一信封 + HTTP 传输」三件事,一个
Call贯穿。 - 厚映射层:把"PHIS 数据 → OSI 文档契约"的字段/字典映射做成显式、表驱动、可单测的独立层——这是新项目的核心资产。
- 可靠投递:继承旧项目的重试分类/幂等/熔断/批次报告,并用
checkId升级幂等。 - 无状态、弱依赖:删除登录/Cookie/Redis 会话;Redis 仅用于幂等与报告缓存,且完全可选。
- 可联调、可对账、可交接:一套 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 / ...
├── cache/ 公开字典缓存:网格/责任医生/机构 → 映射层主数据反查
├── 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 —— 薄客户端
唯一与平台对话的层。核心是一个泛化调用:
// 伪代码:统一调用。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。这里维护一份经联调校准的结构体与常量,承担"文档坑点的唯一修正点"。 信封统一:
type Envelope struct {
ServiceID string `json:"serviceId"`
UploadInfo any `json:"uploadinfo,omitempty"` // 查询/创建/更新统一信封
}
type UploadInfo struct {
BaseInfo any `json:"baseInfo,omitempty"`
ManageInfo ManageInfo `json:"manageInfo"`
}
type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
4.3 cache —— 公开字典缓存
承接 osi/public.go 的网格、责任医生、机构查询结果,生成内存 DictionarySnapshot,按名称反查创建档案所需的 regionCode、manaDoctorId、manaUnitId。持久化存储通过可选接口接入,Redis 不可用时只影响持久化,不阻断内存快照刷新和映射。
4.4 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.5 source —— 任务源
- 拉取待上送明细(替代旧项目的本地 mock 文件)。
- 任务模型:
{taskId, dataType, payload(PHIS原始), ...}。 - 投递后回写状态
done/retry/failed(旧项目一直 TODO,新项目做实)。 trace_id建议直接用 PHIS 任务号,贯穿日志与报告。
4.6 pipeline —— 投递编排
T-213 已落地可复用的 HealthRecordUpsertService。它接收 source.HealthRecordTask,通过注入的 Converter、HealthRecordClient、IdempotencyStore、ReportSink 和 PHISStatusWriter 完成单条健康档案编排;HTTP handler 与未来 worker 必须复用该服务,不复制分支规则。
T-204 已通过 POST /api/health-record/upsert 接入该服务。HTTP 运行时先执行无网络字段/码表预校验,再按请求中的 manaUnitId 加载责任医生和机构字典,映射层仍执行真实主数据 membership 校验;同步接口使用进程内 MemoryIdempotencyStore 只防同进程并发,成功后释放租约,避免稳定 checkId 阻断后续合法更新。HTTP 模式不执行 PHIS 回调,调用方直接消费结构化 outcome;持久化幂等和真实 PHIS 状态回写仍分别由阶段 3、T-214 承接。
健康档案采用 query-first:映射校验通过后,以稳定 checkId + idCard 的哈希键原子获取幂等租约,再按身份证调用 JKDA00002。明确 0 条才调用 JKDA00001;恰好 1 条且身份证、状态、机构、责任医生、phrId 均安全时调用 JKDA00003;多条或任一授权条件不满足均转 manual_review。查询失败、创建失败、更新失败均不切换另一种写操作。
当前幂等接口是带 owner token 的 Acquire/Complete/Release 契约,完成和释放必须匹配租约 token,防止过期 worker 改写新租约;T-213 只用假实现验证占用和完成语义,Redis/文件持久化、租约超时与恢复属于阶段 3。报告与 PHIS 状态同样仅定义端口,真实回写由 T-214 实现。CHIS 已明确写入成功后,必须先完成幂等记录再发布 done;完成失败时保留业务结果 done、返回基础设施错误且不发送成功通知,调用方不得据此重放 CHIS 写入。副作用失败通过 NotificationError 分别暴露 PriorErr/ReportErr/StatusErr,并只携带失败端的补偿载荷,补偿时不重新执行 upsert。
单条投递流程(继承旧项目 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 次。 - 幂等:健康档案键 =
sha256(checkId + "|" + idCard),原子获取带 owner token 的租约;存储 redis 优先、文件降级。 - 熔断:连续网络失败达阈值则暂停(阈值/休眠秒可配)。
- 批次报告:
total/success/failed/skipped/retry、失败 Top、逐条明细(沿用旧项目格式)。
4.7 observ / store —— 可观测与存储
- 一套 report log(收敛旧项目的 apitrace/reportlog/snapshot 三套):普通日志只记录脱敏标识、serviceId、响应码和最终判定;原始 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. 配置(对比旧项目,做减法)
# === 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. 开放问题(需厂家/联调确认)
- 完整度是否需接入方计算?(
completeLevel/perfection是否由平台算;isFillShhj之外有无完整度入参) - 列表/查询类接口的完整路径与 serviceId(档案列表、自理/体质/中医指导的列表与查询,docx 缺漏)。
- 中医健康指导(zyjkzd)的 serviceId 与字段表。
405之外的错误码字典(是否有更细的失败码用于重试/告警分级)。checkId规则约束(长度 20、是否要求全局唯一、平台是否以 checkId 做幂等去重——若是,则更新走 update 还是 create 幂等覆盖)。deviceSN取值来源(一机构一值还是一设备一值)。- 沙箱环境地址与一组真实可用账号,用于回归样本采集。
这些问题不阻塞架构落地:薄客户端、契约、映射层、流水线均可先按现有文档搭骨架,再以联调样本校准。