Compare commits
5 Commits
Iteration/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -346,7 +346,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 +881,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 +889,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 +1387,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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 回调装配存在。
|
||||
Reference in New Issue
Block a user