song.jun
3 天以前 712de30133f921b5ac1f1ae2a1b77fff8b709c20
修正代码评审发现的 Critical 问题(a3f4e4f 后续修复)

评审发现两个 P0 回归和一个 P1 问题:

P0.1: ToEntityByLabCode 的 ProjectId 过滤导致后台流程静默失效
- 问题:前端 QCDistributionLabs.cshtml 的 openFeeWindow/openEMSWindow
把 CurrentQCDistRegisterInfo 重置为不含 ProjectId 字段的 JSON 字面量,
ko.mapping.toJS 序列化时根本不会带 ProjectId,服务端收到 0,
无条件加过滤会让 ConfigrmFee/ConfigrmEMS/switchNextOne 全部命不中
现有登记行,静默失败。
- 修复:改为条件过滤 —— 仅当 regInfoivewModel.ProjectId > 0 才加入
WHERE 子句,否则退化为只按 (QCDist, LabId) 匹配,与修复前一致。
未来若前端补 ProjectId 控件,服务端自动切换到精准匹配。

P0.2: MergeRegisterInfo 字符串规则在 Id=0 路径下会清空既有 EMSNo
- 问题:QCDistributionRegisterInfoViewModel.ToEntity:629 有
`entity.EMSNo = viewModel.EMSNo == null ? "" : viewModel.EMSNo.Trim()`
这类 force-set 代码,把 null 强制变成 ""。MergeRegisterInfo 按
`source != null` 判断就会把空串覆盖到 existing.EMSNo,清空既有
快递单号。
- 修复:10 个字符串字段全部改用 `!string.IsNullOrEmpty(source)` 判断。
空串视为"未设置",不覆盖既有值。

P1.4: LogHelper.Info("[WARN] ...") 告警级别形同虚设
- 问题:LogHelper wrapper 没有 Warn 方法,用 Info + 字符串前缀冒充
告警级别,实际生产 NLog 配置只对 Warn/Error/Fatal 级别转发告警,
Info 条目只会静默进日志文件。
- 修复:改用 LogHelper.Error。本条目本质是"应用状态异常需立即调查",
用 Error 级别合理。CLAUDE.md 同步更新解释此选择。

P1.3 不采纳:两道防线的合并规则不对称是刻意设计 —— 第一道(SaveLabList
路径)基于业务语义只同步 IsCharged+ModifyTime,第二道(SaveQcDistributionRegister
兜底)走全字段通用合并;两者服务不同场景,一致性不是目标。CLAUDE.md
补充说明此意图。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3个文件已修改
47 ■■■■■ 已修改文件
CLAUDE.md 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sbcLabSystem.Service/QC/QCService.cs 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CLAUDE.md
@@ -118,18 +118,19 @@
- **唯一的保存入口**:所有保存 `QCDistributionRegisterInfo` 的路径都必须经过 `QCService.SaveQcDistributionRegister`。新写任何涉及登记表写入的代码,**不要**直接 `_qcDistributionRegisters.Insert`、不要绕开此方法。
- **Id==0 的语义**:该方法把 `Id==0` 视为"新增"请求,进入前会按 `(QCDistributionId, LabId, ProjectId)` 做查重:
  - 查不到 → 正常 Insert。
  - 查到 → 打一条 `[WARN]` 前缀的 Info 日志,按 `MergeRegisterInfo` 规则**合并**到既有行后 UPDATE;同时把入参 `qcDistributionRegister.Id` 回写为 `existing.Id`,便于调用方后续引用。
  - 查到 → 打一条 `LogHelper.Error` 日志(强制告警),按 `MergeRegisterInfo` 规则**合并**到既有行后 UPDATE;同时把入参 `qcDistributionRegister.Id` 回写为 `existing.Id`,便于调用方后续引用。
- **MergeRegisterInfo 的合并规则(刻意不对称)**:
  - `string` 字段:`source != null` 才覆盖(保留既有内容,不会被意外 null 清空)。
  - `string` 字段:`!string.IsNullOrEmpty(source)` 才覆盖(保留既有内容;空串也不覆盖,因为 `QCDistributionRegisterInfoViewModel.ToEntity` 里的 `entity.EMSNo = "" ?? ...` 这类 force-set 代码会把 null 变成 "", 若按 `!= null` 判断就会清空既有 `EMSNo`)。
  - `DateTime?` 字段:`HasValue` 才覆盖。
  - `DateTime ModifyTime`:非默认值才覆盖(调用方都用 `DateTime.Now`,天然非默认)。
  - `bool` 字段:**只能 `false → true`,不能 `true → false`**。这是为了防止一次 Id==0 的脏 Save 把既有的 `IsCharged=1` 意外清零。
  - `QCDistributionId / LabId / ProjectId`:业务唯一键,不动。
- **要把 bool 清回 false、或清空 string/Date 字段,必须走 Id>0 路径**:先用 `GetQcDistributionRegister(id)` 查出 tracked entity,直接改字段再 `SaveQcDistributionRegister`。用 `Id=0 + IsCharged=false` 这种方式期望"反收费"会被静默忽略。
- **生产日志里出现 `[WARN] SaveQcDistributionRegister 命中已有行`** = 某个上层调用方漏做了查重或前端提交了脏 Id,**必须排查根因**,不要忽略。
- **`SaveLabList` 路径(`QCDistributionInfoViewModel.ToEntity`)**:作为第一道防线,保留了自己的 `(QCDist, LabId, ProjectId)` 查重分支 —— 与 `SaveQcDistributionRegister` 里的收口形成双保险。修改这两处任一处时都要同步检查另一处。
- **生产日志里出现 `SaveQcDistributionRegister 命中已有行`(Error 级别)** = 某个上层调用方漏做了查重或前端提交了脏 Id,**必须排查根因**。这条日志**刻意用 `LogHelper.Error`** 而不是 Info —— 因为本仓库的 `LogHelper` 没有 Warn 方法,而 Info 级别不会触发告警系统;用 Error 保证运维能第一时间收到通知。
- **`SaveLabList` 路径(`QCDistributionInfoViewModel.ToEntity`)**:作为第一道防线,保留了自己的 `(QCDist, LabId, ProjectId)` 查重分支 —— 与 `SaveQcDistributionRegister` 里的收口形成双保险。修改这两处任一处时都要同步检查另一处。注意第一道防线命中后**只同步 `IsCharged` 和 `ModifyTime`**(基于 SaveLabList 的业务语义),第二道防线命中后走 `MergeRegisterInfo` 全字段合并(通用兜底);两者行为刻意不对称。
- **`QCDistributionRegisterInfoViewModel.ToEntityByLabCode`** 的 ProjectId 过滤是**条件的**:只有 `regInfoivewModel.ProjectId > 0` 才加入 WHERE 子句,否则退化为只按 `(QCDistributionId, LabId)` 匹配。原因:前端 `Views/Backstage/QCDistributionLabs.cshtml` 的 `openFeeWindow` / `openEMSWindow` 把 `CurrentQCDistRegisterInfo` 重置为不含 ProjectId 字段的 JSON 字面量,序列化后服务端 `regInfoivewModel.ProjectId == 0`,无条件加过滤会让 `ConfigrmFee` / `ConfigrmEMS` / `switchNextOne` 全部静默失效。**未来若前端补上 ProjectId 选择控件**,服务端会自动切换到精准匹配,不需要改服务端代码。多项目共存时当前会匹配第一条(与修复前行为一致)。
- **`BackstageController.ImportLabs`**:是唯一绕过 `SaveQcDistributionRegister` 的写入路径(直接操作 `QCDistribution.QCDistributionRegisters` 导航集合)。它自己在源读取后用 `GroupBy(LabId, ProjectId).Select(g => g.First())` 去重;**修改 ImportLabs 时必须保留这段 GroupBy**,否则脏数据会跨分发传染。
- **残余风险**(已知,不修复):两个管理员并发编辑同一分发时,两个独立 DbContext 的查重都可能返回空,仍有产生重复的理论窗口。本仓库不通过 DB 约束兜底,缓解办法是前端按钮防抖 + 日志监控 `[WARN]` 条目。如果日后发现并发窗口被真实触发,加唯一索引是根治方案(见"一致性"章节末尾)。
- **残余风险**(已知,不修复):两个管理员并发编辑同一分发时,两个独立 DbContext 的查重都可能返回空,仍有产生重复的理论窗口。本仓库不通过 DB 约束兜底,缓解办法是前端按钮防抖 + 监控 `SaveQcDistributionRegister 命中已有行` 的 Error 日志。如果日后发现并发窗口被真实触发,加唯一索引是根治方案(见"一致性"章节末尾)。
## 修改时的红线(针对本仓库的具体风险)
sbcLabSystem.Service/QC/QCService.cs
@@ -1043,8 +1043,8 @@
                    MergeRegisterInfo(existing, qcDistributionRegister);
                    _qcDistributionRegisters.Update(existing);
                    qcDistributionRegister.Id = existing.Id;
                    LogHelper.Info(string.Format(
                        "[WARN] SaveQcDistributionRegister 命中已有行 Id={0}(入参 Id=0),已重定向 UPDATE:QCDist={1}, Lab={2}, Project={3}。请检查上层为何未先查重。",
                    LogHelper.Error(string.Format(
                        "SaveQcDistributionRegister 命中已有行 Id={0}(入参 Id=0),已重定向 UPDATE:QCDist={1}, Lab={2}, Project={3}。上层必定漏做查重或前端提交了脏 Id,必须排查根因。",
                        existing.Id,
                        qcDistributionRegister.QCDistributionId,
                        qcDistributionRegister.LabId,
@@ -1061,16 +1061,16 @@
        private static void MergeRegisterInfo(QCDistributionRegisterInfo target, QCDistributionRegisterInfo source)
        {
            if (source.LetterNo != null) target.LetterNo = source.LetterNo;
            if (source.ChargeRemark != null) target.ChargeRemark = source.ChargeRemark;
            if (source.SampleNo != null) target.SampleNo = source.SampleNo;
            if (source.EMSNo != null) target.EMSNo = source.EMSNo;
            if (source.PacketContent != null) target.PacketContent = source.PacketContent;
            if (source.Remark != null) target.Remark = source.Remark;
            if (source.ModifyUser != null) target.ModifyUser = source.ModifyUser;
            if (source.AnswerJSON != null) target.AnswerJSON = source.AnswerJSON;
            if (source.SubmitUserNo != null) target.SubmitUserNo = source.SubmitUserNo;
            if (source.Score_Detail != null) target.Score_Detail = source.Score_Detail;
            if (!string.IsNullOrEmpty(source.LetterNo)) target.LetterNo = source.LetterNo;
            if (!string.IsNullOrEmpty(source.ChargeRemark)) target.ChargeRemark = source.ChargeRemark;
            if (!string.IsNullOrEmpty(source.SampleNo)) target.SampleNo = source.SampleNo;
            if (!string.IsNullOrEmpty(source.EMSNo)) target.EMSNo = source.EMSNo;
            if (!string.IsNullOrEmpty(source.PacketContent)) target.PacketContent = source.PacketContent;
            if (!string.IsNullOrEmpty(source.Remark)) target.Remark = source.Remark;
            if (!string.IsNullOrEmpty(source.ModifyUser)) target.ModifyUser = source.ModifyUser;
            if (!string.IsNullOrEmpty(source.AnswerJSON)) target.AnswerJSON = source.AnswerJSON;
            if (!string.IsNullOrEmpty(source.SubmitUserNo)) target.SubmitUserNo = source.SubmitUserNo;
            if (!string.IsNullOrEmpty(source.Score_Detail)) target.Score_Detail = source.Score_Detail;
            if (source.ChargeTime.HasValue) target.ChargeTime = source.ChargeTime;
            if (source.SendEMSTime.HasValue) target.SendEMSTime = source.SendEMSTime;
            if (source.SubmitTime.HasValue) target.SubmitTime = source.SubmitTime;
sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs
@@ -69,10 +69,14 @@
            {
                return null;
            }
            QCDistributionRegisterInfo entity = PalGainEngine.Instance.Resolve<QCService>().GetQcDistributionRegisters()
                .FirstOrDefault(p => p.QCDistributionId == regInfoivewModel.QCDistributionId
                                  && p.LabId == labId
                                  && p.ProjectId == regInfoivewModel.ProjectId);
            var query = PalGainEngine.Instance.Resolve<QCService>().GetQcDistributionRegisters()
                .Where(p => p.QCDistributionId == regInfoivewModel.QCDistributionId
                         && p.LabId == labId);
            if (regInfoivewModel.ProjectId > 0)
            {
                query = query.Where(p => p.ProjectId == regInfoivewModel.ProjectId);
            }
            QCDistributionRegisterInfo entity = query.FirstOrDefault();
            if (entity != null)
            {
                return entity;