diff --git a/frontend/src/components/exchange/ExchangeRecords.vue b/frontend/src/components/exchange/ExchangeRecords.vue new file mode 100644 index 0000000..484d42a --- /dev/null +++ b/frontend/src/components/exchange/ExchangeRecords.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/frontend/src/components/layout/RightConsole.vue b/frontend/src/components/layout/RightConsole.vue index e72123b..d9ab6a6 100644 --- a/frontend/src/components/layout/RightConsole.vue +++ b/frontend/src/components/layout/RightConsole.vue @@ -8,6 +8,7 @@ import AccountTable from '@/components/account/AccountTable.vue' import LogPanel from '@/components/log/LogPanel.vue' import SettingsModal from '@/components/modal/SettingsModal.vue' import CKManager from '@/components/ck/CKManager.vue' +import ExchangeRecords from '@/components/exchange/ExchangeRecords.vue' defineProps<{ serverTime: string @@ -17,7 +18,7 @@ const { theme, toggleTheme } = useTheme() const { runState, countdownSeconds, formatRemain } = useScheduler() const settingsModalRef = ref | null>(null) -const currentPage = ref<'console' | 'ck'>('console') +const currentPage = ref<'console' | 'ck' | 'exchange'>('console') const openSettings = () => { settingsModalRef.value?.open() @@ -44,6 +45,13 @@ const openSettings = () => { > CK 管理 + { + + + diff --git a/internal/app/app_verify.go b/internal/app/app_verify.go index 7903148..891d665 100644 --- a/internal/app/app_verify.go +++ b/internal/app/app_verify.go @@ -12,6 +12,7 @@ import ( ) const exchangeLookupConcurrency = 8 +const xiaodianExchangeLookupConcurrency = 4 type exchangeLookupResult struct { Account types.Account @@ -108,6 +109,98 @@ func (a *App) VerifyAllExchange() types.VerifyAllExchangeResult { return types.VerifyAllExchangeResult{Results: results} } +// QueryXiaodianExchangeRecords 查询所有账号的和平小店真实订单,并标记当前商品是否已兑换。 +func (a *App) QueryXiaodianExchangeRecords(commodityID, commodityName string) types.XiaodianExchangeRecordsResult { + result := types.XiaodianExchangeRecordsResult{ + ProductID: strings.TrimSpace(commodityID), + ProductName: strings.TrimSpace(commodityName), + Results: make(map[string]types.XiaodianExchangeAccountResult), + } + if !a.getClient().Activity.IsXiaodian { + result.Error = "当前活动不是和平小店" + return result + } + if result.ProductID == "" { + result.Error = "请先选择商品" + return result + } + + accounts := a.scheduler.AccountSnapshot() + if len(accounts) == 0 { + result.Success = true + return result + } + start := time.Now() + logger.LogEvent("兑换记录", "info", "和平小店兑换记录批量查询开始: 账号=%d 并发=%d", len(accounts), xiaodianExchangeLookupConcurrency) + + type lookup struct { + account types.Account + records []types.XiaodianExchangeRecord + err error + } + lookups := make([]lookup, len(accounts)) + jobs := make(chan int) + workerCount := xiaodianExchangeLookupConcurrency + if len(accounts) < workerCount { + workerCount = len(accounts) + } + var wg sync.WaitGroup + for worker := 0; worker < workerCount; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for index := range jobs { + account := accounts[index] + item := lookup{account: account} + if strings.TrimSpace(account.Cookie) == "" { + item.err = fmt.Errorf("Cookie 为空") + } else { + item.records, item.err = a.getClient().XiaodianExchangeList(account.Cookie) + } + lookups[index] = item + } + }() + } + for index := range accounts { + jobs <- index + } + close(jobs) + wg.Wait() + + failed := 0 + matched := 0 + for _, lookup := range lookups { + if lookup.err != nil { + failed++ + result.Results[lookup.account.Name] = types.XiaodianExchangeAccountResult{Error: lookup.err.Error()} + continue + } + isMatched := false + for _, record := range lookup.records { + if record.CommodityID == result.ProductID { + isMatched = true + break + } + } + if isMatched { + matched++ + } + result.Results[lookup.account.Name] = types.XiaodianExchangeAccountResult{ + Success: true, + Records: lookup.records, + Matched: isMatched, + } + } + result.Success = true + level := "success" + if failed > 0 { + level = "warn" + } + logger.LogEvent("兑换记录", level, "和平小店兑换记录批量查询完成: 账号=%d 命中=%d 失败=%d 耗时=%s", + len(accounts), matched, failed, time.Since(start).Round(time.Millisecond)) + return result +} + // VerifyAndExport 复验并导出结果。 func (a *App) VerifyAndExport(commodityID string) types.VerifyAndExportResult { accounts := a.scheduler.AccountSnapshot() diff --git a/internal/client/client.go b/internal/client/client.go index e0a995e..b530b75 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -284,6 +284,26 @@ type xiaodianRoleContext struct { PlatID xiaodianFlexibleString `json:"platId"` } +type xiaodianOrderList struct { + Data struct { + Count xiaodianFlexibleString `json:"cnt"` + List []xiaodianOrder `json:"list"` + } `json:"data"` +} + +type xiaodianOrder struct { + BuyTime string `json:"dtBuyTime"` + PayTime string `json:"dtPayTime"` + Status string `json:"iStatus"` + GoodsRaw string `json:"sGoodsInfo"` +} + +type xiaodianOrderGoods struct { + GoodsID string `json:"iGoodsId"` + GoodsName string `json:"sGoodsName"` + Quantity xiaodianFlexibleString `json:"iQuantity"` +} + // xiaodianEmbedQuery 获取斗鱼为当前账号签发的短时道具城参数。 func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) { uid := cookieValue(cookie, "acf_uid") @@ -567,6 +587,81 @@ func (c *DouyuClient) XiaodianAssets(cookie string) (types.XiaodianAssets, error return types.XiaodianAssets{Success: true, Coupons: coupons, Fragments: fragments}, nil } +// XiaodianExchangeList 查询和平小店真实订单记录。 +func (c *DouyuClient) XiaodianExchangeList(cookie string) ([]types.XiaodianExchangeRecord, error) { + if !c.Activity.IsXiaodian { + return nil, errors.New("当前活动不是和平小店") + } + if strings.TrimSpace(cookie) == "" { + return nil, errors.New("Cookie 为空") + } + embedQuery, err := c.xiaodianEmbedQuery(cookie) + if err != nil { + return nil, err + } + endpoint, err := url.Parse(strings.TrimRight(c.Activity.RoleURL, "/") + "/") + if err != nil { + return nil, err + } + all := make([]types.XiaodianExchangeRecord, 0) + const pageSize = 100 + for page := 1; page <= 100; page++ { + query := endpoint.Query() + query.Set("_service", "order.list") + query.Set("_jsvar", "info") + query.Set("acctype", "livelink") + query.Set("_app_id", "2123") + query.Set("hist", "0") + query.Set("ps", strconv.Itoa(pageSize)) + query.Set("pn", strconv.Itoa(page)) + query.Set("_biz_code", "cjm") + query.Set("_act_id", c.Activity.ActID) + query.Set("authType", "delegate") + query.Set("_sid", "8") + query.Set("set", "8") + for _, key := range []string{"gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"} { + query.Set(key, embedQuery.Get(key)) + } + endpoint.RawQuery = query.Encode() + req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, err + } + c.setXiaodianHeaders(req) + body, err := c.doText(req, "和平小店兑换记录", true) + if err != nil { + return nil, err + } + var payload xiaodianOrderList + if err := parseXiaodianVariable(body, "info", &payload); err != nil { + return nil, fmt.Errorf("和平小店兑换记录响应解析失败: %w", err) + } + for _, order := range payload.Data.List { + var goods struct { + List []xiaodianOrderGoods `json:"list"` + } + if strings.TrimSpace(order.GoodsRaw) != "" { + if err := json.Unmarshal([]byte(order.GoodsRaw), &goods); err != nil { + return nil, fmt.Errorf("和平小店订单商品解析失败: %w", err) + } + } + for _, item := range goods.List { + all = append(all, types.XiaodianExchangeRecord{ + CommodityID: item.GoodsID, CommodityName: item.GoodsName, + Quantity: parseXiaodianInt(string(item.Quantity)), BuyTime: order.BuyTime, + PayTime: order.PayTime, Status: order.Status, + }) + } + } + totalCount := parseXiaodianInt(string(payload.Data.Count)) + if len(payload.Data.List) == 0 || (totalCount > 0 && len(all) >= totalCount) || + (totalCount <= 0 && len(payload.Data.List) < pageSize) { + break + } + } + return all, nil +} + func (c *DouyuClient) xiaodianAsset(cookie string, role xiaodianRoleContext, areaID, platID int, variable, service, field string, withTime bool) (int, error) { endpoint, err := url.Parse(strings.TrimRight(c.Activity.RoleURL, "/") + "/") if err != nil { @@ -936,6 +1031,21 @@ func (r ExchangeListResponse) APIStatus() (int, string, bool) { // ExchangeList 查询兑换记录列表。 func (c *DouyuClient) ExchangeList(cookie string) ([]types.ExchangeRecord, error) { + if c.Activity.IsXiaodian { + xiaodianRecords, err := c.XiaodianExchangeList(cookie) + if err != nil { + return nil, err + } + records := make([]types.ExchangeRecord, 0, len(xiaodianRecords)) + for _, record := range xiaodianRecords { + records = append(records, types.ExchangeRecord{ + CommodityID: record.CommodityID, CommodityName: record.CommodityName, + ShowName: record.CommodityName, ExchangeNum: record.Quantity, + Time: record.BuyTime, + }) + } + return records, nil + } var result ExchangeListResponse if err := c.getJSON(c.Activity.ExchangeListURL, cookie, "兑换记录", &result); err != nil { return nil, err diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 20991f7..c315319 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -230,6 +230,48 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) { } } +func TestXiaodianExchangeListUsesOrderListAndParsesGoods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/embed": + _, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code×tamp=1&v=2.0&sig=sig"}}`)) + case "/role/": + if got := r.URL.Query().Get("_service"); got != "order.list" { + t.Errorf("_service = %q", got) + } + if got := r.URL.Query().Get("code"); got != "code" { + t.Errorf("code = %q", got) + } + if got := r.URL.Query().Get("_sid"); got != "8" { + t.Errorf("_sid = %q", got) + } + _, _ = w.Write([]byte(`var info={"data":{"cnt":1,"list":[{"dtBuyTime":"2026-08-08 01:00:42","dtPayTime":"2026-08-08 01:00:55","iStatus":"3","sGoodsInfo":"{\"list\":[{\"iGoodsId\":\"1565395\",\"sGoodsName\":\"改名卡\",\"iQuantity\":\"2\"}]}"}]}};`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + act := &activity.Activity{ + ID: "xiaodian", DisplayName: "和平小店", RoomID: "9263298", ActAlias: "20260623KDQFH", + ActID: "46195", RoleParamURL: server.URL + "/embed", RoleURL: server.URL + "/role", IsXiaodian: true, + } + cli := New(act, "", &trace.Recorder{}) + cli.HTTPClient = server.Client() + + records, err := cli.XiaodianExchangeList("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid") + if err != nil { + t.Fatalf("XiaodianExchangeList() error = %v", err) + } + if len(records) != 1 { + t.Fatalf("记录数量 = %d", len(records)) + } + got := records[0] + if got.CommodityID != "1565395" || got.CommodityName != "改名卡" || got.Quantity != 2 || got.Status != "3" { + t.Fatalf("记录解析异常: %#v", got) + } +} + func testCTNClient(t *testing.T, ctnURL string) *DouyuClient { t.Helper() act := &activity.Activity{ diff --git a/internal/types/responses.go b/internal/types/responses.go index 05e7ee3..e6b9c0d 100644 --- a/internal/types/responses.go +++ b/internal/types/responses.go @@ -119,6 +119,23 @@ type VerifyAllExchangeResult struct { Results map[string]AccountExchangeResult `json:"results"` } +// XiaodianExchangeAccountResult 是单个账号的和平小店兑换记录查询结果。 +type XiaodianExchangeAccountResult struct { + Success bool `json:"success"` + Records []XiaodianExchangeRecord `json:"records,omitempty"` + Matched bool `json:"matched"` + Error string `json:"error,omitempty"` +} + +// XiaodianExchangeRecordsResult 是批量查询和平小店兑换记录的结果。 +type XiaodianExchangeRecordsResult struct { + Success bool `json:"success"` + ProductID string `json:"productId"` + ProductName string `json:"productName"` + Results map[string]XiaodianExchangeAccountResult `json:"results"` + Error string `json:"error,omitempty"` +} + // ExportAccountsResult 导出账号结果。 type ExportAccountsResult struct { Success bool `json:"success"` diff --git a/internal/types/types.go b/internal/types/types.go index 02a8c5b..79a6e32 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -223,3 +223,13 @@ type ExchangeRecord struct { Time string `json:"time"` Score string `json:"score"` } + +// XiaodianExchangeRecord 是和平小店订单记录,商品明细来自 sGoodsInfo.list。 +type XiaodianExchangeRecord struct { + CommodityID string `json:"commodityId"` + CommodityName string `json:"commodityName"` + Quantity int `json:"quantity"` + BuyTime string `json:"buyTime"` + PayTime string `json:"payTime"` + Status string `json:"status"` +}