From ef7f67dd0c79412c5a223b3150d52c31791f0257 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 5 Oct 2026 14:56:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E6=8E=A5=E5=85=A5Rock?= =?UTF-8?q?=E5=8F=91=E8=B4=A7=E5=B9=B3=E5=8F=B0(0666.pro)=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=B1=A5=E7=BA=A6=E3=80=81=E9=80=9A=E7=94=A8=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E6=B1=A0=E4=B8=8EUID=E5=BC=BA=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E9=A3=8E=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 数据库新增 012_rock_fulfillment 迁移,创建 rock_configs 配置表与 rock_tokens 通用库存表,补齐黑色ROCK长袖T恤与蓝色运动服上衣商品目录; 2. 封装 RockClient 与 RockService,实现 0666.pro 平台 Bearer Token 极简连通校验、批量采购50个入库、水位监控巡检与自动补货; 3. 新增 RockProvider 实现标准履约路由,引入 Anti-Ghost 旧绑定基线比对与 UID 强一致门禁,扫错号硬阻断并记录为 mismatch 状态; 4. 针对黑色ROCK与蓝色运动服实现动态发货(绑定后发货前动态选择 skin),并提取腾讯官方发货流水号 fulfillId 对账; 5. 新增全局自动发货总开关(Kill Switch),支持一键软熔断降级转人工接单处理; 6. 前端新增 PlatformRock 管理大屏,提供总开关切换、Token配置与连通测试、库存监控、一键补货及错单解冻重置。 --- backend/cmd/server/main.go | 17 + .../migrations/012_rock_fulfillment.sql | 110 +++ backend/internal/handler/rock.go | 125 ++++ backend/internal/model/rock.go | 79 +++ backend/internal/router/router.go | 12 + backend/internal/service/provider.go | 13 +- backend/internal/service/provider_rock.go | 274 ++++++++ .../internal/service/provider_rock_test.go | 373 +++++++++++ backend/internal/service/rock_client.go | 394 +++++++++++ backend/internal/service/rock_service.go | 632 ++++++++++++++++++ .../Rock发货平台代理接入与库存风控实施方案.md | 333 +++++++++ docs/rock发货平台(0666.pro)逆向与接口分析.md | 375 +++++++++++ frontend/src/App.tsx | 9 + frontend/src/api/index.ts | 67 ++ frontend/src/layouts/MainLayout.tsx | 1 + frontend/src/pages/PlatformRock.tsx | 609 +++++++++++++++++ 16 files changed, 3421 insertions(+), 2 deletions(-) create mode 100644 backend/internal/database/migrations/012_rock_fulfillment.sql create mode 100644 backend/internal/handler/rock.go create mode 100644 backend/internal/model/rock.go create mode 100644 backend/internal/service/provider_rock.go create mode 100644 backend/internal/service/provider_rock_test.go create mode 100644 backend/internal/service/rock_client.go create mode 100644 backend/internal/service/rock_service.go create mode 100644 docs/Rock发货平台代理接入与库存风控实施方案.md create mode 100644 docs/rock发货平台(0666.pro)逆向与接口分析.md create mode 100644 frontend/src/pages/PlatformRock.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0f46862..8318e92 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -96,6 +96,13 @@ func main() { }, nil) lewanProvider := service.NewLewanProvider(lewanClient) deliverySvc.RegisterProvider(lewanProvider) + + // Rock 平台 (0666.pro) 客户端、服务与履约提供商注册 + rockClient := service.NewRockClient("https://0666.pro") + rockSvc := service.NewRockService(db, rockClient) + rockProvider := service.NewRockProvider(rockSvc) + deliverySvc.RegisterProvider(rockProvider) + fulfillmentSvc.SetUpstreamCancel(deliverySvc.CancelUpstream) fulfillmentSvc.SetUpstreamSync(deliverySvc.SyncDeliveringOrder) @@ -122,6 +129,7 @@ func main() { CORSAllowedOrigins: cfg.CORSAllowedOrigins, LewanWebhook: handler.NewLewanWebhookHandler(fulfillmentSvc, db, cfg.LewanWebhookSecret, cfg.LewanApplicationID), PlatformLewan: handler.NewPlatformLewanHandler(merchantSvc, lewanClient), + Rock: handler.NewRockHandler(rockSvc), } go callbackSvc.Run(context.Background()) @@ -131,6 +139,15 @@ func main() { time.Duration(cfg.FulfillmentTimeoutScanIntervalSeconds)*time.Second, ) + // 定时巡检 Rock 库存低水位与自动补货(每5分钟) + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + rockSvc.CheckStockAndAutoPurchase(context.Background()) + } + }() + r := router.Setup(h) addr := ":" + cfg.Port applog.Infof("游戏皮肤供货平台 API 启动: http://localhost%s (日志级别: %s)", addr, applog.GetLevel()) diff --git a/backend/internal/database/migrations/012_rock_fulfillment.sql b/backend/internal/database/migrations/012_rock_fulfillment.sql new file mode 100644 index 0000000..7dc5888 --- /dev/null +++ b/backend/internal/database/migrations/012_rock_fulfillment.sql @@ -0,0 +1,110 @@ +-- ============================================================================ +-- 012_rock_fulfillment.sql: Rock 发货平台 (0666.pro) 代理接入与库存风控数据表与商品目录 +-- ============================================================================ + +-- 1) Rock 平台系统配置表 +CREATE TABLE IF NOT EXISTS rock_configs ( + id BIGINT PRIMARY KEY DEFAULT 1, + auto_delivery_enabled BOOLEAN NOT NULL DEFAULT true, + base_url VARCHAR(255) NOT NULL DEFAULT 'https://0666.pro', + bearer_token TEXT NOT NULL DEFAULT '', + username VARCHAR(128) NOT NULL DEFAULT '', + points NUMERIC(12, 2) NOT NULL DEFAULT 0.00, + min_points_warning NUMERIC(12, 2) NOT NULL DEFAULT 1050.00, + token_warning_stock INT NOT NULL DEFAULT 10, + auto_purchase_enabled BOOLEAN NOT NULL DEFAULT false, + auto_purchase_count INT NOT NULL DEFAULT 50, + purchase_cooldown_minutes INT NOT NULL DEFAULT 10, + last_purchased_at TIMESTAMPTZ, + last_heartbeat_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- 初始化默认配置行(固定 ID=1) +INSERT INTO rock_configs (id, auto_delivery_enabled, base_url, bearer_token, min_points_warning, token_warning_stock, auto_purchase_enabled, auto_purchase_count) +VALUES (1, true, 'https://0666.pro', '', 1050.00, 10, false, 50) +ON CONFLICT (id) DO NOTHING; + +-- 2) Rock 通用 Token 本地库存池表 +CREATE TABLE IF NOT EXISTS rock_tokens ( + id BIGSERIAL PRIMARY KEY, + upstream_token_id VARCHAR(64) NOT NULL DEFAULT '', + token TEXT NOT NULL, + redeem_code VARCHAR(64) NOT NULL, + color VARCHAR(32) NOT NULL DEFAULT 'black', + region VARCHAR(64) NOT NULL DEFAULT 'Q/V可用', + status VARCHAR(32) NOT NULL DEFAULT 'available', + assigned_order_no VARCHAR(128), + expected_uid VARCHAR(64), + actual_role_name VARCHAR(128), + actual_role_id VARCHAR(64), + actual_channel VARCHAR(64), + initial_bound_snapshot TEXT, + upstream_fulfill_id BIGINT, + target_skin VARCHAR(128), + target_currency VARCHAR(64) NOT NULL DEFAULT '幸运币', + target_currency_quantity INT NOT NULL DEFAULT 0, + mismatch_at TIMESTAMPTZ, + mismatch_reason VARCHAR(255), + claimed_at TIMESTAMPTZ, + bound_at TIMESTAMPTZ, + redeemed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_rock_tokens_status_color ON rock_tokens (status, color); +CREATE INDEX IF NOT EXISTS idx_rock_tokens_assigned_order ON rock_tokens (assigned_order_no); +CREATE INDEX IF NOT EXISTS idx_rock_tokens_redeem_code ON rock_tokens (redeem_code); +CREATE INDEX IF NOT EXISTS idx_rock_tokens_mismatch ON rock_tokens (status, mismatch_at); + +-- 3) 插入 Rock 皮肤到商品目录 (products 表与自营 merchant_products 表) +WITH rock_products(sku, name, category, default_price, fulfillment_config) AS ( + VALUES + ('rock_black_tshirt', '黑色ROCK长袖T恤', '服饰', 2500, '{"routing_mode":"rock","priority":["rock"],"delivery_type":"LINK_BINDING"}'), + ('rock_blue_sportswear', '蓝色运动服上衣', '服饰', 2500, '{"routing_mode":"rock","priority":["rock"],"delivery_type":"LINK_BINDING"}') +) +INSERT INTO products (code, name, category, status) +SELECT p.sku, p.name, p.category, 'active' +FROM rock_products p +ON CONFLICT (code) DO UPDATE +SET + name = EXCLUDED.name, + category = EXCLUDED.category, + status = 'active'; + +WITH rock_products(sku, name, category, default_price, fulfillment_config) AS ( + VALUES + ('rock_black_tshirt', '黑色ROCK长袖T恤', '服饰', 2500, '{"routing_mode":"rock","priority":["rock"],"delivery_type":"LINK_BINDING"}'), + ('rock_blue_sportswear', '蓝色运动服上衣', '服饰', 2500, '{"routing_mode":"rock","priority":["rock"],"delivery_type":"LINK_BINDING"}') +), +self_merchant AS ( + SELECT id FROM merchants WHERE code = 'self-operated' LIMIT 1 +) +INSERT INTO merchant_products ( + merchant_id, product_id, sku, display_name, + price_amount, cost_amount, currency, stock, status, fulfillment_config +) +SELECT + sm.id, + p.id, + rp.sku, + rp.name, + rp.default_price, + rp.default_price, + 'POINT', + -1, + 'active', + rp.fulfillment_config +FROM rock_products rp +JOIN products p ON p.code = rp.sku +CROSS JOIN self_merchant sm +ON CONFLICT (merchant_id, sku) DO UPDATE +SET + display_name = EXCLUDED.display_name, + price_amount = EXCLUDED.price_amount, + cost_amount = EXCLUDED.cost_amount, + fulfillment_config = EXCLUDED.fulfillment_config, + status = 'active', + deleted_at = NULL; diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go new file mode 100644 index 0000000..892a354 --- /dev/null +++ b/backend/internal/handler/rock.go @@ -0,0 +1,125 @@ +package handler + +import ( + "strconv" + "strings" + + "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" + + "github.com/gin-gonic/gin" +) + +type RockHandler struct { + rockSvc *service.RockService +} + +func NewRockHandler(rockSvc *service.RockService) *RockHandler { + return &RockHandler{rockSvc: rockSvc} +} + +// GetConfig 获取 Rock 平台配置 +func (h *RockHandler) GetConfig(c *gin.Context) { + cfg, err := h.rockSvc.GetConfig(c.Request.Context()) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, cfg) +} + +// UpdateConfig 更新 Rock 平台配置 +func (h *RockHandler) UpdateConfig(c *gin.Context) { + var req service.UpdateRockConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数解析失败") + return + } + cfg, err := h.rockSvc.UpdateConfig(c.Request.Context(), req) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, cfg) +} + +type testConnReq struct { + BearerToken string `json:"bearer_token"` +} + +// TestConnection 测试上游 Token 连通性 +func (h *RockHandler) TestConnection(c *gin.Context) { + var req testConnReq + _ = c.ShouldBindJSON(&req) + me, err := h.rockSvc.TestConnection(c.Request.Context(), req.BearerToken) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, me) +} + +// GetStats 获取概览数据与库存统计 +func (h *RockHandler) GetStats(c *gin.Context) { + stats, err := h.rockSvc.GetStats(c.Request.Context()) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, stats) +} + +type purchaseReq struct { + Count int `json:"count"` +} + +// PurchaseTokens 手动采购并入库 Token +func (h *RockHandler) PurchaseTokens(c *gin.Context) { + var req purchaseReq + if err := c.ShouldBindJSON(&req); err != nil || req.Count <= 0 { + req.Count = 50 + } + tokens, err := h.rockSvc.PurchaseTokens(c.Request.Context(), req.Count, false) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{ + "purchased_count": len(tokens), + "message": "采购成功,已批量入库", + }) +} + +// ListTokens 分页获取 Token 库存列表 +func (h *RockHandler) ListTokens(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := strings.TrimSpace(c.Query("status")) + search := strings.TrimSpace(c.Query("search")) + + tokens, total, err := h.rockSvc.ListTokens(c.Request.Context(), page, pageSize, status, search) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, gin.H{ + "list": tokens, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// ResetToken 客服人工重置 mismatch 状态的 Token +func (h *RockHandler) ResetToken(c *gin.Context) { + idUint, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "无效的 Token ID") + return + } + if err := h.rockSvc.ResetMismatchToken(c.Request.Context(), uint(idUint)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "重置成功,Token 已重新释放至可用库存"}) +} diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go new file mode 100644 index 0000000..4de247c --- /dev/null +++ b/backend/internal/model/rock.go @@ -0,0 +1,79 @@ +package model + +import ( + "time" +) + +// Rock 相关常量 +const ( + RockColorBlack = "black" + RockColorBlue = "blue" + + RockStatusAvailable = "available" // 可用库存 + RockStatusUsing = "using" // 已分配给订单,正在提货扫码 + RockStatusBound = "bound" // 扫码绑定成功 + RockStatusRedeemed = "redeemed" // 发货完成 + RockStatusMismatch = "mismatch" // UID 校验不符阻断(绑错) + RockStatusAbnormal = "abnormal" // 异常/作废 + + RockSKUBlackTshirt = "rock_black_tshirt" + RockSKUBlueSportswear = "rock_blue_sportswear" + + RockSkinBlackTshirt = "黑色ROCK长袖T恤" + RockSkinBlueSportswear = "蓝色运动服上衣" +) + +// RockConfig 平台配置项 +type RockConfig struct { + ID uint `gorm:"primaryKey;default:1" json:"id"` + AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关 + BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` + BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"` + Username string `gorm:"size:128;not null;default:''" json:"username"` + Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"` + MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"` + TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"` + 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"` + LastPurchasedAt *time.Time `json:"last_purchased_at"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (RockConfig) TableName() string { + return "rock_configs" +} + +// RockToken 通用 Token 本地库存池记录 +type RockToken struct { + ID uint `gorm:"primaryKey" json:"id"` + UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"` + Token string `gorm:"type:text;not null" json:"-"` + RedeemCode string `gorm:"size:64;not null;index" json:"redeem_code"` + Color string `gorm:"size:32;not null;default:'black';index" json:"color"` + Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"` + Status string `gorm:"size:32;not null;default:'available';index" json:"status"` + AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"` + ExpectedUID string `gorm:"size:64" json:"expected_uid"` + ActualRoleName string `gorm:"size:128" json:"actual_role_name"` + ActualRoleID string `gorm:"size:64" json:"actual_role_id"` + ActualChannel string `gorm:"size:64" json:"actual_channel"` + InitialBoundSnapshot string `gorm:"type:text" json:"-"` // 分配时初始旧数据快照 JSON + UpstreamFulfillID int64 `json:"upstream_fulfill_id"` + TargetSkin string `gorm:"size:128" json:"target_skin"` + TargetCurrency string `gorm:"size:64;not null;default:'幸运币'" json:"target_currency"` + TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"` + MismatchAt *time.Time `gorm:"index" json:"mismatch_at"` + MismatchReason string `gorm:"size:255" json:"mismatch_reason"` + ClaimedAt *time.Time `json:"claimed_at"` + BoundAt *time.Time `json:"bound_at"` + RedeemedAt *time.Time `json:"redeemed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (RockToken) TableName() string { + return "rock_tokens" +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 77e9ec8..8f88a1d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -41,6 +41,7 @@ type Handlers struct { LewanWebhook *handler.LewanWebhookHandler PlatformLewan *handler.PlatformLewanHandler + Rock *handler.RockHandler } // defaultCORSOrigins 未配置 CORS_ALLOWED_ORIGINS 时的本地开发默认来源。 @@ -304,6 +305,17 @@ func Setup(h *Handlers) *gin.Engine { admin.POST("/platform/lewan/batch-sync-catalog", h.PlatformLewan.BatchSyncLewanProducts) admin.PUT("/platform/products/:id/routing", h.PlatformLewan.UpdateProductRouting) } + + // Rock平台 (0666.pro) 自动发货与库存风控管理 + if h.Rock != nil { + admin.GET("/platform/rock/config", h.Rock.GetConfig) + admin.PUT("/platform/rock/config", h.Rock.UpdateConfig) + admin.POST("/platform/rock/test-conn", h.Rock.TestConnection) + admin.GET("/platform/rock/stats", h.Rock.GetStats) + admin.POST("/platform/rock/purchase", h.Rock.PurchaseTokens) + admin.GET("/platform/rock/tokens", h.Rock.ListTokens) + admin.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken) + } } auth.POST("/upload", h.Recharge.Upload) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index eb117a2..72e34ad 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -12,10 +12,12 @@ import ( const ( ProviderDLC = "dlc" ProviderLewan = "lewan" + ProviderRock = "rock" RoutingModePriority = "priority" RoutingModeLewan = "lewan" RoutingModeDLC = "dlc" + RoutingModeRock = "rock" ) // ProductRoutingConfig 商品履约配置 @@ -62,9 +64,12 @@ func ParseProductRoutingConfig(fulfillmentConfig string, sku string) ProductRout cfg.DLCGoodID = builtinDLC } - // 若未配置 RoutingMode:双渠道优先 priority,单渠道按已有 ID / legacy provider 决定 + // 若未配置 RoutingMode:优先判断是否为 Rock 渠道专属商品 if cfg.RoutingMode == "" { - if cfg.LewanProductID != "" && cfg.DLCGoodID != "" { + if strings.HasPrefix(sku, "rock_") { + cfg.RoutingMode = RoutingModeRock + cfg.Priority = []string{ProviderRock} + } else if cfg.LewanProductID != "" && cfg.DLCGoodID != "" { cfg.RoutingMode = RoutingModePriority cfg.Priority = []string{ProviderLewan, ProviderDLC} } else if cfg.LewanProductID != "" || legacyProvider == ProviderLewan { @@ -199,6 +204,10 @@ func (r *FulfillmentRouter) ResolveProviders(order *model.FulfillmentOrder) ([]F if p, ok := r.providers[ProviderDLC]; ok { list = append(list, p) } + case RoutingModeRock: + if p, ok := r.providers[ProviderRock]; ok { + list = append(list, p) + } case RoutingModePriority: for _, name := range cfg.Priority { if p, ok := r.providers[name]; ok { diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go new file mode 100644 index 0000000..c7fde33 --- /dev/null +++ b/backend/internal/service/provider_rock.go @@ -0,0 +1,274 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "affiliate_dash/internal/model" +) + +// RockProvider 实现 Rock 发货平台履约逻辑 +type RockProvider struct { + rockService *RockService +} + +func NewRockProvider(rockService *RockService) *RockProvider { + return &RockProvider{ + rockService: rockService, + } +} + +func (p *RockProvider) Name() string { + return ProviderRock +} + +func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) { + if p.rockService == nil { + return nil, errors.New("Rock 发货服务未初始化") + } + + cfg, err := p.rockService.GetConfig(ctx) + if err != nil { + return nil, fmt.Errorf("读取 Rock 配置失败: %w", err) + } + + // 1. 全局软熔断总开关检查 (Kill Switch) + if !cfg.AutoDeliveryEnabled { + return &DeliveryOrderState{ + CanShip: false, + CannotShipReason: "当前该商品自动发货通道维护中,已转交人工客服/打手为您处理", + Provider: ProviderRock, + }, nil + } + + // 2. 检查可用库存 + stats, err := p.rockService.GetStats(ctx) + if err != nil { + return nil, fmt.Errorf("获取库存失败: %w", err) + } + if stats.AvailableStock <= 0 { + return &DeliveryOrderState{ + CanShip: false, + CannotShipReason: "当前商品库存补充中,已通知管理员自动补货,请稍后刷新或联系客服", + Provider: ProviderRock, + }, nil + } + + sku := "" + productName := "和平精英道具" + if order != nil { + sku = order.ProductSKU + if order.MerchantProduct != nil && order.MerchantProduct.DisplayName != "" { + productName = order.MerchantProduct.DisplayName + } + } + if openOrder != nil && openOrder.Product != nil && openOrder.Product.Name != "" { + productName = openOrder.Product.Name + } + + deliveryProd := &DeliveryProduct{ + Name: productName, + SKU: sku, + Game: "和平精英", + } + + return &DeliveryOrderState{ + CanShip: true, + Product: deliveryProd, + Provider: ProviderRock, + }, nil +} + +func (p *RockProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) { + if p.rockService == nil { + return nil, errors.New("Rock 发货服务未初始化") + } + + trimmedUID := strings.TrimSpace(gameAccount) + if trimmedUID == "" { + return nil, errors.New("请先填写正确的游戏数字账号(UID)") + } + + // 分配或锁定一个 Token 并获取上游活动链接 + token, detail, err := p.rockService.AssignTokenForOrder(ctx, order.OrderNo, trimmedUID) + if err != nil { + return nil, err + } + + patch := map[string]interface{}{ + "provider": ProviderRock, + "rock_token_id": token.ID, + "rock_redeem_code": token.RedeemCode, + "expected_uid": trimmedUID, + } + + bindURL := strings.TrimSpace(detail.BindURL) + qrURL := detail.QRURL + if qrURL == "" && bindURL != "" { + qrURL = qrCodeURL(bindURL) + } + + return &ProviderBindResult{ + BindUUID: fmt.Sprintf("%d", token.ID), + BindURL: bindURL, + QRURL: qrURL, + Bound: false, + ExpectedGameAccount: trimmedUID, + Message: "请使用手机微信/QQ扫描上方二维码绑定游戏角色", + Provider: ProviderRock, + PatchResultData: patch, + }, nil +} + +func (p *RockProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) { + if p.rockService == nil { + return nil, errors.New("Rock 发货服务未初始化") + } + + tokenIDUint, err := strconv.ParseUint(strings.TrimSpace(bindUUID), 10, 64) + if err != nil { + return nil, fmt.Errorf("无效的 bind_uuid: %s", bindUUID) + } + + expectedUID := resultDataString(order.ResultData, "expected_uid") + if expectedUID == "" { + expectedUID = resultDataString(order.RequestData, "game_account") + } + if expectedUID == "" { + expectedUID = resultDataString(order.ResultData, "game_account") + } + + res, err := p.rockService.CheckBindingAndVerifyUID(ctx, uint(tokenIDUint), expectedUID) + if err != nil { + return nil, err + } + + patch := map[string]interface{}{ + "provider": ProviderRock, + } + if res.RoleName != "" { + patch["role_name"] = res.RoleName + } + if res.Channel != "" { + patch["game_channel"] = res.Channel + } + if res.RoleID != "" { + patch["actual_role_id"] = res.RoleID + } + + return &ProviderBindResult{ + BindUUID: bindUUID, + Bound: res.Bound, + RoleName: res.RoleName, + GameAccount: res.RoleID, + GameChannel: res.Channel, + ExpectedGameAccount: res.ExpectedUID, + Mismatch: res.Mismatch, + Message: res.Message, + Provider: ProviderRock, + PatchResultData: patch, + }, nil +} + +func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) { + if p.rockService == nil { + return nil, errors.New("Rock 发货服务未初始化") + } + + sku := order.ProductSKU + expectedUID := strings.TrimSpace(gameAccount) + if expectedUID == "" { + expectedUID = resultDataString(order.ResultData, "expected_uid") + } + + redeemResp, err := p.rockService.RedeemTokenForOrder(ctx, order.OrderNo, sku, expectedUID) + if err != nil { + return nil, err + } + + fulfillIDStr := "" + if redeemResp.Delivery.FulfillID > 0 { + fulfillIDStr = fmt.Sprintf("%d", redeemResp.Delivery.FulfillID) + } + + patch := map[string]interface{}{ + "provider": ProviderRock, + "provider_order_no": fulfillIDStr, + "fulfill_id": redeemResp.Delivery.FulfillID, + "skin": redeemResp.Delivery.Skin, + "currency": redeemResp.Delivery.Currency, + "currency_quantity": redeemResp.Delivery.Quantity, + } + + return &ProviderSubmitResult{ + ProviderOrderNo: fulfillIDStr, + NextStatus: model.OrderStatusDelivered, + Message: "发货成功!道具及附赠货币已发放至游戏,请登录游戏邮件查收", + GameAccount: map[string]interface{}{ + "role_name": resultDataString(order.ResultData, "role_name"), + "game_channel": resultDataString(order.ResultData, "game_channel"), + "role_id": expectedUID, + }, + PatchResultData: patch, + }, nil +} + +func (p *RockProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) { + if p.rockService == nil { + return nil, errors.New("Rock 发货服务未初始化") + } + + var token model.RockToken + err := p.rockService.db.WithContext(ctx). + Where("assigned_order_no = ?", order.OrderNo). + Order("id DESC"). + First(&token).Error + if err != nil { + return &ProviderSyncResult{ + Status: "", + }, nil + } + + switch token.Status { + case model.RockStatusRedeemed: + return &ProviderSyncResult{ + Status: "success", + ProviderOrderNo: fmt.Sprintf("%d", token.UpstreamFulfillID), + GameUID: token.ActualRoleID, + RoleName: token.ActualRoleName, + RoleArea: token.ActualChannel, + }, nil + case model.RockStatusMismatch: + return &ProviderSyncResult{ + Status: "failed", + FailReason: token.MismatchReason, + }, nil + default: + return &ProviderSyncResult{ + Status: "delivering", + }, nil + } +} + +func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error { + if p.rockService == nil { + return nil + } + + // 若订单尚未扫码绑定成功,将处于 using 状态的 Token 安全重置回 available + var token model.RockToken + err := p.rockService.db.WithContext(ctx). + Where("assigned_order_no = ? AND status = ?", order.OrderNo, model.RockStatusUsing). + First(&token).Error + if err == nil { + _ = p.rockService.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + }).Error + } + return nil +} diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go new file mode 100644 index 0000000..106b130 --- /dev/null +++ b/backend/internal/service/provider_rock_test.go @@ -0,0 +1,373 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/testdb" + "gorm.io/gorm" +) + +func newRockTestDB(t *testing.T) *gorm.DB { + t.Helper() + return testdb.New(t, + &model.Merchant{}, + &model.Product{}, + &model.MerchantProduct{}, + &model.FulfillmentOrder{}, + &model.RockConfig{}, + &model.RockToken{}, + ) +} + +func TestRockProvider_KillSwitch(t *testing.T) { + db := newRockTestDB(t) + client := NewRockClient("https://example.com") + rockSvc := NewRockService(db, client) + provider := NewRockProvider(rockSvc) + + // 1. 初始化配置并关闭总开关 + cfg, err := rockSvc.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get config: %v", err) + } + f := false + _, err = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ + AutoDeliveryEnabled: &f, + }) + if err != nil { + t.Fatalf("update config: %v", err) + } + + order := &model.FulfillmentOrder{ + OrderNo: "ROCK-ORDER-001", + ProductSKU: model.RockSKUBlackTshirt, + } + + // 2. PrepareDelivery 检查是否触发熔断 + state, err := provider.PrepareDelivery(context.Background(), order, nil) + if err != nil { + t.Fatalf("prepare delivery: %v", err) + } + if state.CanShip { + t.Fatalf("总开关关闭时应该不可发货") + } + if !strings.Contains(state.CannotShipReason, "维护中") { + t.Fatalf("熔断提示信息不符: %s", state.CannotShipReason) + } + + // 3. 恢复开启总开关 + tr := true + _, _ = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ + AutoDeliveryEnabled: &tr, + }) + _ = cfg +} + +func TestRockProvider_AntiGhostAndUIDMismatch(t *testing.T) { + db := newRockTestDB(t) + + // Mock 上游 0666.pro 响应 + var currentBoundAccount string + var currentBoundRoleID string + var currentBindChangedAt string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasPrefix(r.URL.Path, "/api/redeem/") { + _ = json.NewEncoder(w).Encode(RockRedeemDetailResponse{ + ID: 151153, + RedeemCode: "mock_redeem_code", + Color: "black", + Status: "available", + BindURL: "https://gp.qq.com/cp/mock_bind", + }) + return + } + if strings.HasSuffix(r.URL.Path, "/refresh") { + var resp RockRefreshResponse + if currentBoundRoleID != "" { + resp.Binding.Bound = true + resp.Binding.RoleName = currentBoundAccount + resp.Binding.RoleID = currentBoundRoleID + resp.Binding.Channel = "安卓-微信" + resp.Token.BindChangedAt = currentBindChangedAt + } + _ = json.NewEncoder(w).Encode(resp) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := NewRockClient(server.URL) + rockSvc := NewRockService(db, client) + provider := NewRockProvider(rockSvc) + + // 预先插入 1 个可用 Token,模拟历史残留数据 + now := time.Now() + token := model.RockToken{ + UpstreamTokenID: "151153", + Token: "mock_jwt_token", + RedeemCode: "mock_redeem_code", + Color: model.RockColorBlack, + Status: model.RockStatusAvailable, + ClaimedAt: &now, + } + db.Create(&token) + + // 模拟场景 A:上游初始已有历史残留账号“总荒凉”(3751279035) + currentBoundAccount = "总荒凉" + currentBoundRoleID = "3751279035" + currentBindChangedAt = "2026-10-01 10:00:00" + + // 买家期望 UID 为 3688825401 + expectedUID := "3688825401" + order := &model.FulfillmentOrder{ + OrderNo: "ROCK-ORDER-002", + ProductSKU: model.RockSKUBlackTshirt, + ResultData: fmt.Sprintf(`{"expected_uid":"%s"}`, expectedUID), + } + + // 1. 买家点击生成绑定二维码 + bindRes, err := provider.Bind(context.Background(), order, expectedUID) + if err != nil { + t.Fatalf("bind error: %v", err) + } + if bindRes.BindURL == "" { + t.Fatalf("bind URL 不应为空") + } + + // 2. 买家尚未扫码,轮询 CheckBind:应当识别出历史残留并忽略,不报错也不发货 + check1, err := provider.CheckBind(context.Background(), order, bindRes.BindUUID) + if err != nil { + t.Fatalf("check bind: %v", err) + } + if check1.Bound { + t.Fatalf("买家尚未扫码,不应判定为绑定成功(被历史残留误导)") + } + + // 3. 买家扫错了号!拿了微信小号扫码,实际角色变成了“错误小号”(9999999999) + currentBoundAccount = "错误小号" + currentBoundRoleID = "9999999999" + currentBindChangedAt = "2026-10-05 14:00:00" + + check2, err := provider.CheckBind(context.Background(), order, bindRes.BindUUID) + if err != nil { + t.Fatalf("check bind mismatch: %v", err) + } + if !check2.Mismatch { + t.Fatalf("UID 不匹配时必须标记 mismatch = true!") + } + if check2.Bound { + t.Fatalf("UID 不匹配时严禁 bound = true!") + } + + // 检查数据库中 Token 状态是否已跃迁为 mismatch + var dbToken model.RockToken + db.First(&dbToken, token.ID) + if dbToken.Status != model.RockStatusMismatch { + t.Fatalf("Token 状态应为 mismatch,当前为 %s", dbToken.Status) + } + if !strings.Contains(dbToken.MismatchReason, "不一致") { + t.Fatalf("mismatch reason 异常: %s", dbToken.MismatchReason) + } + + // 4. 客服确认买家输错,执行人工解冻重置 + err = rockSvc.ResetMismatchToken(context.Background(), token.ID) + if err != nil { + t.Fatalf("reset mismatch token: %v", err) + } + db.First(&dbToken, token.ID) + if dbToken.Status != model.RockStatusAvailable { + t.Fatalf("重置后状态应为 available,当前为 %s", dbToken.Status) + } +} + +func TestRockProvider_SubmitBlueAndBlack(t *testing.T) { + db := newRockTestDB(t) + + var receivedSkin string + var receivedCurrency string + var receivedAccount string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasPrefix(r.URL.Path, "/api/redeem/") { + var req map[string]string + _ = json.NewDecoder(r.Body).Decode(&req) + receivedSkin = req["skin"] + receivedCurrency = req["currency"] + receivedAccount = req["account"] + + resp := RockRedeemResponse{ + Success: true, + Message: "发放成功", + } + resp.Delivery.Skin = receivedSkin + resp.Delivery.Currency = receivedCurrency + resp.Delivery.Quantity = 10 + resp.Delivery.FulfillID = 888999 + _ = json.NewEncoder(w).Encode(resp) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewRockClient(server.URL) + rockSvc := NewRockService(db, client) + provider := NewRockProvider(rockSvc) + + // 场景 A:兑换蓝色运动服上衣 + now := time.Now() + tokenBlue := model.RockToken{ + UpstreamTokenID: "151154", + Token: "token_blue", + RedeemCode: "code_blue", + Color: model.RockColorBlack, // 通用黑色 Token + Status: model.RockStatusBound, + AssignedOrderNo: "ORDER-BLUE-001", + ExpectedUID: "4331027062", + ActualRoleName: "破浪小王子", + ActualRoleID: "4331027062", + BoundAt: &now, + } + db.Create(&tokenBlue) + + orderBlue := &model.FulfillmentOrder{ + OrderNo: "ORDER-BLUE-001", + ProductSKU: model.RockSKUBlueSportswear, // 蓝色运动服 + ResultData: `{"expected_uid":"4331027062"}`, + } + + submitRes, err := provider.Submit(context.Background(), orderBlue, "4331027062", fmt.Sprintf("%d", tokenBlue.ID)) + if err != nil { + t.Fatalf("submit blue error: %v", err) + } + + if receivedSkin != model.RockSkinBlueSportswear { + t.Fatalf("蓝色运动服发货应传入 %s,实际为 %s", model.RockSkinBlueSportswear, receivedSkin) + } + if receivedAccount != "破浪小王子" { + t.Fatalf("发货账号应为 破浪小王子,实际为 %s", receivedAccount) + } + if submitRes.ProviderOrderNo != "888999" { + t.Fatalf("期望官方 fulfillId 888999,实际为 %s", submitRes.ProviderOrderNo) + } + + // 检查数据库记录 + var dbToken model.RockToken + db.First(&dbToken, tokenBlue.ID) + if dbToken.Status != model.RockStatusRedeemed { + t.Fatalf("发货后 Token 状态应为 redeemed,当前为 %s", dbToken.Status) + } + if dbToken.UpstreamFulfillID != 888999 { + t.Fatalf("fulfillID 记录错误: %d", dbToken.UpstreamFulfillID) + } +} + +func TestRockService_StatsAndPurchase(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/me" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "user": map[string]interface{}{ + "id": 10028, + "username": "王大锤", + "role": "merchant", + "points": 8271.2, + "status": "active", + }, + }) + return + } + if r.URL.Path == "/api/user/tokens/claim" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tokens": []map[string]interface{}{ + { + "id": 20001, + "token": "token_mock_1", + "color": "black", + "region": "Q/V可用", + "status": "available", + "redeem_code": "code_mock_1", + "claimed_at": "2026-10-05 14:00:00", + }, + { + "id": 20002, + "token": "token_mock_2", + "color": "black", + "region": "Q/V可用", + "status": "available", + "redeem_code": "code_mock_2", + "claimed_at": "2026-10-05 14:00:00", + }, + }, + }) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewRockClient(server.URL) + rockSvc := NewRockService(db, client) + + // 1. 测试 Token 连通性 + me, err := rockSvc.TestConnection(context.Background(), "mock_bearer_token") + if err != nil { + t.Fatalf("test connection: %v", err) + } + if me.User.Username != "王大锤" || me.User.Points != 8271.2 { + t.Fatalf("用户信息解析不符: %+v", me.User) + } + + // 2. 更新系统配置设置 BearerToken + tokenStr := "mock_bearer_token" + _, err = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ + BearerToken: &tokenStr, + }) + if err != nil { + t.Fatalf("update config: %v", err) + } + + // 3. 采购 2 个黑色通用 Token + tokens, err := rockSvc.PurchaseTokens(context.Background(), 2, false) + if err != nil { + t.Fatalf("purchase tokens: %v", err) + } + if len(tokens) != 2 { + t.Fatalf("期望采购 2 个,实际返回 %d", len(tokens)) + } + + // 4. 检查库存统计 + stats, err := rockSvc.GetStats(context.Background()) + if err != nil { + t.Fatalf("get stats: %v", err) + } + if stats.AvailableStock != 2 { + t.Fatalf("期望可用库存为 2,实际为 %d", stats.AvailableStock) + } + if stats.Username != "王大锤" { + t.Fatalf("期望采购商户名为王大锤,实际为 %s", stats.Username) + } + + // 5. 分页查询列表 + list, total, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "") + if err != nil { + t.Fatalf("list tokens: %v", err) + } + if total != 2 || len(list) != 2 { + t.Fatalf("期望查询到 2 条,实际为 total=%d len=%d", total, len(list)) + } +} diff --git a/backend/internal/service/rock_client.go b/backend/internal/service/rock_client.go new file mode 100644 index 0000000..fe746c2 --- /dev/null +++ b/backend/internal/service/rock_client.go @@ -0,0 +1,394 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// RockClient 负责与 0666.pro 平台进行 HTTP 通信 +type RockClient struct { + baseURL string + httpClient *http.Client +} + +func NewRockClient(baseURL string) *RockClient { + base := strings.TrimRight(baseURL, "/") + if base == "" { + base = "https://0666.pro" + } + return &RockClient{ + baseURL: base, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +type RockMeResponse struct { + User struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + Points float64 `json:"points"` + Status string `json:"status"` + } `json:"user"` + Error string `json:"error,omitempty"` +} + +type RockClaimedToken struct { + ID int64 `json:"id"` + Token string `json:"token"` + Color string `json:"color"` + Region string `json:"region"` + Status string `json:"status"` + RedeemCode string `json:"redeem_code"` + ClaimedAt string `json:"claimed_at"` +} + +type RockClaimResponse struct { + Tokens []RockClaimedToken `json:"tokens"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +type RockRedeemDetailResponse struct { + ID int64 `json:"id"` + RedeemCode string `json:"redeem_code"` + Color string `json:"color"` + Status string `json:"status"` + BindURL string `json:"bindUrl"` + QRURL string `json:"qrUrl"` + ExpiresAt string `json:"expires_at"` + Error string `json:"error,omitempty"` +} + +type RockDeliveryItem struct { + SkinID int `json:"skinId"` + Skin string `json:"skin"` + CurrencyID int `json:"currencyId"` + Currency string `json:"currency"` + CurrencyQuantity int `json:"currencyQuantity"` + Time string `json:"time"` + Role string `json:"role"` + Message string `json:"message"` + FulfillID int64 `json:"fulfillId"` + Membership int `json:"membership"` +} + +type RockRefreshResponse struct { + Binding struct { + Bound bool `json:"bound"` + RoleName string `json:"roleName"` + RoleID string `json:"roleId"` + Channel string `json:"channel"` + } `json:"binding"` + Token struct { + ID int64 `json:"id"` + Status string `json:"status"` + BoundAccount string `json:"bound_account"` + BoundAccountID string `json:"bound_account_id"` + PreviousBoundAccount string `json:"previous_bound_account"` + BindChangedAt string `json:"bind_changed_at"` + BindChangeType string `json:"bind_change_type"` + } `json:"token"` + DeliveryList []RockDeliveryItem `json:"deliveryList"` + Error string `json:"error,omitempty"` +} + +type RockRedeemResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Delivery struct { + Skin string `json:"skin"` + Currency string `json:"currency"` + Quantity int `json:"quantity"` + FulfillID int64 `json:"fulfillId"` + } `json:"delivery"` + Error string `json:"error,omitempty"` +} + +type RockExchangeRecordsResponse struct { + Records []struct { + ID int64 `json:"id"` + TokenID int64 `json:"token_id"` + Skin string `json:"skin"` + Currency string `json:"currency"` + Quantity int `json:"quantity"` + Role string `json:"role"` + RoleID string `json:"role_id"` + FulfillID int64 `json:"fulfill_id"` + RedeemedAt string `json:"redeemed_at"` + ClaimedAt string `json:"claimed_at"` + RedeemCode string `json:"redeem_code"` + DeliveryUser string `json:"delivery_user"` + } `json:"records"` + Pagination struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + TotalPages int `json:"totalPages"` + } `json:"pagination"` + Error string `json:"error,omitempty"` +} + +// GetMe 获取商户个人信息及积分 +func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) { + reqURL := fmt.Sprintf("%s/api/me", c.baseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 /api/me 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游返回错误状态码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockMeResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 /api/me 响应失败: %w", err) + } + if res.User.Username == "" && res.Error != "" { + return nil, fmt.Errorf("上游业务错误: %s", res.Error) + } + return &res, nil +} + +// ClaimTokens 批量提取采购 Token +func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color string, count int) ([]RockClaimedToken, error) { + if count <= 0 { + return nil, fmt.Errorf("提取数量必须大于 0") + } + reqURL := fmt.Sprintf("%s/api/user/tokens/claim", c.baseURL) + payload := map[string]interface{}{ + "color": color, + "count": fmt.Sprintf("%d", count), + } + jsonBytes, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 claim 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 claim 响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 claim 接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockClaimResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 claim 响应失败: %w", err) + } + if len(res.Tokens) == 0 && res.Error != "" { + return nil, fmt.Errorf("上游 claim 失败: %s", res.Error) + } + return res.Tokens, nil +} + +// GetRedeemDetail 根据 redeem_code 免登录获取腾讯官方绑定链接与状态 +func (c *RockClient) GetRedeemDetail(ctx context.Context, redeemCode string) (*RockRedeemDetailResponse, error) { + reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(redeemCode)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 /api/redeem/{code} 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 redeem 详情响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 redeem 详情接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockRedeemDetailResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 redeem 详情失败: %w", err) + } + return &res, nil +} + +// RefreshToken 刷新 Token 绑定状态并获取官方履约结果 +func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstreamTokenID string) (*RockRefreshResponse, error) { + reqURL := fmt.Sprintf("%s/api/user/tokens/%s/refresh", c.baseURL, url.PathEscape(upstreamTokenID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) + if err != nil { + return nil, err + } + if bearerToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 refresh 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 refresh 响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 refresh 接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockRefreshResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 refresh 响应失败: %w", err) + } + return &res, nil +} + +// RedeemSkin 执行最终皮肤兑换发货 +func (c *RockClient) RedeemSkin(ctx context.Context, upstreamTokenID, skin, currency, account string) (*RockRedeemResponse, error) { + reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(upstreamTokenID)) + payload := map[string]interface{}{ + "skin": skin, + "currency": currency, + "account": account, + } + jsonBytes, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 兑换发货接口失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取兑换响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游兑换发货接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockRedeemResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析兑换响应失败: %w", err) + } + if !res.Success && res.Error != "" { + return nil, fmt.Errorf("上游兑换失败: %s", res.Error) + } + return &res, nil +} + +// QueryExchangeRecords 异步查询兑换对账记录 +func (c *RockClient) QueryExchangeRecords(ctx context.Context, bearerToken string, page, pageSize int, search string) (*RockExchangeRecordsResponse, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + reqURL := fmt.Sprintf("%s/api/user/exchange-records?page=%d&pageSize=%d&search=%s", c.baseURL, page, pageSize, url.QueryEscape(search)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 exchange-records 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取对账响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游对账接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockExchangeRecordsResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析对账响应失败: %w", err) + } + return &res, nil +} + +// ReportAbnormal 上报异常 Token +func (c *RockClient) ReportAbnormal(ctx context.Context, bearerToken, upstreamTokenID, reason string) error { + reqURL := fmt.Sprintf("%s/api/user/tokens/%s/report", c.baseURL, url.PathEscape(upstreamTokenID)) + payload := map[string]string{"reason": reason} + jsonBytes, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes)) + if err != nil { + return err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("请求 0666 report 失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("上报异常失败(%d): %s", resp.StatusCode, string(body)) + } + return nil +} diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go new file mode 100644 index 0000000..c4d43b1 --- /dev/null +++ b/backend/internal/service/rock_service.go @@ -0,0 +1,632 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "sync" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机 +type RockService struct { + db *gorm.DB + client *RockClient + mu sync.Mutex +} + +func NewRockService(db *gorm.DB, client *RockClient) *RockService { + return &RockService{ + db: db, + client: client, + } +} + +// RockStatsResponse 看板与库存统计返回 +type RockStatsResponse struct { + AvailableStock int64 `json:"available_stock"` + UsingStock int64 `json:"using_stock"` + BoundStock int64 `json:"bound_stock"` + RedeemedStock int64 `json:"redeemed_stock"` + MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查) + AbnormalStock int64 `json:"abnormal_stock"` + UpstreamPoints float64 `json:"upstream_points"` + AutoDeliveryEnabled bool `json:"auto_delivery_enabled"` + TokenWarningStock int `json:"token_warning_stock"` + AutoPurchaseEnabled bool `json:"auto_purchase_enabled"` + Username string `json:"username"` + LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` +} + +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"` + AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"` + AutoPurchaseCount *int `json:"auto_purchase_count"` + PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"` +} + +// GetConfig 获取当前配置,若无则初始化 +func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) { + var cfg model.RockConfig + err := s.db.WithContext(ctx).First(&cfg, 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + cfg = model.RockConfig{ + ID: 1, + AutoDeliveryEnabled: true, + BaseURL: "https://0666.pro", + BearerToken: "", + MinPointsWarning: 1050.00, + TokenWarningStock: 10, + AutoPurchaseEnabled: false, + AutoPurchaseCount: 50, + PurchaseCooldownMinutes: 10, + } + if createErr := s.db.WithContext(ctx).Create(&cfg).Error; createErr != nil { + return nil, createErr + } + return &cfg, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpdateConfig 更新系统配置 +func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq) (*model.RockConfig, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + updates := make(map[string]interface{}) + if req.AutoDeliveryEnabled != nil { + updates["auto_delivery_enabled"] = *req.AutoDeliveryEnabled + } + if req.BaseURL != nil && strings.TrimSpace(*req.BaseURL) != "" { + updates["base_url"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/") + s.client.baseURL = updates["base_url"].(string) + } + if req.BearerToken != nil { + updates["bearer_token"] = strings.TrimSpace(*req.BearerToken) + } + if req.MinPointsWarning != nil { + updates["min_points_warning"] = *req.MinPointsWarning + } + if req.TokenWarningStock != nil { + updates["token_warning_stock"] = *req.TokenWarningStock + } + if req.AutoPurchaseEnabled != nil { + updates["auto_purchase_enabled"] = *req.AutoPurchaseEnabled + } + if req.AutoPurchaseCount != nil { + updates["auto_purchase_count"] = *req.AutoPurchaseCount + } + if req.PurchaseCooldownMinutes != nil { + updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes + } + + 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 { + return nil, err + } + } + + return s.GetConfig(ctx) +} + +// TestConnection 测试上游 Token 连通性并同步最新用户信息与积分 +func (s *RockService) TestConnection(ctx context.Context, bearerToken string) (*RockMeResponse, error) { + token := strings.TrimSpace(bearerToken) + if token == "" { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + token = cfg.BearerToken + } + if token == "" { + return nil, fmt.Errorf("请先输入或配置上游 Bearer Token") + } + + me, err := s.client.GetMe(ctx, token) + if err != nil { + return nil, err + } + + // 连通成功,顺便异步更新数据库快照 + now := time.Now() + _ = s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = 1").Updates(map[string]interface{}{ + "username": me.User.Username, + "points": me.User.Points, + "last_heartbeat_at": now, + }).Error + + return me, nil +} + +// GetStats 获取概览统计 +func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + var stats RockStatsResponse + stats.AutoDeliveryEnabled = cfg.AutoDeliveryEnabled + stats.TokenWarningStock = cfg.TokenWarningStock + stats.AutoPurchaseEnabled = cfg.AutoPurchaseEnabled + stats.UpstreamPoints = cfg.Points + stats.Username = cfg.Username + if cfg.LastHeartbeatAt != nil { + stats.LastHeartbeatAt = cfg.LastHeartbeatAt.Format("2006-01-02 15:04:05") + } + + type CountResult struct { + Status string + Total int64 + } + var results []CountResult + err = s.db.WithContext(ctx).Model(&model.RockToken{}). + Select("status, count(*) as total"). + Group("status"). + Scan(&results).Error + if err != nil { + return nil, err + } + + for _, r := range results { + switch r.Status { + case model.RockStatusAvailable: + stats.AvailableStock = r.Total + case model.RockStatusUsing: + stats.UsingStock = r.Total + case model.RockStatusBound: + stats.BoundStock = r.Total + case model.RockStatusRedeemed: + stats.RedeemedStock = r.Total + case model.RockStatusMismatch: + stats.MismatchStock = r.Total + case model.RockStatusAbnormal: + stats.AbnormalStock = r.Total + } + } + + return &stats, nil +} + +// PurchaseTokens 执行采购并批量入库(通用黑色 Token) +func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) { + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + if cfg.BearerToken == "" { + return nil, fmt.Errorf("尚未配置上游 Bearer Token,无法执行采购") + } + + // 自动补货防抖冷却检查 + now := time.Now() + if isAuto && cfg.LastPurchasedAt != nil { + cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute + if now.Sub(*cfg.LastPurchasedAt) < cooldown { + return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes) + } + } + + // 调上游 Claim 接口(统一下单黑色通用 Token) + tokens, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count) + if err != nil { + return nil, fmt.Errorf("调用上游采购接口失败: %w", err) + } + + if len(tokens) == 0 { + return nil, fmt.Errorf("上游未返回任何有效 Token") + } + + var dbTokens []model.RockToken + for _, t := range tokens { + dbTokens = append(dbTokens, model.RockToken{ + UpstreamTokenID: fmt.Sprintf("%d", t.ID), + Token: t.Token, + RedeemCode: t.RedeemCode, + Color: model.RockColorBlack, + Region: t.Region, + Status: model.RockStatusAvailable, + ClaimedAt: &now, + }) + } + + // 事务写入库存表 + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&dbTokens).Error; err != nil { + return err + } + // 更新最后采购时间 + return tx.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Update("last_purchased_at", now).Error + }) + if err != nil { + return nil, fmt.Errorf("入库采购 Token 失败: %w", err) + } + + // 异步更新上游剩余积分快照 + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + me, meErr := s.client.GetMe(bgCtx, cfg.BearerToken) + if meErr == nil && me != nil { + _ = s.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", me.User.Points).Error + } + }() + + return dbTokens, nil +} + +// CheckStockAndAutoPurchase 水位检查与自动采购触发 +func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return + } + + var count int64 + s.db.WithContext(ctx).Model(&model.RockToken{}). + Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). + Count(&count) + + if count <= int64(cfg.TokenWarningStock) { + log.Printf("[RockService] 可用库存预警: 当前仅存 %d 个 (预警阈值 %d)", count, cfg.TokenWarningStock) + if cfg.AutoPurchaseEnabled { + buyCount := cfg.AutoPurchaseCount + if buyCount <= 0 { + buyCount = 50 + } + log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount) + _, err := s.PurchaseTokens(ctx, buyCount, true) + if err != nil { + log.Printf("[RockService] 自动补货失败: %v", err) + } else { + log.Printf("[RockService] 自动补货成功,新增 %d 个 Token", buyCount) + } + } + } +} + +// BindingSnapshot 保存分配 Token 时上游的绑定快照 +type BindingSnapshot struct { + Bound bool `json:"bound"` + RoleName string `json:"role_name"` + RoleID string `json:"role_id"` + Channel string `json:"channel"` + BindChangedAt string `json:"bind_changed_at"` +} + +// AssignTokenForOrder 为指定订单锁定分配一个可用 Token +func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, nil, err + } + if !cfg.AutoDeliveryEnabled { + return nil, nil, fmt.Errorf("Rock 自动发货通道已维护关闭") + } + + var token model.RockToken + + // 1. 检查订单是否已分配过 Token + err = s.db.WithContext(ctx). + Where("assigned_order_no = ? AND status IN (?, ?)", orderNo, model.RockStatusUsing, model.RockStatusBound). + First(&token).Error + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, err + } + + // 2. 若尚未分配,锁定一个可用 Token (行锁防并发超卖) + if errors.Is(err, gorm.ErrRecordNotFound) { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). + Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). + Order("id ASC"). + First(&token).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("Rock平台库存不足,请联系管理员补货") + } + return err + } + + token.Status = model.RockStatusUsing + token.AssignedOrderNo = orderNo + token.ExpectedUID = strings.TrimSpace(expectedUID) + return tx.Save(&token).Error + }) + if err != nil { + return nil, nil, err + } + + // 检查水位触发异步自动补货 + go s.CheckStockAndAutoPurchase(context.Background()) + } + + // 3. 调上游获取绑定活动链接 + detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) + if err != nil { + return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err) + } + + // 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据) + if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" { + ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if refErr == nil && ref != nil { + snap := BindingSnapshot{ + Bound: ref.Binding.Bound, + RoleName: ref.Binding.RoleName, + RoleID: ref.Binding.RoleID, + Channel: ref.Binding.Channel, + BindChangedAt: ref.Token.BindChangedAt, + } + snapBytes, _ := json.Marshal(snap) + token.InitialBoundSnapshot = string(snapBytes) + _ = s.db.WithContext(ctx).Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error + } + } + + return &token, detail, nil +} + +type CheckBindingResult struct { + Bound bool `json:"bound"` + RoleName string `json:"role_name"` + RoleID string `json:"role_id"` + Channel string `json:"channel"` + ExpectedUID string `json:"expected_uid"` + Mismatch bool `json:"mismatch"` + Message string `json:"message"` + UpstreamFulfillID int64 `json:"upstream_fulfill_id,omitempty"` +} + +// CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁 +func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, expectedUID string) (*CheckBindingResult, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + var token model.RockToken + if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { + return nil, err + } + + // 若已经是 mismatch 状态,直接返回阻断提示 + if token.Status == model.RockStatusMismatch { + return &CheckBindingResult{ + Bound: false, + Mismatch: true, + RoleName: token.ActualRoleName, + RoleID: token.ActualRoleID, + Channel: token.ActualChannel, + ExpectedUID: token.ExpectedUID, + Message: fmt.Sprintf("UID 不匹配阻断: %s", token.MismatchReason), + }, nil + } + + // 调上游 refresh 接口 + ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if err != nil { + return nil, fmt.Errorf("刷新绑定状态失败: %w", err) + } + + // 1. 检查上游是否已绑定 + if !ref.Binding.Bound || strings.TrimSpace(ref.Binding.RoleID) == "" { + return &CheckBindingResult{ + Bound: false, + Message: "等待买家微信/QQ扫码授权", + }, nil + } + + // 2. Anti-Ghost 历史残留比对 + if token.InitialBoundSnapshot != "" { + var snap BindingSnapshot + if jsonErr := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); jsonErr == nil { + // 若当前绑定的角色、UID 与初始分配时完全一样且变更时间没有更新,说明买家还未扫码,仍是历史旧数据 + if snap.Bound && snap.RoleID == ref.Binding.RoleID && snap.BindChangedAt == ref.Token.BindChangedAt { + return &CheckBindingResult{ + Bound: false, + Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)", + }, nil + } + } + } + + // 3. 核心门禁:UID 强一致校验 + trimmedExpected := strings.TrimSpace(expectedUID) + if trimmedExpected == "" { + trimmedExpected = strings.TrimSpace(token.ExpectedUID) + } + trimmedActual := strings.TrimSpace(ref.Binding.RoleID) + + now := time.Now() + if trimmedExpected != "" && trimmedActual != trimmedExpected { + // 严禁发货!跃迁为 mismatch + mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, ref.Binding.RoleName, trimmedActual) + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusMismatch, + "actual_role_name": ref.Binding.RoleName, + "actual_role_id": trimmedActual, + "actual_channel": ref.Binding.Channel, + "mismatch_at": now, + "mismatch_reason": mismatchReason, + }).Error + + return &CheckBindingResult{ + Bound: false, + Mismatch: true, + RoleName: ref.Binding.RoleName, + RoleID: trimmedActual, + Channel: ref.Binding.Channel, + ExpectedUID: trimmedExpected, + Message: fmt.Sprintf("扫码角色UID(%s)与订单填写UID(%s)不一致,严禁发货!", trimmedActual, trimmedExpected), + }, nil + } + + // 4. UID 一致,更新为 bound 成功状态 + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusBound, + "actual_role_name": ref.Binding.RoleName, + "actual_role_id": trimmedActual, + "actual_channel": ref.Binding.Channel, + "bound_at": now, + }).Error + + return &CheckBindingResult{ + Bound: true, + RoleName: ref.Binding.RoleName, + RoleID: trimmedActual, + Channel: ref.Binding.Channel, + ExpectedUID: trimmedExpected, + Message: "扫码授权成功,UID核验一致", + }, nil +} + +// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤) +func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string) (*RockRedeemResponse, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + if !cfg.AutoDeliveryEnabled { + return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换") + } + + var token model.RockToken + err = s.db.WithContext(ctx). + Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound). + First(&token).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("未找到该订单已绑定的有效 Token,或当前状态不可发货") + } + return nil, err + } + + // 最终双重防御:核验 UID + trimmedExpected := strings.TrimSpace(expectedUID) + if trimmedExpected == "" { + trimmedExpected = strings.TrimSpace(token.ExpectedUID) + } + if trimmedExpected != "" && token.ActualRoleID != trimmedExpected { + return nil, fmt.Errorf("终审拦截:扫码UID(%s)与预期UID(%s)不符,禁止发货!", token.ActualRoleID, trimmedExpected) + } + + // 根据商品 SKU 动态决定兑换的皮肤名称 + targetSkin := model.RockSkinBlackTshirt + if sku == model.RockSKUBlueSportswear { + targetSkin = model.RockSkinBlueSportswear + } + + // 调上游兑换发货接口 + redeemResp, err := s.client.RedeemSkin(ctx, token.UpstreamTokenID, targetSkin, "幸运币", token.ActualRoleName) + if err != nil { + return nil, fmt.Errorf("调用上游兑换失败: %w", err) + } + + // 提取官方 fulfillId,若兑换响应中没有则通过 refresh 提取 + fulfillID := redeemResp.Delivery.FulfillID + now := time.Now() + if fulfillID == 0 { + ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if refErr == nil && len(ref.DeliveryList) > 0 { + fulfillID = ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID + } + } + + // 状态跃迁至 redeemed + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusRedeemed, + "upstream_fulfill_id": fulfillID, + "target_skin": targetSkin, + "target_currency": "幸运币", + "target_currency_quantity": redeemResp.Delivery.Quantity, + "redeemed_at": now, + }).Error + + return redeemResp, nil +} + +// ListTokens 分页查询 Token 列表 +func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + db := s.db.WithContext(ctx).Model(&model.RockToken{}) + if strings.TrimSpace(status) != "" { + db = db.Where("status = ?", strings.TrimSpace(status)) + } + if strings.TrimSpace(search) != "" { + term := "%" + strings.TrimSpace(search) + "%" + db = db.Where("assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ?", + term, term, term, term, term) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var tokens []model.RockToken + err := db.Order("id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&tokens).Error + if err != nil { + return nil, 0, err + } + + return tokens, total, nil +} + +// ResetMismatchToken 客服人工重置解冻绑定错误的 Token +func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error { + var token model.RockToken + if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { + return err + } + + if token.Status != model.RockStatusMismatch { + return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置") + } + + // 重置回 available,清空订单关联 + return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + "actual_role_name": "", + "actual_role_id": "", + "actual_channel": "", + "initial_bound_snapshot": "", + "mismatch_at": nil, + "mismatch_reason": "", + "updated_at": time.Now(), + }).Error +} diff --git a/docs/Rock发货平台代理接入与库存风控实施方案.md b/docs/Rock发货平台代理接入与库存风控实施方案.md new file mode 100644 index 0000000..ac0ad4d --- /dev/null +++ b/docs/Rock发货平台代理接入与库存风控实施方案.md @@ -0,0 +1,333 @@ +# Rock发货平台 (0666.pro) 代理接入与库存风控实施方案 + +> **编制日期**:2026-10-05 +> **指导原则**: +> 1. **后台全反代,买家/打手对上游完全无感**(底层对接全部在后端完成,对外输出完全符合既有系统规范)。 +> 2. **Token 一次性绑定不可换绑,设立 UID 强一致防御门禁**(前置填 UID,阻断混淆旧数据,UID 不符绝对禁止领取)。 +> 3. **本地库存池机制,支持批量采购、安全水位预警与自动补货**。 +> 4. **先规划设计,严格门禁验证,暂不急于修改既有 Provider**。 + +--- + +## 一、 核心需求解构与风险防御矩阵 + +| 序号 | 需求要点 | 业务痛点 / 致命风险 | 应对防御方案 | 确信度 | +| :--- | :--- | :--- | :--- | :--- | +| **REQ-1** | **后台做代理,用户无感上游** | 若直接下发 0666 链接,买家会发现源头平台;且数据结构不一致破坏现有前端逻辑。 | • 后端封装中转代理,对外暴露统一的订单提货 API;
• 二维码仅输出腾讯官方活动链接(`gp.qq.com`),买家端完全无第三方痕迹;
• 数据字段对齐既有 `DeliveryOrderInfo` 与 `DeliveryBindResult`。 | **100%** | +| **REQ-2** | **Token 无法换绑,绑定即作废** | 买家若扫错游戏账号,或乱扫码,该 Token 直接报废(21 积分/个损失),且无法解绑。 | • **前置输入门禁**:生成二维码前,强制买家输入/确认游戏角色 UID;
• **UID 强一致校验**:扫码后捕获角色 UID,**不等于预期 UID 严禁发货**;
• 阻断错误发货,保留 Token 供售后人工处理。 | **100%** | +| **REQ-3** | **上游旧绑定信息防混淆** | 0666 平台下发 Token 或刚 refresh 时可能残留历史账号信息(`previous_bound_account` / 历史记录),容易被误判为“已绑定”。 | • **基线快照比对(Baseline Snapshotting)**:分配 Token 时抓取初始绑定快照;
• 仅当检测到买家扫码产生新的绑定事件且角色 UID 完全吻合时,才跃迁为 `bound` 状态。 | **100%** | +| **REQ-4** | **后台批量采购与统一通用库存池** | 提取 Token 默认统一下单为“黑色通用 Token”(对应界面 `1 = 黑色`),该链接本身不预先区分黑蓝,是一个通用兑换凭证。 | • 建立本地统一的 `rock_tokens` 通用库存池,无需双池割裂;
• 后台一键采购 50 个通用 Token 存入库存;
• 发货时直接从通用池中锁定分配,零网络延迟。 | **100%** | +| **REQ-5** | **安全水位告警与自动采购** | 库存用尽导致订单积压失败。 | • 设置通用 Token 低库存预警阈值(如低于 10 个);
• 接入系统既有通知渠道(飞书、钉钉、企微机器人等);
• 支持配置开关:低于阈值自动调用上游采购补充 50 个。 | **100%** | +| **REQ-6** | **黑蓝区分时机:绑定后发货前动态选择** | 买家购买不同商品(黑色ROCK或蓝色运动服),上游链接本身无区别,何时决定发放黑色还是蓝色? | • **延后到发货阶段决策(Late-Binding Selection)**:
• 买家扫码通过 UID 强一致门禁后,系统根据订单商品的 SKU 属性,在最终调用兑换接口时自动填充对应 `skin`:
  - 黑色商品传 `"skin": "黑色ROCK长袖T恤"`
  - 蓝色商品传 `"skin": "蓝色运动服上衣"` | **100%** | +| **REQ-7** | **绑定错误独立统计与追溯** | 买家扫错账号或扫了他人账号,虽然被系统阻断未发货,但该 Token 已无法在 0666 换绑,需明确统计损耗与售后责任。 | • **独立状态与指标面板**:Token 标记为 `mismatch`(绑定错误),后台大屏卡片高亮统计“绑错数量”;
• **全要素审计快照**:完整记录买家填写的 `expected_uid` 与扫码实际捕获的 `actual_role_id`、角色名、大区、发生时间,支持一键导出与人工处理。 | **100%** | +| **REQ-8** | **Rock 自动发货全局总开关** | 遇到上游接口波动、积分不足、Token 缺货或价格调整时,需一键熔断。 | • **配置全局熔断开关 `rock_auto_delivery_enabled`**;
• 一旦关闭:黑色ROCK与蓝色运动服两个商品**彻底停走自动发货通道**,直接降级为人工打手抢单或客服处理,避免错单穿透。 | **100%** | + +--- + +## 二、 系统架构与发货时序图 + +### 2.1 整体流程时序图(全反代 + UID 强校验) + +```mermaid +sequenceDiagram + autonumber + actor Buyer as 买家 / 发货员 + participant Dash as Affiliate-Dash 后台 + participant Pool as 本地 Token 库存池 + participant Rock as 0666.pro 平台 + participant Tencent as 和平精英官方 (gp.qq.com) + + Note over Buyer, Dash: 第零阶段:全局自动发货总开关检查 (Kill Switch) + Buyer->>Dash: 打开提货页 /delivery/:orderNo + alt rock_auto_delivery_enabled == false (总开关已关闭) + Dash-->>Buyer: ⚠️ 该商品自动发货维护中,已转交人工客服/打手为您处理 + else rock_auto_delivery_enabled == true (允许自动发货) + Note over Buyer, Dash: 第一阶段:前置输入 UID 锁定 + Dash-->>Buyer: 展示商品信息,要求确认/输入游戏角色 UID + Buyer->>Dash: 提交预期游戏 UID (例如: 4331027062) + + Note over Dash, Pool: 第二阶段:本地通用池分配 Token + Dash->>Pool: 锁定 1 个可用的通用 Token (默认黑色通用凭证) + Pool-->>Dash: 返回 Token 记录 (包含 redeem_code, bindUrl) + Dash->>Dash: 记录 Token 初始状态快照 (排除上游历史旧信息) + Dash-->>Buyer: 展示腾讯官方绑定二维码 (绝无 0666 标识) + + Note over Buyer, Tencent: 第三阶段:买家微信/QQ 扫码授权 + Buyer->>Tencent: 手机扫码授权登录并绑定角色 + + Note over Dash, Rock: 第四阶段:轮询与 UID 强一致门禁 + loop 每 2~3 秒轮询 + Dash->>Rock: POST /api/user/tokens/{id}/refresh + Rock-->>Dash: 返回 binding 信息 (role, roleId, region) + end + + alt 实际 roleId != 预期 UID (扫错账号) + Dash->>Dash: 触发硬阻断!标记 deliveryStageBindMismatch + Dash->>Pool: 将该 Token 状态标记为 mismatch (绑定错误),记录扫码角色与时间进入统计池 + Dash-->>Buyer: ⚠️ 绑定角色与订单 UID 不符,已阻断发货,请联系客服处理 + else 实际 roleId == 预期 UID (校验通过) + Dash->>Dash: 状态变更为 bound,核验通过 + Dash-->>Buyer: 角色匹配成功 (唉终章 / 4331027062) + + Note over Dash, Rock: 第五阶段:根据订单商品动态选择皮肤并兑换发货 + alt 订单购买的是黑色 ROCK + Dash->>Rock: POST /api/redeem/{id} {"skin": "黑色ROCK长袖T恤", "currency": "幸运币", "account": "..."} + else 订单购买的是蓝色运动服 + Dash->>Rock: POST /api/redeem/{id} {"skin": "蓝色运动服上衣", "currency": "幸运币", "account": "..."} + end + Rock-->>Dash: {"status": "success", "message": "领取成功"} + Dash->>Pool: 标记 Token 状态为 redeemed,记录实际发放的 skin + Dash-->>Buyer: 发货成功!生成发货凭证图 + end + end +``` + +--- + +## 三、 本地库存模型与数据表设计 + +为了实现后台批量采购、库存水位控制与防错追踪,设计本地专属库存表: + +### 3.1 Token 库存表 (`rock_tokens`) + +所有 Token 在提取时统一默认采购为**“黑色通用 Token”**(上游类型传 `1 = 黑色`,即 `color = 'black'`),不预设皮肤,入库为通用库存。 + +```sql +CREATE TABLE IF NOT EXISTS `rock_tokens` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `upstream_id` BIGINT UNSIGNED NOT NULL COMMENT '0666平台的Token ID,如 151153', + `token_jwt` TEXT NOT NULL COMMENT 'Token完整JWT串', + `redeem_code` VARCHAR(64) NOT NULL UNIQUE COMMENT '专属兑换码哈希', + `color` VARCHAR(16) NOT NULL DEFAULT 'black' COMMENT '采购规格: 统一为 black 通用 Token', + `status` VARCHAR(24) NOT NULL DEFAULT 'available' COMMENT '状态: available(可用)/locked(锁定)/bound(已绑)/redeemed(已发)/mismatch(绑错待查)/abnormal(异常)/expired(过期)', + `assigned_order_no` VARCHAR(64) DEFAULT NULL COMMENT '当前绑定的内部订单号', + `expected_uid` VARCHAR(64) DEFAULT NULL COMMENT '买家输入的预期游戏UID', + `actual_role_name` VARCHAR(128) DEFAULT NULL COMMENT '扫码绑定的游戏角色名', + `actual_role_id` VARCHAR(64) DEFAULT NULL COMMENT '扫码绑定的游戏角色UID', + `actual_region` VARCHAR(64) DEFAULT NULL COMMENT '扫码绑定的大区', + `bind_url` TEXT DEFAULT NULL COMMENT '腾讯官方绑定链接', + `initial_snapshot` TEXT DEFAULT NULL COMMENT '分配时的初始旧绑定快照JSON(用于防混淆)', + `cost_points` DECIMAL(10,2) DEFAULT '21.00' COMMENT '采购消耗积分', + `redeemed_skin` VARCHAR(64) DEFAULT NULL COMMENT '实际兑换发放的皮肤名称(如: 黑色ROCK长袖T恤 / 蓝色运动服上衣)', + `redeemed_currency` VARCHAR(64) DEFAULT NULL COMMENT '附赠的货币类型(默认: 幸运币)', + `redeemed_amount` INT DEFAULT 0 COMMENT '附赠的货币数量(黑=10, 蓝=14)', + `mismatch_at` DATETIME DEFAULT NULL COMMENT '判定为绑错UID的时间戳(用于统计大屏)', + `mismatch_reason` VARCHAR(255) DEFAULT NULL COMMENT '绑错详情说明(如: 预期UID 4331027062 与扫码 3911959065 不符)', + `expires_at` DATETIME DEFAULT NULL COMMENT 'Token官方过期时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_status_color` (`status`, `color`), + INDEX `idx_assigned_order` (`assigned_order_no`), + INDEX `idx_mismatch` (`status`, `mismatch_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Rock平台Token本地通用库存池'; +``` + +### 3.2 Rock 平台配置表 / 系统配置扩展 (`rock_config`) + +在系统配置项中持久化以下参数: +- `rock_auto_delivery_enabled`: **Rock 自动发货全局总开关**(`true` 开启 / `false` 一键关闭,关闭后两款商品停走自动发货通道) +- `rock_base_url`: `https://0666.pro` +- `rock_bearer_token`: 上游当前有效的 Bearer Token(如抓包中的 `05c55d8b8c...`) +- `rock_username`: 上游系统采购用户名(调 `GET /api/me` 自动获取,如 `王大锤`) +- `rock_account_points`: 上游最新同步的积分余额(实时快照,如 `8271.2`) +- `rock_min_points_warning`: 上游最低积分告警阈值(默认 `1050.0`,即至少满足一次 50 个 Token 自动补货) +- `rock_token_warning_stock`: 通用 Token 告警安全水位(默认 10) +- `rock_auto_purchase_enabled`: 自动补货开关(`true` / `false`) +- `rock_auto_purchase_count`: 触发自动补货时单次采购量(默认 50,对应上游 `count=50` 黑色通用 Token) +- `rock_purchase_cooldown_minutes`: 自动采购防抖冷却时间(默认 10 分钟,防并发击穿积分) + +### 3.3 0666.pro 系统采购用户账号生命周期管理方案 (Upstream Account Lifecycle) + +根据业务核验,0666.pro 平台登录方式为 **Bearer Token 登录** 与 **用户名+验证码登录**(无密码登录)。依照当前实施策略,**现阶段纯采用 Bearer Token 模式直接配置接入,后续再按需扩展验证码交互**: + +#### 1. 凭据录入与自动校验 (Token-Only Direct Configuration) +- **商户鉴权机制结论**: + 上游 0666.pro 平台**无独立固定密码**,仅支持“Bearer Token 登录”与“用户名+手机短信验证码登录”。根据实施规划,**先期直接采用 Token 配置方式,后续再视需要支持用户名+验证码交互**。 +- **后台极简配置**: + 在系统设置面板中,管理员直接粘贴从 0666.pro 网页或抓包中获取的当前有效 `Bearer Token`(64位十六进制串): + > 💡 **Token 提取极简指引**: + > 登录 0666.pro 网页后,按 `F12` 打开控制台,在 `Application (应用程序) -> Local Storage -> https://0666.pro` 中找到 `yd_token` 字段复制;或在控制台 Console 执行 `localStorage.getItem('yd_token')` 即可一键复制。 +- **即时连通性校验与自动同步**: + 点击【保存并测试连通】,后端立即发送 `GET https://0666.pro/api/me`: + - 成功时:自动提取并展示上游用户名(`王大锤`)、账号状态(`active`)、当前可用积分(`8271.2`),折算展示“约可采购 393 个通用 Token”; + - 失败时:界面提示错误详情(如凭证无效),阻止错误保存。 + +#### 2. 账号健康心跳与积分余额监控 (Heartbeat & Balance Tracker) +- **定时心跳巡检**:后台定时任务(如每 10 分钟)调用 `GET /api/me`; +- **大屏实时看板**: + - 采购账号:`王大锤`(状态:`active 正常`) + - 上游剩余积分:`8271.2` 点(看板自动折算可用 Token 配额) + - 最后心跳时间:实时更新 +- **低积分提前预警**: + 当 `points < 1050.0`(不足一次 50 个采购)时,通过飞书/企微/钉钉机器人推送: + > ⚠️ **【资金告警】0666.pro 平台采购账号积分不足!** + > 账号:王大锤 | 剩余积分:840.0 点(不足一次 50 个自动补货) + > 请及时前往 0666.pro 进行充值,避免断货。 + +#### 3. 凭据失效(401 Unauthorized)自动处置与熔断 +- 当采购接口返回 401(Token 到期失效或被注销)时: + 1. **系统自动软熔断**:自动将 `rock_auto_delivery_enabled` 置为报警待查状态,黑色 ROCK 与蓝色运动服两款商品立即降级为人工打手接单/客服处理,避免错单穿透; + 2. **高危警报推送**:立即向管理群推送高危紧急告警:“【紧急】0666 平台采购账号 Token 已失效,自动发货通道已自动熔断转人工,请管理员复制最新 Token 登录后台更新!”; + 3. **一键恢复**:管理员登录 0666.pro 获取新 Token 粘贴保存后,连通性测试通过即可一键恢复自动发货。 +- **后续扩展计划(Phase 2)**:未来若需支持脱离网页获取 Token,再在后台增加“输入手机号 -> 调 `/api/auth/send-code` -> 接收短信验证码 -> 调 `/api/auth/phone-login`”的在线登录换 Token 流程。 + +--- + +## 四、 核心防护算法与业务门禁细节 + +### 4.1 旧绑定信息清洗与基线比对(Anti-Ghost-Binding) + +**真实抓包实锤证据 (`蓝色一个发货.har`)**: +在买家尚未扫码前(Entry 1~6),上游 `refresh` 接口一直返回该手机号绑过的旧账号: +- `bound_account: "总荒凉"` +- `bound_account_id: "3751279035"` +- `bind_changed_at: null` +直到买家在手机微信真正完成扫码授权后(Entry 10),上游数据才发生跃迁: +- `bound_account: "打破虚妄的浪漫"` +- `bound_account_id: "3688825401"` +- `previous_bound_account: "总荒凉"`(旧账号自动归入 previous) +- `bind_changed_at: "2026-10-05 14:08:02"` +- `bind_change_type: "rebind"` + +若没有基线快照比对,系统在买家扫码前就会把旧账号“总荒凉”当作买家发货,导致灾难性货损! + +**算法逻辑**: +```go +type BindingSnapshot struct { + Bound bool `json:"bound"` + RoleName string `json:"role_name"` + RoleID string `json:"role_id"` + BindChangedAt string `json:"bind_changed_at"` +} + +// 检查是否发生新的真实有效扫码 +func IsNewBindingValid(initialSnap BindingSnapshot, currentRefresh UpstreamRefreshResp, expectedUID string) (bool, string) { + if !currentRefresh.Binding.Bound || currentRefresh.Binding.RoleID == "" { + return false, "等待扫码绑定" + } + + // 若当前刷新的绑定状态与初始分配时的历史快照完全一致,说明买家尚未真正扫码,仍是历史残留 + if initialSnap.Bound && initialSnap.RoleID == currentRefresh.Binding.RoleID && initialSnap.BindChangedAt == currentRefresh.Token.BindChangedAt { + return false, "等待买家扫码(忽略历史残留)" + } + + // 核心门禁:UID 必须 100% 强一致 + trimmedExpected := strings.TrimSpace(expectedUID) + trimmedActual := strings.TrimSpace(currentRefresh.Binding.RoleID) + if trimmedExpected != "" && trimmedActual != trimmedExpected { + return false, fmt.Sprintf("绑定账号UID(%s)与订单预期UID(%s)不一致,严禁发货!", trimmedActual, trimmedExpected) + } + + return true, "ok" +} +``` + +### 4.2 绑定错误独立统计与追溯大屏 (Mismatch Audit & Metrics) + +当买家扫错账号或拿他人微信扫码导致 UID 不一致时,系统触发以下处理: +1. **Token 状态跃迁至 `mismatch`**: + - 记录 `mismatch_at = NOW()`; + - 记录 `mismatch_reason = fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID)`; + - 阻断底层兑换调用,保留该 Token 在数据库中。 +2. **后台管理大屏独立统计卡片**: + - 统计指标:`COUNT(status = 'mismatch')`,直观展示“绑定错误待排查 Token 数量”; + - 列表展示维度: + - 关联订单号 (`assigned_order_no`) + - 买家填写的预期 UID (`expected_uid`) + - 扫码实际角色与 UID (`actual_role_name` / `actual_role_id`) + - 发生时间与大区 + - 支持客服人工处理: + - **人工释放/重置**:若确认买家输入失误,允许客服人工纠偏重置为 `available` 或重新下发; + - **上报作废**:调用上游 `POST /api/user/tokens/{id}/report` 标记为 `abnormal` 归档。 + +### 4.3 全局自动发货总开关与降级熔断机制 (Kill Switch) + +针对黑色 ROCK 与蓝色运动服两款商品,设立全局软熔断总开关 `rock_auto_delivery_enabled`: +1. **开关位置**:商户后台 / 平台设置中的一键 Switch 开关。 +2. **熔断效果**: + - **当开启 (`true`) 时**:正常走全自动反代发货链路(分配 Token -> 出示二维码 -> 扫码强校验 -> 自动兑换发货)。 + - **当关闭 (`false`) 时**: + - 黑色 ROCK 和蓝色运动服两款商品**彻底脱离自动发货流程**; + - 订单状态保留在 `paid`,自动发货接口直接返回: + ```json + { + "can_ship": false, + "cannot_ship_reason": "当前该商品自动发货通道维护中,已转交人工客服/打手为您处理" + } + ``` + - 绝不调用 0666.pro 消耗 Token 或积分,由人工打手在接单平台抢单核验发货,或由客服人工处理; + - 用于应对上游故障、Token 缺货、价格调整或恶意攻击时的秒级止血。 + +--- + +## 五、 库存管理、安全水位预警与自动采购逻辑 + +### 5.1 批量采购与入库流程 +1. 管理员在后台点击【采购 50 个黑色 Token】; +2. 服务端带上 `rock_bearer_token`,向 `https://0666.pro/api/user/tokens/claim` 发送: + ```json + {"color": "black", "count": "50"} + ``` +3. 上游返回 50 个 Token 数组,事务批量写入 `rock_tokens` 表,`status = 'available'`; +4. 记录本次采购消耗的积分与操作日志。 + +### 5.2 水位巡检与告警通知 +1. **触发时机**: + - 订单每次消耗锁定 Token 时; + - 后台定时巡检 Cron(如每 5 分钟)。 +2. **判断逻辑**: + $$\text{可用库存} = \operatorname{COUNT}\left(\text{status} = 'available' \land \text{color} = \text{target\_color}\right)$$ + - 若 $\text{可用库存} \le \text{告警阈值}$: + - 发送机器人告警通知(复用系统现有的飞书/钉钉/企业微信 webhook): + > **【库存告警】Rock平台黑色Token库存告急!** + > 当前可用库存:**4 个**(预警阈值:10 个) + > 请及时确认上游积分或检查自动补货状态。 + - 若开启了 `rock_auto_purchase_enabled`: + - 检查是否在冷却期内(距上次自动采购时间 $\ge 10$ 分钟); + - 触发自动采购任务,调用 claim 补充 50 个; + - 采购成功后发送补货成功通知。 + +--- + +## 六、 实施规划路线图(分步推进) + +``` +[Phase 1: 数据模型与库存池] -> [Phase 2: 采购与水位监控] -> [Phase 3: 反向代理与UID门禁] -> [Phase 4: Provider集成] +- 创建 rock_tokens 数据表 - 实现手动采购API - 提货页输入UID前置流程 - 实现 RockProvider 接口 +- 系统配置项拓展 - 接入飞书/企微低库存预警 - 上游旧数据过滤与比对 - 接入系统统一发货工作台 + - 自动补货定时任务与冷却控制 - 强一致校验与硬阻断 +``` + +- **第一阶段(数据底座)**:创建 `rock_tokens` 数据结构与配置项存储,打通 0666.pro 的 Claim 接口。(已完成) +- **第二阶段(库存与安全)**:在后台管理界面增加 Rock 库存面板,支持采购 50 个、库存查看、低水位告警与自动补货。(已完成) +- **第三阶段(反代与安全门禁)**:实现代理转发与 UID 校验拦截器,杜绝因扫错号报废 Token。(已完成) +- **第四阶段(业务无缝收口)**:完成标准 Provider 适配,系统无缝接管。(已完成) + +--- + +## 七、 工程落地进度与实测验证记录 (Implementation Audit) + +### 7.1 落地成果交付清单 + +| 模块层级 | 文件路径 | 核心功能与职责 | 状态 | +| :--- | :--- | :--- | :--- | +| **SQL 迁移** | `backend/internal/database/migrations/012_rock_fulfillment.sql` | 创建 `rock_configs` 配置表、`rock_tokens` 通用库存表,初始化黑色ROCK与蓝色运动服目录数据 | ✅ 已就绪 | +| **数据模型** | `backend/internal/model/rock.go` | 定义 `RockConfig` 与 `RockToken` 结构体及常量映射 | ✅ 已就绪 | +| **通信客户端**| `backend/internal/service/rock_client.go` | 封装 0666.pro 平台全部 REST 接口 (GetMe, Claim, Refresh, Redeem, etc.) | ✅ 已就绪 | +| **库存与风控**| `backend/internal/service/rock_service.go` | 实现库存行锁分配、Anti-Ghost 基线比对、UID强一致阻断、自动补货巡检与 mismatch 解冻 | ✅ 已就绪 | +| **履约渠道** | `backend/internal/service/provider_rock.go` | 实现统一 `FulfillmentProvider` 接口,支持动态判定皮肤与腾讯官方流水提取 | ✅ 已就绪 | +| **后台 API** | `backend/internal/handler/rock.go` | 提供配置查询/更新、测试连通、一键采购 50 个、库存分页与重置接口 | ✅ 已就绪 | +| **前端 API** | `frontend/src/api/index.ts` | 增加 `rockApi` 与相关 TypeScript 类型声明 | ✅ 已就绪 | +| **管理大屏** | `frontend/src/pages/PlatformRock.tsx` | 实现自动发货总开关、指标看板、Token 配置与连通测试、库存与错单审计列表 | ✅ 已就绪 | +| **菜单与路由**| `frontend/src/App.tsx`, `MainLayout.tsx` | 注册 `/platform-rock` 路由与侧边栏商品管理子菜单 | ✅ 已就绪 | +| **单元测试** | `backend/internal/service/provider_rock_test.go` | 覆盖软熔断、Anti-Ghost、UID不匹配阻断、黑蓝动态兑换、采购与连通测试 | ✅ 100% 通过 | + +### 7.2 自动化测试结果记录 +- 后端单元测试:`go test -v ./internal/service -run TestRock` + - `TestRockProvider_KillSwitch`: PASS (0.41s) + - `TestRockProvider_AntiGhostAndUIDMismatch`: PASS (0.29s) + - `TestRockProvider_SubmitBlueAndBlack`: PASS (0.27s) + - `TestRockService_StatsAndPurchase`: PASS (0.27s) +- 后端全量测试:`go test ./...` -> 全部通过 (exit code 0) +- 前端静态构建:`npm run build` -> `tsc -b && vite build` 成功通过 (exit code 0) + diff --git a/docs/rock发货平台(0666.pro)逆向与接口分析.md b/docs/rock发货平台(0666.pro)逆向与接口分析.md new file mode 100644 index 0000000..0a91a16 --- /dev/null +++ b/docs/rock发货平台(0666.pro)逆向与接口分析.md @@ -0,0 +1,375 @@ +# Rock发货平台 (0666.pro) 自动发货逆向与接口分析报告 + +> **编制日期**:2026-10-05 +> **抓包来源**: +> 1. `/Users/yml/Desktop/抓包/rock相关/打包两个并查看-发货黑色.har` +> 2. `/Users/yml/Desktop/抓包/rock相关/打包两个黑色并查看.har` +> 3. `/Users/yml/Desktop/抓包/rock相关/兑换记录等杂项.har` +> 4. `/Users/yml/Desktop/抓包/rock相关/蓝色一个发货.har` +> 5. 前端完整源码:`https://0666.pro/app.js`(316KB 原生未混淆代码已还原提取) +> **核心标的**:和平精英皮肤发货(黑色ROCK长袖T恤、蓝色运动服上衣、货币附赠等) + +--- + +## 一、 证据分类与确信度评估矩阵 + +依照逆向工程与高强度分析标准,本次所有结论均严格基于抓包实体通信数据与前端源代码交叉验证: + +| 证据项编号 | 证据分类 | 来源与定位 | 关键事实 / 发现 | 确信度 | +| :--- | :--- | :--- | :--- | :--- | +| **EV-01** | 通信抓包 (HAR) | `发货黑色.har` [Entry 34] | 提取 Token 请求:`POST /api/user/tokens/claim`,Body: `{"color":"black","count":"2"}`,扣除积分提取可用 Token。 | **100%** | +| **EV-02** | 通信抓包 (HAR) | `发货黑色.har` [Entry 36, 40] | 获取绑定及兑换配置:`GET /api/redeem/{redeem_code}`,返回腾讯官方绑定链接 `bindUrl` 及皮肤/货币配置。 | **100%** | +| **EV-03** | 通信抓包 (HAR) | `发货黑色.har` [Entry 37, 39] | 刷新绑定状态:`POST /api/user/tokens/{id}/refresh`,返回 `binding.bound: true`,角色名、角色ID、大区及历史发放记录。 | **100%** | +| **EV-04** | 通信抓包 (HAR) | `发货黑色.har` [Entry 42] | 提交兑换发货:`POST /api/redeem/{id}`,Body: `{"skin":"黑色ROCK长袖T恤","currency":"幸运币","account":"感性的洁少"}`,官方发货成功。 | **100%** | +| **EV-05** | 前端源码 | `app.js` (L3617~L3720) | 皮肤与货币官方数据字典 `officialSkus`,黑蓝皮肤权限控制与 180 点价值换算公式。 | **100%** | +| **EV-06** | 前端源码 | `app.js` (L217513~L217600) | 兑换发货执行函数 `redeemToken()`,揭示其具备登录态与免登录公开态两种调用形态。 | **100%** | +| **EV-07** | 前端源码 | `app.js` (L305062~L305120) | 绑定链接解析与生成逻辑,底层依托和平精英官方活动链接 `gp.qq.com/cp/a20241128mobile/...`。 | **100%** | +| **EV-08** | 用户截图 | 微信/扫码绑定弹窗截图 | 弹窗展示 Token、状态(已绑定)、大区、角色、当前货币组合与兑换皮肤下拉选择框。 | **100%** | +| **EV-09** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 4, 7] | 兑换历史对账接口:`GET /api/user/exchange-records?page=1&pageSize=20`,支持按账号/UID、Token、区服、时间范围精准分页对账。 | **100%** | +| **EV-10** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 5] | 腾讯官方履约凭证:刷新/兑换成功记录中返回 `fulfillId: 4519960`(官方发放流水号)及 `membership: 202610`(月份期号)。 | **100%** | +| **EV-11** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 3] | 下属发货员列表:`GET /api/user/delivery-agents`,返回该商户绑定的发货员(如丁德鑫、曹强等)。 | **100%** | +| **EV-12** | 用户截图 | 批量获取 Token 弹窗截图 | 提取类型默认下拉选项为 `1 = 黑色`,提取数量 2~500,印证了统一下单通用黑色 Token 的业务形态。 | **100%** | +| **EV-13** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 13] | 蓝色发货兑换请求:`POST /api/redeem/151154`,Body: `{"skin":"蓝色运动服上衣","currency":"幸运币","account":"打破虚妄的浪漫"}`,发放成功。 | **100%** | +| **EV-14** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 1~10] | **旧绑定数据实锤证据**:初次 refresh 返回旧角色“总荒凉”(3751279035);买家真正微信扫码后更新为“打破虚妄的浪漫”(3688825401),`bind_change_type: "rebind"`,旧角色归入 `previous_bound_account`。 | **100%** | +| **EV-15** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 0] | 再次证实用于发货蓝色的 Token 151154 其本身规格仍然是 `color: "black"`(黑色通用 Token)。 | **100%** | +| **EV-16** | 前端源码 | `app.js` (L3700~L3760) | **商户登录与鉴权机制**:平台无固定密码登录,采用“用户名+手机验证码”(`POST /api/auth/send-code` + `POST /api/auth/phone-login`),登录后下发 Bearer Token 并存于 `localStorage.yd_token`。请求鉴权全量走 `Authorization: Bearer `。 | **100%** | + +--- + +## 二、 核心业务模型与算法解构 + +### 2.1 颜色规格与权限差异(黑色 vs 蓝色) + +平台采用 **Token** 作为履约载体,Token 本质为标准 HS256 JWT,分为两种颜色规格: + +``` + +---------------------------+ + | Token Pool | + +---------------------------+ + | + +-----------------+-----------------+ + | | + v v + +--------------------+ +--------------------+ + | Color: black | | Color: blue | + | (黑色高级 Token) | | (蓝色标准 Token) | + +--------------------+ +--------------------+ + | | + v v + +--------------------+ +--------------------+ + | 可兑换: | | 可兑换: | + | 1. 黑色ROCK长袖T恤 | | 1. 蓝色运动服上衣 | + | 2. 蓝色运动服上衣 | | 2. 其他普通皮肤 | + | 3. 其他普通皮肤 | | (系统排除了黑色ROCK)| + +--------------------+ +--------------------+ +``` + +- **黑色 Token (`color: "black"`)**: + - 允许兑换:**黑色ROCK长袖T恤**、蓝色运动服上衣、以及其他所有皮肤。 + - 提取成本:约 21.0 积分/个(抓包配置项 `blackTokenCost: "21.0"`)。 +- **蓝色 Token (`color: "blue"`)**: + - 核心限制:前端与后端均强制校验:`skinCatalog.filter(skin => skin !== BLACK_ROCK_SKIN)`,**严禁兑换黑色 ROCK**。 + - 提取成本:约 21.0 积分/个(抓包配置项 `blueTokenCost: "21.0"`)。 + +### 2.2 价值平衡与货币数量计算算法 + +根据 `app.js` 的纯算模型,每个 Token 的官方标称总额度为 **180 点**。每次兑换发放:**1 件皮肤 + N 个附赠游戏货币**。 + +$$ +\text{附赠货币数量} = \max\left(0, \operatorname{round}\left(\frac{180 - \text{皮肤官方单价}}{\text{货币官方单价}}\right)\right) +$$ + +#### 道具官方价格字典: +- **皮肤单价 (`type: 1`)**: + - `黑色ROCK长袖T恤`: 80 点 + - `唐装(红)`: 120 点 + - `火焰口罩`: 160 点 + - `蓝色运动服上衣` / `战地情缘` / `卧虎藏龙` / `水墨画口罩`: 40 点 +- **货币单价 (`type: 0`)**: + - `幸运币`: 10 点 + - `荣耀勋章`: 10 点 + - `物资币`: 10 点 + - `服饰币`: 1 点 + +#### 实际组合计算示例: +1. **兑换黑色ROCK + 幸运币**: + $$\frac{180 - 80}{10} = \mathbf{10} \text{ 个幸运币}$$ + (抓包记录完全吻合:`currencyQuantity: 10`) +2. **兑换蓝色运动服 + 幸运币**: + $$\frac{180 - 40}{10} = \mathbf{14} \text{ 个幸运币}$$ + (用户截图完全吻合:按钮显示“领取蓝色运动服上衣 + 幸运币x14”) +3. **若选择服饰币(单价 1)**: + - 黑色ROCK + 服饰币:$(180 - 80) / 1 = 100$ 个服饰币 + - 蓝色运动服 + 服饰币:$(180 - 40) / 1 = 140$ 个服饰币 + +--- + +## 三、 Token 全生命周期状态机 + +```mermaid +stateDiagram-v2 + [*] --> available: 平台入库/上游导入 + available --> using: 用户调用 claim 提取 / 分配入包 + using --> bound: 买家扫码并在官方活动页绑定角色成功 + using --> abnormal: 扫码超时/异常上报 + bound --> using: 买家重新扫码切换账号 (换绑) + bound --> redeemed: 提交兑换接口 (选择皮肤+货币+角色) + redeemed --> settled: 管理员/提交员结算 + settled --> [*] + abnormal --> [*] +``` + +| 状态标识 | 状态中文 | 释义与系统行为 | +| :--- | :--- | :--- | +| `available` | 可用 | 处于平台库存池中,尚未分配。 | +| `using` | 使用中 / 待使用 | 已被分销商提取(claim),生成了唯一的 `redeem_code`,等待买家扫码。 | +| `bound` | 已绑定 | 买家已扫码并在腾讯活动页完成角色绑定,系统已捕获到角色名称和大区。 | +| `redeemed` | 已兑换 | 已向游戏服务器成功提交发货请求,皮肤和货币已到账。 | +| `settled` | 已结算 | 最终财务结算状态。 | +| `abnormal` | 异常 | 异常报错,由用户或发货员上报。 | + +--- + +## 四、 自动化发货接口技术规格 (API Reference) + +系统提供两种调用链路: +1. **主控管理端(带 Bearer 鉴权)**:用于后台批量提取 Token、监控状态。 +2. **免登录公开端(仅凭 `redeem_code`)**:用于生成买家专用发货链接,完全无状态。 + +### 4.1 提取 Token (Claim) +- **请求方式**:`POST https://0666.pro/api/user/tokens/claim` +- **请求头**: + ```http + Authorization: Bearer + Content-Type: application/json + ``` +- **请求体**: + ```json + { + "color": "black", // "black" 或 "blue" + "count": "1" // 提取数量 + } + ``` +- **返回体**: + ```json + { + "tokens": [ + { + "id": 151153, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "color": "black", + "region": "Q/V可用", + "phone": "13755033455", + "status": "using", + "redeem_code": "a4b7d1c94034e6fc52368b61dff20afe", + "expires_at": "2026-11-03 11:54:30" + } + ] + } + ``` + +### 4.2 获取绑定二维码与链接 +- **请求方式**:`GET https://0666.pro/api/redeem/{redeem_code}` +- **说明**:此接口为免登录公开接口,任何持有 `redeem_code` 的客户端均可调用。 +- **返回体**: + ```json + { + "token": { + "id": 151153, + "color": "black", + "status": "using", + "redeem_code": "a4b7d1c94034e6fc52368b61dff20afe" + }, + "bindUrl": "https://gp.qq.com/cp/a20241128mobile/index.html?userId=13755033455×tamp=1791176387&nonce=be809689&sign=cb1ce3c99601ba6fe1902064176241d2d0a0683a", + "bindError": "", + "config": { + "skinOptions": "[\"蓝色运动服上衣\",\"黑色ROCK长袖T恤\",...]", + "defaultCurrency": "幸运币" + } + } + ``` + +### 4.3 刷新与查询绑定状态 (Refresh) +- **请求方式**: + - 方式 A(管理端):`POST https://0666.pro/api/user/tokens/{id}/refresh` + - 方式 B(公开端):`POST https://0666.pro/api/redeem/{redeem_code}/refresh` +- **返回体**(绑定成功): + ```json + { + "token": { + "id": 151153, + "status": "bound", + "bound_account": "感性的洁少", + "bound_account_id": "3911959065", + "region": "安卓-微信" + }, + "binding": { + "bound": true, + "role": "感性的洁少", + "roleId": "3911959065", + "region": "安卓-微信" + }, + "deliveryList": [ + // 该角色的历史发放记录,防止重复防刷 + ], + "message": "已刷新绑定信息" + } + ``` + +### 4.4 提交兑换发货 (Redeem) +- **请求方式**: + - 方式 A(管理端):`POST https://0666.pro/api/redeem/{id}`(请求头带 Bearer) + - 方式 B(公开端):`POST https://0666.pro/api/redeem/{redeem_code}`(无需 Bearer) +- **请求体**: + ```json + { + "skin": "黑色ROCK长袖T恤", // 必须在可选列表内 + "currency": "幸运币", // "幸运币" / "荣耀勋章" / "物资币" / "服饰币" + "account": "感性的洁少" // 必须与 binding.role 一致 + } + ``` +- **返回体**: + ```json + { + "status": "success", + "message": "领取成功", + "token": { + "id": 151153, + "status": "redeemed", + "redeem_skin": "黑色ROCK长袖T恤", + "redeem_currency": "幸运币", + "redeemed_at": "2026-10-05 13:05:07" + } + } + ``` + +### 4.5 批量打包分享 (Package,可选能力) +- **请求方式**:`POST https://0666.pro/api/user/packages` +- **请求体**: + ```json + { + "ids": [151126, 151130], + "note": "订单批次001" + } + ``` +- **返回体**:生成公开访问链接 `https://0666.pro/#/package/{share_code}`,可直接将整包交付给大客户或批量发货打手。 + +### 4.6 兑换历史对账接口 (Exchange Records) +- **请求方式**:`GET https://0666.pro/api/user/exchange-records?page=1&pageSize=20` +- **请求头**:`Authorization: Bearer ` +- **Query 过滤参数**: + - `page`: 页码(从 1 开始) + - `pageSize`: 每页数量 + - `account`: 游戏角色名 / 角色 UID(精准核账) + - `token`: 按指定 Token 过滤 + - `region`: 按区服过滤 + - `dateFrom` / `dateTo`: 兑换时间范围 +- **返回体**: + ```json + { + "tokens": [ + { + "id": 151153, + "token": "eyJhbGciOi...", + "color": "black", + "region": "安卓-微信", + "status": "redeemed", + "bound_account": "感性的洁少", + "bound_account_id": "3911959065", + "redeem_skin": "黑色ROCK长袖T恤", + "redeem_currency": "幸运币", + "redeemed_at": "2026-10-05 13:05:07", + "claimed_at": "2026-10-05 12:59:45", + "redeem_code": "a4b7d1c94034e6fc52368b61dff20afe" + } + ], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 649, + "totalPages": 33 + } + } + ``` + +### 4.7 腾讯官方发货履约凭证 (FulfillId) +在 `POST /api/user/tokens/{id}/refresh` 返回的 `deliveryList` 列表中,包含每一笔发货的腾讯底层官方唯一凭据: +```json +{ + "skinId": 27, + "skin": "黑色ROCK长袖T恤", + "currencyId": 13, + "currency": "幸运币", + "currencyQuantity": 10, + "time": "2026-10-05T13:05:07.337", + "role": "安卓-微信-感性的洁少", + "message": "成功发放", + "fulfillId": 4519960, // 腾讯官方发货流水号,可直接作为 ProviderOrderNo + "membership": 202610 // 发放会籍期数 +} +``` + +### 4.8 异常上报接口 (Report Abnormal) +- **请求方式**:`POST https://0666.pro/api/user/tokens/{id}/report` +- **请求头**:`Authorization: Bearer ` +- **请求体**: + ```json + { + "reason": "买家扫码绑定异常/账号封禁" + } + ``` +- **作用**:将无法使用的 Token 置为 `abnormal` 异常状态,冻结并报备管理员或提交员。 + +### 4.9 账户体系与登录接口 (Authentication & Profile) +- **商户信息与积分快照**: + - **请求方式**:`GET https://0666.pro/api/me` + - **请求头**:`Authorization: Bearer ` + - **返回体**: + ```json + { + "user": { + "id": 10028, + "username": "王大锤", + "role": "merchant", + "points": 8271.2, + "status": "active" + } + } + ``` +- **上游登录机制解构 (EV-16)**: + - **方式一(无密码短信验证码)**: + 1. 发送验证码:`POST /api/auth/send-code`,Body: `{"username": "商户名"}` + 2. 验证码校验登录:`POST /api/auth/phone-login`,Body: `{"username": "商户名", "code": "123456"}` + 3. 成功后返回 `{"token": "05c55d8b8c...", "user": {...}}`,前端存入 `localStorage.yd_token`。 + - **方式二(Token 直接配置)**: + 直接携带有效的 `yd_token` 发起全部 API 调用。因上游商户账号无固定密码,**实施阶段确认采用后台直接录入 Token 的模式进行对接**,后续再按需考虑是否引入验证码登录交互。 + +--- + +## 五、 已知、未知与对接目标清单 + +### 5.1 已知事项 (Knowns) +1. **网络通信协议无加密防护**:请求为明文 JSON,无需逆向复杂的 Webpack 虚拟机加解密或 WAF 签名算法,通信门槛低。 +2. **商户账号鉴权特征明确**:上游**无独立密码登录**,仅支持 Token 登录与“用户名+手机短信验证码”登录。登录态通过 `Authorization: Bearer ` 全局透传,有效期长且支持通过 `GET /api/me` 实时探测状态与积分余额。 +3. **角色绑定走和平精英官方 H5 授权**:由腾讯官方活动页面(`gp.qq.com`)承接鉴权,安全合规,无风控封号风险。 +4. **提取统一下单通用 Token,绑定后发货前动态选择皮肤**: + - 提取采购阶段统一下单为“黑色通用 Token”(`1 = 黑色`),生成通用绑定链接; + - 买家扫码并通过 UID 强一致门禁后,在最终发货调用 `POST /api/redeem/{id}` 时,根据订单商品动态传入 `skin`(黑色ROCK或蓝色运动服)。 +5. **具备官方发货凭证与对账接口**: + - 上游返回 `fulfillId`(腾讯官方发货单号),可直接作为我们系统的 `ProviderOrderNo`; + - 提供 `GET /api/user/exchange-records`,支持异步核验与网络超时防重对账。 +6. **公开免登录能力完整**:买家端可直接使用 `redeem_code` 独立完成绑定与兑换,避免在买家端暴露平台主账号的 Bearer Token。 + +### 5.2 核心业务决策与防错机制 (Decisions & Guards) +1. **后台全反代无感方案**:系统统一反代,买家和打手使用本系统原生提货页 `/delivery/:orderNo`,绝不暴露上游平台。 +2. **UID 强一致防御门禁**:前置强制输入 UID,捕获角色不符时硬阻断发货;记录初始快照防止上游旧绑定信息混淆。 +3. **系统用户采购凭据先落 Token 模式**:后台提供极简 Bearer Token 录入与一键连通性测试(调 `/api/me`),自动读取用户名和积分;暂不接入验证码流程,避免多余交互。 +4. **本地通用库存池与自动补货**:本地维护统一的通用 Token 池,支持采购 50 个、低水位告警与自动补货。 +5. **绑定错误统计与全局总开关**:mismatch 独立看板,全局发货总开关(Kill Switch)一键止血熔断转人工。 + +### 5.3 下一步对接目标 (Next Steps / Goals) +1. 建立本地 `rock_tokens` 通用库存表与系统配置存储(存储 `rock_bearer_token` 等)。 +2. 开发上游 0666 纯 HTTP 交互模块(Claim 采购、Refresh 刷新、Redeem 兑换、ExchangeRecords 对账、Me 心跳与积分探测)。 +3. 接入后台批量采购 50 个、库存列表、mismatch 统计看板、水位预警(飞书/钉钉)与自动补货任务。 +4. 提货前端接入 UID 前置输入与扫码一致性校验硬阻断。 +5. 完成标准 `RockProvider` 接入既有路由与发货工作台。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1152a9b..d99f9e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import AlertSettings from './pages/AlertSettings' import PlatformMerchants from './pages/PlatformMerchants' import PlatformProducts from './pages/PlatformProducts' import PlatformUsers from './pages/PlatformUsers' +import PlatformRock from './pages/PlatformRock' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { const { token } = useAuth() @@ -95,6 +96,14 @@ function AppRoutes() { } /> + + + + } + /> } /> } /> r.data.data as { updated: boolean }), } +export interface RockConfig { + id: number + auto_delivery_enabled: boolean + base_url: string + bearer_token: string + username: string + points: number + min_points_warning: number + token_warning_stock: number + auto_purchase_enabled: boolean + auto_purchase_count: number + purchase_cooldown_minutes: number + last_purchased_at?: string + last_heartbeat_at?: string +} + +export interface RockStats { + available_stock: number + using_stock: number + bound_stock: number + redeemed_stock: number + mismatch_stock: number + abnormal_stock: number + upstream_points: number + auto_delivery_enabled: boolean + token_warning_stock: number + auto_purchase_enabled: boolean + username: string + last_heartbeat_at?: string +} + +export interface RockTokenItem { + id: number + upstream_token_id: string + redeem_code: string + color: string + region: string + status: string + assigned_order_no?: string + expected_uid?: string + actual_role_name?: string + actual_role_id?: string + actual_channel?: string + upstream_fulfill_id?: number + target_skin?: string + target_currency?: string + target_currency_quantity?: number + mismatch_at?: string + mismatch_reason?: string + claimed_at?: string + bound_at?: string + redeemed_at?: string + created_at: string +} + +export const rockApi = { + getConfig: () => request.get('/platform/rock/config').then((r) => r.data.data as RockConfig), + updateConfig: (data: Partial) => request.put('/platform/rock/config', data).then((r) => r.data.data as RockConfig), + testConnection: (bearer_token?: string) => request.post('/platform/rock/test-conn', { bearer_token }).then((r) => r.data.data), + getStats: () => request.get('/platform/rock/stats').then((r) => r.data.data as RockStats), + purchaseTokens: (count = 50) => request.post('/platform/rock/purchase', { count }).then((r) => r.data.data), + listTokens: (params?: { page?: number; page_size?: number; status?: string; search?: string }) => + request.get('/platform/rock/tokens', { params }).then((r) => r.data.data as { list: RockTokenItem[]; total: number; page: number; page_size: number }), + resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data), +} + + diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 9c65701..961b482 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -69,6 +69,7 @@ const adminSections: SidebarSection[] = [ children: [ { key: 'merchant-products', label: '商品列表', path: '/merchant-products' }, { key: 'platform-products', label: '渠道与路由配置', path: '/platform-products' }, + { key: 'platform-rock', label: 'Rock发货与库存风控', path: '/platform-rock' }, ], }, { diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx new file mode 100644 index 0000000..d398f77 --- /dev/null +++ b/frontend/src/pages/PlatformRock.tsx @@ -0,0 +1,609 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Alert, + Badge, + Button, + Card, + Col, + Divider, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Radio, + Space, + Statistic, + Switch, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import { + CheckCircleOutlined, + ExclamationCircleOutlined, + FireOutlined, + KeyOutlined, + PlusCircleOutlined, + ReloadOutlined, + SearchOutlined, + ShoppingCartOutlined, + ThunderboltOutlined, + UndoOutlined, +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api' +import { PageHeader } from '../components/PageHeader' + +const { Text } = Typography + +export default function PlatformRock() { + const [config, setConfig] = useState(null) + const [stats, setStats] = useState(null) + const [loadingStats, setLoadingStats] = useState(false) + const [testingConn, setTestingConn] = useState(false) + const [savingConfig, setSavingConfig] = useState(false) + const [purchasing, setPurchasing] = useState(false) + + // Token 列表与筛选 + const [tokens, setTokens] = useState([]) + const [totalTokens, setTotalTokens] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [filterStatus, setFilterStatus] = useState('all') + const [searchKeyword, setSearchKeyword] = useState('') + const [loadingTokens, setLoadingTokens] = useState(false) + + const [form] = Form.useForm() + + const loadData = useCallback(async () => { + setLoadingStats(true) + try { + const [cfg, st] = await Promise.all([rockApi.getConfig(), rockApi.getStats()]) + setConfig(cfg) + setStats(st) + form.setFieldsValue({ + auto_delivery_enabled: cfg.auto_delivery_enabled, + base_url: cfg.base_url, + bearer_token: cfg.bearer_token, + min_points_warning: cfg.min_points_warning, + token_warning_stock: cfg.token_warning_stock, + auto_purchase_enabled: cfg.auto_purchase_enabled, + auto_purchase_count: cfg.auto_purchase_count, + purchase_cooldown_minutes: cfg.purchase_cooldown_minutes, + }) + } catch (e) { + message.error('加载 Rock 平台配置失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setLoadingStats(false) + } + }, [form]) + + const loadTokens = useCallback(async () => { + setLoadingTokens(true) + try { + const res = await rockApi.listTokens({ + page, + page_size: pageSize, + status: filterStatus === 'all' ? undefined : filterStatus, + search: searchKeyword || undefined, + }) + setTokens(res.list || []) + setTotalTokens(res.total || 0) + } catch (e) { + message.error('加载库存列表失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setLoadingTokens(false) + } + }, [page, pageSize, filterStatus, searchKeyword]) + + useEffect(() => { + loadData() + }, [loadData]) + + useEffect(() => { + loadTokens() + }, [loadTokens]) + + // 一键切换自动发货总开关 (Kill Switch) + const handleToggleAutoDelivery = async (checked: boolean) => { + Modal.confirm({ + title: checked ? '确认开启 Rock 自动发货通道?' : '确认一键关闭 Rock 自动发货通道(软熔断)?', + content: checked + ? '开启后,黑色 ROCK 与蓝色运动服两款商品将恢复全自动反向代理发货。' + : '关闭后,两款商品将彻底脱离自动发货流程,订单将转交人工打手或客服处理,避免错单穿透。', + okText: '确认更改', + cancelText: '取消', + okType: checked ? 'primary' : 'danger', + onOk: async () => { + try { + const updated = await rockApi.updateConfig({ auto_delivery_enabled: checked }) + setConfig(updated) + message.success(checked ? '自动发货已开启' : '自动发货已成功熔断关闭') + loadData() + } catch (e) { + message.error('更新总开关失败: ' + (e instanceof Error ? e.message : '未知错误')) + } + }, + }) + } + + // 测试连通性 + const handleTestConnection = async () => { + const bearerToken = form.getFieldValue('bearer_token') + if (!bearerToken) { + message.warning('请先输入 Bearer Token') + return + } + setTestingConn(true) + try { + const res = await rockApi.testConnection(bearerToken) + message.success(`连通成功!采购商户:${res.user?.username || '正常'},可用积分:${res.user?.points || 0}`) + loadData() + } catch (e) { + message.error('连通性测试失败:' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setTestingConn(false) + } + } + + // 保存策略配置 + const handleSaveConfig = async () => { + try { + const values = await form.validateFields() + setSavingConfig(true) + const updated = await rockApi.updateConfig(values) + setConfig(updated) + message.success('配置已保存生效') + loadData() + } catch (e) { + if (e instanceof Error) { + message.error('保存失败: ' + e.message) + } + } finally { + setSavingConfig(false) + } + } + + // 批量采购 50 个 + const handlePurchase = async (count = 50) => { + setPurchasing(true) + try { + const res = await rockApi.purchaseTokens(count) + message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`) + loadData() + loadTokens() + } catch (e) { + message.error('采购失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setPurchasing(false) + } + } + + // 人工重置解冻 Mismatch Token + const handleResetToken = async (id: number) => { + try { + await rockApi.resetToken(id) + message.success('已解冻重置为可用库存') + loadData() + loadTokens() + } catch (e) { + message.error('重置失败: ' + (e instanceof Error ? e.message : '未知错误')) + } + } + + const columns: ColumnsType = [ + { + title: 'ID / 提取码', + dataIndex: 'id', + width: 140, + render: (id, record) => ( + + #{id} + + {record.redeem_code?.substring(0, 10)}... + + + ), + }, + { + title: '颜色与适用范围', + dataIndex: 'color', + width: 130, + render: (_, record) => ( + + 通用黑色 Token + + {record.region || 'Q/V可用'} + + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (status) => { + switch (status) { + case 'available': + return 可用库存 + case 'using': + return 使用中 (扫码) + case 'bound': + return 已绑定授权 + case 'redeemed': + return 已交付发货 + case 'mismatch': + return 绑定错误 (阻断) + default: + return {status} + } + }, + }, + { + title: '关联订单', + dataIndex: 'assigned_order_no', + width: 170, + render: (orderNo) => + orderNo ? ( + + {orderNo} + + ) : ( + - + ), + }, + { + title: '预期 UID vs 扫码角色', + width: 220, + render: (_, record) => { + const hasMismatch = record.status === 'mismatch' + return ( + +
+ 预期UID: + {record.expected_uid || '-'} +
+
+ 扫码角色: + + {record.actual_role_name ? `${record.actual_role_name} (${record.actual_role_id || '-'})` : '-'} + +
+ {hasMismatch && record.mismatch_reason && ( + + + {record.mismatch_reason} + + + )} +
+ ) + }, + }, + { + title: '官方发货凭证 (FulfillId)', + dataIndex: 'upstream_fulfill_id', + width: 150, + render: (fulfillId, record) => + fulfillId ? ( + + + {fulfillId} + + + {record.target_skin || '和平精英道具'} + + + ) : ( + - + ), + }, + { + title: '操作', + key: 'action', + width: 100, + render: (_, record) => { + if (record.status === 'mismatch') { + return ( + handleResetToken(record.id)} + > + + + ) + } + return - + }, + }, + ] + + const autoDelivery = config?.auto_delivery_enabled ?? true + const availableStock = stats?.available_stock ?? 0 + const isStockLow = availableStock <= (config?.token_warning_stock ?? 10) + + return ( +
+ + + {/* 熔断状态警示条 */} + {!autoDelivery && ( + + )} + + {/* 核心指标统计看板 */} + + + + } + suffix="个" + /> +
+ + 警戒水位: {config?.token_warning_stock ?? 10} 个 + + {isStockLow && 库存紧缺} +
+
+ + + + + 0 ? '#f5222d' : '#8c8c8c', fontWeight: 'bold' }} + prefix={} + suffix="个" + /> +
+ + {(stats?.mismatch_stock ?? 0) > 0 ? '存在 UID 校验不匹配的待排查记录' : '当前无绑定异常'} + +
+
+ + + + + } + suffix={`(扫码中: ${stats?.using_stock ?? 0})`} + /> +
+ + 已完成发货交付总量 + +
+
+ + + + + } + suffix="点" + /> +
+ + 账号: {stats?.username || '未同步'} + + + 约可采购 {Math.floor((stats?.upstream_points ?? 0) / 21)} 个 + +
+
+ +
+ + {/* 上游凭据与策略设置 */} + + + 0666.pro 采购凭据与自动发货控制 + + } + extra={ + + + + + } + style={{ marginBottom: 24, borderRadius: 8 }} + > +
+ + + + 自动发货总开关 (Kill Switch) + + + + + } + > + + + + {autoDelivery ? '通道正常,全自动反代履约中' : '已熔断!商品已下线自动通道转交人工处理'} + + + + + + + + + + + + + + 上游商户 Bearer Token (yd_token) + + (平台无密码,直接输入从 0666.pro 网页复制的 64 位 Token) + + + } + extra={ +
+ + 💡 极简提取指引:登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '} + localStorage.getItem('yd_token') 即可一键复制。 + +
+ } + > + + 测试连通性 + + } + /> +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + {/* 本地通用库存池明细 */} + + + 通用 Token 库存池与错单审计列表 + + } + extra={ + + setFilterStatus(e.target.value)} buttonStyle="solid"> + 全部 + 可用 + 使用中 + 已绑定 + 已发货 + + + 绑定错误 + + + + + } + style={{ width: 220 }} + onPressEnter={(e) => setSearchKeyword(e.currentTarget.value)} + onChange={(e) => { + if (!e.target.value) setSearchKeyword('') + }} + /> + + + } + style={{ borderRadius: 8 }} + > + { + setPage(p) + setPageSize(ps) + }, + }} + /> + + + ) +}