diff --git a/cache/dictionary.go b/cache/dictionary.go new file mode 100644 index 0000000..27f0046 --- /dev/null +++ b/cache/dictionary.go @@ -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 +} diff --git a/cache/dictionary_test.go b/cache/dictionary_test.go new file mode 100644 index 0000000..209f4d1 --- /dev/null +++ b/cache/dictionary_test.go @@ -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") +} diff --git a/docs/03-目标架构设计.md b/docs/03-目标架构设计.md index 8d41976..6e4f3c4 100644 --- a/docs/03-目标架构设计.md +++ b/docs/03-目标架构设计.md @@ -59,6 +59,7 @@ chis_osi/ ├── contract/ ★ 校验后的接口契约(请求/响应结构体 + serviceId 常量) │ ├── envelope.go 通用信封:{serviceId, uploadinfo|baseInfo, manageInfo} │ └── jkda.go / jktj.go / lnr.go / ... +├── cache/ 公开字典缓存:网格/责任医生/机构 → 映射层主数据反查 ├── mapping/ ★ PHIS→OSI 映射(本项目核心) │ ├── dict.go 码表:性别/民族/血型/职业/文化程度/婚姻/医保...(双向) │ ├── health_record.go 档案字段映射 + 校验 @@ -133,7 +134,11 @@ type UploadInfo struct { 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"的隐式逻辑,重写为"对齐文档"的显式逻辑: @@ -145,14 +150,14 @@ type ManageInfo struct { DSFMC, OperateUnit, OperateUser string } > 完整度(completeLevel/perfection):默认**不本地计算**,按文档字段如实上送,依赖平台计算。 > 若联调发现平台要求接入方计算,再把旧项目 `health_record_complete_level.go`/`health_check_perfection.go` 移植进 `mapping/`(开放问题,见第 8 节)。 -### 4.4 `source` —— 任务源 +### 4.5 `source` —— 任务源 - 拉取待上送明细(替代旧项目的本地 mock 文件)。 - 任务模型:`{taskId, dataType, payload(PHIS原始), ...}`。 - 投递后回写状态 `done/retry/failed`(旧项目一直 TODO,新项目做实)。 - `trace_id` 建议直接用 PHIS 任务号,贯穿日志与报告。 -### 4.5 `pipeline` —— 投递编排 +### 4.6 `pipeline` —— 投递编排 单条投递流程(继承旧项目 worker 经验): @@ -170,7 +175,7 @@ type ManageInfo struct { DSFMC, OperateUnit, OperateUser string } - **熔断**:连续网络失败达阈值则暂停(阈值/休眠秒可配)。 - **批次报告**:`total/success/failed/skipped/retry`、失败 Top、逐条明细(沿用旧项目格式)。 -### 4.6 `observ` / `store` —— 可观测与存储 +### 4.7 `observ` / `store` —— 可观测与存储 - 一套 report log(收敛旧项目的 apitrace/reportlog/snapshot 三套):记录每次 PHIS 输入、映射结果、OSI 请求/响应、最终判定。 - Redis 优先、本地 JSONL 降级;Redis 启动失败不阻断(沿用旧项目)。 diff --git a/docs/04-字段与接口映射.md b/docs/04-字段与接口映射.md index 8104793..d148cdd 100644 --- a/docs/04-字段与接口映射.md +++ b/docs/04-字段与接口映射.md @@ -92,7 +92,7 @@ manaDoctorId/operateUser ← QueryDoctors(manaUnitId) // 责任医生 manaUnitId/organizCode ← QueryOrgs(...) // 机构 ``` -策略:启动或定时拉取这些字典,缓存到本地(redis 可选 + 内存),映射时按 PHIS 的地址/医生/机构名称反查 OSI 码。命中失败计入校验错误,不投递。 +策略:启动或定时拉取这些字典,缓存到 `cache.DictionarySnapshot`(内存为必选,持久化接口可接 Redis)。映射时通过 `MapContext.MasterData` 按 PHIS 的地址/医生/机构名称反查 OSI 码:`RegionCodeByName`、`DoctorIDByName`、`ManaUnitIDByName`。命中失败仍表现为必填校验错误,不投递;Redis/持久化失败不阻断内存快照刷新。 --- diff --git a/docs/05-实施路线图.md b/docs/05-实施路线图.md index 4fd7488..a8ac7b1 100644 --- a/docs/05-实施路线图.md +++ b/docs/05-实施路线图.md @@ -22,7 +22,7 @@ - [x] 实现 `public.go` 四个查询:网格/责任医生/药品/机构。 - [x] `mapping/dict.go` 落地全部码表(含 56 项民族)。 -- [ ] 字典缓存(内存 + redis 可选),供映射层反查 `regionCode/manaDoctorId/manaUnitId`。 +- [x] 字典缓存(内存 + redis 可选),供映射层反查 `regionCode/manaDoctorId/manaUnitId`。 - **验收**:能用真实机构码查到下级网格、责任医生、机构树。 ## 阶段 2 · 健康档案闭环(第一条业务线) diff --git a/docs/current-state.md b/docs/current-state.md index 7911278..a03c5b6 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,14 +6,14 @@ ## 当前快照 - 日期: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 四个公开查询薄封装 -- 生产代码:已有 `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 已完成客户端封装和单测,尚未做真实药品关键字样本验证 -- 测试:`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` - 标准验证路径:`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/jkda.go` | 已有 | JKDA00002 Find 与 JKDA00005 FindRqbj | | `osi/public.go` | 已有 | WGDZ00001 网格、ZRYS00001 责任医生、YPML00001 药品、CXJG00002 机构查询 | +| `cache/dictionary.go` | 已有 | 公开字典内存快照;按名称反查 `regionCode`/`manaDoctorId`/`manaUnitId`;可选持久化失败不阻断 | | `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 | | `config.yaml.example` | 已有 | 占位配置,不含真实凭据 | | `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` | @@ -37,7 +38,7 @@ | `scripts/` | 已有·不入库 | Python 联调脚本(硬编码真实凭据与身份证,勿提交) | | `config.yaml` | 已有·不入库 | 真实/本地配置;不要提交 | | `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 风格、联调风格、脏数据映射回归样本 | | `pipeline/` 等 | 待建 | 后续任务 | @@ -52,6 +53,7 @@ - OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。 - 真实平台/代理对请求头名大小写敏感;Go `net/http` 会规范化头名,OSI 传输层需保留 `orgCode/deviceSN/userName` 的原始大小写。裸写传输层默认请求 `Accept-Encoding: identity`,避免收到未解压压缩响应。 - 公开字典查询返回 `data` 数组;网格、责任医生、机构三类已真实验证可作为 T-103 字典缓存来源。 +- `cache.DictionarySnapshot` 可作为 `mapping.MapContext.MasterData` 注入;PHIS 只有名称、缺平台码时,映射层可反查补齐主数据,未命中仍按必填错误处理。 ## 当前可运行内容 @@ -60,6 +62,7 @@ go test ./... go build ./... go test ./contract ./osi +go test ./cache ./mapping go run . -mode server -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` 快照。 -2. T-206:字典缓存就绪后补健康档案 Create/Update 与创建请求契约。 +1. T-206:健康档案 Create/Update + 创建请求结构体;用真实字典快照补齐 `regionCode/manaDoctorId/manaUnitId` 后跑创建闭环。 +2. T-204:T-206 完成后建立 server 模式 `/api/health-record/save`。 ## 维护规则 diff --git a/mapping/health_record.go b/mapping/health_record.go index 1ea9ae7..77b7344 100644 --- a/mapping/health_record.go +++ b/mapping/health_record.go @@ -10,8 +10,11 @@ type PHISHealthRecord struct { Birthday string MobileNumber string RegionCode string + RegionName string ManaDoctorID string + ManaDoctorName string ManaUnitID string + ManaUnitName string NationCode string EducationCode string WorkCode string @@ -51,8 +54,15 @@ type DictSnapshot struct { Insurance Dict } +type MasterDataLookup interface { + RegionCodeByName(name string) (string, bool) + DoctorIDByName(name string) (string, bool) + ManaUnitIDByName(name string) (string, bool) +} + type MapContext struct { - Dicts DictSnapshot + Dicts DictSnapshot + MasterData MasterDataLookup } func DefaultDictSnapshot() DictSnapshot { @@ -72,6 +82,7 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord, if ctx.Dicts.Sex.Name == "" { ctx.Dicts = DefaultDictSnapshot() } + resolveMasterData(&src, ctx.MasterData) var errs []ValidationError for _, field := range []struct { name string @@ -112,6 +123,26 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord, 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 { if code == "" { return "" diff --git a/mapping/health_record_test.go b/mapping/health_record_test.go index 515f606..3960a0f 100644 --- a/mapping/health_record_test.go +++ b/mapping/health_record_test.go @@ -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) { first := 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 { return PHISHealthRecord{ SourceSystem: "PHIS", diff --git a/progress.md b/progress.md index ebcb247..7af95c1 100644 --- a/progress.md +++ b/progress.md @@ -159,3 +159,16 @@ - 验证:`go build ./...` 通过。 - 联调:使用本地未入库脚本中的沙箱凭据和身份证,仅在临时 Go 程序内读取;先 JKDA00002 取一条档案主数据,再调用公开查询。结果:Find `code=01 count=1`;责任医生 `code=01 count>0`;机构 `code=01 count>0`;网格 `code=01 count>0`。未输出真实身份证、机构码、医生姓名或密钥。 - 说明:药品目录查询本次完成客户端封装和单测,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 + 创建请求契约)。 diff --git a/tasks.md b/tasks.md index e15c28e..0c395df 100644 --- a/tasks.md +++ b/tasks.md @@ -58,7 +58,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | 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-204 | `handler`+`router`:`/api/health-record/save` | T-206 | server 模式起服务,curl 全链路返回投递结果 | TODO |