Compare commits
9 Commits
0138a3fb9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ead341f2f | ||
|
|
ee5772be2c | ||
|
|
7c1026d65e | ||
|
|
f44f1be0d1 | ||
|
|
adcd4f5e9d | ||
|
|
76f7d47167 | ||
|
|
e9d8c95b44 | ||
|
|
7ca79ebf28 | ||
|
|
1ffc923b94 |
133
docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md
Normal file
133
docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 2026-11-04 换货迁移导致卡上两个主套餐同时生效的数据修正
|
||||
|
||||
## 现象
|
||||
|
||||
卡 `89860624630055040571`(`tb_iot_card.id = 93937`)在后台的预计套餐到期时间显示"数据异常"(`expiry_estimate_status = invalid_data`)。
|
||||
|
||||
## 原因
|
||||
|
||||
### 1. 换货迁移把老卡的生效中套餐原样挂到了新卡上
|
||||
|
||||
2026-11-03 17:42,换货单 `EXC20261103174239616885`(`tb_exchange_order.id = 1094`)完成直接换货:
|
||||
|
||||
- 老卡:`89860624630055027198`(`id = 93846`),停机、未实名,从未产生流量。
|
||||
- 新卡:`89860624630055040571`(`id = 93937`)。
|
||||
- 选了迁移数据。换货原因是"未实名卡,给老卡续费"。
|
||||
|
||||
`internal/service/exchange/migration.go` 的 `migratePackageUsageWithTx` 只把老卡套餐使用记录的 `iot_card_id` 改成新卡,状态原样保留,也不检查新卡是否已有生效中的主套餐。迁移后新卡上同时有两个生效中(`status = 1`)的主套餐:
|
||||
|
||||
| 套餐使用记录 | 套餐 | 订单号 | 修正前状态 | 到期时间 |
|
||||
|---|---|---|---|---|
|
||||
| 47051 | Mifi-U省内版(12个月省内流量每月3000G),`package_id = 272` | `MIG-89860624630055040571-1` | 生效中 | 2026-12-14 |
|
||||
| 46981 | 12个月省内流量每月1000G,`package_id = 230` | `MIG-89860624630055027198-1` | 生效中,`activated_at` 为空 | 2027-12-14 |
|
||||
|
||||
46981 的到期时间正好是 47051 到期后再加一年,说明运营的本意是"续费,接在现有套餐后面"。
|
||||
|
||||
`internal/query/packageexpiry/query.go` 的 `Calculate` 要求一个资产只能有一个生效中或已用完的主套餐。出现两个时返回 `invalid_data`。
|
||||
|
||||
### 2. 流量扣到了续费的套餐上
|
||||
|
||||
换货后,新卡的流量全部扣到了 46981 上,47051 从 11-03 17:40 起没有再被扣过:
|
||||
|
||||
| 日期 | 47051 日用量 | 46981 日用量 |
|
||||
|---|---|---|
|
||||
| 11-01 | 941 MB | |
|
||||
| 11-02 | 255 MB | |
|
||||
| 11-03 | 5,393 MB | 52 MB |
|
||||
| 11-04 | | 2,707 MB |
|
||||
|
||||
合计 9,348 MB,和新卡 11 月的 `current_month_usage_mb = 9348.33` 一致。老卡本月和上月的用量都是 0,所以 46981 上的用量全部来自新卡。
|
||||
|
||||
### 3. 第一次修正后仍显示数据异常:排序问题
|
||||
|
||||
46981 改成待生效后,页面仍然显示数据异常。原因是两条记录的 `priority` 都是 1,`created_at` 也完全相同(同一次迁移写入,都是 `2026-09-02 16:40:32.81965`),排序只能按 `id`,46981 就排在了 47051 前面。`Calculate` 不允许待生效套餐排在当前套餐之前,所以仍然判成 `invalid_data`。把 46981 的 `priority` 改成 2 后,它排到了 47051 后面。
|
||||
|
||||
## 已执行的修正(生产库,2026-11-04)
|
||||
|
||||
两步都在单个事务中执行:先锁行,改完后自动校验,校验不通过就整体回滚。
|
||||
|
||||
### 第一步:用量转回 47051,46981 改为待生效
|
||||
|
||||
执行前:46981 的 `data_usage_mb = 2759`,47051 的 `data_usage_mb = 112102`。
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SELECT id, status, data_usage_mb, iot_card_id FROM tb_package_usage WHERE id IN (46981, 47051) ORDER BY id FOR UPDATE;
|
||||
|
||||
-- 46981 的已用量转回 47051
|
||||
UPDATE tb_package_usage a
|
||||
SET data_usage_mb = a.data_usage_mb + b.data_usage_mb, updated_at = now()
|
||||
FROM tb_package_usage b
|
||||
WHERE a.id = 47051 AND b.id = 46981 AND a.status = 1 AND b.status = 1 AND a.iot_card_id = 93937 AND b.iot_card_id = 93937;
|
||||
|
||||
-- 日用量记录并到 47051
|
||||
INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
|
||||
SELECT 47051, d.date, d.daily_usage_mb, 0, now(), now()
|
||||
FROM tb_package_usage_daily_record d WHERE d.package_usage_id = 46981
|
||||
ON CONFLICT (package_usage_id, date) DO UPDATE
|
||||
SET daily_usage_mb = tb_package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, updated_at = now();
|
||||
|
||||
-- 以 11-02 的累计值为起点,重算 47051 从 11-03 起的累计用量
|
||||
UPDATE tb_package_usage_daily_record r SET cumulative_usage_mb = x.cum, updated_at = now()
|
||||
FROM (
|
||||
SELECT id,
|
||||
(SELECT cumulative_usage_mb FROM tb_package_usage_daily_record WHERE package_usage_id = 47051 AND date = '2026-11-02')
|
||||
+ sum(daily_usage_mb) OVER (ORDER BY date) AS cum
|
||||
FROM tb_package_usage_daily_record
|
||||
WHERE package_usage_id = 47051 AND date >= '2026-11-03'
|
||||
) x
|
||||
WHERE r.id = x.id;
|
||||
|
||||
DELETE FROM tb_package_usage_daily_record WHERE package_usage_id = 46981;
|
||||
|
||||
-- 46981 改为待生效,和现有待生效套餐的数据形态一致;激活时不会把已用量清零,所以这里必须清零
|
||||
UPDATE tb_package_usage
|
||||
SET status = 0, data_usage_mb = 0, activated_at = NULL, expires_at = NULL,
|
||||
last_reset_at = NULL, next_reset_at = NULL, updated_at = now()
|
||||
WHERE id = 46981 AND status = 1 AND iot_card_id = 93937;
|
||||
-- 校验项:47051 的已用量等于两者之和;46981 是待生效、已用量为 0;
|
||||
-- 47051 最后一天的累计用量等于它的已用量;46981 已经没有日用量记录
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### 第二步:调整排队顺序
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
UPDATE tb_package_usage SET priority = 2, updated_at = now()
|
||||
WHERE id = 46981 AND status = 0 AND iot_card_id = 93937 AND priority = 1;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## 修正后的状态
|
||||
|
||||
| 套餐使用记录 | 状态 | 优先级 | 已用量 | 生效时间 | 到期时间 | 下次重置 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 47051 | 生效中 | 1 | 114,861 MB | 2025-12-14 | 2026-12-14 | 2026-11-09 |
|
||||
| 46981 | 待生效 | 2 | 0 | 空 | 空 | 空 |
|
||||
|
||||
47051 的日用量记录:11-03 为 5,445 MB,累计 112,154 MB;11-04 为 2,707 MB,累计 114,861 MB。
|
||||
|
||||
预期效果:
|
||||
|
||||
- 后续流量扣到 47051 上。
|
||||
- 预计到期时间推算为约 2027-12-14:47051 到期后,46981 由排队激活接上 365 天。
|
||||
|
||||
## 回滚
|
||||
|
||||
一般不需要回滚。如果必须恢复到修正前的状态:
|
||||
|
||||
1. 46981 恢复为 `status = 1`、`priority = 1`、`data_usage_mb = 2759`、`expires_at = '2027-12-14 00:00:00'`、`last_reset_at = '2026-10-18 01:23:14.86183'`、`next_reset_at = '2026-11-17 00:00:00'`。
|
||||
2. 47051 的 `data_usage_mb` 减去 2759。
|
||||
3. 日用量记录拆回原样:
|
||||
- 46981:11-03 为 52/52,11-04 为 2707/2759;
|
||||
- 47051:11-03 为 5393/112102,并删除 47051 的 11-04 记录。
|
||||
|
||||
回滚前,要先确认修正之后又扣进 47051 的新用量。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- **代码漏洞未修。** 换货迁移不处理新资产已有生效中主套餐的情况,以后同类换货还会再出现两个主套餐同时生效。可选的修法:
|
||||
- 迁移过来的主套餐改为待生效,排在新资产现有套餐之后(`priority` 递增);
|
||||
- 或者在换货前检查,直接拒绝,提示运营先处理。
|
||||
- **同类问题未排查。** 生产中可能还有其他卡或设备同时挂着多个生效中的主套餐,尚未全量排查。
|
||||
@@ -41,9 +41,13 @@ func (s *RefundOffsetService) ApplyInTx(ctx context.Context, tx *gorm.DB, source
|
||||
if s == nil || tx == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置")
|
||||
}
|
||||
if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount <= 0 {
|
||||
if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "员工代收款退款冲销参数无效")
|
||||
}
|
||||
// 0 元退款只作废权益、不退钱:不冲减、不关闭账单、不写冲销记录,也不能让退款事务失败。
|
||||
if source.RefundAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
var bill model.EmployeeCollectionBill
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("source_key = ?", employeecollectiondomain.OrderSourceKey(source.OrderID)).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -332,6 +333,7 @@ func RequiredSceneFields(businessType string) []RequiredSceneField {
|
||||
}
|
||||
|
||||
// RequiredRefundFields 返回退款提交固定与条件必需字段;快照方式编码缺失或未知时由表单构建拒绝。
|
||||
// 客户收款信息退款要求客户收款信息与凭证必须映射并提交;0 元退款不退钱,两者不强制。
|
||||
func RequiredRefundFields(snapshot map[string]any) []RequiredSceneField {
|
||||
methodCode, ok := snapshot[constants.ApprovalFieldRefundMethodCode].(string)
|
||||
methodCode = strings.TrimSpace(methodCode)
|
||||
@@ -339,12 +341,22 @@ func RequiredRefundFields(snapshot map[string]any) []RequiredSceneField {
|
||||
return []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码"}}
|
||||
}
|
||||
result := []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", RequiredMapping: true}}
|
||||
if methodCode == constants.RefundMethodCustomerAccount {
|
||||
if methodCode == constants.RefundMethodCustomerAccount && !isZeroAmountRefund(snapshot) {
|
||||
result = append(result, RequiredSceneField{Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息"}, RequiredSceneField{Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证"})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// isZeroAmountRefund 判断快照中的申请退款金额是否为 0;金额缺失或无法解析时按非 0 处理,保持强制校验。
|
||||
func isZeroAmountRefund(snapshot map[string]any) bool {
|
||||
raw, ok := snapshot[constants.ApprovalFieldRequestedRefundAmount].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
amount, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
return err == nil && amount == 0
|
||||
}
|
||||
|
||||
// approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。
|
||||
func approvalBusinessTypes() []string {
|
||||
return []string{
|
||||
|
||||
@@ -38,6 +38,10 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
|
||||
// ListDeviceTypes 查询设备类型选项。
|
||||
// GET /api/admin/devices/types
|
||||
func (h *DeviceHandler) ListDeviceTypes(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
result, err := h.service.ListDeviceTypes(c.UserContext())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -786,6 +786,15 @@ func packageStatusName(status int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// clientDeviceLANIP 网关未返回局域网网关地址(空或仅空白)时使用设备默认网关地址,保证 C 端可进入设备后台。
|
||||
func clientDeviceLANIP(lanIP *string) *string {
|
||||
if lanIP != nil && strings.TrimSpace(*lanIP) != "" {
|
||||
return lanIP
|
||||
}
|
||||
defaultIP := constants.DefaultDeviceLANIP
|
||||
return &defaultIP
|
||||
}
|
||||
|
||||
// mapDeviceGatewayInfoToClientInfo 将 B 端 DeviceGatewayInfo 映射为 C 端 DeviceRealtimeInfo
|
||||
// B 端和 C 端结构独立,通过映射函数转换(per D-08)
|
||||
func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealtimeInfo {
|
||||
@@ -829,7 +838,7 @@ func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealt
|
||||
info.WifiPassword = g.WifiPassword
|
||||
info.IPAddress = g.IPAddress
|
||||
info.WANIP = g.WANIP
|
||||
info.LANIP = g.LANIP
|
||||
info.LANIP = clientDeviceLANIP(g.LANIP)
|
||||
info.MACAddress = g.MACAddress
|
||||
info.DailyUsage = g.DailyUsage
|
||||
info.DLStats = g.DLStats
|
||||
|
||||
39
internal/handler/app/client_asset_lan_ip_test.go
Normal file
39
internal/handler/app/client_asset_lan_ip_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestMapDeviceGatewayInfoToClientInfo_LANIPDefault(t *testing.T) {
|
||||
strPtr := func(s string) *string { return &s }
|
||||
cases := []struct {
|
||||
name string
|
||||
lanIP *string
|
||||
want string
|
||||
}{
|
||||
{name: "网关未返回", lanIP: nil, want: constants.DefaultDeviceLANIP},
|
||||
{name: "空字符串", lanIP: strPtr(""), want: constants.DefaultDeviceLANIP},
|
||||
{name: "仅空白", lanIP: strPtr(" "), want: constants.DefaultDeviceLANIP},
|
||||
{name: "网关有值", lanIP: strPtr("192.168.0.1"), want: "192.168.0.1"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := mapDeviceGatewayInfoToClientInfo(&dto.DeviceGatewayInfo{LANIP: tc.lanIP})
|
||||
if info.LANIP == nil || *info.LANIP != tc.want {
|
||||
t.Fatalf("期望 %q,实际 %v", tc.want, info.LANIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 网关调用失败时同样返回默认地址,前端仍可进入设备后台。
|
||||
func TestMapDeviceGatewayInfoToClientInfo_GatewayFailedStillDefaultLANIP(t *testing.T) {
|
||||
msg := "网关调用失败"
|
||||
info := mapDeviceGatewayInfoToClientInfo(&dto.DeviceGatewayInfo{GatewayMsg: &msg})
|
||||
if info.LANIP == nil || *info.LANIP != constants.DefaultDeviceLANIP {
|
||||
t.Fatalf("期望默认地址,实际 %v", info.LANIP)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID,
|
||||
attachmentCount := 0
|
||||
for _, mapping := range mappings {
|
||||
raw, exists := lookupSnapshotValue(snapshot, mapping.BusinessField)
|
||||
if !exists || raw == nil || isEmptyFileValue(mapping, raw) {
|
||||
if !exists || raw == nil || isEmptyFileValue(mapping, raw) || isEmptyCustomerAccountInfo(mapping, raw) {
|
||||
if requiredControls[mapping.ControlID] {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
|
||||
}
|
||||
@@ -142,6 +142,15 @@ func isEmptyFileValue(mapping dto.WeComControlMappingItem, raw any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// isEmptyCustomerAccountInfo 判断客户收款信息是否为空文本;只有 0 元退款可能为空,按未填写处理,由模板必填规则决定是否拦截。
|
||||
func isEmptyCustomerAccountInfo(mapping dto.WeComControlMappingItem, raw any) bool {
|
||||
if strings.TrimSpace(mapping.BusinessField) != constants.ApprovalFieldCustomerAccountInfo {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
return ok && strings.TrimSpace(value) == ""
|
||||
}
|
||||
|
||||
// lookupSnapshotValue 支持用点分路径读取嵌套业务快照,避免模板映射绑定具体 DTO。
|
||||
func lookupSnapshotValue(snapshot map[string]any, path string) (any, bool) {
|
||||
segments := strings.Split(strings.TrimSpace(path), ".")
|
||||
|
||||
@@ -9,7 +9,7 @@ type CreateExchangeRequest struct {
|
||||
FlowType string `json:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
||||
NewIdentifier string `json:"new_identifier" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"新资产输入标识,direct 流程必填;卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN"`
|
||||
MigrateData *bool `json:"migrate_data" description:"是否执行全量迁移,direct 流程未传按 false 处理"`
|
||||
ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"换货原因"`
|
||||
ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"换货原因"`
|
||||
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
|
||||
}
|
||||
|
||||
|
||||
34
internal/model/dto/exchange_dto_test.go
Normal file
34
internal/model/dto/exchange_dto_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// 换货原因按字符计数,上限 500:从微信复制的多行说明(含换行)不超过 500 字都应通过校验。
|
||||
func TestCreateExchangeRequest_ExchangeReasonLength(t *testing.T) {
|
||||
validate := validator.New()
|
||||
multiLine := "862639070766110(门店:862639070766110机头,862639070743218后盖)\n\n" +
|
||||
"862639070743218(厂家:862639070766110后盖,862639070743218机头)\n\n这两台后盖机头窜了。"
|
||||
cases := []struct {
|
||||
name string
|
||||
reason string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "多行说明124字", reason: multiLine, wantErr: false},
|
||||
{name: "500个中文字", reason: strings.Repeat("换", 500), wantErr: false},
|
||||
{name: "501个中文字", reason: strings.Repeat("换", 501), wantErr: true},
|
||||
{name: "空", reason: "", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := CreateExchangeRequest{OldAssetType: "device", OldIdentifier: "862639070743218", ExchangeReason: tc.reason}
|
||||
err := validate.Struct(req)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("wantErr=%v, err=%v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ type CreateRefundRequest struct {
|
||||
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"`
|
||||
Method string `json:"method" validate:"required,oneof=original_route customer_account asset_wallet agent_wallet" required:"true" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"`
|
||||
CustomerAccountInfo string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"min=0" required:"true" minimum:"0" description:"申请退款金额(分),0 ~ 冻结实收金额;0 元退款表示作废权益不退钱,仅限客户收款信息方式(钱包支付订单不支持),此时客户收款信息可不填但凭证必传"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(最多5个,仅客户收款信息退款方式必填,通过/storage/upload-url上传图片后获得)"`
|
||||
RefundReason string `json:"refund_reason" validate:"required,max=1000" required:"true" maxLength:"1000" description:"退款原因(必填,去除首尾空白后不得为空)"`
|
||||
// Remark 是申请人备注,随本次提交冻结进当次审批尝试快照;与审批备注(详情响应中的 remark)语义不同。
|
||||
@@ -32,7 +32,7 @@ type ResubmitRefundRequest struct {
|
||||
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"`
|
||||
Method *string `json:"method" validate:"omitempty,oneof=original_route customer_account asset_wallet agent_wallet" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),不填沿用原有方式"`
|
||||
CustomerAccountInfo *string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=0" minimum:"0" description:"申请退款金额(分),不填沿用原金额;0 元退款规则同创建:仅限客户收款信息方式(钱包支付订单不支持),客户收款信息可不填但凭证必传"`
|
||||
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;客户收款信息退款方式必填,最多5个)"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因(不填沿用原有原因;重提时去除首尾空白后不得为空)"`
|
||||
// Remark 是本次重提的申请人备注,随本次尝试冻结;不填沿用该退款单最近一次尝试的备注。
|
||||
|
||||
@@ -56,7 +56,7 @@ type ExchangeOrder struct {
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:换货完成时间,物流换货和直接换货完成后写入" json:"completed_at,omitempty"`
|
||||
|
||||
// 业务信息
|
||||
ExchangeReason string `gorm:"column:exchange_reason;type:varchar(100);not null;comment:换货原因" json:"exchange_reason"`
|
||||
ExchangeReason string `gorm:"column:exchange_reason;type:varchar(500);not null;comment:换货原因" json:"exchange_reason"`
|
||||
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_exchange_order_status;comment:换货状态 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消" json:"status"`
|
||||
|
||||
|
||||
@@ -6,24 +6,15 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, importHandler *admin.DeviceImportHandler, doc *openapi.Generator, basePath string) {
|
||||
devices := router.Group("/devices")
|
||||
groupPath := basePath + "/devices"
|
||||
platformDevices := router.Group("/devices", func(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
Register(platformDevices, doc, groupPath, "GET", "/types", handler.ListDeviceTypes, RouteSpec{
|
||||
// 仅平台可用的判断放在 ListDeviceTypes 内部:Fiber 的 Group 带处理函数会注册为整个 /devices 前缀的中间件,会拦住代理与企业账号的所有设备接口。
|
||||
Register(devices, doc, groupPath, "GET", "/types", handler.ListDeviceTypes, RouteSpec{
|
||||
Summary: "查询设备类型选项",
|
||||
Description: "仅超级管理员与平台账号可访问。返回未删除设备中去重、去除首尾空白且按升序排列的非空设备类型字符串。",
|
||||
Tags: []string{"设备管理"},
|
||||
|
||||
@@ -216,7 +216,7 @@ func (s *Service) buildCardExchangeCompletionResources(
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, transactionResources...)
|
||||
usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldCard.ID, newCard.ID)
|
||||
usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, migration.PackageUsageChanges, oldCard.ID, newCard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -377,24 +377,46 @@ func loadExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchange
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldCardID, newCardID uint) ([]audit.ResourceInput, error) {
|
||||
return loadExchangePackageUsageResources(ctx, tx, ids, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡")
|
||||
func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, changes map[uint]*packageUsageStateChange, oldCardID, newCardID uint) ([]audit.ResourceInput, error) {
|
||||
return loadExchangePackageUsageResources(ctx, tx, ids, changes, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡")
|
||||
}
|
||||
|
||||
func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) {
|
||||
if len(ids) == 0 {
|
||||
func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, changes map[uint]*packageUsageStateChange, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) {
|
||||
migrated := make(map[uint]bool, len(ids))
|
||||
allIDs := make([]uint, 0, len(ids)+len(changes))
|
||||
for _, id := range ids {
|
||||
migrated[id] = true
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
for id := range changes {
|
||||
if !migrated[id] {
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
}
|
||||
if len(allIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []model.PackageUsage
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", allIDs).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益失败")
|
||||
}
|
||||
orderIDs := make(map[uint]struct{}, len(rows))
|
||||
packageIDs := make(map[uint]struct{}, len(rows))
|
||||
resources := make([]audit.ResourceInput, 0, len(rows)*3)
|
||||
for _, row := range rows {
|
||||
resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role,
|
||||
map[string]any{assetIDField: oldAssetID}, map[string]any{assetIDField: newAssetID})
|
||||
beforeData, afterData := map[string]any{}, map[string]any{}
|
||||
if migrated[row.ID] {
|
||||
beforeData[assetIDField], afterData[assetIDField] = oldAssetID, newAssetID
|
||||
}
|
||||
if change := changes[row.ID]; change != nil {
|
||||
for key, value := range change.Before {
|
||||
beforeData[key] = value
|
||||
}
|
||||
for key, value := range change.After {
|
||||
afterData[key] = value
|
||||
}
|
||||
}
|
||||
resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role, beforeData, afterData)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
orderIDs[row.OrderID] = struct{}{}
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Service) buildDeviceExchangeCompletionResources(
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, transactions...)
|
||||
usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldDevice.ID, newDevice.ID)
|
||||
usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, migration.PackageUsageChanges, oldDevice.ID, newDevice.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -337,8 +337,8 @@ func loadDeviceExchangeTransactionResources(ctx context.Context, tx *gorm.DB, ex
|
||||
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleDeviceExchangeWalletTransaction, "设备")
|
||||
}
|
||||
|
||||
func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) {
|
||||
return loadExchangePackageUsageResources(ctx, tx, ids, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备")
|
||||
func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, changes map[uint]*packageUsageStateChange, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) {
|
||||
return loadExchangePackageUsageResources(ctx, tx, ids, changes, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备")
|
||||
}
|
||||
|
||||
func (s *Service) recordExchangeOrderFailure(ctx context.Context, cardActionCode, summary string, order *model.ExchangeOrder, businessErr error) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
type exchangeMigrationResult struct {
|
||||
Balance int64
|
||||
PackageUsageIDs []uint
|
||||
// PackageUsageChanges 记录因迁移规则改变状态、优先级或用量的套餐使用记录(含新资产原有主套餐)
|
||||
PackageUsageChanges map[uint]*packageUsageStateChange
|
||||
}
|
||||
|
||||
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (*exchangeMigrationResult, error) {
|
||||
@@ -22,7 +24,7 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
|
||||
}
|
||||
usageIDs, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset)
|
||||
usageIDs, usageChanges, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
|
||||
}
|
||||
@@ -32,7 +34,7 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
|
||||
}
|
||||
return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil
|
||||
return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs, PackageUsageChanges: usageChanges}, nil
|
||||
}
|
||||
|
||||
func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
|
||||
@@ -101,39 +103,79 @@ func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB,
|
||||
return migrationBalance, nil
|
||||
}
|
||||
|
||||
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, error) {
|
||||
query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||
query = query.Where("iot_card_id = ?", oldAsset.AssetID)
|
||||
} else {
|
||||
query = query.Where("device_id = ?", oldAsset.AssetID)
|
||||
// migratePackageUsageWithTx 把旧资产待生效、生效中、已用完的套餐使用记录迁到新资产,
|
||||
// 并按"是否已开始、新资产实名条件、新资产是否已有当前主套餐"决定主套餐迁移后的状态与排队顺序。
|
||||
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, map[uint]*packageUsageStateChange, error) {
|
||||
column := carrierColumn(oldAsset.AssetType)
|
||||
oldUsages, err := lockLiveUsagesWithTx(ctx, tx, column, oldAsset.AssetID, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(oldUsages) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
newMains, err := lockLiveUsagesWithTx(ctx, tx, carrierColumn(newAsset.AssetType), newAsset.AssetID, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var usageIDs []uint
|
||||
if err := query.Pluck("id", &usageIDs).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
usageIDs := make([]uint, 0, len(oldUsages))
|
||||
oldMains := make([]*model.PackageUsage, 0, len(oldUsages))
|
||||
for _, usage := range oldUsages {
|
||||
usageIDs = append(usageIDs, usage.ID)
|
||||
if usage.MasterUsageID == nil {
|
||||
oldMains = append(oldMains, usage)
|
||||
}
|
||||
}
|
||||
|
||||
if len(usageIDs) == 0 {
|
||||
if err = tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).
|
||||
Updates(map[string]any{column: newAsset.AssetID, "updated_at": time.Now()}).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
|
||||
}
|
||||
if err = tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
|
||||
}
|
||||
|
||||
changes, err := s.arrangeMigratedMainsWithTx(ctx, tx, oldMains, newMains, newAsset)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return usageIDs, changes, nil
|
||||
}
|
||||
|
||||
// arrangeMigratedMainsWithTx 决定迁入主套餐状态并重排新资产主套餐优先级,保证同一载体最多一个当前主套餐。
|
||||
func (s *Service) arrangeMigratedMainsWithTx(ctx context.Context, tx *gorm.DB, oldMains, newMains []*model.PackageUsage, newAsset *resolvedExchangeAsset) (map[uint]*packageUsageStateChange, error) {
|
||||
if len(oldMains) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
updates := map[string]any{"updated_at": time.Now()}
|
||||
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||
updates["iot_card_id"] = newAsset.AssetID
|
||||
} else {
|
||||
updates["device_id"] = newAsset.AssetID
|
||||
now := time.Now()
|
||||
dailyRecords, err := loadDailyRecordUsageIDsWithTx(ctx, tx, oldMains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
|
||||
newHasCurrent := false
|
||||
for _, usage := range newMains {
|
||||
if isCurrentMainUsage(usage, now) {
|
||||
newHasCurrent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
|
||||
var realnameChecked, realnameOK bool
|
||||
realnameSatisfied := func() (bool, error) {
|
||||
if !realnameChecked {
|
||||
ok, checkErr := newAssetRealnameSatisfiedWithTx(ctx, tx, newAsset)
|
||||
if checkErr != nil {
|
||||
return false, checkErr
|
||||
}
|
||||
realnameChecked, realnameOK = true, ok
|
||||
}
|
||||
return realnameOK, nil
|
||||
}
|
||||
|
||||
return usageIDs, nil
|
||||
plans, promoted, err := planMigratedMains(oldMains, dailyRecords, newHasCurrent, now, realnameSatisfied)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applyMigratedMainPlansWithTx(ctx, tx, plans, buildMainQueueOrder(newMains, promoted, plans, now))
|
||||
}
|
||||
|
||||
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
|
||||
|
||||
289
internal/service/exchange/package_migration.go
Normal file
289
internal/service/exchange/package_migration.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// packageUsageStateChange 记录换货迁移中套餐使用记录状态字段的前后值,供换货审计使用。
|
||||
type packageUsageStateChange struct {
|
||||
Before map[string]any
|
||||
After map[string]any
|
||||
}
|
||||
|
||||
// migratedMainPlan 是旧资产主套餐迁移后的目标状态。
|
||||
type migratedMainPlan struct {
|
||||
usage *model.PackageUsage
|
||||
updates map[string]any // 为空表示状态字段原样保留
|
||||
pending bool // 迁移后为待生效,参与排队
|
||||
}
|
||||
|
||||
func carrierColumn(assetType string) string {
|
||||
if assetType == constants.ExchangeAssetTypeIotCard {
|
||||
return "iot_card_id"
|
||||
}
|
||||
return "device_id"
|
||||
}
|
||||
|
||||
func liveUsageStatuses() []int {
|
||||
return []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
|
||||
}
|
||||
|
||||
// lockLiveUsagesWithTx 锁定载体上待生效、生效中、已用完的套餐使用记录,按优先级、创建时间、标识排序。
|
||||
func lockLiveUsagesWithTx(ctx context.Context, tx *gorm.DB, column string, assetID uint, mainOnly bool) ([]*model.PackageUsage, error) {
|
||||
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(column+" = ?", assetID).
|
||||
Where("status IN ?", liveUsageStatuses())
|
||||
if mainOnly {
|
||||
query = query.Where("master_usage_id IS NULL")
|
||||
}
|
||||
var rows []*model.PackageUsage
|
||||
if err := query.Order("priority ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐使用记录失败")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// isCurrentMainUsage 与 FindCurrentMainPackageForQueue 口径一致:生效中或已用完且未到期。
|
||||
func isCurrentMainUsage(usage *model.PackageUsage, now time.Time) bool {
|
||||
if usage.Status != constants.PackageUsageStatusActive && usage.Status != constants.PackageUsageStatusDepleted {
|
||||
return false
|
||||
}
|
||||
return usage.ExpiresAt == nil || usage.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
func isExpiredOccupyingUsage(usage *model.PackageUsage, now time.Time) bool {
|
||||
return (usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted) &&
|
||||
!isCurrentMainUsage(usage, now)
|
||||
}
|
||||
|
||||
// isUsageStarted 判断套餐是否已开始:有生效时间、有到期时间、有用量、有日流量记录或已用完。
|
||||
// 老系统迁移数据可能只有固定到期时间、没有生效时间,按已开始处理以保留原到期日。
|
||||
func isUsageStarted(usage *model.PackageUsage, hasDailyRecord bool) bool {
|
||||
return usage.ActivatedAt != nil || usage.ExpiresAt != nil || usage.DataUsageMB > 0 || hasDailyRecord ||
|
||||
usage.Status == constants.PackageUsageStatusDepleted
|
||||
}
|
||||
|
||||
func loadDailyRecordUsageIDsWithTx(ctx context.Context, tx *gorm.DB, usages []*model.PackageUsage) (map[uint]bool, error) {
|
||||
result := make(map[uint]bool)
|
||||
if len(usages) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
ids = append(ids, usage.ID)
|
||||
}
|
||||
var withRecords []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).
|
||||
Where("package_usage_id IN ?", ids).Distinct().Pluck("package_usage_id", &withRecords).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐日流量记录失败")
|
||||
}
|
||||
for _, id := range withRecords {
|
||||
result[id] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resetToPendingUpdates 生成"改为待生效并清空生效周期"的字段更新。
|
||||
func resetToPendingUpdates(pendingRealname, clearUsage bool) map[string]any {
|
||||
updates := map[string]any{
|
||||
"status": constants.PackageUsageStatusPending,
|
||||
"activated_at": nil,
|
||||
"expires_at": nil,
|
||||
"last_reset_at": nil,
|
||||
"next_reset_at": nil,
|
||||
"pending_realname_activation": pendingRealname,
|
||||
}
|
||||
if clearUsage {
|
||||
updates["data_usage_mb"] = int64(0)
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
// isCleanPendingUsage 判断记录是否已是目标待生效状态,避免无变化的写入与审计。
|
||||
func isCleanPendingUsage(usage *model.PackageUsage, pendingRealname bool) bool {
|
||||
return usage.Status == constants.PackageUsageStatusPending && usage.PendingRealnameActivation == pendingRealname &&
|
||||
usage.ActivatedAt == nil && usage.ExpiresAt == nil && usage.LastResetAt == nil && usage.NextResetAt == nil
|
||||
}
|
||||
|
||||
// planMigratedMains 按"是否已开始、新资产实名条件、新资产是否已有当前主套餐"决定旧资产主套餐的迁移状态。
|
||||
// realnameSatisfied 仅在存在未开始的主套餐时被调用。
|
||||
func planMigratedMains(oldMains []*model.PackageUsage, dailyRecords map[uint]bool, newHasCurrent bool, now time.Time,
|
||||
realnameSatisfied func() (bool, error)) ([]migratedMainPlan, *model.PackageUsage, error) {
|
||||
plans := make([]migratedMainPlan, 0, len(oldMains))
|
||||
var promoted *model.PackageUsage
|
||||
for _, usage := range oldMains {
|
||||
if isExpiredOccupyingUsage(usage, now) {
|
||||
plans = append(plans, migratedMainPlan{usage: usage})
|
||||
continue
|
||||
}
|
||||
if !isUsageStarted(usage, dailyRecords[usage.ID]) {
|
||||
satisfied, err := realnameSatisfied()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var updates map[string]any
|
||||
if !isCleanPendingUsage(usage, !satisfied) {
|
||||
updates = resetToPendingUpdates(!satisfied, false)
|
||||
}
|
||||
plans = append(plans, migratedMainPlan{usage: usage, updates: updates, pending: true})
|
||||
continue
|
||||
}
|
||||
if !newHasCurrent && promoted == nil && isCurrentMainUsage(usage, now) {
|
||||
promoted = usage
|
||||
plans = append(plans, migratedMainPlan{usage: usage})
|
||||
continue
|
||||
}
|
||||
plans = append(plans, migratedMainPlan{usage: usage, updates: resetToPendingUpdates(false, true), pending: true})
|
||||
}
|
||||
return plans, promoted, nil
|
||||
}
|
||||
|
||||
// buildMainQueueOrder 迁移后新资产主套餐顺序:当前主套餐 → 新资产原有待生效 → 迁入的待生效。
|
||||
func buildMainQueueOrder(newMains []*model.PackageUsage, promoted *model.PackageUsage, plans []migratedMainPlan, now time.Time) []*model.PackageUsage {
|
||||
ordered := make([]*model.PackageUsage, 0, len(newMains)+len(plans))
|
||||
for _, usage := range newMains {
|
||||
if isCurrentMainUsage(usage, now) {
|
||||
ordered = append(ordered, usage)
|
||||
}
|
||||
}
|
||||
if promoted != nil {
|
||||
ordered = append(ordered, promoted)
|
||||
}
|
||||
for _, usage := range newMains {
|
||||
if usage.Status == constants.PackageUsageStatusPending {
|
||||
ordered = append(ordered, usage)
|
||||
}
|
||||
}
|
||||
for _, plan := range plans {
|
||||
if plan.pending {
|
||||
ordered = append(ordered, plan.usage)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func usageStateSnapshot(usage *model.PackageUsage) map[string]any {
|
||||
return map[string]any{
|
||||
"status": usage.Status, "priority": usage.Priority, "data_usage_mb": usage.DataUsageMB,
|
||||
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
|
||||
"last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt,
|
||||
"pending_realname_activation": usage.PendingRealnameActivation,
|
||||
}
|
||||
}
|
||||
|
||||
func recordStateChange(changes map[uint]*packageUsageStateChange, usage *model.PackageUsage, updates map[string]any) {
|
||||
change, ok := changes[usage.ID]
|
||||
if !ok {
|
||||
change = &packageUsageStateChange{Before: usageStateSnapshot(usage), After: usageStateSnapshot(usage)}
|
||||
changes[usage.ID] = change
|
||||
}
|
||||
for key, value := range updates {
|
||||
change.After[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func applyUsageUpdatesWithTx(ctx context.Context, tx *gorm.DB, id uint, updates map[string]any) error {
|
||||
values := make(map[string]any, len(updates)+1)
|
||||
for key, value := range updates {
|
||||
values[key] = value
|
||||
}
|
||||
values["updated_at"] = time.Now()
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ?", id).Updates(values).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新迁移套餐状态失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMigratedMainPlansWithTx 写入迁移主套餐的目标状态并重排新资产主套餐优先级。
|
||||
func applyMigratedMainPlansWithTx(ctx context.Context, tx *gorm.DB, plans []migratedMainPlan, ordered []*model.PackageUsage) (map[uint]*packageUsageStateChange, error) {
|
||||
changes := make(map[uint]*packageUsageStateChange)
|
||||
pendingUpdates := make(map[uint]map[string]any)
|
||||
for _, plan := range plans {
|
||||
if len(plan.updates) > 0 {
|
||||
pendingUpdates[plan.usage.ID] = plan.updates
|
||||
recordStateChange(changes, plan.usage, plan.updates)
|
||||
}
|
||||
}
|
||||
for index, usage := range ordered {
|
||||
priority := index + 1
|
||||
if usage.Priority == priority {
|
||||
continue
|
||||
}
|
||||
updates := pendingUpdates[usage.ID]
|
||||
if updates == nil {
|
||||
updates = make(map[string]any)
|
||||
pendingUpdates[usage.ID] = updates
|
||||
}
|
||||
updates["priority"] = priority
|
||||
recordStateChange(changes, usage, map[string]any{"priority": priority})
|
||||
}
|
||||
for id, updates := range pendingUpdates {
|
||||
if err := applyUsageUpdatesWithTx(ctx, tx, id, updates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// newAssetRealnameSatisfiedWithTx 判断新资产是否满足实名条件。
|
||||
// 口径同 StopResumeService.isRealnameOK:行业卡、已实名、或适用实名策略为无需实名;设备看自身策略与已绑定卡。
|
||||
func newAssetRealnameSatisfiedWithTx(ctx context.Context, tx *gorm.DB, asset *resolvedExchangeAsset) (bool, error) {
|
||||
if asset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||
return cardRealnameSatisfiedWithTx(ctx, tx, asset.AssetID)
|
||||
}
|
||||
return deviceRealnameSatisfiedWithTx(ctx, tx, asset.AssetID)
|
||||
}
|
||||
|
||||
func cardRealnameSatisfiedWithTx(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Select("id", "card_category", "real_name_status", "realname_policy", "is_standalone").
|
||||
First(&card, cardID).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新卡实名条件失败")
|
||||
}
|
||||
if card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified {
|
||||
return true, nil
|
||||
}
|
||||
if card.IsStandalone {
|
||||
return card.RealnamePolicy == constants.RealnamePolicyNone, nil
|
||||
}
|
||||
var device model.Device
|
||||
err := tx.WithContext(ctx).Select("devices.realname_policy").Table("tb_device AS devices").
|
||||
Joins("JOIN tb_device_sim_binding AS bindings ON bindings.device_id = devices.id").
|
||||
Where("bindings.iot_card_id = ? AND bindings.bind_status = ? AND bindings.deleted_at IS NULL", cardID, constants.BindStatusBound).
|
||||
Where("devices.deleted_at IS NULL").
|
||||
Take(&device).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return card.RealnamePolicy == constants.RealnamePolicyNone, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新卡绑定设备实名策略失败")
|
||||
}
|
||||
return device.RealnamePolicy == constants.RealnamePolicyNone, nil
|
||||
}
|
||||
|
||||
func deviceRealnameSatisfiedWithTx(ctx context.Context, tx *gorm.DB, deviceID uint) (bool, error) {
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).Select("id", "realname_policy").First(&device, deviceID).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新设备实名条件失败")
|
||||
}
|
||||
if device.RealnamePolicy == constants.RealnamePolicyNone {
|
||||
return true, nil
|
||||
}
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Joins("JOIN tb_device_sim_binding AS bindings ON bindings.iot_card_id = tb_iot_card.id").
|
||||
Where("bindings.device_id = ? AND bindings.bind_status = ? AND bindings.deleted_at IS NULL", deviceID, constants.BindStatusBound).
|
||||
Where("tb_iot_card.card_category = ? OR tb_iot_card.real_name_status = ?", constants.CardCategoryIndustry, constants.RealNameStatusVerified).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新设备绑定卡实名条件失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
38
internal/service/iot_card/stop_resume_polling_guard.go
Normal file
38
internal/service/iot_card/stop_resume_polling_guard.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// pollingDisabled 判断卡是否退出本系统自动停复机:卡级关闭轮询,或卡绑定的设备关闭轮询。
|
||||
// 关闭轮询的卡/设备已交由其他卡管管理,本系统任何入口都不得再对其自动停机或复机。
|
||||
// 查询失败时返回错误,由调用方放弃本次停复机,避免在状态不明时误操作。
|
||||
func (s *StopResumeService) pollingDisabled(ctx context.Context, card *model.IotCard) (bool, error) {
|
||||
if !card.EnablePolling {
|
||||
return true, nil
|
||||
}
|
||||
if s.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
deviceID, bound, err := s.getCardDeviceID(ctx, card)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
|
||||
}
|
||||
if !bound {
|
||||
return false, nil
|
||||
}
|
||||
var device model.Device
|
||||
if err := s.db.WithContext(ctx).Select("id", "enable_polling").First(&device, deviceID).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备轮询状态失败")
|
||||
}
|
||||
return !device.EnablePolling, nil
|
||||
}
|
||||
45
internal/service/iot_card/stop_resume_polling_guard_test.go
Normal file
45
internal/service/iot_card/stop_resume_polling_guard_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// 关闭轮询的卡已交由其他卡管:无论在线还是停机,EvaluateAndAct 都必须直接返回,
|
||||
// 不得进入停机原因判断或复机流程(服务未注入任何 store/网关,进入后续流程会直接 panic)。
|
||||
func TestEvaluateAndAct_CardPollingDisabledSkipsStopResume(t *testing.T) {
|
||||
svc := &StopResumeService{logger: zap.NewNop()}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
card *model.IotCard
|
||||
}{
|
||||
{
|
||||
name: "在线卡无套餐不停机",
|
||||
card: &model.IotCard{NetworkStatus: constants.NetworkStatusOnline, EnablePolling: false, IsStandalone: false},
|
||||
},
|
||||
{
|
||||
name: "轮询原因停机的卡不复机",
|
||||
card: &model.IotCard{NetworkStatus: constants.NetworkStatusOffline, StopReason: constants.StopReasonNoPackage, EnablePolling: false},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := svc.EvaluateAndAct(context.Background(), tc.card); err != nil {
|
||||
t.Fatalf("期望直接返回 nil,实际 %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAndAct_NilCard(t *testing.T) {
|
||||
svc := &StopResumeService{logger: zap.NewNop()}
|
||||
if err := svc.EvaluateAndAct(context.Background(), nil); err != nil {
|
||||
t.Fatalf("期望 nil,实际 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,19 @@ func (s *StopResumeService) SetPollingCallback(callback PollingCallback) {
|
||||
// EvaluateAndAct 停复机统一入口
|
||||
// 根据卡的当前网络状态,自动判断并执行停机或复机操作
|
||||
// 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡)
|
||||
// 卡或其绑定设备关闭轮询时直接返回,不停机也不复机。
|
||||
func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
disabled, err := s.pollingDisabled(ctx, card)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if disabled {
|
||||
s.logger.Debug("卡或绑定设备已关闭轮询,跳过自动停复机", zap.Uint("card_id", card.ID))
|
||||
return nil
|
||||
}
|
||||
switch card.NetworkStatus {
|
||||
case constants.NetworkStatusOnline:
|
||||
// 卡在线,检查是否需要停机
|
||||
|
||||
@@ -63,6 +63,15 @@ func (s *Service) clawbackCommission(ctx context.Context, refundID uint) error {
|
||||
if refund.Method == constants.RefundMethodOriginalRoute && refund.ChannelRefundStatus != constants.RefundChannelStatusSucceeded {
|
||||
return errors.New(errors.CodeServiceUnavailable, "原路退款渠道结果尚未明确成功,暂不生成佣金回溯")
|
||||
}
|
||||
// 0 元退款只作废权益、不退钱,佣金不处理:与订单佣金是否已终态无关,直接闭合为无需回溯,
|
||||
// 不能落到下方「成功金额非正」的可恢复失败而反复重试。原路退款的成功金额由渠道决定,不走这里。
|
||||
if isZeroAmountRefund(&refund) {
|
||||
return s.settleClawback(ctx, &refund, constants.AuditActionRefundClawbackNotRequired,
|
||||
"0 元退款无需回溯佣金", clawbackEventID(refund.ID, "clawback-not-required"),
|
||||
map[string]any{
|
||||
"order_id": refund.OrderID, "zero_amount_refund": true, "clawback_required": false,
|
||||
})
|
||||
}
|
||||
|
||||
// 订单佣金未终态时等待:此时「无已发放佣金」只代表尚未计算完成或尚待人工修正,
|
||||
// 提前判定为无需回溯会让后续入账的佣金永久失去回溯机会,违反「MUST NOT 提前判定为无需回溯」。
|
||||
@@ -171,6 +180,12 @@ func (s *Service) clawbackCommission(ctx context.Context, refundID uint) error {
|
||||
return s.applyClawback(ctx, &refund, allocations)
|
||||
}
|
||||
|
||||
// isZeroAmountRefund 判定已通过的退款是否为 0 元退款:非原路方式(含方式为空的存量申请)且审批退款金额为 0。
|
||||
func isZeroAmountRefund(refund *model.RefundRequest) bool {
|
||||
return refund.Method != constants.RefundMethodOriginalRoute &&
|
||||
refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount == 0
|
||||
}
|
||||
|
||||
// clawbackEventID 生成回溯审计事件的稳定键,重复执行返回同一事件,审计写入因此天然幂等。
|
||||
func clawbackEventID(refundID uint, suffix string) string {
|
||||
return "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":" + suffix
|
||||
|
||||
@@ -36,7 +36,8 @@ type refundMethodDecision struct {
|
||||
// decideRefundMethods 派生权威实收金额并按来源实际支付方式生成可选退款方式。
|
||||
//
|
||||
// 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单
|
||||
// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代。
|
||||
// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代;
|
||||
// 唯一例外是后台线下订单实收为 0(如迁移订单),冻结实收取 0,只能申请 0 元退款。
|
||||
func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) {
|
||||
if order == nil || order.ID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款来源订单无效")
|
||||
@@ -44,7 +45,8 @@ func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (
|
||||
|
||||
decision := &refundMethodDecision{}
|
||||
switch order.PaymentMethod {
|
||||
case model.PaymentMethodWechat, model.PaymentMethodAlipay:
|
||||
// 富友是微信支付的上游通道,套餐订单回调写入 fuiou,按线上支付处理。
|
||||
case model.PaymentMethodWechat, model.PaymentMethodAlipay, model.ProviderTypeFuiou:
|
||||
payment, err := s.loadPaidPackagePayment(ctx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -81,7 +83,7 @@ func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (
|
||||
return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款")
|
||||
}
|
||||
case model.PaymentMethodOffline:
|
||||
amount, err := orderActualPaidAmount(order)
|
||||
amount, err := offlineActualPaidAmount(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,6 +105,14 @@ func orderActualPaidAmount(order *model.Order) (int64, error) {
|
||||
return *order.ActualPaidAmount, nil
|
||||
}
|
||||
|
||||
// offlineActualPaidAmount 取后台线下订单的实际收款金额:允许为 0(只能申请 0 元退款),缺失或为负时拒绝。
|
||||
func offlineActualPaidAmount(order *model.Order) (int64, error) {
|
||||
if order.ActualPaidAmount == nil || *order.ActualPaidAmount < 0 {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "订单实际收款金额缺失或为负,无法确定权威实收金额")
|
||||
}
|
||||
return *order.ActualPaidAmount, nil
|
||||
}
|
||||
|
||||
// loadPaidPackagePayment 读取该订单原成功的线上支付记录。
|
||||
func (s *Service) loadPaidPackagePayment(ctx context.Context, orderID uint) (*model.Payment, error) {
|
||||
var payment model.Payment
|
||||
@@ -219,12 +229,13 @@ func validateRefundMethod(decision *refundMethodDecision, method string) error {
|
||||
}
|
||||
|
||||
// validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。
|
||||
// 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。
|
||||
func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error {
|
||||
// 正数退款时客户收款信息与至少一个客户凭证附件必须同时存在;0 元退款不退钱,客户收款信息可为空,
|
||||
// 但仍须至少一个客户凭证附件。不得复用公司员工收款方式字典。
|
||||
func validateCustomerAccountMaterial(method string, amount int64, customerAccountInfo string, voucherKeys []string) error {
|
||||
if method != constants.RefundMethodCustomerAccount {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(customerAccountInfo) == "" {
|
||||
if amount > 0 && strings.TrimSpace(customerAccountInfo) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息")
|
||||
}
|
||||
hasVoucher := false
|
||||
@@ -240,13 +251,13 @@ func validateCustomerAccountMaterial(method, customerAccountInfo string, voucher
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。
|
||||
// validateFrozenRefundAmount 校验退款金额不为负且不超过冻结实收金额;0 元退款表示作废权益、不退钱。
|
||||
func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error {
|
||||
if amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "退款金额必须为正分")
|
||||
if amount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "退款金额不能为负")
|
||||
}
|
||||
if frozenActualReceivedAmount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请")
|
||||
if frozenActualReceivedAmount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "冻结实收金额为负,无法受理退款申请")
|
||||
}
|
||||
if amount > frozenActualReceivedAmount {
|
||||
return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额")
|
||||
@@ -254,6 +265,15 @@ func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateZeroAmountMethod 校验 0 元退款只能选客户收款信息方式:原路退款与退回原钱包都要动钱。
|
||||
// 钱包支付订单的方式矩阵不含客户收款信息,因此本校验同时保证钱包订单不能申请 0 元退款。
|
||||
func validateZeroAmountMethod(amount int64, method string) error {
|
||||
if amount == 0 && strings.TrimSpace(method) != constants.RefundMethodCustomerAccount {
|
||||
return errors.New(errors.CodeInvalidParam, "0 元退款仅支持客户收款信息方式")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildChannelRefundRequestNo 仅在原路退款方式下按冻结商户派生本次尝试的渠道退款请求号。
|
||||
// 请求号由收口审批申请用例冻结到不可变尝试记录上,同一尝试的渠道重试复用它作为幂等标识,
|
||||
// 重提生成新值;非原路方式返回空串。
|
||||
|
||||
@@ -175,11 +175,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateZeroAmountMethod(req.RequestedRefundAmount, req.Method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil {
|
||||
if err := validateCustomerAccountMaterial(req.Method, req.RequestedRefundAmount, req.CustomerAccountInfo, refundVoucherKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -860,6 +863,10 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
if err := validateZeroAmountMethod(requestedRefundAmount, method); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
refundVoucherKey := refund.RefundVoucherKey
|
||||
if req.RefundVoucherKey != nil {
|
||||
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
|
||||
@@ -884,7 +891,7 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
if req.CustomerAccountInfo != nil {
|
||||
customerAccountInfo = *req.CustomerAccountInfo
|
||||
}
|
||||
if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil {
|
||||
if err := validateCustomerAccountMaterial(method, requestedRefundAmount, customerAccountInfo, refundVoucherKey); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func (s *Service) SendCode(ctx context.Context, phone string) error {
|
||||
|
||||
// 构造短信内容
|
||||
cfg := config.Get()
|
||||
content := fmt.Sprintf("您的验证码是%s,%d分钟内有效", code, int(constants.VerificationCodeExpiration.Minutes()))
|
||||
content := fmt.Sprintf("您的验证码是%s,请在%d分钟内输入验证。如非本人操作,请忽略本短信。", code, int(constants.VerificationCodeExpiration.Minutes()))
|
||||
|
||||
// 发送短信
|
||||
_, err = s.smsClient.SendMessage(ctx, content, []string{phone})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-04
|
||||
@@ -0,0 +1,97 @@
|
||||
## Context
|
||||
|
||||
`migratePackageUsageWithTx`(`internal/service/exchange/migration.go:104`)在换货完成事务内,把旧资产全部待生效、生效中、已用完的套餐使用记录批量改载体(`iot_card_id` / `device_id`),其余字段不动,日流量记录只刷新 `updated_at`。
|
||||
|
||||
相关现状:
|
||||
|
||||
- 当前主套餐判断:`packagepkg.FindCurrentMainPackageForQueue` / `HasCurrentMainPackageForQueue`(`internal/service/package/addon_main_package.go`),口径为生效中或已用完、未到期。订单、自动续费、自动购包都用它决定新主套餐是否排队。
|
||||
- 待生效主套餐的接续激活:
|
||||
- 前一个到期:到期任务 `PackageActivationHandler.processExpiredPackage` → 激活队首。
|
||||
- 无占位主套餐:`findAndActivateOrphanPackages`(`internal/polling/package_activation_handler.go:168`)定时扫描"有待生效、无生效中或已用完主套餐"的载体,取队首(优先级、创建时间、标识)提交 `ActivateSpecificPackage`;后者对标记等实名激活的套餐检查实名,未标记的直接激活,激活时从当天起重新计算有效期与重置时间,并处理复机、轮询、审计等副作用。
|
||||
- 实名后:`ActivateByRealname` 激活标记等实名激活的套餐。
|
||||
- `activatePendingUsage` 激活时不重置 `data_usage_mb`。
|
||||
- 实名条件口径:`StopResumeService.isRealnameOK`(`internal/service/iot_card/stop_resume_service.go:332`)认行业卡、已实名、无需实名策略;套餐激活侧的 `isCarrierRealnamed` 只认已实名(另开 Change 处理,本 Change 不改)。
|
||||
- 预计到期推算 `internal/query/packageexpiry/query.go` 在多个生效/已用完主套餐、待生效排在当前之前时返回数据异常。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 换货迁移后新资产最多一个当前主套餐,主套餐优先级连续、无重复。
|
||||
- 未开始的套餐迁移后不直接生效;新资产满足实名条件时由既有机制接续激活。
|
||||
- 已开始的套餐在新资产无占位时无感知继续使用。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不改换货接口、不拦截换货、不改钱包余额、累计充值、标签迁移。
|
||||
- 不改订单、自动续费、自动购包、实名激活的实名判断。
|
||||
- 不在代码里修复存量数据。
|
||||
- 不处理加油包与主套餐的重新挂靠。
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 "已开始"的判定
|
||||
|
||||
满足任一即已开始:`activated_at` 非空、`expires_at` 非空、`data_usage_mb > 0`、存在日流量记录、状态为已用完。
|
||||
|
||||
- 老系统迁移数据存在"状态生效中、生效时间为空、到期时间固定"的记录(2026-11-04 换货单 1095–1104、1108)。固定到期日是老系统卖给客户的权益,换货不应改写,因此有到期时间即视为已开始;真正未开始的只剩从未激活的待生效套餐。
|
||||
- 换货单 1107 的套餐生效时间为空但有 10328 MB 用量,需要用量与日流量兜底。
|
||||
|
||||
备选一:只看 `activated_at`。会把 1107 判为未开始、清空进度,拒绝。
|
||||
备选二:不看 `expires_at`。会把老系统固定到期日的套餐改为待生效、实名后重算 12 个月,改变客户已有到期日,拒绝。
|
||||
|
||||
### D2 实名条件判断放在换货服务内
|
||||
|
||||
新增换货侧私有函数,按 spec 口径判断新资产是否满足实名条件:卡看行业卡、实名状态、适用实名策略;设备看设备策略与已绑定卡。
|
||||
|
||||
- 只影响等实名激活标记,口径需与停复机一致。
|
||||
- `isRealnameOK` 是 `StopResumeService` 私有方法且只接受卡;为一处调用把它导出或抽公共包,会顺带改动停复机模块,违反"不主动重构"。
|
||||
|
||||
备选:复用套餐侧 `isCarrierRealnamed`。不认行业卡,迁到行业卡的套餐会永远等实名,拒绝。
|
||||
|
||||
### D3 换货事务内不激活,交给孤儿扫描与实名激活
|
||||
|
||||
未开始与排队的主套餐只写为待生效和正确的等实名激活标记。
|
||||
|
||||
- 新资产无占位主套餐且无需等实名:孤儿扫描下一轮提交 `ActivateSpecificPackage`,统一走激活副作用(审计、观测、优先轮询、复机)。
|
||||
- 需要等实名:新资产实名后由 `ActivateByRealname` 激活。
|
||||
|
||||
备选:换货服务注入 `ActivationService` 在事务后立即激活。要新增依赖与失败补偿,且孤儿扫描已覆盖同一场景,拒绝。代价是激活最多延迟一个扫描周期。
|
||||
|
||||
### D4 排队的已开始套餐清零用量、重算完整周期
|
||||
|
||||
与 M15 排队购买一致:排队主套餐轮到时从激活当天起算完整周期。用量清零是因为 `activatePendingUsage` 不重置用量,不清零会把旧周期用量带入新周期。换货前用量保留在日流量记录中作为历史。与 2026-11-04 生产手工修正(46981)结果一致。
|
||||
|
||||
备选:保留剩余天数。需要新增"剩余时长"字段并改激活逻辑,影响面超出本 Change,拒绝。
|
||||
|
||||
### D5 统一重排优先级
|
||||
|
||||
迁移后对新资产全部未结束主套餐(待生效、生效中、已用完且未到期)按"当前主套餐 → 新资产原有待生效 → 迁入待生效"重排为 1..n。
|
||||
|
||||
- 现有新购排队写 `max + 1`,迁移场景下新资产原有待生效与迁入的生效主套餐可能都是 1,只追加无法消除重复。
|
||||
- 孤儿扫描与预计到期推算都按优先级、创建时间、标识排序,连续编号后结果确定。
|
||||
|
||||
### D6 锁与事务
|
||||
|
||||
在换货完成事务内先对新、旧资产的主套餐记录 `SELECT ... FOR UPDATE`,再判断与写入,避免与同时进行的购买、到期接续交叉。沿用换货现有事务,失败整体回滚并按既有规则记录迁移失败。
|
||||
|
||||
### D7 审计
|
||||
|
||||
状态、优先级或用量发生变化的记录,通过换货服务现有 `auditWriter` 写套餐使用记录变更审计,前后值包含状态、优先级、用量、生效、到期、重置时间与等实名激活标记,附换货单号。仅改载体、其余不变的记录不额外写审计。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [激活延迟] 无需等实名的未开始套餐要等孤儿扫描下一轮才生效 → 扫描周期分钟级;换货后新资产本无占位套餐,客户影响小。
|
||||
- [旧资产已过期未处理的主套餐] 原样迁移后,到期任务处理前新资产可能短暂出现两条状态生效中的记录 → 其中一条已过期,到期任务会处理;扣量与推算以未到期为准。
|
||||
- [实名口径两处维护] 换货侧与停复机侧各有一份 → 在函数注释注明口径来源,后续"统一实名判断"Change 合并。
|
||||
- [清零用量] 排队套餐换货前的周期内用量不再显示在套餐上 → 日流量记录保留,可追溯。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- 无数据库迁移,无配置变化。
|
||||
- 按维护者生产发布流程手工发布;回滚即回退二进制。
|
||||
- 存量不修正:2026-11-04 换货单 1095–1104、1108 的 11 条主套餐有固定到期日,按 D1 属于已开始,现状即为本规则下的结果。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
换货迁移套餐时(`internal/service/exchange/migration.go` 的 `migratePackageUsageWithTx`),系统只把套餐使用记录的载体改成新资产,状态、优先级、用量、有效期原样保留,不看套餐是否真正开始过,也不看新资产上已有的套餐。生产已出现后果:
|
||||
|
||||
- 2026-11-03 换货单 EXC20261103174239616885:新卡 89860624630055040571 自己有生效主套餐,迁过来的主套餐也是生效中,一张卡两个生效主套餐,流量重复扣减,预计到期时间显示"数据异常"(已手工修正,见 `docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md`)。
|
||||
|
||||
业务原则:换货只是换载体,不改变套餐所处的阶段;同一载体同时只能有一个生效主套餐(《八月迭代后端修正技术方案汇总》3.15 M15);新资产已有生效套餐时换货不拦截、叠加数据(同文 3.12)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 换货迁移主套餐时,按以下顺序决定迁移后的状态:
|
||||
1. **套餐是否已开始**:有生效时间、或有到期时间、或有用量、或有日流量记录、或状态为已用完,视为已开始;否则视为未开始。老系统迁移数据中"生效中、无生效时间、有固定到期日"的套餐按已开始处理,保留原到期日。
|
||||
2. **未开始的主套餐**:一律以待生效迁移,清空生效时间、到期时间与重置时间;新资产不满足实名条件时标记为等实名激活。实名条件与停复机口径一致:行业卡、已实名、或实名策略为无需实名,任一满足即视为满足。
|
||||
3. **已开始的主套餐**:新资产上没有当前主套餐(生效中或已用完、且未到期)时,原样迁移、继续生效;新资产已有当前主套餐时,改为待生效排在后面,清空生效时间、到期时间与重置时间,已用量清零,等轮到时从激活当天起重新计算完整周期。
|
||||
- 迁移后新资产上全部未结束主套餐按"当前主套餐 → 新资产原有待生效 → 迁入的主套餐(按原顺序)"统一重排优先级,不再出现优先级重复或待生效排在当前套餐之前。
|
||||
- 加油包仍原样迁移,继续挂在原主套餐下。
|
||||
- 未开始或排队的主套餐迁移后不在换货事务内激活;新资产无占位主套餐且满足激活条件时,由现有孤儿套餐扫描或实名激活流程接续生效。
|
||||
- 换货迁移导致状态、优先级或用量变化的套餐记录写审计,保留前后值。
|
||||
- 不拦截换货,不改换货接口入参与出参。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `order-refund-exchange`:新增"换货迁移后的主套餐状态"要求,规定迁移主套餐时按"是否已开始、新资产实名条件、新资产是否已有当前主套餐"决定状态与排队顺序。
|
||||
|
||||
## Impact
|
||||
|
||||
- 代码:`internal/service/exchange/migration.go`(`migratePackageUsageWithTx` 改为按规则逐条处理主套餐、重排优先级);新增换货侧的实名条件判断(行业卡、已实名、无需实名,口径同 `internal/service/iot_card/stop_resume_service.go` 的 `isRealnameOK`);当前主套餐判断沿用 `internal/service/package` 的 `FindCurrentMainPackageForQueue` 口径。
|
||||
- 接口:无变化。
|
||||
- 数据库:无迁移。
|
||||
- 存量数据:不修正。2026-11-04 换货单 1095–1104、1108 迁入的 11 条主套餐有固定到期日,按本规则属于已开始、新卡无当前主套餐时原样迁移,与现状一致;新卡未实名且已停机,未产生用量。
|
||||
- 不涉及:其他入口"等实名激活"判断不认行业卡与无需实名的问题(订单、自动续费、自动购包、实名激活),另开 Change 处理。
|
||||
@@ -0,0 +1,79 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 换货迁移后的主套餐状态
|
||||
|
||||
选择业务数据迁移的换货完成时,系统 SHALL 逐条决定旧资产主套餐使用记录(`master_usage_id` 为空,状态为待生效、生效中或已用完)迁移到新资产后的状态,保证新资产同一时刻最多一个生效中或已用完的未到期主套餐。
|
||||
|
||||
- **已开始**:主套餐有生效时间、或有到期时间、或已用量大于 0、或存在日流量记录、或状态为已用完,视为已开始;否则视为未开始。
|
||||
- **实名条件**:新资产为卡时,卡为行业卡、或已实名、或其适用实名策略(已绑定设备时取设备策略,否则取卡策略)为无需实名,视为满足;新资产为设备时,设备实名策略为无需实名、或任一已绑定卡为行业卡或已实名,视为满足。
|
||||
- **当前主套餐**:载体上状态为生效中或已用完、且未到期(到期时间为空或晚于当前时间)的主套餐。
|
||||
|
||||
规则:
|
||||
|
||||
1. 未开始的主套餐 MUST 以待生效迁移,生效时间、到期时间、上次重置时间、下次重置时间 MUST 清空;新资产不满足实名条件时 MUST 标记为等实名激活,满足时 MUST NOT 标记。
|
||||
2. 已开始的主套餐,若新资产迁移前没有当前主套餐,且它是旧资产按优先级、创建时间、标识排序的第一条已开始的当前主套餐,SHALL 原样迁移,状态、用量、有效期与重置时间不变。
|
||||
3. 其余已开始的主套餐 MUST 以待生效迁移,生效时间、到期时间、上次重置时间、下次重置时间 MUST 清空,已用量 MUST 清零,MUST NOT 标记为等实名激活;日流量记录 MUST 保留为历史。
|
||||
4. 迁移完成后,新资产全部待生效、生效中、已用完的主套餐 SHALL 按"当前主套餐 → 新资产原有待生效主套餐(保持原顺序)→ 旧资产迁入的待生效主套餐(保持原顺序)"重新编排优先级为 1 起连续整数。
|
||||
5. 旧资产上已到期但尚未被到期任务处理的主套餐 SHALL 原样迁移,不参与排队与优先级编排。
|
||||
6. 加油包 SHALL 原样迁移并保持与原主套餐的关联。
|
||||
7. 换货 MUST NOT 因新资产已有钱包余额或套餐而被拒绝;换货事务内 MUST NOT 激活待生效主套餐。
|
||||
8. 因本规则改变状态、优先级或用量的每条套餐使用记录 SHALL 写入审计,包含变更前后值与换货单号。
|
||||
|
||||
#### Scenario: 新资产无套餐,迁入在用主套餐
|
||||
|
||||
- **GIVEN** 旧资产有一条生效中、已用量大于 0 的主套餐和一条挂在其下的加油包,新资产没有任何套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 主套餐在新资产上仍为生效中,用量、到期时间与重置时间不变,优先级为 1;加油包原样迁移
|
||||
|
||||
#### Scenario: 新资产已有生效主套餐,迁入在用主套餐
|
||||
|
||||
- **GIVEN** 新资产有一条生效中的主套餐,旧资产有一条生效中、已用量大于 0 的主套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 新资产原主套餐保持生效、优先级为 1;迁入主套餐变为待生效、优先级为 2,生效时间、到期时间与重置时间为空,已用量为 0,未标记等实名激活,原日流量记录保留
|
||||
|
||||
#### Scenario: 新资产已用完但未到期
|
||||
|
||||
- **GIVEN** 新资产主套餐为已用完且到期时间晚于当前时间,旧资产有生效中的已开始主套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 迁入主套餐变为待生效并排在新资产主套餐之后
|
||||
|
||||
#### Scenario: 新资产主套餐已过期但未被处理
|
||||
|
||||
- **GIVEN** 新资产主套餐状态为生效中但到期时间早于当前时间,旧资产有生效中的已开始主套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 新资产视为没有当前主套餐,迁入主套餐保持生效中
|
||||
|
||||
#### Scenario: 未开始的主套餐迁到未实名普通卡
|
||||
|
||||
- **GIVEN** 旧资产主套餐为待生效,没有生效时间、到期时间,已用量为 0、没有日流量记录;新资产为未实名普通卡,实名策略为购买后实名
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 迁入主套餐变为待生效并标记等实名激活,生效时间、到期时间与重置时间为空,换货事务内不激活
|
||||
|
||||
#### Scenario: 老系统迁移的固定到期日套餐
|
||||
|
||||
- **GIVEN** 旧资产主套餐状态为生效中,没有生效时间、已用量为 0,但有固定到期时间;新资产没有任何套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 该主套餐视为已开始,在新资产上原样保持生效中,到期时间不变
|
||||
|
||||
#### Scenario: 未开始的主套餐迁到行业卡
|
||||
|
||||
- **GIVEN** 旧资产主套餐未开始,新资产为未实名的行业卡且没有当前主套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 迁入主套餐变为待生效且不标记等实名激活,之后由孤儿套餐扫描接续激活,有效期从激活当天起算
|
||||
|
||||
#### Scenario: 新资产只有待生效主套餐
|
||||
|
||||
- **GIVEN** 新资产只有一条优先级为 1 的待生效主套餐,旧资产有一条生效中的已开始主套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 迁入主套餐保持生效中、优先级为 1,新资产原待生效主套餐优先级变为 2,不出现优先级重复
|
||||
|
||||
#### Scenario: 旧资产有多条主套餐
|
||||
|
||||
- **GIVEN** 新资产有一条生效中的主套餐,旧资产有一条生效中的已开始主套餐和一条待生效主套餐
|
||||
- **WHEN** 换货选择业务数据迁移并确认完成
|
||||
- **THEN** 两条迁入主套餐均为待生效,按旧资产原顺序依次排在新资产主套餐之后,优先级分别为 2、3
|
||||
|
||||
#### Scenario: 迁移后预计到期时间可计算
|
||||
|
||||
- **WHEN** 任一上述场景换货完成后查询新资产预计到期时间
|
||||
- **THEN** 系统返回推算结果,不返回数据异常
|
||||
@@ -0,0 +1,23 @@
|
||||
## 1. 迁移规则实现
|
||||
|
||||
- [x] 1.1 `internal/service/exchange/` 新增换货侧实名条件判断:卡为行业卡、已实名,或适用实名策略(已绑定设备取设备策略,否则取卡策略)为无需实名;设备为无需实名策略,或任一已绑定卡为行业卡或已实名。注释注明口径同 `StopResumeService.isRealnameOK`。
|
||||
- [x] 1.2 新增"套餐是否已开始"判断:`activated_at` 非空、`expires_at` 非空、`data_usage_mb > 0`、存在日流量记录、状态为已用完任一满足。
|
||||
- [x] 1.3 改造 `migratePackageUsageWithTx`:事务内锁定新、旧资产主套餐记录;按 `FindCurrentMainPackageForQueue` 同一口径(生效中或已用完且未到期)在已锁定记录上判断新资产迁移前是否有当前主套餐;加油包与旧资产已到期主套餐原样改载体。
|
||||
- [x] 1.4 按 spec 规则 1–3 逐条处理旧资产主套餐:未开始 → 待生效、清空时间、按实名条件设置等实名激活标记;首条已开始的当前主套餐在新资产无当前主套餐时原样迁移;其余已开始 → 待生效、清空时间、用量清零、不标记等实名激活。
|
||||
- [x] 1.5 迁移后按"当前主套餐 → 新资产原有待生效 → 迁入待生效"把新资产未结束主套餐优先级重排为 1..n。
|
||||
- [x] 1.6 对状态、优先级或用量变化的记录通过换货 `auditWriter` 写审计(前后值、换货单号)。
|
||||
- [x] 1.7 返回的迁移套餐记录标识列表保持不变;日流量记录只保留,不删除。
|
||||
|
||||
## 2. 构建与文档
|
||||
|
||||
- [x] 2.1 `gofmt`、`go build ./cmd/api ./cmd/worker`。
|
||||
- [ ] 2.2 `openspec validate --all`、`./scripts/context-health.sh`。
|
||||
|
||||
## 3. 测试环境验证(AGENTS.md 规定自动化测试 N/A,不写单元测试)
|
||||
|
||||
- [ ] 3.1 按 ENG-TEST-001 在维护者指定测试环境造数,逐一验证 spec 全部场景:新资产无套餐;新资产已有生效;新资产已用完未到期;新资产已过期未处理;未开始迁到未实名普通卡;老系统固定到期日套餐原样迁移;未开始迁到行业卡(孤儿扫描后激活);新资产只有待生效;旧资产多条主套餐。
|
||||
- [ ] 3.2 每个场景换货后查询新资产预计到期时间不为数据异常,审计记录前后值正确。
|
||||
|
||||
## 4. 存量数据
|
||||
|
||||
- [x] 4.1 核对 2026-11-04 换货单 1095–1104、1108 共 11 条主套餐:均有固定到期日,按 D1 属于已开始、新卡无当前主套餐,现状与本规则一致,不修正。
|
||||
4
pkg/constants/device_network.go
Normal file
4
pkg/constants/device_network.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package constants
|
||||
|
||||
// DefaultDeviceLANIP 网关未返回设备局域网网关地址时 C 端使用的默认值(设备出厂默认网关)。
|
||||
const DefaultDeviceLANIP = "192.168.1.1"
|
||||
Reference in New Issue
Block a user