From 8494fabc823dd0748091a8293d552ef714d3f972 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Thu, 29 Oct 2026 16:05:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=87=AA=E5=8A=A8=E8=A1=A5=E8=B4=A7/?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E9=87=87=E8=B4=AD=E9=80=BB=E8=BE=91=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=EF=BC=9A=E6=89=B9=E9=87=8F=E8=8E=B7=E5=8F=96-?= =?UTF-8?q?=E3=80=8B=E6=89=93=E5=8C=85-=E3=80=8B=E8=8E=B7=E5=8F=96Token?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/015_rock_packages_final.sql | 25 + .../migrations/016_rock_black_token_cost.sql | 7 + backend/internal/handler/rock.go | 10 + backend/internal/model/rock.go | 8 + backend/internal/router/router.go | 1 + .../internal/service/provider_rock_test.go | 4 + backend/internal/service/rock_client.go | 248 +++ .../internal/service/rock_dashboard_test.go | 179 +++ backend/internal/service/rock_package.go | 170 ++ .../service/rock_purchase_package_test.go | 272 ++++ backend/internal/service/rock_service.go | 63 + backend/internal/service/rock_stock.go | 393 ++++- docs/Rock模块逻辑分析.md | 413 +++++ docs/系统脑图与流程图.md | 257 +++ docs/项目梳理-业务流程与数据模型.md | 1407 +++++++++++++++++ frontend/src/api/index.ts | 20 + frontend/src/pages/PlatformRock.tsx | 97 +- .../pages/platformRock/PurchaseTokenModal.tsx | 60 +- .../pages/platformRock/RockMetricCards.tsx | 52 +- .../pages/platformRock/RockStrategyCard.tsx | 23 +- .../pages/platformRock/RockTokenFilterBar.tsx | 6 +- .../src/pages/platformRock/RockTokenTable.tsx | 7 +- .../src/pages/platformRock/tokenColumns.tsx | 42 +- 23 files changed, 3679 insertions(+), 85 deletions(-) create mode 100644 backend/internal/database/migrations/015_rock_packages_final.sql create mode 100644 backend/internal/database/migrations/016_rock_black_token_cost.sql create mode 100644 backend/internal/service/rock_dashboard_test.go create mode 100644 backend/internal/service/rock_package.go create mode 100644 backend/internal/service/rock_purchase_package_test.go create mode 100644 docs/Rock模块逻辑分析.md create mode 100644 docs/系统脑图与流程图.md create mode 100644 docs/项目梳理-业务流程与数据模型.md diff --git a/backend/internal/database/migrations/015_rock_packages_final.sql b/backend/internal/database/migrations/015_rock_packages_final.sql new file mode 100644 index 0000000..f67e638 --- /dev/null +++ b/backend/internal/database/migrations/015_rock_packages_final.sql @@ -0,0 +1,25 @@ +-- ============================================================================ +-- 015_rock_packages_final.sql +-- Rock 打包 Token 字段 + 采购失败冷却/告警字段(合并原 015/016/017 的最终态) +-- ============================================================================ + +-- 原 016:最终态不再保留上游流转时间字段 +DROP INDEX IF EXISTS idx_rock_tokens_upstream_updated_at; +ALTER TABLE rock_tokens DROP COLUMN IF EXISTS upstream_updated_at; + +-- 原 015:打包 Token 字段 +ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS package_id BIGINT; +ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS raw_data TEXT NOT NULL DEFAULT ''; +ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS source VARCHAR(32) NOT NULL DEFAULT 'purchase'; + +CREATE INDEX IF NOT EXISTS idx_rock_tokens_package_id ON rock_tokens (package_id); + +-- 导入幂等键:上游 0666 编号唯一 +CREATE UNIQUE INDEX IF NOT EXISTS idx_rock_tokens_upstream_token_id + ON rock_tokens (upstream_token_id) + WHERE upstream_token_id <> ''; + +-- 原 017:采购尝试/失败冷却/告警 +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMPTZ; +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS last_error TEXT NOT NULL DEFAULT ''; +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ; diff --git a/backend/internal/database/migrations/016_rock_black_token_cost.sql b/backend/internal/database/migrations/016_rock_black_token_cost.sql new file mode 100644 index 0000000..f97aeec --- /dev/null +++ b/backend/internal/database/migrations/016_rock_black_token_cost.sql @@ -0,0 +1,7 @@ +-- ============================================================================ +-- 016_rock_black_token_cost.sql +-- Rock 上游黑色 Token 采购单价(页面打开时从 /api/user/dashboard 同步) +-- ============================================================================ + +ALTER TABLE rock_configs + ADD COLUMN IF NOT EXISTS black_token_cost NUMERIC(12,2) NOT NULL DEFAULT 20.00; diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index e7b6ef8..7de9102 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -69,6 +69,16 @@ func (h *RockHandler) GetStats(c *gin.Context) { response.OK(c, stats) } +// RefreshDashboard 拉取上游 dashboard 并同步最新采购单价。 +func (h *RockHandler) RefreshDashboard(c *gin.Context) { + result, err := h.rockSvc.RefreshDashboard(c.Request.Context()) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, result) +} + type purchaseReq struct { Count int `json:"count"` } diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index 82a1feb..56855a3 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -41,11 +41,15 @@ type RockConfig struct { 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"` + BlackTokenCost float64 `gorm:"type:numeric(12,2);not null;default:20.00" json:"black_token_cost"` TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"` 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"` + LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` // 最近一次采购尝试(成功/失败)时间,用于失败冷却 + LastError string `gorm:"type:text;not null;default:''" json:"last_error"` // 最近一次补货/打包错误,成功后清空 + LastErrorAt *time.Time `json:"last_error_at,omitempty"` LastHeartbeatAt *time.Time `json:"last_heartbeat_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -64,8 +68,10 @@ type RockToken struct { 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"` + PackageID *int64 `gorm:"index" json:"package_id,omitempty"` AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"` ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号 + TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化 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"` @@ -78,6 +84,8 @@ type RockToken struct { MismatchAt *time.Time `gorm:"index" json:"mismatch_at"` MismatchReason string `gorm:"size:255" json:"mismatch_reason"` Remark string `gorm:"type:text;not null;default:''" json:"remark"` + RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"` + Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"` ClaimedAt *time.Time `json:"claimed_at"` BoundAt *time.Time `json:"bound_at"` RedeemedAt *time.Time `json:"redeemed_at"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 59609f3..4a2a439 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -287,6 +287,7 @@ func Setup(h *Handlers) *gin.Engine { platform.PUT("/platform/rock/config", h.Rock.UpdateConfig) platform.POST("/platform/rock/test-conn", h.Rock.TestConnection) platform.GET("/platform/rock/stats", h.Rock.GetStats) + platform.POST("/platform/rock/dashboard/refresh", h.Rock.RefreshDashboard) platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens) platform.GET("/platform/rock/tokens", h.Rock.ListTokens) platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID) diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go index 3060a25..b8f8f31 100644 --- a/backend/internal/service/provider_rock_test.go +++ b/backend/internal/service/provider_rock_test.go @@ -394,6 +394,10 @@ func TestRockService_StatsAndPurchase(t *testing.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/user/dashboard" { + writeRockDashboardJSON(w, 999, "20.0") + return + } if r.URL.Path == "/api/me" { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "user": map[string]interface{}{ diff --git a/backend/internal/service/rock_client.go b/backend/internal/service/rock_client.go index 4ea9f7d..6ab0e1a 100644 --- a/backend/internal/service/rock_client.go +++ b/backend/internal/service/rock_client.go @@ -249,6 +249,178 @@ type RockExchangeRecordsResponse struct { Error string `json:"error,omitempty"` } +// RockPackage 是上游打包记录(/api/user/packages)的列表项。 +type RockPackage struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + TokenCount int `json:"token_count"` + ShareCode string `json:"share_code"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Note string `json:"note"` +} + +// RockPackageToken 是打包详情(/api/package/{share_code})里的 Token 原始结构。 +// 字段命名与 0666.pro 返回保持一致,便于把整条 JSON 原样存入 raw_data。 +type RockPackageToken struct { + ID int64 `json:"id"` + RedeemCode string `json:"redeem_code"` + Token string `json:"token"` + Color string `json:"color"` + Region string `json:"region"` + Phone string `json:"phone"` + Status string `json:"status"` + BoundAccount string `json:"bound_account"` + BoundAccountID string `json:"bound_account_id"` + PreviousBoundAccount string `json:"previous_bound_account"` + PreviousBoundAccountID string `json:"previous_bound_account_id"` + BindChangedAt string `json:"bind_changed_at"` + BindChangeType string `json:"bind_change_type"` + RedeemSkin string `json:"redeem_skin"` + RedeemCurrency string `json:"redeem_currency"` + RedeemedAt string `json:"redeemed_at"` + ClaimGroup string `json:"claim_group"` + ClaimRound int `json:"claim_round"` + ClaimNo int `json:"claim_no"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type RockPackageDetailResponse struct { + Package RockPackage `json:"package"` + Owner RockPackageOwner `json:"owner"` + Tokens []RockPackageToken `json:"tokens"` + Config map[string]any `json:"config"` + Settings map[string]any `json:"settings"` + Error string `json:"error,omitempty"` +} + +type RockPackageOwner struct { + Username string `json:"username"` + Phone string `json:"phone"` +} + +// RockPackageCreateResponse 是 POST /api/user/packages 的返回结构。 +// 上游示例:{"package":{"id":1512,"token_count":2,"share_code":"1448fffa85bb3d4f913c",...}} +type RockPackageCreateResponse struct { + Package *RockPackage `json:"package"` + Packages []RockPackage `json:"packages"` + ShareCode string `json:"share_code"` + URL string `json:"url"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +// ResolvePackage 兼容 package / packages / share_code / url 四种返回形态,返回包 ID 与 share_code。 +func (r *RockPackageCreateResponse) ResolvePackage() (int64, string) { + if r == nil { + return 0, "" + } + if r.Package != nil { + if code := strings.TrimSpace(r.Package.ShareCode); code != "" { + return r.Package.ID, code + } + } + for _, p := range r.Packages { + if code := strings.TrimSpace(p.ShareCode); code != "" { + return p.ID, code + } + } + if code := strings.TrimSpace(r.ShareCode); code != "" { + return 0, code + } + if rawURL := strings.TrimSpace(r.URL); rawURL != "" { + if idx := strings.LastIndex(rawURL, "/"); idx >= 0 && idx < len(rawURL)-1 { + return 0, strings.TrimSpace(rawURL[idx+1:]) + } + } + return 0, "" +} + +// CreatePackage 把一批已提取 Token 打包成公开分享包(需 Bearer Token)。 +// 注意:上游未返回 share_code 时不会在这里报错,由调用方决定兜底策略。 +func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids []string, note string) (*RockPackageCreateResponse, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("打包 ids 不能为空") + } + reqURL := fmt.Sprintf("%s/api/user/packages", c.BaseURL()) + payload := map[string]interface{}{ + "ids": ids, + "note": note, + } + 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, text/plain, */*") + req.Header.Set("Origin", c.BaseURL()) + req.Header.Set("Referer", c.BaseURL()+"/") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 packages 创建接口失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 packages 创建响应失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 packages 创建接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + + var res RockPackageCreateResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 packages 创建响应失败: %w", err) + } + if res.Error != "" { + return nil, fmt.Errorf("上游 packages 创建业务错误: %s", res.Error) + } + return &res, nil +} + +// GetPackageDetail 按 share_code 查询打包详情(公开接口,无需 Bearer)。 +func (c *RockClient) GetPackageDetail(ctx context.Context, shareCode string) (*RockPackageDetailResponse, error) { + shareCode = strings.TrimSpace(shareCode) + if shareCode == "" { + return nil, fmt.Errorf("share_code 不能为空") + } + reqURL := fmt.Sprintf("%s/api/package/%s", c.BaseURL(), url.PathEscape(shareCode)) + 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 package 详情失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 package 详情响应失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 package 详情接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + var res RockPackageDetailResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 package 详情失败: %w", err) + } + if res.Error != "" { + return nil, fmt.Errorf("上游 package 详情业务错误: %s", res.Error) + } + return &res, nil +} + // GetMe 获取商户个人信息及积分 func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) { reqURL := fmt.Sprintf("%s/api/me", c.BaseURL()) @@ -287,6 +459,82 @@ func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResp return &res, nil } +// RockDashboardResponse 是 GET /api/user/dashboard 的返回结构。 +type RockDashboardResponse struct { + Available struct { + Black int `json:"black"` + Blue int `json:"blue"` + } `json:"available"` + EffectiveTokenCosts struct { + BlackTokenCost FlexibleFloat64 `json:"blackTokenCost"` + BlueTokenCost FlexibleFloat64 `json:"blueTokenCost"` + } `json:"effectiveTokenCosts"` + Config struct { + BlackTokenCost FlexibleFloat64 `json:"blackTokenCost"` + BlueTokenCost FlexibleFloat64 `json:"blueTokenCost"` + } `json:"config"` + Error string `json:"error,omitempty"` +} + +// ResolveBlackTokenCost 优先取 effectiveTokenCosts,其次 config。 +func (r *RockDashboardResponse) ResolveBlackTokenCost() float64 { + if r == nil { + return 0 + } + if cost := float64(r.EffectiveTokenCosts.BlackTokenCost); cost > 0 { + return cost + } + return float64(r.Config.BlackTokenCost) +} + +func (r *RockDashboardResponse) ResolveBlueTokenCost() float64 { + if r == nil { + return 0 + } + if cost := float64(r.EffectiveTokenCosts.BlueTokenCost); cost > 0 { + return cost + } + return float64(r.Config.BlueTokenCost) +} + +// GetDashboard 获取上游可用库存与实时采购单价(需 Bearer Token)。 +func (c *RockClient) GetDashboard(ctx context.Context, bearerToken string) (*RockDashboardResponse, error) { + reqURL := fmt.Sprintf("%s/api/user/dashboard", 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, text/plain, */*") + req.Header.Set("Origin", c.BaseURL()) + req.Header.Set("Referer", c.BaseURL()+"/") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 dashboard 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 dashboard 响应失败: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 dashboard 接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + var res RockDashboardResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 dashboard 响应失败: %w", err) + } + if res.Error != "" { + return nil, fmt.Errorf("上游 dashboard 业务错误: %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 { diff --git a/backend/internal/service/rock_dashboard_test.go b/backend/internal/service/rock_dashboard_test.go new file mode 100644 index 0000000..96b8ea5 --- /dev/null +++ b/backend/internal/service/rock_dashboard_test.go @@ -0,0 +1,179 @@ +package service + +import ( + "context" + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "affiliate_dash/internal/model" +) + +// writeRockDashboardJSON 输出与 0666.pro 一致的 dashboard 结构,供各用例复用。 +func writeRockDashboardJSON(w http.ResponseWriter, blackStock int, blackCost string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "available": map[string]int{"black": blackStock, "blue": 7}, + "effectiveTokenCosts": map[string]string{"blackTokenCost": blackCost, "blueTokenCost": "13.0"}, + }) +} + +func TestRefreshDashboard_SameCostSkipsUpdate(t *testing.T) { + db := newRockTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/user/dashboard" { + w.WriteHeader(http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer bearer-test" { + w.WriteHeader(http.StatusUnauthorized) + return + } + writeRockDashboardJSON(w, 326, "20.0") + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + // 冻结 updated_at:价格未变化时不允许产生任何 UPDATE。 + frozen := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + if err := db.Model(&model.RockConfig{}).Where("id = 1").UpdateColumn("updated_at", frozen).Error; err != nil { + t.Fatalf("冻结 updated_at 失败: %v", err) + } + + res, err := svc.RefreshDashboard(context.Background()) + if err != nil { + t.Fatalf("刷新 dashboard 失败: %v", err) + } + if res.AvailableBlack != 326 { + t.Fatalf("上游可用库存应为 326,实际 %d", res.AvailableBlack) + } + if math.Abs(res.BlackTokenCost-20) > 1e-9 { + t.Fatalf("采购单价应为 20,实际 %v", res.BlackTokenCost) + } + if res.CostUpdated { + t.Fatalf("价格一致时不应标记为已更新") + } + + cfg, _ := svc.GetConfig(context.Background()) + if math.Abs(cfg.BlackTokenCost-20) > 1e-9 { + t.Fatalf("价格一致时库值不应变化,实际 %v", cfg.BlackTokenCost) + } + if !cfg.UpdatedAt.Equal(frozen) { + t.Fatalf("价格一致时不应写库,updated_at 被改为 %v", cfg.UpdatedAt) + } +} + +func TestRefreshDashboard_UpdatesChangedCost(t *testing.T) { + db := newRockTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/user/dashboard" { + w.WriteHeader(http.StatusNotFound) + return + } + writeRockDashboardJSON(w, 88, "21.5") + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + res, err := svc.RefreshDashboard(context.Background()) + if err != nil { + t.Fatalf("刷新 dashboard 失败: %v", err) + } + if !res.CostUpdated { + t.Fatalf("价格变化时应标记为已更新") + } + if math.Abs(res.BlackTokenCost-21.5) > 1e-9 { + t.Fatalf("采购单价应为 21.5,实际 %v", res.BlackTokenCost) + } + + cfg, _ := svc.GetConfig(context.Background()) + if math.Abs(cfg.BlackTokenCost-21.5) > 1e-9 { + t.Fatalf("采购单价应同步为 21.5,实际 %v", cfg.BlackTokenCost) + } +} + +func TestPurchaseTokens_BlocksWhenUpstreamStockInsufficient(t *testing.T) { + db := newRockTestDB(t) + var claimCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/user/dashboard": + writeRockDashboardJSON(w, 1, "20.0") + case "/api/user/tokens/claim": + atomic.AddInt32(&claimCalls, 1) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tokens": []map[string]interface{}{ + {"id": 41001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"}, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + _, err := svc.PurchaseTokens(context.Background(), 2, false) + if err == nil || !strings.Contains(err.Error(), "上游可用库存不足") { + t.Fatalf("库存不足时应阻止采购,实际 err=%v", err) + } + if calls := atomic.LoadInt32(&claimCalls); calls != 0 { + t.Fatalf("库存不足时不应调用上游 claim,实际调用 %d 次", calls) + } + + var count int64 + db.Model(&model.RockToken{}).Count(&count) + if count != 0 { + t.Fatalf("库存不足时不应入库任何 Token,实际 %d 条", count) + } + + cfg, _ := svc.GetConfig(context.Background()) + if !strings.Contains(cfg.LastError, "上游可用库存不足") { + t.Fatalf("last_error 应记录库存不足,实际 %q", cfg.LastError) + } +} + +func TestPurchaseTokens_BlocksWhenDashboardFails(t *testing.T) { + db := newRockTestDB(t) + var claimCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/user/dashboard": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"dashboard down"}`)) + case "/api/user/tokens/claim": + atomic.AddInt32(&claimCalls, 1) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"tokens": []interface{}{}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + _, err := svc.PurchaseTokens(context.Background(), 2, true) + if err == nil || !strings.Contains(err.Error(), "获取上游库存失败,已取消本次采购") { + t.Fatalf("dashboard 查询失败时应阻止采购,实际 err=%v", err) + } + if calls := atomic.LoadInt32(&claimCalls); calls != 0 { + t.Fatalf("dashboard 失败时不应调用上游 claim,实际调用 %d 次", calls) + } + + cfg, _ := svc.GetConfig(context.Background()) + if !strings.Contains(cfg.LastError, "获取上游库存失败,已取消本次采购") { + t.Fatalf("last_error 应记录 dashboard 失败,实际 %q", cfg.LastError) + } +} diff --git a/backend/internal/service/rock_package.go b/backend/internal/service/rock_package.go new file mode 100644 index 0000000..8bc8843 --- /dev/null +++ b/backend/internal/service/rock_package.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" +) + +// importPackageDetail 把单个包详情幂等写入本地 Token,返回新增/更新/跳过数量。 +func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPackageDetailResponse, forceAvailableIDs map[string]struct{}) (int, int, int, error) { + if detail == nil || detail.Package.ID == 0 { + return 0, 0, 0, errors.New("上游未返回 package.id") + } + + packageID := detail.Package.ID + inserted, updated, skipped := 0, 0, 0 + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, pt := range detail.Tokens { + if pt.ID <= 0 { + skipped++ + continue + } + upstreamID := strconv.FormatInt(pt.ID, 10) + status := normalizeRockPackageStatus(pt.Status) + raw, marshalErr := json.Marshal(pt) + if marshalErr != nil { + raw = []byte(`{}`) + } + claimedAt := parseRockPackageTime(pt.CreatedAt) + boundAt := parseRockPackageTime(pt.BindChangedAt) + redeemedAt := parseRockPackageTime(pt.RedeemedAt) + + var existing model.RockToken + findErr := tx.Where("upstream_token_id = ?", upstreamID).First(&existing).Error + if errors.Is(findErr, gorm.ErrRecordNotFound) { + insertStatus := status + if _, ok := forceAvailableIDs[upstreamID]; ok { + insertStatus = model.RockStatusAvailable + } + rec := model.RockToken{ + UpstreamTokenID: upstreamID, + Token: strings.TrimSpace(pt.Token), + RedeemCode: strings.TrimSpace(pt.RedeemCode), + Color: defaultValue(strings.TrimSpace(pt.Color), model.RockColorBlack), + Region: defaultValue(strings.TrimSpace(pt.Region), "Q/V可用"), + Status: insertStatus, + PackageID: &packageID, + ActualRoleName: strings.TrimSpace(pt.BoundAccount), + ActualRoleID: strings.TrimSpace(pt.BoundAccountID), + ActualChannel: strings.TrimSpace(pt.Region), + TargetSkin: strings.TrimSpace(pt.RedeemSkin), + TargetCurrency: defaultValue(strings.TrimSpace(pt.RedeemCurrency), "幸运币"), + TargetCurrencyQuantity: 0, + RawData: string(raw), + Source: "package", + ClaimedAt: claimedAt, + BoundAt: boundAt, + RedeemedAt: redeemedAt, + } + if err := tx.Create(&rec).Error; err != nil { + return err + } + inserted++ + continue + } + if findErr != nil { + return findErr + } + + updates := map[string]interface{}{ + "package_id": packageID, + "raw_data": string(raw), + } + // 已存在的本地 Token 只补空字段,避免覆盖手动采购/正在履约的本地状态。 + if existing.Token == "" { + updates["token"] = strings.TrimSpace(pt.Token) + } + if existing.RedeemCode == "" { + updates["redeem_code"] = strings.TrimSpace(pt.RedeemCode) + } + if existing.Color == "" { + updates["color"] = defaultValue(strings.TrimSpace(pt.Color), model.RockColorBlack) + } + if existing.Region == "" { + updates["region"] = defaultValue(strings.TrimSpace(pt.Region), "Q/V可用") + } + if existing.Source == "" { + updates["source"] = "package" + } + + // 打包导入且没有本地订单的记录:允许随上游刷新展示字段; + // 但手工释放为 available 的记录保持 available,避免全量导入把可售库存回退。 + canRefreshUpstream := existing.Source == "package" && + strings.TrimSpace(existing.AssignedOrderNo) == "" && + existing.Status != model.RockStatusAvailable + if canRefreshUpstream { + updates["status"] = status + if strings.TrimSpace(pt.BoundAccount) != "" { + updates["actual_role_name"] = strings.TrimSpace(pt.BoundAccount) + } + if strings.TrimSpace(pt.BoundAccountID) != "" { + updates["actual_role_id"] = strings.TrimSpace(pt.BoundAccountID) + } + if strings.TrimSpace(pt.Region) != "" { + updates["actual_channel"] = strings.TrimSpace(pt.Region) + } + if strings.TrimSpace(pt.RedeemSkin) != "" { + updates["target_skin"] = strings.TrimSpace(pt.RedeemSkin) + } + if strings.TrimSpace(pt.RedeemCurrency) != "" { + updates["target_currency"] = strings.TrimSpace(pt.RedeemCurrency) + } + if claimedAt != nil { + updates["claimed_at"] = claimedAt + } + if boundAt != nil { + updates["bound_at"] = boundAt + } + if redeemedAt != nil { + updates["redeemed_at"] = redeemedAt + } + } + if err := tx.Model(&existing).Updates(updates).Error; err != nil { + return err + } + updated++ + } + return nil + }) + return inserted, updated, skipped, err +} + +func normalizeRockPackageStatus(status string) string { + switch strings.TrimSpace(status) { + case model.RockStatusAvailable: + return model.RockStatusAvailable + case model.RockStatusUsing: + return model.RockStatusUsing + case model.RockStatusBound: + return model.RockStatusBound + case model.RockStatusRedeemed: + return model.RockStatusRedeemed + case model.RockStatusMismatch: + return model.RockStatusMismatch + default: + // 未知上游状态默认落 abnormal,避免被错误分配为可用库存。 + return model.RockStatusAbnormal + } +} + +func parseRockPackageTime(raw string) *time.Time { + if t, ok := parseRockTime(raw); ok { + return &t + } + return nil +} + +func defaultValue(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/backend/internal/service/rock_purchase_package_test.go b/backend/internal/service/rock_purchase_package_test.go new file mode 100644 index 0000000..10313bb --- /dev/null +++ b/backend/internal/service/rock_purchase_package_test.go @@ -0,0 +1,272 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "affiliate_dash/internal/model" +) + +func setRockTestBearer(t *testing.T, svc *RockService, token string) { + t.Helper() + bearer := token + if _, err := svc.UpdateConfig(context.Background(), UpdateRockConfigReq{BearerToken: &bearer}); err != nil { + t.Fatalf("设置 Bearer Token 失败: %v", err) + } +} + +func TestPurchaseTokens_CreatesPackageAndImportsAvailable(t *testing.T) { + db := newRockTestDB(t) + + var gotClaimCount string + var gotClaimColor string + var gotIDs []string + var gotNote string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/api/user/dashboard": + writeRockDashboardJSON(w, 999, "20.0") + case r.URL.Path == "/api/user/tokens/claim": + var body struct { + Color string `json:"color"` + Count string `json:"count"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotClaimColor = body.Color + gotClaimCount = body.Count + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tokens": []map[string]interface{}{ + {"id": 30001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"}, + {"id": 30002, "token": "token-2", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-2"}, + }, + }) + case r.URL.Path == "/api/user/packages" && r.Method == http.MethodPost: + var body struct { + IDs []string `json:"ids"` + Note string `json:"note"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotIDs = body.IDs + gotNote = body.Note + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "package": map[string]interface{}{ + "id": 9001, "token_count": 2, "share_code": "share123", "status": "active", "note": body.Note, + }, + }) + case r.URL.Path == "/api/package/share123": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "package": map[string]interface{}{"id": 9001, "token_count": 2, "share_code": "share123", "status": "active"}, + "tokens": []map[string]interface{}{ + {"id": 30001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1", "created_at": "2026-10-29 13:45:27"}, + {"id": 30002, "token": "token-2", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-2", "created_at": "2026-10-29 13:45:27"}, + }, + }) + case r.URL.Path == "/api/me": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "user": map[string]interface{}{"username": "tester", "points": "880.0"}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + tokens, err := svc.PurchaseTokens(context.Background(), 2, true) + if err != nil { + t.Fatalf("采购失败: %v", err) + } + if len(tokens) != 2 { + t.Fatalf("期望返回 2 个 Token,实际 %d", len(tokens)) + } + if gotClaimColor != model.RockColorBlack || gotClaimCount != "2" { + t.Fatalf("claim 参数不符: color=%s count=%s", gotClaimColor, gotClaimCount) + } + if len(gotIDs) != 2 || gotIDs[0] != "30001" || gotIDs[1] != "30002" { + t.Fatalf("打包 ids 不符: %v", gotIDs) + } + if !strings.HasPrefix(gotNote, "补货-") { + t.Fatalf("自动补货 note 前缀不符: %s", gotNote) + } + + var rows []model.RockToken + if err := db.Order("id ASC").Find(&rows).Error; err != nil { + t.Fatalf("查询 Token 失败: %v", err) + } + if len(rows) != 2 { + t.Fatalf("期望本地 2 条 Token,实际 %d", len(rows)) + } + for _, row := range rows { + if row.Status != model.RockStatusAvailable { + t.Fatalf("新 Token 状态应为 available,实际 %s", row.Status) + } + if row.Source != "package" { + t.Fatalf("新 Token 来源应为 package,实际 %s", row.Source) + } + if row.PackageID == nil || *row.PackageID != 9001 { + t.Fatalf("package_id 未写入: %+v", row.PackageID) + } + if row.Token == "" || row.RedeemCode == "" { + t.Fatalf("提取码/兑换码未写入: %+v", row) + } + } + + cfg, _ := svc.GetConfig(context.Background()) + if cfg.LastError != "" { + t.Fatalf("成功后应清空 last_error,实际 %q", cfg.LastError) + } + if cfg.LastPurchasedAt == nil || cfg.LastAttemptAt == nil { + t.Fatalf("last_purchased_at/last_attempt_at 未记录") + } +} + +func TestPurchaseTokens_PackageFailureFallsBackToPurchase(t *testing.T) { + db := newRockTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/user/dashboard": + writeRockDashboardJSON(w, 999, "20.0") + case "/api/user/tokens/claim": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tokens": []map[string]interface{}{ + {"id": 31001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"}, + }, + }) + case "/api/user/packages": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"package down"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + tokens, err := svc.PurchaseTokens(context.Background(), 1, false) + if err != nil { + t.Fatalf("打包失败时应兜底成功,实际报错: %v", err) + } + if len(tokens) != 1 { + t.Fatalf("期望兜底入库 1 个,实际 %d", len(tokens)) + } + + var row model.RockToken + if err := db.First(&row).Error; err != nil { + t.Fatalf("查询 Token 失败: %v", err) + } + if row.Status != model.RockStatusAvailable || row.Source != "purchase" || row.PackageID != nil { + t.Fatalf("兜底入库字段不符: status=%s source=%s package_id=%v", row.Status, row.Source, row.PackageID) + } + + cfg, _ := svc.GetConfig(context.Background()) + if !strings.Contains(cfg.LastError, "创建上游打包记录失败") { + t.Fatalf("last_error 应记录打包失败,实际 %q", cfg.LastError) + } +} + +func TestPurchaseTokens_PackageDetailFailureFallsBackWithPackageID(t *testing.T) { + db := newRockTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/user/dashboard": + writeRockDashboardJSON(w, 999, "20.0") + case "/api/user/tokens/claim": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tokens": []map[string]interface{}{ + {"id": 32001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"}, + }, + }) + case "/api/user/packages": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "package": map[string]interface{}{"id": 9002, "token_count": 1, "share_code": "detailfail", "status": "active"}, + }) + case "/api/package/detailfail": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"detail down"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + tokens, err := svc.PurchaseTokens(context.Background(), 1, false) + if err != nil { + t.Fatalf("详情失败时应兜底成功,实际报错: %v", err) + } + if len(tokens) != 1 { + t.Fatalf("期望兜底入库 1 个,实际 %d", len(tokens)) + } + + var row model.RockToken + if err := db.First(&row).Error; err != nil { + t.Fatalf("查询 Token 失败: %v", err) + } + if row.Status != model.RockStatusAvailable || row.Source != "package" { + t.Fatalf("兜底入库字段不符: status=%s source=%s", row.Status, row.Source) + } + if row.PackageID == nil || *row.PackageID != 9002 { + t.Fatalf("兜底入库应保留 package_id=9002,实际 %v", row.PackageID) + } + + cfg, _ := svc.GetConfig(context.Background()) + if !strings.Contains(cfg.LastError, "详情拉取失败") { + t.Fatalf("last_error 应记录详情失败,实际 %q", cfg.LastError) + } +} + +func TestAutoPurchase_FailureCooldown(t *testing.T) { + db := newRockTestDB(t) + var claimCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/user/dashboard" { + writeRockDashboardJSON(w, 999, "20.0") + return + } + if r.URL.Path == "/api/user/tokens/claim" { + atomic.AddInt32(&claimCalls, 1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"insufficient points"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + if _, err := svc.PurchaseTokens(context.Background(), 50, true); err == nil { + t.Fatalf("第一次补货应因上游 500 失败") + } + if _, err := svc.PurchaseTokens(context.Background(), 50, true); err == nil || !strings.Contains(err.Error(), "冷却中") { + t.Fatalf("失败后第二次应立即命中冷却,实际 err=%v", err) + } + if calls := atomic.LoadInt32(&claimCalls); calls != 1 { + t.Fatalf("冷却期内不应再次请求上游 claim,实际调用 %d 次", calls) + } + + cfg, _ := svc.GetConfig(context.Background()) + if cfg.LastAttemptAt == nil { + t.Fatalf("last_attempt_at 未记录") + } + if !strings.Contains(cfg.LastError, "调用上游采购接口失败") { + t.Fatalf("last_error 未记录采购失败: %q", cfg.LastError) + } +} diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index 61887e7..e9c484b 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" "sync" "time" @@ -42,6 +43,16 @@ type RockStatsResponse struct { AutoPurchaseEnabled bool `json:"auto_purchase_enabled"` Username string `json:"username"` LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` + LastPurchaseError string `json:"last_purchase_error,omitempty"` + LastPurchaseErrorAt string `json:"last_purchase_error_at,omitempty"` +} + +type RockDashboardResult struct { + AvailableBlack int `json:"available_black"` + AvailableBlue int `json:"available_blue"` + BlackTokenCost float64 `json:"black_token_cost"` + BlueTokenCost float64 `json:"blue_token_cost"` + CostUpdated bool `json:"cost_updated"` } type UpdateRockConfigReq struct { @@ -66,6 +77,7 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) BaseURL: model.RockDefaultBaseURL, BearerToken: "", MinPointsWarning: 1050.00, + BlackTokenCost: 20.00, TokenWarningStock: 10, AutoPurchaseEnabled: false, AutoPurchaseCount: 50, @@ -170,6 +182,53 @@ func (s *RockService) TestConnection(ctx context.Context, bearerToken string) (* return me, nil } +// RefreshDashboard 拉取上游 dashboard,并在采购单价变化时同步到本系统。 +func (s *RockService) RefreshDashboard(ctx context.Context) (*RockDashboardResult, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + return s.refreshDashboardWithConfig(ctx, cfg) +} + +// refreshDashboardWithConfig 复用已读取的配置,避免同一次采购内重复查库。 +func (s *RockService) refreshDashboardWithConfig(ctx context.Context, cfg *model.RockConfig) (*RockDashboardResult, error) { + if cfg == nil { + return nil, errors.New("Rock 配置不存在") + } + if strings.TrimSpace(cfg.BearerToken) == "" { + return nil, errors.New("请先配置 0666.pro Bearer Token") + } + + dash, err := s.client.GetDashboard(ctx, cfg.BearerToken) + if err != nil { + return nil, err + } + + result := &RockDashboardResult{ + AvailableBlack: dash.Available.Black, + AvailableBlue: dash.Available.Blue, + BlueTokenCost: dash.ResolveBlueTokenCost(), + } + cost := dash.ResolveBlackTokenCost() + if cost <= 0 { + result.BlackTokenCost = cfg.BlackTokenCost + return result, nil + } + + result.BlackTokenCost = cost + // 价格一样就完全不写库;只有变化超过 0.001 才更新。 + if math.Abs(cfg.BlackTokenCost-cost) > 0.001 { + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfg.ID). + Update("black_token_cost", cost).Error; err != nil { + return nil, err + } + result.CostUpdated = true + } + return result, nil +} + // GetStats 获取概览统计 func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) { cfg, err := s.GetConfig(ctx) @@ -186,6 +245,10 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) if cfg.LastHeartbeatAt != nil { stats.LastHeartbeatAt = cfg.LastHeartbeatAt.Format("2006-01-02 15:04:05") } + stats.LastPurchaseError = cfg.LastError + if cfg.LastErrorAt != nil { + stats.LastPurchaseErrorAt = cfg.LastErrorAt.Format("2006-01-02 15:04:05") + } type CountResult struct { Status string diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go index 5c7c7a7..dd6db45 100644 --- a/backend/internal/service/rock_stock.go +++ b/backend/internal/service/rock_stock.go @@ -2,8 +2,11 @@ package service import ( "context" + "encoding/json" + "errors" "fmt" "log" + "strconv" "strings" "time" @@ -11,75 +14,340 @@ import ( "gorm.io/gorm" ) -// PurchaseTokens 执行采购并批量入库(通用黑色 Token) +const ( + rockPurchaseNoteAutoPrefix = "补货" + rockPurchaseNoteManualPrefix = "手动采购" +) + +// PurchaseTokens 执行采购并入库。 +// 流程:claim 批量提取 → 创建上游打包记录 → 拉取包详情 → 幂等导入本地(新记录 available)。 +// 后续任一步失败都会用 claim 数据兜底入库,保证已扣积分的 Token 不会丢失。 func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) { s.mu.Lock() defer s.mu.Unlock() + if count <= 0 { + return nil, fmt.Errorf("采购数量必须大于 0") + } + cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } - - if cfg.BearerToken == "" { + if strings.TrimSpace(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) + if isAuto { + // 冷却以最近一次“尝试”为准:失败也生效,避免上游故障时每个触发点反复请求。 + last := cfg.LastAttemptAt + if last == nil { + last = cfg.LastPurchasedAt + } + if last != nil && cfg.PurchaseCooldownMinutes > 0 { + cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute + if now.Sub(*last) < cooldown { + return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes) + } } } + s.updatePurchaseAttempt(ctx, cfg.ID, now) - // 调上游 Claim 接口(统一下单黑色通用 Token) - tokens, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count) - if err != nil { - return nil, fmt.Errorf("调用上游采购接口失败: %w", err) + // 采购前先刷新上游 dashboard:校验可用库存,同时同步最新采购单价。 + dashboard, dashErr := s.refreshDashboardWithConfig(ctx, cfg) + if dashErr != nil { + wrapped := fmt.Errorf("获取上游库存失败,已取消本次采购: %w", dashErr) + s.recordPurchaseFailure(ctx, cfg.ID, wrapped.Error()) + return nil, wrapped + } + if dashboard.AvailableBlack < count { + err := fmt.Errorf("上游可用库存不足:当前 %d 个,本次需要 %d 个", dashboard.AvailableBlack, count) + s.recordPurchaseFailure(ctx, cfg.ID, err.Error()) + return nil, err } + claimed, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count) + if err != nil { + wrapped := fmt.Errorf("调用上游采购接口失败: %w", err) + s.recordPurchaseFailure(ctx, cfg.ID, wrapped.Error()) + return nil, wrapped + } + if len(claimed) == 0 { + err := errors.New("上游未返回任何有效 Token") + s.recordPurchaseFailure(ctx, cfg.ID, err.Error()) + return nil, err + } + + // 积分已经扣除,立即刷新最后采购时间,后续打包失败也不会被重复采购。 + s.markPurchased(ctx, cfg.ID, now) + s.refreshPointsAsync(cfg.ID, cfg.BearerToken) + + ids := make([]string, 0, len(claimed)) + claimedSet := make(map[string]struct{}, len(claimed)) + for _, t := range claimed { + if t.ID <= 0 { + continue + } + upstreamID := strconv.FormatInt(t.ID, 10) + ids = append(ids, upstreamID) + claimedSet[upstreamID] = struct{}{} + } + if len(ids) == 0 { + err := errors.New("上游返回的 Token ID 均无效") + s.recordPurchaseFailure(ctx, cfg.ID, err.Error()) + return nil, err + } + + // 1. 创建上游打包记录 + note := purchaseNote(isAuto, now) + pkgResp, pkgErr := s.client.CreatePackage(ctx, cfg.BearerToken, ids, note) + var packageID int64 + shareCode := "" + if pkgErr == nil && pkgResp != nil { + packageID, shareCode = pkgResp.ResolvePackage() + if shareCode == "" { + pkgErr = errors.New("上游未返回 package.share_code") + } + } else if pkgErr == nil { + pkgErr = errors.New("上游打包接口未返回内容") + } + if pkgErr != nil { + return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "purchase", now, + "创建上游打包记录失败: "+pkgErr.Error()) + } + + // 2. 拉取包详情(带重试) + detail, detailErr := s.getPackageDetailWithRetry(ctx, shareCode) + if detailErr != nil { + return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now, + fmt.Sprintf("包 %s 详情拉取失败: %v", shareCode, detailErr)) + } + + // 3. 导入包内 Token:本批刚 claim 的 Token 强制 available,其余沿用上游状态 + inserted, updated, skipped, importErr := s.importPackageDetail(ctx, detail, claimedSet) + if importErr != nil { + return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now, + fmt.Sprintf("包 %s 导入失败: %v", shareCode, importErr)) + } + if inserted+updated == 0 { + return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now, + fmt.Sprintf("包 %s 未返回有效 Token", shareCode)) + } + + // 4. 极端情况下详情缺 Token:用 claim 数据补齐,保证每个已扣积分的 Token 都在本地 + ensureErr := s.ensureClaimedTokens(ctx, claimed, packageID, "package", now) + if ensureErr != nil { + s.recordPurchaseFailure(ctx, cfg.ID, "补录缺失 Token 失败: "+ensureErr.Error()) + } + + dbTokens, findErr := s.findTokensByUpstreamIDs(ctx, ids) + if findErr != nil || len(dbTokens) == 0 { + if findErr != nil { + log.Printf("[RockService] 回查采购 Token 失败: %v", findErr) + } + dbTokens = s.buildClaimedTokens(claimed, packageID, "package", now) + } + + if ensureErr == nil { + s.recordPurchaseSuccess(ctx, cfg.ID) + } + log.Printf("[RockService] 采购打包完成: 包=%s 新增=%d 更新=%d 跳过=%d 实际入库=%d", + shareCode, inserted, updated, skipped, len(dbTokens)) + return dbTokens, nil +} + +// fallbackClaimedTokens 在打包/详情/导入失败时,用 claim 返回的数据直接入库,避免积分白花。 +func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, claimed []RockClaimedToken, packageID int64, source string, at time.Time, reason string) ([]model.RockToken, error) { + tokens, err := s.insertClaimedTokens(ctx, claimed, packageID, source, at) + if err != nil { + s.recordPurchaseFailure(ctx, cfgID, reason+";兜底入库失败: "+err.Error()) + return nil, fmt.Errorf("%s;兜底入库失败: %w", reason, err) + } + s.recordPurchaseFailure(ctx, cfgID, fmt.Sprintf("%s;已兜底入库 %d 个 Token", reason, len(tokens))) + return tokens, nil +} + +// insertClaimedTokens 把 claim 结果直接写入库存表。 +func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, source string, at time.Time) ([]model.RockToken, error) { + tokens := s.buildClaimedTokens(claimed, packageID, source, at) if len(tokens) == 0 { - return nil, fmt.Errorf("上游未返回任何有效 Token") + return nil, errors.New("没有可入库的 Token") } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return tx.Create(&tokens).Error + }); err != nil { + return nil, err + } + return tokens, nil +} - var dbTokens []model.RockToken +// ensureClaimedTokens 补录本地缺失的 claim Token(详情返回不完整时使用)。 +func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, source string, at time.Time) error { + tokens := s.buildClaimedTokens(claimed, packageID, source, at) + if len(tokens) == 0 { + return nil + } + ids := make([]string, 0, len(tokens)) 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, - }) + ids = append(ids, t.UpstreamTokenID) } - // 事务写入库存表 - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&dbTokens).Error; err != nil { - return err + var existing []string + if err := s.db.WithContext(ctx).Model(&model.RockToken{}). + Where("upstream_token_id IN ?", ids). + Pluck("upstream_token_id", &existing).Error; err != nil { + return err + } + seen := make(map[string]bool, len(existing)) + for _, id := range existing { + seen[id] = true + } + missing := make([]model.RockToken, 0, len(tokens)) + for _, t := range tokens { + if !seen[t.UpstreamTokenID] { + missing = append(missing, t) } - // 更新最后采购时间 - 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) } + if len(missing) == 0 { + return nil + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return tx.Create(&missing).Error + }) +} - // 异步更新上游剩余积分快照 +func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID int64, source string, at time.Time) []model.RockToken { + out := make([]model.RockToken, 0, len(claimed)) + for _, t := range claimed { + if t.ID <= 0 { + continue + } + raw, err := json.Marshal(t) + if err != nil { + raw = []byte(`{}`) + } + rec := model.RockToken{ + UpstreamTokenID: strconv.FormatInt(t.ID, 10), + Token: strings.TrimSpace(t.Token), + RedeemCode: strings.TrimSpace(t.RedeemCode), + Color: defaultValue(strings.TrimSpace(t.Color), model.RockColorBlack), + Region: defaultValue(strings.TrimSpace(t.Region), "Q/V可用"), + Status: model.RockStatusAvailable, + RawData: string(raw), + Source: source, + ClaimedAt: &at, + } + if packageID > 0 { + pid := packageID + rec.PackageID = &pid + } + out = append(out, rec) + } + return out +} + +func (s *RockService) findTokensByUpstreamIDs(ctx context.Context, ids []string) ([]model.RockToken, error) { + if len(ids) == 0 { + return nil, nil + } + var tokens []model.RockToken + err := s.db.WithContext(ctx). + Where("upstream_token_id IN ?", ids). + Order("id ASC"). + Find(&tokens).Error + return tokens, err +} + +// getPackageDetailWithRetry 拉取包详情,最多 3 次(1s/2s 退避)。 +func (s *RockService) getPackageDetailWithRetry(ctx context.Context, shareCode string) (*RockPackageDetailResponse, error) { + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + detail, err := s.client.GetPackageDetail(ctx, shareCode) + if err == nil && detail != nil && detail.Package.ID != 0 { + return detail, nil + } + if err == nil { + err = errors.New("上游未返回 package.id") + } + lastErr = err + if attempt < 3 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + } + return nil, lastErr +} + +func purchaseNote(isAuto bool, at time.Time) string { + prefix := rockPurchaseNoteManualPrefix + if isAuto { + prefix = rockPurchaseNoteAutoPrefix + } + return prefix + "-" + at.Format("20060102-150405") +} + +func (s *RockService) updatePurchaseAttempt(ctx context.Context, cfgID uint, at time.Time) { + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfgID). + Update("last_attempt_at", at).Error; err != nil { + log.Printf("[RockService] 记录采购尝试时间失败: %v", err) + } +} + +func (s *RockService) markPurchased(ctx context.Context, cfgID uint, at time.Time) { + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfgID). + Update("last_purchased_at", at).Error; err != nil { + log.Printf("[RockService] 记录最后采购时间失败: %v", err) + } +} + +func (s *RockService) recordPurchaseFailure(ctx context.Context, cfgID uint, msg string) { + msg = truncatePurchaseError(msg) + log.Printf("[RockService][ALERT] 补货异常: %s", msg) + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfgID). + Updates(map[string]interface{}{ + "last_error": msg, + "last_error_at": time.Now(), + }).Error; err != nil { + log.Printf("[RockService] 写入补货错误失败: %v", err) + } +} + +func (s *RockService) recordPurchaseSuccess(ctx context.Context, cfgID uint) { + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfgID). + Updates(map[string]interface{}{ + "last_error": "", + "last_error_at": nil, + }).Error; err != nil { + log.Printf("[RockService] 清理补货错误失败: %v", err) + } +} + +func (s *RockService) refreshPointsAsync(cfgID uint, bearerToken string) { go func() { bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - me, meErr := s.client.GetMe(bgCtx, cfg.BearerToken) + me, meErr := s.client.GetMe(bgCtx, bearerToken) if meErr == nil && me != nil { - _ = s.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", me.User.Points.Float64()).Error + _ = s.db.Model(&model.RockConfig{}).Where("id = ?", cfgID).Update("points", me.User.Points.Float64()).Error } }() +} - return dbTokens, nil +func truncatePurchaseError(msg string) string { + msg = strings.TrimSpace(msg) + runes := []rune(msg) + if len(runes) <= 500 { + return msg + } + return string(runes[:500]) + "..." } // CheckStockAndAutoPurchase 水位检查与自动采购触发 @@ -90,26 +358,32 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { } var count int64 - s.db.WithContext(ctx).Model(&model.RockToken{}). + if err := 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) - } - } + Count(&count).Error; err != nil { + log.Printf("[RockService] 统计可用库存失败: %v", err) + return } + + if count > int64(cfg.TokenWarningStock) { + return + } + log.Printf("[RockService] 可用库存预警: 当前仅存 %d 个 (预警阈值 %d)", count, cfg.TokenWarningStock) + if !cfg.AutoPurchaseEnabled { + return + } + + buyCount := cfg.AutoPurchaseCount + if buyCount <= 0 { + buyCount = 50 + } + log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount) + tokens, err := s.PurchaseTokens(ctx, buyCount, true) + if err != nil { + log.Printf("[RockService] 自动补货失败: %v", err) + return + } + log.Printf("[RockService] 自动补货成功,实际入库 %d 个 Token", len(tokens)) } // ListTokens 分页查询 Token 列表 @@ -127,8 +401,8 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status } if strings.TrimSpace(search) != "" { term := "%" + strings.TrimSpace(search) + "%" - db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR remark LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)", - strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term, term) + db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR remark LIKE ? OR CAST(package_id AS TEXT) LIKE ? OR token LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)", + strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term, term, term, term) } var total int64 @@ -137,13 +411,18 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status } var tokens []model.RockToken - err := db.Order("id DESC"). + // 按包 ID 倒序(未打包的手工采购记录排在最后),同包内按本地 ID 倒序,保证分页稳定 + err := db.Order("package_id DESC NULLS LAST"). + Order("id DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&tokens).Error if err != nil { return nil, 0, err } + for i := range tokens { + tokens[i].TokenCode = tokens[i].Token + } // 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo) orderNos := make([]string, 0, len(tokens)) diff --git a/docs/Rock模块逻辑分析.md b/docs/Rock模块逻辑分析.md new file mode 100644 index 0000000..04657b4 --- /dev/null +++ b/docs/Rock模块逻辑分析.md @@ -0,0 +1,413 @@ +# Rock 发货平台(0666.pro)自动发货与库存风控模块分析 + +> 分析日期:2026-10-27 +> 分析范围:`backend/internal/model/rock.go`、`backend/internal/service/rock_*.go`、`provider_rock.go`、`handler/rock.go`、迁移 `012/013`、路由与启动装配,以及 `docs/` 中 Rock 相关文档。 +> 来源约定:代码使用 `文件:行号`;文档使用 `docs/文件:行号`。本文所有结论均可按来源复核。 + +--- + +## 0. 一句话结论 + +Rock 模块的本质是:**预采购 Token 库存池 + 一单一 Token + 买家扫码绑定 + UID 强一致门禁 + 自动兑换发货 + 库存水位补货**。 +主链路设计完整,防重、防超卖、防历史绑定误判都有专门机制;但**采购的崩溃一致性、取消/重置的回收边界、兑换超时的对账**仍是资金与串单风险最集中的地方。 + +--- + +## 1. 模块边界与入口 + +### 1.1 商品与渠道路由 + +- Rock 商品使用 `rock_` 前缀 SKU;未显式配置 `RoutingMode` 时自动落到 `RoutingModeRock`,优先级只有 `rock`(`backend/internal/service/provider.go:67-81`)。 +- 订单一旦在 `result_data.provider` 写入 `rock`,后续 `ResolveProviders` 只返回 Rock,渠道被锁定(`provider.go:181-188`)。 +- Rock Provider 在启动时注册(`backend/cmd/server/main.go:123-132`),库存巡检 goroutine 每 5 分钟运行一次(`main.go:171-178`)。 +- 管理端接口全部在平台管理员组下,受 JWT + `RoleAdmin` 保护(`backend/internal/router/router.go:238-240, 284-297`)。 + +### 1.2 管理端能力 + +| 能力 | 接口 | Handler | +|------|------|---------| +| 查看/修改配置 | `GET/PUT /platform/rock/config` | `handler/rock.go:22-44` | +| 测试连接 | `POST /platform/rock/test-conn` | `handler/rock.go:51-60` | +| 看板统计 | `GET /platform/rock/stats` | `handler/rock.go:63-70` | +| 手动采购 | `POST /platform/rock/purchase` | `handler/rock.go:77-91` | +| Token 列表 | `GET /platform/rock/tokens` | `handler/rock.go:94-111` | +| 修正买家 UID | `POST /platform/rock/tokens/:id/expected-uid` | `handler/rock.go:118-143` | +| 解冻重置 | `POST /platform/rock/tokens/:id/reset` | `handler/rock.go:146-157` | +| 释放回库存 | `POST /platform/rock/tokens/:id/release` | `handler/rock.go:160-171` | +| 备注/状态联动 | `POST /platform/rock/tokens/:id/remark` | `handler/rock.go:179-199` | +| 标记上报失效 | `POST /platform/rock/tokens/:id/abnormal` | `handler/rock.go:206-223` | + +### 1.3 数据表 + +- `rock_configs`:单行配置(固定 `id=1`),存上游地址、Bearer Token、自动发货/自动采购开关、库存阈值、冷却时间、积分快照等(`backend/internal/database/migrations/012_rock_fulfillment.sql:6-26`、`model/rock.go:35-52`)。 +- `rock_tokens`:本地 Token 库存池,存上游 Token、兑换码、状态、订单绑定、UID、绑定快照、履约 ID、备注等(`012_rock_fulfillment.sql:30-55`、`model/rock.go:58-90`)。 +- `remark` 字段由 `013_rock_tokens_remark.sql` 追加(`013_rock_tokens_remark.sql:4`)。 +- 商品目录:`rock_black_tshirt`(黑色 ROCK 长袖 T 恤)与 `rock_blue_sportswear`(蓝色运动服上衣),默认价 2500 积分,自营商户库存 `-1`(无限),履约配置 `routing_mode=rock`、`delivery_type=LINK_BINDING`(`012_rock_fulfillment.sql:63-101`)。 + +--- + +## 2. 端到端主流程 + +```mermaid +sequenceDiagram + autonumber + participant A as 管理员 + participant S as affiliate_dash + participant R as Rock 0666.pro + participant B as 买家 + participant O as 订单 + + A->>S: 配置 Bearer Token / 自动发货 / 库存阈值 + A->>S: 手动或自动采购 Token + S->>R: POST /api/user/tokens/claim + R-->>S: token + redeem_code 列表 + S->>S: 批量写入 rock_tokens,status=available + + O->>S: 买家打开提货页,填写游戏 UID + S->>S: 渠道路由锁定 rock + S->>S: AssignTokenForOrder:一单一 Token,status=using + S->>R: GET /api/redeem/{redeem_code} + R-->>S: bindUrl + qrUrl + S-->>B: 返回二维码/绑定链接 + + B->>R: 微信/QQ 扫码,在腾讯活动页绑定角色 + B->>S: 前端轮询绑定结果 + S->>R: POST /api/user/tokens/{id}/refresh + R-->>S: bound_account / bound_account_id / bind_changed_at + S->>S: 反 Ghost 快照比对 + UID 强一致校验 + alt UID 一致 + S->>S: token.status=bound + B->>S: 点击确认发货 + S->>R: POST /api/redeem/{id}(skin/currency/account) + R-->>S: status=success, token.status=redeemed + S->>O: 订单 delivered + 回调 outbox + else UID 不一致 + S->>S: token.status=mismatch,阻断发货 + S-->>B: 提示修改 UID 或联系客服 + end +``` + +### 2.1 订单侧状态变化 + +- `Bind` 只写 `result_data`(`provider=rock`、`rock_token_id`、`rock_redeem_code`、`expected_uid`),**不提前把订单改成 `delivering`**(`provider_rock.go:139-178`、`delivery_bind.go:165-174`)。 +- `Submit` 先由发货服务抢占提交权(`provider_order_stage=claimed`、`submit_started_at`、`ship_attempts+1`),再调用 Rock 兑换(`delivery_submit.go:70-95, 175-194`)。 +- Rock 兑换成功后直接返回 `NextStatus=delivered`,订单从 `paid` 直接到 `delivered`;失败则 `markDeliverySubmissionFailed` 置 `ship_failed`(`provider_rock.go:293-303`、`delivery_submit.go:96-128`)。 +- 因此 Rock 订单通常不经过 `delivering` 停留;`delivering` 更多用于乐玩 Webhook / 超时扫描等异步链路。 + +--- + +## 3. Token 状态机 + +```mermaid +stateDiagram-v2 + [*] --> available: 采购入库 + available --> using: 订单绑定,分配 Token + using --> bound: 扫码绑定 + UID 一致 + using --> mismatch: 扫码 UID 不一致 + bound --> mismatch: 再次校验发现 UID 不一致 + mismatch --> bound: 买家/客服把期望 UID 改成扫码 UID + bound --> redeemed: 上游兑换成功 + using --> available: 释放未绑定 / 订单取消 + mismatch --> available: 客服解冻重置 + abnormal --> available: 客服解冻重置 + using --> abnormal: 人工标记失效 + bound --> abnormal: 人工标记失效 + redeemed --> abnormal: 人工标记失效 + redeemed --> available: 备注接口直接改状态(危险) + bound --> available: 备注接口直接改状态(危险) +``` + +### 3.1 本地状态字典 + +| 状态 | 含义 | 关键来源 | +|------|------|----------| +| `available` | 可用库存,未被订单占用 | `model/rock.go:21` | +| `using` | 已分配给订单,等待买家扫码 | `model/rock.go:22` | +| `bound` | 扫码绑定成功,UID 已核验 | `model/rock.go:23` | +| `redeemed` | 已向上游兑换发货 | `model/rock.go:24` | +| `mismatch` | 扫码 UID 与订单 UID 不一致,禁止发货 | `model/rock.go:25` | +| `abnormal` | 异常/上报失效/作废 | `model/rock.go:26` | + +> 上游文档还提到 `settled`(已结算),但本地模型没有该状态;本地财务结算不落在 Token 状态机里(`docs/rock发货平台(0666.pro)逆向与接口分析.md:107-129`)。 + +### 3.2 代码允许的流转比业务状态机更宽 + +- `ReleaseUnboundUsingToken` 只允许 `using -> available`,且必须上游未扫码(`rock_redeem.go:95-144`)。 +- `ResetMismatchToken` 允许 `mismatch/abnormal -> available`,但**不检查上游绑定状态**(`rock_recover.go:101-127`)。 +- `UpdateTokenRemark` 允许把任意当前状态改成 `available/mismatch/abnormal`,且**不清理订单关联字段**(`rock_recover.go:129-166`)。 +- `ProviderRock.Cancel` 只处理 `using -> available`,`bound` 不释放(`provider_rock.go:343-361`)。 + +--- + +## 4. 核心机制 + +### 4.1 渠道选择与 Kill Switch + +- `PrepareDelivery` 先读配置;`AutoDeliveryEnabled=false` 时直接返回 `CanShip=false`,订单保留 `paid`,不调用上游、不消耗 Token(`provider_rock.go:28-45`)。 +- 若订单已经分配了 `using/bound` Token,即使池子可用库存为 0,也允许继续履约;只有 `abnormal` 会阻断;`mismatch` 留给绑定页提示(`provider_rock.go:47-79`)。 +- 若无已分配 Token 且 `AvailableStock <= 0`,异步触发补货检查,并返回“库存补充中”(`provider_rock.go:81-95`)。 + +### 4.2 一单一 Token:分配算法 + +入口:`AssignTokenForOrder(orderNo, expectedUID)`(`rock_assign.go:32-158`)。 + +1. **先查该订单是否已占用 Token**,状态范围 `using/bound/mismatch/redeemed/abnormal`,按 `id DESC` 取最新(`rock_assign.go:43-57`)。 +2. 按状态分支: + - `abnormal`:直接拒绝,提示“已绑定失败,不能更换新提货号”(`rock_assign.go:60-62`)。 + - `redeemed`:拒绝,“已发货,不能再次领取”(`rock_assign.go:63-64`)。 + - `mismatch`:仅当传入的 `expectedUID` 等于扫码实际 UID 时,自动解除阻断并转 `bound`;否则返回“扫码角色是 X,请改成 X 或联系客服”(`rock_assign.go:65-94`)。 + - `bound`:若传入 UID 与已记录期望 UID 不同,拒绝换 UID;相同则复用(`rock_assign.go:95-104`)。 + - `using`:允许更新 `expected_uid`(`rock_assign.go:95-104`)。 +3. 若没有占用记录:在事务中用 `SELECT ... FOR UPDATE SKIP LOCKED` 取 `status=available AND color=black` 的最小 ID,置为 `using`,写 `assigned_order_no` 与 `expected_uid`(`rock_assign.go:107-131`)。 +4. 分配成功后异步触发库存检查(`rock_assign.go:129-131`),再调 `GET /api/redeem/{code}` 获取绑定链接并缓存(`rock_assign.go:133-138`)。 +5. 首次分配且配置了 Bearer Token 时,调用 `RefreshToken` 记录初始绑定快照(`rock_assign.go:140-155`)。 + +**关键点**:`SKIP LOCKED` 只解决“两个订单同时抢同一个 available Token”,不解决多实例采购、取消回收、管理员重置带来的状态竞争。 + +### 4.3 反 Ghost-Binding:初始快照 + 新扫码判定 + +上游 Token 可能带着上一个买家的历史绑定。若直接把 `refresh` 返回的绑定当成本次扫码,会把货发给历史角色。模块用两层防线: + +**第一层:分配时快照**(`rock_assign.go:140-155`) + +```go +type BindingSnapshot struct { + Bound bool + RoleName string + RoleID string + Channel string + BindChangedAt string +} +``` + +快照存在 `rock_tokens.initial_bound_snapshot`,JSON 不对外输出(`model/rock.go:73`)。 + +**第二层:`rockBuyerNewlyBound` 判定**(`rock_snapshot.go:15-36`) + +| 条件 | 结论 | 代码 | +|------|------|------| +| 上游未绑定 / 无角色 ID | 不是新绑定 | `rock_snapshot.go:16-18` | +| 快照已绑定,角色 ID 与 `bind_changed_at` 都与当前一致 | 历史残留,不是新绑定 | `rock_snapshot.go:19-22` | +| 发货记录时间晚于领取基线,或 `bind_changed_at` 晚于基线-1 分钟 | 新绑定 | `rock_snapshot.go:23-25, 90-100, 102-119` | +| 快照角色 ID 与当前不同 | 新绑定(换号) | `rock_snapshot.go:26-28` | +| `bind_changed_at` 为空,且只有历史发货记录 | 历史残留 | `rock_snapshot.go:29-31` | +| 有快照但以上都不满足 | 历史残留 | `rock_snapshot.go:32-34` | +| 无快照、无历史记录 | 按新绑定处理 | `rock_snapshot.go:35` | + +- 基线取 `claimed_at`(采购领取时间),没有则取 `created_at`(`rock_snapshot.go:58-66`);**不是分配时间**。真正兜底的是分配时快照。 +- 上游时间按固定 UTC+8 解析,避免进程时区漂移(`rock_snapshot.go:68-88`)。 + +**门禁**:如果 `expected_uid` 非空且与扫码 `actual_role_id` 不一致,立即把 Token 置为 `mismatch`,记录 `mismatch_at/reason`,严禁发货(`rock_bindcheck.go:162-194`)。一致才置 `bound`(`rock_bindcheck.go:196-212`)。 + +### 4.4 绑定状态轮询与 110001 降级 + +- `CheckBind` 每轮最多请求一次上游 `refresh`,但有 800ms 进程内缓存,避免前端高频轮询撞上游频控(`rock_bindcheck.go:98-140`)。 +- 上游返回 110001“请求过于频繁”时: + - 有缓存则用缓存; + - 本地已是 `bound` 则直接按已绑定返回; + - 否则返回“正在同步,请稍后重试”,不把买家打断(`rock_bindcheck.go:110-129`)。 +- 绑定链接缓存 8 分钟(上游签名约 10 分钟过期),过期前复用;上游仍返回旧链接时 15 秒内不重复请求(`rock_bindlink.go:18-23, 51-63, 83-121`)。 +- 上游未给 `qrUrl` 时,用 `api.qrserver.com` 生成二维码——这会把带 UID/签名的绑定 URL 发给第三方(`provider.go:234-236`、`provider_rock.go:146-150`)。 + +### 4.5 最终兑换发货 + +`RedeemTokenForOrder`(`rock_redeem.go:17-91`): + +1. 检查 Kill Switch。 +2. 按 `assigned_order_no + status=bound` 取 Token;找不到则拒绝。 +3. 最终二次 UID 校验:`actual_role_id` 必须等于 `expected_uid`,否则“终审拦截”(`rock_redeem.go:37-44`)。 +4. 按 SKU 决定皮肤:`rock_blue_sportswear` -> 蓝色运动服,否则黑色 ROCK 长袖(`rock_redeem.go:46-50`)。 +5. 确保 `actual_role_name` 存在;为空则再 `refresh` 一次(`rock_redeem.go:52-64`)。 +6. 调 `POST /api/redeem/{upstreamTokenID}`,请求体 `skin/currency/account`(`rock_client.go:404-449`)。 +7. 再 `refresh` 取最新 `deliveryList` 的最后一条 `fulfillId`(`rock_redeem.go:72-78`)。 +8. 本地置 `redeemed`,写 `upstream_fulfill_id/target_skin/target_currency/target_currency_quantity/redeemed_at`(`rock_redeem.go:80-88`)。 +9. `ProviderRock.Submit` 把这些字段 patch 到订单 `result_data`,返回 `delivered`(`provider_rock.go:281-303`)。 + +**注意**:`target_currency_quantity` 固定写 `1`(`rock_redeem.go:86`),但上游按皮肤单价动态计算附赠货币数量(文档示例黑色 ROCK 为 10 个幸运币、蓝色运动服为 14 个,`docs/rock发货平台(0666.pro)逆向与接口分析.md:74-103`)。本地订单里的 `currency_quantity` 会与实际发放数量不一致。 + +### 4.6 取消、释放、重置、备注 + +| 操作 | 允许的当前状态 | 是否检查上游 | 清理订单关联 | 风险 | +|------|----------------|--------------|--------------|------| +| `ProviderRock.Cancel` | `using` | **否** | **否** | 买家刚扫码但本地未更新时,可能把已绑定 Token 放回池子;`bound` 不释放,订单取消后 Token 卡死 | +| `ReleaseUnboundUsingToken` | `using` | 是(refresh + anti-ghost) | 是(清 token 字段 + detach 订单 result_data) | 相对安全,但依赖上游可用 | +| `ResetMismatchToken` | `mismatch/abnormal` | **否** | **否**(只清 token) | 可能把仍绑定在上游的 Token 重新投放;旧订单 result_data 残留 | +| `UpdateTokenRemark(status=available)` | 任意 | **否** | **否** | 可把 `bound/redeemed` 直接改回可用,旧订单仍指向它,严重串单风险 | +| `CorrectMismatchExpectedUID` | `mismatch` | 否(客服人工判断) | 更新订单 `expected_uid` | 改对扫码 UID 才解除阻断;改错仍 mismatch | + +来源:`provider_rock.go:343-361`、`rock_redeem.go:95-168`、`rock_recover.go:18-87, 101-175`。 + +### 4.7 库存水位与自动采购 + +**统计口径**:`COUNT(rock_tokens WHERE status='available' AND color='black')`(`rock_stock.go:92-95`)。 + +**触发点**: + +1. 主进程每 5 分钟 ticker(`main.go:171-178`)。 +2. 每次新分配 Token 后异步触发(`rock_assign.go:129-131`)。 +3. `PrepareDelivery` 发现可用库存为 0 时异步触发(`provider_rock.go:86-88`)。 + +**判定与采购**(`rock_stock.go:86-113`): + +1. 库存 `count <= token_warning_stock` 时打印预警日志。 +2. 若 `auto_purchase_enabled=true`,取 `auto_purchase_count`(<=0 时兜底 50)。 +3. 调用 `PurchaseTokens(ctx, count, true)`。 +4. `PurchaseTokens` 用进程内 `sync.Mutex` 串行化;自动采购额外检查 `last_purchased_at` 冷却(`rock_stock.go:15-35`)。 +5. 调上游 `POST /api/user/tokens/claim`,`color=black`,`count` 以字符串提交(`rock_client.go:291-335`)。 +6. 上游返回 Token 数组后,事务批量写 `rock_tokens` 并把 `last_purchased_at` 更新为当前时间(`rock_stock.go:47-67`)。 +7. 异步 `GET /api/me` 刷新配置里的 `points` 快照(`rock_stock.go:72-80`)。 + +**手动采购**:`isAuto=false`,跳过冷却,但仍受进程内 mutex 串行化(`handler/rock.go:77-91`、`rock_stock.go:15-35`)。 + +### 4.8 配置、统计与心跳 + +- `GetConfig` 找不到 `id=1` 时自动创建默认配置(`rock_service.go:59-83`)。 +- 启动时 `ApplyConfigToClient` 把 DB 的 `base_url` 同步到 HTTP 客户端;后台修改 `base_url` 也会立即 `SetBaseURL`(`rock_service.go:89-96, 109-113`、`main.go:128-130`)。 +- `TestConnection` 调 `/api/me`,成功时更新 `username/points/last_heartbeat_at`(`rock_service.go:144-171`)。 +- `GetStats` 从 DB 聚合各状态数量,`UpstreamPoints/Username/LastHeartbeatAt` 取配置快照(`rock_service.go:174-221`)。 + +--- + +## 5. 关键规则速查 + +| 编号 | 规则 | 来源 | +|------|------|------| +| R1 | `rock_` SKU 自动路由到 Rock;`result_data.provider` 一旦落值即锁定渠道 | `provider.go:67-81, 181-188` | +| R2 | Kill Switch 关闭时只保留 `paid`,不消耗 Token/积分 | `provider_rock.go:38-45`、`rock_assign.go:37-39`、`rock_redeem.go:22-24` | +| R3 | 一个订单只能占用一个 Token;`abnormal/redeemed` 拒绝再分配,`mismatch` 仅允许改成扫码 UID,`bound` 不允许换 UID | `rock_assign.go:43-105` | +| R4 | 新 Token 分配用 `FOR UPDATE SKIP LOCKED`,只取 `available+black` 的最小 ID | `rock_assign.go:107-124` | +| R5 | 首次分配记录上游绑定快照;快照一致视为历史残留,角色变化/绑定时间晚于领取基线视为新扫码 | `rock_assign.go:140-155`、`rock_snapshot.go:15-36` | +| R6 | 扫码 UID 与订单 UID 强一致才允许 `bound`;不一致立即 `mismatch` | `rock_bindcheck.go:162-212` | +| R7 | 最终兑换前再次校验 UID,不一致直接拒绝 | `rock_redeem.go:37-44` | +| R8 | 绑定链接 8 分钟刷新一次,110001 频控时降级使用缓存/本地已绑定状态 | `rock_bindlink.go:18-23, 83-121`、`rock_bindcheck.go:98-140` | +| R9 | 兑换成功后 Token 置 `redeemed`,订单直接 `delivered`,回调走 outbox | `rock_redeem.go:80-88`、`provider_rock.go:293-303` | +| R10 | 取消只释放 `using`,且当前实现不检查上游、不清订单关联 | `provider_rock.go:343-361` | +| R11 | 库存水位 = `available+black` 数量;低于阈值告警,开启自动采购时补货 | `rock_stock.go:86-113` | +| R12 | 自动采购有进程内 mutex + 冷却;手动采购跳过冷却 | `rock_stock.go:15-35` | +| R13 | 采购先调上游、后写本地;没有 pending 采购单/对账 | `rock_stock.go:38-67` | +| R14 | 只有 `TestConnection` 更新心跳和积分;没有周期心跳 | `rock_service.go:144-171` | +| R15 | 管理端可把任意 Token 置 `available/mismatch/abnormal`,没有状态迁移校验 | `rock_recover.go:129-166` | + +--- + +## 6. 问题清单(按风险排序) + +### P0:资金/数据不可恢复 + +**P0-1 采购崩溃窗口:上游已扣积分,本地没入库** + +- 代码先 `ClaimTokens`,成功后才在本地事务插入 Token(`rock_stock.go:38-67`)。 +- 若进程在两步之间崩溃、DB 事务失败或网络超时,上游已扣积分/已发 Token,本地没有任何记录,也没有 `purchase_attempt` 可对账。 +- 建议:先写 `purchase_attempts`(唯一请求号,状态 `pending`)→ 调上游 → 回填 Token;增加按上游流水/Token 列表的对账任务。 + +**P0-2 多实例自动采购竞态** + +- `s.mu` 是进程内锁,冷却检查在 DB 事务之前读取 `last_purchased_at`(`rock_service.go:16-19`、`rock_stock.go:16-35`)。 +- 两个实例可同时通过冷却检查,各自采购一批,造成超买/积分浪费(`main.go:171-178`)。 +- 建议:PostgreSQL advisory lock、单例 leader worker,或对 `last_purchased_at` 做条件更新抢占。 + +### P1:串单/状态不一致 + +**P1-1 `ProviderRock.Cancel` 绕过上游检查,且不清理快照** + +- 只把 `using` 改成 `available`,不调用 `RefreshToken`、不检查 `rockBuyerNewlyBound`、不清 `initial_bound_snapshot/actual_*`、不清订单 `result_data`(`provider_rock.go:343-361`)。 +- 买家刚扫码但本地轮询还没更新时取消订单,Token 会带着上游绑定回到库存池;下一位买家可能遇到 Ghost-Binding。 +- `bound` 状态取消时完全不释放,订单取消后 Token 永久卡在 `bound`。 +- 建议:取消时统一走 `ReleaseUnboundUsingToken`;`bound` 需人工复核;清理快照/实际角色/订单关联。 + +**P1-2 `ResetMismatchToken` / `UpdateTokenRemark` 可把已绑定 Token 重新投放** + +- `ResetMismatchToken` 允许 `mismatch/abnormal -> available`,不检查上游是否仍绑定(`rock_recover.go:101-127`)。 +- `UpdateTokenRemark` 允许任意状态改成 `available/mismatch/abnormal`,且不清理 `assigned_order_no/expected_uid/actual_*`(`rock_recover.go:129-166`)。 +- 把 `bound/redeemed` 改成 `available` 后,旧订单仍指向该 Token;新订单再次分配会造成一 Token 多订单。 +- 建议:状态迁移白名单 + 强制清理关联字段 + 可选上游 refresh 校验;`bound/redeemed` 不允许直接改 available。 + +**P1-3 `CheckBindingAndVerifyUID` 的 mismatch 分支没有校验订单归属** + +- 函数在 `status=mismatch` 分支里,只要传入 `expectedUID == actualRoleID` 就直接改成 `bound` 并返回,**没有先校验 `AssignedOrderNo == orderNo`**(`rock_bindcheck.go:35-66`)。 +- 攻击者若拿到其他订单的 Token ID(`bind_uuid`)和扫码 UID,可能把别人的 mismatch Token 改成 bound。 +- 建议:在任何状态分支之前统一校验 `token.AssignedOrderNo` 与订单号一致。 + +**P1-4 兑换超时/失败后的对账缺口** + +- `RedeemSkin` 成功后本地才置 `redeemed`;若上游已成功但 HTTP 超时/进程崩溃,本地仍是 `bound`,订单可能 `ship_failed`。 +- `ProviderRock.SyncStatus` 只看本地 Token 状态,不主动 refresh 上游;`QueryExchangeRecords` 客户端方法存在但从未调用(`rock_client.go:451-487`、`provider_rock.go:306-341`)。 +- `deliverySubmittedUpstream` 依赖 `provider_order_no`/`delivery`/`upstream_order`/`provider_order_stage=submitted`;Rock 失败时通常没有这些标记,重试可能再次调用兑换(`delivery.go:29-46`、`delivery_submit.go:96-105`)。 +- 建议:增加 `redeeming` 中间态/兑换尝试表;超时后先用 `refresh`/`exchange-records` 对账,再决定重试或人工处理;重试前检查上游是否已 `redeemed`。 + +**P1-5 `RedeemSkin` 可能把“非 success 且无 error”当成功** + +- `RedeemSkin` 只在 `!IsSuccess() && res.Error != ""` 时报错(`rock_client.go:441-448`)。 +- 若上游返回 `status=failed` 但 `error` 为空,函数仍返回 response,`RedeemTokenForOrder` 会把本地置 `redeemed`(`rock_redeem.go:66-88`)。 +- 建议:以 `IsSuccess()` 为唯一成功条件;非 success 一律返回错误并保留 Token 状态。 + +**P1-6 附赠货币数量写死为 1** + +- `target_currency_quantity` 固定 `1`(`rock_redeem.go:86`),订单 patch 也用它(`provider_rock.go:287`)。 +- 上游按皮肤价格动态计算(黑色 ROCK 10 个幸运币、蓝色运动服 14 个),本地数据会误导客服/对账。 +- 建议:从上游 `redeem`/`refresh` 响应解析实际数量,或至少不要写死 1。 + +**P1-7 采购无唯一约束,可能重复入库** + +- `rock_tokens.redeem_code` 只有普通索引,`upstream_token_id` 没有唯一约束(`012_rock_fulfillment.sql:30-60`)。 +- 上游重试/重复返回同一 Token 时,本地可插入多条相同 `redeem_code`,两个订单可能各自拿到同一上游 Token。 +- 建议:至少对 `upstream_token_id` 建唯一索引;`redeem_code` 视业务决定是否唯一;采购入库用 `ON CONFLICT DO NOTHING`。 + +**P1-8 积分预警/库存告警未落地** + +- `min_points_warning` 只存配置,从未参与判断(`model/rock.go:43`、`rock_service.go:117-118`)。 +- `CheckStockAndAutoPurchase` 只 `log.Printf`,没有调用现有告警渠道(`rock_stock.go:97-111`)。 +- 上游积分不足时采购失败只写日志;没有周期 `GetMe`,`points` 可能长期陈旧。 +- 建议:采购前检查积分;低于阈值发钉钉/飞书/企微;增加周期心跳;把告警接入 `alert_notify_channels`。 + +**P1-9 `Bearer Token` 明文存储并回显** + +- `BearerToken` 是明文 `TEXT`(`model/rock.go:40`),`GET /platform/rock/config` 直接返回整个配置(`handler/rock.go:22-29`)。 +- 建议:`json:"-"` + 掩码 DTO + `DATA_ENCRYPTION_KEY` 加密;只保留“测试连接”不回显密钥。 + +### P2:可运维性/边界细节 + +- **P2-1** `GetStats.AvailableStock` 统计所有颜色,而分配只取黑色;若池中有蓝色 Token,`PrepareDelivery` 可能误判有库存(`rock_service.go:190-218`、`rock_assign.go:110-112`)。 +- **P2-2** `refreshCache`/`bindLinkCache` 是进程内 `sync.Map`,没有淘汰;长期运行会累积(`rock_service.go:20-21`)。 +- **P2-3** 手动采购没有数量上限;`CheckStockAndAutoPurchase` 成功后日志打印的是请求数量而非实际返回数量(`handler/rock.go:77-90`、`rock_stock.go:105-109`)。 +- **P2-4** 二维码兜底依赖 `api.qrserver.com`,绑定 URL 含 UID/签名,存在隐私与可用性风险(`provider.go:234-236`)。 +- **P2-5** `ResetMismatchToken` 不清理订单 `result_data` 的 `rock_token_id/redeem_code`;`ProviderRock.Cancel` 也不清理(`rock_recover.go:101-127`、`provider_rock.go:343-361`)。 +- **P2-6** `ReportAbnormal`/`QueryExchangeRecords` 客户端方法已实现但未接入业务;当前“上报失效”只是本地 `abnormal`,需要确认人工上报流程是否真的执行(`rock_client.go:451-514`)。 +- **P2-7** `GetConfig` 首次并发创建 `id=1` 可能撞唯一键;`UpdateConfig` 先改客户端 baseURL 再写 DB,DB 失败会出现内存/DB 不一致(`rock_service.go:59-83, 109-113`)。 + +--- + +## 7. 加固优先级建议 + +1. **采购链路**:`purchase_attempts` + 上游对账 + 单例锁/ advisory lock + `upstream_token_id` 唯一约束。 +2. **回收链路**:取消统一走 `ReleaseUnboundUsingToken`;`bound` 不自动回收;重置/备注加状态迁移白名单和字段清理。 +3. **兑换链路**:增加 `redeeming` 中间态/尝试表;超时先对账;`IsSuccess()` 严格判定;解析真实货币数量。 +4. **风控观测**:积分预警、库存告警接入通知渠道;周期心跳;采购/兑换/回收指标。 +5. **安全**:Bearer Token 加密 + 掩码;`bind_uuid` 校验订单归属;管理端危险操作二次确认。 + +--- + +## 8. 来源索引 + +| 主题 | 文件 | +|------|------| +| 配置/Token 模型 | `backend/internal/model/rock.go` | +| 配置、统计、连接测试 | `backend/internal/service/rock_service.go` | +| Token 分配 | `backend/internal/service/rock_assign.go` | +| 绑定校验/UID 门禁 | `backend/internal/service/rock_bindcheck.go` | +| 反 Ghost 快照 | `backend/internal/service/rock_snapshot.go` | +| 绑定链接缓存 | `backend/internal/service/rock_bindlink.go` | +| 兑换/释放 | `backend/internal/service/rock_redeem.go` | +| 修正/重置/备注 | `backend/internal/service/rock_recover.go` | +| 采购/库存巡检 | `backend/internal/service/rock_stock.go` | +| 上游 HTTP 客户端 | `backend/internal/service/rock_client.go` | +| Rock Provider | `backend/internal/service/provider_rock.go` | +| 渠道路由 | `backend/internal/service/provider.go` | +| 管理端 handler | `backend/internal/handler/rock.go` | +| 路由注册 | `backend/internal/router/router.go:284-297` | +| 启动装配/巡检 | `backend/cmd/server/main.go:123-132, 171-178` | +| 数据表/商品 | `backend/internal/database/migrations/012_rock_fulfillment.sql` | +| 备注字段 | `backend/internal/database/migrations/013_rock_tokens_remark.sql` | +| 上游接口逆向 | `docs/rock发货平台(0666.pro)逆向与接口分析.md` | +| 接入/风控方案 | `docs/Rock发货平台代理接入与库存风控实施方案.md` | +| 失效/备注优化 | `docs/Rock发货记录时间与备注及失效状态优化方案.md` | diff --git a/docs/系统脑图与流程图.md b/docs/系统脑图与流程图.md new file mode 100644 index 0000000..ca52eb6 --- /dev/null +++ b/docs/系统脑图与流程图.md @@ -0,0 +1,257 @@ +# affiliate_dash 系统脑图与流程图 + +> 配套文档:`docs/项目梳理-业务流程与数据模型.md` +> 用途:用 5 张图快速建立系统全貌;细节、字段与代码来源以配套文档为准。 +> 日期:2026-10-26 + +## 1. 系统全景脑图 + +```mermaid +mindmap + root((affiliate_dash)) + 角色与入口 + 平台管理员 + platform 管理端 + 商户 商品 渠道 Rock + 下游商户 + merchant 后台 + client v1 开放接口 + 终端买家 + delivery 提货页 + 上游货源 + open v1 回执 + 乐玩 Webhook + 核心业务 + 下单 + 开放 API + 后台人工 + 联调测试 + 资金 + 钱包账户 + 不可变流水 + 充值审核 + 履约 + 渠道路由 + DLC + 乐玩 + Rock + 提货 + HMAC 链接 + UID 绑定 + 回调 + 事务 outbox + HMAC 签名 + 状态机 + paid + delivering + delivered + ship_failed + cancelled + 关键规则 + 幂等键 + 金额与手续费 + 渠道锁定 + 已提交禁重试 + Rock 一单一 Token + 北京时间 + 数据模型 + 24 张表 + 订单事实表 + 钱包流水 + 回调 outbox + Rock 库存 + 审计日志 + 技术栈 + Go Gin GORM + PostgreSQL 16 + React AntD Vite + Caddy Docker + 风险 + 图片代理 SSRF XSS + 数据库暴露 + 无备份 + 无优雅停机 + 无限流 +``` + +## 2. 端到端主流程 + +```mermaid +flowchart TD + S["商户创建订单"] --> SRC{"订单来源"} + SRC -->|api| API["开放接口:验签 + nonce + scope"] + SRC -->|manual| MAN["后台人工:权限 + 功能开关"] + SRC -->|test| TEST["测试单:不扣款、不占库存"] + + API --> TX["事务创建订单"] + MAN --> TX + TX --> IDEM{"client_order_no 已存在?"} + IDEM -->|是,指纹一致| RET["返回原订单"] + IDEM -->|是,指纹不同| REJ["拒绝:幂等冲突"] + IDEM -->|否| PAY["扣钱包 + 写流水 + 扣库存"] + PAY --> ORD["写 fulfillment_orders,状态 paid"] + TEST --> ORD2["写测试订单,状态 paid"] + + ORD --> LINK["生成 HMAC 提货链接"] + ORD2 --> LINK + LINK --> BUYER["买家打开提货页"] + BUYER --> BIND["填写或扫码绑定游戏 UID"] + BIND --> ROUTE["渠道路由:priority / lewan / dlc / rock"] + ROUTE --> LOCK["锁定 result_data.provider"] + LOCK --> SUBMIT["提交上游发货"] + + SUBMIT --> RES{"提交结果"} + RES -->|成功| DELIV["delivering"] + RES -->|失败| FAIL["ship_failed"] + + DELIV --> NOTIFY["上游回执 / 乐玩 Webhook"] + NOTIFY --> SHIP{"发货结果"} + SHIP -->|成功| DONE["delivered"] + SHIP -->|失败| FAIL + + DONE --> OUT["事务写 callback outbox"] + FAIL --> OUT + OUT --> WORKER["worker 每 3 秒批量 50 条"] + WORKER --> CB["商户回调:X-Event-ID + X-Sign"] + + DELIV -.->|超时 30 分钟| FAIL + FAIL -.->|未提交上游| SUBMIT + PAY -.->|取消| CAN["cancelled + 退款"] + FAIL -.->|未提交上游,取消| CAN + DELIV -.->|已提交上游| NO["禁止自动重试/取消"] +``` + +## 3. 订单状态机 + +```mermaid +stateDiagram-v2 + [*] --> paid : 创建订单并扣款 + paid --> delivering : 提交上游成功 + paid --> ship_failed : 上游受理失败 + paid --> cancelled : 取消退款 + + delivering --> delivered : 回执成功 + delivering --> ship_failed : 回执失败或超时 + + ship_failed --> delivering : 未提交上游时重试 + ship_failed --> cancelled : 取消退款 + + delivered --> [*] + cancelled --> [*] + + note right of delivering + 已提交上游的订单 + 禁止自动重试/取消 + end note +``` + +**状态速记** + +| 状态 | 含义 | 能否取消 | 能否重试 | +|------|------|----------|----------| +| `paid` | 已付款,待发货 | 可以 | 可以提交上游 | +| `delivering` | 已提交上游,发货中 | 不可以 | 不可以,等待回执 | +| `delivered` | 已交付 | 不可以 | 不可以 | +| `ship_failed` | 发货失败 | 可以 | 仅未提交上游时可重试 | +| `cancelled` | 已取消并退款 | 终态 | 不可以 | + +## 4. 下单到回调时序 + +```mermaid +sequenceDiagram + autonumber + participant M as 商户系统 + participant A as affiliate_dash + participant DB as 订单/钱包库 + participant R as 渠道路由 + participant U as 上游货源 + participant B as 终端买家 + participant C as 商户回调 + + M->>A: POST /api/client/v1/orders(HMAC 签名) + A->>A: 验签 + nonce + scope + 功能开关 + A->>DB: 事务:幂等检查 + 扣款 + 扣库存 + 写订单 + DB-->>A: order_no / paid + A-->>M: 订单创建成功 + 提货链接 + M-->>B: 发送提货链接 + + B->>A: 打开提货页,提交游戏 UID + A->>R: 绑定 UID + 选择渠道 + R->>U: 提交发货 + U-->>R: 受理 / 失败 + R-->>A: 更新 delivering / ship_failed + + U->>A: ship_notify 或乐玩 Webhook + A->>DB: 幂等处理回执 + 更新 delivered / ship_failed + A->>DB: 同事务写 callback_deliveries + A->>C: 异步投递(X-Event-ID / X-Sign) + C-->>A: 2xx 或按退避重试 +``` + +## 5. 核心数据模型 + +```mermaid +erDiagram + users ||--o{ merchant_members : "成员" + merchants ||--o{ merchant_members : "成员" + merchants ||--o{ merchant_roles : "角色" + merchants ||--o{ merchant_products : "可售商品" + products ||--o{ merchant_products : "商品目录" + + merchants ||--|| wallet_accounts : "钱包" + wallet_accounts ||--o{ wallet_ledger_entries : "流水" + + merchants ||--o{ fulfillment_orders : "订单" + merchant_products ||--o{ fulfillment_orders : "SKU" + merchants ||--o{ callback_subscriptions : "回调订阅" + callback_subscriptions ||--o{ callback_deliveries : "投递" + fulfillment_orders ||--o{ callback_deliveries : "事件" + + rock_configs ||--o{ rock_tokens : "库存" + fulfillment_orders ||--o{ rock_tokens : "占用" + + merchants ||--o{ recharge_applications : "充值" + users ||--o{ user_devices : "设备" + users ||--o{ login_sms_codes : "验证码" + + merchants ||--o{ audit_logs : "租户" + users ||--o{ audit_logs : "操作人" + + fulfillment_orders { + bigint id PK + string order_no UK + string client_order_no UK + string status + bigint amount + jsonb result_data + } + + wallet_ledger_entries { + bigint id PK + bigint merchant_id FK + string idempotency_key UK + bigint amount + bigint balance_after + } + + rock_tokens { + bigint id PK + string status + string token + bigint order_id FK + } +``` + +## 6. 一分钟速记 + +| 维度 | 记住这些 | +|------|----------| +| 一句话 | 商户下单扣积分,平台路由到上游,买家绑定 UID,上游发货后回调商户 | +| 5 个状态 | `paid` → `delivering` → `delivered`;失败 `ship_failed`;取消 `cancelled` | +| 3 种来源 | `api`、`manual`、`test` | +| 4 条硬规则 | 幂等键防重;渠道锁定;已提交上游禁重试/取消;Rock 一单一 Token | +| 渠道路由 | `priority`、`lewan`、`dlc`、`rock` 四种模式;实际 Provider 为 DLC、乐玩、Rock | +| 3 个核心表 | `fulfillment_orders`、`wallet_ledger_entries`、`callback_deliveries` | +| 1 个异步链路 | 业务事务写 outbox → worker 批量投递 → 商户回调 | + +> 需要定位字段、索引、代码行号时,回到 `docs/项目梳理-业务流程与数据模型.md` 的 §5 数据模型与 §7 来源索引。 diff --git a/docs/项目梳理-业务流程与数据模型.md b/docs/项目梳理-业务流程与数据模型.md new file mode 100644 index 0000000..f85d228 --- /dev/null +++ b/docs/项目梳理-业务流程与数据模型.md @@ -0,0 +1,1407 @@ + # affiliate_dash 项目梳理:核心业务流程、状态流转、关键规则与数据模型 + +> 生成日期:2026-10-26 +> 分析范围:`backend/`、`frontend/`、`docs/`、数据库迁移与本次实际运行库 +> 来源约定:代码使用 `文件:行号`;文档使用 `docs/文件:行号`;“本次实测”指 2026-10-26 在 `affiliate_dash` 库/Docker 环境验证 +> 时效性说明:`docs/项目分析.md` 写于 2026-10-06,未覆盖 2026-10-21 接入的 DCAuth v2/短信登录;`docs/商品目录.md` 为 2026-09-14 快照。本文以当前代码与 14 个迁移为准。 + +--- + +## 0. 摘要 + +1. **系统定位**:游戏皮肤(和平精英)分销供货 SaaS。平台方对接上游货源,向下游商户提供“下单 → 扣钱包 → 渠道路由 → 买家绑定 → 上游发货 → 回写状态 → 回调商户”的完整闭环(`README.md:1-14`、`docs/项目分析.md:9-22`)。 +2. **三类使用者 + 两类外部系统**:平台管理员 `/platform-*`、下游商户 `/merchant-*` 与 `/api/client/v1`、终端买家 `/delivery/:channel/:orderNo`;上游通过 `/api/open/v1` 或乐玩 Webhook 回传结果(`backend/internal/router/router.go:171-233`)。 +3. **订单事实集中在 `fulfillment_orders`**:状态只有 `paid`、`delivering`、`delivered`、`ship_failed`、`cancelled` 五种;订单来源区分 `api`、`manual`、`test`(`backend/internal/model/merchant.go:46-54`)。 +4. **资金只记整数积分**:`base_amount = price_amount × quantity`,手续费按 `rate`(BP)或 `fixed` 二选一,`amount = base_amount + service_fee_amount`;钱包用 `int64`,流水带 `balance_after` 与幂等键(`backend/internal/service/fulfillment_order.go:22-44`、`backend/internal/service/fulfillment_create.go:131-199`、`backend/internal/model/merchant.go:185-210`)。 +5. **渠道抽象是架构核心**:`FulfillmentProvider` 统一 `PrepareDelivery / Bind / CheckBind / Submit / SyncStatus / Cancel`,实现 DLC、乐玩、Rock 三个渠道;路由支持 `priority/lewan/dlc/rock`,并在订单 `result_data.provider` 落值后锁定渠道(`backend/internal/service/provider.go:12-21, 139-232`)。 +6. **可靠性基础较好**:订单幂等指纹、钱包流水幂等键、回调事务性 outbox、API nonce 持久化、行锁/`SKIP LOCKED` 均已落地(`fulfillment_create.go:94-107`、`callback.go:213-250`、`middleware/open_auth.go:122-148`、`rock_assign.go:108-124`)。 +7. **主要风险**:公开的 `image-proxy` 可访问任意 URL 并透传上游 `Content-Type`,存在 SSRF/同源 XSS 风险;Rock `Bearer Token` 明文存储且 `GET /platform/rock/config` 原样返回;核心表没有数据库外键;`audit_logs.request_id` 实际未写入;来源侧永久拒绝返回 400,与 service 注释和乐玩 Webhook 的 200 语义不一致(详见第 6 节)。 +8. **工程化缺口**:无 CI、无数据库备份脚本、无优雅关闭、无运维指标/告警基线、无前端测试;Rock 自动采购在多实例下存在重复采购风险(`main.go:164-179`、`rock_stock.go:15-113`、`frontend/package.json`)。 + +--- + +## 1. 系统定位与角色 + +| 角色 | 入口 | 职责 | 关键代码 | +|------|------|------|----------| +| 平台管理员 | `/platform-*` | 商户、商品目录、渠道路由、Rock 库存、平台账号 | `backend/internal/router/router.go:238-298` | +| 下游商户 | `/merchant-*`、`/api/client/v1` | 卖皮肤、查订单、钱包、充值、回调订阅 | `router.go:300-354`、`router.go:215-233` | +| 终端买家 | `/delivery/:channel/:orderNo` | 填/确认游戏 UID、扫码绑定、领取凭证 | `router.go:189-198`、`frontend/src/pages/Delivery.tsx` | +| 上游货源 | `/api/open/v1`、乐玩 Webhook | 查单、回传发货结果 | `router.go:200-212`、`handler/lewan_webhook.go:136-148` | + +技术栈:Go 1.26 + Gin + GORM + PostgreSQL 16;React 19 + TypeScript + Ant Design 6 + Vite 8;Caddy + Docker Compose(`README.md:5-14`、`docs/项目分析.md:25-31`)。 + +分层:`cmd/server` 组装依赖 → `router` 路由/日志 → `handler` 参数与响应 → `service` 业务 → `database` 显式 SQL 迁移(`docs/项目分析.md:48-59`)。 + +--- + +## 2. 核心业务流程 + +### 2.1 商户下单(开放 API / 后台人工 / 联调测试) + +**入口**:`POST /api/client/v1/orders`,使用 `X-App-Key + X-Timestamp + X-Nonce + X-Sign`,并要求 `orders:write` scope(`backend/internal/router/router.go:223-225`、`backend/internal/middleware/open_auth.go:55-152`)。 + +**处理链**: + +1. 校验 `client_order_no` 非空且 ≤96 位、`sku` 非空、`quantity` ≥1(`backend/internal/service/fulfillment_create.go:56-73`)。 +2. 计算请求指纹:`client_order_no + sku + quantity + buyer_reference + request_data` 的 SHA-256(`backend/internal/service/fulfillment_state.go:21-39`)。 +3. 在事务内先按 `(merchant_id, client_order_no)` 查已存在订单: + - 存在且指纹一致 → 返回原单,`idempotent=true`; + - 存在但指纹不一致 → 报错“参数与原订单不一致”(`fulfillment_create.go:94-107`、`fulfillment_state.go:56-64`)。 +4. 校验商户 `active`、商户商品 `active`、目录商品 `active`;商品行加 `FOR UPDATE` 锁(`fulfillment_create.go:109-127`)。 +5. 校验库存:`stock >= 0` 时按数量扣减,`stock = -1` 表示无限库存(`fulfillment_create.go:128-130, 200-205`、`backend/internal/model/merchant.go:177`)。 +6. 计算金额:`base = price_amount × quantity`;手续费按商户 `fee_type=rate`(`base × fee_rate_bp / 10000`)或 `fixed`(固定积分)二选一;`amount = base + service_fee`(`fulfillment_order.go:22-44`、`fulfillment_create.go:131-142`)。 +7. 钱包行 `FOR UPDATE`,校验可用余额,扣减并写 `wallet_ledger_entries`(`type=debit`,`amount=-total`,`balance_after`,幂等键=`client_order_no`)(`fulfillment_create.go:144-199`)。 +8. 创建订单,初始状态 `paid`,写入请求快照 `request_data`、指纹、来源 `api/manual`(`fulfillment_create.go:157-180`)。 +9. 写审计:`open_order.create` 或 `merchant_order.manual_create`(`fulfillment_create.go:206-212`)。 +10. 事务内写回调 outbox:`order.created`;**人工订单不写该事件**(`fulfillment_create.go:213-217`)。 + +```mermaid +sequenceDiagram + participant M as 商户系统 + participant A as API Handler + participant F as FulfillmentService + participant DB as PostgreSQL + participant C as CallbackService + + M->>A: POST /api/client/v1/orders + A->>A: HMAC + nonce + scope 校验 + A->>F: CreateOrder(...) + F->>DB: 查 (merchant_id, client_order_no) + alt 已存在且指纹一致 + DB-->>F: 原订单 + F-->>A: idempotent=true + else 新订单 + F->>DB: 锁商品/钱包,扣余额,写订单+流水+库存+审计 + F->>C: Enqueue(order.created) + F-->>A: 新订单 + end + A-->>M: 201(新建)/ 200(幂等) +``` + +**人工订单**:`CreateManualOrder` 复用同一 `CreateOrder`,来源 `manual`,同样扣钱包/扣库存/写审计,但不产生 `order.created` 回调(`fulfillment_create.go:43-54, 213-217`)。 + +**联调测试订单**:`CreateTestOrder` 不扣钱包、不占库存,金额为 0,来源 `test`;初始状态仅允许 `paid`、`ship_failed`、`cancelled`(`fulfillment_testorder.go:22-104`)。 + +**开放接口额外限制**:`data` 最长 2048 字节;拒绝 `LEWAN_*` 镜像 SKU;新建返回 201,幂等返回 200(`handler/open_v1.go:60-107`)。 + +--- + +### 2.2 提货链接与买家绑定 + +**入口**: +- 商户获取链接:`GET /api/merchant/orders/:order_no/delivery-link`,或开放接口 `GET /api/client/v1/orders/:order_no/delivery-link`(`router.go:227, 321`)。 +- 买家页:`GET /api/delivery/v1/orders/:order_no`、`POST bind`、`GET bind-result`、`POST submit`(`router.go:190-198`)。 + +**链接规则**: + +- 链接格式:`/delivery/{channel}/{orderNo}?exp={exp}&sign={sign}`(`delivery_link.go:77-87`)。 +- 签名:`HMAC-SHA256(linkSecret, orderNo + "|" + exp)`,比较使用常量时间(`delivery_link.go:89-93`)。 +- `exp=0` 表示永久有效;有时效链接的 `exp` 必须等于数据库 `delivery_link_expires_at`(UTC 秒级),且未过期(`delivery_link.go:38-57`)。 +- 已作废、已取消、从未生成链接均拒绝;已生成永久链接直接复用(`delivery_link.go:27-45, 117-120`)。 +- 只有 `CanFulfill(order)=true` 的订单才能生成/续期新链接;已交付/发货中订单可复用现有未过期链接查看结果,但不再发新链接(`delivery_link.go:112-143`)。 +- `DELIVERY_LINK_TTL_MINUTES<=0` 表示永久;未设置 `DELIVERY_LINK_SECRET` 时会回落到硬编码默认值(`delivery.go:68-76`)。 + +**买家流程**: + +1. 打开链接 → 校验签名/状态 → `buildDeliveryState` 组合本地订单状态与上游 `PrepareDelivery` 的 `CanShip`,并解析商品、渠道、角色字段(`delivery_query.go:72-198`)。 +2. 填 UID → `POST bind`:按路由优先级逐个 `Provider.Bind`;若已锁定渠道失败,清除锁定后重试其余渠道;成功后把 `provider` 及上游单号写回 `result_data`(`delivery_bind.go:92-188`)。 +3. 轮询 `GET bind-result?bind_uuid=...`:调用 `Provider.CheckBind`;未完成返回 `bound=false`,不报错(`delivery_bind.go:19-54`)。 +4. `POST submit`:先做幂等/重复发货检查,再在事务内 `FOR UPDATE` 抢占订单,写入 `provider_order_stage=claimed`、`submit_started_at`、`ship_attempts+1`,然后调用 `Provider.Submit`,最后更新订单状态与 `result_data`(`delivery_submit.go:24-129, 131-201`)。 +5. 若上游提交失败,订单置为 `ship_failed`,并记录 `fail_stage/fail_reason/fail_at`;已提交上游的失败订单禁止直接重试,避免重复发货(`delivery_submit.go:221-248`、`delivery.go:29-46`)。 + +```mermaid +sequenceDiagram + participant B as 买家浏览器 + participant D as DeliveryService + participant P as FulfillmentProvider + participant DB as PostgreSQL + + B->>D: GET /api/delivery/v1/orders/{no}?exp&sign + D->>DB: 校验链接签名/过期/作废/取消 + D->>P: PrepareDelivery + P-->>D: CanShip + 商品信息 + D-->>B: 商品 + can_ship + + B->>D: POST bind { game_account } + D->>P: Bind + P-->>D: bind_uuid / qr_url / 绑定结果 + D-->>B: 二维码/绑定信息 + + loop 轮询 + B->>D: GET bind-result?bind_uuid + D->>P: CheckBind + P-->>D: bound / mismatch + end + + B->>D: POST submit { game_account, bind_uuid } + D->>DB: 行锁 claim,写 stage/attempts + D->>P: Submit + P-->>D: provider_order_no + next_status + D->>DB: 更新订单/result_data +``` + +**渠道差异**: + +| 渠道 | Bind/Submit 特征 | 结果回写 | +|------|------------------|----------| +| DLC | 通过第一平台 BFF `/sign-proxy` 签名代理;有内置 `deliveryGoodID` 映射 | Submit 可能返回 `delivering`,后续依赖上游 `ship-notify` | +| 乐玩 | 开放平台 LINK/EMAIL 绑定;`provider_order_no`/`lewan_order_id` 写入 `result_data` | 支持主动查单/Webhook 回写 | +| Rock | 本地 Token 库存池;绑定即分配 Token,最终兑换同步返回 | Submit 成功直接置 `delivered`(`provider_rock.go:293-303`) | + +来源:`backend/internal/service/provider.go:12-21`、`delivery_helpers.go:79-118`、`provider_rock.go:28-361`、`docs/项目分析.md:61-78`。 + +--- + +### 2.3 上游发货与回执 + +**上游查单**:`GET /api/open/v1/orders/:order_no`,使用 `X-Api-Key + X-Timestamp + X-Nonce + X-Sign`,返回 `status`、`can_ship`、`cannot_ship_reason`、商品/买家/金额/上游单号/发货时间/失败原因/游戏字段(`handler/open.go:25-54`、`fulfillment_query.go:145-182`)。 + +**上游回传**:`POST /api/open/v1/orders/ship-notify`,只接受 `success` / `failed`: + +1. `failed` 时 `fail_reason` 必填,最长 512 字符;`shipped_at` 必须是 RFC3339(`fulfillment_shipnotify.go:48-62`、`handler/open.go:90-99`)。 +2. 事务内按 `order_no` 加 `FOR UPDATE` 锁;若找不到且订单号包含 `_`,会尝试去掉后缀取基础订单号(适配批量/子订单号)(`fulfillment_shipnotify.go:74-95`)。 +3. 已交付订单重复推 `success` → 幂等成功,并刷新 `result_data`;已交付再推 `failed` → 永久拒绝并写审计(`fulfillment_shipnotify.go:103-116, 146-163`)。 +4. 已取消订单 → 拒绝并写审计(`fulfillment_shipnotify.go:118-124`)。 +5. `success` → `delivered`,清空 `failure_reason`,写入 `delivered_at`、上游单号、游戏字段;`failed` → `ship_failed`,写入失败原因(`fulfillment_shipnotify.go:126-174`)。 +6. 事务内写 `order.shipping.updated` 回调(`fulfillment_shipnotify.go:180-184`)。 + +**状态矩阵(服务层)**: + +| 当前状态 | success | failed | 说明 | +|----------|---------|--------|------| +| `paid` | → `delivered` | → `ship_failed` | 上游可直接成功或失败 | +| `delivering` | → `delivered` | → `ship_failed` | 正常回执 | +| `ship_failed` | → `delivered` | → `ship_failed` | 允许补推成功/重复失败 | +| `delivered` | 幂等成功 | 拒绝(永久) | 不允许回退 | +| `cancelled` | 拒绝 | 拒绝 | 不允许更新 | + +来源:`fulfillment_state.go:76-116`、`fulfillment_shipnotify.go:103-163`、`docs/发货通知约定.md:8-72`。 + +**一个契约不一致点**:`fulfillment_shipnotify.go:36-45` 的注释明确“永久拒绝应回 200 避免无限重试”,乐玩 Webhook 也确实对永久拒绝返回 200(`handler/lewan_webhook.go:136-145`);但源头侧 `handler/open.go:113-121` 只区分“订单不存在=404”,其余错误统一 400。若上游对 400 重试,会对已取消/已交付订单反复推送。建议在源头 handler 中对 `ErrShipNotifyRejected` 返回 200(详见第 6 节风险)。 + +--- +### 2.4 商户回调(事务性 outbox) + +**配置**:每个商户可配置回调订阅;`events` 为逗号分隔事件或 `*`;`url` 仅接受 http/https;secret 使用 AES-GCM 加密落库,创建/轮换时只返回一次明文(`backend/internal/service/callback.go:103-196`、`backend/internal/model/merchant.go:254-267`、`backend/internal/service/secret_codec.go:18-55`)。服务层会把同一商户其他 active 订阅置为 disabled,保证“一个商户一个 active 订阅”(`callback.go:187-190`)。 + +**事件**: + +| 事件 | 触发点 | 来源 | +|------|--------|------| +| `order.created` | 开放 API 新建订单;人工订单**不触发** | `fulfillment_create.go:213-217` | +| `order.shipping.updated` | 上游 `ship-notify`、状态更新、超时巡检 | `fulfillment_shipnotify.go:180-184`、`fulfillment_update.go:80-82`、`fulfillment_timeout.go:96-100` | +| `order.cancelled` | 取消/退款成功 | `fulfillment_cancel.go:132-136` | + +**投递机制**: + +- `Enqueue` 在业务事务内写 `callback_deliveries`,`event_id` 唯一,payload 为 JSON;业务事务失败则事件不落库(`callback.go:213-250`)。 +- 后台 worker 每 3 秒取一批(≤50 条),用条件 UPDATE 原子抢占 `pending` 或“发送中超过 5 分钟”的记录,天然支持多实例(`callback.go:253-348, 378-394`)。 +- 默认最多 16 次尝试;重试序列 15s、15s、30s、3m、10m、20m、30m×3、60m、3h×3、6h×2,用尽后复用最后间隔(`callback.go:27-46, 361-375, 429-440`)。 +- 404 或响应体包含 `task_not_found` / “未匹配到本地任务”视为永久失败,不再重试(`callback.go:351-358`)。 +- 请求头:`X-Event-ID`、`X-Timestamp`、`X-Sign`;签名串为 `body_sha256=×tamp=`,`X-Sign=HMAC-SHA256(secret, 签名串)`(`callback.go:316-324, 396-407`)。 +- payload 由 `orderCallbackData` 生成,包含 `order_no/client_order_no/product_sku/quantity/base_amount/fee_type/service_fee_amount/amount/currency/order_status/order_source/can_ship/cannot_ship_reason/provider_order_no/failure_reason`,以及原始 `data`(`fulfillment.go:51-74`)。 + +```mermaid +stateDiagram-v2 + [*] --> pending: Enqueue + pending --> sending: 原子抢占 + sending --> delivered: 2xx + sending --> pending: 失败且未达上限 + sending --> failed: 达 16 次 / 永久拒绝 + pending --> pending: 未到 next_attempt_at + delivered --> [*] + failed --> [*] +``` + +> 注意:回调 URL 只校验了协议和 host,未拦截私网地址(`callback.go:418-427`)。具备 `callbacks:manage` 的商户成员可以把回调指向内网服务,属于“商户可控的出站请求”风险。 + +--- + +### 2.5 取消、退款与发货超时 + +**取消**: + +1. 按 `(merchant_id, order_no)` 加行锁;已 `cancelled` 直接幂等返回(`fulfillment_cancel.go:56-70`)。 +2. `canCancelOrder` 只允许 `paid` 或 `ship_failed`;`delivering`/`delivered` 拒绝(`fulfillment_state.go:118-134`)。 +3. 若 `ship_failed` 且已提交上游正式订单(`provider_order_no`/`delivery`/`upstream_order`/`provider_order_stage=submitted` 任一成立),拒绝取消,避免“本地退款但上游继续发货”的双重损失(`fulfillment_cancel.go:74-78`、`delivery.go:29-46`)。 +4. 先调用上游取消钩子;失败则整个取消失败(`fulfillment_cancel.go:79-84`)。 +5. 订单置 `cancelled`,写 `failure_reason=取消原因`、`cancelled_at`;按 `order.Amount`(含手续费)全额退回可用余额,写 `type=refund` 流水,幂等键 `cancel:`;库存回补;写审计;事务内写 `order.cancelled` 回调(`fulfillment_cancel.go:85-136`)。 + +**发货超时**: + +- 每分钟扫描(`FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS`,默认 60 秒)`order_status=delivering` 且 `updated_at` 超过阈值的订单(默认 30 分钟,`FULFILLMENT_PROCESSING_TIMEOUT_MINUTES`)(`fulfillment_timeout.go:17-33, 107-129`)。 +- 先尝试 `upstreamSync` 主动同步上游状态;同步成功则跳过(`fulfillment_timeout.go:35-43`)。 +- 行锁内再次确认超时,`validateOrderStatusTransition(..., timeout)` 只允许从 `delivering` 置 `ship_failed`;根据是否已提交上游写不同的失败原因;写审计与 `order.shipping.updated` 回调(`fulfillment_timeout.go:55-104`)。 +- 已提交上游的超时订单提示“可能仍在处理,请勿直接重试,先在上游确认状态”(`fulfillment_timeout.go:71-77`)。 + +**可发货判定 `CanFulfill`**: + +| 状态 | 可发货 | 原因 | +|------|--------|------| +| `paid` | 是 | — | +| `ship_failed` | 是,除非已提交上游 | 已提交上游则拒绝自动重试 | +| `delivering` | 否 | 发货中 | +| `delivered` | 否 | 已交付 | +| `cancelled` | 否 | 已取消 | + +来源:`fulfillment.go:31-49`。 + +--- + +### 2.6 充值、钱包与余额告警 + +**充值申请**: + +- 最小 10 元(`1000` 分);`1 元 = 100 积分`,即 `1 分人民币 = 1 积分`;`points_amount = amount_cny × 1`(`recharge.go:26-30, 80-88`)。 +- 必须上传 1–5 张打款凭证;备注 ≤512 字;状态初始 `pending`(`recharge.go:53-93`、`model/recharge.go:16-35`)。 +- 凭证图片:单张 ≤5MB;按文件魔数只接受 jpg/png/gif/webp;以 UUID 重命名,返回 `/uploads/.`(`handler/recharge.go:14, 242-262`、`service/recharge.go:570-612`)。 + +**审核入账**: + +- 行锁读取申请;只有 `pending` 可审核;通过则 `AdjustWallet`,幂等键=`application_no`,状态与入账在同一事务;拒绝只改状态;写审计 `recharge.review`(`recharge.go:133-185`)。 +- 钱包调整必须提供幂等键;已有同 `(merchant_id, idempotency_key)` 流水则直接返回钱包;行锁余额;调整后不得为负;正数写 `credit`,负数写 `debit`,`reference_type=manual_adjustment`;写审计 `wallet.adjust`(`fulfillment_wallet.go:22-81`)。 + +**余额告警**: + +- `merchant_alert_configs` 每商户一份:阈值、通知间隔、最大通知次数、上次通知时间/次数;迁移 005 增加通知策略(`model/recharge.go:37-55`、`003_recharge.sql:26`、`005_alert_policy.sql`)。 +- 通知渠道支持钉钉/飞书/企微/Bark/通用 Webhook;配置以 JSON 存 `alert_notify_channels.config`;响应层对 webhook URL/Key 做尾部 4 位脱敏,service 层保留真实值用于发送(`recharge.go:240-380`、`model/recharge.go:57-85`)。 +- 历史 `merchant_alert_configs.webhook_url` 在 004 迁移中转为通用 webhook 渠道(`004_alert_channels.sql:19-24`)。 + +--- + +### 2.7 Rock 库存、采购、绑定与核销 + +**配置(单行,`id=1`)**:全局自动发货开关、上游地址、Bearer Token、上游积分、低积分阈值、库存预警阈值、自动采购开关/数量/冷却、上次采购时间/心跳(`model/rock.go:35-56`、`012_rock_fulfillment.sql:6-27`)。 + +**Token 状态机**: + +- `available`:可用库存; +- `using`:已分配给订单,等待扫码; +- `bound`:扫码绑定成功且 UID 一致; +- `redeemed`:已兑换发货; +- `mismatch`:扫码 UID 与填写 UID 不一致,硬阻断; +- `abnormal`:人工异常/作废(`model/rock.go:21-26`)。 + +**分配(Bind)**: + +1. 若订单已占用过 Token(`using/bound/mismatch/redeemed/abnormal`),不再领新号;`using/bound` 复用原号并允许更新期望 UID;`bound` 后禁止换 UID;`redeemed`、`abnormal` 拒绝(`rock_assign.go:43-105`)。 +2. 若无占用,事务内 `SELECT ... FOR UPDATE SKIP LOCKED` 取一个 `available + black` Token,置 `using`、写 `assigned_order_no`、`expected_uid`(`rock_assign.go:107-124`)。 +3. 调上游获取绑定活动链接;首次分配时记录上游初始绑定快照(角色、UID、区服、绑定变更时间),用于识别“历史绑定残留”(`rock_assign.go:133-155`、`rock_snapshot.go:15-36`)。 + +**绑定校验(CheckBind)**: + +- 已 `mismatch` 的订单:若买家把填写 UID 改成扫码角色的实际 UID,则解除阻断置 `bound`;否则继续提示(`rock_bindcheck.go:35-82`)。 +- Token 已释放或不属于当前订单 → 拒绝(`rock_bindcheck.go:84-96`)。 +- 上游 refresh 有 800ms 防抖缓存;上游频控 `110001` 时平滑降级,不打断买家(`rock_bindcheck.go:98-140`)。 +- `rockBuyerNewlyBound` 用“分配快照 + 绑定变更时间 + 发货记录时间”判断是否本次新扫码;历史残留忽略(`rock_snapshot.go:15-36`)。 +- UID 强一致:填写 UID 非空且 ≠ 扫码 UID → Token 置 `mismatch`,记录 `mismatch_at/reason`,**严禁发货**;一致则置 `bound` 并保存角色信息(`rock_bindcheck.go:162-212`)。 + +**核销(Submit)**: + +- 只接受 `bound` Token;再次校验 UID;按 SKU 决定皮肤(默认黑色 ROCK 长袖 T 恤,`rock_blue_sportswear` 为蓝色运动服);调上游兑换;成功后 Token 置 `redeemed`,记录 `upstream_fulfill_id`、目标皮肤/货币/数量(`rock_redeem.go:17-91`)。 +- 取消订单时,仅 `using`(尚未扫码绑定)的 Token 会重置回 `available`;已绑定不释放(`provider_rock.go:343-361`)。 +- 人工释放仅允许 `using` 且未绑定/无实际角色;释放前会向上游确认,若已出现新扫码则拒绝;释放后清空订单上的 `rock_token_id/redeem_code` 并标记 `rock_token_released`(`rock_redeem.go:93-168`)。 + +```mermaid +stateDiagram-v2 + [*] --> available: 采购入库 + available --> using: 分配订单/填写 UID + using --> bound: 扫码且 UID 一致 + using --> mismatch: 扫码但 UID 不一致 + mismatch --> bound: 买家确认改为扫码 UID + bound --> redeemed: 上游兑换成功 + using --> available: 取消/人工释放 + available --> abnormal: 人工作废 + mismatch --> abnormal: 人工作废 + redeemed --> [*] + abnormal --> [*] +``` + +**自动采购**: + +- 每 5 分钟巡检一次 `available + black` 数量;≤ `TokenWarningStock` 且开启 `AutoPurchaseEnabled` 时采购 `AutoPurchaseCount`(默认 50)(`main.go:172-179`、`rock_stock.go:85-113`)。 +- 采购使用进程内 `sync.Mutex` + `LastPurchasedAt` 冷却(默认 10 分钟);调上游 Claim 后批量入库并更新 `last_purchased_at`(`rock_stock.go:15-83`)。 +- **多实例风险**:`s.mu` 只在单进程内有效,冷却检查在 DB 事务前;两个实例可能同时通过冷却检查并各自采购。扩容前必须改为 DB advisory lock / 单例 worker(详见第 6 节)。 + +--- + +### 2.8 登录、租户与权限 + +**登录模式**: + +| 账号状态 | 登录方式 | 说明 | +|----------|----------|------| +| 未绑定手机号 | 账号密码 | bcrypt 校验;JWT 有效期 7 天(`auth.go:54-64`、`jwt.go:19-35`) | +| 已绑定手机号 | 密码 + 短信验证码 + DCAuth 设备签名 | 密码单独登录会被 403 `device_auth_required` 拒绝(`auth.go:59-62`) | + +**短信**:6 位数字,SHA-256 哈希存储、一次性消费;默认 TTL 300 秒、冷却 60 秒、最多 5 次校验失败;阿里云模式下按手机号每日上限 10 次,Mock 模式不做每日上限(`login_sms_service.go:29-39, 98-160, 170-210`)。 + +**DCAuth v2**:challenge TTL 120 秒;时钟偏差 ≤300 秒;失败/轮询限流 30 次/分钟;Ed25519 验签;`pending → authorized → ticket_issued → consumed`;每个用户同时只能有一台 active 设备(`login_dcauth_service.go:25-33, 234-317`、`014_login_sms_device_auth.sql:47-92`)。 + +**租户上下文**: + +- 商户后台/商户开放接口通过 `X-Merchant-ID` 或账号默认成员关系解析当前商户;管理员携带 `X-Merchant-ID` 时按目标商户解析,否则回退到自己的默认成员关系(`middleware/auth.go:78-113`)。 +- 平台路由(`/platform/*`)刻意不挂 `Tenant`,避免管理员的残留商户上下文把整个管理端 403(`router.go:235-240`、`docs/项目分析.md:345-385`)。 +- 商户功能开关:`products/orders/wallet/api/callbacks`;商户角色权限:`members:manage/products:manage/orders:manage/wallet:view/wallet:ledger/recharge:manage/api:manage/callbacks:manage`;owner 拥有全部权限,viewer 为空,operator/finance 从 `merchant_roles` 读取(`model/merchant.go:16-35`、`tenant.go:127-136`、`008_default_merchant_team_roles.sql`)。 + +--- + +## 3. 状态流转 + +### 3.1 订单状态机 + +```mermaid +stateDiagram-v2 + [*] --> paid: 创建订单/扣款 + paid --> delivering: 提交发货 + paid --> delivered: 上游直接回 success + paid --> ship_failed: 上游回 failed + paid --> cancelled: 取消并退款 + delivering --> delivered: 上游回 success + delivering --> ship_failed: 上游 failed / 超时巡检 + ship_failed --> delivering: 允许重试 + ship_failed --> delivered: 上游补回 success + ship_failed --> cancelled: 取消并退款 + delivered --> [*] + cancelled --> [*] +``` + +| 状态 | 含义 | 可发货 | 允许的下一状态(服务层校验) | +|------|------|--------|------------------------------| +| `paid` | 已扣款,待发货 | 是 | `delivering`、`delivered`、`ship_failed`、`cancelled` | +| `delivering` | 发货中 | 否 | `delivered`、`ship_failed` | +| `delivered` | 已交付 | 否 | 终态(不允许回退) | +| `ship_failed` | 发货失败,可重试 | 是(未提交上游时) | `delivering`、`delivered`、`cancelled` | +| `cancelled` | 已取消/已退款 | 否 | 终态(重复取消幂等) | + +来源:`backend/internal/model/merchant.go:46-50`、`fulfillment_state.go:76-134`、`fulfillment.go:31-49`。 + +### 3.2 发货提交阶段(`result_data.provider_order_stage`) + +| 阶段 | 含义 | +|------|------| +| `claimed` | 已锁定订单,尚未建上游 queue | +| `queue_created` | 已建 queue,尚未生成正式订单 | +| `submitted` | 已在上游创建正式订单 | +| `bind_verify` | 绑定账号校验失败 | +| `bind_mismatch` | 玩家编号与绑定账号不一致 | +| `create_queue` | 创建上游 queue 失败 | +| `patch_queue` | 补充 queue 账号信息失败 | +| `create_upstream` | 创建上游正式订单失败 | + +来源:`backend/internal/service/delivery.go:13-27`。已提交上游的判定同时看 `provider_order_no`、`delivery`/`upstream_order` 字段和 `provider_order_stage=submitted`(`delivery.go:29-46`)。 + +### 3.3 回调投递状态 + +`pending → sending → delivered`;失败且未达上限回到 `pending`;达到 16 次或永久拒绝 → `failed`。`sending` 超过 5 分钟可被重新抢占(`callback.go:289-375`、`model/merchant.go:64-67`)。 + +### 3.4 Rock Token 状态 + +见 2.7 的状态图与 `model/rock.go:21-26`。关键约束: + +- 一个订单只能占用一个 Token;`mismatch` 后不能换新号,只能“确认为扫码 UID”解除或人工处理(`rock_assign.go:26-105`)。 +- `bound` 后不能改 UID;`redeemed` 不能再次领取;`abnormal` 永久锁定(`rock_assign.go:59-105`、`rock_redeem.go:93-144`)。 +- 取消订单只释放 `using`,不释放 `bound/redeemed`(`provider_rock.go:343-361`)。 + +### 3.5 其它状态字典 + +| 域 | 取值 | 来源 | +|----|------|------| +| 订单状态 | `paid` / `delivering` / `delivered` / `ship_failed` / `cancelled` | `model/merchant.go:46-50` | +| 订单来源 | `api` / `manual` / `test` | `model/merchant.go:52-54` | +| 钱包流水类型 | `credit` / `debit` / `refund` / `adjust` | `model/merchant.go:56-59` | +| 回调订阅状态 | `active` / `disabled` | `model/merchant.go:61-62` | +| 回调投递状态 | `pending` / `sending` / `delivered` / `failed` | `model/merchant.go:64-67` | +| 充值状态 | `pending` / `approved` / `rejected` | `model/recharge.go:9-14` | +| Rock Token 状态 | `available` / `using` / `bound` / `redeemed` / `mismatch` / `abnormal` | `model/rock.go:21-26` | +| 商户状态 | `active` / `disabled` | `model/merchant.go:10-11` | +| 商户成员角色 | `owner` / `operator` / `finance` / `viewer`(自定义如 `support`) | `model/merchant.go:23-26`、`008_default_merchant_team_roles.sql` | +| 商品状态 | `active` / `inactive` | `model/merchant.go:43-44` | +| API 客户端状态 | `active` / `disabled` | `model/merchant.go:40-41` | +| 手续费类型 | `rate` / `fixed` | `model/merchant.go:37-38` | +| DCAuth challenge 状态 | `pending` / `authorized` / `ticket_issued` / `consumed` / `expired` / `rejected` | `model/login_auth.go:8-13` | +| 告警渠道类型 | `dingtalk` / `feishu` / `wecom` / `bark` / `webhook` | `model/recharge.go:57-64` | + +--- +## 4. 关键逻辑规则 + +### 4.1 租户、权限与接口 + +**R1:三套外部鉴权不可混用。** + +| 链路 | Key Header | 签名参与内容 | Secret 来源 | +|------|------------|--------------|-------------| +| 商户侧 `/api/client/v1` | `X-App-Key` | `app_key + body_sha256 + method + nonce + path + timestamp` | `api_clients.secret_ciphertext` 解密 | +| 源头侧 `/api/open/v1` | `X-Api-Key` | `api_key + 原始 body + method + nonce + path + timestamp` | `OPEN_API_SECRET` | +| 商户回调 | 无 Key | `body_sha256 + timestamp` | `callback_subscriptions.secret_ciphertext` 解密 | + +来源:`backend/internal/middleware/open_auth.go:55-152, 184-197`、`source_open_auth.go:52-124`、`callback.go:316-324, 396-407`、`docs/API对接关系.md:8-48`。 + +**R2:nonce 防重放落库,跨进程/重启生效。** + +- 商户侧 nonce 长度 8–96;源头侧 8–64;时间戳偏差默认 300 秒。 +- `api_request_nonces(api_client_id, nonce)` 与 `source_api_nonces(api_key, nonce)` 唯一;请求时先清理过期行,再 `ON CONFLICT DO NOTHING` 插入,`RowsAffected=0` 即判定重放并返回 401。 +- 乐玩 Webhook 复用 `source_api_nonces`(`api_key='lewan_webhook'`, `nonce=event_id`):永久拒绝保留占位,可重试错误释放占位。 + +来源:`open_auth.go:65-148`、`source_open_auth.go:68-119`、`handler/lewan_webhook.go:136-165`。 + +**R3:商户功能、角色权限、API scope 三层门禁。** + +- 功能开关:`merchants.features` 的 `products/orders/wallet/api/callbacks`;不包含则该模块全部接口 403(`middleware/feature.go`)。 +- 角色权限:`members:manage`、`products:manage`、`orders:manage`、`wallet:view`、`wallet:ledger`、`recharge:manage`、`api:manage`、`callbacks:manage`;`owner` 全量、`viewer` 为空、`operator/finance` 从 `merchant_roles` 读取(`model/merchant.go:16-35`、`tenant.go:110-136`)。 +- API scope:`products:read`、`orders:read`、`orders:write`、`shipping:read`、`wallet:read`;`RequireAPIScope` 只要求命中任意一个(`middleware/open_auth.go:156-166`、`router.go:223-232`)。 + +**R4:两个订单入口的数据范围不同。** + +- `GET /api/merchant/orders`:强制单商户,受 `X-Merchant-ID`/默认成员关系影响(`fulfillment_query.go:85-93`)。 +- `GET /api/platform/orders`:管理员跨商户,`merchantID=nil` 表示不限商户(`fulfillment_query.go:95-105`)。 +- 平台路由刻意不挂 `Tenant`;前端对 `/platform/*` 和管理员 `/dashboard` 不发送 `X-Merchant-ID`(`router.go:235-240`、`frontend/src/api/request.ts:22-32`)。 +- 管理员 `/api/dashboard` 是平台口径;商户后台是单商户口径,两者数字不同是设计结果,不是 bug(`docs/项目分析.md:329-385`)。 + +**R5:CORS 显式白名单,拒绝 `*`。** + +`corsOrigins` 会丢弃空值和 `*`;未配置时只放行 `localhost/127.0.0.1` 的 15173/18080;`AllowCredentials=true`(`router.go:48-70, 154-160`)。 + +--- + +### 4.2 订单、金额与库存 + +**R6:订单幂等 = 联合唯一键 + 请求指纹。** + +- 唯一索引 `(merchant_id, client_order_no)`(`001_init.sql:229`)。 +- 已存在时比对 `request_fingerprint`(client_order_no、sku、quantity、buyer_reference、request_data 的 SHA-256);一致返回原单,不一致报错(`fulfillment_state.go:21-64`、`fulfillment_create.go:94-107`)。 +- 并发首次插入撞唯一约束时,捕获 duplicate/UNIQUE 后回查原单,再次比对指纹(`fulfillment_create.go:221-233`)。 + +**R7:金额 = 商品价 × 数量 + 手续费;手续费二选一。** + +- `rate`:`base_amount × fee_rate_bp / 10000`;`fixed`:直接取 `fee_fixed_amount`;两者互斥,不叠加(`fulfillment_order.go:22-44`)。 +- `amount = base_amount + service_fee_amount`;订单落手续费快照(`fee_type/fee_rate_bp/fee_fixed_amount/service_fee_amount`),后续商户改费率不影响历史订单(`fulfillment_create.go:157-177`)。 +- 金额全程 `int64`,对乘法/加法做溢出保护(`fulfillment_create.go:131-142`)。 + +**R8:钱包是“账户 + 不可变流水”,不是可随便改的余额字段。** + +- `wallet_accounts` 以 `merchant_id` 唯一;`available_balance`、`frozen_balance` 均为 `int64`(`model/merchant.go:185-194`)。 +- 每笔变化写 `wallet_ledger_entries`:`entry_no` 唯一、`balance_after` 快照、`idempotency_key` 与 `merchant_id` 联合唯一、`reference_type/reference_no` 关联业务(`001_init.sql:170-190`)。 +- 下单扣款:`type=debit`,`amount=-total`,幂等键=`client_order_no`(`fulfillment_create.go:186-199`)。 +- 取消退款:`type=refund`,`amount=+order.Amount`(含手续费),幂等键=`cancel:`(`fulfillment_cancel.go:100-113`)。 +- 人工调账:幂等键必填;已有同幂等键流水直接返回;调整后余额不得为负;正数 `credit`、负数 `debit`(`fulfillment_wallet.go:22-81`)。 +- `frozen_balance` 目前只在仪表盘汇总,主流程没有冻结/解冻写入,实际恒为 0(`fulfillment_dashboard.go:176`、`handler/open_v1.go:164`)。 + +**R9:库存 `-1` 表示无限。** + +- `merchant_products.stock >= 0` 时下单校验并扣减,取消时回补;`-1` 跳过扣减/回补(`fulfillment_create.go:128-130, 200-205`、`fulfillment_cancel.go:115-123`)。 +- 商品扣减使用 `WHERE id=? AND stock>=?` 的条件更新,避免并发超卖(`fulfillment_create.go:201-203`)。 + +**R10:订单状态机是“白名单”,不是任意字符串。** + +- 终态:`cancelled` 不能更新;`delivered` 不能回退(`fulfillment_state.go:84-89`)。 +- `delivering` 只允许从 `paid`/`ship_failed` 进入;`delivered` 允许从 `paid`/`ship_failed`/`delivering` 进入;`ship_failed` 允许从 `paid`/`ship_failed`/`delivering` 进入(超时只能从 `delivering`);`cancelled` 只允许从 `paid`/`ship_failed` 进入(`fulfillment_state.go:90-116`)。 +- 取消另有一套 `canCancelOrder`:`paid`/`ship_failed` 可取消,`delivering`/`delivered` 不可,`cancelled` 幂等(`fulfillment_state.go:118-134`)。 + +**R11:软删除语义不统一,注意唯一键。** + +- 用户名:`009_users_username_active_unique.sql` 改为 `WHERE deleted_at IS NULL` 的部分唯一索引,软删除后可复用用户名。 +- 订单:`(merchant_id, client_order_no)` 唯一索引没有 `WHERE deleted_at IS NULL`,**软删除订单仍占用商户单号**(`001_init.sql:229`)。 +- 商品/商户/商户商品/API 客户端/回调订阅/充值申请等含 `deleted_at`,查询默认排除软删除(GORM 行为)。 + +**R12:订单来源影响回调与账务。** + +- `api`:开放接口创建,扣钱包/扣库存,写 `order.created`。 +- `manual`:商户后台人工创建,扣钱包/扣库存,但**不写 `order.created`**;后续发货/取消仍回调(`fulfillment_create.go:43-54, 213-217`)。 +- `test`:联调测试订单,不扣钱包、不占库存、金额 0,来源 `test`,仅用于上游查单/发货回传(`fulfillment_testorder.go:22-104`)。 + +--- + +### 4.3 履约、渠道与提货 + +**R13:渠道路由 = 模式 + 优先级 + 锁定 + 兜底。** + +- 模式:`priority` / `lewan` / `dlc` / `rock`;`priority` 按 `priority` 数组顺序尝试(`provider.go:23-31, 169-217`)。 +- 配置解析默认:`rock_` 前缀 → `rock`;同时有乐玩和 DLC GoodID → `priority=[lewan,dlc]`;只有乐玩 → `lewan`;否则 DLC 兜底(`provider.go:33-89`)。 +- 订单 `result_data.provider` 一旦落值即锁定;后续 `ResolveProviders` 只返回该渠道(`provider.go:181-189`)。 +- 解析为空时最终兜底 DLC;DLC 不存在则任取一个可用 provider(`provider.go:219-231`)。 +- 已锁定渠道的发货失败时,绑定/查询会清除锁定并按完整优先级重试其他渠道(`delivery_bind.go:136-156`、`delivery_query.go:94-120`)。 + +**R14:已提交上游的失败订单禁止自动重试/取消。** + +`deliverySubmittedUpstream` 的判定:`provider_order_no` 非空,或 `result_data` 含 `delivery`/`upstream_order`,或 `provider_order_stage=submitted`(`delivery.go:29-46`)。`CanFulfill`、`Submit`、`Cancel` 都复用该判定(`fulfillment.go:31-49`、`delivery_submit.go:59-62`、`fulfillment_cancel.go:74-78`)。 + +**R15:提货链接是 HMAC 签名 + 可选 TTL + 可作废。** + +- 签名 `HMAC-SHA256(linkSecret, orderNo|exp)`;`exp=0` 永久;有时效链接要求 `exp` 与 DB `delivery_link_expires_at` 完全一致(UTC 秒级),且未过期(`delivery_link.go:38-57, 89-93`)。 +- 已作废(`delivery_link_revoked_at`)、已取消、从未生成均拒绝;已生成永久链接直接复用(`delivery_link.go:27-45, 117-120`)。 +- 只有可发货订单能生成/续期;已交付/发货中可复用现有未过期链接查看结果,但不发新链接(`delivery_link.go:112-143`)。 +- `DELIVERY_LINK_TTL_MINUTES<=0` 永久;`DELIVERY_LINK_SECRET` 为空时回落到硬编码默认值,生产必须显式配置(`delivery.go:68-76`)。 + +**R16:提交发货有“抢占 + 阶段 + 幂等”三重保护。** + +- 先检查 `delivering/delivered`、已有上游单号、`queue_order_id`/`lewan_order_id`,直接返回既有结果(`delivery_submit.go:49-58`)。 +- 事务内 `FOR UPDATE` 锁订单;若已有活跃提交(35 秒窗口内 `claimed/queue_created`)直接返回;非失败态且提交中未超过 10 分钟也拒绝并发(`delivery_submit.go:131-174`)。 +- 抢占成功写 `provider_order_stage=claimed`、`submit_started_at`、`ship_attempts+1`(`delivery_submit.go:175-194`)。 +- 上游提交成功后按 `PatchResultData` 写阶段/上游单号/游戏字段;失败则置 `ship_failed` 并记录 `fail_stage`(`delivery_submit.go:88-104, 221-248`)。 +- 若上游已创建订单但本地更新失败,会先把 `result_data` 固化,避免重试双发(`delivery_submit.go:116-119`)。 + +**R17:Rock 的核心不是“发货”,是“UID 强一致 + 一单一 Token”。** + +- 一个订单只能占用一个 Token;`using/bound` 复用,`mismatch` 只能“确认改为扫码 UID”解除,`redeemed/abnormal` 拒绝再领(`rock_assign.go:26-105`)。 +- 分配用 `FOR UPDATE SKIP LOCKED` 取 `available+black`,防并发超卖(`rock_assign.go:107-124`)。 +- 首次分配记录上游绑定快照;`rockBuyerNewlyBound` 用快照/绑定变更时间/发货记录判断“是否本次新扫码”,避免把历史绑定误判为新绑定(`rock_snapshot.go:15-36`)。 +- UID 不一致 → `mismatch`,记录 `mismatch_at/reason`,禁止发货;一致 → `bound`(`rock_bindcheck.go:162-212`)。 +- 兑换前再次校验 UID;成功后 `redeemed`,记录 `upstream_fulfill_id`、目标皮肤/货币(`rock_redeem.go:17-91`)。 +- 全局 `AutoDeliveryEnabled=false` 时 Rock 渠道直接返回“维护中”,不发货(`provider_rock.go:38-45`)。 + +**R18:Rock 自动采购只在单实例下安全。** + +- 每 5 分钟巡检 `available+black`;≤ `TokenWarningStock` 且开启自动采购时买 `AutoPurchaseCount`(`main.go:172-179`、`rock_stock.go:85-113`)。 +- 采购用进程内 `sync.Mutex` + `LastPurchasedAt` 冷却;冷却检查在 DB 事务外(`rock_stock.go:15-35`)。 +- 多实例下两个进程各自持有自己的 mutex,可能同时通过冷却检查并重复采购;扩容前需要 DB advisory lock/单例 worker(见第 6 节)。 + +--- + +### 4.4 回调与审计 + +**R19:商户回调是事务性 outbox,不是“业务提交后另起 goroutine”。** + +- `Enqueue` 在业务事务内写 `callback_deliveries`;业务事务失败则事件不落库(`callback.go:213-250`)。 +- worker 每 3 秒批量 50 条;条件 UPDATE 抢占 `pending` 或 `sending` 超过 5 分钟的记录,多实例安全(`callback.go:253-348, 378-394`)。 +- 默认 16 次尝试,固定退避序列;404/`task_not_found` 永久失败(`callback.go:27-46, 351-375`)。 +- 签名 `X-Sign=HMAC-SHA256(secret, "body_sha256=×tamp=")`;`X-Event-ID` 供接收方幂等(`callback.go:316-324, 396-407`)。 + +**R20:回调 URL 只校验协议,不拦截私网。** + +`validateCallbackURL` 只要求 `http/https` + host 非空;没有私网 IP/端口白名单(`callback.go:418-427`)。具备 `callbacks:manage` 的商户成员可把回调指向内网服务。 + +**R21:审计日志“写得很广,但读不出来”。** + +- `writeAudit` 被订单创建/取消、发货通知、超时、钱包调整、充值审核、商户/成员/角色/API 客户端/商品/路由、Rock 修正/备注、手机号绑定等大量路径调用(`backend/internal/service/audit.go:11-27` 及第 5.2 节引用)。 +- `metadata` 以 JSON 文本写入;`request_id` 字段存在,但 `writeAudit` 没有赋值,实际为空,无法与请求日志关联(`model/merchant.go:311-323`、`audit.go:11-27`)。 +- 当前路由没有审计查询/导出接口(`router.go` 中无 `audit`),审计是“只写不读”。 + +--- + +### 4.5 充值、钱包与告警 + +**R22:充值换算固定,审核与入账同事务。** + +- 最小 10 元;`1 元 = 100 积分`,`points_amount = amount_cny`(分)×1;凭证 1–5 张(`recharge.go:26-30, 53-93`)。 +- 审核行锁;只允许 `pending`;通过时 `AdjustWallet`,幂等键=`application_no`;状态与入账同一事务;拒绝只改状态(`recharge.go:133-185`)。 + +**R23:钱包调整必须幂等,且不能调成负数。** + +- 幂等键为空直接报错;同 `(merchant_id, idempotency_key)` 已存在则返回原钱包;行锁余额;`newBalance<0` 拒绝;正数 `credit`、负数 `debit`;`reference_type=manual_adjustment`(`fulfillment_wallet.go:22-81`)。 + +**R24:告警渠道密钥在响应层脱敏,但 service 层保留真值。** + +- webhook 查询串逐 value 脱敏、飞书路径末段脱敏、Bark Key 整体脱敏;`MaskAlertChannel` 只用于 HTTP 响应(`recharge.go:265-354`)。 +- 支持的渠道:钉钉/飞书/企微/Bark/通用 Webhook;配置以 JSON 存 `alert_notify_channels.config`(`model/recharge.go:57-85`)。 + +--- + +### 4.6 登录、密钥、时间与迁移 + +**R25:登录模式由“是否绑定手机号”决定。** + +- 未绑定:账号密码(bcrypt)→ JWT,7 天有效(`auth.go:54-64`、`jwt.go:19-35`)。 +- 已绑定:密码 + 短信 + DCAuth;单独密码登录返回 403 `device_auth_required`(`auth.go:59-62`)。 + +**R26:短信验证码一次性、哈希存储、限频。** + +- 6 位数字;只存 SHA-256;TTL 300 秒、冷却 60 秒、最多 5 次失败;失败次数在 SQL 中原子累加,达到上限直接消费(`login_sms_service.go:29-39, 170-210`)。 +- 阿里云模式按手机号每日 10 次;Mock 模式跳过每日上限(`login_sms_service.go:123-135`)。 + +**R27:DCAuth v2 是 challenge + Ed25519 + 一次性 ticket。** + +- challenge TTL 120 秒;时钟偏差 ≤300 秒;限流 30 次/分钟;Ed25519 验签;`pending→authorized→ticket_issued→consumed`;每个用户只能有一台 active 设备(`login_dcauth_service.go:25-33, 234-317`、`014_login_sms_device_auth.sql:47-92`)。 + +**R28:敏感密钥加密,但 Rock Bearer Token 是例外。** + +- `APIClient.SecretCiphertext`、`CallbackSubscription.SecretCiphertext` 使用 AES-256-GCM;密钥 = SHA256(masterKey),nonce 前置,base64 RawURL;两者 JSON 均为 `json:"-"`,创建/轮换时只返回一次明文(`secret_codec.go:18-55`、`model/merchant.go:128-146, 254-267`)。 +- `JWT_SECRET` 同时是 JWT 签名密钥与敏感数据加密主密钥;换掉会导致已加密密钥无法解密(`config.go:86-90`、`docs/项目分析.md:135-146`)。 +- **Rock `BearerToken` 例外**:`rock_configs.bearer_token` 为明文 `TEXT`,且 `GET /api/platform/rock/config` 直接 `response.OK(c, cfg)`,JSON 标签为 `bearer_token`,会把上游 Token 原样返回给管理员前端(`model/rock.go:40`、`012_rock_fulfillment.sql:10`、`handler/rock.go:22-29`)。 + +**R29:时间统一北京时间、API 秒级 RFC3339。** + +- `timeutil` 使用固定 `UTC+8`(`time.FixedZone("CST", 8*3600)`),`APITimeLayout="2006-01-02T15:04:05-07:00"`,`FormatAPITime` 截断到秒(`timeutil.go:8-20, 47-51`)。 +- 订单号:`FO` + `20060102150405` + 12 位随机十六进制;测试订单 `O` 前缀;充值单 `RC` + 时间 + 8 位随机(`fulfillment_order.go:14-20`、`recharge.go:614-616`)。 +- pgx 会把 `timestamptz` 解码到 `time.Local`;进程时区必须 `TZ=Asia/Shanghai`,否则接口时间偏移会变(`timeutil.go:22-40`、`config.go` 启动告警)。 + +**R30:迁移是显式 SQL + checksum + advisory lock,只前进不回滚。** + +- 16 个迁移按文件名排序执行;`schema_migrations(version, name, checksum, applied_at)`;已执行迁移 checksum 不一致直接启动失败;整个迁移在事务内,并用 `pg_advisory_xact_lock` 防并发(`database/migrate.go:31-116`)。 +- 已执行迁移不可修改;`015_rock_packages_final.sql` 是 Rock 打包/采购字段的合并最终态(原 015/016/017);`016_rock_black_token_cost.sql` 给 `rock_configs` 增加 `black_token_cost`,后续新增变更必须从 `017_*.sql` 开始(`docs/项目分析.md:228-229`)。 + +**R31:核心表没有数据库外键。** + +- 001–013 只建索引/唯一约束,不建 `REFERENCES`;只有 014 的登录表有 5 个外键(`users.phone_bound_by`、`login_sms_codes.user_id`、`user_devices.user_id/revoked_by`、`device_challenges.user_id`)(`014_login_sms_device_auth.sql:14-27, 29-87`)。 +- `merchant_members`、`merchant_products`、`fulfillment_orders`、`wallet_*`、`callback_*`、`rock_*` 等均为“应用层逻辑关联”,删除/改 ID 时没有数据库级级联保护。 + +**R32:后台 worker 与 API 同进程,且没有优雅关闭。** + +- `main.go` 启动 `callbackSvc.Run`、发货超时巡检、Rock 5 分钟巡检,均为进程内 goroutine(`main.go:164-179`)。 +- 回调 worker 与超时巡检有多实例安全设计(原子抢占/行锁);Rock 自动采购没有。 +- `main.go` 末尾直接 `r.Run(addr)`,没有 `http.Server`/`signal.Notify`/`Shutdown`;发布/重启会中断在途请求(`main.go` 末尾、全文无 `signal.Notify`/`Shutdown`)。 + +--- +## 5. 数据模型 + +### 5.1 总览与约定 + +本次实际库共有 **24 张表**(`information_schema.tables`,2026-10-26 实测),由 14 个迁移创建。除特别说明外: + +- 所有业务表默认有 `id BIGSERIAL PRIMARY KEY`、`created_at`/`updated_at TIMESTAMPTZ`;带软删除的表还有 `deleted_at TIMESTAMPTZ`。字段字典只列业务字段。 +- 金额统一 `BIGINT`(积分,1 元 = 100 积分);`NUMERIC(12,2)` 只用于 Rock 上游积分快照。禁止用 `float64` 记账(`model/merchant.go:185`)。 +- 状态/来源/类型都是 `VARCHAR` 字符串,不是数据库 ENUM;取值由 Go 常量约束(见 3.5)。 +- `features`、`scopes`、`events`、`permissions` 是逗号分隔字符串;`request_data`、`result_data`、`payload`、`metadata`、`attributes`、`vouchers`、`config` 是 JSON 文本。 +- **001–013 没有数据库外键**;只有 014 的登录表有 5 个 FK。其余关系均为应用层逻辑关联(见 R31)。 + +```mermaid +erDiagram + USERS ||--o{ MERCHANT_MEMBERS : user_id + MERCHANTS ||--o{ MERCHANT_MEMBERS : merchant_id + MERCHANTS ||--o{ MERCHANT_ROLES : merchant_id + MERCHANTS ||--o{ API_CLIENTS : merchant_id + API_CLIENTS ||--o{ API_REQUEST_NONCES : api_client_id + MERCHANTS ||--|| WALLET_ACCOUNTS : merchant_id + WALLET_ACCOUNTS ||--o{ WALLET_LEDGER_ENTRIES : wallet_account_id + MERCHANTS ||--o{ MERCHANT_PRODUCTS : merchant_id + PRODUCTS ||--o{ MERCHANT_PRODUCTS : product_id + MERCHANTS ||--o{ FULFILLMENT_ORDERS : merchant_id + MERCHANT_PRODUCTS ||--o{ FULFILLMENT_ORDERS : merchant_product_id + MERCHANTS ||--o{ CALLBACK_SUBSCRIPTIONS : merchant_id + CALLBACK_SUBSCRIPTIONS ||--o{ CALLBACK_DELIVERIES : callback_subscription_id + MERCHANTS ||--o{ RECHARGE_APPLICATIONS : merchant_id + MERCHANTS ||--|| MERCHANT_ALERT_CONFIGS : merchant_id + MERCHANTS ||--o{ ALERT_NOTIFY_CHANNELS : merchant_id + USERS ||--o{ LOGIN_SMS_CODES : user_id + USERS ||--o{ USER_DEVICES : user_id + USERS ||--o{ DEVICE_CHALLENGES : user_id + ROCK_TOKENS }o--o{ FULFILLMENT_ORDERS : assigned_order_no + MERCHANTS ||--o{ AUDIT_LOGS : merchant_id +``` + +> `ROCK_TOKENS` 与订单不是外键关系,而是 `rock_tokens.assigned_order_no = fulfillment_orders.order_no` 的字符串关联;`AUDIT_LOGS` 是多态日志(merchant/user/api_client/entity)。 + +### 5.2 表字典 + +#### 5.2.1 `users` — 系统账号 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `username` | varchar(64) not null;`deleted_at IS NULL` 部分唯一 | 登录名;软删除后可复用(009) | +| `password_hash` | varchar(255) not null | bcrypt 哈希;JSON 隐藏 | +| `nickname` | varchar(64) | 显示名 | +| `role` | varchar(32) default `merchant` | `admin` 平台管理员 / `merchant` 商户账号 | +| `status` | bigint default 1 | 1 启用 / 0 禁用 | +| `phone` | varchar(32) default `''`;非空部分唯一 | 登录短信手机号 | +| `phone_bound_at` | timestamptz | 手机号绑定时间 | +| `phone_bound_by` | bigint FK `users.id` ON DELETE SET NULL | 执行绑定的管理员 | + +来源:`backend/internal/model/models.go:16-40`、`001_init.sql:11-24`、`009_users_username_active_unique.sql`、`014_login_sms_device_auth.sql:7-27`。 + +#### 5.2.2 `merchants` — 商户租户 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `code` | varchar(64) not null unique | 商户编码;自营商户固定 `self-operated` | +| `name` | varchar(128) not null | 商户名 | +| `status` | varchar(16) default `active` | `active` / `disabled` | +| `contact_name` / `contact_info` | varchar | 联系人/联系方式 | +| `features` | text default `products,orders,wallet,api,callbacks` | 功能开关 CSV | +| `fee_type` | varchar(16) default `rate` | `rate` 百分比 / `fixed` 固定 | +| `fee_rate_bp` | bigint default 0 | 费率基点(1/10000),仅 `rate` 使用 | +| `fee_fixed_amount` | bigint default 0 | 固定手续费积分,仅 `fixed` 使用 | + +来源:`backend/internal/model/merchant.go:70-88`、`001_init.sql:26-44`;自营种子 `001_init.sql:313-321`。 + +#### 5.2.3 `merchant_members` — 账号与商户的多对多关系 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 ID | +| `user_id` | bigint not null | 用户 ID | +| `role` | varchar(32) default `operator` | `owner/operator/finance/viewer` 或自定义角色 code | +| `status` | bigint default 1 | 1 启用 / 0 禁用 | +| `is_default` | boolean default false | 未传 `X-Merchant-ID` 时的默认商户 | + +唯一索引 `(merchant_id, user_id)`;来源:`model/merchant.go:100-114`、`001_init.sql:46-59`。 + +#### 5.2.4 `merchant_roles` — 商户自定义角色 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 所属商户 | +| `code` | varchar(64) not null | 角色 code,商户内唯一 | +| `name` | varchar(64) not null | 角色名 | +| `permissions` | text default `''` | 权限 CSV | +| `status` | bigint default 1 | 1 启用 / 0 禁用 | + +唯一索引 `(merchant_id, code)`;默认种子 `operator/finance/support`(`007_merchant_roles.sql:2-18`、`008_default_merchant_team_roles.sql`、`model/merchant.go:116-126`)。 + +#### 5.2.5 `api_clients` — 商户开放接口凭证 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 所属商户 | +| `name` | varchar(128) not null | 客户端名称 | +| `app_key` | varchar(96) not null unique | `X-App-Key` | +| `secret_ciphertext` | text not null | AES-GCM 密文;JSON 隐藏;创建时只返回一次明文 | +| `signature_version` | varchar(16) default `v1` | 目前只接受 `v1`(`merchant_api_client.go:37-40`) | +| `scopes` | text not null | API scope CSV | +| `status` | varchar(16) default `active` | `active` / `disabled` | +| `expires_at` | timestamptz | 过期时间;为空长期有效 | +| `last_used_at` | timestamptz | 每次验签通过后更新 | + +来源:`model/merchant.go:128-146`、`001_init.sql:65-84`、`middleware/open_auth.go:144-148`。 + +#### 5.2.6 `api_request_nonces` — 商户侧防重放 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `api_client_id` | bigint not null | API 客户端 | +| `nonce` | varchar(96) not null | 请求 nonce | +| `expires_at` | timestamptz not null | 过期时间(默认 300 秒窗口) | + +唯一索引 `(api_client_id, nonce)`;来源:`model/merchant.go:289-296`、`001_init.sql:86-96`。 + +#### 5.2.7 `source_api_nonces` — 源头侧/乐玩 Webhook 防重放 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `api_key` | varchar(96) not null | 源头 API Key;乐玩 Webhook 固定为 `lewan_webhook` | +| `nonce` | varchar(64) not null | 请求 nonce / 乐玩 event_id | +| `expires_at` | timestamptz not null | 过期时间 | + +唯一索引 `(api_key, nonce)`;来源:`model/merchant.go:298-309`、`001_init.sql:98-107`。 + +#### 5.2.8 `products` — 平台通用商品目录 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `code` | varchar(96) not null unique | 目录编码,通常等于英文 SKU | +| `name` | varchar(160) not null | 中文名 | +| `category` | varchar(64) | 分类(套装/枪械/道具等) | +| `description` | text | 描述 | +| `attributes` | text | 扩展属性 JSON | +| `status` | varchar(16) default `active` | `active` / `inactive` | + +来源:`model/merchant.go:148-161`、`001_init.sql:113-129`;实际库 38 条(本次实测),`docs/商品目录.md` 为 36 条的 2026-09-14 快照。 + +#### 5.2.9 `merchant_products` — 商户可售商品(价格/库存/履约配置) + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 | +| `product_id` | bigint not null | 平台目录商品 | +| `sku` | varchar(96) not null | 对外主 SKU;与 `merchant_id` 联合唯一 | +| `display_name` | varchar(160) | 商户展示名 | +| `price_amount` | bigint default 0 | 售价(积分) | +| `cost_amount` | bigint default 0 | 成本价(积分),用于配置/运营,不参与下单扣款 | +| `currency` | varchar(12) default `POINT` | 统一积分 | +| `stock` | bigint default -1 | `-1` 无限;`>=0` 有限库存 | +| `status` | varchar(16) default `active` | `active` / `inactive` | +| `fulfillment_config` | text | 履约 JSON:`routing_mode/priority/dlc_good_id/lewan_product_id/lewan_name/delivery_type` | + +唯一索引 `(merchant_id, sku)`;来源:`model/merchant.go:163-183`、`001_init.sql:131-152`、`provider.go:23-31`、`docs/商品目录.md:5-19`。 + +#### 5.2.10 `wallet_accounts` — 商户积分账户 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null unique | 每个商户一个账户 | +| `currency` | varchar(12) default `POINT` | 积分 | +| `available_balance` | bigint default 0 | 可用余额 | +| `frozen_balance` | bigint default 0 | 冻结余额(预留;主流程未写入,实际恒为 0) | + +来源:`model/merchant.go:185-194`、`001_init.sql:158-168`、`fulfillment_dashboard.go:176`。 + +#### 5.2.11 `wallet_ledger_entries` — 不可变钱包流水 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 | +| `wallet_account_id` | bigint not null | 账户 | +| `entry_no` | varchar(64) not null unique | 流水号,`WL` + UUID | +| `type` | varchar(24) not null | `credit` / `debit` / `refund` / `adjust` | +| `amount` | bigint not null | 有符号金额:扣款为负、退款/入账为正、调账可正负 | +| `balance_after` | bigint not null | 变动后余额快照 | +| `reference_type` | varchar(32) not null | `fulfillment_order` / `manual_adjustment` | +| `reference_no` | varchar(96) not null | 订单号 / 调账业务号 | +| `idempotency_key` | varchar(128) | 幂等键;与 `merchant_id` 联合唯一(NULL 可重复) | +| `note` | varchar(255) | 备注 | + +来源:`model/merchant.go:196-210`、`001_init.sql:170-190`;流水无 `updated_at/deleted_at`,设计为只追加。 + +#### 5.2.12 `fulfillment_orders` — 订单事实表(核心) + +| 分组 | 字段 | 类型/约束 | 含义 | +|------|------|-----------|------| +| 身份 | `merchant_id` | bigint not null | 所属商户 | +| 身份 | `order_no` | varchar(64) not null unique | 平台订单号,`FO` + 时间 + 随机 | +| 身份 | `client_order_no` | varchar(96) not null | 商户单号;与 `merchant_id` 联合唯一 | +| 身份 | `merchant_product_id` | bigint not null | 下单时商户商品 | +| 快照 | `product_sku` / `product_name` | varchar | 下单时商品快照 | +| 快照 | `quantity` | bigint default 1 | 数量 | +| 金额 | `base_amount` | bigint default 0 | `price_amount × quantity` | +| 金额 | `fee_type` / `fee_rate_bp` / `fee_fixed_amount` | varchar/bigint | 下单时手续费配置快照 | +| 金额 | `service_fee_amount` | bigint default 0 | 手续费金额 | +| 金额 | `amount` | bigint not null | 总扣款 = base + service_fee | +| 金额 | `currency` | varchar(12) default `POINT` | 积分 | +| 状态 | `order_status` | varchar(16) default `paid` | 5 种状态 | +| 状态 | `order_source` | varchar(16) default `api` | `api` / `manual` / `test` | +| 买方 | `buyer_reference` | varchar(128) | 商户侧买家标识 | +| 快照 | `request_fingerprint` | varchar(64) default `''` | 幂等指纹 SHA-256 | +| 快照 | `request_data` | text | 下单原始 `data` JSON | +| 快照 | `result_data` | text | 履约上下文 JSON(渠道/阶段/上游单号/游戏字段等) | +| 上游 | `provider_order_no` | varchar(96) | 上游正式单号 | +| 失败 | `failure_reason` | varchar(512) | 失败/取消原因 | +| 时间 | `cancelled_at` / `delivered_at` | timestamptz | 取消/交付时间 | +| 链接 | `delivery_link_expires_at` | timestamptz | NULL=永久有效;否则为过期时间 | +| 链接 | `delivery_link_revoked_at` | timestamptz | 作废时间 | +| 链接 | `delivery_link_generated_at` | timestamptz | 生成/恢复时间;用于区分“从未生成”和“永久有效”(010) | + +约束/索引:`order_no` 唯一;`(merchant_id, client_order_no)` 唯一(不排除软删除);`merchant_id`、`merchant_product_id`、`order_status`、`request_fingerprint`、`provider_order_no`、链接时间字段均有索引(`001_init.sql:228-237`、`010_delivery_link_generated_at.sql`、`006_fulfillment_order_source.sql`)。 + +`result_data` 常见键:`provider`、`provider_order_no`、`provider_order_stage`、`submit_started_at`、`ship_attempts`、`queue_order_id`、`lewan_order_id`、`ship_status`、`fail_reason`、`shipped_at`、`game_channel`、`game_uid`、`role_name`、`pay_score`、`rock_token_id`、`rock_redeem_code`、`expected_uid`、`actual_role_id`(`delivery.go:13-27`、`delivery_submit.go:175-183`、`fulfillment_data.go:13-42`、`provider_rock.go:139-144, 281-291`)。 + +来源:`model/merchant.go:212-252`、`001_init.sql:196-237`。 + +#### 5.2.13 `callback_subscriptions` — 商户回调订阅 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 | +| `name` | varchar(128) not null | 订阅名 | +| `url` | varchar(1024) not null | 回调地址(http/https) | +| `events` | text not null | 事件 CSV 或 `*` | +| `secret_ciphertext` | text not null | AES-GCM 密文;JSON 隐藏;轮换只返回一次 | +| `status` | varchar(16) default `active` | `active` / `disabled` | + +服务层保证每商户仅一个 active 订阅(创建/更新时把其他 active 置 disabled);DB 无部分唯一索引。来源:`model/merchant.go:254-267`、`001_init.sql:243-258`、`callback.go:117-196`。 + +#### 5.2.14 `callback_deliveries` — 回调 outbox / 投递记录 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 | +| `callback_subscription_id` | bigint not null | 订阅 | +| `event_id` | varchar(64) not null unique | 事件 ID;接收方幂等键 | +| `event` | varchar(64) not null | 事件名 | +| `payload` | text not null | JSON 报文 | +| `status` | varchar(16) default `pending` | `pending/sending/delivered/failed` | +| `attempts` | bigint default 0 | 已尝试次数 | +| `next_attempt_at` | timestamptz not null | 下次尝试时间 | +| `last_status_code` | bigint default 0 | 上次 HTTP 状态码 | +| `last_response` | text | 上次响应(截断 4KB) | +| `delivered_at` | timestamptz | 成功时间 | + +来源:`model/merchant.go:269-287`、`001_init.sql:260-282`、`callback.go:289-375`。 + +#### 5.2.15 `audit_logs` — 多态审计日志 + +| 字段 | 类型 | 含义 | +|------|------|------| +| `merchant_id` | bigint nullable | 商户上下文;平台级操作可为空 | +| `actor_user_id` | bigint nullable | 操作者用户 | +| `api_client_id` | bigint nullable | 开放接口客户端 | +| `request_id` | varchar(64) | 设计用于请求链路关联;**当前 writeAudit 未赋值,实际为空** | +| `action` | varchar(96) not null | 动作,如 `open_order.create`、`ship.notify`、`wallet.adjust` | +| `entity_type` | varchar(64) not null | 实体类型,如 `fulfillment_order` | +| `entity_id` | varchar(96) not null | 实体 ID/订单号 | +| `metadata` | text | JSON 详情 | + +来源:`model/merchant.go:311-323`、`001_init.sql:288-307`、`audit.go:11-27`。当前无审计查询/导出路由。 + +#### 5.2.16 `recharge_applications` — 充值申请 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 | +| `application_no` | varchar(64) not null unique | 充值单号,`RC` + 时间 + 随机 | +| `amount_cny` | bigint not null | 人民币金额,单位:分 | +| `points_amount` | bigint not null | 入账积分(1 分 = 1 积分) | +| `status` | varchar(16) default `pending` | `pending/approved/rejected` | +| `vouchers` | text default `'[]'` | 凭证 URL JSON 数组(1–5 张) | +| `note` | varchar(512) default `''` | 商户备注 | +| `review_note` | varchar(512) default `''` | 审核备注 | +| `reviewed_at` / `reviewed_by` | timestamptz / bigint | 审核时间/审核人 | + +来源:`model/recharge.go:16-35`、`003_recharge.sql:6-24`、`recharge.go:26-93`。 + +#### 5.2.17 `merchant_alert_configs` — 低余额告警策略 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null unique | 每商户一份 | +| `enabled` | boolean default false | 是否启用 | +| `threshold_points` | bigint default 0 | 预警阈值(积分) | +| `webhook_url` | varchar(1024) default `''` | 历史字段;JSON 隐藏;004 已迁移到渠道表 | +| `notify_interval_minutes` | int default 60 | 重复通知间隔 | +| `max_notifications` | int default 5 | 最大通知次数(0=不限) | +| `last_notified_at` | timestamptz | 上次通知时间 | +| `notification_count` | int default 0 | 已通知次数;余额恢复后重置 | + +来源:`model/recharge.go:37-55`、`003_recharge.sql:26-34`、`005_alert_policy.sql`。 + +#### 5.2.18 `alert_notify_channels` — 告警通知渠道 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `merchant_id` | bigint not null | 商户 | +| `channel_type` | varchar(32) not null | `dingtalk/feishu/wecom/bark/webhook` | +| `name` | varchar(64) default `''` | 渠道名 | +| `config` | text default `'{}'` | JSON:webhook 用 `webhook_url`;Bark 用 `server/key` | +| `enabled` | boolean default true | 是否启用 | + +来源:`model/recharge.go:66-85`、`004_alert_channels.sql:5-17`、`recharge.go:240-354`。 + +#### 5.2.19 `rock_configs` — Rock 平台配置(单行) + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `id` | bigint PK default 1 | 固定单行 | +| `auto_delivery_enabled` | boolean default true | 全局自动发货 kill switch | +| `base_url` | varchar(255) default `https://0666.pro` | 上游地址;启动时同步到客户端(`ApplyConfigToClient`) | +| `bearer_token` | text default `''` | 上游 Bearer Token;**明文存储,且 GetConfig 原样返回** | +| `username` | varchar(128) | 上游账号名快照 | +| `points` | numeric(12,2) default 0 | 上游积分快照 | +| `min_points_warning` | numeric(12,2) default 1050 | 上游低积分阈值 | +| `token_warning_stock` | int default 10 | 本地库存预警阈值 | +| `auto_purchase_enabled` | boolean default false | 自动采购开关 | +| `auto_purchase_count` | int default 50 | 每次自动采购数量 | +| `purchase_cooldown_minutes` | int default 10 | 自动采购冷却 | +| `last_purchased_at` | timestamptz | 上次采购时间 | +| `last_heartbeat_at` | timestamptz | 上次连通测试时间 | + +来源:`model/rock.go:35-56`、`012_rock_fulfillment.sql:6-27`、`rock_service.go:58-141`、`handler/rock.go:22-29`。 + +#### 5.2.20 `rock_tokens` — Rock 本地 Token 库存池 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `upstream_token_id` | varchar(64) default `''` | 上游 Token ID | +| `token` | text not null | 上游 Token 原文;JSON 隐藏(`json:"-"`) | +| `redeem_code` | varchar(64) not null indexed | 提取码 | +| `color` | varchar(32) default `black` indexed | 颜色(当前只采购 black) | +| `region` | varchar(64) default `Q/V可用` | 可用区服 | +| `status` | varchar(32) default `available` indexed | 6 种状态 | +| `assigned_order_no` | varchar(128) indexed | 占用它的订单号(逻辑关联) | +| `expected_uid` | varchar(64) | 买家填写/期望 UID | +| `actual_role_name` / `actual_role_id` / `actual_channel` | varchar | 扫码得到的角色信息 | +| `initial_bound_snapshot` | text | 分配时上游绑定快照 JSON;JSON 隐藏 | +| `upstream_fulfill_id` | bigint | 上游发货单 ID | +| `target_skin` / `target_currency` / `target_currency_quantity` | varchar/varchar/int | 目标皮肤、货币、数量 | +| `mismatch_at` / `mismatch_reason` | timestamptz / varchar(255) | UID 不一致时间/原因 | +| `remark` | text default `''` | 人工备注(013) | +| `claimed_at` / `bound_at` / `redeemed_at` | timestamptz | 分配/绑定/兑换时间 | + +索引:`(status,color)`、`assigned_order_no`、`redeem_code`、`(status,mismatch_at)`;`redeem_code`/`upstream_token_id` 没有唯一约束,依赖采购/分配流程保证(`012_rock_fulfillment.sql:57-60`)。来源:`model/rock.go:58-90`、`012_rock_fulfillment.sql:30-60`、`013_rock_tokens_remark.sql`。 + +#### 5.2.21 `login_sms_codes` — 登录短信验证码 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `user_id` | bigint not null FK `users.id` ON DELETE CASCADE | 账号 | +| `phone` | varchar(32) not null | 接收手机号 | +| `purpose` | varchar(32) default `login` | 用途 | +| `code_hash` | varchar(64) not null | 验证码 SHA-256;不存明文 | +| `expires_at` | timestamptz not null | 过期时间 | +| `consumed_at` | timestamptz | 消费时间;一次性 | +| `failed_attempt_count` | int default 0 | 失败次数(上限 5) | +| `request_ip` | varchar(64) default `''` | 请求 IP | + +索引:`(user_id,purpose,created_at DESC)`、`(phone,created_at DESC)`;来源:`model/login_auth.go:19-33`、`014_login_sms_device_auth.sql:29-45`。 + +#### 5.2.22 `user_devices` — DCAuth 设备绑定 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `user_id` | bigint not null FK `users.id` ON DELETE CASCADE | 账号 | +| `device_id` | varchar(128) not null indexed | DCAuth 设备码(`D1-...`) | +| `public_key` | varchar(128) not null | Ed25519 公钥 | +| `client_version` | varchar(64) default `''` | 客户端版本 | +| `status` | varchar(32) default `active` | `active` / `revoked` | +| `bound_at` | timestamptz not null | 绑定时间 | +| `last_used_at` | timestamptz | 最近使用 | +| `revoked_at` / `revoked_by` | timestamptz / bigint | 撤销时间/操作人 | + +部分唯一索引 `(user_id) WHERE status='active'`:每个用户只能有一台 active 设备。来源:`model/login_auth.go:35-51`、`014_login_sms_device_auth.sql:47-66`。 + +#### 5.2.23 `device_challenges` — DCAuth challenge / 一次性 ticket + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `challenge_id` | varchar(128) not null unique | challenge ID | +| `app` | varchar(64) not null | 固定 `affiliate_dash` | +| `purpose` | varchar(32) default `login` | 用途 | +| `challenge` | varchar(512) not null | 随机 challenge | +| `poll_token_hash` | varchar(64) not null | 轮询令牌哈希 | +| `status` | varchar(32) default `pending` | `pending/authorized/ticket_issued/consumed/expired/rejected` | +| `user_id` | bigint FK `users.id` ON DELETE SET NULL | 关联账号 | +| `device_id` / `public_key` / `client_version` | varchar | 设备信息 | +| `device_ticket_hash` | varchar(64) default `''` | 一次性 ticket 哈希 | +| `expires_at` | timestamptz not null | 过期时间(默认 120 秒) | +| `authorized_at` / `ticket_issued_at` / `consumed_at` | timestamptz | 状态时间 | + +来源:`model/login_auth.go:53-76`、`014_login_sms_device_auth.sql:68-92`。 + +#### 5.2.24 `schema_migrations` — 迁移版本 + +| 字段 | 类型/约束 | 含义 | +|------|-----------|------| +| `version` | varchar(64) PK | 迁移版本号(如 `014`) | +| `name` | varchar(255) not null | 迁移名 | +| `checksum` | varchar(64) not null | SQL 文件 SHA-256;已执行版本不可修改 | +| `applied_at` | timestamptz not null | 执行时间 | + +来源:`backend/internal/database/migrate.go:20-25, 45-57`;本次实测 14 行。 + +--- +## 6. 结论与风险清单 + +> 本节是全文的收口:把散落在各节的机制、约束与缺口收敛为可执行的结论。等级定义: +> **P0** 可被外部直接利用,或会造成资金/数据不可恢复损失,需立即处理; +> **P1** 上线前应处理,影响安全基线、可运维性或契约正确性; +> **P2** 技术债与契约细节,随迭代处理。 +> 每条均给出代码/配置证据;标注“本次实测”的为 2026-10-26 对运行库与容器的直接观察。 + +### 6.1 总体结论 + +1. **业务闭环完整,主流程的一致性设计是合格的。** 系统把“下单扣款 → 生成提货链接 → 用户绑定游戏 UID → 向上游提交 → 回执/回调”串成一条状态机,并用事务、行锁、幂等键和 outbox 把资金与消息的边界守住了(详见 §2、§3、§4 R6–R19)。 +2. **资金安全的支点不是 `balance` 字段,而是“钱包流水 + 订单状态 + 幂等键”三件套。** 扣款、退款、人工调账都落 `wallet_ledger_entries`,订单创建与扣款在同一事务内完成,重复请求由 `(merchant_id, client_order_no)` 与 `request_fingerprint` 拦截(`backend/internal/database/migrations/001_init.sql:229`、`backend/internal/model/merchant.go:200-221`、§2.1)。 +3. **真正的短板在“边缘入口”和“运维基线”,不在主流程。** 公开的 `image-proxy`、默认数据库暴露面、缺失的备份/优雅停机/限流/审计查询,是当前最需要补的课;这些问题不影响日常演示,但决定了能否安全地放到公网。 +4. **多实例部署尚未就绪。** 目前多处关键互斥是进程内内存态(DCAuth 限流器、Rock 采购互斥、自动补货冷却),单实例运行正确,横向扩容会放大为重复采购或限流失效(`backend/internal/service/rock_stock.go:16-35`、`backend/internal/service/login_dcauth_service.go:93-131`)。 + +### 6.2 已经形成的有效防线 + +| 防线 | 机制 | 来源 | +|------|------|------| +| 资金一致性 | 订单创建事务内扣款并写流水;余额变更与订单状态同事务提交 | `backend/internal/service/fulfillment_create.go`、`backend/internal/service/fulfillment_wallet.go`;§4 R6–R8 | +| 下单幂等 | `(merchant_id, client_order_no)` 唯一 + `request_fingerprint` SHA-256 比对 | `001_init.sql:229`、`backend/internal/model/merchant.go:219-221`;§2.1 | +| 回调可靠 | 事务 outbox + 定时批量投递 + `X-Event-ID` 去重 + 固定退避重试 | `backend/internal/service/callback.go`;§2.4、§3.3 | +| 迁移可审计 | 显式 SQL + SHA-256 checksum + `pg_advisory_xact_lock`,已执行版本禁止改写 | `backend/internal/database/migrate.go:31-116`;§5.2.24 | +| 租户隔离 | JWT → `Tenant` 中间件解析 `X-Merchant-ID` 并校验成员关系;平台端刻意不挂 Tenant | `backend/internal/middleware/auth.go`、`backend/internal/service/tenant.go`、`backend/internal/router/router.go:235-240`;§2.8 | +| 上游防串单 | `result_data.provider` 锁定履约商;`deliverySubmittedUpstream` 阻断重复提交/取消;Rock 按订单绑定唯一 Token | `backend/internal/service/delivery_state.go`、`backend/internal/service/rock_assign.go`;§2.7、§4 R13–R18 | +| 登录协议 | DCAuth v2 设备签名 + 一次性 ticket + 手机号绑定;短信验证码只存哈希 | `backend/internal/service/login_dcauth_service.go`、`backend/internal/model/login_auth.go`;§2.8 | + +### 6.3 风险总表 + +| ID | 级别 | 结论 | 关键证据 | +|----|------|------|----------| +| P0-1 | P0 | 公开 `image-proxy` 可请求任意 URL、透传上游 `Content-Type`、无大小上限,构成 SSRF + 同源 XSS + DoS | `router.go:190-197`、`handler/delivery.go:91-184` | +| P0-2 | P0 | PostgreSQL 默认以超级用户运行且端口发布到宿主机;示例配置为弱口令 + `sslmode=disable` | `docker-compose.yml:4-14`、`.env.example:13-20` | +| P0-3 | P0 | 仓库没有备份/恢复脚本或演练流程;内置库仅靠数据卷,外部库也无仓库级备份保障 | `Makefile`(无 backup 目标)、`docker-compose.yml:112-114` | +| P0-4 | P0 | 无优雅停机;Rock 采购在“上游成功→入库”之间存在丢 Token 窗口 | `backend/cmd/server/main.go:164-194`、`rock_stock.go:38-67` | +| P1-1 | P1 | 没有全局限流;仅 DCAuth challenge 与短信验证码有局部限制,登录、开放接口、发货页、图片代理均可被刷 | `login_dcauth_service.go:93-131`、`router.go:173-198` | +| P1-2 | P1 | JWT 存在 `localStorage`,且 Caddy 未下发 CSP/nosniff/HSTS 等安全头 | `frontend/src/store/auth.tsx:22,63`、`Caddyfile:1-40` | +| P1-3 | P1 | 审计只写不读;`audit_logs.request_id` 有字段但从未写入 | `service/audit.go:11-27`、`model/merchant.go:312-318`、`router.go:150-358` | +| P1-4 | P1 | Rock `Bearer Token` 明文存储,并被 `GET /platform/rock/config` 原样返回 | `model/rock.go:40`、`handler/rock.go:22-29`、`router.go:286` | +| P1-5 | P1 | 源头侧 `ship_notify` 对永久拒绝返回 400,与 service 注释及乐玩 Webhook 的 200 语义冲突 | `handler/open.go:113-121`、`fulfillment_shipnotify.go:36-45`、`lewan_webhook.go:136-145` | +| P1-6 | P1 | Rock 自动补货在多实例下会重复采购;进程崩溃会丢失已购 Token | `main.go:172-178`、`rock_stock.go:15-67` | +| P1-7 | P1 | `/health` 不检查数据库;无连接池配置;无 metrics/tracing | `router.go:162-164`、`main.go:65`、`backend/go.mod` | +| P1-8 | P1 | `/uploads` 静态目录公开可读,充值凭证等敏感图片仅靠文件名不可枚举 | `router.go:166-169`、`Caddyfile:21-24` | +| P1-9 | P1 | 无 CI、无前端测试;只有后端 Go 测试与本地构建脚本 | 无 `.github`(本次实测)、`frontend/package.json` scripts | +| P2-1 | P2 | 001–013 迁移没有外键;软删除订单仍占用 `(merchant_id, client_order_no)` | `001_init.sql:229`、`014_login_sms_device_auth.sql:21-76` | +| P2-2 | P2 | 时间输出两种精度并存(DTO 秒级、直接返回模型的接口可能带纳秒);OpenAPI 是前端静态文件,易漂移 | `model/merchant.go:73-74`、`frontend/src/openapi/endpoints.ts` | +| P2-3 | P2 | 现有文档存在时间漂移:项目分析早于 DCAuth v2/短信登录,商品目录是 36 条旧快照 | `docs/项目分析.md:3`、`docs/商品目录.md:3-5` | +| P2-4 | P2 | 可观测性以日志为主;成功轮询降为 DEBUG,无指标/追踪/告警基线 | `router.go:73-107`、`backend/go.mod` | + +### 6.4 P0 详情 + +#### P0-1 公开 `image-proxy`:SSRF + 同源 XSS + 无界下载 + +- **证据链**: + - 路由挂在用户侧公开组,未使用 `Auth`/`Tenant`:`backend/internal/router/router.go:190-197`(`delivery.GET("/image-proxy", ...)` 在 196 行)。 + - 只校验 `http://`/`https://` 前缀,没有域名、IP、端口白名单:`backend/internal/handler/delivery.go:92-96`。 + - 使用默认 `http.Client`,会跟随重定向,仅设置 15s 超时:`delivery.go:149-155`。 + - `io.ReadAll` 读取完整响应,没有大小上限:`delivery.go:167-171`。 + - 将上游 `Content-Type` 原样回写:`delivery.go:178-183`;响应体同时落盘到 `data/uploads/products`:`delivery.go:173-176`。 + - `/uploads` 由后端公开静态服务:`router.go:166-169`。 +- **影响**: + 1. **SSRF**:可探测 `127.0.0.1`、局域网 `192.168.10.0/24`、云元数据地址等,把内网响应(哪怕不是图片)带回给攻击者。 + 2. **同源 XSS**:若上游返回 `image/svg+xml` 或 `text/html`,浏览器会在本站源执行脚本,直接读取 `localStorage` 中的 JWT(`frontend/src/store/auth.tsx:22`),导致账号接管。 + 3. **DoS/磁盘膨胀**:无上限读取会打满内存;每个未见过的 URL 都会永久落盘,没有淘汰策略。 + 4. **取证困难**:成功轮询日志被降为 DEBUG(`router.go:74-86,105`),大规模滥用不会出现在 INFO 日志里。 +- **建议**:优先删除该端点,改为构建期同步商品图;若必须保留,则做“域名+路径白名单 → DNS 解析后拒绝私网 IP → 禁止重定向 → `io.LimitReader` 5 MB → 解码并重新编码为 WebP/PNG → 强制 `Content-Type: image/webp` + `X-Content-Type-Options: nosniff`”,并放到独立无 Cookie 域名,缓存加 TTL/LRU。 + +#### P0-2 PostgreSQL 暴露面与默认凭据 + +- **证据链**: + - Compose 内 PostgreSQL 使用 `POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB` 初始化,并把 `${POSTGRES_PORT:-15432}:5432` 发布到宿主机:`docker-compose.yml:4-14`。官方镜像会以 `POSTGRES_USER` 作为超级用户。 + - `.env.example` 默认口令为 `affiliate_dev_password`,连接串 `sslmode=disable`:`.env.example:13-20`。 + - 本次部署实际使用外部 PostgreSQL `192.168.10.9:5432/affiliate_dash`(本次实测;`.env` 已被 `.gitignore` 忽略),因此同一问题转移到该实例:需确认 `listen_addresses`、`pg_hba.conf` 与业务账号权限。 +- **影响**:一旦 5432 可达且口令弱,攻击者可直接读写全部订单、钱包、密钥表;`sslmode=disable` 使凭据与数据在局域网明文传输。 +- **建议**:运行账号降为最小权限(`CONNECT/USAGE` + 业务表 DML,不做超级用户);迁移使用独立 `migrator` 账号或在发布阶段执行;强制 TLS;端口只绑 `127.0.0.1` 或用防火墙限制来源;更换示例口令并轮换现有口令。 + +#### P0-3 没有备份/恢复链路 + +- **证据链**: + - `Makefile` 只有 `backend/frontend/install/fmt/lint/test/test-db/stop/docker-*` 目标,没有 `backup`/`dump`/`restore`(`Makefile:1-78`)。 + - 仓库没有 `scripts/` 目录(本次实测),也没有备份 sidecar。 + - `docker-compose.yml:112-114` 为内置库定义了命名卷 `postgres-data`;本次实际使用外部 PostgreSQL `192.168.10.9:5432`,仓库里同样没有针对它的备份脚本、定时任务或恢复说明(本次实测)。 +- **影响**:仓库层面没有任何可执行的恢复路径;误删、迁移失败、磁盘损坏时,是否可恢复完全取决于外部库是否另行配置了备份与 PITR。`014` 之前的迁移没有外键,误删父表不会被数据库拦截,进一步放大数据损坏风险。 +- **建议**:每日 `pg_dump -Fc` + 异地对象存储;开启 WAL 归档做 PITR;每次发布迁移前做一次快照;每季度做一次“从备份恢复到隔离库”的演练并记录 RTO/RPO。 + +#### P0-4 无优雅停机,Rock 采购存在丢 Token 窗口 + +- **证据链**: + - 入口直接 `r.Run(addr)`,没有 `signal.Notify`/`http.Server.Shutdown`:`backend/cmd/server/main.go:192-194`。 + - 后台 goroutine(回调、超时扫描、Rock 巡检)都用 `context.Background()`,没有取消信号:`main.go:164-178`。 + - Rock 采购先调上游 `ClaimTokens`,成功后才在本地事务插入 Token 并更新 `last_purchased_at`:`rock_stock.go:38-67`。 +- **影响**:滚动发布/重启会直接切断在途 HTTP 请求;更严重的是,进程若在“上游已扣积分、Token 尚未入库”之间被杀,这批 Token 永久丢失且没有对账补偿。 +- **建议**:改用 `http.Server` + `signal.NotifyContext` + 带超时的 `Shutdown`;后台任务统一接收可取消的 `ctx` 并 `WaitGroup` 等待;采购改为“先写 pending 采购单(唯一键)→ 调上游 → 回填 Token → 对账任务按上游流水补齐”,崩溃后能自动恢复。 + +### 6.5 P1 详情 + +#### P1-1 缺少全局限流 + +- **证据**:没有全局/中间件级限流;DCAuth challenge 只有进程内 30 次/分钟(`login_dcauth_service.go:30,93-131`),短信验证码仅有按手机号的冷却与每日上限(§4 R26);`middleware/` 只有认证/功能开关/开放平台签名,没有限流中间件(本次实测目录清单);`/api/auth/login`、`/api/auth/login/sms-code`、`/api/client/v1/*`、`/api/delivery/v1/*` 均直接挂业务 handler(`router.go:173-198,215-233`)。 +- **影响**:后台密码可被撞库;短信接口可被刷费用;开放接口可被恶意下单/查询;`image-proxy` 可被当作免费代理刷流量。 +- **建议**:在 Caddy 或 Gin 中间件做“IP + 账号 + app_key”三维令牌桶;登录/短信更严格;多实例用 Redis 共享计数;对 `image-proxy` 单独设置更低配额。 + +#### P1-2 JWT 存 `localStorage` 且缺少安全响应头 + +- **证据**:`frontend/src/store/auth.tsx:22` 从 `localStorage` 读取 token,`auth.tsx:63` 登录后写入;`Caddyfile` 全文没有 `header` 指令(`Caddyfile:1-40`)。 +- **影响**:任意 XSS(P0-1 就是现成入口)即可窃取长期 JWT;缺少 CSP、`X-Content-Type-Options`、`frame-ancestors`、HSTS,点击劫持与 MIME 嗅探也未被阻断。 +- **建议**:改为 `HttpOnly; Secure; SameSite=Lax` Cookie + CSRF Token,或短期内存 token + 刷新 token;在 Caddy 统一加 CSP(至少 `default-src 'self'`)、`nosniff`、`frame-ancestors 'none'`、HSTS。 + +#### P1-3 审计只写不读,`request_id` 永远为空 + +- **证据**:`writeAudit` 只写 `merchant_id/actor/action/entity/metadata`,没有给 `RequestID` 赋值(`backend/internal/service/audit.go:11-27`);模型定义了 `RequestID` 字段(`backend/internal/model/merchant.go:312-318`);路由表没有审计查询端点(`router.go:150-358`)。 +- **影响**:出现资金争议或越权操作时,无法按请求链路还原“谁在何时改了什么”;`request_id` 列存在但无数据,日志与审计无法关联。 +- **建议**:在 `requestLogger` 生成的 request_id 注入 `gin.Context`,服务层写审计时带上;新增平台端审计查询(按商户/操作人/实体/时间过滤);设定保留期与导出能力。 + +#### P1-4 Rock `Bearer Token` 明文存储并回显 + +- **证据**:`BearerToken` 为 `type:text;not null` 的明文字段(`backend/internal/model/rock.go:40`);`GetConfig` 直接把配置对象返回(`backend/internal/handler/rock.go:22-29`);路由为平台管理员可见(`router.go:238-240,286`)。 +- **影响**:拥有平台管理员会话的人可在浏览器/日志/截图中拿到上游采购凭据;一旦泄露,攻击者可直接消耗 Rock 余额。 +- **建议**:模型改为 `json:"-"`,接口返回掩码(如 `sk-****abcd`);写入时用 `DATA_ENCRYPTION_KEY` 加密;提供“测试连接”而不回显密钥;定期轮换。 + +#### P1-5 源头侧 `ship_notify` 永久拒绝返回 400 + +- **证据**:service 注释明确“业务永久拒绝应回 200 避免无限重试”(`fulfillment_shipnotify.go:36-45`);乐玩 Webhook 对永久拒绝返回 200(`lewan_webhook.go:136-145`,分类器在 `177-188`);但源头侧 handler 只把“订单不存在”映射为 404,其余错误统一 400(`handler/open.go:113-121`)。 +- **影响**:上游若遵循“非 2xx 重试”,会对已取消/已交付订单反复推送,形成无限重试与日志噪音,甚至触发上游限流。 +- **建议**:在 `open.go` 中对 `errors.Is(err, service.ErrShipNotifyRejected)` 返回 200 + `{status:"rejected", message:...}`;其余参数/签名错误保持 400,订单不存在保持 404。 + +#### P1-6 Rock 自动补货的多实例竞态与崩溃窗口 + +- **证据**:每个实例启动后都跑 5 分钟 ticker(`main.go:172-178`);`PurchaseTokens` 用进程内 `s.mu` 互斥,冷却检查在数据库事务之前读取 `last_purchased_at`(`rock_stock.go:16-35`);上游调用在 38 行,入库事务在 61-67 行。 +- **影响**:两个实例可同时通过冷却检查并各自采购,造成超买;进程在 38-61 行之间崩溃会丢失已购 Token;`last_purchased_at` 只在成功后更新,失败重试也没有退避。 +- **建议**:用 PostgreSQL advisory lock 或单例 leader worker 串行化;采购前插入带唯一键的 `purchase_attempt`,成功后回填;增加按上游流水/Token 列表的对账任务;失败按指数退避并告警。 + +#### P1-7 `/health` 不检查数据库,无连接池与指标 + +- **证据**:`/health` 固定返回 `{"status":"ok"}`(`router.go:162-164`);Compose 用它做健康检查(`docker-compose.yml:80-85`);`gorm.Open` 后没有 `SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime`(`main.go:65`,全仓库无相关调用);`backend/go.mod` 无 Prometheus/OpenTelemetry 依赖,路由无 `/metrics`。 +- **影响**:数据库宕机时容器仍显示 healthy,流量继续打进来;默认连接池无上限,突发流量可能打满 PostgreSQL;没有延迟/错误率/队列积压指标,故障只能靠用户反馈。 +- **建议**:新增 `/readyz` 执行 `sqlDB.PingContext`;设置池上限与生命周期;暴露 Prometheus 指标(HTTP 延迟、5xx、回调积压、Rock 库存、钱包扣款失败);对关键指标配告警。 + +#### P1-8 `/uploads` 公开可读 + +- **证据**:后端 `r.Static("/uploads", h.UploadDir)`(`router.go:166-169`),Caddy 将 `/uploads*` 反向代理到后端(`Caddyfile:21-24`)。 +- **影响**:充值凭证、付款截图等可能含姓名/账号/金额;文件名不可枚举不是访问控制,URL 会通过日志、Referer、聊天记录泄露。 +- **建议**:改为鉴权后下载或短时效签名 URL;独立域名/独立存储;上传时剥离 EXIF、限制类型与大小;敏感文件加密存储。 + +#### P1-9 无 CI、无前端测试 + +- **证据**:仓库没有 `.github`(本次实测 `Test-Path .github` 为 False);`frontend/package.json` 的 scripts 只有 `dev/build/lint/preview`;后端有 `*_test.go`,但没有流水线执行。 +- **影响**:迁移 checksum、OpenAPI 契约、前端构建、Docker 镜像都靠人工验证;回归成本高,容易出现“本地能跑、部署失败”。 +- **建议**:CI 至少跑 `go test ./...`、`go vet ./...`、`npm run build`、`npm run lint`、`docker build`;加迁移 dry-run 与一条 Playwright 冒烟(登录→下单→提货→回调)。 + +### 6.6 P2 详情 + +#### P2-1 数据完整性依赖应用层;软删除与唯一索引冲突 + +- **证据**:`001`–`013` 迁移没有任何 `REFERENCES`,外键只出现在 `014`(`014_login_sms_device_auth.sql:21,31,49,57,76`);`fulfillment_orders` 的唯一索引是 `(merchant_id, client_order_no)`,不含 `deleted_at`(`001_init.sql:229`),而模型启用了软删除(`backend/internal/model/merchant.go:217-221`)。 +- **影响**:父表删除后可能留下孤儿行;软删除的订单仍占用客户端单号,商户无法用同一单号重试,且错误信息可能暴露“已存在”。 +- **建议**:对核心关系补 `FOREIGN KEY ... NOT VALID` 后再 `VALIDATE`,避免长锁;明确单号语义——若要求“永久幂等”则保留现状并写入文档,若允许删除后复用则改为 `WHERE deleted_at IS NULL` 的部分唯一索引。 + +#### P2-2 时间与 OpenAPI 契约细节 + +- **证据**:项目同时存在两种时间输出:DTO 走 `timeutil.FormatAPITime` 的秒级 RFC3339(`backend/internal/pkg/timeutil/timeutil.go:8-20,47-51`),但直接返回 GORM 模型的接口(如 Rock 配置)仍走 `time.Time` 默认序列化,可能带纳秒(`backend/internal/model/merchant.go:73-74`、`backend/internal/model/rock.go:48-51`);OpenAPI 是前端静态文件(`frontend/src/openapi/endpoints.ts`、`types.ts`),后端没有生成器。 +- **影响**:同一套 API 里两种时间精度并存,部分客户端只接受秒级时间或 `Z` 结尾,可能解析失败;静态文档与真实 handler 容易漂移,联调时以代码为准。 +- **建议**:统一时间格式(如 `2006-01-02T15:04:05+08:00`)或自定义 `JSONTime`;金额统一写明“整数 POINT,1 CNY = 100 POINT”;用代码生成 OpenAPI 或加契约测试。 + +#### P2-3 文档时间漂移 + +- **证据**:`docs/项目分析.md:3` 标注分析日期 2026-10-06,而短信验证码 + DCAuth v2 是后续 `014_login_sms_device_auth.sql` 才引入;`docs/商品目录.md:3-5` 标注 2026-09-14、36 条商品,本次实测 `products` 已有 38 行。 +- **影响**:新成员按旧文档理解登录流程或商品范围,会得出错误结论。 +- **建议**:文档头部统一加“适用版本/日期/迁移号”;商品目录改为从数据库导出;把本文作为当前基线,旧文档标注“历史存档”。 + +#### P2-4 可观测性以日志为主 + +- **证据**:请求日志中间件只做结构化输出与 request_id 透传(`router.go:102-107`),成功轮询降为 DEBUG(`router.go:73-86,105`);没有指标、追踪或告警依赖。 +- **影响**:图片代理滥用、回调积压、慢查询、钱包失败率都缺少可视化;排障依赖 grep 日志。 +- **建议**:日志保留 request_id/merchant_id/order_no 三个关联键;补 metrics 与 trace;对“回调重试次数、待处理超时订单数、Rock 可用库存、5xx 率”设阈值告警。 + +### 6.7 上线前最小检查清单 + +- [ ] `image-proxy` 关闭,或完成“白名单 + 私网拦截 + 禁止重定向 + 5 MB 上限 + 重编码 + nosniff”。 +- [ ] PostgreSQL 运行账号非超级用户;端口不对公网;强制 TLS;更换示例口令。 +- [ ] 每日备份 + 异地保存 + 至少一次恢复演练;发布前快照。 +- [ ] `http.Server.Shutdown` + 后台任务可取消;Rock 采购有 pending 记录与对账。 +- [ ] 登录、短信、开放接口、发货页、图片代理全部有限流。 +- [ ] JWT 改 HttpOnly Cookie(或短期内存 token);Caddy 下发 CSP/nosniff/frame-ancestors/HSTS。 +- [ ] 审计写入 `request_id`,并提供平台端查询。 +- [ ] Rock `Bearer Token` 不回显、加密存储、可轮换。 +- [ ] 源头 `ship_notify` 对永久拒绝返回 200。 +- [ ] `/readyz` 检查数据库;连接池有上限;关键指标有告警。 +- [ ] `/uploads` 鉴权或签名 URL。 +- [ ] CI 跑通后端测试、前端构建/lint、Docker 构建与一条端到端冒烟。 + +--- + +## 7. 来源索引 + +> 下列文件是本文全部结论的原始依据。代码行号以 2026-10-26 的工作区为准;迁移一旦执行便不可改写,历史结论可直接复核。标注“本次实测”的为对运行中的数据库/容器/命令输出的直接观察。 + +### 7.1 后端入口、配置、路由与中间件 + +| 主题 | 文件 | +|------|------| +| 进程启动、迁移、后台任务、HTTP 监听 | `backend/cmd/server/main.go` | +| 环境变量与默认值 | `backend/internal/config/config.go`、`.env.example` | +| 路由注册与中间件编排 | `backend/internal/router/router.go` | +| JWT 认证、租户解析、角色校验 | `backend/internal/middleware/auth.go` | +| 商户开放接口 HMAC 鉴权 | `backend/internal/middleware/open_auth.go` | +| 源头侧开放接口鉴权 | `backend/internal/middleware/source_open_auth.go` | +| 商户功能开关 | `backend/internal/middleware/feature.go` | + +### 7.2 订单、履约与状态机 + +| 主题 | 文件 | +|------|------| +| 订单创建、扣款、幂等 | `backend/internal/service/fulfillment_create.go` | +| 订单查询/列表/详情 | `backend/internal/service/fulfillment_query.go`、`fulfillment_order.go` | +| 状态迁移与校验 | `backend/internal/service/fulfillment_state.go`、`delivery_state.go` | +| 取消与退款 | `backend/internal/service/fulfillment_cancel.go` | +| 上游回执处理 | `backend/internal/service/fulfillment_shipnotify.go` | +| 处理超时扫描 | `backend/internal/service/fulfillment_timeout.go` | +| 订单更新/人工操作 | `backend/internal/service/fulfillment_update.go` | +| 测试单 | `backend/internal/service/fulfillment_testorder.go` | +| 钱包扣款/退款/流水 | `backend/internal/service/fulfillment_wallet.go` | +| HTTP handler | `backend/internal/handler/open.go`、`open_v1.go`、`order.go`、`merchant_order.go` | + +### 7.3 提货链接、发货与上游 Provider + +| 主题 | 文件 | +|------|------| +| 提货链接签名/校验/过期 | `backend/internal/service/delivery_link.go`、`delivery_helpers.go` | +| 提货页查询与绑定 | `backend/internal/service/delivery_query.go`、`delivery_bind.go` | +| 提交上游与状态推进 | `backend/internal/service/delivery_submit.go`、`delivery_upstream.go` | +| Provider 抽象与注册 | `backend/internal/service/delivery_provider.go`、`provider.go` | +| 乐玩 Provider | `backend/internal/service/provider_lewan.go` | +| DLC Provider | `backend/internal/service/provider_dlc.go` | +| Rock Provider | `backend/internal/service/provider_rock.go` | +| 图片代理(风险点) | `backend/internal/handler/delivery.go:91-184` | +| 乐玩 Webhook | `backend/internal/handler/lewan_webhook.go` | +| 上游商品同步 | `backend/internal/handler/platform_lewan.go` | + +### 7.4 回调、钱包、充值、告警与审计 + +| 主题 | 文件 | +|------|------| +| 回调 outbox 与投递 | `backend/internal/service/callback.go` | +| 钱包账本与调账 | `backend/internal/service/fulfillment_wallet.go`、`backend/internal/handler/merchant_wallet.go` | +| 充值申请/审核 | `backend/internal/service/recharge.go`、`backend/internal/handler/recharge.go` | +| 审计写入 | `backend/internal/service/audit.go` | +| 告警渠道/策略 | `backend/internal/database/migrations/004_alert_channels.sql`、`005_alert_policy.sql`、`backend/internal/handler/merchant_platform.go` | + +### 7.5 Rock 平台 + +| 主题 | 文件 | +|------|------| +| 配置与 Token 模型 | `backend/internal/model/rock.go` | +| 客户端与上游协议 | `backend/internal/service/rock_client.go` | +| 采购/库存/自动补货 | `backend/internal/service/rock_stock.go` | +| 分配/绑定/解绑 | `backend/internal/service/rock_assign.go`、`rock_bindcheck.go`、`rock_bindlink.go` | +| 兑换/恢复/快照 | `backend/internal/service/rock_redeem.go`、`rock_recover.go`、`rock_snapshot.go` | +| 管理端 handler | `backend/internal/handler/rock.go` | + +### 7.6 认证、租户、用户与密钥 + +| 主题 | 文件 | +|------|------| +| 后台账号/密码/JWT | `backend/internal/service/auth.go`、`backend/internal/handler/auth.go` | +| DCAuth v2 设备登录 | `backend/internal/service/login_dcauth_service.go`、`backend/internal/handler/dcauth.go` | +| 短信验证码 | `backend/internal/service/login_sms_service.go` | +| 登录相关模型 | `backend/internal/model/login_auth.go` | +| 租户解析与隔离 | `backend/internal/service/tenant.go` | +| 用户/成员/角色 | `backend/internal/service/user.go`、`merchant_member.go`、`backend/internal/handler/user.go` | +| 敏感字段加解密 | `backend/internal/service/secret_codec.go` | + +### 7.7 数据模型 + +| 主题 | 文件 | +|------|------| +| 用户、商户、API 客户端、商品、订单、回调、审计 | `backend/internal/model/merchant.go` | +| 钱包、充值 | `backend/internal/model/recharge.go` | +| Rock 配置/Token | `backend/internal/model/rock.go` | +| 登录短信/设备/challenge | `backend/internal/model/login_auth.go` | +| 公共模型 | `backend/internal/model/models.go` | + +### 7.8 数据库迁移(14 个版本,全部为显式 SQL) + +| 版本 | 作用 | +|------|------| +| `001_init.sql` | 初始表:用户、商户、API 客户端、商品、钱包、订单、回调、审计等 | +| `002_add_medal_coin_products.sql` | 增加勋章币商品 | +| `003_recharge.sql` | 充值申请与审核 | +| `004_alert_channels.sql` | 告警渠道 | +| `005_alert_policy.sql` | 告警策略 | +| `006_fulfillment_order_source.sql` | 订单来源字段(`api/manual/test`) | +| `007_merchant_roles.sql` | 商户角色 | +| `008_default_merchant_team_roles.sql` | 默认团队角色种子 | +| `009_users_username_active_unique.sql` | 用户名有效唯一约束 | +| `010_delivery_link_generated_at.sql` | 提货链接生成时间 | +| `011_catalog_english_sku_and_prices.sql` | 英文 SKU 与价格字段 | +| `012_rock_fulfillment.sql` | Rock 履约相关表 | +| `013_rock_tokens_remark.sql` | Rock Token 备注 | +| `014_login_sms_device_auth.sql` | 短信验证码、设备绑定、challenge/ticket | + +迁移执行器与 checksum:`backend/internal/database/migrate.go`。 + +### 7.9 前端 + +| 主题 | 文件 | +|------|------| +| 路由与懒加载 | `frontend/src/App.tsx` | +| 登录态与 localStorage | `frontend/src/store/auth.tsx` | +| Axios 实例、鉴权头、错误处理 | `frontend/src/api/request.ts` | +| API 封装 | `frontend/src/api/index.ts` | +| 主布局/菜单/商户切换 | `frontend/src/layouts/MainLayout.tsx` | +| 静态端点文档 | `frontend/src/openapi/EndpointDoc.tsx`、`endpoints.ts`、`types.ts` | +| 类型定义 | `frontend/src/types/index.ts` | + +### 7.10 部署与运维 + +| 主题 | 文件 | +|------|------| +| 编排、PostgreSQL、健康检查、卷 | `docker-compose.yml` | +| 后端多阶段构建 | `Dockerfile.backend` | +| 前端构建 + Caddy 运行镜像 | `Dockerfile.caddy` | +| 反向代理、SPA 回退、上传代理 | `Caddyfile` | +| 构建/测试/容器命令 | `Makefile` | +| 一键启动 | `start.sh` | +| 环境变量样例与默认值 | `.env.example` | + +### 7.11 项目文档 + +| 文档 | 用途/时点 | +|------|-----------| +| `docs/项目分析.md` | 2026-10-06 的旧版项目分析,早于 DCAuth v2/短信登录 | +| `docs/API对接关系.md` | 商户/源头/内部接口对接关系 | +| `docs/发货通知约定.md` | `ship_notify` 字段与状态约定 | +| `docs/开放接口-皮肤源头对接.md` | 源头侧开放接口协议 | +| `docs/乐玩开放平台接入文档-V1.2.md` | 乐玩上游协议 | +| `docs/rock发货平台(0666.pro)逆向与接口分析.md` | Rock 上游接口分析 | +| `docs/商品目录.md` | 2026-09-14 的 36 条商品快照(现库 38 条) | +| `docs/项目梳理-业务流程与数据模型.md` | 本文,当前基线 | + +### 7.12 本次实测记录 + +- 数据库:`192.168.10.9:5432/affiliate_dash`,`schema_migrations` 14 行,`public` 表 24 张(含 `schema_migrations`),`products` 38 行,`users` 1 行(`admin`,绑定 `self-operated` 商户,角色 `owner`)。 +- 容器:`affiliate_dash-backend-1`(healthy,`:18080`)、`affiliate_dash-caddy-1`(`:18000→80`、`:18443→443`)。 +- 仓库:`.github` 不存在;`frontend/package.json` 无 `test` 脚本;`Makefile` 无备份目标;`scripts/` 不存在。 + +> 复核方式:按表中的 `文件:行号` 直接打开源码;涉及运行库的数字以 `psql` 查询为准。若代码与本文不一致,以代码为准,并回来更新本文对应章节。 + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 22793fb..054cd93 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -572,6 +572,7 @@ export interface RockConfig { bearer_token: string; username: string; points: number; + black_token_cost: number; min_points_warning: number; token_warning_stock: number; auto_purchase_enabled: boolean; @@ -594,15 +595,28 @@ export interface RockStats { auto_purchase_enabled: boolean; username: string; last_heartbeat_at?: string; + last_purchase_error?: string; + last_purchase_error_at?: string; +} + +export interface RockDashboard { + available_black: number; + available_blue: number; + black_token_cost: number; + blue_token_cost: number; + cost_updated: boolean; } export interface RockTokenItem { id: number; upstream_token_id: string; + package_id?: number; + token?: string; redeem_code: string; color: string; region: string; status: string; + source?: string; assigned_order_no?: string; client_order_no?: string; expected_uid?: string; @@ -616,10 +630,12 @@ export interface RockTokenItem { mismatch_at?: string; mismatch_reason?: string; remark?: string; + raw_data?: string; claimed_at?: string; bound_at?: string; redeemed_at?: string; created_at: string; + updated_at?: string; } export const rockApi = { @@ -635,6 +651,10 @@ export const rockApi = { .then((r) => r.data.data), getStats: () => request.get("/platform/rock/stats").then((r) => r.data.data as RockStats), + refreshDashboard: () => + request + .post("/platform/rock/dashboard/refresh") + .then((r) => r.data.data as RockDashboard), purchaseTokens: (count = 50) => request.post("/platform/rock/purchase", { count }).then((r) => r.data.data), listTokens: (params?: { diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index 37c4717..94ba6af 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { Button, Form, Modal, Space, message } from 'antd' +import { Alert, Button, Form, Modal, Space, message } from 'antd' import { ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons' import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api' import { PageHeader } from '../components/PageHeader' @@ -12,15 +12,28 @@ import { UidEditModal } from './platformRock/UidEditModal' import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal' import type { UidEditState } from './platformRock/types' +function prettyRawData(raw?: string) { + if (!raw) return '暂无原始数据' + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} + export default function PlatformRock() { const [config, setConfig] = useState(null) const [stats, setStats] = useState(null) + const [upstreamStock, setUpstreamStock] = useState(null) const [loadingStats, setLoadingStats] = useState(false) const [testingConn, setTestingConn] = useState(false) const [savingConfig, setSavingConfig] = useState(false) + const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false) + const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false) const [purchasing, setPurchasing] = useState(false) const [purchaseModalOpen, setPurchaseModalOpen] = useState(false) const [purchaseCount, setPurchaseCount] = useState(50) + const [rawDataRecord, setRawDataRecord] = useState(null) // 凭证图片操作状态 const [copyingVoucherId, setCopyingVoucherId] = useState(null) @@ -43,16 +56,23 @@ export default function PlatformRock() { const loadData = useCallback(async () => { setLoadingStats(true) try { + try { + const dashboard = await rockApi.refreshDashboard() + setUpstreamStock(dashboard.available_black) + } catch (e) { + setUpstreamStock(null) + message.warning('获取上游库存/采购单价失败: ' + (e instanceof Error ? e.message : '未知错误')) + } const [cfg, st] = await Promise.all([rockApi.getConfig(), rockApi.getStats()]) setConfig(cfg) setStats(st) + setAutoPurchaseEnabled(cfg.auto_purchase_enabled) 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, }) @@ -114,6 +134,24 @@ export default function PlatformRock() { }) } + // 自动补货开关:切换即保存,避免只改表单值后被刷新回填覆盖 + const handleToggleAutoPurchase = async (checked: boolean) => { + const previous = autoPurchaseEnabled + setAutoPurchaseEnabled(checked) + setTogglingAutoPurchase(true) + try { + const updated = await rockApi.updateConfig({ auto_purchase_enabled: checked }) + setConfig(updated) + setAutoPurchaseEnabled(updated.auto_purchase_enabled) + message.success(checked ? '自动补货已开启' : '自动补货已关闭') + } catch (e) { + setAutoPurchaseEnabled(previous) + message.error('更新自动补货开关失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setTogglingAutoPurchase(false) + } + } + // 测试连通性 const handleTestConnection = async () => { const bearerToken = form.getFieldValue('bearer_token') @@ -162,6 +200,10 @@ export default function PlatformRock() { message.warning('请输入有效的采购数量') return } + if (upstreamStock !== null && count > upstreamStock) { + message.warning(`上游可用库存不足:当前 ${upstreamStock} 个,本次需要 ${count} 个`) + return + } setPurchasing(true) try { const res = await rockApi.purchaseTokens(count) @@ -219,8 +261,9 @@ export default function PlatformRock() { const isStockLow = availableStock <= warningStock const hasMismatch = (stats?.mismatch_stock ?? 0) > 0 const upstreamPoints = stats?.upstream_points ?? 0 - const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / 21)) - const estimatedCost = (purchaseCount || 0) * 21 + const tokenCost = config?.black_token_cost && config.black_token_cost > 0 ? config.black_token_cost : 20 + const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost)) + const estimatedCost = (purchaseCount || 0) * tokenCost const isPointsInsufficient = estimatedCost > upstreamPoints return ( @@ -249,9 +292,23 @@ export default function PlatformRock() { } /> + {stats?.last_purchase_error ? ( + + ) : null} + {/* 顶部高质感指标卡片 */} @@ -296,6 +356,7 @@ export default function PlatformRock() { setPreviewVoucherUrl={setPreviewVoucherUrl} setPreviewVoucherOpen={setPreviewVoucherOpen} onEditUid={setUidEdit} + onViewRawData={setRawDataRecord} onPageChange={handlePageChange} onReloadData={loadData} onReloadTokens={loadTokens} @@ -311,11 +372,39 @@ export default function PlatformRock() { upstreamPoints={upstreamPoints} estimatedCost={estimatedCost} isPointsInsufficient={isPointsInsufficient} + tokenCost={tokenCost} + upstreamStock={upstreamStock} onPurchaseCountChange={setPurchaseCount} onCancel={() => setPurchaseModalOpen(false)} onConfirm={handlePurchase} /> + setRawDataRecord(null)} + > +
+          {prettyRawData(rawDataRecord?.raw_data)}
+        
+
+ void onCancel: () => void onConfirm: (count: number) => void @@ -25,10 +27,13 @@ export function PurchaseTokenModal({ upstreamPoints, estimatedCost, isPointsInsufficient, + tokenCost, + upstreamStock, onPurchaseCountChange, onCancel, onConfirm, }: PurchaseTokenModalProps) { + const isStockInsufficient = upstreamStock !== null && purchaseCount > upstreamStock return ( } loading={purchasing} - disabled={isPointsInsufficient || purchaseCount <= 0} + disabled={isPointsInsufficient || isStockInsufficient || purchaseCount <= 0} onClick={() => onConfirm(purchaseCount)} style={{ - background: isPointsInsufficient - ? undefined - : 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', + background: + isPointsInsufficient || isStockInsufficient + ? undefined + : 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', }} > - 确认采购 {purchaseCount} 个 ({estimatedCost} 点) + 确认采购 {purchaseCount} 个 ({estimatedCost.toFixed(2)} 点) , ]} destroyOnClose @@ -88,12 +94,48 @@ export function PurchaseTokenModal({ -
- 采购单价:21 点 / 个 - 最大可采购:{maxPurchasable} 个 +
+ + 采购单价:{tokenCost.toFixed(2)} 点 / 个 + + + 上游可用库存: + + {upstreamStock === null ? '未获取' : `${upstreamStock} 个`} + + + + 最大可采购:{maxPurchasable} 个 +
+ {/* 上游库存不足提示 */} + {isStockInsufficient && ( +
+ ⚠️ 上游可用库存不足:当前 {upstreamStock} 个,本次需要 {purchaseCount} 个,请减少采购数量。 +
+ )} + {/* 采购数量输入 */}
@@ -153,7 +195,7 @@ export function PurchaseTokenModal({ 本次采购消耗积分: - {estimatedCost} 点 + {estimatedCost.toFixed(2)} 点
- + +
+
+ 上游可用库存 (0666.pro) +
+ +
+
+
+ {upstreamStock === null ? '-' : upstreamStock} + 个 +
+
+ 采购单价: {blackTokenCost.toFixed(2)} 点/个 + {upstreamStock === null ? ( + + 未获取 + + ) : upstreamStock === 0 ? ( + + 无库存 + + ) : ( + + 实时 + + )} +
+
+ + +
可用通用库存 (Available) @@ -45,7 +81,9 @@ export function RockMetricCards({
警戒水位: {warningStock} 个 {isStockLow ? ( - 库存紧缺 + + 库存紧缺 + ) : ( 储备充足 )} @@ -53,7 +91,7 @@ export function RockMetricCards({
- +
绑定错误阻断 (Mismatch) @@ -68,7 +106,9 @@ export function RockMetricCards({
{hasMismatch ? 'UID强一致校验拦截保护' : '暂无异常阻断'} {hasMismatch ? ( - 需核对处理 + + 需核对处理 + ) : ( 状态正常 )} @@ -76,7 +116,7 @@ export function RockMetricCards({
- +
已履约交付 (Redeemed) @@ -95,7 +135,7 @@ export function RockMetricCards({
- +
0666.pro 账号积分 diff --git a/frontend/src/pages/platformRock/RockStrategyCard.tsx b/frontend/src/pages/platformRock/RockStrategyCard.tsx index fd6fae4..273ae9e 100644 --- a/frontend/src/pages/platformRock/RockStrategyCard.tsx +++ b/frontend/src/pages/platformRock/RockStrategyCard.tsx @@ -27,9 +27,12 @@ const { Text } = Typography export interface RockStrategyCardProps { form: FormInstance autoDelivery: boolean + autoPurchaseEnabled: boolean + togglingAutoPurchase: boolean testingConn: boolean savingConfig: boolean onToggleAutoDelivery: (checked: boolean) => void + onToggleAutoPurchase: (checked: boolean) => void onTestConnection: () => void onSaveConfig: () => void } @@ -37,9 +40,12 @@ export interface RockStrategyCardProps { export function RockStrategyCard({ form, autoDelivery, + autoPurchaseEnabled, + togglingAutoPurchase, testingConn, savingConfig, onToggleAutoDelivery, + onToggleAutoPurchase, onTestConnection, onSaveConfig, }: RockStrategyCardProps) { @@ -227,13 +233,24 @@ export function RockStrategyCard({ - - + + + + + 切换后立即保存 + + - + diff --git a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx index 1137690..b55081d 100644 --- a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx +++ b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx @@ -34,12 +34,12 @@ export function RockTokenFilterBar({ } value={searchKeyword} onChange={(e) => onSearchKeywordChange(e.target.value)} onPressEnter={onRefresh} - style={{ width: 340 }} + style={{ width: 400 }} allowClear /> - 共检索到 {totalTokens} 条库存记录 + 共检索到 {totalTokens} 条 Token 记录 diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx index 619f740..ae0cbd5 100644 --- a/frontend/src/pages/platformRock/RockTokenTable.tsx +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -18,6 +18,7 @@ export interface RockTokenTableProps { setPreviewVoucherUrl: Dispatch> setPreviewVoucherOpen: Dispatch> onEditUid: (state: UidEditState) => void + onViewRawData: (record: RockTokenItem) => void onPageChange: (page: number, pageSize: number) => void onReloadData: () => void onReloadTokens: () => void @@ -34,6 +35,7 @@ export function RockTokenTable({ setPreviewVoucherUrl, setPreviewVoucherOpen, onEditUid, + onViewRawData, onPageChange, onReloadData, onReloadTokens, @@ -153,6 +155,7 @@ export function RockTokenTable({ onCopyVoucher: handleCopyVoucher, onPreviewVoucher: handlePreviewVoucher, onEditUid, + onViewRawData, onResetToken: handleResetToken, onReleaseToken: handleReleaseToken, onUpdateRemark: handleUpdateRemark, @@ -165,14 +168,14 @@ export function RockTokenTable({ columns={columns} dataSource={tokens} loading={loadingTokens} - scroll={{ x: 1300 }} + scroll={{ x: 1700 }} pagination={{ current: page, pageSize, total: totalTokens, showSizeChanger: true, pageSizeOptions: ['10', '20', '50', '100'], - showTotal: (total) => `共 ${total} 条库存记录`, + showTotal: (total) => `共 ${total} 条 Token 记录`, onChange: (p, ps) => { onPageChange(p, ps) }, diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index a8768b2..c80cc9b 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -13,6 +13,7 @@ export interface TokenColumnHandlers { onCopyVoucher: (record: RockTokenItem) => void onPreviewVoucher: (record: RockTokenItem) => void onEditUid: (state: UidEditState) => void + onViewRawData: (record: RockTokenItem) => void onResetToken: (id: number) => void onReleaseToken: (id: number) => void onUpdateRemark: (id: number, remark: string, status?: string) => void @@ -24,6 +25,7 @@ export function buildTokenColumns({ onCopyVoucher, onPreviewVoucher, onEditUid, + onViewRawData, onResetToken, onReleaseToken, onUpdateRemark, @@ -31,7 +33,7 @@ export function buildTokenColumns({ }: TokenColumnHandlers): ColumnsType { return [ { - title: '0666 编号 / 提取码', + title: '0666 编号 / 兑换码', dataIndex: 'upstream_token_id', width: 250, render: (_, record) => ( @@ -53,6 +55,32 @@ export function buildTokenColumns({ ), }, + { + title: '包ID / 提取码', + width: 230, + render: (_, record) => ( + +
+ 包ID: + {record.package_id ? `#${record.package_id}` : '-'} +
+
+ 提取码: + + {record.token || '-'} + +
+ {record.source === 'package' ? ( + + 打包导入 + + ) : null} +
+ ), + }, { title: '规格与适用范围', dataIndex: 'color', @@ -338,6 +366,18 @@ export function buildTokenColumns({ ) }, }, + { + title: '原始数据', + width: 100, + render: (_, record) => + record.raw_data ? ( + + ) : ( + - + ), + }, { title: '操作', key: 'action',