feat:2、刷新绑定信息,操作加一个刷新绑定状态的按钮,3、绑定错误有提示,手动上报异常,拿到包ID做链接跳转(定位)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- ============================================================================
|
||||
-- 017_rock_using_warning.sql
|
||||
-- Rock Token「扫码使用中」超时预警:记录变为 using 的时间 + 可配置阈值
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. 使用中起点:进入 using 时写入,释放/重置回 available 时清空。
|
||||
ALTER TABLE rock_tokens
|
||||
ADD COLUMN IF NOT EXISTS using_at TIMESTAMPTZ;
|
||||
|
||||
-- 只对 using 建部分索引,列表按状态过滤时可直接用上。
|
||||
CREATE INDEX IF NOT EXISTS idx_rock_tokens_using_at
|
||||
ON rock_tokens (using_at)
|
||||
WHERE status = 'using';
|
||||
|
||||
-- 2. 历史数据无法拿到精确的“进入 using”时刻:
|
||||
-- 0666.pro 只暴露 Token 创建时间,且与后续打包导入的口径保持一致,
|
||||
-- 这里统一用 claimed_at(缺失时退回 created_at)近似回填。
|
||||
UPDATE rock_tokens
|
||||
SET using_at = COALESCE(claimed_at, created_at)
|
||||
WHERE status = 'using' AND using_at IS NULL;
|
||||
|
||||
-- 3. 橙色/红色预警阈值(天),默认 3 / 5。
|
||||
ALTER TABLE rock_configs
|
||||
ADD COLUMN IF NOT EXISTS using_warn_days INT NOT NULL DEFAULT 3;
|
||||
|
||||
ALTER TABLE rock_configs
|
||||
ADD COLUMN IF NOT EXISTS using_danger_days INT NOT NULL DEFAULT 5;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ============================================================================
|
||||
-- 018_rock_package_share_code.sql
|
||||
-- 保存 Token 所属上游打包记录的分享码,用于跳转 0666.pro 包页面定位
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE rock_tokens
|
||||
ADD COLUMN IF NOT EXISTS package_share_code VARCHAR(64) NOT NULL DEFAULT '';
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -37,6 +38,11 @@ func (h *RockHandler) UpdateConfig(c *gin.Context) {
|
||||
}
|
||||
cfg, err := h.rockSvc.UpdateConfig(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
var validationErr *service.RockConfigValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
response.BadRequest(c, validationErr.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -180,6 +186,36 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
|
||||
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
||||
}
|
||||
|
||||
// RefreshBinding 人工刷新指定 Token 的上游绑定状态
|
||||
func (h *RockHandler) RefreshBinding(c *gin.Context) {
|
||||
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "无效的 Token ID")
|
||||
return
|
||||
}
|
||||
result, err := h.rockSvc.RefreshTokenBinding(c.Request.Context(), uint(idUint))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// TokenPackageLink 返回 Token 所属上游包页面的定位链接
|
||||
func (h *RockHandler) TokenPackageLink(c *gin.Context) {
|
||||
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "无效的 Token ID")
|
||||
return
|
||||
}
|
||||
result, err := h.rockSvc.TokenPackageLink(c.Request.Context(), uint(idUint))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
type updateRemarkReq struct {
|
||||
Remark string `json:"remark"`
|
||||
Status string `json:"status"` // 可选:比如 abnormal
|
||||
|
||||
@@ -43,6 +43,8 @@ type RockConfig struct {
|
||||
MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"`
|
||||
BlackTokenCost float64 `gorm:"type:numeric(12,2);not null;default:20.00" json:"black_token_cost"`
|
||||
TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"`
|
||||
UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
|
||||
UsingDangerDays int `gorm:"not null;default:5" json:"using_danger_days"` // 使用中超过 N 天红色预警
|
||||
AutoPurchaseEnabled bool `gorm:"not null;default:false" json:"auto_purchase_enabled"`
|
||||
AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
|
||||
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
|
||||
@@ -69,6 +71,7 @@ type RockToken struct {
|
||||
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
|
||||
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
|
||||
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
|
||||
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
|
||||
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
|
||||
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
|
||||
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
|
||||
@@ -87,6 +90,7 @@ type RockToken struct {
|
||||
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
|
||||
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
|
||||
BoundAt *time.Time `json:"bound_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -293,6 +293,8 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID)
|
||||
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
|
||||
platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
|
||||
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
|
||||
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
|
||||
platform.POST("/platform/rock/tokens/:id/remark", h.Rock.UpdateRemark)
|
||||
platform.POST("/platform/rock/tokens/:id/abnormal", h.Rock.MarkAbnormal)
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder
|
||||
"status": model.RockStatusAvailable,
|
||||
"assigned_order_no": "",
|
||||
"expected_uid": "",
|
||||
"using_at": nil,
|
||||
}).Error
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -117,9 +117,11 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
token.Status = model.RockStatusUsing
|
||||
token.AssignedOrderNo = orderNo
|
||||
token.ExpectedUID = strings.TrimSpace(expectedUID)
|
||||
token.UsingAt = &now
|
||||
return tx.Save(&token).Error
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
// TokenBindingRefreshResult 是人工“刷新绑定状态”的返回结果。
|
||||
type TokenBindingRefreshResult struct {
|
||||
Status string `json:"status"`
|
||||
Bound bool `json:"bound"`
|
||||
RoleName string `json:"role_name"`
|
||||
RoleID string `json:"role_id"`
|
||||
Channel string `json:"channel"`
|
||||
Changed bool `json:"changed"`
|
||||
Warning bool `json:"warning"`
|
||||
Message string `json:"message"`
|
||||
RedeemedAt string `json:"redeemed_at,omitempty"`
|
||||
}
|
||||
|
||||
func canRefreshRockBinding(status string) bool {
|
||||
switch status {
|
||||
case model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
|
||||
//
|
||||
// 规则:
|
||||
// - 上游已发货 → 本地同步 redeemed;
|
||||
// - 上游已绑定且本地有预期 UID → 一致转 bound,不一致转 mismatch;
|
||||
// - 上游已绑定但本地没有预期 UID → 只同步角色信息,状态保持不变;
|
||||
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
|
||||
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) {
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canRefreshRockBinding(token.Status) {
|
||||
return nil, errors.New("仅扫码使用中 / 已授权绑定 / 绑定错误的 Token 可以刷新绑定状态")
|
||||
}
|
||||
if strings.TrimSpace(token.RedeemCode) == "" {
|
||||
return nil, errors.New("该 Token 缺少兑换码,无法刷新绑定状态")
|
||||
}
|
||||
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ref, err := s.client.RefreshRedeemBinding(ctx, token.RedeemCode)
|
||||
if err != nil {
|
||||
// 公开端接口不可用时,降级到管理端 refresh(需要 upstream_token_id + Bearer)。
|
||||
var statusErr *RockUpstreamStatusError
|
||||
if errors.As(err, &statusErr) &&
|
||||
(statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) &&
|
||||
strings.TrimSpace(token.UpstreamTokenID) != "" {
|
||||
ref, err = s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err)
|
||||
}
|
||||
}
|
||||
if ref == nil {
|
||||
return nil, errors.New("上游未返回刷新结果")
|
||||
}
|
||||
|
||||
// 上游请求期间本地状态可能已被买家轮询改写,落库前重新读取。
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canRefreshRockBinding(token.Status) {
|
||||
return nil, fmt.Errorf("Token 状态已变为 %s,无法再刷新绑定状态", token.Status)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
originalStatus := token.Status
|
||||
originalRoleName := token.ActualRoleName
|
||||
originalRoleID := token.ActualRoleID
|
||||
originalChannel := token.ActualChannel
|
||||
|
||||
actualRoleName := ref.GetRoleName()
|
||||
actualRoleID := ref.GetRoleID()
|
||||
actualChannel := ref.GetChannel()
|
||||
expectedUID := strings.TrimSpace(token.ExpectedUID)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"updated_at": now,
|
||||
}
|
||||
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
|
||||
if ref.IsBound() {
|
||||
if actualRoleName != "" {
|
||||
updates["actual_role_name"] = actualRoleName
|
||||
}
|
||||
if actualRoleID != "" {
|
||||
updates["actual_role_id"] = actualRoleID
|
||||
}
|
||||
if actualChannel != "" {
|
||||
updates["actual_channel"] = actualChannel
|
||||
}
|
||||
}
|
||||
|
||||
result := &TokenBindingRefreshResult{
|
||||
Status: token.Status,
|
||||
Bound: ref.IsBound(),
|
||||
}
|
||||
if ref.IsBound() {
|
||||
result.RoleName = actualRoleName
|
||||
result.RoleID = actualRoleID
|
||||
result.Channel = actualChannel
|
||||
}
|
||||
|
||||
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
|
||||
redeemedAtRaw := strings.TrimSpace(ref.Token.RedeemedAt)
|
||||
|
||||
switch {
|
||||
case upstreamStatus == model.RockStatusRedeemed || redeemedAtRaw != "":
|
||||
redeemedAt, ok := parseRockTime(redeemedAtRaw)
|
||||
if !ok {
|
||||
redeemedAt = now
|
||||
}
|
||||
updates["status"] = model.RockStatusRedeemed
|
||||
updates["redeemed_at"] = redeemedAt
|
||||
if len(ref.DeliveryList) > 0 {
|
||||
if fulfillID := ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID; fulfillID > 0 {
|
||||
updates["upstream_fulfill_id"] = fulfillID
|
||||
}
|
||||
}
|
||||
result.Status = model.RockStatusRedeemed
|
||||
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
|
||||
result.Message = "上游已发货,本地状态已同步为已交付"
|
||||
|
||||
case ref.IsBound() && actualRoleID == "":
|
||||
result.Warning = true
|
||||
result.Message = "上游返回已绑定但缺少角色 UID,未改动本地状态,请人工核对"
|
||||
|
||||
case ref.IsBound() && expectedUID == "":
|
||||
// 没有预期 UID 无法核验,只同步角色信息。
|
||||
result.Warning = true
|
||||
result.Message = fmt.Sprintf(
|
||||
"上游已绑定「%s」(UID %s),但本地缺少预期 UID,已同步角色信息,请人工核对",
|
||||
displayRockRole(actualRoleName, actualRoleID), actualRoleID,
|
||||
)
|
||||
|
||||
case ref.IsBound() && token.Status == model.RockStatusMismatch && actualRoleID == expectedUID:
|
||||
updates["status"] = model.RockStatusBound
|
||||
updates["mismatch_at"] = nil
|
||||
updates["mismatch_reason"] = ""
|
||||
if token.BoundAt == nil {
|
||||
updates["bound_at"] = resolveRockBoundAt(ref, now)
|
||||
}
|
||||
result.Status = model.RockStatusBound
|
||||
result.Message = "上游绑定 UID 与预期一致,已解除绑定错误"
|
||||
|
||||
case ref.IsBound() && token.Status == model.RockStatusUsing && !rockBuyerNewlyBound(&token, ref):
|
||||
result.Warning = true
|
||||
result.Message = "检测到历史绑定残留(非本次扫码),已同步角色信息,状态保持不变"
|
||||
|
||||
case ref.IsBound() && actualRoleID != expectedUID:
|
||||
reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID)
|
||||
updates["status"] = model.RockStatusMismatch
|
||||
updates["mismatch_reason"] = reason
|
||||
if token.MismatchAt == nil {
|
||||
updates["mismatch_at"] = now
|
||||
}
|
||||
result.Status = model.RockStatusMismatch
|
||||
result.Warning = true
|
||||
result.Message = fmt.Sprintf(
|
||||
"上游绑定「%s」(UID %s) 与预期 UID(%s) 不一致,已标记绑定错误",
|
||||
displayRockRole(actualRoleName, actualRoleID), actualRoleID, expectedUID,
|
||||
)
|
||||
|
||||
case ref.IsBound():
|
||||
updates["status"] = model.RockStatusBound
|
||||
updates["mismatch_at"] = nil
|
||||
updates["mismatch_reason"] = ""
|
||||
if token.BoundAt == nil {
|
||||
updates["bound_at"] = resolveRockBoundAt(ref, now)
|
||||
}
|
||||
result.Status = model.RockStatusBound
|
||||
result.Message = "上游已绑定,UID 核验一致"
|
||||
|
||||
default:
|
||||
result.Message = "上游仍未绑定,本地状态保持不变"
|
||||
}
|
||||
|
||||
result.Changed = result.Status != originalStatus ||
|
||||
(ref.IsBound() && actualRoleName != "" && actualRoleName != originalRoleName) ||
|
||||
(ref.IsBound() && actualRoleID != "" && actualRoleID != originalRoleID) ||
|
||||
(ref.IsBound() && actualChannel != "" && actualChannel != originalChannel)
|
||||
|
||||
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.refresh_binding", "rock_token",
|
||||
fmt.Sprintf("%d", token.ID), map[string]interface{}{
|
||||
"status": result.Status,
|
||||
"role_id": result.RoleID,
|
||||
"message": result.Message,
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func displayRockRole(name, roleID string) string {
|
||||
if strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
return strings.TrimSpace(roleID)
|
||||
}
|
||||
|
||||
func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time {
|
||||
if ref != nil {
|
||||
if t, ok := parseRockTime(ref.Token.BindChangedAt); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/api/redeem/CODE-BOUND/refresh" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{
|
||||
"status": "bound",
|
||||
"bound_account": "痛写心扉",
|
||||
"bound_account_id": "3132710334",
|
||||
"region": "安卓-微信",
|
||||
},
|
||||
"binding": map[string]interface{}{
|
||||
"bound": true,
|
||||
"role": "痛写心扉",
|
||||
"roleId": "3132710334",
|
||||
"region": "安卓-微信",
|
||||
},
|
||||
"message": "已刷新绑定信息",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9001",
|
||||
Token: "jwt-9001",
|
||||
RedeemCode: "CODE-BOUND",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-REFRESH-1",
|
||||
ExpectedUID: "3132710334",
|
||||
ClaimedAt: &now,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusBound || !res.Bound {
|
||||
t.Fatalf("期望 bound,实际 status=%s bound=%v", res.Status, res.Bound)
|
||||
}
|
||||
if res.RoleID != "3132710334" || res.RoleName != "痛写心扉" {
|
||||
t.Fatalf("角色信息未同步: %+v", res)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" || saved.ActualRoleName != "痛写心扉" {
|
||||
t.Fatalf("数据库未更新: %+v", saved)
|
||||
}
|
||||
if saved.BoundAt == nil {
|
||||
t.Fatalf("bound_at 未写入")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_MarksMismatch(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{
|
||||
"status": "bound",
|
||||
"bound_account": "痛写心扉",
|
||||
"bound_account_id": "3132710334",
|
||||
"region": "安卓-微信",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9002",
|
||||
RedeemCode: "CODE-MISMATCH",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-REFRESH-2",
|
||||
ExpectedUID: "1111111111",
|
||||
ClaimedAt: &now,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusMismatch || !strings.Contains(res.Message, "不一致") {
|
||||
t.Fatalf("期望 mismatch 提示,实际 %+v", res)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusMismatch || saved.MismatchAt == nil || !strings.Contains(saved.MismatchReason, "3132710334") {
|
||||
t.Fatalf("数据库 mismatch 字段不符: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_EmptyExpectedUIDKeepsStatus(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"binding": map[string]interface{}{
|
||||
"bound": true,
|
||||
"role": "痛写心扉",
|
||||
"roleId": "3132710334",
|
||||
"region": "安卓-微信",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9003",
|
||||
RedeemCode: "CODE-NOUID",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusUsing {
|
||||
t.Fatalf("缺少预期 UID 时状态应保持 using,实际 %s", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Message, "人工核对") {
|
||||
t.Fatalf("应提示人工核对,实际 %q", res.Message)
|
||||
}
|
||||
if !res.Warning {
|
||||
t.Fatalf("缺少预期 UID 时应返回 warning=true")
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusUsing || saved.ActualRoleID != "3132710334" {
|
||||
t.Fatalf("只应同步角色信息,实际 %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{
|
||||
"status": "redeemed",
|
||||
"bound_account": "痛写心扉",
|
||||
"bound_account_id": "3132710334",
|
||||
"redeemed_at": "2026-10-29 12:00:00",
|
||||
},
|
||||
"deliveryList": []map[string]interface{}{
|
||||
{"fulfillId": 4519960, "time": "2026-10-29T12:00:00.1", "role": "安卓-微信-痛写心扉"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9004",
|
||||
RedeemCode: "CODE-REDEEMED",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-REFRESH-4",
|
||||
ExpectedUID: "3132710334",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusRedeemed {
|
||||
t.Fatalf("期望 redeemed,实际 %s", res.Status)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusRedeemed || saved.RedeemedAt == nil || saved.UpstreamFulfillID != 4519960 {
|
||||
t.Fatalf("已发货字段未同步: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_UpstreamErrorKeepsState(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"refresh down"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9005",
|
||||
RedeemCode: "CODE-ERROR",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RefreshTokenBinding(context.Background(), token.ID); err == nil {
|
||||
t.Fatalf("上游 500 时应返回错误")
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusUsing || saved.ActualRoleID != "" {
|
||||
t.Fatalf("失败时不应改动本地状态: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_FallsBackToAdminRefresh(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/redeem/CODE-FALLBACK/refresh":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case "/api/user/tokens/9006/refresh":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{
|
||||
"status": "bound",
|
||||
"bound_account": "痛写心扉",
|
||||
"bound_account_id": "3132710334",
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9006",
|
||||
RedeemCode: "CODE-FALLBACK",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-REFRESH-6",
|
||||
ExpectedUID: "3132710334",
|
||||
ClaimedAt: &now,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("降级刷新失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusBound {
|
||||
t.Fatalf("降级后期望 bound,实际 %s", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_NotBoundKeepsRoleFields(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{
|
||||
"status": "using",
|
||||
"region": "Q/V可用",
|
||||
},
|
||||
"binding": map[string]interface{}{"bound": false},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9008",
|
||||
RedeemCode: "CODE-UNBOUND",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
ActualRoleName: "旧角色",
|
||||
ActualRoleID: "123456",
|
||||
ActualChannel: "安卓-微信",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusUsing || res.Bound {
|
||||
t.Fatalf("上游未绑定时应保持 using 且 bound=false,实际 %+v", res)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.ActualRoleName != "旧角色" || saved.ActualRoleID != "123456" || saved.ActualChannel != "安卓-微信" {
|
||||
t.Fatalf("上游未绑定时不应覆盖本地角色字段: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenBinding_RejectsAvailableToken(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9007",
|
||||
RedeemCode: "CODE-AVAILABLE",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusAvailable,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RefreshTokenBinding(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "仅扫码使用中") {
|
||||
t.Fatalf("available 状态应被拒绝,实际 err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -191,27 +191,60 @@ type RockDeliveryItem struct {
|
||||
Membership int `json:"membership"`
|
||||
}
|
||||
|
||||
type RockBindingInfo struct {
|
||||
Bound bool `json:"bound"`
|
||||
Role string `json:"role"`
|
||||
RoleID string `json:"roleId"`
|
||||
Region string `json:"region"`
|
||||
BindTime string `json:"bindTime"`
|
||||
}
|
||||
|
||||
type RockRefreshResponse struct {
|
||||
Token RockTokenData `json:"token"`
|
||||
Binding RockBindingInfo `json:"binding"`
|
||||
DeliveryList []RockDeliveryItem `json:"deliveryList"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r *RockRefreshResponse) IsBound() bool {
|
||||
return r.Token.IsBound()
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if r.Token.IsBound() {
|
||||
return true
|
||||
}
|
||||
return r.Binding.Bound || strings.TrimSpace(r.Binding.RoleID) != ""
|
||||
}
|
||||
|
||||
func (r *RockRefreshResponse) GetRoleName() string {
|
||||
return r.Token.GetRoleName()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if name := r.Token.GetRoleName(); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(r.Binding.Role)
|
||||
}
|
||||
|
||||
func (r *RockRefreshResponse) GetRoleID() string {
|
||||
return r.Token.GetRoleID()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if id := r.Token.GetRoleID(); id != "" {
|
||||
return id
|
||||
}
|
||||
return strings.TrimSpace(r.Binding.RoleID)
|
||||
}
|
||||
|
||||
func (r *RockRefreshResponse) GetChannel() string {
|
||||
return r.Token.GetChannel()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if channel := r.Token.GetChannel(); channel != "" {
|
||||
return channel
|
||||
}
|
||||
return strings.TrimSpace(r.Binding.Region)
|
||||
}
|
||||
|
||||
type RockRedeemResponse struct {
|
||||
@@ -649,6 +682,52 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// RockUpstreamStatusError 保留上游 HTTP 状态码,调用方可据此决定是否降级到备用接口。
|
||||
type RockUpstreamStatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *RockUpstreamStatusError) Error() string {
|
||||
return fmt.Sprintf("上游返回错误码 %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// RefreshRedeemBinding 通过兑换码刷新绑定状态(0666.pro 公开端接口,无需 Bearer)。
|
||||
func (c *RockClient) RefreshRedeemBinding(ctx context.Context, redeemCode string) (*RockRefreshResponse, error) {
|
||||
code := strings.TrimSpace(redeemCode)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("兑换码为空,无法刷新绑定状态")
|
||||
}
|
||||
reqURL := fmt.Sprintf("%s/api/redeem/%s/refresh", c.BaseURL(), url.PathEscape(code))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Origin", c.BaseURL())
|
||||
req.Header.Set("Referer", c.BaseURL()+"/")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 0666 redeem refresh 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 redeem refresh 响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &RockUpstreamStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var res RockRefreshResponse
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return nil, fmt.Errorf("解析 redeem refresh 响应失败: %w", err)
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// RedeemSkin 执行最终皮肤兑换发货(需携带商户 Bearer Token 鉴权)
|
||||
func (c *RockClient) RedeemSkin(ctx context.Context, bearerToken, upstreamTokenID, skin, currency, account string) (*RockRedeemResponse, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.BaseURL(), url.PathEscape(upstreamTokenID))
|
||||
|
||||
@@ -20,6 +20,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -44,6 +45,15 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
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),
|
||||
@@ -52,6 +62,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
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),
|
||||
@@ -61,6 +72,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
RawData: string(raw),
|
||||
Source: "package",
|
||||
ClaimedAt: claimedAt,
|
||||
UsingAt: usingAt,
|
||||
BoundAt: boundAt,
|
||||
RedeemedAt: redeemedAt,
|
||||
}
|
||||
@@ -78,6 +90,9 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
"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)
|
||||
@@ -102,6 +117,13 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
// TokenPackageLinkResult 是 Token 所属上游打包页面的定位链接。
|
||||
type TokenPackageLinkResult struct {
|
||||
PackageID int64 `json:"package_id"`
|
||||
ShareCode string `json:"share_code"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// TokenPackageLink 返回 Token 所属上游包页面的跳转链接。
|
||||
// 只对本地已保存分享码的新包生效;历史包不向上游反查。
|
||||
func (s *RockService) TokenPackageLink(ctx context.Context, tokenID uint) (*TokenPackageLinkResult, error) {
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token.PackageID == nil || *token.PackageID <= 0 {
|
||||
return nil, errors.New("该 Token 没有关联上游打包记录,无法跳转上游")
|
||||
}
|
||||
shareCode := strings.TrimSpace(token.PackageShareCode)
|
||||
if shareCode == "" {
|
||||
return nil, errors.New("该 Token 缺少上游包分享码(历史包不支持定位),无法跳转上游")
|
||||
}
|
||||
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = model.RockDefaultBaseURL
|
||||
}
|
||||
|
||||
return &TokenPackageLinkResult{
|
||||
PackageID: *token.PackageID,
|
||||
ShareCode: shareCode,
|
||||
URL: fmt.Sprintf("%s/#/package/%s", base, url.PathEscape(shareCode)),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func TestTokenPackageLink_BuildsUpstreamURL(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
packageID := int64(1507)
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9101",
|
||||
RedeemCode: "CODE-LINK",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusMismatch,
|
||||
PackageID: &packageID,
|
||||
PackageShareCode: "share-abc",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.TokenPackageLink(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("生成包链接失败: %v", err)
|
||||
}
|
||||
if res.PackageID != 1507 || res.ShareCode != "share-abc" {
|
||||
t.Fatalf("包信息不符: %+v", res)
|
||||
}
|
||||
if res.URL != "https://0666.pro/#/package/share-abc" {
|
||||
t.Fatalf("包链接不符: %s", res.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPackageLink_RejectsWithoutPackage(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9102",
|
||||
RedeemCode: "CODE-NOPKG",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusMismatch,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "没有关联上游打包记录") {
|
||||
t.Fatalf("无包 Token 应被拒绝,实际 err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPackageLink_RejectsMissingShareCode(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
packageID := int64(1508)
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9103",
|
||||
RedeemCode: "CODE-NOSHARE",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusMismatch,
|
||||
PackageID: &packageID,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "缺少上游包分享码") {
|
||||
t.Fatalf("缺少分享码应被拒绝,实际 err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPackageDetail_StoresPackageShareCode(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
var detail RockPackageDetailResponse
|
||||
raw := `{
|
||||
"package": {"id": 9009, "share_code": "share-import"},
|
||||
"tokens": [
|
||||
{"id": 39001, "token": "t1", "redeem_code": "c1", "color": "black", "status": "using", "created_at": "2026-10-27 23:31:59"}
|
||||
]
|
||||
}`
|
||||
if err := json.Unmarshal([]byte(raw), &detail); err != nil {
|
||||
t.Fatalf("解析包详情失败: %v", err)
|
||||
}
|
||||
|
||||
if _, _, _, err := svc.importPackageDetail(context.Background(), &detail, nil); err != nil {
|
||||
t.Fatalf("导入包详情失败: %v", err)
|
||||
}
|
||||
|
||||
var row model.RockToken
|
||||
if err := db.Where("upstream_token_id = ?", "39001").First(&row).Error; err != nil {
|
||||
t.Fatalf("查询导入 Token 失败: %v", err)
|
||||
}
|
||||
if row.PackageShareCode != "share-import" {
|
||||
t.Fatalf("package_share_code 未写入,实际 %q", row.PackageShareCode)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,9 @@ func TestPurchaseTokens_CreatesPackageAndImportsAvailable(t *testing.T) {
|
||||
if row.PackageID == nil || *row.PackageID != 9001 {
|
||||
t.Fatalf("package_id 未写入: %+v", row.PackageID)
|
||||
}
|
||||
if row.PackageShareCode != "share123" {
|
||||
t.Fatalf("package_share_code 未写入,实际 %q", row.PackageShareCode)
|
||||
}
|
||||
if row.Token == "" || row.RedeemCode == "" {
|
||||
t.Fatalf("提取码/兑换码未写入: %+v", row)
|
||||
}
|
||||
@@ -169,6 +172,9 @@ func TestPurchaseTokens_PackageFailureFallsBackToPurchase(t *testing.T) {
|
||||
if row.Status != model.RockStatusAvailable || row.Source != "purchase" || row.PackageID != nil {
|
||||
t.Fatalf("兜底入库字段不符: status=%s source=%s package_id=%v", row.Status, row.Source, row.PackageID)
|
||||
}
|
||||
if row.PackageShareCode != "" {
|
||||
t.Fatalf("打包创建失败时不应有分享码,实际 %q", row.PackageShareCode)
|
||||
}
|
||||
|
||||
cfg, _ := svc.GetConfig(context.Background())
|
||||
if !strings.Contains(cfg.LastError, "创建上游打包记录失败") {
|
||||
@@ -223,6 +229,9 @@ func TestPurchaseTokens_PackageDetailFailureFallsBackWithPackageID(t *testing.T)
|
||||
if row.PackageID == nil || *row.PackageID != 9002 {
|
||||
t.Fatalf("兜底入库应保留 package_id=9002,实际 %v", row.PackageID)
|
||||
}
|
||||
if row.PackageShareCode != "detailfail" {
|
||||
t.Fatalf("兜底入库应保留 package_share_code=detailfail,实际 %q", row.PackageShareCode)
|
||||
}
|
||||
|
||||
cfg, _ := svc.GetConfig(context.Background())
|
||||
if !strings.Contains(cfg.LastError, "详情拉取失败") {
|
||||
|
||||
@@ -122,6 +122,7 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro
|
||||
"initial_bound_snapshot": "",
|
||||
"mismatch_at": nil,
|
||||
"mismatch_reason": "",
|
||||
"using_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
@@ -144,6 +145,9 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar
|
||||
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
|
||||
updates["status"] = trimmedStatus
|
||||
token.Status = trimmedStatus
|
||||
if trimmedStatus == model.RockStatusAvailable {
|
||||
updates["using_at"] = nil
|
||||
}
|
||||
if trimmedStatus == model.RockStatusAbnormal {
|
||||
s.refreshCache.Delete(token.UpstreamTokenID)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint
|
||||
"mismatch_at": nil,
|
||||
"mismatch_reason": "",
|
||||
"bound_at": nil,
|
||||
"using_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -55,12 +55,23 @@ type RockDashboardResult struct {
|
||||
CostUpdated bool `json:"cost_updated"`
|
||||
}
|
||||
|
||||
// RockConfigValidationError 表示 Rock 配置参数不合法,handler 会转成 400。
|
||||
type RockConfigValidationError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *RockConfigValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type UpdateRockConfigReq struct {
|
||||
AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
BearerToken *string `json:"bearer_token"`
|
||||
MinPointsWarning *float64 `json:"min_points_warning"`
|
||||
TokenWarningStock *int `json:"token_warning_stock"`
|
||||
UsingWarnDays *int `json:"using_warn_days"`
|
||||
UsingDangerDays *int `json:"using_danger_days"`
|
||||
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
|
||||
AutoPurchaseCount *int `json:"auto_purchase_count"`
|
||||
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
|
||||
@@ -79,6 +90,8 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error)
|
||||
MinPointsWarning: 1050.00,
|
||||
BlackTokenCost: 20.00,
|
||||
TokenWarningStock: 10,
|
||||
UsingWarnDays: 3,
|
||||
UsingDangerDays: 5,
|
||||
AutoPurchaseEnabled: false,
|
||||
AutoPurchaseCount: 50,
|
||||
PurchaseCooldownMinutes: 10,
|
||||
@@ -142,6 +155,27 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq)
|
||||
updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes
|
||||
}
|
||||
|
||||
// 使用中超时预警阈值:橙色必须小于红色,否则前端无法区分两级。
|
||||
warnDays := cfg.UsingWarnDays
|
||||
if req.UsingWarnDays != nil {
|
||||
warnDays = *req.UsingWarnDays
|
||||
updates["using_warn_days"] = warnDays
|
||||
}
|
||||
dangerDays := cfg.UsingDangerDays
|
||||
if req.UsingDangerDays != nil {
|
||||
dangerDays = *req.UsingDangerDays
|
||||
updates["using_danger_days"] = dangerDays
|
||||
}
|
||||
if warnDays < 1 || warnDays > 365 {
|
||||
return nil, &RockConfigValidationError{Message: "使用中橙色预警天数必须在 1-365 之间"}
|
||||
}
|
||||
if dangerDays < 1 || dangerDays > 365 {
|
||||
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须在 1-365 之间"}
|
||||
}
|
||||
if dangerDays <= warnDays {
|
||||
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -113,30 +113,30 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
||||
pkgErr = errors.New("上游打包接口未返回内容")
|
||||
}
|
||||
if pkgErr != nil {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "purchase", now,
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "", "purchase", now,
|
||||
"创建上游打包记录失败: "+pkgErr.Error())
|
||||
}
|
||||
|
||||
// 2. 拉取包详情(带重试)
|
||||
detail, detailErr := s.getPackageDetailWithRetry(ctx, shareCode)
|
||||
if detailErr != nil {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now,
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
|
||||
fmt.Sprintf("包 %s 详情拉取失败: %v", shareCode, detailErr))
|
||||
}
|
||||
|
||||
// 3. 导入包内 Token:本批刚 claim 的 Token 强制 available,其余沿用上游状态
|
||||
inserted, updated, skipped, importErr := s.importPackageDetail(ctx, detail, claimedSet)
|
||||
if importErr != nil {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now,
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
|
||||
fmt.Sprintf("包 %s 导入失败: %v", shareCode, importErr))
|
||||
}
|
||||
if inserted+updated == 0 {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now,
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
|
||||
fmt.Sprintf("包 %s 未返回有效 Token", shareCode))
|
||||
}
|
||||
|
||||
// 4. 极端情况下详情缺 Token:用 claim 数据补齐,保证每个已扣积分的 Token 都在本地
|
||||
ensureErr := s.ensureClaimedTokens(ctx, claimed, packageID, "package", now)
|
||||
ensureErr := s.ensureClaimedTokens(ctx, claimed, packageID, shareCode, "package", now)
|
||||
if ensureErr != nil {
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, "补录缺失 Token 失败: "+ensureErr.Error())
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
||||
if findErr != nil {
|
||||
log.Printf("[RockService] 回查采购 Token 失败: %v", findErr)
|
||||
}
|
||||
dbTokens = s.buildClaimedTokens(claimed, packageID, "package", now)
|
||||
dbTokens = s.buildClaimedTokens(claimed, packageID, shareCode, "package", now)
|
||||
}
|
||||
|
||||
if ensureErr == nil {
|
||||
@@ -158,8 +158,8 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
||||
}
|
||||
|
||||
// fallbackClaimedTokens 在打包/详情/导入失败时,用 claim 返回的数据直接入库,避免积分白花。
|
||||
func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, claimed []RockClaimedToken, packageID int64, source string, at time.Time, reason string) ([]model.RockToken, error) {
|
||||
tokens, err := s.insertClaimedTokens(ctx, claimed, packageID, source, at)
|
||||
func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time, reason string) ([]model.RockToken, error) {
|
||||
tokens, err := s.insertClaimedTokens(ctx, claimed, packageID, shareCode, source, at)
|
||||
if err != nil {
|
||||
s.recordPurchaseFailure(ctx, cfgID, reason+";兜底入库失败: "+err.Error())
|
||||
return nil, fmt.Errorf("%s;兜底入库失败: %w", reason, err)
|
||||
@@ -169,8 +169,8 @@ func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, cla
|
||||
}
|
||||
|
||||
// insertClaimedTokens 把 claim 结果直接写入库存表。
|
||||
func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, source string, at time.Time) ([]model.RockToken, error) {
|
||||
tokens := s.buildClaimedTokens(claimed, packageID, source, at)
|
||||
func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time) ([]model.RockToken, error) {
|
||||
tokens := s.buildClaimedTokens(claimed, packageID, shareCode, source, at)
|
||||
if len(tokens) == 0 {
|
||||
return nil, errors.New("没有可入库的 Token")
|
||||
}
|
||||
@@ -183,8 +183,8 @@ func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockCla
|
||||
}
|
||||
|
||||
// ensureClaimedTokens 补录本地缺失的 claim Token(详情返回不完整时使用)。
|
||||
func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, source string, at time.Time) error {
|
||||
tokens := s.buildClaimedTokens(claimed, packageID, source, at)
|
||||
func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time) error {
|
||||
tokens := s.buildClaimedTokens(claimed, packageID, shareCode, source, at)
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockCla
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID int64, source string, at time.Time) []model.RockToken {
|
||||
func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time) []model.RockToken {
|
||||
out := make([]model.RockToken, 0, len(claimed))
|
||||
for _, t := range claimed {
|
||||
if t.ID <= 0 {
|
||||
@@ -241,6 +241,7 @@ func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID i
|
||||
if packageID > 0 {
|
||||
pid := packageID
|
||||
rec.PackageID = &pid
|
||||
rec.PackageShareCode = strings.TrimSpace(shareCode)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
)
|
||||
|
||||
func TestAssignTokenForOrder_SetsUsingAtAndReleaseClears(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/redeem/"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": 151153,
|
||||
"redeem_code": "CODE-USING-1",
|
||||
"bind_url": "https://gp.qq.com/cp/mock_bind",
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/refresh"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "88001",
|
||||
Token: "jwt-using",
|
||||
RedeemCode: "CODE-USING-1",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusAvailable,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建可用 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
assigned, _, err := svc.AssignTokenForOrder(context.Background(), "ORDER-USING-1", "12345678")
|
||||
if err != nil {
|
||||
t.Fatalf("分配 Token 失败: %v", err)
|
||||
}
|
||||
if assigned.Status != model.RockStatusUsing {
|
||||
t.Fatalf("分配后状态应为 using,实际 %s", assigned.Status)
|
||||
}
|
||||
if assigned.UsingAt == nil {
|
||||
t.Fatalf("分配后 using_at 必须写入")
|
||||
}
|
||||
|
||||
var stored model.RockToken
|
||||
if err := db.First(&stored, assigned.ID).Error; err != nil {
|
||||
t.Fatalf("重新读取 Token 失败: %v", err)
|
||||
}
|
||||
if stored.UsingAt == nil {
|
||||
t.Fatalf("数据库中 using_at 未写入")
|
||||
}
|
||||
|
||||
if err := svc.ReleaseUnboundUsingToken(context.Background(), assigned.ID); err != nil {
|
||||
t.Fatalf("释放未绑定 Token 失败: %v", err)
|
||||
}
|
||||
var released model.RockToken
|
||||
if err := db.First(&released, assigned.ID).Error; err != nil {
|
||||
t.Fatalf("读取释放后的 Token 失败: %v", err)
|
||||
}
|
||||
if released.Status != model.RockStatusAvailable {
|
||||
t.Fatalf("释放后状态应为 available,实际 %s", released.Status)
|
||||
}
|
||||
if released.UsingAt != nil {
|
||||
t.Fatalf("释放回库存后 using_at 必须清空,实际 %v", released.UsingAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPackageDetail_SetsUsingAtFromClaimedAt(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
var detail RockPackageDetailResponse
|
||||
raw := `{
|
||||
"package": {"id": 9001, "share_code": "share-using"},
|
||||
"tokens": [
|
||||
{"id": 30001, "token": "t1", "redeem_code": "c1", "color": "black", "status": "using", "created_at": "2026-10-27 23:31:59"}
|
||||
]
|
||||
}`
|
||||
if err := json.Unmarshal([]byte(raw), &detail); err != nil {
|
||||
t.Fatalf("解析包详情失败: %v", err)
|
||||
}
|
||||
|
||||
inserted, _, _, err := svc.importPackageDetail(context.Background(), &detail, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("导入包详情失败: %v", err)
|
||||
}
|
||||
if inserted != 1 {
|
||||
t.Fatalf("期望新增 1 条,实际 %d", inserted)
|
||||
}
|
||||
|
||||
var row model.RockToken
|
||||
if err := db.Where("upstream_token_id = ?", "30001").First(&row).Error; err != nil {
|
||||
t.Fatalf("查询导入 Token 失败: %v", err)
|
||||
}
|
||||
if row.Status != model.RockStatusUsing {
|
||||
t.Fatalf("期望状态 using,实际 %s", row.Status)
|
||||
}
|
||||
if row.UsingAt == nil {
|
||||
t.Fatalf("上游状态为 using 时必须写入 using_at")
|
||||
}
|
||||
expected := time.Date(2026, 10, 27, 23, 31, 59, 0, timeutil.Location())
|
||||
if !row.UsingAt.Equal(expected) {
|
||||
t.Fatalf("using_at 应取上游 claimed_at,实际 %v", row.UsingAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfig_UsingWarningDaysValidation(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, err := svc.GetConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("获取配置失败: %v", err)
|
||||
}
|
||||
if cfg.UsingWarnDays != 3 || cfg.UsingDangerDays != 5 {
|
||||
t.Fatalf("默认阈值应为 3/5,实际 %d/%d", cfg.UsingWarnDays, cfg.UsingDangerDays)
|
||||
}
|
||||
|
||||
warnSix := 6
|
||||
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnSix}); err == nil {
|
||||
t.Fatalf("橙色预警 6 天大于默认红色 5 天时必须报错")
|
||||
}
|
||||
|
||||
warnFour, dangerFour := 4, 4
|
||||
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnFour, UsingDangerDays: &dangerFour}); err == nil {
|
||||
t.Fatalf("红色预警不大于橙色预警时必须报错")
|
||||
}
|
||||
|
||||
warnZero := 0
|
||||
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnZero}); err == nil {
|
||||
t.Fatalf("预警天数小于 1 时必须报错")
|
||||
}
|
||||
|
||||
warnTwo, dangerFourAgain := 2, 4
|
||||
updated, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnTwo, UsingDangerDays: &dangerFourAgain})
|
||||
if err != nil {
|
||||
t.Fatalf("合法阈值保存失败: %v", err)
|
||||
}
|
||||
if updated.UsingWarnDays != 2 || updated.UsingDangerDays != 4 {
|
||||
t.Fatalf("阈值未保存,实际 %d/%d", updated.UsingWarnDays, updated.UsingDangerDays)
|
||||
}
|
||||
}
|
||||
@@ -628,8 +628,8 @@ stateDiagram-v2
|
||||
|
||||
**R30:迁移是显式 SQL + checksum + advisory lock,只前进不回滚。**
|
||||
|
||||
- 16 个迁移按文件名排序执行;`schema_migrations(version, name, checksum, applied_at)`;已执行迁移 checksum 不一致直接启动失败;整个迁移在事务内,并用 `pg_advisory_xact_lock` 防并发(`database/migrate.go:31-116`)。
|
||||
- 已执行迁移不可修改;`015_rock_packages_final.sql` 是 Rock 打包/采购字段的合并最终态(原 015/016/017);`016_rock_black_token_cost.sql` 给 `rock_configs` 增加 `black_token_cost`,后续新增变更必须从 `017_*.sql` 开始(`docs/项目分析.md:228-229`)。
|
||||
- 18 个迁移按文件名排序执行;`schema_migrations(version, name, checksum, applied_at)`;已执行迁移 checksum 不一致直接启动失败;整个迁移在事务内,并用 `pg_advisory_xact_lock` 防并发(`database/migrate.go:31-116`)。
|
||||
- 已执行迁移不可修改;`015_rock_packages_final.sql` 是 Rock 打包/采购字段的合并最终态(原 015/016/017);`016_rock_black_token_cost.sql` 给 `rock_configs` 增加 `black_token_cost`;`017_rock_using_warning.sql` 给 `rock_tokens` 增加 `using_at`、给 `rock_configs` 增加使用中超时预警天数;`018_rock_package_share_code.sql` 给 `rock_tokens` 增加 `package_share_code`,后续新增变更必须从 `019_*.sql` 开始(`docs/项目分析.md:228-229`)。
|
||||
|
||||
**R31:核心表没有数据库外键。**
|
||||
|
||||
|
||||
@@ -575,6 +575,8 @@ export interface RockConfig {
|
||||
black_token_cost: number;
|
||||
min_points_warning: number;
|
||||
token_warning_stock: number;
|
||||
using_warn_days: number;
|
||||
using_danger_days: number;
|
||||
auto_purchase_enabled: boolean;
|
||||
auto_purchase_count: number;
|
||||
purchase_cooldown_minutes: number;
|
||||
@@ -607,10 +609,29 @@ export interface RockDashboard {
|
||||
cost_updated: boolean;
|
||||
}
|
||||
|
||||
export interface TokenBindingRefreshResult {
|
||||
status: string;
|
||||
bound: boolean;
|
||||
role_name?: string;
|
||||
role_id?: string;
|
||||
channel?: string;
|
||||
changed: boolean;
|
||||
warning: boolean;
|
||||
message: string;
|
||||
redeemed_at?: string;
|
||||
}
|
||||
|
||||
export interface TokenPackageLink {
|
||||
package_id: number;
|
||||
share_code: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RockTokenItem {
|
||||
id: number;
|
||||
upstream_token_id: string;
|
||||
package_id?: number;
|
||||
package_share_code?: string;
|
||||
token?: string;
|
||||
redeem_code: string;
|
||||
color: string;
|
||||
@@ -632,6 +653,7 @@ export interface RockTokenItem {
|
||||
remark?: string;
|
||||
raw_data?: string;
|
||||
claimed_at?: string;
|
||||
using_at?: string;
|
||||
bound_at?: string;
|
||||
redeemed_at?: string;
|
||||
created_at: string;
|
||||
@@ -684,6 +706,14 @@ export const rockApi = {
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/release`)
|
||||
.then((r) => r.data.data),
|
||||
refreshTokenBinding: (id: number) =>
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/refresh-binding`)
|
||||
.then((r) => r.data.data as TokenBindingRefreshResult),
|
||||
getTokenPackageLink: (id: number) =>
|
||||
request
|
||||
.get(`/platform/rock/tokens/${id}/package-link`)
|
||||
.then((r) => r.data.data as TokenPackageLink),
|
||||
updateRemark: (id: number, data: { remark: string; status?: string }) =>
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/remark`, data)
|
||||
|
||||
@@ -73,6 +73,8 @@ export default function PlatformRock() {
|
||||
bearer_token: cfg.bearer_token,
|
||||
min_points_warning: cfg.min_points_warning,
|
||||
token_warning_stock: cfg.token_warning_stock,
|
||||
using_warn_days: cfg.using_warn_days ?? 3,
|
||||
using_danger_days: cfg.using_danger_days ?? 5,
|
||||
auto_purchase_count: cfg.auto_purchase_count,
|
||||
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
|
||||
})
|
||||
@@ -262,6 +264,9 @@ export default function PlatformRock() {
|
||||
const hasMismatch = (stats?.mismatch_stock ?? 0) > 0
|
||||
const upstreamPoints = stats?.upstream_points ?? 0
|
||||
const tokenCost = config?.black_token_cost && config.black_token_cost > 0 ? config.black_token_cost : 20
|
||||
const usingWarnDays = config?.using_warn_days && config.using_warn_days > 0 ? config.using_warn_days : 3
|
||||
const usingDangerDays =
|
||||
config?.using_danger_days && config.using_danger_days > usingWarnDays ? config.using_danger_days : 5
|
||||
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
|
||||
const estimatedCost = (purchaseCount || 0) * tokenCost
|
||||
const isPointsInsufficient = estimatedCost > upstreamPoints
|
||||
@@ -348,6 +353,8 @@ export default function PlatformRock() {
|
||||
<RockTokenTable
|
||||
tokens={tokens}
|
||||
loadingTokens={loadingTokens}
|
||||
usingWarnDays={usingWarnDays}
|
||||
usingDangerDays={usingDangerDays}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalTokens={totalTokens}
|
||||
|
||||
@@ -258,6 +258,24 @@ export function RockStrategyCard({
|
||||
<InputNumber min={1} max={60} style={{ width: '100%' }} addonAfter="分钟" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="using_warn_days"
|
||||
label="使用中橙色预警"
|
||||
tooltip="扫码使用中的 Token 超过该天数后,状态标签变为橙色"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="using_danger_days"
|
||||
label="使用中红色预警"
|
||||
tooltip="必须大于橙色预警天数;超过后状态标签变为红色"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Table, message } from 'antd'
|
||||
import type { RockTokenItem } from '../../api'
|
||||
import { rockApi } from '../../api'
|
||||
@@ -13,6 +14,8 @@ export interface RockTokenTableProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalTokens: number
|
||||
usingWarnDays: number
|
||||
usingDangerDays: number
|
||||
copyingVoucherId: number | null
|
||||
setCopyingVoucherId: Dispatch<SetStateAction<number | null>>
|
||||
setPreviewVoucherUrl: Dispatch<SetStateAction<string>>
|
||||
@@ -30,6 +33,8 @@ export function RockTokenTable({
|
||||
page,
|
||||
pageSize,
|
||||
totalTokens,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
copyingVoucherId,
|
||||
setCopyingVoucherId,
|
||||
setPreviewVoucherUrl,
|
||||
@@ -40,6 +45,52 @@ export function RockTokenTable({
|
||||
onReloadData,
|
||||
onReloadTokens,
|
||||
}: RockTokenTableProps) {
|
||||
const [refreshingTokenId, setRefreshingTokenId] = useState<number | null>(null)
|
||||
const [openingUpstreamId, setOpeningUpstreamId] = useState<number | null>(null)
|
||||
|
||||
const handleOpenUpstream = async (id: number) => {
|
||||
// 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。
|
||||
const win = window.open('about:blank', '_blank')
|
||||
setOpeningUpstreamId(id)
|
||||
try {
|
||||
const res = await rockApi.getTokenPackageLink(id)
|
||||
if (!win) {
|
||||
message.warning('浏览器拦截了新窗口,请允许弹窗后重试')
|
||||
return
|
||||
}
|
||||
win.opener = null
|
||||
win.location.href = res.url
|
||||
} catch (e) {
|
||||
win?.close()
|
||||
message.error('获取上游包链接失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setOpeningUpstreamId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshBinding = async (id: number) => {
|
||||
setRefreshingTokenId(id)
|
||||
try {
|
||||
const res = await rockApi.refreshTokenBinding(id)
|
||||
if (res.status === 'mismatch' || res.warning) {
|
||||
message.warning(res.message || '上游绑定状态需要人工核对')
|
||||
} else if (res.status === 'redeemed') {
|
||||
message.success(res.message || '上游已发货,本地状态已同步为已交付')
|
||||
} else if (res.bound) {
|
||||
const role = res.role_name || res.role_id || '-'
|
||||
message.success(res.message || `刷新成功:上游已绑定「${role}」`)
|
||||
} else {
|
||||
message.info(res.message || '上游仍未绑定,本地状态保持不变')
|
||||
}
|
||||
onReloadData()
|
||||
onReloadTokens()
|
||||
} catch (e) {
|
||||
message.error('刷新绑定状态失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setRefreshingTokenId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 人工重置解冻 Mismatch 或 Abnormal Token
|
||||
const handleResetToken = async (id: number) => {
|
||||
try {
|
||||
@@ -152,6 +203,12 @@ export function RockTokenTable({
|
||||
|
||||
const columns = buildTokenColumns({
|
||||
copyingVoucherId,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
refreshingTokenId,
|
||||
onRefreshBinding: handleRefreshBinding,
|
||||
openingUpstreamId,
|
||||
onOpenUpstream: handleOpenUpstream,
|
||||
onCopyVoucher: handleCopyVoucher,
|
||||
onPreviewVoucher: handlePreviewVoucher,
|
||||
onEditUid,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
|
||||
import { CopyOutlined, EyeOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons'
|
||||
import { CopyOutlined, ExportOutlined, EyeOutlined, StopOutlined, SyncOutlined, UndoOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { RockTokenItem } from '../../api'
|
||||
import { formatDateTime } from '../../utils/time'
|
||||
@@ -8,8 +8,25 @@ import type { UidEditState } from './types'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
// 满 72 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。
|
||||
function usingElapsedDays(usingAt?: string): number | null {
|
||||
if (!usingAt) return null
|
||||
const startedAt = new Date(usingAt).getTime()
|
||||
if (Number.isNaN(startedAt)) return null
|
||||
const diff = Date.now() - startedAt
|
||||
return diff > 0 ? diff / MS_PER_DAY : 0
|
||||
}
|
||||
|
||||
export interface TokenColumnHandlers {
|
||||
copyingVoucherId: number | null
|
||||
usingWarnDays: number
|
||||
usingDangerDays: number
|
||||
refreshingTokenId: number | null
|
||||
onRefreshBinding: (id: number) => void
|
||||
openingUpstreamId: number | null
|
||||
onOpenUpstream: (id: number) => void
|
||||
onCopyVoucher: (record: RockTokenItem) => void
|
||||
onPreviewVoucher: (record: RockTokenItem) => void
|
||||
onEditUid: (state: UidEditState) => void
|
||||
@@ -22,6 +39,12 @@ export interface TokenColumnHandlers {
|
||||
|
||||
export function buildTokenColumns({
|
||||
copyingVoucherId,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
refreshingTokenId,
|
||||
onRefreshBinding,
|
||||
openingUpstreamId,
|
||||
onOpenUpstream,
|
||||
onCopyVoucher,
|
||||
onPreviewVoucher,
|
||||
onEditUid,
|
||||
@@ -99,8 +122,8 @@ export function buildTokenColumns({
|
||||
{
|
||||
title: '库存状态',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (status) => {
|
||||
width: 170,
|
||||
render: (status, record) => {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return (
|
||||
@@ -108,12 +131,31 @@ export function buildTokenColumns({
|
||||
● 可用库存
|
||||
</Tag>
|
||||
)
|
||||
case 'using':
|
||||
case 'using': {
|
||||
const days = usingElapsedDays(record.using_at)
|
||||
if (days !== null && days >= usingDangerDays) {
|
||||
return (
|
||||
<Tag
|
||||
color="error"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
|
||||
>
|
||||
⚠️ 使用中超时 {days.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (days !== null && days >= usingWarnDays) {
|
||||
return (
|
||||
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中 · {days.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'bound':
|
||||
return (
|
||||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
@@ -154,39 +196,57 @@ export function buildTokenColumns({
|
||||
},
|
||||
{
|
||||
title: '流转时间',
|
||||
width: 175,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<div style={{ fontSize: 12, color: '#334155' }}>
|
||||
<span style={{ color: '#94a3b8' }}>入库: </span>
|
||||
{formatDateTime(record.created_at)}
|
||||
</div>
|
||||
{record.mismatch_at && (
|
||||
<div style={{ fontSize: 11, color: '#e11d48' }}>
|
||||
<span>⚠️ 绑错: </span>
|
||||
{formatDateTime(record.mismatch_at)}
|
||||
width: 210,
|
||||
render: (_, record) => {
|
||||
const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null
|
||||
const usingColor =
|
||||
usingDays === null
|
||||
? '#64748b'
|
||||
: usingDays >= usingDangerDays
|
||||
? '#dc2626'
|
||||
: usingDays >= usingWarnDays
|
||||
? '#d97706'
|
||||
: '#0891b2'
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<div style={{ fontSize: 12, color: '#334155' }}>
|
||||
<span style={{ color: '#94a3b8' }}>入库: </span>
|
||||
{formatDateTime(record.created_at)}
|
||||
</div>
|
||||
)}
|
||||
{record.redeemed_at && (
|
||||
<div style={{ fontSize: 11, color: '#16a34a' }}>
|
||||
<span>✅ 发货: </span>
|
||||
{formatDateTime(record.redeemed_at)}
|
||||
</div>
|
||||
)}
|
||||
{!record.redeemed_at && record.bound_at && (
|
||||
<div style={{ fontSize: 11, color: '#0284c7' }}>
|
||||
<span>🔗 绑定: </span>
|
||||
{formatDateTime(record.bound_at)}
|
||||
</div>
|
||||
)}
|
||||
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
|
||||
<div style={{ fontSize: 11, color: '#6366f1' }}>
|
||||
<span>📦 提货: </span>
|
||||
{formatDateTime(record.claimed_at)}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
{record.status === 'using' && record.using_at && (
|
||||
<div style={{ fontSize: 11, color: usingColor }}>
|
||||
<span>⏳ 使用中: </span>
|
||||
{formatDateTime(record.using_at)}
|
||||
{usingDays !== null ? `(${usingDays.toFixed(1)} 天)` : ''}
|
||||
</div>
|
||||
)}
|
||||
{record.mismatch_at && (
|
||||
<div style={{ fontSize: 11, color: '#e11d48' }}>
|
||||
<span>⚠️ 绑错: </span>
|
||||
{formatDateTime(record.mismatch_at)}
|
||||
</div>
|
||||
)}
|
||||
{record.redeemed_at && (
|
||||
<div style={{ fontSize: 11, color: '#16a34a' }}>
|
||||
<span>✅ 发货: </span>
|
||||
{formatDateTime(record.redeemed_at)}
|
||||
</div>
|
||||
)}
|
||||
{!record.redeemed_at && record.bound_at && (
|
||||
<div style={{ fontSize: 11, color: '#0284c7' }}>
|
||||
<span>🔗 绑定: </span>
|
||||
{formatDateTime(record.bound_at)}
|
||||
</div>
|
||||
)}
|
||||
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
|
||||
<div style={{ fontSize: 11, color: '#6366f1' }}>
|
||||
<span>📦 提货: </span>
|
||||
{formatDateTime(record.claimed_at)}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '关联业务工单',
|
||||
@@ -381,12 +441,31 @@ export function buildTokenColumns({
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const refreshButton = (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
loading={refreshingTokenId === record.id}
|
||||
onClick={() => onRefreshBinding(record.id)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
)
|
||||
if (record.status === 'mismatch') {
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
{refreshButton}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ExportOutlined />}
|
||||
loading={openingUpstreamId === record.id}
|
||||
onClick={() => onOpenUpstream(record.id)}
|
||||
>
|
||||
跳转上游
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -446,21 +525,30 @@ export function buildTokenColumns({
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
|
||||
if (record.status === 'using') {
|
||||
const canRelease = !record.bound_at && !record.actual_role_id
|
||||
return (
|
||||
<Popconfirm
|
||||
title="释放这条未绑定的提货链接?"
|
||||
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
|
||||
okText="确认释放"
|
||||
cancelText="取消"
|
||||
onConfirm={() => onReleaseToken(record.id)}
|
||||
>
|
||||
<Button size="small" icon={<UndoOutlined />}>
|
||||
释放回库存
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Space direction="vertical" size={4}>
|
||||
{refreshButton}
|
||||
{canRelease && (
|
||||
<Popconfirm
|
||||
title="释放这条未绑定的提货链接?"
|
||||
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
|
||||
okText="确认释放"
|
||||
cancelText="取消"
|
||||
onConfirm={() => onReleaseToken(record.id)}
|
||||
>
|
||||
<Button size="small" icon={<UndoOutlined />}>
|
||||
释放回库存
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
if (record.status === 'bound') {
|
||||
return refreshButton
|
||||
}
|
||||
return <Text type="secondary">-</Text>
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user