问题:上游 0666.pro 兑换返回 HTTP 200 + status=pending(官方处理中), 旧逻辑仅凭「没有 error 字段」就判定成功并写 redeemed 终态,导致本地显示 已发货而上游仍是 bound,且 redeemed 为硬终态无法自愈。 后端: - RedeemSkin 改为三态判定(success / pending / failed),不再无 error 即成功 - 新增 redeeming「发货处理中」状态;pending 不写 redeemed_at/fulfill_id - 成功落库前二次 refresh 确认上游 redeemed 或 redeemed_at 才置终态 - 新增确认 worker:20s 轮询、30s→5m 指数退避、110001 全局冷却 60s、 15 分钟未确认仅告警不回退,确认后同步订单为 delivered 并触发回调 - pending 期间禁止重复兑换,订单保持 delivering,不下发发货成功回调 - 新增 rock_delivery_logs 表与记录器:入站参数、内部状态、上游请求/响应 原文全部留档,保留 180 天 - 分配/绑定校验/刷新/导入/删除/回收站/统计/客服查询全部适配 redeeming - 迁移 026:redeeming 跟踪字段、部分索引与报文日志表 前端: - Token 列表新增「发货处理中」筛选项、标签与超时提示 - 删除/回收站纳入 redeeming 危险状态 - 历史抽屉拆为「状态历史 / 发货报文」双页签,按 trace 展示报文详情 - 买家发货页在 pending 时提示官方处理中,无需重复提交 测试:新增 pending 不落终态、异常响应不误判、重复提交不重复兑换、 确认后同步订单、保持 pending 并退避 5 个用例;go build/vet/test 与 前端构建均通过。
210 lines
6.9 KiB
Go
210 lines
6.9 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// importPackageDetail 把单个包详情幂等写入本地 Token,返回新增/更新/跳过数量。
|
|
func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPackageDetailResponse, forceAvailableIDs map[string]struct{}) (int, int, int, error) {
|
|
if detail == nil || detail.Package.ID == 0 {
|
|
return 0, 0, 0, errors.New("上游未返回 package.id")
|
|
}
|
|
|
|
packageID := detail.Package.ID
|
|
shareCode := strings.TrimSpace(detail.Package.ShareCode)
|
|
inserted, updated, skipped := 0, 0, 0
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
for _, pt := range detail.Tokens {
|
|
if pt.ID <= 0 {
|
|
skipped++
|
|
continue
|
|
}
|
|
upstreamID := strconv.FormatInt(pt.ID, 10)
|
|
status := normalizeRockPackageStatus(pt.Status)
|
|
raw, marshalErr := json.Marshal(pt)
|
|
if marshalErr != nil {
|
|
raw = []byte(`{}`)
|
|
}
|
|
claimedAt := parseRockPackageTime(pt.CreatedAt)
|
|
boundAt := parseRockPackageTime(pt.BindChangedAt)
|
|
redeemedAt := parseRockPackageTime(pt.RedeemedAt)
|
|
|
|
var existing model.RockToken
|
|
findErr := tx.Unscoped().Where("upstream_token_id = ?", upstreamID).First(&existing).Error
|
|
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
|
insertStatus := status
|
|
if _, ok := forceAvailableIDs[upstreamID]; ok {
|
|
insertStatus = model.RockStatusAvailable
|
|
}
|
|
// 上游不提供“进入 using”的时间,用 Token 创建时间近似(与 017 回填口径一致)。
|
|
var usingAt *time.Time
|
|
if insertStatus == model.RockStatusUsing {
|
|
usingAt = claimedAt
|
|
if usingAt == nil {
|
|
now := time.Now()
|
|
usingAt = &now
|
|
}
|
|
}
|
|
rec := model.RockToken{
|
|
UpstreamTokenID: upstreamID,
|
|
Token: strings.TrimSpace(pt.Token),
|
|
RedeemCode: strings.TrimSpace(pt.RedeemCode),
|
|
Color: defaultValue(strings.TrimSpace(pt.Color), model.RockColorBlack),
|
|
Region: defaultValue(strings.TrimSpace(pt.Region), "Q/V可用"),
|
|
Status: insertStatus,
|
|
PackageID: &packageID,
|
|
PackageShareCode: shareCode,
|
|
ActualRoleName: strings.TrimSpace(pt.BoundAccount),
|
|
ActualRoleID: strings.TrimSpace(pt.BoundAccountID),
|
|
ActualChannel: strings.TrimSpace(pt.Region),
|
|
TargetSkin: strings.TrimSpace(pt.RedeemSkin),
|
|
TargetCurrency: defaultValue(strings.TrimSpace(pt.RedeemCurrency), "幸运币"),
|
|
TargetCurrencyQuantity: 0,
|
|
RawData: string(raw),
|
|
Source: "package",
|
|
ClaimedAt: claimedAt,
|
|
UsingAt: usingAt,
|
|
BoundAt: boundAt,
|
|
RedeemedAt: redeemedAt,
|
|
}
|
|
if err := tx.Create(&rec).Error; err != nil {
|
|
return err
|
|
}
|
|
writeRockTokenStatusLog(tx, &rec, rockTokenEventCreated, "", rec.Status,
|
|
rockTokenSourcePackageSync, fmt.Sprintf("导入上游包 %d", packageID), false, nil)
|
|
inserted++
|
|
continue
|
|
}
|
|
if findErr != nil {
|
|
return findErr
|
|
}
|
|
// 已软删除的 Token 视为“用户主动清理过”,再次导入时不复活、不覆盖。
|
|
if existing.DeletedAt.Valid {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"package_id": packageID,
|
|
"raw_data": string(raw),
|
|
}
|
|
if existing.PackageShareCode == "" && shareCode != "" {
|
|
updates["package_share_code"] = shareCode
|
|
}
|
|
// 已存在的本地 Token 只补空字段,避免覆盖手动采购/正在履约的本地状态。
|
|
if existing.Token == "" {
|
|
updates["token"] = strings.TrimSpace(pt.Token)
|
|
}
|
|
if existing.RedeemCode == "" {
|
|
updates["redeem_code"] = strings.TrimSpace(pt.RedeemCode)
|
|
}
|
|
if existing.Color == "" {
|
|
updates["color"] = defaultValue(strings.TrimSpace(pt.Color), model.RockColorBlack)
|
|
}
|
|
if existing.Region == "" {
|
|
updates["region"] = defaultValue(strings.TrimSpace(pt.Region), "Q/V可用")
|
|
}
|
|
if existing.Source == "" {
|
|
updates["source"] = "package"
|
|
}
|
|
|
|
// 打包导入且没有本地订单的记录:允许随上游刷新展示字段;
|
|
// 但手工释放为 available 的记录保持 available,避免全量导入把可售库存回退。
|
|
oldStatus := existing.Status
|
|
canRefreshUpstream := existing.Source == "package" &&
|
|
strings.TrimSpace(existing.AssignedOrderNo) == "" &&
|
|
existing.Status != model.RockStatusAvailable &&
|
|
existing.Status != model.RockStatusRedeeming
|
|
if canRefreshUpstream {
|
|
updates["status"] = status
|
|
updates["restocked_at"] = nil // 上游包同步回库不属于「释放回库存」,不产生优先权
|
|
if status == model.RockStatusUsing && existing.UsingAt == nil {
|
|
if claimedAt != nil {
|
|
updates["using_at"] = claimedAt
|
|
} else {
|
|
updates["using_at"] = time.Now()
|
|
}
|
|
}
|
|
if strings.TrimSpace(pt.BoundAccount) != "" {
|
|
updates["actual_role_name"] = strings.TrimSpace(pt.BoundAccount)
|
|
}
|
|
if strings.TrimSpace(pt.BoundAccountID) != "" {
|
|
updates["actual_role_id"] = strings.TrimSpace(pt.BoundAccountID)
|
|
}
|
|
if strings.TrimSpace(pt.Region) != "" {
|
|
updates["actual_channel"] = strings.TrimSpace(pt.Region)
|
|
}
|
|
if strings.TrimSpace(pt.RedeemSkin) != "" {
|
|
updates["target_skin"] = strings.TrimSpace(pt.RedeemSkin)
|
|
}
|
|
if strings.TrimSpace(pt.RedeemCurrency) != "" {
|
|
updates["target_currency"] = strings.TrimSpace(pt.RedeemCurrency)
|
|
}
|
|
if claimedAt != nil {
|
|
updates["claimed_at"] = claimedAt
|
|
}
|
|
if boundAt != nil {
|
|
updates["bound_at"] = boundAt
|
|
}
|
|
if redeemedAt != nil {
|
|
updates["redeemed_at"] = redeemedAt
|
|
}
|
|
}
|
|
if err := tx.Model(&existing).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if canRefreshUpstream && oldStatus != status {
|
|
writeRockTokenStatusChange(tx, &existing, oldStatus, status,
|
|
rockTokenSourcePackageSync, fmt.Sprintf("上游包 %d 同步", packageID), false, nil)
|
|
}
|
|
updated++
|
|
}
|
|
return nil
|
|
})
|
|
return inserted, updated, skipped, err
|
|
}
|
|
|
|
func normalizeRockPackageStatus(status string) string {
|
|
switch strings.TrimSpace(status) {
|
|
case model.RockStatusAvailable:
|
|
return model.RockStatusAvailable
|
|
case model.RockStatusUsing:
|
|
return model.RockStatusUsing
|
|
case model.RockStatusBound:
|
|
return model.RockStatusBound
|
|
case model.RockStatusRedeeming:
|
|
return model.RockStatusRedeeming
|
|
case model.RockStatusRedeemed:
|
|
return model.RockStatusRedeemed
|
|
case model.RockStatusMismatch:
|
|
return model.RockStatusMismatch
|
|
default:
|
|
// 未知上游状态默认落 abnormal,避免被错误分配为可用库存。
|
|
return model.RockStatusAbnormal
|
|
}
|
|
}
|
|
|
|
func parseRockPackageTime(raw string) *time.Time {
|
|
if t, ok := parseRockTime(raw); ok {
|
|
return &t
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func defaultValue(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|