新增和平小店兑换记录查询

This commit is contained in:
yml2213
2026-08-08 19:44:39 +08:00
parent 57344c7874
commit c84940a5dc
7 changed files with 668 additions and 1 deletions
@@ -0,0 +1,382 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useProducts } from '@/composables/useProducts'
import { useAccounts } from '@/composables/useAccounts'
import { useActivity } from '@/composables/useActivity'
import { useToast } from '@/composables/useToast'
import { QueryXiaodianExchangeRecords } from '@wails/go/app/App'
import AccountTable from '@/components/account/AccountTable.vue'
const { selectedProductId, selectedProductName } = useProducts()
const { accounts } = useAccounts()
const { currentActivity } = useActivity()
const { showToast } = useToast()
const loading = ref(false)
const queryResult = ref<any>(null)
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const rows = computed(() => {
const results = queryResult.value?.results || {}
return accounts.value.map(account => ({
name: account.name,
...(results[account.name] || { success: false, error: '尚未查询' })
}))
})
const matchedCount = computed(() => rows.value.filter(row => row.success && row.matched).length)
const noMatchCount = computed(() => rows.value.filter(row => row.success && !row.matched).length)
const failedCount = computed(() => rows.value.filter(row => !row.success).length)
const queryRecords = async () => {
if (!isXiaodian.value) {
showToast('暂不支持', '兑换记录查询目前仅支持和平小店', 'warn')
return
}
if (!selectedProductId.value) {
showToast('提示', '请先在左侧商品列表选择商品', 'warn')
return
}
loading.value = true
try {
const result = await QueryXiaodianExchangeRecords(
selectedProductId.value,
selectedProductName.value || ''
)
queryResult.value = result
if (!result.success) {
showToast('查询失败', result.error || '兑换记录接口未返回成功', 'error')
return
}
const total = Object.keys(result.results || {}).length
showToast('查询完成', `已查询 ${total} 个账号,当前商品命中 ${matchedCount.value} 个`, 'success')
} catch (e: any) {
showToast('查询失败', e?.message || '', 'error')
} finally {
loading.value = false
}
}
const formatTime = (value: string) => {
if (!value) return '-'
const numeric = Number(value)
if (Number.isFinite(numeric) && numeric > 0) {
return new Date(numeric < 10_000_000_000 ? numeric * 1000 : numeric).toLocaleString('zh-CN')
}
return value.replace('T', ' ')
}
watch(selectedProductId, () => {
queryResult.value = null
})
</script>
<template>
<section class="exchange-page">
<div class="exchange-head">
<div>
<h2>兑换记录</h2>
<p>查询每个账号的和平小店订单,并核对当前商品</p>
</div>
<button class="btn btn-primary" :disabled="loading || !selectedProductId || !isXiaodian" @click="queryRecords">
{{ loading ? '查询中...' : '查询兑换记录' }}
</button>
</div>
<div class="selected-product-bar">
<span class="bar-label">当前商品</span>
<span v-if="selectedProductId" class="bar-product">{{ selectedProductName || selectedProductId }}</span>
<span v-else class="bar-empty">请先在左侧商品列表选择商品</span>
<span v-if="selectedProductId" class="bar-id">ID {{ selectedProductId }}</span>
</div>
<div v-if="queryResult?.error" class="exchange-error">{{ queryResult.error }}</div>
<template v-if="queryResult?.success">
<div class="exchange-stats">
<div class="exchange-stat matched">
<span>已兑换当前商品</span><strong>{{ matchedCount }}</strong>
</div>
<div class="exchange-stat no-match">
<span>未兑换当前商品</span><strong>{{ noMatchCount }}</strong>
</div>
<div class="exchange-stat failed">
<span>查询失败</span><strong>{{ failedCount }}</strong>
</div>
</div>
<div class="record-table-wrap">
<table class="record-table">
<thead>
<tr>
<th>账号</th>
<th class="status-column">当前商品</th>
<th class="count-column">记录</th>
<th>兑换明细</th>
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<td colspan="4" class="record-empty">暂无账号</td>
</tr>
<tr v-for="row in rows" :key="row.name">
<td class="record-account">{{ row.name }}</td>
<td class="status-column">
<span v-if="!row.success" class="record-status error">查询失败</span>
<span v-else-if="row.matched" class="record-status matched">已兑换</span>
<span v-else class="record-status no-match">未兑换</span>
</td>
<td class="count-column">{{ row.success ? row.records?.length || 0 : '-' }}</td>
<td>
<div v-if="!row.success" class="record-error">{{ row.error }}</div>
<div v-else-if="!row.records?.length" class="record-muted">暂无记录</div>
<div v-else class="record-details">
<div v-for="(record, index) in row.records" :key="`${row.name}-${index}`" class="record-detail">
<span class="detail-name">{{ record.commodityName || record.commodityId }}</span>
<span class="detail-quantity">×{{ record.quantity || 1 }}</span>
<span class="detail-time">{{ formatTime(record.buyTime) }}</span>
<span class="detail-status">状态 {{ record.status || '-' }}</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-else-if="!queryResult" class="record-placeholder">
选择商品后点击查询,结果会显示所有账号的订单记录。
</div>
<section class="exchange-account-section">
<div class="account-section-head">
<span>账号表格</span>
<span class="account-section-meta">可查看账号选择、凭据和资产状态</span>
</div>
<AccountTable />
</section>
</section>
</template>
<style scoped>
.exchange-page {
flex: 1;
min-height: 0;
overflow: auto;
padding: 22px 24px 28px;
}
.exchange-head,
.selected-product-bar,
.exchange-stats {
display: flex;
align-items: center;
}
.exchange-head {
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.exchange-head h2 {
color: var(--text-strong);
font-size: 18px;
font-weight: 600;
}
.exchange-head p {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.selected-product-bar {
min-height: 42px;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
}
.bar-label {
color: var(--muted);
font-size: 12px;
}
.bar-product {
overflow: hidden;
color: var(--text-strong);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-id,
.bar-empty {
color: var(--muted-2);
font-size: 11px;
}
.bar-id {
margin-left: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.exchange-error {
margin-top: 14px;
padding: 10px 12px;
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
color: var(--danger);
background: var(--danger-soft);
}
.exchange-stats {
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
}
.exchange-stat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
min-width: 160px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--muted);
font-size: 12px;
}
.exchange-stat strong {
font-size: 18px;
font-variant-numeric: tabular-nums;
}
.exchange-stat.matched strong { color: var(--success); }
.exchange-stat.no-match strong { color: var(--warning); }
.exchange-stat.failed strong { color: var(--danger); }
.record-table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.record-table {
width: 100%;
min-width: 700px;
border-collapse: collapse;
table-layout: fixed;
}
.record-table th,
.record-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.record-table th {
background: var(--surface-2);
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.record-table tbody tr:last-child td { border-bottom: 0; }
.record-table tbody tr:hover { background: var(--surface-2); }
.status-column { width: 116px; }
.count-column { width: 64px; text-align: center !important; }
.record-account { color: var(--text-strong); font-weight: 500; }
.record-status {
display: inline-flex;
padding: 3px 8px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 11px;
}
.record-status.matched { color: var(--success); background: var(--success-soft); }
.record-status.no-match { color: var(--warning); background: var(--warning-soft); }
.record-status.error { color: var(--danger); background: var(--danger-soft); }
.record-details {
display: flex;
flex-direction: column;
gap: 5px;
max-height: 180px;
overflow: auto;
}
.record-detail {
display: grid;
grid-template-columns: minmax(130px, 1fr) 34px 145px 58px;
gap: 8px;
align-items: baseline;
color: var(--muted);
font-size: 11px;
}
.detail-name {
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-quantity,
.detail-status { color: var(--muted-2); }
.detail-time { font-variant-numeric: tabular-nums; }
.record-muted,
.record-error,
.record-placeholder,
.record-empty { color: var(--muted); font-size: 12px; }
.record-error { color: var(--danger); }
.record-empty { height: 100px; text-align: center !important; vertical-align: middle !important; }
.record-placeholder {
padding: 54px 16px;
text-align: center;
}
.exchange-account-section {
margin-top: 22px;
}
.account-section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
color: var(--text-strong);
font-size: 14px;
font-weight: 600;
}
.account-section-meta {
color: var(--muted);
font-size: 11px;
font-weight: 400;
}
.exchange-account-section :deep(.panel) {
min-height: 260px;
}
@media (max-width: 760px) {
.exchange-page { padding: 16px; }
.exchange-head { align-items: flex-start; flex-direction: column; }
.exchange-head .btn { width: 100%; }
.record-detail { grid-template-columns: minmax(120px, 1fr) 34px; }
.detail-time, .detail-status { grid-column: span 2; }
}
</style>
@@ -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<InstanceType<typeof SettingsModal> | 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 管理
</button>
<button
class="nav-tab"
:class="{ active: currentPage === 'exchange' }"
@click="currentPage = 'exchange'"
>
兑换记录
</button>
</div>
<span
v-if="currentPage === 'console'"
@@ -93,6 +101,11 @@ const openSettings = () => {
<CKManager />
</section>
</template>
<!-- 兑换记录页面 -->
<template v-else-if="currentPage === 'exchange'">
<ExchangeRecords />
</template>
</main>
<SettingsModal ref="settingsModalRef" />
+93
View File
@@ -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()
+110
View File
@@ -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
+42
View File
@@ -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&timestamp=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{
+17
View File
@@ -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"`
+10
View File
@@ -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"`
}