package service import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/timeutil" ) // TestCheckReleaseToken_UpstreamStatuses 覆盖释放前上游校验的 6 种结果。 func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) { db := newRockTestDB(t) mode := "unbound" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch mode { case "bound": _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": map[string]interface{}{ "status": "bound", "bound_account": "买家新角色", "bound_account_id": "5550001", "bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"), }, }) case "redeemed": _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": map[string]interface{}{ "status": "redeemed", "redeemed_at": "2026-11-07 22:44:00", }, }) case "abnormal": _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": map[string]interface{}{"status": "abnormal"}, }) case "not_found": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"token 不存在"}`)) case "check_failed": w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"boom"}`)) default: _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": map[string]interface{}{"status": "using"}, }) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") claimedAt := time.Now().Add(-30 * time.Minute) cases := []struct { name string mode string wantStatus string wantCan bool }{ {name: "unbound", mode: "unbound", wantStatus: "unbound", wantCan: true}, {name: "bound", mode: "bound", wantStatus: "bound", wantCan: false}, {name: "redeemed", mode: "redeemed", wantStatus: "redeemed", wantCan: false}, {name: "abnormal", mode: "abnormal", wantStatus: "abnormal", wantCan: false}, {name: "not_found", mode: "not_found", wantStatus: "not_found", wantCan: false}, {name: "check_failed", mode: "check_failed", wantStatus: "check_failed", wantCan: false}, } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { mode = tc.mode token := model.RockToken{ UpstreamTokenID: fmt.Sprintf("release-check-%d", i), Token: "jwt", RedeemCode: fmt.Sprintf("CODE-CHECK-%d", i), Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: fmt.Sprintf("ORDER-CHECK-%d", i), ExpectedUID: "5550001", ClaimedAt: &claimedAt, } if err := db.Create(&token).Error; err != nil { t.Fatalf("创建 Token 失败: %v", err) } res, err := svc.CheckReleaseToken(context.Background(), token.ID) if err != nil { t.Fatalf("校验释放失败: %v", err) } if res.UpstreamStatus != tc.wantStatus || res.CanRelease != tc.wantCan { t.Fatalf("校验结果不符: status=%s can_release=%v message=%s", res.UpstreamStatus, res.CanRelease, res.Message) } if res.Message == "" { t.Fatalf("校验结果必须带提示文案") } }) } } // TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus 验证非强制释放被上游拦截时状态不变。 func TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus(t *testing.T) { db := newRockTestDB(t) server := newBoundReleaseServer(t) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") claimedAt := time.Now().Add(-30 * time.Minute) token := model.RockToken{ UpstreamTokenID: "release-force-1", Token: "jwt", RedeemCode: "CODE-FORCE-1", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "ORDER-FORCE-1", ExpectedUID: "5550001", ClaimedAt: &claimedAt, } if err := db.Create(&token).Error; err != nil { t.Fatalf("创建 Token 失败: %v", err) } err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, false, nil) var forceErr *RockReleaseRequiresForceError if !errors.As(err, &forceErr) { t.Fatalf("非强制释放应返回 RockReleaseRequiresForceError,实际: %v", err) } if forceErr.Check.UpstreamStatus != "bound" || forceErr.Check.CanRelease { t.Fatalf("校验结果不符: %+v", forceErr.Check) } var stored model.RockToken if err := db.First(&stored, token.ID).Error; err != nil { t.Fatalf("重新读取 Token 失败: %v", err) } if stored.Status != model.RockStatusUsing || stored.AssignedOrderNo != "ORDER-FORCE-1" { t.Fatalf("拒绝释放后状态不应变化: status=%s order=%s", stored.Status, stored.AssignedOrderNo) } var logCount int64 db.Model(&model.RockTokenStatusLog{}).Where("token_id = ?", token.ID).Count(&logCount) if logCount != 0 { t.Fatalf("拒绝释放不应写状态日志,实际 %d 条", logCount) } } // TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs 验证人工强制释放清空残留字段并留下追溯记录。 func TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs(t *testing.T) { db := newRockTestDB(t) server := newBoundReleaseServer(t) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") now := time.Now() claimedAt := now.Add(-30 * time.Minute) token := model.RockToken{ UpstreamTokenID: "release-force-2", Token: "jwt", RedeemCode: "CODE-FORCE-2", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "ORDER-FORCE-2", ExpectedUID: "5550001", ActualRoleName: "残留角色", ActualRoleID: "5550001", ActualChannel: "安卓-微信", InitialBoundSnapshot: `{"bound":true,"role_id":"5550001"}`, UpstreamFulfillID: 98765, MismatchAt: &now, MismatchReason: "历史不一致", ClaimedAt: &claimedAt, UsingAt: &now, BoundAt: &now, RedeemedAt: &now, } if err := db.Create(&token).Error; err != nil { t.Fatalf("创建 Token 失败: %v", err) } if err := db.Create(&model.FulfillmentOrder{ OrderNo: "ORDER-FORCE-2", ProductSKU: model.RockSKUBlackTshirt, ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d}`, token.ID), }).Error; err != nil { t.Fatalf("创建订单失败: %v", err) } actor := uint(42) if err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, true, &actor); err != nil { t.Fatalf("强制释放失败: %v", err) } var released model.RockToken if err := db.First(&released, token.ID).Error; err != nil { t.Fatalf("读取释放后的 Token 失败: %v", err) } if released.Status != model.RockStatusAvailable { t.Fatalf("释放后应为 available,实际 %s", released.Status) } if released.AssignedOrderNo != "" || released.ExpectedUID != "" || released.ActualRoleName != "" || released.ActualRoleID != "" || released.ActualChannel != "" || released.InitialBoundSnapshot != "" || released.MismatchReason != "" { t.Fatalf("释放后绑定字段未清空: %+v", released) } if released.MismatchAt != nil || released.UsingAt != nil || released.BoundAt != nil || released.RedeemedAt != nil { t.Fatalf("释放后时间字段未清空: using=%v bound=%v redeemed=%v mismatch=%v", released.UsingAt, released.BoundAt, released.RedeemedAt, released.MismatchAt) } if released.UpstreamFulfillID != 0 { t.Fatalf("释放后 upstream_fulfill_id 应清空,实际 %d", released.UpstreamFulfillID) } var logs []model.RockTokenStatusLog if err := db.Where("token_id = ?", token.ID).Order("id DESC").Find(&logs).Error; err != nil { t.Fatalf("读取状态日志失败: %v", err) } if len(logs) != 1 { t.Fatalf("强制释放应写 1 条状态日志,实际 %d 条", len(logs)) } entry := logs[0] if entry.EventType != rockTokenEventStatusChanged || entry.FromStatus != model.RockStatusUsing || entry.ToStatus != model.RockStatusAvailable || entry.Source != rockTokenSourceRelease || !entry.Forced { t.Fatalf("状态日志字段不符: %+v", entry) } if !strings.Contains(entry.Reason, "强制释放") || !strings.Contains(entry.Reason, "bound") { t.Fatalf("状态日志原因应包含强制标记与上游状态: %s", entry.Reason) } if entry.ActorUserID == nil || *entry.ActorUserID != actor { t.Fatalf("状态日志操作人不符: %+v", entry.ActorUserID) } var audit model.AuditLog if err := db.Where("action = ? AND entity_id = ?", "rock_token.release", fmt.Sprintf("%d", token.ID)). First(&audit).Error; err != nil { t.Fatalf("未找到释放审计日志: %v", err) } if audit.ActorUserID == nil || *audit.ActorUserID != actor { t.Fatalf("审计日志操作人不符: %+v", audit.ActorUserID) } if !strings.Contains(audit.Metadata, `"forced":true`) || !strings.Contains(audit.Metadata, `"upstream_status":"bound"`) { t.Fatalf("审计日志元数据不符: %s", audit.Metadata) } } // newBoundReleaseServer 返回一个「上游已扫码绑定」的 mock,用于强制释放场景。 func newBoundReleaseServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": map[string]interface{}{ "status": "bound", "bound_account": "买家新角色", "bound_account_id": "5550001", "bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"), }, }) })) }