Compare commits
6 Commits
ef4d3696d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f2fe4a61a | |||
| 30c6e5bb78 | |||
| 3b4afd287e | |||
| 999acc128e | |||
| f5f03d1803 | |||
| 3c371ab88a |
@@ -264,6 +264,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package)
|
||||
packageSeriesService.SetAccessAudit(deps.DB, auditWriter)
|
||||
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
|
||||
orderService.SetResumeCallback(stopResumeService)
|
||||
orderService.SetLifecycleAudit(auditWriter)
|
||||
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
|
||||
@@ -648,7 +648,7 @@ func (h *AssetHandler) resolveAssetPackageUsagePath(c *fiber.Ctx) (*dto.AssetRes
|
||||
}
|
||||
|
||||
func ensureAssetPackageAdjuster(c *fiber.Ctx) error {
|
||||
if middleware.GetUserIDFromContext(c.UserContext()) != 41 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
|
||||
if middleware.GetUserIDFromContext(c.UserContext()) != 1124 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -241,13 +241,23 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
|
||||
})
|
||||
}
|
||||
|
||||
// RealNameStatus 以当前使用卡为准;无当前卡则视为未实名
|
||||
// RealNameStatus 以当前使用卡为准;无当前卡时,任一有效绑定卡已实名即视为已实名
|
||||
hasCurrentCard := false
|
||||
for _, c := range cards {
|
||||
if isCurrentMap[c.ID] {
|
||||
resp.RealNameStatus = c.RealNameStatus
|
||||
hasCurrentCard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCurrentCard {
|
||||
for _, c := range cards {
|
||||
if c.RealNameStatus == constants.RealNameStatusVerified {
|
||||
resp.RealNameStatus = constants.RealNameStatusVerified
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// RealNameAt 取所有绑定卡中最早的实名时间
|
||||
for _, c := range cards {
|
||||
if c.FirstRealnameAt == nil {
|
||||
@@ -346,7 +356,7 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC
|
||||
|
||||
// fillPackageInfo 填充当前主套餐信息到响应中
|
||||
func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {
|
||||
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, carrierID)
|
||||
usage, err := s.packageUsageStore.GetCurrentMainPackage(ctx, carrierType, carrierID)
|
||||
if err != nil || usage == nil {
|
||||
return
|
||||
}
|
||||
@@ -881,7 +891,7 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentPackage 获取资产当前生效的主套餐
|
||||
// GetCurrentPackage 获取资产当前可展示的主套餐。
|
||||
// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回
|
||||
func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint, callerAccountType string) (*dto.AssetPackageResponse, error) {
|
||||
carrierType := assetType
|
||||
@@ -889,14 +899,14 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
|
||||
carrierType = "iot_card"
|
||||
}
|
||||
|
||||
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, id)
|
||||
usage, err := s.packageUsageStore.GetCurrentMainPackage(ctx, carrierType, id)
|
||||
if err != nil {
|
||||
// 记录不存在表示当前无生效套餐,属于正常业务状态,返回 nil 而非错误
|
||||
// 记录不存在表示当前无套餐,属于正常业务状态,返回 nil 而非错误
|
||||
// 其他 DB 错误才作为服务端错误处理
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询当前生效套餐失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询当前套餐失败")
|
||||
}
|
||||
|
||||
return s.buildAssetPackageResponse(ctx, usage, callerAccountType), nil
|
||||
@@ -1387,9 +1397,34 @@ func (s *Service) HasValidRechargeOrPaidOrder(ctx context.Context, assetType str
|
||||
}
|
||||
}
|
||||
|
||||
if assetType == "card" || assetType == "device" {
|
||||
return s.hasPaidPackageUsage(ctx, assetType, assetID, generation)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasPaidPackageUsage 检查资产在当前世代是否存在已支付订单购买且未退款的套餐权益
|
||||
// 换货迁移只迁移套餐权益不迁移订单,订单仍挂在旧资产上,需要按套餐权益兜底判定
|
||||
func (s *Service) hasPaidPackageUsage(ctx context.Context, assetType string, assetID uint, generation int) (bool, error) {
|
||||
assetColumn := "tb_package_usage.iot_card_id"
|
||||
if assetType == "device" {
|
||||
assetColumn = "tb_package_usage.device_id"
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Joins("JOIN tb_order ON tb_order.id = tb_package_usage.order_id AND tb_order.deleted_at IS NULL").
|
||||
Where(assetColumn+" = ? AND tb_package_usage.generation = ?", assetID, generation).
|
||||
Where("tb_package_usage.refund_id IS NULL").
|
||||
Where("tb_order.payment_status = ?", model.PaymentStatusPaid).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益订单失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// packageStatusName 套餐状态码转中文名称
|
||||
func packageStatusName(status int) string {
|
||||
switch status {
|
||||
|
||||
@@ -146,12 +146,27 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewDeviceSimBindingStore(tx, nil).Create(ctx, binding); err != nil {
|
||||
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
|
||||
if err := txBindingStore.Create(ctx, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
// 单槽设备绑定后有效绑定唯一,当前使用卡不可能是其他卡,直接初始化为当前卡
|
||||
if device.MaxSimSlots == 1 {
|
||||
activeCount, err := txBindingStore.CountByDeviceID(ctx, device.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activeCount == 1 {
|
||||
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Where("id = ?", binding.ID).Update("is_current", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
binding.IsCurrent = true
|
||||
}
|
||||
}
|
||||
item.Binding = binding
|
||||
item.BindingRole = constants.AuditResourceRoleDeviceCreatedBinding
|
||||
item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, false)
|
||||
item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, binding.IsCurrent)
|
||||
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": nil},
|
||||
|
||||
@@ -58,13 +58,12 @@ func (s *PackageUsageStore) Update(ctx context.Context, usage *model.PackageUsag
|
||||
return s.db.WithContext(ctx).Save(usage).Error
|
||||
}
|
||||
|
||||
// GetActiveMainPackage 查询载体的生效中主套餐
|
||||
// carrierType: "iot_card" 或 "device"
|
||||
// carrierID: iot_card_id 或 device_id
|
||||
func (s *PackageUsageStore) GetActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (*model.PackageUsage, error) {
|
||||
// GetCurrentMainPackage 查询载体当前可展示的主套餐。
|
||||
// 当前套餐包含生效中和已用完状态;carrierType 为 "iot_card" 或 "device"。
|
||||
func (s *PackageUsageStore) GetCurrentMainPackage(ctx context.Context, carrierType string, carrierID uint) (*model.PackageUsage, error) {
|
||||
var usage model.PackageUsage
|
||||
query := s.db.WithContext(ctx).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN (?, ?)", constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted).
|
||||
Where("master_usage_id IS NULL"). // 主套餐的 master_usage_id 为 NULL
|
||||
Order("priority ASC, activated_at ASC")
|
||||
|
||||
|
||||
@@ -372,6 +372,8 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
|
||||
now := time.Now()
|
||||
validCardIDs := make([]uint, 0, len(validCards))
|
||||
// 单槽设备导入后有效绑定唯一,当前使用卡只能是该卡
|
||||
initializeCurrentCard := device.MaxSimSlots == 1 && len(validCards) == 1
|
||||
for _, slotICCID := range validCards {
|
||||
cardID := existingCards[slotICCID.ICCID].ID
|
||||
validCardIDs = append(validCardIDs, cardID)
|
||||
@@ -382,6 +384,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
SlotPosition: slotICCID.SlotPosition,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
IsCurrent: initializeCurrentCard,
|
||||
}
|
||||
if err := txBindingStore.Create(ctx, binding); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-18
|
||||
@@ -0,0 +1,77 @@
|
||||
## Context
|
||||
|
||||
- 设备资产解析在 `internal/service/asset` 内一次组装绑定卡、外层实名与套餐摘要;C 端资产信息 DTO 只暴露 `real_name_status` 一个外层实名信号(不含 `real_name_at`),逐卡实名由卡列表返回。
|
||||
- `is_current` 目前只在运营商设备观测、显式切卡、解绑与删设备路径写入;导入与手工绑定创建时不写,DB 默认 false。
|
||||
- 设备在运营商侧的识别标识为 IMEI 优先、空则用 SN,读取侧(资产信息实时状态)与观测侧(卡观测序列)口径一致。
|
||||
- 生产只读核对:有有效绑定的设备 19031 台,其中 639 台无任何当前卡标识;这 639 台里 76 台存在已实名绑定卡(16 台为单槽单卡,其余为多卡设备)。
|
||||
- 既有约束:自动化测试当前为 N/A(用户决策);生产写入由维护者按生产运行说明执行,Agent 只做只读诊断。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 让「设备没有任何当前卡标识」这一分支不再把已实名设备展示为未实名。
|
||||
- 让单槽单卡设备在绑定后即具备当前卡标识,消除「当前卡」展示为空的产生条件。
|
||||
- 给存量同类数据一个可复核、可回滚的核对与修复口径。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不改变存在当前卡标识时的判定结果。
|
||||
- 不改变管理端设备列表既有的「任一有效绑定卡已实名」筛选,以及设备激活状态判定。
|
||||
- 不为多卡设备推断当前卡,不改变运营商观测/切卡对 `is_current` 的写入职责。
|
||||
- 不处理运营商侧设备登记(如 Gateway 返回设备号无效)的修复。
|
||||
- 不引入 Schema 变更、迁移、一次性常驻命令或自动化测试。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 回退判定放在读取侧,不改写业务事实
|
||||
|
||||
无当前卡时,外层实名按有效绑定卡的实名状态取或(任一已实名即已实名),实现位置为设备资产解析中的外层实名判定。
|
||||
|
||||
- 理由:读取侧回退只影响展示,不把「哪张卡在用」变成猜测事实;多卡设备无当前卡写侧无法推断,回填会污染 `is_current` 语义。
|
||||
- 备选:在所有情况下都改为「任一有效绑定卡已实名」(否决,等于回退既有当前卡语义);在读取时按最大槽位推断当前卡(否决,会让 `is_current` 出现第二套判定来源)。
|
||||
|
||||
### 2. 保留「当前卡优先」分支,仅补分支缺口
|
||||
|
||||
存在 `is_current=true` 的有效绑定时沿用该卡实名状态;仅在一条当前卡标识都没有时进入回退。如此本 change 不改变有当前卡设备的既有返回。
|
||||
|
||||
### 3. 绑定创建时初始化单槽单卡的当前卡标识
|
||||
|
||||
判据:设备最大槽位为 1,且绑定创建后该设备的有效绑定唯一。此时设备只有唯一的可能当前卡,标记为当前使用卡是事实而非猜测。写入与绑定创建放在同一事务,保证绑定与当前卡标识一致可见;导入与手工绑定两条创建路径都要覆盖。
|
||||
|
||||
- 理由:多卡设备的当前卡必须来自运营商观测或显式切卡,绑定时不猜。
|
||||
- 备选:绑定后异步触发一次设备观测(否决,依赖外部调用且失败时仍留空)。
|
||||
|
||||
### 4. 存量数据走受控人工修复,不写一次性代码
|
||||
|
||||
只读核对口径(识别待修复绑定):
|
||||
|
||||
```sql
|
||||
SELECT d.id AS device_id, b.id AS binding_id, b.iot_card_id
|
||||
FROM tb_device_sim_binding b
|
||||
JOIN tb_device d ON d.id = b.device_id AND d.deleted_at IS NULL
|
||||
WHERE b.bind_status = 1
|
||||
AND d.max_sim_slots = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding x
|
||||
WHERE x.device_id = b.device_id AND x.bind_status = 1 AND x.is_current)
|
||||
AND (SELECT count(*) FROM tb_device_sim_binding y
|
||||
WHERE y.device_id = b.device_id AND y.bind_status = 1) = 1;
|
||||
```
|
||||
|
||||
受控修复:仅对上述结果集内的绑定置 `is_current = true`,同一设备只允许一条;执行前留存核对结果,执行后抽样比对设备卡列表与资产信息。
|
||||
|
||||
- 理由:生产写操作由维护者执行;一次性命令会长期留在仓库成为隐性维护面。
|
||||
- 备选:新增数据修复命令或迁移(否决,超出必要范围且规则与绑定创建规则重复)。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [无当前卡集合里包含「当前卡标识因观测缺失而丢失」的设备,回退可能把实际在用卡未实名的设备显示为已实名] → 以绑定卡本地事实为准,卡列表仍逐卡返回真实状态;上游设备登记类问题另行在运维侧处理,不在本 change。
|
||||
- [绑定即置当前卡可能让设备观测的期望值提前满足] → 判据限定最大槽位为 1 且有效绑定唯一,此时可被切换的目标只有同一张卡;多卡设备不写入。
|
||||
- [存量修复误伤多卡或重复当前卡] → 核对口径限定单槽且唯一有效绑定,修复语句限定 `bind_status = 1` 并保证同一设备至多一条 `is_current`;先只读核对,再小批执行。
|
||||
- [C 端展示口径变化无自动化测试覆盖] → 按用户既有决策不新增自动化测试,改为对案例设备(19090)与抽样设备做发布后实接口核对。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 代码发布:无 Schema 变更,随常规二进制发布生效;展示口径回退只需回滚二进制,无数据残留。
|
||||
2. 存量修复:先在只读环境按核对口径确认结果集,再由维护者按生产运行说明执行修复;不随发布自动执行。
|
||||
3. 发布后核对:以案例设备 `90001595626` 与抽样多卡设备比对资产信息外层实名、卡列表当前卡标识。
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
C 端设备资产信息 `GET /api/c/v1/asset/info` 的外层 `real_name_status` 只取「当前使用卡」(`is_current=true` 的绑定),无当前卡即返回未实名。而 `is_current` 仅在 Gateway 观测成功后写入,导入与手工绑定创建时恒为 false。于是从未与 Gateway 建立有效观测的设备长期「无当前卡」,即使绑定卡已完成实名,对外仍显示未实名(生产确认:设备 19090 的绑定 36730 `is_current=false`、`tb_device.last_gateway_sync_at` 为空,卡 131609 `real_name_status=1`)。同口径下生产现有 76 台设备受此影响,且设备卡列表中的「当前卡」标识同样为空。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 设备资产信息外层 `real_name_status`:存在 `is_current=true` 的有效绑定时沿用该卡状态(不推翻既有当前卡语义);设备没有任何当前卡时,回退为「任一有效绑定卡已实名即视为设备已实名」,不再一律返回未实名。
|
||||
- 设备绑定卡创建:当设备最大槽位为 1 且创建后仅存在一条有效绑定时,将该绑定初始化为当前卡;多卡设备的当前卡判定仍由 Gateway 观测与切卡流程写入,不由绑定时猜测。
|
||||
- 提供只读核对口径与受控人工修复步骤,用于按上述规则修复存量单槽单卡设备的当前卡标识;生产写入由维护者按生产运行说明执行。
|
||||
- 不改变卡列表的逐卡实名返回、实名链接目标卡选择、设备激活状态判定,以及管理端设备列表既有的「任一有效绑定卡已实名」筛选口径。
|
||||
- 不改变有当前卡时的既有判定结果。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- 无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `personal-customer`: 设备资产信息在没有当前使用卡时的实名状态判定。
|
||||
- `asset-device`: 单槽单卡设备绑定时的当前卡标识初始化,以及存量当前卡标识缺失的核对与受控修复口径。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 `internal/service/asset`(设备资产解析的外层实名判定)与设备绑定创建路径 `internal/service/device`、`internal/task`(设备导入)。
|
||||
- 生产影响面(只读核对):有有效绑定的设备 19031 台,其中 639 台无当前卡;639 台里 76 台存在已实名绑定卡(16 台为单槽单卡),修复后其 C 端设备实名展示由未实名变为已实名。
|
||||
- 不涉及 Schema 改动、迁移、外部接口契约、金额、库存与状态机。
|
||||
@@ -0,0 +1,153 @@
|
||||
# 存量当前卡标识核对与受控修复清单
|
||||
|
||||
本文件是 change `fix-device-realname-without-current-card` 任务 4.1 的只读核对产物,供维护者执行任务 4.2 的受控修复使用。
|
||||
|
||||
- 核对时间:2026-09-18
|
||||
- 核对环境:生产库只读查询(dbhub `pro_main`),全程未产生写入。
|
||||
- 核对口径:`design.md` 决策 4 的只读查询。
|
||||
|
||||
## 口径 SQL
|
||||
|
||||
```sql
|
||||
SELECT d.id AS device_id, b.id AS binding_id, b.iot_card_id
|
||||
FROM tb_device_sim_binding b
|
||||
JOIN tb_device d ON d.id = b.device_id AND d.deleted_at IS NULL
|
||||
WHERE b.bind_status = 1
|
||||
AND d.max_sim_slots = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding x
|
||||
WHERE x.device_id = b.device_id AND x.bind_status = 1 AND x.is_current)
|
||||
AND (SELECT count(*) FROM tb_device_sim_binding y
|
||||
WHERE y.device_id = b.device_id AND y.bind_status = 1) = 1;
|
||||
```
|
||||
|
||||
## 结果统计
|
||||
|
||||
| 指标 | 数量 |
|
||||
| --- | --- |
|
||||
| 待修复绑定(=待修复设备) | 75 |
|
||||
| 其中绑定卡已实名(`real_name_status = 1`) | 16 |
|
||||
| 其中绑定卡未实名 | 59 |
|
||||
|
||||
影响面参考(同库只读统计):
|
||||
|
||||
| 指标 | 数量 |
|
||||
| --- | --- |
|
||||
| 存在有效绑定卡的设备 | 19031 |
|
||||
| 其中没有任何当前卡标识 | 638 |
|
||||
| 其中存在已实名绑定卡 | 76 |
|
||||
| 其中单槽唯一有效绑定(本清单范围) | 75(已实名 16) |
|
||||
|
||||
## 待修复绑定清单
|
||||
|
||||
| device_no | device_id | binding_id | iot_card_id | iccid | real_name_status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 60002846141 | 6 | 11 | 5113 | 89860624660028461414 | 1 |
|
||||
| 60002846880 | 11 | 16 | 5118 | 89860624660028468807 | 1 |
|
||||
| 60002817472 | 13 | 18 | 5120 | 89860624660028174728 | 1 |
|
||||
| 60002817732 | 14 | 19 | 5121 | 89860624660028177325 | 1 |
|
||||
| 60002842491 | 16 | 21 | 5123 | 89860624660028424917 | 1 |
|
||||
| 90001595835 | 369 | 974 | 86943 | 89860624590015958355 | 1 |
|
||||
| 60003081045 | 18909 | 36349 | 131202 | 89860624660030810459 | 1 |
|
||||
| 90001595210 | 18912 | 36356 | 131211 | 89860624590015952101 | 1 |
|
||||
| 90001595801 | 18921 | 36373 | 131227 | 89860624590015958017 | 1 |
|
||||
| 90001595438 | 18942 | 36414 | 131355 | 89860624590015954388 | 1 |
|
||||
| 60003080864 | 19081 | 36711 | 131611 | 89860624660030808644 | 1 |
|
||||
| 90001595459 | 19082 | 36712 | 131226 | 89860624590015954594 | 1 |
|
||||
| 90001595626 | 19090 | 36730 | 131609 | 89860624590015956268 | 1 |
|
||||
| 60003080488 | 19091 | 36731 | 131610 | 89860624660030804882 | 1 |
|
||||
| 90001595086 | 19092 | 36732 | 131679 | 89860624590015950865 | 1 |
|
||||
| 60003081496 | 19128 | 36784 | 131780 | 89860624660030814964 | 1 |
|
||||
| 60002842359 | 7 | 12 | 5114 | 89860624660028423596 | 0 |
|
||||
| 60002846803 | 8 | 13 | 5115 | 89860624660028468039 | 0 |
|
||||
| 60002842779 | 9 | 14 | 5116 | 89860624660028427795 | 0 |
|
||||
| 60002842976 | 10 | 15 | 5117 | 89860624660028429767 | 0 |
|
||||
| 60002817595 | 12 | 17 | 5119 | 89860624660028175956 | 0 |
|
||||
| 60002817949 | 15 | 20 | 5122 | 89860624660028179495 | 0 |
|
||||
| 60003080109 | 368 | 973 | 86942 | 89860624660030801094 | 0 |
|
||||
| 60003081398 | 370 | 975 | 86944 | 89860624660030813982 | 0 |
|
||||
| 90001595523 | 371 | 976 | 86945 | 89860624590015955237 | 0 |
|
||||
| 60003081183 | 372 | 977 | 86946 | 89860624660030811838 | 0 |
|
||||
| 60003081174 | 373 | 978 | 86947 | 89860624660030811747 | 0 |
|
||||
| 60003081651 | 374 | 979 | 86948 | 89860624660030816514 | 0 |
|
||||
| 90001595896 | 375 | 980 | 86949 | 89860624590015958967 | 0 |
|
||||
| 60003080879 | 376 | 981 | 86950 | 89860624660030808792 | 0 |
|
||||
| 90001595588 | 377 | 982 | 86951 | 89860624590015955880 | 0 |
|
||||
| 60003081516 | 378 | 983 | 86952 | 89860624660030815169 | 0 |
|
||||
| 60003081645 | 379 | 984 | 86953 | 89860624660030816456 | 0 |
|
||||
| 60003081643 | 380 | 985 | 86954 | 89860624660030816431 | 0 |
|
||||
| 60003080711 | 381 | 986 | 86955 | 89860624660030807117 | 0 |
|
||||
| 60003080190 | 382 | 987 | 86956 | 89860624660030801904 | 0 |
|
||||
| 90001595805 | 383 | 988 | 86957 | 89860624590015958058 | 0 |
|
||||
| 60003080161 | 384 | 989 | 86958 | 89860624660030801615 | 0 |
|
||||
| 90001595810 | 385 | 990 | 86959 | 89860624590015958108 | 0 |
|
||||
| 60003081366 | 386 | 991 | 86960 | 89860624660030813669 | 0 |
|
||||
| 60003081251 | 387 | 992 | 86961 | 89860624660030812513 | 0 |
|
||||
| 60003081350 | 388 | 993 | 86962 | 89860624660030813503 | 0 |
|
||||
| 60003080752 | 389 | 994 | 86963 | 89860624660030807521 | 0 |
|
||||
| 60003081310 | 390 | 995 | 86964 | 89860624660030813107 | 0 |
|
||||
| 60003080791 | 391 | 996 | 86965 | 89860624660030807919 | 0 |
|
||||
| 60003081268 | 392 | 997 | 86966 | 89860624660030812687 | 0 |
|
||||
| 90001595763 | 18913 | 36357 | 131212 | 89860624590015957639 | 0 |
|
||||
| 90001595413 | 18914 | 36358 | 131213 | 89860624590015954131 | 0 |
|
||||
| 60003080907 | 19093 | 36733 | 131680 | 89860624660030809071 | 0 |
|
||||
| 60002817276 | 19101 | 36756 | 112006 | 89860624660028172763 | 0 |
|
||||
| 60003080052 | 19102 | 36757 | 131715 | 89860624660030800526 | 0 |
|
||||
| 90001595253 | 19103 | 36758 | 131716 | 89860624590015952531 | 0 |
|
||||
| 90001595502 | 19104 | 36759 | 131717 | 89860624590015955021 | 0 |
|
||||
| 60003081741 | 19105 | 36760 | 131718 | 89860624660030817413 | 0 |
|
||||
| 90001595886 | 19107 | 36763 | 131719 | 89860624590015958868 | 0 |
|
||||
| 90001595902 | 19108 | 36764 | 131720 | 89860624590015959023 | 0 |
|
||||
| 90001595553 | 19109 | 36765 | 131721 | 89860624590015955534 | 0 |
|
||||
| 90001595460 | 19110 | 36766 | 131722 | 89860624590015954602 | 0 |
|
||||
| 60003081979 | 19111 | 36767 | 131723 | 89860624660030819799 | 0 |
|
||||
| 60003081938 | 19112 | 36768 | 131724 | 89860624660030819385 | 0 |
|
||||
| 60003081807 | 19113 | 36769 | 131725 | 89860624660030818072 | 0 |
|
||||
| 90001595761 | 19114 | 36770 | 131726 | 89860624590015957613 | 0 |
|
||||
| 90001595422 | 19115 | 36771 | 131727 | 89860624590015954222 | 0 |
|
||||
| 60003081975 | 19116 | 36772 | 131728 | 89860624660030819757 | 0 |
|
||||
| 60003080037 | 19117 | 36773 | 131729 | 89860624660030800377 | 0 |
|
||||
| 90001595510 | 19118 | 36774 | 131730 | 89860624590015955104 | 0 |
|
||||
| 60003080038 | 19119 | 36775 | 131731 | 89860624660030800385 | 0 |
|
||||
| 60003080062 | 19120 | 36776 | 131732 | 89860624660030800625 | 0 |
|
||||
| 60002817135 | 19121 | 36777 | 111363 | 89860624660028171351 | 0 |
|
||||
| 60003080264 | 19122 | 36778 | 131745 | 89860624660030802647 | 0 |
|
||||
| 90001595473 | 19123 | 36779 | 131746 | 89860624590015954735 | 0 |
|
||||
| 60003080128 | 19124 | 36780 | 131776 | 89860624660030801284 | 0 |
|
||||
| 90001595873 | 19125 | 36781 | 131777 | 89860624590015958736 | 0 |
|
||||
| 60003081839 | 19126 | 36782 | 131778 | 89860624660030818395 | 0 |
|
||||
| 60003081051 | 19127 | 36783 | 131779 | 89860624660030810517 | 0 |
|
||||
|
||||
## 受控修复(由维护者执行,不随发布自动执行)
|
||||
|
||||
修复语句使用与核对完全相同的口径,且限定 `bind_status = 1`,同一设备至多命中一条:
|
||||
|
||||
```sql
|
||||
UPDATE tb_device_sim_binding b
|
||||
SET is_current = true, updated_at = now()
|
||||
WHERE b.bind_status = 1
|
||||
AND b.id IN (
|
||||
SELECT b2.id
|
||||
FROM tb_device_sim_binding b2
|
||||
JOIN tb_device d ON d.id = b2.device_id AND d.deleted_at IS NULL
|
||||
WHERE b2.bind_status = 1
|
||||
AND d.max_sim_slots = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding x
|
||||
WHERE x.device_id = b2.device_id AND x.bind_status = 1 AND x.is_current)
|
||||
AND (SELECT count(*) FROM tb_device_sim_binding y
|
||||
WHERE y.device_id = b2.device_id AND y.bind_status = 1) = 1
|
||||
);
|
||||
```
|
||||
|
||||
执行前后核对:
|
||||
|
||||
1. 执行前重跑本文件口径 SQL,与清单行数(75)比对;不一致则以最新结果为准。
|
||||
2. 执行后重跑口径 SQL,结果应为 0 行。
|
||||
3. 校验同一设备不存在多条 `is_current = true`:
|
||||
```sql
|
||||
SELECT device_id, count(*) FROM tb_device_sim_binding
|
||||
WHERE bind_status = 1 AND is_current GROUP BY device_id HAVING count(*) > 1;
|
||||
```
|
||||
应返回 0 行。
|
||||
4. 抽样实接口核对资产信息外层实名与卡列表当前卡标识(含设备 `90001595626`)。
|
||||
|
||||
回滚:`UPDATE tb_device_sim_binding SET is_current = false WHERE id IN (<清单 binding_id>)`。
|
||||
@@ -0,0 +1,47 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 单槽单卡设备绑定时初始化当前卡标识
|
||||
|
||||
系统 SHALL 在设备最大槽位为 1 且该设备绑定的有效卡唯一时,将该绑定标记为当前使用卡;绑定成功后的设备卡列表 SHALL 把该卡作为当前卡对外返回。对多卡设备,系统 MUST NOT 在绑定时推断当前使用卡,其当前卡标识仍由运营商设备观测或显式切卡结果决定。系统 MUST 保证同一设备同一时刻至多一条有效绑定被标记为当前使用卡;解绑 SHALL 清除该绑定的当前卡标识。
|
||||
|
||||
#### Scenario: 单槽设备绑定唯一卡
|
||||
|
||||
- **GIVEN** 设备最大槽位为 1 且当前没有有效绑定卡
|
||||
- **WHEN** 管理员为该设备绑定一张有效卡
|
||||
- **THEN** 该绑定被标记为当前使用卡
|
||||
- **AND** 后续设备卡列表中该卡返回为当前卡
|
||||
|
||||
#### Scenario: 多卡设备绑定不推断当前卡
|
||||
|
||||
- **GIVEN** 设备最大槽位大于 1
|
||||
- **WHEN** 管理员为该设备绑定一张卡
|
||||
- **THEN** 绑定结果不改变该设备既有的当前使用卡标识
|
||||
|
||||
#### Scenario: 解绑清除当前卡标识
|
||||
|
||||
- **GIVEN** 某有效绑定被标记为当前使用卡
|
||||
- **WHEN** 该绑定被解绑
|
||||
- **THEN** 该设备不再存在被标记为当前使用卡的有效绑定
|
||||
|
||||
### Requirement: 存量当前卡标识的核对与受控修复
|
||||
|
||||
系统 SHALL 提供只读核对口径,用于按「设备最大槽位为 1、有效绑定唯一、当前无任何当前卡标识」识别需要修复的存量绑定。受控修复 SHALL 仅将符合该口径的绑定标记为当前使用卡,MUST NOT 修改其他绑定、MUST NOT 使同一设备出现多条当前使用卡。修复后设备卡列表 SHALL 反映该当前卡标识,设备实名判定 SHALL 按绑定卡事实返回。
|
||||
|
||||
#### Scenario: 只读核对识别待修复设备
|
||||
|
||||
- **GIVEN** 设备最大槽位为 1、存在唯一有效绑定且没有任何当前卡标识
|
||||
- **WHEN** 维护者按核对口径查询
|
||||
- **THEN** 该设备出现在待修复结果中,且查询本身不产生任何写入
|
||||
|
||||
#### Scenario: 受控修复后当前卡可见
|
||||
|
||||
- **GIVEN** 已按核对口径确认的设备
|
||||
- **WHEN** 维护者执行受控修复
|
||||
- **THEN** 该设备的唯一有效绑定被标记为当前使用卡
|
||||
- **AND** 该设备的卡列表与资产信息按最新绑定事实返回
|
||||
|
||||
#### Scenario: 多卡设备不在修复范围
|
||||
|
||||
- **GIVEN** 设备最大槽位大于 1 或有效绑定多于一条
|
||||
- **WHEN** 维护者按核对口径查询
|
||||
- **THEN** 该设备不出现在待修复结果中
|
||||
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 缺少当前使用卡时的设备实名判定
|
||||
|
||||
系统 SHALL 在个人客户查询设备资产 `GET /api/c/v1/asset/info` 时按下述规则返回外层 `real_name_status`:存在有效绑定卡的当前使用标识时,返回该卡的实名状态;不存在任何当前使用卡时,只要该设备存在任一已实名(`real_name_status=1`)的有效绑定卡,SHALL 返回已实名,否则返回未实名。返回的 `real_name_status_name` SHALL 与 `real_name_status` 保持一致。卡列表中的逐卡实名状态 MUST 保持按卡返回,不受本判定影响。该判定 MUST NOT 因运营商实时接口调用失败或超时而改变。
|
||||
|
||||
#### Scenario: 无当前使用卡但绑定卡已实名
|
||||
|
||||
- **GIVEN** 设备存在有效绑定卡且其中至少一张已实名,且该设备没有任何 `is_current=true` 的有效绑定卡
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应外层 `real_name_status` 返回 `1` 且 `real_name_status_name` 为已实名
|
||||
- **AND** 卡列表中该已实名卡仍按其自身实名状态返回
|
||||
|
||||
#### Scenario: 无当前使用卡且绑定卡均未实名
|
||||
|
||||
- **GIVEN** 设备存在有效绑定卡且全部未实名,且该设备没有任何 `is_current=true` 的有效绑定卡
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应外层 `real_name_status` 返回 `0` 且 `real_name_status_name` 为未实名
|
||||
|
||||
#### Scenario: 有当前使用卡时沿用该卡状态
|
||||
|
||||
- **GIVEN** 设备存在一张 `is_current=true` 的未实名绑定卡,同时存在另一张已实名绑定卡
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应外层 `real_name_status` 返回该当前使用卡的未实名状态
|
||||
|
||||
#### Scenario: 运营商实时查询失败不改变实名判定
|
||||
|
||||
- **GIVEN** 设备的运营商实时状态查询失败或超时
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应仍按绑定卡事实返回外层实名状态,不因实时查询失败而返回未实名
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. 设备实名回退判定
|
||||
|
||||
- [x] 1.1 在设备资产解析的外层实名判定中保留「有当前使用卡时沿用该卡状态」分支,补上「无任何当前使用卡时按有效绑定卡实名状态取或」的回退。
|
||||
- [x] 1.2 确认回退不改变逐卡实名状态、实名策略、激活状态、套餐摘要与运营商实时字段的既有返回。
|
||||
|
||||
## 2. 绑定创建初始化当前卡标识
|
||||
|
||||
- [x] 2.1 手工绑定路径:设备最大槽位为 1 且绑定后有效绑定唯一时,在与绑定创建同一事务内将该绑定标记为当前使用卡。
|
||||
- [x] 2.2 设备导入路径:同一判据在导入创建绑定的事务内生效。
|
||||
- [x] 2.3 确认多卡设备绑定不写入当前卡标识,既有切卡、运营商观测与解绑对当前卡标识的写入行为不变。
|
||||
|
||||
## 3. 交付验证(临时手段,不留产物)
|
||||
|
||||
- [x] 3.1 用隔离数据或临时调用验证三类返回:无当前卡且存在已实名绑定卡时为已实名;无当前卡且绑定卡均未实名时为未实名;存在当前卡时沿用该卡状态。
|
||||
- [x] 3.2 验证单槽单卡设备绑定后设备卡列表返回该卡为当前卡,且多卡设备绑定后当前卡标识不变。
|
||||
- [x] 3.3 运行 gofmt、`go build ./cmd/api ./cmd/worker`、`openspec validate --all` 与 `./scripts/context-health.sh`;未新增路由,按需确认 OpenAPI 描述是否需要重新生成;删除临时验证产物。
|
||||
|
||||
## 4. 存量核对与发布后验证
|
||||
|
||||
- [x] 4.1 按 design.md 的只读核对口径在只读环境产出待修复绑定清单并留存证据。
|
||||
- [ ] 4.2 由维护者按生产运行说明执行受控修复,仅覆盖核对清单内的单槽唯一绑定,不随发布自动执行。
|
||||
- [ ] 4.3 发布后以设备 `90001595626` 及抽样多卡设备实接口核对资产信息外层实名与卡列表当前卡标识。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-08
|
||||
27
openspec/changes/fix-depleted-package-display/design.md
Normal file
27
openspec/changes/fix-depleted-package-display/design.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Context
|
||||
|
||||
现有资产信息组装只查询 `status=1` 的主套餐。套餐达到有效业务阈值会变为 `status=2`,但仍未到期;此时 C 端资产信息把套餐摘要投影为无套餐零值。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 让 C 端资产信息对 `status IN (1,2)` 的当前主套餐使用同一套摘要投影。
|
||||
- 维持既有主套餐选择顺序与空套餐响应形状。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不改变虚流量耗尽阈值或任何套餐状态流转。
|
||||
- 不改变待生效、已过期、已失效套餐的展示规则。
|
||||
- 不修改套餐历史接口、价格、续费资格、Schema 或迁移。
|
||||
|
||||
## Decisions
|
||||
|
||||
- 将资产信息使用的主套餐查询语义扩展为 `status IN (1,2)`,并仅限 `master_usage_id IS NULL`。两种状态都是当前生命周期内占位的主套餐;已有生命周期与到期扫描也以这两个状态共同处理。
|
||||
- 保留既有 `priority ASC, activated_at ASC` 排序,避免多记录异常时改变当前选择结果。
|
||||
- 套餐摘要继续直接使用选中的 `PackageUsage` 快照及统一流量指标计算,避免针对已用完状态复制或重算数据。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- B1 的 `current_package_*` 字段会从零值变为已用完套餐数据;这是用户要求的兼容性行为修正,前端需按已用完状态展示而非将有使用记录视为可继续使用。
|
||||
- 若存量数据同时存在多条 `status IN (1,2)` 主套餐,接口沿用既有排序选第一条;本变更不修复该数据不变量问题。
|
||||
24
openspec/changes/fix-depleted-package-display/proposal.md
Normal file
24
openspec/changes/fix-depleted-package-display/proposal.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
C 端资产信息接口将已用完的主套餐当作“无套餐”返回,导致用户无法查看已用量、套餐名称和到期时间。已用完仍是当前世代中可展示且未到期的套餐状态,必须与生效中套餐一致投影其使用信息。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 调整 `GET /api/c/v1/asset/info` 的当前主套餐选择范围:除生效中主套餐外,也选择已用完主套餐。
|
||||
- 已用完主套餐命中时,接口返回套餐名称、使用记录 ID、开始/到期时间及真/虚流量指标,不再返回套餐字段零值。
|
||||
- 保持待生效、已过期、已失效主套餐不作为当前套餐展示;不改变套餐耗尽阈值、状态流转、续费价格或套餐历史接口。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- 无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `personal-customer`: C 端资产信息必须展示当前已用完主套餐的使用信息。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 `internal/service/asset` 的当前主套餐读取,以及 `internal/store/postgres` 的主套餐状态筛选。
|
||||
- `GET /api/c/v1/asset/info` 对已用完套餐由零值摘要改为完整套餐摘要;不涉及 Schema、迁移、外部调用或写操作。
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 资产信息展示当前可用或已用完主套餐
|
||||
|
||||
系统 SHALL 在个人客户查询其已绑定资产的 `GET /api/c/v1/asset/info` 时,将当前世代中状态为生效中(`status=1`)或已用完(`status=2`)的主套餐作为当前套餐摘要返回。返回的套餐摘要 SHALL 包含套餐名称、套餐使用记录 ID、开始时间、到期时间、真流量与虚流量指标。待生效、已过期和已失效的主套餐不得作为当前套餐摘要返回。
|
||||
|
||||
#### Scenario: 已用完主套餐仍展示使用情况
|
||||
|
||||
- **GIVEN** 已认证个人客户拥有当前世代唯一的已用完主套餐,且该主套餐状态为 `status=2`
|
||||
- **WHEN** 客户请求 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应返回该主套餐的名称、使用记录 ID、开始时间、到期时间及流量指标
|
||||
- **AND** 响应不得将该套餐摘要字段返回为无套餐零值
|
||||
|
||||
#### Scenario: 生效中主套餐保持既有展示
|
||||
|
||||
- **GIVEN** 已认证个人客户拥有当前世代唯一的生效中主套餐,且该主套餐状态为 `status=1`
|
||||
- **WHEN** 客户请求 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应返回该主套餐的既有套餐摘要和流量指标
|
||||
|
||||
#### Scenario: 不可展示状态不作为当前套餐
|
||||
|
||||
- **GIVEN** 已认证个人客户没有状态为 `status=1` 或 `status=2` 的主套餐
|
||||
- **WHEN** 客户请求 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应的当前套餐摘要保持无套餐零值
|
||||
9
openspec/changes/fix-depleted-package-display/tasks.md
Normal file
9
openspec/changes/fix-depleted-package-display/tasks.md
Normal file
@@ -0,0 +1,9 @@
|
||||
## 1. 当前套餐读取
|
||||
|
||||
- [x] 1.1 将资产信息的当前主套餐读取范围扩展为生效中和已用完状态,同时保留主套餐与既有排序限制。
|
||||
- [x] 1.2 确认资产信息响应复用既有套餐快照和流量指标映射,不改变其他状态、价格或续费逻辑。
|
||||
|
||||
## 2. 验证与交付
|
||||
|
||||
- [x] 2.1 用临时可删除的调用或隔离数据验证已用完主套餐在资产信息中返回名称、使用记录、时间和流量指标;不新增自动化测试。
|
||||
- [x] 2.2 运行受影响的格式化、构建、OpenAPI 生成与 OpenSpec 校验,并删除临时验证产物。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
@@ -0,0 +1,33 @@
|
||||
## Context
|
||||
|
||||
API 与 Worker 分别创建订单服务。Worker 已为订单服务注入停复机模块,API 未注入;因此支付回调调用支付后自动复机入口时没有可执行的回调。支付后自动复机入口在事务提交后查询本订单的已生效主套餐,再异步调用停复机模块。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 使 API 支付回调复用既有的支付后自动复机入口。
|
||||
- 保持“仅已生效主套餐触发”的现有查询语义和停复机资格判断。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不改变套餐权益状态、支付事务范围、Gateway 重试和轮询调度。
|
||||
- 不让支付回调等待 Gateway 复机结果。
|
||||
- 不新增队列、端口、数据表或配置。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 在 API Composition Root 注入既有回调
|
||||
|
||||
在 API 创建订单服务后调用既有的 `SetResumeCallback(stopResumeService)`。这是 Worker 已使用的装配方式;复用同一模块可将卡/设备载体解析、有效套餐、剩余流量、实名、停机原因、分布式锁和 Gateway 调用保持在一个实现内。
|
||||
|
||||
不在支付回调中直接调用 Gateway,也不复制资格判断。直接调用会绕过已存在的幂等锁、设备多卡处理和审计/观测写入。
|
||||
|
||||
### 以事务提交后的已生效权益为复机前置条件
|
||||
|
||||
保留订单服务当前的查询:仅本订单 `status=Active` 且 `master_usage_id IS NULL` 的主套餐权益可触发复机。查询发生在支付与权益事务成功返回后,避免使用未提交或排队权益快照。复机模块在异步执行时重新读取卡及权益事实,防止并发状态变化导致错误复机。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Gateway 调用仍是异步副作用;支付成功不等于复机成功。该取舍保持支付事实可靠落库,不将外部网络延迟或失败耦合到支付回调。
|
||||
- API 进程现在可发起复机;其依赖已由同一 Composition Root 配置,且执行路径与 Worker 一致。
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
套餐订单的第三方支付回调已在提交套餐权益后调用自动复机入口,但 API 进程的订单服务未注入复机回调。支付成功后,卡只能等待异步观测或套餐轮询才可能恢复网络,不能满足支付成功后立即按最新业务事实检查并复机的要求。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在 API 进程为订单服务注入已有的停复机模块,使套餐订单支付事务成功提交后立即触发一次自动复机资格检查。
|
||||
- 仅对本订单已持久化为生效中的主套餐触发检查;排队、待实名或其他未生效主套餐不得触发复机。
|
||||
- 沿用已有停复机模块的套餐、流量、实名、停机原因和幂等锁判断;支付回调不等待 Gateway 调用完成。
|
||||
- 不修改套餐生效状态机、支付状态机、Worker 复机逻辑或轮询兜底机制。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- 无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-lifecycle`: 规定套餐订单支付成功后,已生效主套餐触发一次基于已提交业务事实的即时自动复机检查。
|
||||
|
||||
## Impact
|
||||
|
||||
- 代码:`internal/bootstrap/services.go`。
|
||||
- 运行时:API 支付回调完成数据库事务后异步调用既有 Gateway 复机路径;无需迁移、路由或新的外部协议。
|
||||
- 同步:补丁先合入 `main`,再以单独提交定点同步到 `Iteration/8-11`。
|
||||
@@ -0,0 +1,55 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 套餐状态流转
|
||||
|
||||
系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。主套餐到期时,系统 MUST 先确定同一载体是否存在待生效的后续主套餐:存在时,后续套餐激活与停复机重新评估 MUST 由同一条顺序流程完成;系统 MUST NOT 依据后续套餐激活前的无套餐快照发起停机。后续套餐成功生效后,系统 MUST 依据最新套餐、流量和实名事实重新判断卡网络状态,且不得遗留 `no_package` 停机。不存在后续套餐或后续套餐经业务校验不能生效时,系统 SHALL 按现有停机规则评估卡状态。后续套餐激活结果未知或任务投递失败不得被当作无后续套餐处理并据此停机,系统 SHALL 保留既有激活恢复与轮询兜底路径。
|
||||
|
||||
套餐订单支付成功时,系统 MUST 在支付和套餐权益事务提交后,检查本订单是否存在已生效且未挂靠其他主套餐的主套餐权益。存在时,系统 MUST 基于已提交的套餐、流量、实名和停机原因事实异步尝试自动复机;支付回调不得等待上游复机结果。主套餐权益处于待生效、待实名生效或其他非生效状态时,系统 MUST NOT 因本次支付发起自动复机。自动复机失败 SHALL 保留既有失败记录与轮询兜底机制。
|
||||
|
||||
#### Scenario: 支付后已生效主套餐触发自动复机
|
||||
|
||||
- **GIVEN** 套餐订单支付成功后,本订单存在已生效且未挂靠其他主套餐的主套餐权益,载体因可自动恢复的原因处于停机状态
|
||||
- **WHEN** 支付和套餐权益事务提交成功
|
||||
- **THEN** 系统异步按最新套餐、流量、实名和停机原因事实检查载体,并在全部复机条件满足时调用既有上游复机流程;支付回调不等待该调用结束
|
||||
|
||||
#### Scenario: 支付后主套餐未生效不触发自动复机
|
||||
|
||||
- **GIVEN** 套餐订单支付成功后,本订单主套餐权益仍处于待生效、待实名生效或其他非生效状态
|
||||
- **WHEN** 支付和套餐权益事务提交成功
|
||||
- **THEN** 系统不因本次支付发起自动复机,并保留后续套餐激活和轮询处理
|
||||
|
||||
#### Scenario: 支付后复机条件不满足
|
||||
|
||||
- **GIVEN** 套餐订单支付成功后,本订单存在已生效主套餐权益
|
||||
- **WHEN** 载体为手动停机、无有效套餐、流量已耗尽或不满足实名策略
|
||||
- **THEN** 系统不调用上游复机流程,保留当前网络状态与既有停复机处理路径
|
||||
|
||||
#### Scenario: 套餐状态流转
|
||||
|
||||
- **GIVEN** 套餐或使用记录处于允许的前置状态
|
||||
- **WHEN** 执行状态操作
|
||||
- **THEN** 仅发生一次允许的状态变化;不满足前置状态时返回业务错误
|
||||
|
||||
#### Scenario: 到期主套餐接续后续套餐
|
||||
|
||||
- **GIVEN** 某载体的当前主套餐到期,且存在满足激活条件的待生效后续主套餐
|
||||
- **WHEN** 系统处理该主套餐到期
|
||||
- **THEN** 系统先完成后续套餐激活并按最新权益事实重新评估停复机,且不得因到期前的无套餐快照对该载体发起 `no_package` 停机
|
||||
|
||||
#### Scenario: 到期主套餐无后续可生效套餐
|
||||
|
||||
- **GIVEN** 某载体的当前主套餐到期,且不存在后续主套餐或队首后续套餐不满足激活条件
|
||||
- **WHEN** 系统完成该套餐到期处理
|
||||
- **THEN** 系统按当前套餐、流量和实名事实执行既有停机评估
|
||||
|
||||
#### Scenario: 后续套餐激活结果未知
|
||||
|
||||
- **GIVEN** 某载体的当前主套餐到期,存在待生效后续主套餐,但激活任务投递或执行结果暂时未知
|
||||
- **WHEN** 系统处理该套餐到期
|
||||
- **THEN** 系统不得将该未知结果视为不存在后续套餐而依据旧快照发起停机,并保留既有激活恢复与套餐轮询兜底
|
||||
|
||||
#### Scenario: 卡状态轮询发现缺失的套餐任务
|
||||
|
||||
- **GIVEN** 启用轮询的卡匹配套餐检查配置,且其 `polling:package` 分片队列项因异常缺失
|
||||
- **WHEN** 卡状态轮询成功完成且未命中风险停机
|
||||
- **THEN** 系统基于最新卡状态仅补入缺失的套餐任务,不改写已存在套餐任务的执行时间;后续套餐任务仍按既有停复机条件评估该卡
|
||||
@@ -0,0 +1,10 @@
|
||||
## 1. 主干即时复机
|
||||
|
||||
- [x] 1.1 在 API Composition Root 为订单服务注入既有停复机回调,不改变支付或套餐状态机。
|
||||
- [x] 1.2 格式化变更文件并构建 API,核对支付事务提交后仅已生效主套餐进入现有自动复机路径。
|
||||
- [x] 1.3 校验 OpenSpec 变更并将契约和代码以独立中文提交合入 `main`。
|
||||
|
||||
## 2. 八月迭代同步
|
||||
|
||||
- [ ] 2.1 在八月迭代分支的在途改动提交且工作区干净后,定点同步主干补丁提交并保留在途支付商户装配。
|
||||
- [ ] 2.2 构建八月迭代分支 API,并核对同一 API 回调装配存在。
|
||||
@@ -68,6 +68,52 @@
|
||||
- **WHEN** 生产者向公共可靠事件存储提交超过字段上限的事件标识或父事件标识
|
||||
- **THEN** 系统在持久化边界返回明确的参数错误而不是数据库字段错误
|
||||
|
||||
### Requirement: 单槽单卡设备绑定时初始化当前卡标识
|
||||
|
||||
系统 SHALL 在设备最大槽位为 1 且该设备绑定的有效卡唯一时,将该绑定标记为当前使用卡;绑定成功后的设备卡列表 SHALL 把该卡作为当前卡对外返回。对多卡设备,系统 MUST NOT 在绑定时推断当前使用卡,其当前卡标识仍由运营商设备观测或显式切卡结果决定。系统 MUST 保证同一设备同一时刻至多一条有效绑定被标记为当前使用卡;解绑 SHALL 清除该绑定的当前卡标识。
|
||||
|
||||
#### Scenario: 单槽设备绑定唯一卡
|
||||
|
||||
- **GIVEN** 设备最大槽位为 1 且当前没有有效绑定卡
|
||||
- **WHEN** 管理员为该设备绑定一张有效卡
|
||||
- **THEN** 该绑定被标记为当前使用卡
|
||||
- **AND** 后续设备卡列表中该卡返回为当前卡
|
||||
|
||||
#### Scenario: 多卡设备绑定不推断当前卡
|
||||
|
||||
- **GIVEN** 设备最大槽位大于 1
|
||||
- **WHEN** 管理员为该设备绑定一张卡
|
||||
- **THEN** 绑定结果不改变该设备既有的当前使用卡标识
|
||||
|
||||
#### Scenario: 解绑清除当前卡标识
|
||||
|
||||
- **GIVEN** 某有效绑定被标记为当前使用卡
|
||||
- **WHEN** 该绑定被解绑
|
||||
- **THEN** 该设备不再存在被标记为当前使用卡的有效绑定
|
||||
|
||||
### Requirement: 存量当前卡标识的核对与受控修复
|
||||
|
||||
系统 SHALL 提供只读核对口径,用于按「设备最大槽位为 1、有效绑定唯一、当前无任何当前卡标识」识别需要修复的存量绑定。受控修复 SHALL 仅将符合该口径的绑定标记为当前使用卡,MUST NOT 修改其他绑定、MUST NOT 使同一设备出现多条当前使用卡。修复后设备卡列表 SHALL 反映该当前卡标识,设备实名判定 SHALL 按绑定卡事实返回。
|
||||
|
||||
#### Scenario: 只读核对识别待修复设备
|
||||
|
||||
- **GIVEN** 设备最大槽位为 1、存在唯一有效绑定且没有任何当前卡标识
|
||||
- **WHEN** 维护者按核对口径查询
|
||||
- **THEN** 该设备出现在待修复结果中,且查询本身不产生任何写入
|
||||
|
||||
#### Scenario: 受控修复后当前卡可见
|
||||
|
||||
- **GIVEN** 已按核对口径确认的设备
|
||||
- **WHEN** 维护者执行受控修复
|
||||
- **THEN** 该设备的唯一有效绑定被标记为当前使用卡
|
||||
- **AND** 该设备的卡列表与资产信息按最新绑定事实返回
|
||||
|
||||
#### Scenario: 多卡设备不在修复范围
|
||||
|
||||
- **GIVEN** 设备最大槽位大于 1 或有效绑定多于一条
|
||||
- **WHEN** 维护者按核对口径查询
|
||||
- **THEN** 该设备不出现在待修复结果中
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
@@ -32,6 +32,35 @@
|
||||
- **WHEN** 查询资产列表或详情
|
||||
- **THEN** 返回该客户可见的资产而不包含其他客户资产
|
||||
|
||||
### Requirement: 缺少当前使用卡时的设备实名判定
|
||||
|
||||
系统 SHALL 在个人客户查询设备资产 `GET /api/c/v1/asset/info` 时按下述规则返回外层 `real_name_status`:存在有效绑定卡的当前使用标识时,返回该卡的实名状态;不存在任何当前使用卡时,只要该设备存在任一已实名(`real_name_status=1`)的有效绑定卡,SHALL 返回已实名,否则返回未实名。返回的 `real_name_status_name` SHALL 与 `real_name_status` 保持一致。卡列表中的逐卡实名状态 MUST 保持按卡返回,不受本判定影响。该判定 MUST NOT 因运营商实时接口调用失败或超时而改变。
|
||||
|
||||
#### Scenario: 无当前使用卡但绑定卡已实名
|
||||
|
||||
- **GIVEN** 设备存在有效绑定卡且其中至少一张已实名,且该设备没有任何 `is_current=true` 的有效绑定卡
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应外层 `real_name_status` 返回 `1` 且 `real_name_status_name` 为已实名
|
||||
- **AND** 卡列表中该已实名卡仍按其自身实名状态返回
|
||||
|
||||
#### Scenario: 无当前使用卡且绑定卡均未实名
|
||||
|
||||
- **GIVEN** 设备存在有效绑定卡且全部未实名,且该设备没有任何 `is_current=true` 的有效绑定卡
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应外层 `real_name_status` 返回 `0` 且 `real_name_status_name` 为未实名
|
||||
|
||||
#### Scenario: 有当前使用卡时沿用该卡状态
|
||||
|
||||
- **GIVEN** 设备存在一张 `is_current=true` 的未实名绑定卡,同时存在另一张已实名绑定卡
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应外层 `real_name_status` 返回该当前使用卡的未实名状态
|
||||
|
||||
#### Scenario: 运营商实时查询失败不改变实名判定
|
||||
|
||||
- **GIVEN** 设备的运营商实时状态查询失败或超时
|
||||
- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应仍按绑定卡事实返回外层实名状态,不因实时查询失败而返回未实名
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
Reference in New Issue
Block a user