feat(cache): 建立字典缓存快照(T-103)

This commit is contained in:
ila
2026-07-07 21:16:52 +08:00
parent 74748c084b
commit 12346c9923
10 changed files with 295 additions and 15 deletions
+119
View File
@@ -0,0 +1,119 @@
package cache
import (
"context"
"strings"
"sync"
"chis_osi/osi"
)
type DictionarySnapshot struct {
regionByName map[string]string
doctorByName map[string]string
orgByName map[string]string
}
type PublicClient interface {
QueryGridAddress(context.Context, osi.GridAddressQuery) ([]osi.GridAddress, osi.Result, error)
QueryDoctors(context.Context, osi.DoctorQuery) ([]osi.Doctor, osi.Result, error)
QueryOrgs(context.Context, osi.OrgQuery) ([]osi.Org, osi.Result, error)
}
type PersistentStore interface {
SaveDictionarySnapshot(context.Context, DictionarySnapshot) error
}
type RefreshParams struct {
Grid osi.GridAddressQuery
Doctors osi.DoctorQuery
Orgs osi.OrgQuery
}
type DictionaryService struct {
client PublicClient
store PersistentStore
mu sync.RWMutex
memory DictionarySnapshot
}
func NewDictionarySnapshot(grids []osi.GridAddress, doctors []osi.Doctor, orgs []osi.Org) DictionarySnapshot {
snapshot := DictionarySnapshot{
regionByName: make(map[string]string, len(grids)),
doctorByName: make(map[string]string, len(doctors)),
orgByName: make(map[string]string, len(orgs)),
}
for _, row := range grids {
put(snapshot.regionByName, row.RegionName, row.RegionCode)
}
for _, row := range doctors {
put(snapshot.doctorByName, row.PersonName, row.PersonID)
}
for _, row := range orgs {
put(snapshot.orgByName, row.OrganizName, row.OrganizCode)
}
return snapshot
}
func NewDictionaryService(client PublicClient, store PersistentStore) *DictionaryService {
return &DictionaryService{client: client, store: store}
}
func (s *DictionaryService) Refresh(ctx context.Context, params RefreshParams) (DictionarySnapshot, error) {
grids, _, err := s.client.QueryGridAddress(ctx, params.Grid)
if err != nil {
return DictionarySnapshot{}, err
}
doctors, _, err := s.client.QueryDoctors(ctx, params.Doctors)
if err != nil {
return DictionarySnapshot{}, err
}
orgs, _, err := s.client.QueryOrgs(ctx, params.Orgs)
if err != nil {
return DictionarySnapshot{}, err
}
snapshot := NewDictionarySnapshot(grids, doctors, orgs)
s.mu.Lock()
s.memory = snapshot
s.mu.Unlock()
if s.store != nil {
_ = s.store.SaveDictionarySnapshot(ctx, snapshot)
}
return snapshot, nil
}
func (s *DictionaryService) Snapshot() DictionarySnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return s.memory
}
func (s DictionarySnapshot) RegionCodeByName(name string) (string, bool) {
return lookup(s.regionByName, name)
}
func (s DictionarySnapshot) DoctorIDByName(name string) (string, bool) {
return lookup(s.doctorByName, name)
}
func (s DictionarySnapshot) ManaUnitIDByName(name string) (string, bool) {
return lookup(s.orgByName, name)
}
func put(index map[string]string, name string, code string) {
name = strings.TrimSpace(name)
code = strings.TrimSpace(code)
if name == "" || code == "" {
return
}
if _, exists := index[name]; !exists {
index[name] = code
}
}
func lookup(index map[string]string, name string) (string, bool) {
code, ok := index[strings.TrimSpace(name)]
return code, ok
}
+76
View File
@@ -0,0 +1,76 @@
package cache
import (
"context"
"errors"
"testing"
"chis_osi/osi"
)
func TestDictionarySnapshotLooksUpMasterDataByName(t *testing.T) {
snapshot := NewDictionarySnapshot(
[]osi.GridAddress{{RegionCode: "441625000000", RegionName: "测试网格"}},
[]osi.Doctor{{PersonID: "doc-001", PersonName: "测试医生"}},
[]osi.Org{{OrganizCode: "123456789", OrganizName: "测试机构"}},
)
if got, ok := snapshot.RegionCodeByName("测试网格"); !ok || got != "441625000000" {
t.Fatalf("RegionCodeByName = %q, %v", got, ok)
}
if got, ok := snapshot.DoctorIDByName("测试医生"); !ok || got != "doc-001" {
t.Fatalf("DoctorIDByName = %q, %v", got, ok)
}
if got, ok := snapshot.ManaUnitIDByName("测试机构"); !ok || got != "123456789" {
t.Fatalf("ManaUnitIDByName = %q, %v", got, ok)
}
}
func TestDictionaryServiceRefreshKeepsMemoryWhenPersistentStoreFails(t *testing.T) {
client := fakePublicClient{
grids: []osi.GridAddress{{RegionCode: "441625000000", RegionName: "测试网格"}},
doctors: []osi.Doctor{{PersonID: "doc-001", PersonName: "测试医生"}},
orgs: []osi.Org{{OrganizCode: "123456789", OrganizName: "测试机构"}},
}
service := NewDictionaryService(client, failingStore{})
snapshot, err := service.Refresh(context.Background(), RefreshParams{
Grid: osi.GridAddressQuery{ParentCode: "441625", PageNo: 1, OperateUser: "712041"},
Doctors: osi.DoctorQuery{ManaUnitID: "123456789", OperateUser: "712041"},
Orgs: osi.OrgQuery{OrganizCode: "123456789"},
})
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if got, ok := snapshot.DoctorIDByName("测试医生"); !ok || got != "doc-001" {
t.Fatalf("snapshot doctor = %q, %v", got, ok)
}
cached := service.Snapshot()
if got, ok := cached.RegionCodeByName("测试网格"); !ok || got != "441625000000" {
t.Fatalf("memory snapshot region = %q, %v", got, ok)
}
}
type fakePublicClient struct {
grids []osi.GridAddress
doctors []osi.Doctor
orgs []osi.Org
}
func (c fakePublicClient) QueryGridAddress(context.Context, osi.GridAddressQuery) ([]osi.GridAddress, osi.Result, error) {
return c.grids, osi.Result{Code: "01", Success: true}, nil
}
func (c fakePublicClient) QueryDoctors(context.Context, osi.DoctorQuery) ([]osi.Doctor, osi.Result, error) {
return c.doctors, osi.Result{Code: "01", Success: true}, nil
}
func (c fakePublicClient) QueryOrgs(context.Context, osi.OrgQuery) ([]osi.Org, osi.Result, error) {
return c.orgs, osi.Result{Code: "01", Success: true}, nil
}
type failingStore struct{}
func (failingStore) SaveDictionarySnapshot(context.Context, DictionarySnapshot) error {
return errors.New("redis unavailable")
}
+9 -4
View File
@@ -59,6 +59,7 @@ chis_osi/
├── contract/ ★ 校验后的接口契约(请求/响应结构体 + serviceId 常量) ├── contract/ ★ 校验后的接口契约(请求/响应结构体 + serviceId 常量)
│ ├── envelope.go 通用信封:{serviceId, uploadinfo|baseInfo, manageInfo} │ ├── envelope.go 通用信封:{serviceId, uploadinfo|baseInfo, manageInfo}
│ └── jkda.go / jktj.go / lnr.go / ... │ └── jkda.go / jktj.go / lnr.go / ...
├── cache/ 公开字典缓存:网格/责任医生/机构 → 映射层主数据反查
├── mapping/ ★ PHIS→OSI 映射(本项目核心) ├── mapping/ ★ PHIS→OSI 映射(本项目核心)
│ ├── dict.go 码表:性别/民族/血型/职业/文化程度/婚姻/医保...(双向) │ ├── dict.go 码表:性别/民族/血型/职业/文化程度/婚姻/医保...(双向)
│ ├── health_record.go 档案字段映射 + 校验 │ ├── health_record.go 档案字段映射 + 校验
@@ -133,7 +134,11 @@ type UploadInfo struct {
type ManageInfo struct { DSFMC, OperateUnit, OperateUser string } type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
``` ```
### 4.3 `mapping` —— PHIS→OSI 映射(核心) ### 4.3 `cache` —— 公开字典缓存
承接 `osi/public.go` 的网格、责任医生、机构查询结果,生成内存 `DictionarySnapshot`,按名称反查创建档案所需的 `regionCode`、`manaDoctorId`、`manaUnitId`。持久化存储通过可选接口接入,Redis 不可用时只影响持久化,不阻断内存快照刷新和映射。
### 4.4 `mapping` —— PHIS→OSI 映射(核心)
把旧项目"对齐 Chrome"的隐式逻辑,重写为"对齐文档"的显式逻辑: 把旧项目"对齐 Chrome"的隐式逻辑,重写为"对齐文档"的显式逻辑:
@@ -145,14 +150,14 @@ type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
> 完整度(completeLevel/perfection):默认**不本地计算**,按文档字段如实上送,依赖平台计算。 > 完整度(completeLevel/perfection):默认**不本地计算**,按文档字段如实上送,依赖平台计算。
> 若联调发现平台要求接入方计算,再把旧项目 `health_record_complete_level.go`/`health_check_perfection.go` 移植进 `mapping/`(开放问题,见第 8 节)。 > 若联调发现平台要求接入方计算,再把旧项目 `health_record_complete_level.go`/`health_check_perfection.go` 移植进 `mapping/`(开放问题,见第 8 节)。
### 4.4 `source` —— 任务源 ### 4.5 `source` —— 任务源
- 拉取待上送明细(替代旧项目的本地 mock 文件)。 - 拉取待上送明细(替代旧项目的本地 mock 文件)。
- 任务模型:`{taskId, dataType, payload(PHIS原始), ...}`。 - 任务模型:`{taskId, dataType, payload(PHIS原始), ...}`。
- 投递后回写状态 `done/retry/failed`(旧项目一直 TODO,新项目做实)。 - 投递后回写状态 `done/retry/failed`(旧项目一直 TODO,新项目做实)。
- `trace_id` 建议直接用 PHIS 任务号,贯穿日志与报告。 - `trace_id` 建议直接用 PHIS 任务号,贯穿日志与报告。
### 4.5 `pipeline` —— 投递编排 ### 4.6 `pipeline` —— 投递编排
单条投递流程(继承旧项目 worker 经验): 单条投递流程(继承旧项目 worker 经验):
@@ -170,7 +175,7 @@ type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
- **熔断**:连续网络失败达阈值则暂停(阈值/休眠秒可配)。 - **熔断**:连续网络失败达阈值则暂停(阈值/休眠秒可配)。
- **批次报告**:`total/success/failed/skipped/retry`、失败 Top、逐条明细(沿用旧项目格式)。 - **批次报告**:`total/success/failed/skipped/retry`、失败 Top、逐条明细(沿用旧项目格式)。
### 4.6 `observ` / `store` —— 可观测与存储 ### 4.7 `observ` / `store` —— 可观测与存储
- 一套 report log(收敛旧项目的 apitrace/reportlog/snapshot 三套):记录每次 PHIS 输入、映射结果、OSI 请求/响应、最终判定。 - 一套 report log(收敛旧项目的 apitrace/reportlog/snapshot 三套):记录每次 PHIS 输入、映射结果、OSI 请求/响应、最终判定。
- Redis 优先、本地 JSONL 降级;Redis 启动失败不阻断(沿用旧项目)。 - Redis 优先、本地 JSONL 降级;Redis 启动失败不阻断(沿用旧项目)。
+1 -1
View File
@@ -92,7 +92,7 @@ manaDoctorId/operateUser ← QueryDoctors(manaUnitId) // 责任医生
manaUnitId/organizCode ← QueryOrgs(...) // 机构 manaUnitId/organizCode ← QueryOrgs(...) // 机构
``` ```
策略:启动或定时拉取这些字典,缓存到本地(redis 可选 + 内存),映射时按 PHIS 的地址/医生/机构名称反查 OSI 码。命中失败计入校验错误,不投递。 策略:启动或定时拉取这些字典,缓存到 `cache.DictionarySnapshot`(内存为必选,持久化接口可接 Redis)。映射时通过 `MapContext.MasterData` 按 PHIS 的地址/医生/机构名称反查 OSI 码:`RegionCodeByName`、`DoctorIDByName`、`ManaUnitIDByName`。命中失败仍表现为必填校验错误,不投递;Redis/持久化失败不阻断内存快照刷新。
--- ---
+1 -1
View File
@@ -22,7 +22,7 @@
- [x] 实现 `public.go` 四个查询:网格/责任医生/药品/机构。 - [x] 实现 `public.go` 四个查询:网格/责任医生/药品/机构。
- [x] `mapping/dict.go` 落地全部码表(含 56 项民族)。 - [x] `mapping/dict.go` 落地全部码表(含 56 项民族)。
- [ ] 字典缓存(内存 + redis 可选),供映射层反查 `regionCode/manaDoctorId/manaUnitId`。 - [x] 字典缓存(内存 + redis 可选),供映射层反查 `regionCode/manaDoctorId/manaUnitId`。
- **验收**:能用真实机构码查到下级网格、责任医生、机构树。 - **验收**:能用真实机构码查到下级网格、责任医生、机构树。
## 阶段 2 · 健康档案闭环(第一条业务线) ## 阶段 2 · 健康档案闭环(第一条业务线)
+10 -7
View File
@@ -6,14 +6,14 @@
## 当前快照 ## 当前快照
- 日期:2026-07-07 - 日期:2026-07-07
- 阶段:**Phase D 公开字典查询完成**;T-001~T-006、T-201、T-203、T-102、T-202、T-205、T-101 已验收,下一步进入 T-103 字典缓存 - 阶段:**Phase D 字典缓存完成**;T-001~T-006、T-201、T-203、T-102、T-202、T-205、T-101、T-103 已验收,下一步进入 T-206 健康档案 Create/Update
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find、JKDA00005 FindRqbj,以及 WGDZ/ZRYS/YPML/CXJG 四个公开查询薄封装 - 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find、JKDA00005 FindRqbj,以及 WGDZ/ZRYS/YPML/CXJG 四个公开查询薄封装
- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`contract/jkda.go`、`osi/sign.go`、`osi/transport.go`、`osi/codes.go`、`osi/client.go`、`osi/jkda.go`、`osi/public.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`mapping/dict.go`、`mapping/health_record.go`、`mapping/checkid.go` 已建立映射纯函数与码表基线;`pipeline/` 等业务模块仍待后续任务建立 - 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`contract/jkda.go`、`osi/` 薄客户端、`cache/dictionary.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`mapping/dict.go`、`mapping/health_record.go`、`mapping/checkid.go` 已建立映射纯函数、码表基线和主数据反查接入点;`pipeline/` 等业务模块仍待后续任务建立
- 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;**公开查询 WGDZ00001/ZRYS00001/CXJG00002 已用真实档案主数据验证通过**,均返回 `code="01"` 且数组非空;药品目录 YPML00001 已完成客户端封装和单测,尚未做真实药品关键字样本验证 - 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;**公开查询 WGDZ00001/ZRYS00001/CXJG00002 已用真实档案主数据验证通过**,均返回 `code="01"` 且数组非空;药品目录 YPML00001 已完成客户端封装和单测,尚未做真实药品关键字样本验证
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00002 Find、JKDA00005 FindRqbj、公开查询四接口、映射码表双向查找、民族 01~56 完整性、健康档案映射必填/码表校验、checkId 确定性、docx/联调风格映射样本基线、JKDA00002 验证入口 - 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00002 Find、JKDA00005 FindRqbj、公开查询四接口、字典缓存快照与持久化失败不阻断、映射码表双向查找、民族 01~56 完整性、健康档案映射必填/码表校验、主数据名称反查、checkId 确定性、docx/联调风格映射样本基线、JKDA00002 验证入口
- 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example` - 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example`
- 标准验证路径:`go test ./...`、`go build ./...` - 标准验证路径:`go test ./...`、`go build ./...`
- 当前 blocker:无硬 blocker。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实请求通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4,不阻塞当前 T-103/T-206 路径 - 当前 blocker:无硬 blocker。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实请求通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4,不阻塞当前 T-206 路径
## 当前目录要点 ## 当前目录要点
@@ -29,6 +29,7 @@
| `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 | | `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 |
| `osi/jkda.go` | 已有 | JKDA00002 Find 与 JKDA00005 FindRqbj | | `osi/jkda.go` | 已有 | JKDA00002 Find 与 JKDA00005 FindRqbj |
| `osi/public.go` | 已有 | WGDZ00001 网格、ZRYS00001 责任医生、YPML00001 药品、CXJG00002 机构查询 | | `osi/public.go` | 已有 | WGDZ00001 网格、ZRYS00001 责任医生、YPML00001 药品、CXJG00002 机构查询 |
| `cache/dictionary.go` | 已有 | 公开字典内存快照;按名称反查 `regionCode`/`manaDoctorId`/`manaUnitId`;可选持久化失败不阻断 |
| `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 | | `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 |
| `config.yaml.example` | 已有 | 占位配置,不含真实凭据 | | `config.yaml.example` | 已有 | 占位配置,不含真实凭据 |
| `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` | | `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` |
@@ -37,7 +38,7 @@
| `scripts/` | 已有·不入库 | Python 联调脚本(硬编码真实凭据与身份证,勿提交) | | `scripts/` | 已有·不入库 | Python 联调脚本(硬编码真实凭据与身份证,勿提交) |
| `config.yaml` | 已有·不入库 | 真实/本地配置;不要提交 | | `config.yaml` | 已有·不入库 | 真实/本地配置;不要提交 |
| `mapping/dict.go` | 已有 | 性别/民族/血型/RH/文化程度/职业/婚姻/医保/personSign 双向码表;未命中返回 `ValidationError` | | `mapping/dict.go` | 已有 | 性别/民族/血型/RH/文化程度/职业/婚姻/医保/personSign 双向码表;未命中返回 `ValidationError` |
| `mapping/health_record.go` `mapping/checkid.go` | 已有 | 健康档案映射草稿、字典快照注入、必填/码表校验、确定性 checkId | | `mapping/health_record.go` `mapping/checkid.go` | 已有 | 健康档案映射草稿、字典快照注入、主数据反查、必填/码表校验、确定性 checkId |
| `mapping/health_record_baseline_test.go` | 已有 | docx 风格、联调风格、脏数据映射回归样本 | | `mapping/health_record_baseline_test.go` | 已有 | docx 风格、联调风格、脏数据映射回归样本 |
| `pipeline/` 等 | 待建 | 后续任务 | | `pipeline/` 等 | 待建 | 后续任务 |
@@ -52,6 +53,7 @@
- OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。 - OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。
- 真实平台/代理对请求头名大小写敏感;Go `net/http` 会规范化头名,OSI 传输层需保留 `orgCode/deviceSN/userName` 的原始大小写。裸写传输层默认请求 `Accept-Encoding: identity`,避免收到未解压压缩响应。 - 真实平台/代理对请求头名大小写敏感;Go `net/http` 会规范化头名,OSI 传输层需保留 `orgCode/deviceSN/userName` 的原始大小写。裸写传输层默认请求 `Accept-Encoding: identity`,避免收到未解压压缩响应。
- 公开字典查询返回 `data` 数组;网格、责任医生、机构三类已真实验证可作为 T-103 字典缓存来源。 - 公开字典查询返回 `data` 数组;网格、责任医生、机构三类已真实验证可作为 T-103 字典缓存来源。
- `cache.DictionarySnapshot` 可作为 `mapping.MapContext.MasterData` 注入;PHIS 只有名称、缺平台码时,映射层可反查补齐主数据,未命中仍按必填错误处理。
## 当前可运行内容 ## 当前可运行内容
@@ -60,6 +62,7 @@
go test ./... go test ./...
go build ./... go build ./...
go test ./contract ./osi go test ./contract ./osi
go test ./cache ./mapping
go run . -mode server -config config.yaml.example go run . -mode server -config config.yaml.example
go run . -mode deliver -config config.yaml.example go run . -mode deliver -config config.yaml.example
@@ -75,8 +78,8 @@ python3 scripts/query_health_record.py
## 下一步 ## 下一步
1. T-103:字典缓存(内存 + redis 可选),把 T-101 的网格/责任医生/机构查询结果转为映射层可用的 `regionCode/manaDoctorId/manaUnitId` 快照。 1. T-206:健康档案 Create/Update + 创建请求结构体;用真实字典快照补齐 `regionCode/manaDoctorId/manaUnitId` 后跑创建闭环。
2. T-206:字典缓存就绪后补健康档案 Create/Update 与创建请求契约。 2. T-204:T-206 完成后建立 server 模式 `/api/health-record/save`。
## 维护规则 ## 维护规则
+32 -1
View File
@@ -10,8 +10,11 @@ type PHISHealthRecord struct {
Birthday string Birthday string
MobileNumber string MobileNumber string
RegionCode string RegionCode string
RegionName string
ManaDoctorID string ManaDoctorID string
ManaDoctorName string
ManaUnitID string ManaUnitID string
ManaUnitName string
NationCode string NationCode string
EducationCode string EducationCode string
WorkCode string WorkCode string
@@ -51,8 +54,15 @@ type DictSnapshot struct {
Insurance Dict Insurance Dict
} }
type MasterDataLookup interface {
RegionCodeByName(name string) (string, bool)
DoctorIDByName(name string) (string, bool)
ManaUnitIDByName(name string) (string, bool)
}
type MapContext struct { type MapContext struct {
Dicts DictSnapshot Dicts DictSnapshot
MasterData MasterDataLookup
} }
func DefaultDictSnapshot() DictSnapshot { func DefaultDictSnapshot() DictSnapshot {
@@ -72,6 +82,7 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord,
if ctx.Dicts.Sex.Name == "" { if ctx.Dicts.Sex.Name == "" {
ctx.Dicts = DefaultDictSnapshot() ctx.Dicts = DefaultDictSnapshot()
} }
resolveMasterData(&src, ctx.MasterData)
var errs []ValidationError var errs []ValidationError
for _, field := range []struct { for _, field := range []struct {
name string name string
@@ -112,6 +123,26 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord,
return mapped, errs return mapped, errs
} }
func resolveMasterData(src *PHISHealthRecord, lookup MasterDataLookup) {
if lookup == nil {
return
}
if src.RegionCode == "" && src.RegionName != "" {
if code, ok := lookup.RegionCodeByName(src.RegionName); ok {
src.RegionCode = code
}
}
if src.ManaDoctorID == "" && src.ManaDoctorName != "" {
if code, ok := lookup.DoctorIDByName(src.ManaDoctorName); ok {
src.ManaDoctorID = code
}
}
if src.ManaUnitID == "" && src.ManaUnitName != "" {
if code, ok := lookup.ManaUnitIDByName(src.ManaUnitName); ok {
src.ManaUnitID = code
}
}
}
func mapCode(dict Dict, code string, errs *[]ValidationError) string { func mapCode(dict Dict, code string, errs *[]ValidationError) string {
if code == "" { if code == "" {
return "" return ""
+33
View File
@@ -68,6 +68,26 @@ func TestMapHealthRecordUsesInjectedDictSnapshot(t *testing.T) {
} }
} }
func TestMapHealthRecordUsesMasterDataLookupWhenCodesMissing(t *testing.T) {
src := validPHISHealthRecord()
src.RegionCode = ""
src.ManaDoctorID = ""
src.ManaUnitID = ""
src.RegionName = "测试网格"
src.ManaDoctorName = "测试医生"
src.ManaUnitName = "测试机构"
got, errs := MapHealthRecord(src, MapContext{
Dicts: DefaultDictSnapshot(),
MasterData: fakeMasterDataLookup{},
})
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if got.RegionCode != "441625000000" || got.ManaDoctorID != "doc-001" || got.ManaUnitID != "123456789" {
t.Fatalf("master data not resolved: %#v", got)
}
}
func TestGenerateCheckIDIsDeterministicAndVersionSensitive(t *testing.T) { func TestGenerateCheckIDIsDeterministicAndVersionSensitive(t *testing.T) {
first := GenerateCheckID("PHIS", "JKDA", "record-001", "20260707010101") first := GenerateCheckID("PHIS", "JKDA", "record-001", "20260707010101")
second := GenerateCheckID("PHIS", "JKDA", "record-001", "20260707010101") second := GenerateCheckID("PHIS", "JKDA", "record-001", "20260707010101")
@@ -83,6 +103,19 @@ func TestGenerateCheckIDIsDeterministicAndVersionSensitive(t *testing.T) {
} }
} }
type fakeMasterDataLookup struct{}
func (fakeMasterDataLookup) RegionCodeByName(name string) (string, bool) {
return map[string]string{"测试网格": "441625000000"}[name], name == "测试网格"
}
func (fakeMasterDataLookup) DoctorIDByName(name string) (string, bool) {
return map[string]string{"测试医生": "doc-001"}[name], name == "测试医生"
}
func (fakeMasterDataLookup) ManaUnitIDByName(name string) (string, bool) {
return map[string]string{"测试机构": "123456789"}[name], name == "测试机构"
}
func validPHISHealthRecord() PHISHealthRecord { func validPHISHealthRecord() PHISHealthRecord {
return PHISHealthRecord{ return PHISHealthRecord{
SourceSystem: "PHIS", SourceSystem: "PHIS",
+13
View File
@@ -159,3 +159,16 @@
- 验证:`go build ./...` 通过。 - 验证:`go build ./...` 通过。
- 联调:使用本地未入库脚本中的沙箱凭据和身份证,仅在临时 Go 程序内读取;先 JKDA00002 取一条档案主数据,再调用公开查询。结果:Find `code=01 count=1`;责任医生 `code=01 count>0`;机构 `code=01 count>0`;网格 `code=01 count>0`。未输出真实身份证、机构码、医生姓名或密钥。 - 联调:使用本地未入库脚本中的沙箱凭据和身份证,仅在临时 Go 程序内读取;先 JKDA00002 取一条档案主数据,再调用公开查询。结果:Find `code=01 count=1`;责任医生 `code=01 count>0`;机构 `code=01 count>0`;网格 `code=01 count>0`。未输出真实身份证、机构码、医生姓名或密钥。
- 说明:药品目录查询本次完成客户端封装和单测,T-101 验收要求未要求真实药品目录联调;后续需要用药品关键字/拼音码时再补真实样本。 - 说明:药品目录查询本次完成客户端封装和单测,T-101 验收要求未要求真实药品目录联调;后续需要用药品关键字/拼音码时再补真实样本。
## 2026-07-07 T-103 字典缓存
- 状态:DONE
- 变更:新增 `cache/dictionary.go` 与 `cache/dictionary_test.go`;建立 `DictionarySnapshot` 内存快照,支持按名称反查 `regionCode`、`manaDoctorId`、`manaUnitId`;新增 `DictionaryService.Refresh`,从 T-101 公开查询结果刷新内存快照,并通过可选 `PersistentStore` 保存。
- 变更:`mapping.PHISHealthRecord` 增加 `RegionName`、`ManaDoctorName`、`ManaUnitName`;`MapContext` 增加 `MasterDataLookup`,当 PHIS 未直接提供平台码时,映射层可通过主数据快照反查补齐必填主数据。
- RED:`go test ./cache -count=1` 初次失败,缺少 `NewDictionarySnapshot`、`NewDictionaryService`、`RefreshParams`、`DictionarySnapshot`;`go test ./mapping -run TestMapHealthRecordUsesMasterDataLookupWhenCodesMissing -count=1` 初次失败,缺少名称字段与 `MasterData` 上下文。
- GREEN:补最小实现后,`go test ./cache -count=1` 通过;`go test ./mapping -run TestMapHealthRecordUsesMasterDataLookupWhenCodesMissing -count=1` 通过。
- 验证:`go test ./cache ./mapping -count=1` 通过。
- 验证:`go test ./...` 通过。
- 验证:`go build ./...` 通过。
- 决策:T-103 不引入 Redis 客户端依赖,只定义可选持久化接口;持久化失败被忽略,内存快照仍刷新,满足“redis 不可用不阻断”。
- 下一步:T-206(健康档案 Create/Update + 创建请求契约)。
+1 -1
View File
@@ -58,7 +58,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-101 | `osi/public.go` 四个字典查询(网格/责任医生/药品/机构) | T-005 | 真实机构码能查到下级网格、责任医生、机构树 | DONE | | T-101 | `osi/public.go` 四个字典查询(网格/责任医生/药品/机构) | T-005 | 真实机构码能查到下级网格、责任医生、机构树 | DONE |
| T-103 | 字典缓存(内存 + redis 可选) | T-101, T-102 | 映射层能反查 `regionCode/manaDoctorId/manaUnitId`;redis 不可用不阻断 | TODO | | T-103 | 字典缓存(内存 + redis 可选) | T-101, T-102 | 映射层能反查 `regionCode/manaDoctorId/manaUnitId`;redis 不可用不阻断 | DONE |
| T-206 | `osi/jkda.go`:**Create/Update** + `contract/jkda.go` 补创建请求结构体 | T-201, T-202, T-103 | 一条档案经映射(真实字典快照)→ create → 平台返回成功码与 `phrId`;Update 沿用 checkId | TODO | | T-206 | `osi/jkda.go`:**Create/Update** + `contract/jkda.go` 补创建请求结构体 | T-201, T-202, T-103 | 一条档案经映射(真实字典快照)→ create → 平台返回成功码与 `phrId`;Update 沿用 checkId | TODO |
| T-204 | `handler`+`router`:`/api/health-record/save` | T-206 | server 模式起服务,curl 全链路返回投递结果 | TODO | | T-204 | `handler`+`router`:`/api/health-record/save` | T-206 | server 模式起服务,curl 全链路返回投递结果 | TODO |