新增和平小店兑换记录查询
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user