Files

198 lines
6.3 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"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
}
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,避免全量导入把可售库存回退。
canRefreshUpstream := existing.Source == "package" &&
strings.TrimSpace(existing.AssignedOrderNo) == "" &&
existing.Status != model.RockStatusAvailable
if canRefreshUpstream {
updates["status"] = status
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
}
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.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
}