package core import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "modelrouter/internal/lua" ) // Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱, // 全部必须在判据里钉死: // // ① 「无可领活动」必须是 inactive,**不能**报 already-claimed // (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」); // ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive // 而不是"今天已领取"(谎报且不可逆); // ③ 未开通要给出 action_required 的可操作提示。 // // 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领" // 误报成"成功 +100"。 func TestQoderClaimDecisionTable(t *testing.T) { type c struct { name string campaigns string usage string wantKind string wantAction bool wantClaimed int } cases := []c{ { "无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示", `{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`, `{"data":{"userQuota":{"remaining":0}}}`, "inactive", true, 0, }, { "无可领但确实领过(CLAIMED)→ 视刷新时刻", `{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`, `{"data":{"addOnQuota":{"remaining":0}}}`, "", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一 }, { "可领→success(多个活动逐个领)", `{"showCampaign":true,"claimable":true,"campaigns":[ {"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}, {"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`, "", "success", false, 2, }, { "可领但服务端回放 replayed→already-claimed(不是 success)", `{"showCampaign":true,"claimable":true,"campaigns":[ {"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`, "", "already-claimed", false, 1, }, { "无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)", `{"showCampaign":false,"claimable":false,"campaigns":[]}`, `{"data":{"addOnQuota":{"total":100,"remaining":0}}}`, "inactive", false, 0, }, { "有 CLAIM_BENEFIT 但已过期→inactive", `{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`, `{"data":{"addOnQuota":{"remaining":0}}}`, "inactive", false, 0, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { claimed := 0 var claimBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.URL.Path == "/sash/api/v1/me/campaigns": fmt.Fprint(w, tc.campaigns) case r.URL.Path == "/sash/api/v2/me/usage": if tc.usage == "" { w.WriteHeader(500) return } fmt.Fprint(w, tc.usage) case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") && r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim": claimed++ b := make([]byte, 0) if r.ContentLength > 0 { b = make([]byte, r.ContentLength) _, _ = r.Body.Read(b) } claimBody = string(b) if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" { fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`) } else { fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`) } default: w.WriteHeader(404) } })) defer ts.Close() c := kernelTestCore(t) ps := c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := ps.LoadSource("channels", src); err != nil { t.Fatal(err) } if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ "platform": "qoder", "qoder_sash_base": map[string]interface{}{"url": ts.URL}, }); err != nil { t.Fatal(err) } cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"}) body, _ := json.Marshal(map[string]string{ "platform": "qoder", "credential": string(cred), "account": "t", }) if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ Method: "POST", Role: "admin", Body: string(body), }); err != nil { t.Fatal(err) } out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{ Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`, }) if err != nil { t.Fatalf("claim_daily: %v", err) } j, _ := out.JSON.(map[string]interface{}) oc, _ := j["outcome"].(map[string]interface{}) if oc == nil { t.Fatalf("应返回 outcome,实得 %v", j) } kind, _ := oc["kind"].(string) if tc.wantKind == "" { // 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法, // 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。 if kind != "already-claimed" && kind != "inactive" { t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"]) } } else if kind != tc.wantKind { t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"]) } if tc.wantAction && oc["action_required"] != true { t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"]) } if claimed != tc.wantClaimed { t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed) } if tc.wantClaimed > 0 && claimBody != "" { t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody) } }) } } // 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。 // ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同** // —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向: // 判据的输入是 UTC 毫秒,与运行环境时区无关。 func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) { c := kernelTestCore(t) ps := c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := ps.LoadSource("channels", src); err != nil { t.Fatal(err) } // UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00) // UTC 02:00 = UTC+8 10:00 → **已刷新** // 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到: // 把 07:00Z 当本地 15:00 → 判已刷新(错) cases := []struct { name string utcHour int want bool }{ {"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false}, {"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true}, {"UTC 00:30(UTC+8 08:30,未刷新)", 0, false}, {"UTC 15:00(UTC+8 23:00,已刷新)", 15, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰) // 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入 // 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。 ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli() var rec map[string]interface{} got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{ "now_ms": float64(ms), }) if err != nil { t.Fatalf("probe: %v", err) } w, _ := got.(map[string]interface{}) rec, _ = w["json"].(map[string]interface{}) if rec == nil { t.Fatalf("无 json: %#v", got) } if rec["refreshed"] != tc.want { t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"]) } }) } } // 裸 token 凭据必须能领:qoder 是 PKCE 设备码流且无 register 第二跳, // complete_login 存的就是裸 access_token 字符串(真实生产事故:账号池凭据是 // dt-… 裸串,claim 报「缺少凭据对象」,而 account_test 用 token 路径照常通过 // —— 自相矛盾的状态)。活动接口只要 Bearer token,就地包一层即可。 func TestQoderClaimAcceptsBareTokenCredential(t *testing.T) { var auth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") auth = r.Header.Get("Authorization") switch { case r.URL.Path == "/sash/api/v1/me/campaigns": fmt.Fprint(w, `{"showCampaign":true,"claimable":true,"campaigns":[ {"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`) case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") && r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim": fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`) default: w.WriteHeader(404) } })) defer ts.Close() c := kernelTestCore(t) ps := c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := ps.LoadSource("channels", src); err != nil { t.Fatal(err) } if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ "platform": "qoder", "qoder_sash_base": map[string]interface{}{"url": ts.URL}, }); err != nil { t.Fatal(err) } // 与生产一致:凭据是**裸 token 字符串**(不是 JSON 对象)。 body, _ := json.Marshal(map[string]string{ "platform": "qoder", "credential": "dt-D6t0cFLOY3fpSmu2Mck2WWeO", "account": "t", }) if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ Method: "POST", Role: "admin", Body: string(body), }); err != nil { t.Fatal(err) } out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{ Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`, }) if err != nil { t.Fatalf("claim_daily: %v", err) } j, _ := out.JSON.(map[string]interface{}) oc, _ := j["outcome"].(map[string]interface{}) if oc == nil { t.Fatalf("应返回 outcome,实得 %v", j) } // claim_daily 的通用路径把 claim_fn 的 kind=claimed 归一成 success + message; // 判据是**不再是 failed** 且消息带领取金额。 kind, _ := oc["kind"].(string) if kind == "failed" { t.Fatalf("裸 token 凭据应能领取,实得 kind=failed(%v)", oc["message"]) } if msg, _ := oc["message"].(string); !strings.Contains(msg, "100") { t.Errorf("领取成功消息应带金额,实得 %q", msg) } if auth != "Bearer dt-D6t0cFLOY3fpSmu2Mck2WWeO" { t.Errorf("活动接口必须带 Bearer 裸 token,实得 %q", auth) } }