From 80e2bbf875a5500aa4dc11e1a0506b36063030dc Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 19:42:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(rock):=20=E4=B8=8A=E6=B8=B8=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=94=B9=E4=B8=BA=E4=BB=A5=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=BA=E5=87=86=EF=BC=8C=E5=B9=B6=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20baseURL=20=E6=95=B0=E6=8D=AE=E7=AB=9E=E4=BA=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetConfig 从不把 rock_configs.base_url 回写到客户端,main.go 又硬编码了 https://0666.pro,导致后台改上游地址「保存那一刻生效、进程重启后失效」, 数据库里的值被静默忽略且无任何提示。 - 新增 model.RockDefaultBaseURL 统一默认值来源 - 新增 RockService.ApplyConfigToClient,启动时把 DB 地址同步到客户端 - UpdateConfig 改用 client.SetBaseURL 立即生效,不再直接写字段 - RockClient.baseURL 原为裸字段,被 HTTP 请求线程与库存巡检 goroutine 并发 读写(数据竞争),改为 sync.RWMutex 保护并新增 BaseURL/SetBaseURL - ClaimTokens/RefreshToken/RedeemSkin 的 Origin/Referer 跟随配置地址 - 空值回落默认地址,末尾斜杠自动裁剪 为保持该提交可编译,同一次改动把 rock_service.go(1177 行)按域拆分为 8 个文件(最大 221 行):rock_service / rock_bindlink / rock_stock / rock_assign / rock_bindcheck / rock_redeem / rock_recover / rock_snapshot。 35 个声明块全部有归属,无遗漏无重复;迁移文件 012 受 checksum 保护故未改动。 验证:TestRockServiceBaseURLFromConfig 覆盖「改地址→立即生效→重启→仍生效」; 并发用例在 -race 下通过;全量测试通过。 --- backend/cmd/server/main.go | 10 +- backend/internal/model/rock.go | 13 +- backend/internal/service/rock_assign.go | 158 +++ backend/internal/service/rock_baseurl_test.go | 123 +++ backend/internal/service/rock_bindcheck.go | 213 ++++ backend/internal/service/rock_bindlink.go | 77 ++ backend/internal/service/rock_client.go | 68 +- backend/internal/service/rock_recover.go | 127 +++ backend/internal/service/rock_redeem.go | 168 +++ backend/internal/service/rock_service.go | 974 +----------------- backend/internal/service/rock_snapshot.go | 116 +++ backend/internal/service/rock_stock.go | 177 ++++ 12 files changed, 1242 insertions(+), 982 deletions(-) create mode 100644 backend/internal/service/rock_assign.go create mode 100644 backend/internal/service/rock_baseurl_test.go create mode 100644 backend/internal/service/rock_bindcheck.go create mode 100644 backend/internal/service/rock_bindlink.go create mode 100644 backend/internal/service/rock_recover.go create mode 100644 backend/internal/service/rock_redeem.go create mode 100644 backend/internal/service/rock_snapshot.go create mode 100644 backend/internal/service/rock_stock.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c175762..aa78a09 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "affiliate_dash/internal/config" "affiliate_dash/internal/database" "affiliate_dash/internal/handler" + "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/lewan" @@ -102,9 +103,14 @@ func main() { lewanProvider := service.NewLewanProvider(lewanClient) deliverySvc.RegisterProvider(lewanProvider) - // Rock 平台 (0666.pro) 客户端、服务与履约提供商注册 - rockClient := service.NewRockClient("https://0666.pro") + // Rock 平台客户端、服务与履约提供商注册。 + // 这里传的只是初始/兜底地址,真正的上游地址以 rock_configs.base_url 为准, + // 因此紧接着要同步一次,否则后台改过的地址重启后会失效。 + rockClient := service.NewRockClient(model.RockDefaultBaseURL) rockSvc := service.NewRockService(db, rockClient) + if err := rockSvc.ApplyConfigToClient(context.Background()); err != nil { + applog.Warnf("读取 Rock 平台配置失败,暂用默认上游地址 %s: %v", model.RockDefaultBaseURL, err) + } rockProvider := service.NewRockProvider(rockSvc) deliverySvc.RegisterProvider(rockProvider) diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index be22c5c..b02d905 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -6,6 +6,15 @@ import ( // Rock 相关常量 const ( + // RockDefaultBaseURL 是 Rock 上游平台的默认地址,只作为「初始值 / 兜底值」。 + // 运行期一律以 rock_configs.base_url 为准:启动时由 + // RockService.ApplyConfigToClient 同步到 HTTP 客户端,否则后台改过的地址 + // 会在进程重启后失效。 + // + // 注意:迁移文件 012_rock_fulfillment.sql 中的默认值受 checksum 保护, + // 不能为了统一而修改它,否则已有部署会因 checksum mismatch 启动失败。 + RockDefaultBaseURL = "https://0666.pro" + RockColorBlack = "black" RockColorBlue = "blue" @@ -26,8 +35,8 @@ const ( // RockConfig 平台配置项 type RockConfig struct { ID uint `gorm:"primaryKey;default:1" json:"id"` - AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关 - BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` + AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关 + BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"` Username string `gorm:"size:128;not null;default:''" json:"username"` Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"` diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go new file mode 100644 index 0000000..5f4fa5a --- /dev/null +++ b/backend/internal/service/rock_assign.go @@ -0,0 +1,158 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// BindingSnapshot 保存分配 Token 时上游的绑定快照 +type BindingSnapshot struct { + Bound bool `json:"bound"` + RoleName string `json:"role_name"` + RoleID string `json:"role_id"` + Channel string `json:"channel"` + BindChangedAt string `json:"bind_changed_at"` +} + +// rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。 +const rockOrderTokenLockedMessage = "该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理" + +// AssignTokenForOrder 为指定订单锁定分配一个可用 Token。 +// 同一个订单号无论怎么更新 UID,都只能占用一个提货号:using/bound 复用原号并改期望 UID; +// mismatch、已发货或异常则拒绝再分配,需客服人工重置后才能重新领取。 +func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, nil, err + } + if !cfg.AutoDeliveryEnabled { + return nil, nil, fmt.Errorf("Rock 自动发货通道已维护关闭") + } + + var token model.RockToken + + // 1. 该订单已经占用过提货号时,禁止再领新号。 + err = s.db.WithContext(ctx). + Where("assigned_order_no = ? AND status IN ?", orderNo, []string{ + model.RockStatusUsing, + model.RockStatusBound, + model.RockStatusMismatch, + model.RockStatusRedeemed, + model.RockStatusAbnormal, + }). + Order("id DESC"). + First(&token).Error + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, err + } + + if err == nil { + switch token.Status { + case model.RockStatusAbnormal: + return nil, nil, errors.New(rockOrderTokenLockedMessage) + case model.RockStatusRedeemed: + return nil, nil, errors.New("该订单已发货,不能再次领取提货号") + case model.RockStatusMismatch: + trimmedUID := strings.TrimSpace(expectedUID) + actualUID := strings.TrimSpace(token.ActualRoleID) + // 方案 B:买家确认纠正为当前扫码角色的 UID + if trimmedUID != "" && actualUID != "" && trimmedUID == actualUID { + now := time.Now() + token.ExpectedUID = trimmedUID + token.Status = model.RockStatusBound + token.MismatchReason = "" + token.MismatchAt = nil + token.BoundAt = &now + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "expected_uid": trimmedUID, + "status": model.RockStatusBound, + "mismatch_reason": "", + "mismatch_at": nil, + "bound_at": now, + "updated_at": now, + }).Error + log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName) + } else { + roleDesc := token.ActualRoleName + if roleDesc == "" { + roleDesc = actualUID + } + if actualUID != "" { + return nil, nil, fmt.Errorf("该订单提货链接已扫码绑定角色「%s」(%s)。如确系本人账号,请确认将填写 UID 修改为 %s;不能更换为其他账号,如需更换请联系客服处理", roleDesc, actualUID, actualUID) + } + return nil, nil, errors.New(rockOrderTokenLockedMessage) + } + default: + trimmedUID := strings.TrimSpace(expectedUID) + if token.Status == model.RockStatusBound && trimmedUID != "" && token.ExpectedUID != trimmedUID { + return nil, nil, errors.New("该订单提货号已绑定,不能更换 UID,请联系客服处理") + } + if trimmedUID != "" && token.ExpectedUID != trimmedUID { + token.ExpectedUID = trimmedUID + _ = s.db.WithContext(ctx).Model(&token).Update("expected_uid", trimmedUID).Error + } + } + } + + // 2. 若尚未分配,锁定一个可用 Token (行锁防并发超卖) + if errors.Is(err, gorm.ErrRecordNotFound) { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). + Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). + Order("id ASC"). + First(&token).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("Rock平台库存不足,请联系管理员补货") + } + return err + } + + token.Status = model.RockStatusUsing + token.AssignedOrderNo = orderNo + token.ExpectedUID = strings.TrimSpace(expectedUID) + return tx.Save(&token).Error + }) + if err != nil { + return nil, nil, err + } + + // 检查水位触发异步自动补货 + go s.CheckStockAndAutoPurchase(context.Background()) + } + + // 3. 调上游获取绑定活动链接 + detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) + if err != nil { + return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err) + } + s.rememberBindLink(token.ID, detail.BindURL, detail.QRURL) + + // 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据) + if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" { + ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if refErr == nil && ref != nil { + snap := BindingSnapshot{ + Bound: ref.IsBound(), + RoleName: ref.GetRoleName(), + RoleID: ref.GetRoleID(), + Channel: ref.GetChannel(), + BindChangedAt: ref.Token.BindChangedAt, + } + snapBytes, _ := json.Marshal(snap) + token.InitialBoundSnapshot = string(snapBytes) + _ = s.db.WithContext(ctx).Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error + } + } + + return &token, detail, nil +} diff --git a/backend/internal/service/rock_baseurl_test.go b/backend/internal/service/rock_baseurl_test.go new file mode 100644 index 0000000..7b08c19 --- /dev/null +++ b/backend/internal/service/rock_baseurl_test.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "affiliate_dash/internal/model" +) + +// TestRockServiceBaseURLFromConfig 锁定「后台配置的上游地址必须真正生效,并且能跨进程重启」。 +// +// 回归背景:main.go 曾把 baseURL 硬编码进 NewRockClient,而 GetConfig 从 +// 不把 rock_configs.base_url 回写到客户端。结果是后台改了地址在保存那一刻生效, +// 进程一重启就悄悄退回 0666.pro。 +func TestRockServiceBaseURLFromConfig(t *testing.T) { + db := newRockTestDB(t) + ctx := context.Background() + + client := NewRockClient(model.RockDefaultBaseURL) + svc := NewRockService(db, client) + + // 首次读取会初始化配置,默认地址应与常量一致。 + cfg, err := svc.GetConfig(ctx) + if err != nil { + t.Fatalf("读取 Rock 配置失败: %v", err) + } + if cfg.BaseURL != model.RockDefaultBaseURL { + t.Fatalf("初始 base_url 期望 %s,实际 %s", model.RockDefaultBaseURL, cfg.BaseURL) + } + + // 后台改地址后必须立即生效,不需要重启。 + custom := "https://rock-mirror.example.com" + if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{BaseURL: &custom}); err != nil { + t.Fatalf("更新 Rock 配置失败: %v", err) + } + if got := client.BaseURL(); got != custom { + t.Fatalf("UpdateConfig 未立即生效:期望 %s,实际 %s", custom, got) + } + + // 模拟进程重启:新的客户端 + 新的 service,读同一个数据库。 + restarted := NewRockClient(model.RockDefaultBaseURL) + restartedSvc := NewRockService(db, restarted) + if got := restarted.BaseURL(); got != model.RockDefaultBaseURL { + t.Fatalf("同步前应保持构造时传入的地址,实际 %s", got) + } + if err := restartedSvc.ApplyConfigToClient(ctx); err != nil { + t.Fatalf("启动同步 Rock 配置失败: %v", err) + } + if got := restarted.BaseURL(); got != custom { + t.Fatalf("重启后未采用数据库中的上游地址:期望 %s,实际 %s", custom, got) + } +} + +func TestRockClientSetBaseURL(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"空值回落到默认地址", "", model.RockDefaultBaseURL}, + {"纯空白回落到默认地址", " ", model.RockDefaultBaseURL}, + {"裁剪末尾斜杠", "https://a.example.com/", "https://a.example.com"}, + {"裁剪多余空白与斜杠", " https://b.example.com// ", "https://b.example.com"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := NewRockClient(tc.input) + if got := client.BaseURL(); got != tc.want { + t.Fatalf("期望 %q,实际 %q", tc.want, got) + } + }) + } +} + +// TestRockClientOriginHeaderFollowsBaseURL 确认 Origin / Referer 跟随配置地址, +// 而不是始终写死上游域名。 +func TestRockClientOriginHeaderFollowsBaseURL(t *testing.T) { + var gotOrigin, gotReferer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotOrigin = r.Header.Get("Origin") + gotReferer = r.Header.Get("Referer") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tokens":[{"id":1,"redeem_code":"ABC"}]}`)) + })) + defer server.Close() + + client := NewRockClient(server.URL) + if _, err := client.ClaimTokens(context.Background(), "bearer", model.RockColorBlack, 1); err != nil { + t.Fatalf("claim 失败: %v", err) + } + + if gotOrigin != server.URL { + t.Fatalf("Origin 期望 %s,实际 %s", server.URL, gotOrigin) + } + if want := server.URL + "/"; gotReferer != want { + t.Fatalf("Referer 期望 %s,实际 %s", want, gotReferer) + } +} + +// TestRockClientBaseURLConcurrentAccess 在 -race 下验证读写 baseURL 无数据竞争。 +func TestRockClientBaseURLConcurrentAccess(t *testing.T) { + client := NewRockClient(model.RockDefaultBaseURL) + done := make(chan struct{}) + + go func() { + defer close(done) + for i := 0; i < 200; i++ { + client.SetBaseURL("https://rock-mirror.example.com") + client.SetBaseURL(model.RockDefaultBaseURL) + } + }() + + for i := 0; i < 200; i++ { + if got := client.BaseURL(); got == "" { + t.Error("BaseURL 不应为空") + break + } + } + <-done +} diff --git a/backend/internal/service/rock_bindcheck.go b/backend/internal/service/rock_bindcheck.go new file mode 100644 index 0000000..03c0fa9 --- /dev/null +++ b/backend/internal/service/rock_bindcheck.go @@ -0,0 +1,213 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "affiliate_dash/internal/model" +) + +type CheckBindingResult struct { + Bound bool `json:"bound"` + RoleName string `json:"role_name"` + RoleID string `json:"role_id"` + Channel string `json:"channel"` + ExpectedUID string `json:"expected_uid"` + Mismatch bool `json:"mismatch"` + Message string `json:"message"` + UpstreamFulfillID int64 `json:"upstream_fulfill_id,omitempty"` +} + +// CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁 +func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, orderNo, expectedUID string) (*CheckBindingResult, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + var token model.RockToken + if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { + return nil, err + } + + // 若已经是 mismatch 状态,检查传入的 expectedUID 是否已纠正为扫码角色的 UID + if token.Status == model.RockStatusMismatch { + trimmedExpected := strings.TrimSpace(expectedUID) + if trimmedExpected == "" { + trimmedExpected = strings.TrimSpace(token.ExpectedUID) + } + actualUID := strings.TrimSpace(token.ActualRoleID) + if trimmedExpected != "" && actualUID != "" && trimmedExpected == actualUID { + now := time.Now() + token.Status = model.RockStatusBound + token.ExpectedUID = trimmedExpected + token.MismatchReason = "" + token.MismatchAt = nil + token.BoundAt = &now + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "expected_uid": trimmedExpected, + "status": model.RockStatusBound, + "mismatch_reason": "", + "mismatch_at": nil, + "bound_at": now, + "updated_at": now, + }).Error + return &CheckBindingResult{ + Bound: true, + RoleName: token.ActualRoleName, + RoleID: actualUID, + Channel: token.ActualChannel, + ExpectedUID: trimmedExpected, + Mismatch: false, + Message: "扫码授权成功,UID核验一致", + }, nil + } + + roleDesc := token.ActualRoleName + if roleDesc == "" { + roleDesc = actualUID + } + hintMsg := fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualUID, trimmedExpected, actualUID) + return &CheckBindingResult{ + Bound: false, + Mismatch: true, + RoleName: token.ActualRoleName, + RoleID: token.ActualRoleID, + Channel: token.ActualChannel, + ExpectedUID: token.ExpectedUID, + Message: hintMsg, + }, nil + } + + // 已释放回库存、或已改派给其他订单的链接,不能再被旧发货页轮询改写。 + if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound { + return &CheckBindingResult{ + Bound: false, + Message: "该提货链接已释放回库存,请重新获取二维码", + }, nil + } + if strings.TrimSpace(orderNo) != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != strings.TrimSpace(orderNo) { + return &CheckBindingResult{ + Bound: false, + Message: "该提货链接已不属于当前订单", + }, nil + } + + // 调上游 refresh 接口(带 800ms 瞬时防抖缓存,避免自动轮询与手动高频点击撞上上游 110001 频控) + var ref *RockRefreshResponse + cacheVal, hasCache := s.refreshCache.Load(token.UpstreamTokenID) + if hasCache { + item := cacheVal.(rockRefreshCacheItem) + if time.Since(item.cachedAt) < 800*time.Millisecond { + ref = item.response + } + } + + if ref == nil { + upstreamRef, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if err != nil { + // 若上游返回 110001 "请求过于频繁",进行平滑降级处理,绝不报致命错误打断买家 + if strings.Contains(err.Error(), "过于频繁") || strings.Contains(err.Error(), "110001") { + if hasCache { + ref = cacheVal.(rockRefreshCacheItem).response + } else if token.Status == model.RockStatusBound { + return &CheckBindingResult{ + Bound: true, + RoleName: token.ActualRoleName, + RoleID: token.ActualRoleID, + Channel: token.ActualChannel, + ExpectedUID: token.ExpectedUID, + Message: "扫码授权成功,UID核验一致", + }, nil + } else { + return &CheckBindingResult{ + Bound: false, + Message: "正在同步微信/QQ扫码状态,请稍后再次检查", + }, nil + } + } else { + return nil, fmt.Errorf("刷新绑定状态失败: %w", err) + } + } else { + ref = upstreamRef + s.refreshCache.Store(token.UpstreamTokenID, rockRefreshCacheItem{ + response: ref, + cachedAt: time.Now(), + }) + } + } + + // 1. 检查上游是否已绑定 (上游状态 bound 或 bound_account_id 有值) + if !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" { + return &CheckBindingResult{ + Bound: false, + Message: "等待买家微信/QQ扫码授权", + }, nil + } + + actualRoleName := ref.GetRoleName() + actualRoleID := ref.GetRoleID() + actualChannel := ref.GetChannel() + + // 2. 历史残留不算本次扫码:角色和绑定时间与分配快照一致,或发货记录都早于本次领取。 + if !rockBuyerNewlyBound(&token, ref) { + return &CheckBindingResult{ + Bound: false, + Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)", + }, nil + } + + // 3. 核心门禁:UID 强一致校验 + trimmedExpected := strings.TrimSpace(expectedUID) + if trimmedExpected == "" { + trimmedExpected = strings.TrimSpace(token.ExpectedUID) + } + + now := time.Now() + if trimmedExpected != "" && actualRoleID != trimmedExpected { + // 严禁发货!跃迁为 mismatch + mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, actualRoleName, actualRoleID) + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusMismatch, + "actual_role_name": actualRoleName, + "actual_role_id": actualRoleID, + "actual_channel": actualChannel, + "mismatch_at": now, + "mismatch_reason": mismatchReason, + }).Error + + roleDesc := actualRoleName + if roleDesc == "" { + roleDesc = actualRoleID + } + return &CheckBindingResult{ + Bound: false, + Mismatch: true, + RoleName: actualRoleName, + RoleID: actualRoleID, + Channel: actualChannel, + ExpectedUID: trimmedExpected, + Message: fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualRoleID, trimmedExpected, actualRoleID), + }, nil + } + + // 4. UID 一致,更新为 bound 成功状态并保存玩家角色名称 + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusBound, + "actual_role_name": actualRoleName, + "actual_role_id": actualRoleID, + "actual_channel": actualChannel, + "bound_at": now, + }).Error + + return &CheckBindingResult{ + Bound: true, + RoleName: actualRoleName, + RoleID: actualRoleID, + Channel: actualChannel, + ExpectedUID: trimmedExpected, + Message: "扫码授权成功,UID核验一致", + }, nil +} diff --git a/backend/internal/service/rock_bindlink.go b/backend/internal/service/rock_bindlink.go new file mode 100644 index 0000000..fe38478 --- /dev/null +++ b/backend/internal/service/rock_bindlink.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "strings" + "time" + + "affiliate_dash/internal/model" +) + +type rockRefreshCacheItem struct { + response *RockRefreshResponse + cachedAt time.Time +} + +// 腾讯活动链接按生成时间签名。同一提货号过期后重取链接,不另发新号。 +const rockBindLinkMaxAge = 8 * time.Minute + +type rockBindLinkCacheItem struct { + bindURL string + qrURL string + cachedAt time.Time +} + +func (s *RockService) rememberBindLink(tokenID uint, bindURL, qrURL string) { + bindURL = strings.TrimSpace(bindURL) + if s == nil || tokenID == 0 || bindURL == "" { + return + } + qrURL = strings.TrimSpace(qrURL) + if qrURL == "" { + qrURL = qrCodeURL(bindURL) + } + s.bindLinkCache.Store(tokenID, rockBindLinkCacheItem{ + bindURL: bindURL, + qrURL: qrURL, + cachedAt: time.Now(), + }) +} + +// freshBindLink 只为仍在扫码的同一提货号重取活动链接。过期前复用缓存,避免每轮轮询都打 0666。 +func (s *RockService) freshBindLink(ctx context.Context, tokenID uint, orderNo string) (string, string) { + if s == nil || s.client == nil || tokenID == 0 { + return "", "" + } + var token model.RockToken + if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { + return "", "" + } + if token.Status != model.RockStatusUsing || strings.TrimSpace(token.RedeemCode) == "" { + return "", "" + } + if orderNo = strings.TrimSpace(orderNo); orderNo != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != orderNo { + return "", "" + } + if item, ok := s.bindLinkCache.Load(token.ID); ok { + cached := item.(rockBindLinkCacheItem) + if cached.bindURL != "" && time.Since(cached.cachedAt) < rockBindLinkMaxAge { + return cached.bindURL, cached.qrURL + } + } + detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) + if err != nil || detail == nil || strings.TrimSpace(detail.BindURL) == "" { + if item, ok := s.bindLinkCache.Load(token.ID); ok { + cached := item.(rockBindLinkCacheItem) + return cached.bindURL, cached.qrURL + } + return "", "" + } + bindURL := strings.TrimSpace(detail.BindURL) + qrURL := strings.TrimSpace(detail.QRURL) + s.rememberBindLink(token.ID, bindURL, qrURL) + if qrURL == "" { + qrURL = qrCodeURL(bindURL) + } + return bindURL, qrURL +} diff --git a/backend/internal/service/rock_client.go b/backend/internal/service/rock_client.go index 2cd478a..4ea9f7d 100644 --- a/backend/internal/service/rock_client.go +++ b/backend/internal/service/rock_client.go @@ -10,7 +10,10 @@ import ( "net/url" "strconv" "strings" + "sync" "time" + + "affiliate_dash/internal/model" ) // FlexibleFloat64 兼容反序列化 JSON 中的数字或字符串(如 "8271.2") @@ -53,23 +56,48 @@ func (f FlexibleFloat64) Float64() float64 { return float64(f) } -// RockClient 负责与 0666.pro 平台进行 HTTP 通信 +// RockClient 负责与 Rock 上游平台进行 HTTP 通信。 +// +// baseURL 在运行期可被后台配置改动(见 RockService.UpdateConfig),而读取方 +// (HTTP 请求、库存巡检 goroutine)是并发的,因此必须加锁访问。 type RockClient struct { + mu sync.RWMutex baseURL string httpClient *http.Client } func NewRockClient(baseURL string) *RockClient { - base := strings.TrimRight(baseURL, "/") - if base == "" { - base = "https://0666.pro" - } - return &RockClient{ - baseURL: base, + c := &RockClient{ httpClient: &http.Client{ Timeout: 15 * time.Second, }, } + c.SetBaseURL(baseURL) + return c +} + +// BaseURL 返回当前生效的上游地址。 +func (c *RockClient) BaseURL() string { + if c == nil { + return "" + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.baseURL +} + +// SetBaseURL 更新上游地址;传入空值或只有空白时回落到内置默认地址。 +func (c *RockClient) SetBaseURL(baseURL string) { + if c == nil { + return + } + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if base == "" { + base = model.RockDefaultBaseURL + } + c.mu.Lock() + c.baseURL = base + c.mu.Unlock() } type RockMeResponse struct { @@ -223,7 +251,7 @@ type RockExchangeRecordsResponse struct { // GetMe 获取商户个人信息及积分 func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) { - reqURL := fmt.Sprintf("%s/api/me", c.baseURL) + reqURL := fmt.Sprintf("%s/api/me", c.BaseURL()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err @@ -264,7 +292,7 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color if count <= 0 { return nil, fmt.Errorf("提取数量必须大于 0") } - reqURL := fmt.Sprintf("%s/api/user/tokens/claim", c.baseURL) + reqURL := fmt.Sprintf("%s/api/user/tokens/claim", c.BaseURL()) payload := map[string]interface{}{ "color": color, "count": fmt.Sprintf("%d", count), @@ -278,8 +306,8 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color 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", "https://0666.pro") - req.Header.Set("Referer", "https://0666.pro/") + req.Header.Set("Origin", c.BaseURL()) + req.Header.Set("Referer", c.BaseURL()+"/") resp, err := c.httpClient.Do(req) if err != nil { @@ -308,7 +336,7 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color // GetRedeemDetail 根据 redeem_code 免登录获取腾讯官方绑定链接与状态 func (c *RockClient) GetRedeemDetail(ctx context.Context, redeemCode string) (*RockRedeemDetailResponse, error) { - reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(redeemCode)) + reqURL := fmt.Sprintf("%s/api/redeem/%s", c.BaseURL(), url.PathEscape(redeemCode)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err @@ -339,7 +367,7 @@ func (c *RockClient) GetRedeemDetail(ctx context.Context, redeemCode string) (*R // RefreshToken 刷新 Token 绑定状态并获取官方履约结果 func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstreamTokenID string) (*RockRefreshResponse, error) { - reqURL := fmt.Sprintf("%s/api/user/tokens/%s/refresh", c.baseURL, url.PathEscape(upstreamTokenID)) + reqURL := fmt.Sprintf("%s/api/user/tokens/%s/refresh", c.BaseURL(), url.PathEscape(upstreamTokenID)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) if err != nil { return nil, err @@ -348,8 +376,8 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) } req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Origin", "https://0666.pro") - req.Header.Set("Referer", "https://0666.pro/") + req.Header.Set("Origin", c.BaseURL()) + req.Header.Set("Referer", c.BaseURL()+"/") resp, err := c.httpClient.Do(req) if err != nil { @@ -375,7 +403,7 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr // RedeemSkin 执行最终皮肤兑换发货(需携带商户 Bearer Token 鉴权) func (c *RockClient) RedeemSkin(ctx context.Context, bearerToken, upstreamTokenID, skin, currency, account string) (*RockRedeemResponse, error) { - reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(upstreamTokenID)) + reqURL := fmt.Sprintf("%s/api/redeem/%s", c.BaseURL(), url.PathEscape(upstreamTokenID)) payload := map[string]interface{}{ "skin": skin, "currency": currency, @@ -392,8 +420,8 @@ func (c *RockClient) RedeemSkin(ctx context.Context, bearerToken, upstreamTokenI } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Origin", "https://0666.pro") - req.Header.Set("Referer", "https://0666.pro/") + req.Header.Set("Origin", c.BaseURL()) + req.Header.Set("Referer", c.BaseURL()+"/") resp, err := c.httpClient.Do(req) if err != nil { @@ -428,7 +456,7 @@ func (c *RockClient) QueryExchangeRecords(ctx context.Context, bearerToken strin if pageSize <= 0 { pageSize = 20 } - reqURL := fmt.Sprintf("%s/api/user/exchange-records?page=%d&pageSize=%d&search=%s", c.baseURL, page, pageSize, url.QueryEscape(search)) + reqURL := fmt.Sprintf("%s/api/user/exchange-records?page=%d&pageSize=%d&search=%s", c.BaseURL(), page, pageSize, url.QueryEscape(search)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err @@ -460,7 +488,7 @@ func (c *RockClient) QueryExchangeRecords(ctx context.Context, bearerToken strin // ReportAbnormal 上报异常 Token func (c *RockClient) ReportAbnormal(ctx context.Context, bearerToken, upstreamTokenID, reason string) error { - reqURL := fmt.Sprintf("%s/api/user/tokens/%s/report", c.baseURL, url.PathEscape(upstreamTokenID)) + reqURL := fmt.Sprintf("%s/api/user/tokens/%s/report", c.BaseURL(), url.PathEscape(upstreamTokenID)) payload := map[string]string{"reason": reason} jsonBytes, _ := json.Marshal(payload) diff --git a/backend/internal/service/rock_recover.go b/backend/internal/service/rock_recover.go new file mode 100644 index 0000000..c6216cf --- /dev/null +++ b/backend/internal/service/rock_recover.go @@ -0,0 +1,127 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CorrectMismatchExpectedUID 客服修改买家填写的 UID,提货号继续留在原订单上。 +// 改成当前扫码角色的 UID 时解除阻断并记为已绑定;改成其他 UID 只更新期望值,仍保持阻断。 +func (s *RockService) CorrectMismatchExpectedUID(ctx context.Context, tokenID uint, expectedUID string) (*model.RockToken, error) { + trimmedUID := strings.TrimSpace(expectedUID) + if trimmedUID == "" || !isDigits(trimmedUID) || len(trimmedUID) > 15 { + return nil, errors.New("请填写 15 位以内的纯数字 UID") + } + + var token model.RockToken + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("提货号不存在") + } + return err + } + if token.Status != model.RockStatusMismatch { + return errors.New("仅绑定错误的提货号可以修改买家 UID") + } + if strings.TrimSpace(token.AssignedOrderNo) == "" { + return errors.New("该提货号没有关联订单,不能修改 UID") + } + + now := time.Now() + actualUID := strings.TrimSpace(token.ActualRoleID) + updates := map[string]interface{}{ + "expected_uid": trimmedUID, + "updated_at": now, + } + if actualUID != "" && trimmedUID == actualUID { + updates["status"] = model.RockStatusBound + updates["mismatch_reason"] = "" + updates["mismatch_at"] = nil + updates["bound_at"] = now + token.Status = model.RockStatusBound + token.MismatchReason = "" + token.MismatchAt = nil + token.BoundAt = &now + } else if actualUID != "" { + updates["mismatch_reason"] = fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedUID, token.ActualRoleName, actualUID) + token.MismatchReason = updates["mismatch_reason"].(string) + } + if err := tx.Model(&token).Updates(updates).Error; err != nil { + return err + } + token.ExpectedUID = trimmedUID + + var order model.FulfillmentOrder + if err := tx.Where("order_no = ?", token.AssignedOrderNo).First(&order).Error; err == nil { + data := resultDataMap(order.ResultData) + data["expected_uid"] = trimmedUID + raw, marshalErr := json.Marshal(data) + if marshalErr != nil { + return marshalErr + } + if err := tx.Model(&order).Update("result_data", string(raw)).Error; err != nil { + return err + } + } + + return writeAudit(tx, nil, nil, nil, "rock_token.correct_expected_uid", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ + "assigned_order_no": token.AssignedOrderNo, + "expected_uid": trimmedUID, + "actual_role_id": actualUID, + "status": token.Status, + }) + }) + if err != nil { + return nil, err + } + return &token, nil +} + +func isDigits(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// ResetMismatchToken 客服人工重置解冻绑定错误的 Token +func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error { + var token model.RockToken + if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { + return err + } + + if token.Status != model.RockStatusMismatch { + return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置") + } + + s.refreshCache.Delete(token.UpstreamTokenID) + + // 重置回 available,清空订单关联 + return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + "actual_role_name": "", + "actual_role_id": "", + "actual_channel": "", + "initial_bound_snapshot": "", + "mismatch_at": nil, + "mismatch_reason": "", + "updated_at": time.Now(), + }).Error +} diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go new file mode 100644 index 0000000..3bf15c2 --- /dev/null +++ b/backend/internal/service/rock_redeem.go @@ -0,0 +1,168 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" +) + +// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤) +func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string) (*RockRedeemResponse, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + if !cfg.AutoDeliveryEnabled { + return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换") + } + + var token model.RockToken + err = s.db.WithContext(ctx). + Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound). + First(&token).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("未找到该订单已绑定的有效 Token,或当前状态不可发货") + } + return nil, err + } + + // 最终双重防御:核验 UID + trimmedExpected := strings.TrimSpace(expectedUID) + if trimmedExpected == "" { + trimmedExpected = strings.TrimSpace(token.ExpectedUID) + } + if trimmedExpected != "" && token.ActualRoleID != trimmedExpected { + return nil, fmt.Errorf("终审拦截:扫码UID(%s)与预期UID(%s)不符,禁止发货!", token.ActualRoleID, trimmedExpected) + } + + // 根据商品 SKU 动态决定兑换的皮肤名称 + targetSkin := model.RockSkinBlackTshirt + if sku == model.RockSKUBlueSportswear { + targetSkin = model.RockSkinBlueSportswear + } + + // 确保买家角色名存在 + actualRoleName := strings.TrimSpace(token.ActualRoleName) + if actualRoleName == "" { + if ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID); refErr == nil && ref != nil { + actualRoleName = ref.GetRoleName() + if actualRoleName != "" { + _ = s.db.WithContext(ctx).Model(&token).Update("actual_role_name", actualRoleName).Error + } + } + } + if actualRoleName == "" { + actualRoleName = token.ActualRoleID + } + + // 调上游兑换发货接口(携带 Bearer Token 鉴权) + redeemResp, err := s.client.RedeemSkin(ctx, cfg.BearerToken, token.UpstreamTokenID, targetSkin, "幸运币", actualRoleName) + if err != nil { + return nil, fmt.Errorf("调用上游兑换失败: %w", err) + } + + // 提取官方 fulfillId + var fulfillID int64 + now := time.Now() + ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if refErr == nil && len(ref.DeliveryList) > 0 { + fulfillID = ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID + } + + // 状态跃迁至 redeemed + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusRedeemed, + "upstream_fulfill_id": fulfillID, + "target_skin": targetSkin, + "target_currency": "幸运币", + "target_currency_quantity": 1, + "redeemed_at": now, + }).Error + + return redeemResp, nil +} + +// ReleaseUnboundUsingToken 把尚未扫码绑定的使用中链接释放回可用库存。 +// 已绑定或上游已出现新扫码时拒绝释放,避免退款后的旧二维码被当成新库存发出去。 +func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint) error { + var token model.RockToken + if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { + return err + } + if token.Status != model.RockStatusUsing { + return fmt.Errorf("仅扫码使用中且尚未绑定的链接可以释放回库存") + } + if token.BoundAt != nil || strings.TrimSpace(token.ActualRoleID) != "" { + return fmt.Errorf("该链接已扫码绑定,不能释放回库存") + } + + cfg, err := s.GetConfig(ctx) + if err != nil { + return err + } + if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" { + return fmt.Errorf("缺少上游凭证,无法确认是否已扫码绑定") + } + + ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if err != nil { + return fmt.Errorf("确认上游绑定状态失败: %w", err) + } + if rockBuyerNewlyBound(&token, ref) { + return fmt.Errorf("上游已扫码绑定角色 %s(%s),不能释放", ref.GetRoleName(), ref.GetRoleID()) + } + + s.refreshCache.Delete(token.UpstreamTokenID) + orderNo := token.AssignedOrderNo + if err := s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + "actual_role_name": "", + "actual_role_id": "", + "actual_channel": "", + "initial_bound_snapshot": "", + "mismatch_at": nil, + "mismatch_reason": "", + "bound_at": nil, + "updated_at": time.Now(), + }).Error; err != nil { + return err + } + + s.detachReleasedRockToken(ctx, orderNo, token.ID) + log.Printf("[RockService] 人工释放未绑定库存: token_id=%d order=%s", token.ID, orderNo) + return nil +} + +// detachReleasedRockToken 清掉原订单上指向这条链接的记录,避免旧发货页继续占用它。 +func (s *RockService) detachReleasedRockToken(ctx context.Context, orderNo string, tokenID uint) { + orderNo = strings.TrimSpace(orderNo) + if orderNo == "" || tokenID == 0 { + return + } + var order model.FulfillmentOrder + if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil { + return + } + if resultDataNumber(order.ResultData, "rock_token_id") != int64(tokenID) { + return + } + raw := resultDataMap(order.ResultData) + delete(raw, "rock_token_id") + delete(raw, "rock_redeem_code") + raw["rock_token_released"] = true + encoded, err := json.Marshal(raw) + if err != nil { + return + } + _ = s.db.WithContext(ctx).Model(&order).Update("result_data", string(encoded)).Error +} diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index ba3cfba..61887e7 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -2,33 +2,16 @@ package service import ( "context" - "encoding/json" "errors" "fmt" - "log" "strings" "sync" "time" "affiliate_dash/internal/model" "gorm.io/gorm" - "gorm.io/gorm/clause" ) -type rockRefreshCacheItem struct { - response *RockRefreshResponse - cachedAt time.Time -} - -// 腾讯活动链接按生成时间签名。同一提货号过期后重取链接,不另发新号。 -const rockBindLinkMaxAge = 8 * time.Minute - -type rockBindLinkCacheItem struct { - bindURL string - qrURL string - cachedAt time.Time -} - // RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机 type RockService struct { db *gorm.DB @@ -45,60 +28,6 @@ func NewRockService(db *gorm.DB, client *RockClient) *RockService { } } -func (s *RockService) rememberBindLink(tokenID uint, bindURL, qrURL string) { - bindURL = strings.TrimSpace(bindURL) - if s == nil || tokenID == 0 || bindURL == "" { - return - } - qrURL = strings.TrimSpace(qrURL) - if qrURL == "" { - qrURL = qrCodeURL(bindURL) - } - s.bindLinkCache.Store(tokenID, rockBindLinkCacheItem{ - bindURL: bindURL, - qrURL: qrURL, - cachedAt: time.Now(), - }) -} - -// freshBindLink 只为仍在扫码的同一提货号重取活动链接。过期前复用缓存,避免每轮轮询都打 0666。 -func (s *RockService) freshBindLink(ctx context.Context, tokenID uint, orderNo string) (string, string) { - if s == nil || s.client == nil || tokenID == 0 { - return "", "" - } - var token model.RockToken - if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { - return "", "" - } - if token.Status != model.RockStatusUsing || strings.TrimSpace(token.RedeemCode) == "" { - return "", "" - } - if orderNo = strings.TrimSpace(orderNo); orderNo != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != orderNo { - return "", "" - } - if item, ok := s.bindLinkCache.Load(token.ID); ok { - cached := item.(rockBindLinkCacheItem) - if cached.bindURL != "" && time.Since(cached.cachedAt) < rockBindLinkMaxAge { - return cached.bindURL, cached.qrURL - } - } - detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) - if err != nil || detail == nil || strings.TrimSpace(detail.BindURL) == "" { - if item, ok := s.bindLinkCache.Load(token.ID); ok { - cached := item.(rockBindLinkCacheItem) - return cached.bindURL, cached.qrURL - } - return "", "" - } - bindURL := strings.TrimSpace(detail.BindURL) - qrURL := strings.TrimSpace(detail.QRURL) - s.rememberBindLink(token.ID, bindURL, qrURL) - if qrURL == "" { - qrURL = qrCodeURL(bindURL) - } - return bindURL, qrURL -} - // RockStatsResponse 看板与库存统计返回 type RockStatsResponse struct { AvailableStock int64 `json:"available_stock"` @@ -134,7 +63,7 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) cfg = model.RockConfig{ ID: 1, AutoDeliveryEnabled: true, - BaseURL: "https://0666.pro", + BaseURL: model.RockDefaultBaseURL, BearerToken: "", MinPointsWarning: 1050.00, TokenWarningStock: 10, @@ -153,6 +82,19 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) return &cfg, nil } +// ApplyConfigToClient 把数据库中的上游地址同步到 HTTP 客户端。 +// +// 必须在进程启动时调用一次:否则重启后客户端会回落到内置默认地址, +// 后台改过的 base_url 就会表现为「保存时生效、重启即失效」。 +func (s *RockService) ApplyConfigToClient(ctx context.Context) error { + cfg, err := s.GetConfig(ctx) + if err != nil { + return err + } + s.client.SetBaseURL(cfg.BaseURL) + return nil +} + // UpdateConfig 更新系统配置 func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq) (*model.RockConfig, error) { cfg, err := s.GetConfig(ctx) @@ -166,7 +108,8 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq) } if req.BaseURL != nil && strings.TrimSpace(*req.BaseURL) != "" { updates["base_url"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/") - s.client.baseURL = updates["base_url"].(string) + // 立即生效,避免还要等下一次重启或配置读取;客户端内部已加锁。 + s.client.SetBaseURL(updates["base_url"].(string)) } if req.BearerToken != nil { updates["bearer_token"] = strings.TrimSpace(*req.BearerToken) @@ -276,888 +219,3 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) return &stats, nil } - -// PurchaseTokens 执行采购并批量入库(通用黑色 Token) -func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) { - s.mu.Lock() - defer s.mu.Unlock() - - cfg, err := s.GetConfig(ctx) - if err != nil { - return nil, err - } - - if cfg.BearerToken == "" { - return nil, fmt.Errorf("尚未配置上游 Bearer Token,无法执行采购") - } - - // 自动补货防抖冷却检查 - now := time.Now() - if isAuto && cfg.LastPurchasedAt != nil { - cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute - if now.Sub(*cfg.LastPurchasedAt) < cooldown { - return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes) - } - } - - // 调上游 Claim 接口(统一下单黑色通用 Token) - tokens, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count) - if err != nil { - return nil, fmt.Errorf("调用上游采购接口失败: %w", err) - } - - if len(tokens) == 0 { - return nil, fmt.Errorf("上游未返回任何有效 Token") - } - - var dbTokens []model.RockToken - for _, t := range tokens { - dbTokens = append(dbTokens, model.RockToken{ - UpstreamTokenID: fmt.Sprintf("%d", t.ID), - Token: t.Token, - RedeemCode: t.RedeemCode, - Color: model.RockColorBlack, - Region: t.Region, - Status: model.RockStatusAvailable, - ClaimedAt: &now, - }) - } - - // 事务写入库存表 - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&dbTokens).Error; err != nil { - return err - } - // 更新最后采购时间 - return tx.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Update("last_purchased_at", now).Error - }) - if err != nil { - return nil, fmt.Errorf("入库采购 Token 失败: %w", err) - } - - // 异步更新上游剩余积分快照 - go func() { - bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - me, meErr := s.client.GetMe(bgCtx, cfg.BearerToken) - if meErr == nil && me != nil { - _ = s.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", me.User.Points.Float64()).Error - } - }() - - return dbTokens, nil -} - -// CheckStockAndAutoPurchase 水位检查与自动采购触发 -func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { - cfg, err := s.GetConfig(ctx) - if err != nil { - return - } - - var count int64 - s.db.WithContext(ctx).Model(&model.RockToken{}). - Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). - Count(&count) - - if count <= int64(cfg.TokenWarningStock) { - log.Printf("[RockService] 可用库存预警: 当前仅存 %d 个 (预警阈值 %d)", count, cfg.TokenWarningStock) - if cfg.AutoPurchaseEnabled { - buyCount := cfg.AutoPurchaseCount - if buyCount <= 0 { - buyCount = 50 - } - log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount) - _, err := s.PurchaseTokens(ctx, buyCount, true) - if err != nil { - log.Printf("[RockService] 自动补货失败: %v", err) - } else { - log.Printf("[RockService] 自动补货成功,新增 %d 个 Token", buyCount) - } - } - } -} - -// BindingSnapshot 保存分配 Token 时上游的绑定快照 -type BindingSnapshot struct { - Bound bool `json:"bound"` - RoleName string `json:"role_name"` - RoleID string `json:"role_id"` - Channel string `json:"channel"` - BindChangedAt string `json:"bind_changed_at"` -} - -// rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。 -const rockOrderTokenLockedMessage = "该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理" - -// AssignTokenForOrder 为指定订单锁定分配一个可用 Token。 -// 同一个订单号无论怎么更新 UID,都只能占用一个提货号:using/bound 复用原号并改期望 UID; -// mismatch、已发货或异常则拒绝再分配,需客服人工重置后才能重新领取。 -func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) { - cfg, err := s.GetConfig(ctx) - if err != nil { - return nil, nil, err - } - if !cfg.AutoDeliveryEnabled { - return nil, nil, fmt.Errorf("Rock 自动发货通道已维护关闭") - } - - var token model.RockToken - - // 1. 该订单已经占用过提货号时,禁止再领新号。 - err = s.db.WithContext(ctx). - Where("assigned_order_no = ? AND status IN ?", orderNo, []string{ - model.RockStatusUsing, - model.RockStatusBound, - model.RockStatusMismatch, - model.RockStatusRedeemed, - model.RockStatusAbnormal, - }). - Order("id DESC"). - First(&token).Error - - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, err - } - - if err == nil { - switch token.Status { - case model.RockStatusAbnormal: - return nil, nil, errors.New(rockOrderTokenLockedMessage) - case model.RockStatusRedeemed: - return nil, nil, errors.New("该订单已发货,不能再次领取提货号") - case model.RockStatusMismatch: - trimmedUID := strings.TrimSpace(expectedUID) - actualUID := strings.TrimSpace(token.ActualRoleID) - // 方案 B:买家确认纠正为当前扫码角色的 UID - if trimmedUID != "" && actualUID != "" && trimmedUID == actualUID { - now := time.Now() - token.ExpectedUID = trimmedUID - token.Status = model.RockStatusBound - token.MismatchReason = "" - token.MismatchAt = nil - token.BoundAt = &now - _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "expected_uid": trimmedUID, - "status": model.RockStatusBound, - "mismatch_reason": "", - "mismatch_at": nil, - "bound_at": now, - "updated_at": now, - }).Error - log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName) - } else { - roleDesc := token.ActualRoleName - if roleDesc == "" { - roleDesc = actualUID - } - if actualUID != "" { - return nil, nil, fmt.Errorf("该订单提货链接已扫码绑定角色「%s」(%s)。如确系本人账号,请确认将填写 UID 修改为 %s;不能更换为其他账号,如需更换请联系客服处理", roleDesc, actualUID, actualUID) - } - return nil, nil, errors.New(rockOrderTokenLockedMessage) - } - default: - trimmedUID := strings.TrimSpace(expectedUID) - if token.Status == model.RockStatusBound && trimmedUID != "" && token.ExpectedUID != trimmedUID { - return nil, nil, errors.New("该订单提货号已绑定,不能更换 UID,请联系客服处理") - } - if trimmedUID != "" && token.ExpectedUID != trimmedUID { - token.ExpectedUID = trimmedUID - _ = s.db.WithContext(ctx).Model(&token).Update("expected_uid", trimmedUID).Error - } - } - } - - // 2. 若尚未分配,锁定一个可用 Token (行锁防并发超卖) - if errors.Is(err, gorm.ErrRecordNotFound) { - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). - Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). - Order("id ASC"). - First(&token).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("Rock平台库存不足,请联系管理员补货") - } - return err - } - - token.Status = model.RockStatusUsing - token.AssignedOrderNo = orderNo - token.ExpectedUID = strings.TrimSpace(expectedUID) - return tx.Save(&token).Error - }) - if err != nil { - return nil, nil, err - } - - // 检查水位触发异步自动补货 - go s.CheckStockAndAutoPurchase(context.Background()) - } - - // 3. 调上游获取绑定活动链接 - detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) - if err != nil { - return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err) - } - s.rememberBindLink(token.ID, detail.BindURL, detail.QRURL) - - // 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据) - if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" { - ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) - if refErr == nil && ref != nil { - snap := BindingSnapshot{ - Bound: ref.IsBound(), - RoleName: ref.GetRoleName(), - RoleID: ref.GetRoleID(), - Channel: ref.GetChannel(), - BindChangedAt: ref.Token.BindChangedAt, - } - snapBytes, _ := json.Marshal(snap) - token.InitialBoundSnapshot = string(snapBytes) - _ = s.db.WithContext(ctx).Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error - } - } - - return &token, detail, nil -} - -type CheckBindingResult struct { - Bound bool `json:"bound"` - RoleName string `json:"role_name"` - RoleID string `json:"role_id"` - Channel string `json:"channel"` - ExpectedUID string `json:"expected_uid"` - Mismatch bool `json:"mismatch"` - Message string `json:"message"` - UpstreamFulfillID int64 `json:"upstream_fulfill_id,omitempty"` -} - -// CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁 -func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, orderNo, expectedUID string) (*CheckBindingResult, error) { - cfg, err := s.GetConfig(ctx) - if err != nil { - return nil, err - } - - var token model.RockToken - if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { - return nil, err - } - - // 若已经是 mismatch 状态,检查传入的 expectedUID 是否已纠正为扫码角色的 UID - if token.Status == model.RockStatusMismatch { - trimmedExpected := strings.TrimSpace(expectedUID) - if trimmedExpected == "" { - trimmedExpected = strings.TrimSpace(token.ExpectedUID) - } - actualUID := strings.TrimSpace(token.ActualRoleID) - if trimmedExpected != "" && actualUID != "" && trimmedExpected == actualUID { - now := time.Now() - token.Status = model.RockStatusBound - token.ExpectedUID = trimmedExpected - token.MismatchReason = "" - token.MismatchAt = nil - token.BoundAt = &now - _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "expected_uid": trimmedExpected, - "status": model.RockStatusBound, - "mismatch_reason": "", - "mismatch_at": nil, - "bound_at": now, - "updated_at": now, - }).Error - return &CheckBindingResult{ - Bound: true, - RoleName: token.ActualRoleName, - RoleID: actualUID, - Channel: token.ActualChannel, - ExpectedUID: trimmedExpected, - Mismatch: false, - Message: "扫码授权成功,UID核验一致", - }, nil - } - - roleDesc := token.ActualRoleName - if roleDesc == "" { - roleDesc = actualUID - } - hintMsg := fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualUID, trimmedExpected, actualUID) - return &CheckBindingResult{ - Bound: false, - Mismatch: true, - RoleName: token.ActualRoleName, - RoleID: token.ActualRoleID, - Channel: token.ActualChannel, - ExpectedUID: token.ExpectedUID, - Message: hintMsg, - }, nil - } - - // 已释放回库存、或已改派给其他订单的链接,不能再被旧发货页轮询改写。 - if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound { - return &CheckBindingResult{ - Bound: false, - Message: "该提货链接已释放回库存,请重新获取二维码", - }, nil - } - if strings.TrimSpace(orderNo) != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != strings.TrimSpace(orderNo) { - return &CheckBindingResult{ - Bound: false, - Message: "该提货链接已不属于当前订单", - }, nil - } - - // 调上游 refresh 接口(带 800ms 瞬时防抖缓存,避免自动轮询与手动高频点击撞上上游 110001 频控) - var ref *RockRefreshResponse - cacheVal, hasCache := s.refreshCache.Load(token.UpstreamTokenID) - if hasCache { - item := cacheVal.(rockRefreshCacheItem) - if time.Since(item.cachedAt) < 800*time.Millisecond { - ref = item.response - } - } - - if ref == nil { - upstreamRef, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) - if err != nil { - // 若上游返回 110001 "请求过于频繁",进行平滑降级处理,绝不报致命错误打断买家 - if strings.Contains(err.Error(), "过于频繁") || strings.Contains(err.Error(), "110001") { - if hasCache { - ref = cacheVal.(rockRefreshCacheItem).response - } else if token.Status == model.RockStatusBound { - return &CheckBindingResult{ - Bound: true, - RoleName: token.ActualRoleName, - RoleID: token.ActualRoleID, - Channel: token.ActualChannel, - ExpectedUID: token.ExpectedUID, - Message: "扫码授权成功,UID核验一致", - }, nil - } else { - return &CheckBindingResult{ - Bound: false, - Message: "正在同步微信/QQ扫码状态,请稍后再次检查", - }, nil - } - } else { - return nil, fmt.Errorf("刷新绑定状态失败: %w", err) - } - } else { - ref = upstreamRef - s.refreshCache.Store(token.UpstreamTokenID, rockRefreshCacheItem{ - response: ref, - cachedAt: time.Now(), - }) - } - } - - // 1. 检查上游是否已绑定 (上游状态 bound 或 bound_account_id 有值) - if !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" { - return &CheckBindingResult{ - Bound: false, - Message: "等待买家微信/QQ扫码授权", - }, nil - } - - actualRoleName := ref.GetRoleName() - actualRoleID := ref.GetRoleID() - actualChannel := ref.GetChannel() - - // 2. 历史残留不算本次扫码:角色和绑定时间与分配快照一致,或发货记录都早于本次领取。 - if !rockBuyerNewlyBound(&token, ref) { - return &CheckBindingResult{ - Bound: false, - Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)", - }, nil - } - - // 3. 核心门禁:UID 强一致校验 - trimmedExpected := strings.TrimSpace(expectedUID) - if trimmedExpected == "" { - trimmedExpected = strings.TrimSpace(token.ExpectedUID) - } - - now := time.Now() - if trimmedExpected != "" && actualRoleID != trimmedExpected { - // 严禁发货!跃迁为 mismatch - mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, actualRoleName, actualRoleID) - _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusMismatch, - "actual_role_name": actualRoleName, - "actual_role_id": actualRoleID, - "actual_channel": actualChannel, - "mismatch_at": now, - "mismatch_reason": mismatchReason, - }).Error - - roleDesc := actualRoleName - if roleDesc == "" { - roleDesc = actualRoleID - } - return &CheckBindingResult{ - Bound: false, - Mismatch: true, - RoleName: actualRoleName, - RoleID: actualRoleID, - Channel: actualChannel, - ExpectedUID: trimmedExpected, - Message: fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualRoleID, trimmedExpected, actualRoleID), - }, nil - } - - // 4. UID 一致,更新为 bound 成功状态并保存玩家角色名称 - _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusBound, - "actual_role_name": actualRoleName, - "actual_role_id": actualRoleID, - "actual_channel": actualChannel, - "bound_at": now, - }).Error - - return &CheckBindingResult{ - Bound: true, - RoleName: actualRoleName, - RoleID: actualRoleID, - Channel: actualChannel, - ExpectedUID: trimmedExpected, - Message: "扫码授权成功,UID核验一致", - }, nil -} - -// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤) -func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string) (*RockRedeemResponse, error) { - cfg, err := s.GetConfig(ctx) - if err != nil { - return nil, err - } - if !cfg.AutoDeliveryEnabled { - return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换") - } - - var token model.RockToken - err = s.db.WithContext(ctx). - Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound). - First(&token).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("未找到该订单已绑定的有效 Token,或当前状态不可发货") - } - return nil, err - } - - // 最终双重防御:核验 UID - trimmedExpected := strings.TrimSpace(expectedUID) - if trimmedExpected == "" { - trimmedExpected = strings.TrimSpace(token.ExpectedUID) - } - if trimmedExpected != "" && token.ActualRoleID != trimmedExpected { - return nil, fmt.Errorf("终审拦截:扫码UID(%s)与预期UID(%s)不符,禁止发货!", token.ActualRoleID, trimmedExpected) - } - - // 根据商品 SKU 动态决定兑换的皮肤名称 - targetSkin := model.RockSkinBlackTshirt - if sku == model.RockSKUBlueSportswear { - targetSkin = model.RockSkinBlueSportswear - } - - // 确保买家角色名存在 - actualRoleName := strings.TrimSpace(token.ActualRoleName) - if actualRoleName == "" { - if ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID); refErr == nil && ref != nil { - actualRoleName = ref.GetRoleName() - if actualRoleName != "" { - _ = s.db.WithContext(ctx).Model(&token).Update("actual_role_name", actualRoleName).Error - } - } - } - if actualRoleName == "" { - actualRoleName = token.ActualRoleID - } - - // 调上游兑换发货接口(携带 Bearer Token 鉴权) - redeemResp, err := s.client.RedeemSkin(ctx, cfg.BearerToken, token.UpstreamTokenID, targetSkin, "幸运币", actualRoleName) - if err != nil { - return nil, fmt.Errorf("调用上游兑换失败: %w", err) - } - - // 提取官方 fulfillId - var fulfillID int64 - now := time.Now() - ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) - if refErr == nil && len(ref.DeliveryList) > 0 { - fulfillID = ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID - } - - // 状态跃迁至 redeemed - _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusRedeemed, - "upstream_fulfill_id": fulfillID, - "target_skin": targetSkin, - "target_currency": "幸运币", - "target_currency_quantity": 1, - "redeemed_at": now, - }).Error - - return redeemResp, nil -} - -// ListTokens 分页查询 Token 列表 -func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) { - if page <= 0 { - page = 1 - } - if pageSize <= 0 { - pageSize = 20 - } - - db := s.db.WithContext(ctx).Model(&model.RockToken{}) - if strings.TrimSpace(status) != "" { - db = db.Where("status = ?", strings.TrimSpace(status)) - } - if strings.TrimSpace(search) != "" { - term := "%" + strings.TrimSpace(search) + "%" - db = db.Where("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 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) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - - var tokens []model.RockToken - err := db.Order("id DESC"). - Offset((page - 1) * pageSize). - Limit(pageSize). - Find(&tokens).Error - if err != nil { - return nil, 0, err - } - - // 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo) - orderNos := make([]string, 0, len(tokens)) - for _, t := range tokens { - if t.AssignedOrderNo != "" { - orderNos = append(orderNos, t.AssignedOrderNo) - } - } - if len(orderNos) > 0 { - var orders []struct { - OrderNo string `gorm:"column:order_no"` - ClientOrderNo string `gorm:"column:client_order_no"` - } - if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders"). - Select("order_no, client_order_no"). - Where("order_no IN ?", orderNos). - Scan(&orders).Error; scanErr == nil { - clientMap := make(map[string]string, len(orders)) - for _, o := range orders { - clientMap[o.OrderNo] = o.ClientOrderNo - } - for i := range tokens { - if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" { - tokens[i].ClientOrderNo = cNo - } - } - } - } - - return tokens, total, nil -} - -// CorrectMismatchExpectedUID 客服修改买家填写的 UID,提货号继续留在原订单上。 -// 改成当前扫码角色的 UID 时解除阻断并记为已绑定;改成其他 UID 只更新期望值,仍保持阻断。 -func (s *RockService) CorrectMismatchExpectedUID(ctx context.Context, tokenID uint, expectedUID string) (*model.RockToken, error) { - trimmedUID := strings.TrimSpace(expectedUID) - if trimmedUID == "" || !isDigits(trimmedUID) || len(trimmedUID) > 15 { - return nil, errors.New("请填写 15 位以内的纯数字 UID") - } - - var token model.RockToken - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("提货号不存在") - } - return err - } - if token.Status != model.RockStatusMismatch { - return errors.New("仅绑定错误的提货号可以修改买家 UID") - } - if strings.TrimSpace(token.AssignedOrderNo) == "" { - return errors.New("该提货号没有关联订单,不能修改 UID") - } - - now := time.Now() - actualUID := strings.TrimSpace(token.ActualRoleID) - updates := map[string]interface{}{ - "expected_uid": trimmedUID, - "updated_at": now, - } - if actualUID != "" && trimmedUID == actualUID { - updates["status"] = model.RockStatusBound - updates["mismatch_reason"] = "" - updates["mismatch_at"] = nil - updates["bound_at"] = now - token.Status = model.RockStatusBound - token.MismatchReason = "" - token.MismatchAt = nil - token.BoundAt = &now - } else if actualUID != "" { - updates["mismatch_reason"] = fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedUID, token.ActualRoleName, actualUID) - token.MismatchReason = updates["mismatch_reason"].(string) - } - if err := tx.Model(&token).Updates(updates).Error; err != nil { - return err - } - token.ExpectedUID = trimmedUID - - var order model.FulfillmentOrder - if err := tx.Where("order_no = ?", token.AssignedOrderNo).First(&order).Error; err == nil { - data := resultDataMap(order.ResultData) - data["expected_uid"] = trimmedUID - raw, marshalErr := json.Marshal(data) - if marshalErr != nil { - return marshalErr - } - if err := tx.Model(&order).Update("result_data", string(raw)).Error; err != nil { - return err - } - } - - return writeAudit(tx, nil, nil, nil, "rock_token.correct_expected_uid", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ - "assigned_order_no": token.AssignedOrderNo, - "expected_uid": trimmedUID, - "actual_role_id": actualUID, - "status": token.Status, - }) - }) - if err != nil { - return nil, err - } - return &token, nil -} - -func isDigits(value string) bool { - if value == "" { - return false - } - for _, r := range value { - if r < '0' || r > '9' { - return false - } - } - return true -} - -// ResetMismatchToken 客服人工重置解冻绑定错误的 Token -func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error { - var token model.RockToken - if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { - return err - } - - if token.Status != model.RockStatusMismatch { - return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置") - } - - s.refreshCache.Delete(token.UpstreamTokenID) - - // 重置回 available,清空订单关联 - return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusAvailable, - "assigned_order_no": "", - "expected_uid": "", - "actual_role_name": "", - "actual_role_id": "", - "actual_channel": "", - "initial_bound_snapshot": "", - "mismatch_at": nil, - "mismatch_reason": "", - "updated_at": time.Now(), - }).Error -} - -// rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。 -// 和分配快照一致,或者绑定时间仍为空且发货记录都早于本次领取,都是历史残留,链接可以再次使用。 -// 没有快照、也没有更早的发货记录时,上游已有角色按新绑定处理,避免把刚扫上的号放回库存。 -func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool { - if ref == nil || !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" { - return false - } - snap, hasSnap := bindingSnapshot(token) - if hasSnap && snap.Bound && snap.RoleID == ref.GetRoleID() && snap.BindChangedAt == strings.TrimSpace(ref.Token.BindChangedAt) { - return false - } - if deliveryAfterClaim(token, ref) || bindChangedAfterClaim(token, ref.Token.BindChangedAt) { - return true - } - if hasSnap && snap.RoleID != "" && snap.RoleID != ref.GetRoleID() { - return true - } - if strings.TrimSpace(ref.Token.BindChangedAt) == "" && historicalDeliveriesOnly(token, ref) { - return false - } - if hasSnap { - return false - } - return true -} - -func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool { - if ref == nil || len(ref.DeliveryList) == 0 { - return false - } - return !deliveryAfterClaim(token, ref) -} - -func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) { - if token == nil || strings.TrimSpace(token.InitialBoundSnapshot) == "" { - return BindingSnapshot{}, false - } - var snap BindingSnapshot - if err := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); err != nil { - return BindingSnapshot{}, false - } - snap.RoleID = strings.TrimSpace(snap.RoleID) - snap.BindChangedAt = strings.TrimSpace(snap.BindChangedAt) - return snap, true -} - -func rockClaimBaseline(token *model.RockToken) time.Time { - if token == nil { - return time.Time{} - } - if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() { - return token.ClaimedAt.UTC() - } - return token.CreatedAt.UTC() -} - -func parseRockTime(raw string) (time.Time, bool) { - raw = strings.TrimSpace(raw) - if raw == "" { - return time.Time{}, false - } - layouts := []string{ - time.RFC3339Nano, - "2006-01-02T15:04:05.999999999", - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - } - for _, layout := range layouts { - if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { - return t.UTC(), true - } - } - return time.Time{}, false -} - -func bindChangedAfterClaim(token *model.RockToken, changedAt string) bool { - changed, ok := parseRockTime(changedAt) - if !ok { - return false - } - baseline := rockClaimBaseline(token) - if baseline.IsZero() { - return true - } - return !changed.Before(baseline.Add(-time.Minute)) -} - -func deliveryAfterClaim(token *model.RockToken, ref *RockRefreshResponse) bool { - if ref == nil { - return false - } - baseline := rockClaimBaseline(token) - if baseline.IsZero() { - return false - } - for _, item := range ref.DeliveryList { - deliveredAt, ok := parseRockTime(item.Time) - if !ok { - continue - } - if !deliveredAt.Before(baseline.Add(-time.Minute)) { - return true - } - } - return false -} - -// ReleaseUnboundUsingToken 把尚未扫码绑定的使用中链接释放回可用库存。 -// 已绑定或上游已出现新扫码时拒绝释放,避免退款后的旧二维码被当成新库存发出去。 -func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint) error { - var token model.RockToken - if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { - return err - } - if token.Status != model.RockStatusUsing { - return fmt.Errorf("仅扫码使用中且尚未绑定的链接可以释放回库存") - } - if token.BoundAt != nil || strings.TrimSpace(token.ActualRoleID) != "" { - return fmt.Errorf("该链接已扫码绑定,不能释放回库存") - } - - cfg, err := s.GetConfig(ctx) - if err != nil { - return err - } - if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" { - return fmt.Errorf("缺少上游凭证,无法确认是否已扫码绑定") - } - - ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) - if err != nil { - return fmt.Errorf("确认上游绑定状态失败: %w", err) - } - if rockBuyerNewlyBound(&token, ref) { - return fmt.Errorf("上游已扫码绑定角色 %s(%s),不能释放", ref.GetRoleName(), ref.GetRoleID()) - } - - s.refreshCache.Delete(token.UpstreamTokenID) - orderNo := token.AssignedOrderNo - if err := s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusAvailable, - "assigned_order_no": "", - "expected_uid": "", - "actual_role_name": "", - "actual_role_id": "", - "actual_channel": "", - "initial_bound_snapshot": "", - "mismatch_at": nil, - "mismatch_reason": "", - "bound_at": nil, - "updated_at": time.Now(), - }).Error; err != nil { - return err - } - - s.detachReleasedRockToken(ctx, orderNo, token.ID) - log.Printf("[RockService] 人工释放未绑定库存: token_id=%d order=%s", token.ID, orderNo) - return nil -} - -// detachReleasedRockToken 清掉原订单上指向这条链接的记录,避免旧发货页继续占用它。 -func (s *RockService) detachReleasedRockToken(ctx context.Context, orderNo string, tokenID uint) { - orderNo = strings.TrimSpace(orderNo) - if orderNo == "" || tokenID == 0 { - return - } - var order model.FulfillmentOrder - if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil { - return - } - if resultDataNumber(order.ResultData, "rock_token_id") != int64(tokenID) { - return - } - raw := resultDataMap(order.ResultData) - delete(raw, "rock_token_id") - delete(raw, "rock_redeem_code") - raw["rock_token_released"] = true - encoded, err := json.Marshal(raw) - if err != nil { - return - } - _ = s.db.WithContext(ctx).Model(&order).Update("result_data", string(encoded)).Error -} diff --git a/backend/internal/service/rock_snapshot.go b/backend/internal/service/rock_snapshot.go new file mode 100644 index 0000000..591a576 --- /dev/null +++ b/backend/internal/service/rock_snapshot.go @@ -0,0 +1,116 @@ +package service + +import ( + "encoding/json" + "strings" + "time" + + "affiliate_dash/internal/model" +) + +// rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。 +// 和分配快照一致,或者绑定时间仍为空且发货记录都早于本次领取,都是历史残留,链接可以再次使用。 +// 没有快照、也没有更早的发货记录时,上游已有角色按新绑定处理,避免把刚扫上的号放回库存。 +func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool { + if ref == nil || !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" { + return false + } + snap, hasSnap := bindingSnapshot(token) + if hasSnap && snap.Bound && snap.RoleID == ref.GetRoleID() && snap.BindChangedAt == strings.TrimSpace(ref.Token.BindChangedAt) { + return false + } + if deliveryAfterClaim(token, ref) || bindChangedAfterClaim(token, ref.Token.BindChangedAt) { + return true + } + if hasSnap && snap.RoleID != "" && snap.RoleID != ref.GetRoleID() { + return true + } + if strings.TrimSpace(ref.Token.BindChangedAt) == "" && historicalDeliveriesOnly(token, ref) { + return false + } + if hasSnap { + return false + } + return true +} + +func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool { + if ref == nil || len(ref.DeliveryList) == 0 { + return false + } + return !deliveryAfterClaim(token, ref) +} + +func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) { + if token == nil || strings.TrimSpace(token.InitialBoundSnapshot) == "" { + return BindingSnapshot{}, false + } + var snap BindingSnapshot + if err := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); err != nil { + return BindingSnapshot{}, false + } + snap.RoleID = strings.TrimSpace(snap.RoleID) + snap.BindChangedAt = strings.TrimSpace(snap.BindChangedAt) + return snap, true +} + +func rockClaimBaseline(token *model.RockToken) time.Time { + if token == nil { + return time.Time{} + } + if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() { + return token.ClaimedAt.UTC() + } + return token.CreatedAt.UTC() +} + +func parseRockTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + layouts := []string{ + time.RFC3339Nano, + "2006-01-02T15:04:05.999999999", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t.UTC(), true + } + } + return time.Time{}, false +} + +func bindChangedAfterClaim(token *model.RockToken, changedAt string) bool { + changed, ok := parseRockTime(changedAt) + if !ok { + return false + } + baseline := rockClaimBaseline(token) + if baseline.IsZero() { + return true + } + return !changed.Before(baseline.Add(-time.Minute)) +} + +func deliveryAfterClaim(token *model.RockToken, ref *RockRefreshResponse) bool { + if ref == nil { + return false + } + baseline := rockClaimBaseline(token) + if baseline.IsZero() { + return false + } + for _, item := range ref.DeliveryList { + deliveredAt, ok := parseRockTime(item.Time) + if !ok { + continue + } + if !deliveredAt.Before(baseline.Add(-time.Minute)) { + return true + } + } + return false +} diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go new file mode 100644 index 0000000..c447fa4 --- /dev/null +++ b/backend/internal/service/rock_stock.go @@ -0,0 +1,177 @@ +package service + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "affiliate_dash/internal/model" + "gorm.io/gorm" +) + +// PurchaseTokens 执行采购并批量入库(通用黑色 Token) +func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) { + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + if cfg.BearerToken == "" { + return nil, fmt.Errorf("尚未配置上游 Bearer Token,无法执行采购") + } + + // 自动补货防抖冷却检查 + now := time.Now() + if isAuto && cfg.LastPurchasedAt != nil { + cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute + if now.Sub(*cfg.LastPurchasedAt) < cooldown { + return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes) + } + } + + // 调上游 Claim 接口(统一下单黑色通用 Token) + tokens, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count) + if err != nil { + return nil, fmt.Errorf("调用上游采购接口失败: %w", err) + } + + if len(tokens) == 0 { + return nil, fmt.Errorf("上游未返回任何有效 Token") + } + + var dbTokens []model.RockToken + for _, t := range tokens { + dbTokens = append(dbTokens, model.RockToken{ + UpstreamTokenID: fmt.Sprintf("%d", t.ID), + Token: t.Token, + RedeemCode: t.RedeemCode, + Color: model.RockColorBlack, + Region: t.Region, + Status: model.RockStatusAvailable, + ClaimedAt: &now, + }) + } + + // 事务写入库存表 + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&dbTokens).Error; err != nil { + return err + } + // 更新最后采购时间 + return tx.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Update("last_purchased_at", now).Error + }) + if err != nil { + return nil, fmt.Errorf("入库采购 Token 失败: %w", err) + } + + // 异步更新上游剩余积分快照 + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + me, meErr := s.client.GetMe(bgCtx, cfg.BearerToken) + if meErr == nil && me != nil { + _ = s.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", me.User.Points.Float64()).Error + } + }() + + return dbTokens, nil +} + +// CheckStockAndAutoPurchase 水位检查与自动采购触发 +func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return + } + + var count int64 + s.db.WithContext(ctx).Model(&model.RockToken{}). + Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). + Count(&count) + + if count <= int64(cfg.TokenWarningStock) { + log.Printf("[RockService] 可用库存预警: 当前仅存 %d 个 (预警阈值 %d)", count, cfg.TokenWarningStock) + if cfg.AutoPurchaseEnabled { + buyCount := cfg.AutoPurchaseCount + if buyCount <= 0 { + buyCount = 50 + } + log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount) + _, err := s.PurchaseTokens(ctx, buyCount, true) + if err != nil { + log.Printf("[RockService] 自动补货失败: %v", err) + } else { + log.Printf("[RockService] 自动补货成功,新增 %d 个 Token", buyCount) + } + } + } +} + +// ListTokens 分页查询 Token 列表 +func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + db := s.db.WithContext(ctx).Model(&model.RockToken{}) + if strings.TrimSpace(status) != "" { + db = db.Where("status = ?", strings.TrimSpace(status)) + } + if strings.TrimSpace(search) != "" { + term := "%" + strings.TrimSpace(search) + "%" + db = db.Where("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 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) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var tokens []model.RockToken + err := db.Order("id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&tokens).Error + if err != nil { + return nil, 0, err + } + + // 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo) + orderNos := make([]string, 0, len(tokens)) + for _, t := range tokens { + if t.AssignedOrderNo != "" { + orderNos = append(orderNos, t.AssignedOrderNo) + } + } + if len(orderNos) > 0 { + var orders []struct { + OrderNo string `gorm:"column:order_no"` + ClientOrderNo string `gorm:"column:client_order_no"` + } + if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders"). + Select("order_no, client_order_no"). + Where("order_no IN ?", orderNos). + Scan(&orders).Error; scanErr == nil { + clientMap := make(map[string]string, len(orders)) + for _, o := range orders { + clientMap[o.OrderNo] = o.ClientOrderNo + } + for i := range tokens { + if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" { + tokens[i].ClientOrderNo = cNo + } + } + } + } + + return tokens, total, nil +}