mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
feat(channels): Qoder 每日活动领取(多活动逐个领)+ 三条误报陷阱全部钉死
## 参考依据 `qoder-credits.ts` 的 `claimQoderDailyCheckin` / `parseQoderCampaigns` / `claimQoderCampaign` / `hasQoderCampaignRefreshedToday`。 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在实现与判据里钉死: ### ① 「无可领活动」必须是 inactive,不能报 already-claimed 旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项(含**请求头不完整**、 未到刷新时间、本就无活动三种情形),界面就谎报已领,与官方 IDE 的"可领取"直接矛盾。 用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」。 ### ② 每日 10:00(UTC+8)刷新;刷新前的 CLAIMED 属于**昨天** 此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。 必须用**算术平移**判 UTC+8 —— 用本机时区在出差/改时区时会判错方向 (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。 ### ③ 未开通要给 action_required 的可操作提示 判据是**两条同时**满足(无 CLAIM_BENEFIT + 用量响应里 `addOnQuota` **字段缺失**)。 ⚠️ 用「字段是否存在」而非「remaining 是否为 0」—— 后者对"额度用光"与"从未开通" 不可区分,会把用光额度的老账号误报成未开通。文案要可操作(去官方客户端登录一次), 并作为显式字段透传给 UI,而不是让 UI 猜文案。 ### 其他要点 - 一个账号可能同时有**多个** CLAIM_BENEFIT 活动 → 逐个领取(实测有每日 100 与运营活动) - 幂等判据是响应体的 **`replayed`**,不是 HTTP 状态码:重复领取同样回 200, 但 `replayed:true` 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100") - 请求体必须是**空串**(抓包实测 `content-length: 0`) - 缺 `Cosy-MachineToken/MachineType` 时服务端不下发可领活动(见上一个提交) ## 判据 - `TestQoderClaimDecisionTable`:6 个用例覆盖四类结果 + claim 次数 + body 必须为空 - `TestQoderRefreshUsesUTC8NotLocalTime`:**注入 UTC 时刻**验证刷新判定 ★ 修判据自身两处缺陷(首轮变异存活): 1. `TestMain` 把测试进程 TZ 钉成 **UTC** —— 本机恰是 HKT(+0800),于是 「UTC 平移」与「本机时区」结果**完全相同**,误用本机时区的实现抓不到(判据不可达)。 2. 决策表缺「无任何活动且已开通(addOnQuota 存在)」用例 → 把 inactive 误报成 already-claimed 的变异得以存活。补齐后两个变异都被抓。 ## 统一前向声明(Lua 作用域,本文件第 N 次) 把 `qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion` 统一在 PLATFORMS 之前前向声明。此前逐个补、位置还放错过一次,导致运行时报 `attempt to call global 'x' (a nil value)` 而编译期静默。 ## 文档 `docs/channels-parity.md` 更新:gemini-sigstore 标注为**⛔ 不适用** (llmsproxy 的推理走无状态适配器,插件不参与推理 —— 插件侧缓存会成为死代码; 若将来要支持,位置在适配器侧,与"适配器无状态"约定冲突,须单独决策)。
This commit is contained in:
222
internal/core/channels_qoder_claim_test.go
Normal file
222
internal/core/channels_qoder_claim_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
22
internal/core/mutation_guard_test.go
Normal file
22
internal/core/mutation_guard_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 把测试进程的时区钉成 **UTC**。
|
||||
//
|
||||
// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是
|
||||
// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里
|
||||
// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**,
|
||||
// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。
|
||||
// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。
|
||||
//
|
||||
// 只影响测试进程,不改系统时区。
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("TZ", "UTC")
|
||||
time.Local = time.UTC
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user