This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-07
|
||||
@@ -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 供发布审阅与归档。
|
||||
@@ -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 构建验证。
|
||||
@@ -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** 系统报告错误且不对该卡发起本次停复机命令,不按卡策略回退放行
|
||||
@@ -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 留待独立同步/归档流程。
|
||||
@@ -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 为准。
|
||||
|
||||
Reference in New Issue
Block a user