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 }