From 5424751993987225b66539936720a24086ec9cac Mon Sep 17 00:00:00 2001 From: break Date: Mon, 7 Sep 2026 10:06:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=81=9C=E5=A4=8D=E6=9C=BA?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E5=85=8D=E5=AE=9E=E5=90=8D=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=B9=B6=E5=BD=92=E6=A1=A3=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/iot_card/stop_resume_service.go | 58 +++++++++++++++---- .../.openspec.yaml | 2 + .../design.md | 23 ++++++++ .../proposal.md | 24 ++++++++ .../specs/polling-operations/spec.md | 29 ++++++++++ .../tasks.md | 17 ++++++ openspec/specs/polling-operations/spec.md | 28 +++++++++ 7 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/design.md create mode 100644 openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/proposal.md create mode 100644 openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/specs/polling-operations/spec.md create mode 100644 openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/tasks.md diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 4fb34ce..264e062 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -249,10 +249,25 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model. } // isRealnameOK 检查卡是否满足实名要求 -// 行业卡(card_category='industry')无需实名;其他卡需 real_name_status=1 -func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool { - return card.CardCategory == constants.CardCategoryIndustry || - card.RealNameStatus == constants.RealNameStatusVerified +// 行业卡、已实名卡直接放行;其他卡按有效设备或自身的无需实名策略判断。 +func (s *StopResumeService) isRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { + if card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified { + return true, nil + } + + deviceID, bound, err := s.getCardDeviceID(ctx, card) + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名策略绑定关系失败") + } + if !bound { + return card.RealnamePolicy == constants.RealnamePolicyNone, nil + } + + var device model.Device + if err := s.db.WithContext(ctx).Select("realname_policy").First(&device, deviceID).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名策略失败") + } + return device.RealnamePolicy == constants.RealnamePolicyNone, nil } // checkStopReasons 检查卡的停机原因列表,按优先级排序 @@ -278,8 +293,12 @@ func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.Io reasons = append(reasons, constants.StopReasonNoPackage) } - // 条件C:非行业卡且未实名 - if !s.isRealnameOK(card) { + // 条件C:不满足实际生效的实名要求 + realnameOK, err := s.isRealnameOK(ctx, card) + if err != nil { + return nil, err + } + if !realnameOK { reasons = append(reasons, constants.StopReasonNotRealname) } @@ -305,7 +324,7 @@ func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCar return false, nil } - return s.isRealnameOK(card), nil + return s.isRealnameOK(ctx, card) } // stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁) @@ -349,7 +368,7 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, } // resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁) -// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过 +// 遍历时对每张卡检查有效实名策略:不满足要求的卡更新 stop_reason='not_realname' 后跳过。 func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error { // 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发 lockKey := constants.RedisPollingDeviceOpLockKey(deviceID) @@ -374,7 +393,14 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint var cardErrors []error for _, card := range cards { - if !s.isRealnameOK(card) { + realnameOK, realnameErr := s.isRealnameOK(ctx, card) + if realnameErr != nil { + cardErrors = append(cardErrors, realnameErr) + s.logger.Warn("检查设备卡实名策略失败,继续处理其他卡", + zap.Uint("device_id", deviceID), zap.Uint("card_id", card.ID), zap.Error(realnameErr)) + continue + } + if !realnameOK { if updateErr := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); updateErr != nil { cardErrors = append(cardErrors, updateErr) s.logger.Warn("更新未实名卡停机原因失败", @@ -763,7 +789,12 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er } actionCode, summary := stopAuditAction(ctx, constants.StopReasonManual) - if !s.isRealnameOK(card) { + realnameOK, err := s.isRealnameOK(ctx, card) + if err != nil { + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, err) + return err + } + if !realnameOK { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr) return denyErr @@ -815,7 +846,12 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e return denyErr } - if !s.isRealnameOK(card) { + realnameOK, err := s.isRealnameOK(ctx, card) + if err != nil { + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, err) + return err + } + if !realnameOK { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr) return denyErr diff --git a/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/.openspec.yaml b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/.openspec.yaml new file mode 100644 index 0000000..2e24cfa --- /dev/null +++ b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-07 diff --git a/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/design.md b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/design.md new file mode 100644 index 0000000..b58513e --- /dev/null +++ b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/design.md @@ -0,0 +1,23 @@ +## Context + +统一停复机实名判断已有五处调用,行业卡和已实名卡当前直接放行。资产侧既有策略优先级为设备策略优先、无设备时使用卡策略。 + +## Goals / Non-Goals + +**Goals:** 将相同优先级用于停复机实名门槛,避免免实名资产被错误停机或阻止复机。 + +**Non-Goals:** 不修改实名事实、卡分类、套餐激活流程、轮询调度、重试策略或生产数据;不将免实名当成免套餐或免风控。 + +## Decisions + +- 保留 `isRealnameOK` 单一入口,改为接收 context 并返回错误;行业卡或已实名卡直接返回,无额外数据库查询。 +- 其他卡通过既有有效绑定查询确定载体。独立卡和无有效绑定卡使用自身策略;有效绑定设备使用设备策略,通过 GORM 仅查询策略列。绑定或设备读取错误上抛,禁止回退放行。 +- 五处调用全部接收错误;设备批量复机保留逐卡继续及错误聚合,手动命令保留失败审计。 +- Audit Event、Integration Log、Outbox 继续使用现有命令路径;查询失败不执行外部命令。Domain Ledger 为 N/A,无金额变化。 +- 用可删除的本地 Go smoke 程序执行真实停复机服务,GORM 查询回调提供合成卡、绑定、设备和套餐,不连接数据库或网关;验证前后行为差异,再构建 API/worker。不新增永久测试。 + +## Risks / Trade-offs + +- 未实名普通设备卡会新增设备策略读取;行业卡、已实名卡和独立卡不增加此读取。 +- 设备策略读取失败会中止该卡本次操作,这是避免错误停机或错误放行的保守选择。 +- 无需迁移。发布由维护者完成;主 Specs 暂不同步,保留独立 Change 供发布审阅与归档。 diff --git a/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/proposal.md b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/proposal.md new file mode 100644 index 0000000..8e70a0e --- /dev/null +++ b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/proposal.md @@ -0,0 +1,24 @@ +## Why + +停复机服务仅按行业卡类型或实名事实放行,遗漏已配置的无需实名策略,导致具有生效套餐的未实名普通卡被重复请求停机。用户要求只修代码,不核对或修改生产数据。 + +## What Changes + +- 停复机统一实名判断识别实际生效的 `none` 策略;保留行业卡与已实名卡的既有放行规则。 +- 独立卡使用卡策略,有效绑定设备的卡使用设备策略;读取失败不得按免实名放行。 +- 自动停复机、设备复机及手动停复机共用判断;套餐、流量、风控、保护期及失败重试规则不变。 +- 不修改卡类型或实名事实,不访问生产或上游接口验证,不引入永久测试。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `polling-operations`: 停复机实名门槛遵循实际生效策略。 + +## Impact + +修改 `internal/service/iot_card/stop_resume_service.go` 的统一判断及其调用;复用现有 GORM 模型和绑定查询。无 Schema、路由或配置变更。通过本地隔离运行与 API/worker 构建验证。 diff --git a/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/specs/polling-operations/spec.md b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/specs/polling-operations/spec.md new file mode 100644 index 0000000..0b62ded --- /dev/null +++ b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/specs/polling-operations/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: 停复机遵循实际生效实名策略 + +系统 SHALL 在自动停机、自动复机、设备批量复机和手动停复机的实名门槛中放行行业卡、已实名卡及实际生效策略为 `none` 的卡。独立卡或无有效设备绑定的卡使用卡策略,有有效设备绑定的非独立卡使用设备策略。系统 MUST 保留真实实名状态,不得以豁免伪造已实名事实。其他策略的未实名普通卡 MUST 保持实名限制;套餐、流量、风险、保护期及非轮询停机不自动复机等既有限制保持不变。 + +#### Scenario: 免实名独立卡有生效套餐 +- **WHEN** 未实名普通独立卡配置 `none`,存在生效套餐且流量未耗尽,当前网络在线 +- **THEN** 系统不因未实名向上游请求停机 + +#### Scenario: 免实名卡从轮询停机恢复 +- **WHEN** 上述卡当前因 `not_realname` 停机,满足其他自动复机条件 +- **THEN** 系统不因未实名阻止自动复机 + +#### Scenario: 设备策略优先 +- **WHEN** 未实名普通卡有效绑定设备,卡与设备的实名策略不同 +- **THEN** 系统以设备策略判定实名豁免,卡自身的 `none` 不得覆盖设备的实名要求 + +#### Scenario: 行业卡与已实名卡兼容 +- **WHEN** 行业卡未实名或普通卡已实名 +- **THEN** 系统保留已有实名门槛放行行为 + +#### Scenario: 无套餐和流量耗尽不豁免 +- **WHEN** 免实名卡没有生效套餐或流量已经耗尽 +- **THEN** 系统仍按对应业务原因执行停机判断 + +#### Scenario: 策略读取失败 +- **WHEN** 未实名普通卡的有效设备绑定或设备策略查询失败 +- **THEN** 系统报告错误且不对该卡发起本次停复机命令,不按卡策略回退放行 diff --git a/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/tasks.md b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/tasks.md new file mode 100644 index 0000000..9b07ad2 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-fix-stop-resume-realname-policy/tasks.md @@ -0,0 +1,17 @@ +## 1. 修复 + +- [x] 1.1 使用合成卡与套餐运行真实停复机判断,捕捉免实名卡被判停机的修复前失败。 +- [x] 1.2 统一解析停复机实名策略,更新自动、设备及手动调用的错误处理。 + +## 2. 验证 + +- [x] 2.1 运行隔离 smoke,覆盖免实名停复机、设备优先级、兼容行为、套餐限制及读取失败。 +- [x] 2.2 格式化修改的 Go 文件,构建 API/worker,校验独立 Change 并移除临时验证程序。 + +## 验证结果 + +- 临时 `go run ./cmd/realname-smoke-tmp`:修复前 6 个场景失败,其中免实名独立卡被判为 `not_realname` 且不可复机;修复后全部 12 个场景通过。 +- 验证执行真实 `checkStopReasons`、`shouldResume`,允许保持在线的场景同时执行 `EvaluateAndAct`;GORM 查询由合成数据回调提供,不连接数据库或网关。未验证真实上游停复机,也未执行设备批量及手动命令端到端场景。 +- `gofmt -w internal/service/iot_card/stop_resume_service.go && go build ./cmd/api ./cmd/worker`:退出码 0;Go 模块缓存写入出现 permission denied 警告,未阻断构建。 +- `openspec validate fix-stop-resume-realname-policy --strict`:通过。 +- 临时 smoke 文件及命令目录已删除,无永久测试、数据修改或生产操作。主 Specs 留待独立同步/归档流程。 diff --git a/openspec/specs/polling-operations/spec.md b/openspec/specs/polling-operations/spec.md index 35d18de..8d35e0d 100644 --- a/openspec/specs/polling-operations/spec.md +++ b/openspec/specs/polling-operations/spec.md @@ -62,6 +62,34 @@ - **WHEN** 授权操作者查询 `card_status` 的并发状态 - **THEN** 返回的 `task_type_name` 为“卡状态检查” +### Requirement: 停复机遵循实际生效实名策略 + +系统 SHALL 在自动停机、自动复机、设备批量复机和手动停复机的实名门槛中放行行业卡、已实名卡及实际生效策略为 `none` 的卡。独立卡或无有效设备绑定的卡使用卡策略,有有效设备绑定的非独立卡使用设备策略。系统 MUST 保留真实实名状态,不得以豁免伪造已实名事实。其他策略的未实名普通卡 MUST 保持实名限制;套餐、流量、风险、保护期及非轮询停机不自动复机等既有限制保持不变。 + +#### Scenario: 免实名独立卡有生效套餐 +- **WHEN** 未实名普通独立卡配置 `none`,存在生效套餐且流量未耗尽,当前网络在线 +- **THEN** 系统不因未实名向上游请求停机 + +#### Scenario: 免实名卡从轮询停机恢复 +- **WHEN** 上述卡当前因 `not_realname` 停机,满足其他自动复机条件 +- **THEN** 系统不因未实名阻止自动复机 + +#### Scenario: 设备策略优先 +- **WHEN** 未实名普通卡有效绑定设备,卡与设备的实名策略不同 +- **THEN** 系统以设备策略判定实名豁免,卡自身的 `none` 不得覆盖设备的实名要求 + +#### Scenario: 行业卡与已实名卡兼容 +- **WHEN** 行业卡未实名或普通卡已实名 +- **THEN** 系统保留已有实名门槛放行行为 + +#### Scenario: 无套餐和流量耗尽不豁免 +- **WHEN** 免实名卡没有生效套餐或流量已经耗尽 +- **THEN** 系统仍按对应业务原因执行停机判断 + +#### Scenario: 策略读取失败 +- **WHEN** 未实名普通卡的有效设备绑定或设备策略查询失败 +- **THEN** 系统报告错误且不对该卡发起本次停复机命令,不按卡策略回退放行 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。