新增商品类目(限量返场/精英专享)支持与版本号 0.2.1
10.15 抓包确认:积分中心商城 PlayMallUI.queryAllocation 的商品与 webActUI.getActPrizeList(sid=2277) pid 完全一致,属于同一批商品,因此列表无需接商城链路;真号实测 sid=2277 返回 30 项(限量返场 99 共 12 项 + 精英专享 101 共 18 项),sid=2203 已返回空。\n\n- huya:解析商品 tag23/25/26/27/28(发布时间、上架/下架时间、每日时段),新增 PrizeInfo.CategoryIDs/InCategory/StartsAt/NotYetOnSale 与响应级 CategoryStats/CategorySummary/FilterByCategory,列表日志带类目统计\n- sniper:Config.Category 限定自动选品范围,未命中提示带类目\n- CLI:新增 -category / -category-only\n- GUI:Config.Category、ListPrizes 回传类目字典与统计;界面商品列表按类目分页签、每行标注类目、未上架显示上架时间与倒计时,所选类目随配置提交给监控\n- 测试:新增 internal/huya/category_test.go 与 sniper 类目用例;README/WSS_NOTES 补记结论
This commit is contained in:
@@ -112,6 +112,8 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签)
|
||||
- `-pid`:奖品 PID。`exchange` 必填,`monitor` 可用 `-pid` 或 `-name`。
|
||||
- `-max-price`:目标奖品最高积分,`0` 表示不限制(CLI 与 GUI 设置都支持)。
|
||||
- `-min-left`:目标奖品最少剩余库存,`0` 表示不限制(CLI 与 GUI 设置都支持)。
|
||||
- `-category`:类目过滤,支持类目名(如 `限量返场`、`精英专享`)或类目 id(如 `99`);留空表示不限。
|
||||
- `-category-only`:`list` 只输出该类目的商品(配合 `-category` 使用)。
|
||||
- `-cookie`:单账号 Cookie。
|
||||
- `-cookie-file`:多账号 Cookie 文件。
|
||||
- `-transport`:`wss` 或 `http`,默认 `wss`。
|
||||
@@ -145,3 +147,29 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签)
|
||||
- 订单号格式:`sOrderId_2277_13409_<uid>_<时间戳>_<随机>`
|
||||
|
||||
该活动的列表/积分/兑换接口与 `sid=2203` 走同一套 `webActUI`,可直接用 `-cmd list -sid 2277` 验证;同一次抓包里「开通精英宝典 / 购买精英币」属于商城付费链路(`shopMiddleUI.*` + 支付宝),本项目未实现,详见 `WSS_NOTES.md`。
|
||||
|
||||
## 商品类目:限量返场 / 精英专享(2026-10-15 抓包)
|
||||
|
||||
`webActUI.getActPrizeList` 的响应除商品外还下发类目字典(`tRsp.4`),每件商品用 `tags`(TAF tag14)
|
||||
标注归属:`101=精英专享`、`99=限量返场`。2026-10-15 真号实测 `sid=2277`:
|
||||
|
||||
- 共 30 件商品:限量返场(99) 12 件(怪诞乔克服装礼包 8000 积分、天网终结者大礼包 4800 积分、潮萌小黄鸭系列…)+ 精英专享(101) 18 件(幸运币/荣耀勋章礼包、套装…)。
|
||||
- `sid=2203`(上一期活动)已停止下发商品,列表为空,需要把 SID 切到 `2277`。
|
||||
- 限量返场是「预告上架」类商品:`start_time`(TAF tag25,实测 2026-10-16 17:00:00)与 `end_time`(tag26)决定
|
||||
可兑换时间窗,页面上的「xx:xx:xx 后上架」倒计时就来自它;`daily_start/daily_end`(tag27/28)是每日可兑时段。
|
||||
|
||||
用法:
|
||||
|
||||
```bash
|
||||
# 全量列表(日志里会打出「类目=精英专享(101)=18项/限量返场(99)=12项」)
|
||||
./huya-go -cmd list -sid 2277 -cookie-file cookies.txt
|
||||
|
||||
# 只看限量返场(也支持类目 id 或模糊名,如 -category 99 / -category 返场)
|
||||
./huya-go -cmd list -sid 2277 -cookie-file cookies.txt -category 限量返场 -category-only
|
||||
|
||||
# 抢购时限定只在该类目里选品(配合 -auto)
|
||||
./huya-go -cmd monitor -sid 2277 -cookie-file cookies.txt -category 限量返场 -auto
|
||||
```
|
||||
|
||||
界面「商品列表」同样按类目分了页签(全部 / 限量返场 / 精英专享,带商品数),每行标注类目,
|
||||
未上架的商品显示上架时间与倒计时;当前选中的类目会一起提交给监控,用作自动选品的范围。
|
||||
|
||||
@@ -185,8 +185,57 @@ getGoodsInfoV5 → getSupplierInfoV5 → listPayChannelV5 → checkHyProtocolV5
|
||||
|
||||
注意函数名带版本后缀(V5/V6)。`webActUI` 在 WSS 上同样可用(实测 `checkUserBindGameAccount`、`getUserPrizeRecords`)。
|
||||
|
||||
### 积分中心商城的商品列表(`PlayMallUI.queryAllocation`)
|
||||
|
||||
2026-10-15 HAR(`m-shop.huya.com/points.html?pagecode=heping_baodian_credit_center_page`)确认:
|
||||
|
||||
```text
|
||||
tReq.0 user {uid, guid, token, ua=web&1.0.0&huya, cookie, tokenType, deviceInfo}
|
||||
tReq.1 {0:"huya", 1:1001, 3:"", 5:7, 6:"yellowcarlist", 7:""}
|
||||
tReq.2 pageId string # "heping_baodian_credit_center_page"
|
||||
tReq.3 "" tReq.4 0 tReq.5 1 tReq.6 24
|
||||
tReq.7 extraJson string # 切换类目时是 {"jumpTabId":433}
|
||||
tReq.11 list<int> # [6, 19]
|
||||
tRsp.0 {0:200(status), 1:"成功"}
|
||||
tRsp.1 {0:"和平精英宝典积分中心", 1:pageId}
|
||||
tRsp.2 list<tab>{0:tabId(343/345), 4:int, 5:bytes} # 5 是内嵌 TAF 负载
|
||||
tRsp.3 list<string> # CDN 域名
|
||||
```
|
||||
|
||||
内嵌负载解码后是页面区块(tabId 433=限量返场、431=精英专享),每个区块的 `6: list<商品>` 里带
|
||||
`tag9=2277(sid)`、`tag10="13433"(pid)`、价格是活动积分的 10 倍。
|
||||
|
||||
结论:**商城 grid 与 `webActUI.getActPrizeList(sid=2277)` 是同一批商品**(pid 完全一致),
|
||||
本项目不需要走商城链路取列表;10.13 该负载只有 431(精英专享) 一个区块,10.15 新增 433(限量返场)。
|
||||
|
||||
## 商品类目(限量返场 / 精英专享)
|
||||
|
||||
`webActUI.getActPrizeList` 响应除商品外还下发类目字典,商品侧用 `tag14` 标注归属:
|
||||
|
||||
```text
|
||||
tRsp.3 moduleId int32
|
||||
tRsp.4 list<PrizeTag>{0:id, 1:name, 2:weight} # 2026-10-15 实测:101=精英专享、99=限量返场
|
||||
PrizeInfo.14 tags string # 类目 id,多个用逗号分隔
|
||||
```
|
||||
|
||||
2026-10-15 实测 `sid=2277`:30 项商品 = 限量返场(99) 12 项(怪诞乔克服装礼包 8000 积分等,
|
||||
页面显示「xx:xx:xx 后上架」)+ 精英专享(101) 18 项。`sid=2203` 的旧活动已停止返回内容。
|
||||
Go 版:`GetActPrizeListResp.CategorySummary/CategoryStats/FilterByCategory` 与
|
||||
`PrizeInfo.CategoryIDs/InCategory/StartsAt/NotYetOnSale`,CLI `-category` / `-category-only`,
|
||||
界面商品列表按类目筛选并标注类目。
|
||||
|
||||
商品侧还有一组时间字段(此前未解析):
|
||||
|
||||
```text
|
||||
PrizeInfo.23 publish_time int64 # 商品发布时间(实测 2026-10-15 11:04:57)
|
||||
PrizeInfo.25 start_time int64 # 上架时间,页面倒计时来源(实测 2026-10-16 17:00:00)
|
||||
PrizeInfo.26 end_time int64 # 下架时间(实测 2026-10-23 23:59:59)
|
||||
PrizeInfo.27/28 daily_start/daily_end string # 每日可兑时段 "00:00:00"/"23:59:59"
|
||||
```
|
||||
|
||||
## 待完善
|
||||
|
||||
- 使用真实 Cookie 联调 `list/score/exchange`,确认服务端不再超时。
|
||||
- WSS 侧对 `launch.pingSignal` 的实际可用性需真号验证(失败已降级为日志)。
|
||||
- 补更多基于真实抓包摘要的 WSS 编解码单测。
|
||||
- 限量返场商品是「预告上架」,兑换时的服务端文案(如「未开放」)与开抢瞬间的表现需真号验证。
|
||||
|
||||
@@ -34,6 +34,8 @@ func main() {
|
||||
cookie = flag.String("cookie", "", "虎牙 Cookie")
|
||||
cookieFile = flag.String("cookie-file", "", "Cookie 文件,一行一个账号")
|
||||
sid = flag.Int("sid", 0, "活动 SID")
|
||||
category = flag.String("category", "", "类目过滤:类目名(如 限量返场)或类目 id,留空表示不限")
|
||||
categoryOnly = flag.Bool("category-only", false, "list 只输出指定类目的商品")
|
||||
pid = flag.Int("pid", 0, "奖品 PID")
|
||||
pids = flag.String("pids", "", "多个奖品 PID,逗号分隔")
|
||||
name = flag.String("name", "", "按奖品名包含匹配")
|
||||
@@ -113,6 +115,18 @@ func main() {
|
||||
if err != nil {
|
||||
exitErr(err)
|
||||
}
|
||||
logger.LogEvent(categoryCLI, logger.LevelInfo, "sid=%d 共%d项 类目=%s", *sid, len(resp.Prizes), resp.CategorySummary())
|
||||
key := strings.TrimSpace(*category)
|
||||
if *categoryOnly {
|
||||
if key == "" {
|
||||
exitErr(errors.New("-category-only 需要同时提供 -category"))
|
||||
}
|
||||
filtered := *resp
|
||||
filtered.Prizes = resp.FilterByCategory(key)
|
||||
logger.LogEvent(categoryCLI, logger.LevelInfo, "类目[%s]筛选后 %d/%d 项", key, len(filtered.Prizes), len(resp.Prizes))
|
||||
printJSON(filtered)
|
||||
return
|
||||
}
|
||||
printJSON(resp)
|
||||
case "score":
|
||||
waitForCommand(ctx, startAt)
|
||||
@@ -161,6 +175,7 @@ func main() {
|
||||
}
|
||||
cfg := sniper.Config{
|
||||
SID: int32(*sid),
|
||||
Category: strings.TrimSpace(*category),
|
||||
Loops: *loops,
|
||||
Delay: time.Duration(*delayMS) * time.Millisecond,
|
||||
Concurrency: *concurrency,
|
||||
|
||||
+197
-39
@@ -90,6 +90,8 @@ type UiConfig = {
|
||||
sid: number
|
||||
pid: number
|
||||
name: string
|
||||
// category 是类目筛选(类目 id 字符串,空串=全部),只影响监控时的自动选品范围。
|
||||
category: string
|
||||
targetText: string
|
||||
maxPrice: number
|
||||
minLeft: number
|
||||
@@ -165,10 +167,33 @@ type PrizeInfo = {
|
||||
percent?: string
|
||||
is_show_num?: number
|
||||
img?: string
|
||||
// tags 是类目 id(逗号分隔):99=限量返场、101=精英专享(以服务端下发的类目字典为准)。
|
||||
tags?: string
|
||||
publish_time?: number
|
||||
start_time?: number
|
||||
end_time?: number
|
||||
daily_start?: string
|
||||
daily_end?: string
|
||||
}
|
||||
|
||||
type PrizeTag = {
|
||||
id: number
|
||||
name: string
|
||||
weight?: number
|
||||
}
|
||||
|
||||
type CategoryStat = {
|
||||
id: number
|
||||
name: string
|
||||
count?: number
|
||||
left?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
type PrizeCache = {
|
||||
prizes: PrizeInfo[]
|
||||
categories?: PrizeTag[]
|
||||
categoryStats?: CategoryStat[]
|
||||
updatedAt: number
|
||||
sid?: number
|
||||
}
|
||||
@@ -294,6 +319,49 @@ function priceOf(row?: PrizeInfo): number {
|
||||
return row.new_score || row.score || 0
|
||||
}
|
||||
|
||||
// categoryIDsOf 解析奖品 tag14 里的类目 id(服务端可能下发多个,逗号分隔)。
|
||||
function categoryIDsOf(row?: PrizeInfo): number[] {
|
||||
return String(row?.tags || '')
|
||||
.split(',')
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((id) => Number.isFinite(id) && id > 0)
|
||||
}
|
||||
|
||||
// categoryNamesOf 把类目 id 翻译成类目名;字典里没有时用「类目<id>」兜底。
|
||||
function categoryNamesOf(row: PrizeInfo | undefined, categories: PrizeTag[]): string[] {
|
||||
return categoryIDsOf(row).map((id) => categories.find((tag) => Number(tag.id) === id)?.name || `类目${id}`)
|
||||
}
|
||||
|
||||
function formatDateTime(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const date = new Date(seconds * 1000)
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
if (ms <= 0) return ''
|
||||
const total = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
// saleStateOf 描述商品的上下架状态:限量返场是「预告上架」,页面的倒计时来自 start_time。
|
||||
function saleStateOf(row: PrizeInfo | undefined, now: Date): { onSale: boolean; label: string; countdown: string } {
|
||||
const start = Number(row?.start_time || 0)
|
||||
if (!start || start <= 0) {
|
||||
return { onSale: true, label: '', countdown: '' }
|
||||
}
|
||||
const remains = start * 1000 - now.getTime()
|
||||
if (remains > 0) {
|
||||
return { onSale: false, label: `${formatDateTime(start)} 上架`, countdown: formatCountdown(remains) }
|
||||
}
|
||||
return { onSale: true, label: '', countdown: '' }
|
||||
}
|
||||
|
||||
type InventoryInfo = {
|
||||
left?: number
|
||||
total?: number
|
||||
@@ -444,19 +512,28 @@ function readSavedPrizeCache(): PrizeCache {
|
||||
: []
|
||||
return {
|
||||
prizes: prizes as PrizeInfo[],
|
||||
categories: Array.isArray(cache.categories) ? (cache.categories as PrizeTag[]) : [],
|
||||
categoryStats: Array.isArray(cache.categoryStats) ? (cache.categoryStats as CategoryStat[]) : [],
|
||||
updatedAt: Number(cache.updatedAt || 0),
|
||||
sid: Number(cache.sid || 0) || undefined,
|
||||
}
|
||||
} catch {
|
||||
return { prizes: [], updatedAt: 0 }
|
||||
return { prizes: [], categories: [], categoryStats: [], updatedAt: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function writeSavedPrizeCache(prizes: PrizeInfo[], sid: number) {
|
||||
function writeSavedPrizeCache(
|
||||
prizes: PrizeInfo[],
|
||||
sid: number,
|
||||
categories: PrizeTag[] = [],
|
||||
categoryStats: CategoryStat[] = [],
|
||||
) {
|
||||
localStorage.setItem(
|
||||
scopedKey(prizeCacheStorageBase),
|
||||
JSON.stringify({
|
||||
prizes,
|
||||
categories,
|
||||
categoryStats,
|
||||
sid,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
@@ -498,6 +575,7 @@ function pickConfig(values: Partial<UiConfig>): UiConfig {
|
||||
sid: Number(values.sid || 0),
|
||||
pid: Number(values.pid || 0),
|
||||
name: values.name || '',
|
||||
category: values.category || '',
|
||||
targetText: values.targetText || '',
|
||||
maxPrice: Number(values.maxPrice || 0),
|
||||
minLeft: Number(values.minLeft || 0),
|
||||
@@ -552,6 +630,10 @@ export default function Dashboard() {
|
||||
const [accounts, setAccounts] = useState<AccountPreview[]>([])
|
||||
const [selectedAccountLines, setSelectedAccountLines] = useState<Key[]>([])
|
||||
const [prizes, setPrizes] = useState<PrizeInfo[]>([])
|
||||
const [prizeCategories, setPrizeCategories] = useState<PrizeTag[]>([])
|
||||
const [categoryStats, setCategoryStats] = useState<CategoryStat[]>([])
|
||||
// activeCategory 是当前选中的类目(空串=全部);监控时的自动选品也按它过滤。
|
||||
const [activeCategory, setActiveCategory] = useState('')
|
||||
const [selectedPrizeKeys, setSelectedPrizeKeys] = useState<Key[]>([])
|
||||
const [productSearch, setProductSearch] = useState('')
|
||||
const [scores, setScores] = useState<ScoreResult[]>([])
|
||||
@@ -639,13 +721,17 @@ export default function Dashboard() {
|
||||
|
||||
const savedCookies = readSavedCookies()
|
||||
const savedPrizeCache = readSavedPrizeCache()
|
||||
const savedConfig = readSavedConfig()
|
||||
form.setFieldsValue({
|
||||
...(defaults as UiConfig),
|
||||
...readSavedConfig(),
|
||||
...savedConfig,
|
||||
cookieText: cookieTextFromEntries(savedCookies),
|
||||
})
|
||||
setCookieEntries(savedCookies)
|
||||
setActiveCategory(String(savedConfig.category || ''))
|
||||
setPrizes(savedPrizeCache.prizes)
|
||||
setPrizeCategories(savedPrizeCache.categories || [])
|
||||
setCategoryStats(savedPrizeCache.categoryStats || [])
|
||||
setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt)
|
||||
setLogs((prev) => mergeLogs(prev, (logItems || []) as LogEntry[]))
|
||||
setLogPath(String(savedLogPath || instance?.logFile || ''))
|
||||
@@ -785,16 +871,30 @@ export default function Dashboard() {
|
||||
|
||||
const filteredPrizes = useMemo(() => {
|
||||
const keyword = productSearch.trim().toLowerCase()
|
||||
if (!keyword) return prizes
|
||||
return prizes.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
String(item.id).includes(keyword) ||
|
||||
String(priceOf(item)).includes(keyword),
|
||||
)
|
||||
}, [prizes, productSearch])
|
||||
const categoryID = Number(activeCategory || 0)
|
||||
return prizes.filter((item) => {
|
||||
if (categoryID > 0 && !categoryIDsOf(item).includes(categoryID)) return false
|
||||
if (!keyword) return true
|
||||
const text = [item.name, String(item.id), String(priceOf(item)), ...categoryNamesOf(item, prizeCategories)]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return text.includes(keyword)
|
||||
})
|
||||
}, [prizes, productSearch, activeCategory, prizeCategories])
|
||||
const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes])
|
||||
|
||||
// 类目页签与服务端下发的类目字典同序(限量返场/精英专享),数量优先用服务端统计。
|
||||
const categoryItems = useMemo(() => {
|
||||
const items = [{ key: '', label: `全部 ${prizes.length}` }]
|
||||
prizeCategories.forEach((tag) => {
|
||||
const id = Number(tag.id)
|
||||
const stat = categoryStats.find((item) => Number(item.id) === id)
|
||||
const count = stat?.count ?? prizes.filter((item) => categoryIDsOf(item).includes(id)).length
|
||||
items.push({ key: String(id), label: `${tag.name} ${count}` })
|
||||
})
|
||||
return items
|
||||
}, [prizeCategories, categoryStats, prizes])
|
||||
|
||||
// 日志筛选只在界面上生效;后端的 debug 开关由「调试日志」按钮控制。
|
||||
const visibleLogs = useMemo(
|
||||
() => (logFilter === 'all' ? logs : logs.filter((item) => item.level === logFilter)),
|
||||
@@ -803,6 +903,7 @@ export default function Dashboard() {
|
||||
|
||||
const config = () => ({
|
||||
...pickConfig(form.getFieldsValue(true)),
|
||||
category: activeCategory,
|
||||
selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0),
|
||||
})
|
||||
|
||||
@@ -954,20 +1055,34 @@ export default function Dashboard() {
|
||||
setLoading('list')
|
||||
ListPrizes(config())
|
||||
.then((data) => {
|
||||
const result = data as { prizes?: PrizeInfo[] }
|
||||
const result = data as {
|
||||
prizes?: PrizeInfo[]
|
||||
categories?: PrizeTag[]
|
||||
categoryStats?: CategoryStat[]
|
||||
}
|
||||
const nextPrizes = result?.prizes || []
|
||||
if (nextPrizes.length === 0) {
|
||||
if (!quiet) message.warning('当前 CK 未返回可选商品')
|
||||
return
|
||||
}
|
||||
const nextCategories = result?.categories || []
|
||||
const nextStats = result?.categoryStats || []
|
||||
const updatedAt = Date.now()
|
||||
setPrizes(nextPrizes)
|
||||
setPrizeCategories(nextCategories)
|
||||
setCategoryStats(nextStats)
|
||||
setActiveCategory((prev) =>
|
||||
prev && nextCategories.some((tag) => String(tag.id) === prev) ? prev : '',
|
||||
)
|
||||
setPrizeCacheUpdatedAt(updatedAt)
|
||||
writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0))
|
||||
writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0), nextCategories, nextStats)
|
||||
setSelectedPrizeKeys((prev) =>
|
||||
prev.filter((key) => nextPrizes.some((item) => item.id === Number(key))),
|
||||
)
|
||||
if (!quiet) message.success('商品已刷新')
|
||||
if (!quiet) {
|
||||
const summary = nextStats.map((item) => `${item.name} ${item.count ?? 0}`).join(' / ')
|
||||
message.success(summary ? `商品已刷新:${summary}` : '商品已刷新')
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!quiet) message.error(String(err))
|
||||
@@ -991,6 +1106,9 @@ export default function Dashboard() {
|
||||
onOk: () => {
|
||||
localStorage.removeItem(scopedKey(prizeCacheStorageBase))
|
||||
setPrizes([])
|
||||
setPrizeCategories([])
|
||||
setCategoryStats([])
|
||||
setActiveCategory('')
|
||||
setSelectedPrizeKeys([])
|
||||
setPrizeCacheUpdatedAt(0)
|
||||
message.success('商品缓存已清空')
|
||||
@@ -1006,6 +1124,7 @@ export default function Dashboard() {
|
||||
sid: values.sid,
|
||||
pid: values.pid,
|
||||
name: values.name,
|
||||
category: values.category,
|
||||
targetText: values.targetText,
|
||||
maxPrice: values.maxPrice,
|
||||
minLeft: values.minLeft,
|
||||
@@ -1470,7 +1589,17 @@ export default function Dashboard() {
|
||||
onSearch={setProductSearch}
|
||||
/>
|
||||
|
||||
<CurrentProduct prize={currentPrize} />
|
||||
{categoryItems.length > 1 ? (
|
||||
<Tabs
|
||||
className="category-tabs"
|
||||
size="small"
|
||||
activeKey={activeCategory}
|
||||
onChange={(key) => setActiveCategory(String(key))}
|
||||
items={categoryItems}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CurrentProduct prize={currentPrize} now={now} categories={prizeCategories} />
|
||||
|
||||
<div className="inventory-line">
|
||||
<span>商品缓存 {filteredPrizes.length} 个</span>
|
||||
@@ -1485,29 +1614,44 @@ export default function Dashboard() {
|
||||
<span>导入有效 CK 后刷新最新商品</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredPrizes.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`product-row ${selectedPrizeKeys.includes(item.id) ? 'selected' : ''}`}
|
||||
key={item.id}
|
||||
onClick={() => applyPrizeSelection([item.id])}
|
||||
>
|
||||
<div className="product-thumb">
|
||||
{item.img ? (
|
||||
<img src={item.img} alt="" />
|
||||
) : (
|
||||
<span>{String(item.id).slice(-2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="product-meta">
|
||||
<strong>{item.name}</strong>
|
||||
<div className="product-tags">
|
||||
<Tag color="gold">{priceOf(item)} 积分</Tag>
|
||||
<InventoryTags prize={item} compact />
|
||||
filteredPrizes.map((item) => {
|
||||
const sale = saleStateOf(item, now)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`product-row ${selectedPrizeKeys.includes(item.id) ? 'selected' : ''}`}
|
||||
key={item.id}
|
||||
onClick={() => applyPrizeSelection([item.id])}
|
||||
>
|
||||
<div className="product-thumb">
|
||||
{item.img ? (
|
||||
<img src={item.img} alt="" />
|
||||
) : (
|
||||
<span>{String(item.id).slice(-2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
<div className="product-meta">
|
||||
<strong>{item.name}</strong>
|
||||
<div className="product-tags">
|
||||
<Tag color="gold">{priceOf(item)} 积分</Tag>
|
||||
{categoryNamesOf(item, prizeCategories).map((name) => (
|
||||
<Tag key={name} color="purple">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
{sale.onSale ? (
|
||||
<InventoryTags prize={item} compact />
|
||||
) : (
|
||||
<Tag color="orange">{sale.label}</Tag>
|
||||
)}
|
||||
</div>
|
||||
{sale.onSale ? null : (
|
||||
<span className="product-hint">{sale.countdown} 后上架</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1870,8 +2014,17 @@ function InventoryTags({ prize, compact = false }: { prize?: PrizeInfo; compact?
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentProduct({ prize }: { prize?: PrizeInfo }) {
|
||||
function CurrentProduct({
|
||||
prize,
|
||||
now,
|
||||
categories,
|
||||
}: {
|
||||
prize?: PrizeInfo
|
||||
now: Date
|
||||
categories: PrizeTag[]
|
||||
}) {
|
||||
const name = prize?.name || '未从 CK 列表选择'
|
||||
const sale = saleStateOf(prize, now)
|
||||
return (
|
||||
<div className="current-product">
|
||||
<span>当前商品</span>
|
||||
@@ -1881,7 +2034,12 @@ function CurrentProduct({ prize }: { prize?: PrizeInfo }) {
|
||||
<>
|
||||
<Tag color="gold">{priceOf(prize)} 积分</Tag>
|
||||
<Tag>PID {prize.id}</Tag>
|
||||
<InventoryTags prize={prize} />
|
||||
{categoryNamesOf(prize, categories).map((item) => (
|
||||
<Tag key={item} color="purple">
|
||||
{item}
|
||||
</Tag>
|
||||
))}
|
||||
{sale.onSale ? <InventoryTags prize={prize} /> : <Tag color="orange">{sale.label}</Tag>}
|
||||
</>
|
||||
) : (
|
||||
<Tag>CK 商品源</Tag>
|
||||
|
||||
@@ -185,6 +185,26 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
margin: -4px 0 0;
|
||||
}
|
||||
|
||||
.category-tabs .ant-tabs-nav {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.category-tabs .ant-tabs-tab {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #d46b08;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desk {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
@@ -23,6 +23,7 @@ export namespace gui {
|
||||
wssHost: string;
|
||||
sid: number;
|
||||
pid: number;
|
||||
category: string;
|
||||
name: string;
|
||||
targetText: string;
|
||||
maxPrice: number;
|
||||
@@ -55,6 +56,7 @@ export namespace gui {
|
||||
this.wssHost = source["wssHost"];
|
||||
this.sid = source["sid"];
|
||||
this.pid = source["pid"];
|
||||
this.category = source["category"];
|
||||
this.name = source["name"];
|
||||
this.targetText = source["targetText"];
|
||||
this.maxPrice = source["maxPrice"];
|
||||
@@ -161,6 +163,8 @@ export namespace gui {
|
||||
status: number;
|
||||
msg: string;
|
||||
prizes: huya.PrizeInfo[];
|
||||
categories: huya.PrizeTag[];
|
||||
categoryStats: huya.CategoryStat[];
|
||||
raw?: huya.GetActPrizeListResp;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -173,6 +177,8 @@ export namespace gui {
|
||||
this.status = source["status"];
|
||||
this.msg = source["msg"];
|
||||
this.prizes = this.convertValues(source["prizes"], huya.PrizeInfo);
|
||||
this.categories = this.convertValues(source["categories"], huya.PrizeTag);
|
||||
this.categoryStats = this.convertValues(source["categoryStats"], huya.CategoryStat);
|
||||
this.raw = this.convertValues(source["raw"], huya.GetActPrizeListResp);
|
||||
}
|
||||
|
||||
@@ -291,6 +297,26 @@ export namespace huya {
|
||||
this.button_desc = source["button_desc"];
|
||||
}
|
||||
}
|
||||
export class CategoryStat {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
left: number;
|
||||
total: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CategoryStat(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.count = source["count"];
|
||||
this.left = source["left"];
|
||||
this.total = source["total"];
|
||||
}
|
||||
}
|
||||
export class ExchangeInfo {
|
||||
pid: number;
|
||||
exchange_score: number;
|
||||
@@ -341,6 +367,11 @@ export namespace huya {
|
||||
tags?: string;
|
||||
left_num: number;
|
||||
is_show_num: number;
|
||||
publish_time?: number;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
daily_start?: string;
|
||||
daily_end?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new PrizeInfo(source);
|
||||
@@ -365,6 +396,11 @@ export namespace huya {
|
||||
this.tags = source["tags"];
|
||||
this.left_num = source["left_num"];
|
||||
this.is_show_num = source["is_show_num"];
|
||||
this.publish_time = source["publish_time"];
|
||||
this.start_time = source["start_time"];
|
||||
this.end_time = source["end_time"];
|
||||
this.daily_start = source["daily_start"];
|
||||
this.daily_end = source["daily_end"];
|
||||
}
|
||||
}
|
||||
export class GetActPrizeListResp {
|
||||
|
||||
+25
-16
@@ -54,10 +54,12 @@ type App struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Transport string `json:"transport"`
|
||||
WSSHost string `json:"wssHost"`
|
||||
SID int32 `json:"sid"`
|
||||
PID int32 `json:"pid"`
|
||||
Transport string `json:"transport"`
|
||||
WSSHost string `json:"wssHost"`
|
||||
SID int32 `json:"sid"`
|
||||
PID int32 `json:"pid"`
|
||||
// Category 是类目(限量返场/精英专享,或类目 id):只影响监控时的自动选品范围。
|
||||
Category string `json:"category"`
|
||||
Name string `json:"name"`
|
||||
TargetText string `json:"targetText"`
|
||||
MaxPrice int64 `json:"maxPrice"`
|
||||
@@ -107,11 +109,14 @@ type InstanceInfo struct {
|
||||
}
|
||||
|
||||
type PrizeListResult struct {
|
||||
UID int64 `json:"uid"`
|
||||
Status int32 `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Prizes []huya.PrizeInfo `json:"prizes"`
|
||||
Raw *huya.GetActPrizeListResp `json:"raw,omitempty"`
|
||||
UID int64 `json:"uid"`
|
||||
Status int32 `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Prizes []huya.PrizeInfo `json:"prizes"`
|
||||
// Categories 是服务端下发的类目字典(限量返场/精英专享),CategoryStats 是对应的商品数统计。
|
||||
Categories []huya.PrizeTag `json:"categories"`
|
||||
CategoryStats []huya.CategoryStat `json:"categoryStats"`
|
||||
Raw *huya.GetActPrizeListResp `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type ScoreResult struct {
|
||||
@@ -351,13 +356,15 @@ func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
|
||||
return PrizeListResult{}, err
|
||||
}
|
||||
left, total, leftKnown, totalKnown := prizeInventorySummary(resp.Prizes)
|
||||
a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg)
|
||||
a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg)
|
||||
return PrizeListResult{
|
||||
UID: accounts[0].UID,
|
||||
Status: resp.Status,
|
||||
Msg: resp.Msg,
|
||||
Prizes: resp.Prizes,
|
||||
Raw: resp,
|
||||
UID: accounts[0].UID,
|
||||
Status: resp.Status,
|
||||
Msg: resp.Msg,
|
||||
Prizes: resp.Prizes,
|
||||
Categories: resp.Tags,
|
||||
CategoryStats: resp.CategoryStats(),
|
||||
Raw: resp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -489,7 +496,7 @@ func (a *App) StartMonitor(cfg Config) error {
|
||||
}
|
||||
|
||||
a.emitState(true, "运行中")
|
||||
a.addLog("info", "启动监控: sid=%d targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange)
|
||||
a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, sniper.CategoryText(cfg.Category), len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange)
|
||||
|
||||
go func() {
|
||||
defer closeClient()
|
||||
@@ -503,6 +510,7 @@ func (a *App) StartMonitor(cfg Config) error {
|
||||
}
|
||||
runnerCfg := sniper.Config{
|
||||
SID: cfg.SID,
|
||||
Category: cfg.Category,
|
||||
Loops: cfg.Loops,
|
||||
Delay: time.Duration(cfg.DelayMS) * time.Millisecond,
|
||||
Concurrency: cfg.Concurrency,
|
||||
@@ -637,6 +645,7 @@ func normalizeConfig(cfg Config) Config {
|
||||
cfg.TimeoutSec = 15
|
||||
}
|
||||
cfg.Name = strings.TrimSpace(cfg.Name)
|
||||
cfg.Category = strings.TrimSpace(cfg.Category)
|
||||
cfg.TargetText = strings.TrimSpace(cfg.TargetText)
|
||||
cfg.CookieText = strings.TrimSpace(cfg.CookieText)
|
||||
cfg.StartAt = strings.TrimSpace(cfg.StartAt)
|
||||
|
||||
+193
-4
@@ -1,9 +1,11 @@
|
||||
package huya
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"huya-go/internal/taf"
|
||||
)
|
||||
@@ -192,9 +194,18 @@ type PrizeInfo struct {
|
||||
Status int32 `json:"status"`
|
||||
Percent string `json:"percent"`
|
||||
NewScore int64 `json:"new_score"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
LeftNum int64 `json:"left_num"`
|
||||
IsShowNum int32 `json:"is_show_num"`
|
||||
// Tags 是奖品归属的类目 id(tag14,多个用逗号分隔)。
|
||||
// 2026-10-15 实测:99=限量返场、101=精英专享,与响应 tag4 的字典一一对应。
|
||||
Tags string `json:"tags,omitempty"`
|
||||
LeftNum int64 `json:"left_num"`
|
||||
IsShowNum int32 `json:"is_show_num"`
|
||||
// 以下为 2026-10-15 抓包新确认的时间字段:限量返场商品是「预告上架」,
|
||||
// 页面的「xx:xx:xx 后上架」倒计时就来自 StartTime。
|
||||
PublishTime int64 `json:"publish_time,omitempty"` // tag23 商品发布时间
|
||||
StartTime int64 `json:"start_time,omitempty"` // tag25 上架时间(秒)
|
||||
EndTime int64 `json:"end_time,omitempty"` // tag26 下架时间(秒)
|
||||
DailyStart string `json:"daily_start,omitempty"` // tag27 每日可兑时段开始
|
||||
DailyEnd string `json:"daily_end,omitempty"` // tag28 每日可兑时段结束
|
||||
}
|
||||
|
||||
func (p *PrizeInfo) WriteTo(w *taf.Writer) error { return nil }
|
||||
@@ -249,10 +260,75 @@ func (p *PrizeInfo) ReadFrom(r *taf.Reader) error {
|
||||
if p.LeftNum, err = r.ReadInt64(15, false, p.LeftNum); err != nil {
|
||||
return err
|
||||
}
|
||||
p.IsShowNum, err = r.ReadInt32(16, false, p.IsShowNum)
|
||||
if p.IsShowNum, err = r.ReadInt32(16, false, p.IsShowNum); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.PublishTime, err = r.ReadInt64(23, false, p.PublishTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.StartTime, err = r.ReadInt64(25, false, p.StartTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.EndTime, err = r.ReadInt64(26, false, p.EndTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.DailyStart, err = r.ReadString(27, false, p.DailyStart); err != nil {
|
||||
return err
|
||||
}
|
||||
p.DailyEnd, err = r.ReadString(28, false, p.DailyEnd)
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryIDs 返回奖品归属的类目 id(tag14,逗号分隔,可能是空串)。
|
||||
func (p PrizeInfo) CategoryIDs() []int64 {
|
||||
text := strings.TrimSpace(p.Tags)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(text, ",")
|
||||
ids := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// InCategory 判断奖品是否属于指定类目 id。
|
||||
func (p PrizeInfo) InCategory(id int64) bool {
|
||||
for _, item := range p.CategoryIDs() {
|
||||
if item == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StartsAt 返回上架时间;未下发时返回零值时间。
|
||||
func (p PrizeInfo) StartsAt() time.Time {
|
||||
if p.StartTime <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(p.StartTime, 0)
|
||||
}
|
||||
|
||||
// EndsAt 返回下架时间;未下发时返回零值时间。
|
||||
func (p PrizeInfo) EndsAt() time.Time {
|
||||
if p.EndTime <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(p.EndTime, 0)
|
||||
}
|
||||
|
||||
// NotYetOnSale 表示商品只是「预告上架」,还没到可兑换时间。
|
||||
func (p PrizeInfo) NotYetOnSale(now time.Time) bool {
|
||||
start := p.StartsAt()
|
||||
return !start.IsZero() && now.Before(start)
|
||||
}
|
||||
|
||||
func (p PrizeInfo) Price() int64 {
|
||||
if p.NewScore != 0 {
|
||||
return p.NewScore
|
||||
@@ -347,6 +423,119 @@ func (r *GetActPrizeListResp) ReadFrom(rd *taf.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CategoryStat 汇总一个类目下的商品数量与库存。
|
||||
type CategoryStat struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
Left int64 `json:"left"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// CategoryName 按类目 id 查名字;字典里没有时返回空串。
|
||||
func (r GetActPrizeListResp) CategoryName(id int64) string {
|
||||
for _, tag := range r.Tags {
|
||||
if tag.ID == id {
|
||||
return tag.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CategoriesOf 返回某个奖品所属的类目(字典里找不到的 id 用 "类目<id>" 兜底)。
|
||||
func (r GetActPrizeListResp) CategoriesOf(p PrizeInfo) []PrizeTag {
|
||||
ids := p.CategoryIDs()
|
||||
items := make([]PrizeTag, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if name := r.CategoryName(id); name != "" {
|
||||
items = append(items, PrizeTag{ID: id, Name: name})
|
||||
continue
|
||||
}
|
||||
items = append(items, PrizeTag{ID: id, Name: fmt.Sprintf("类目%d", id)})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// CategoryStats 按响应里的类目字典统计商品数、剩余量与总量。
|
||||
//
|
||||
// 统计顺序与响应下发的类目顺序一致(页面 tab 顺序),便于日志与界面直接复用。
|
||||
func (r GetActPrizeListResp) CategoryStats() []CategoryStat {
|
||||
stats := make([]CategoryStat, 0, len(r.Tags))
|
||||
for _, tag := range r.Tags {
|
||||
prizes := make([]PrizeInfo, 0, len(r.Prizes))
|
||||
for _, prize := range r.Prizes {
|
||||
if prize.InCategory(tag.ID) {
|
||||
prizes = append(prizes, prize)
|
||||
}
|
||||
}
|
||||
left, total, _, _ := prizeListInventorySummary(prizes)
|
||||
stats = append(stats, CategoryStat{ID: tag.ID, Name: tag.Name, Count: len(prizes), Left: left, Total: total})
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// CategorySummary 生成「限量返场(99)=12项/精英专享(101)=18项」这样的单行摘要,供日志使用。
|
||||
func (r GetActPrizeListResp) CategorySummary() string {
|
||||
stats := r.CategoryStats()
|
||||
if len(stats) == 0 {
|
||||
return fmt.Sprintf("未下发类目(共%d项)", len(r.Prizes))
|
||||
}
|
||||
parts := make([]string, 0, len(stats))
|
||||
for _, stat := range stats {
|
||||
parts = append(parts, fmt.Sprintf("%s(%d)=%d项", stat.Name, stat.ID, stat.Count))
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// FilterByCategory 按类目过滤商品。
|
||||
//
|
||||
// category 支持:空串(不过滤)、类目 id(如 "99")、类目名(如 "限量返场",
|
||||
// 先精确匹配再模糊匹配)。类目字典里没有匹配项时,退回用原始 tag14 文本匹配,
|
||||
// 这样服务端新增类目但没下发字典时也不会把商品全部过滤掉。
|
||||
func (r GetActPrizeListResp) FilterByCategory(category string) []PrizeInfo {
|
||||
key := strings.TrimSpace(category)
|
||||
if key == "" {
|
||||
return r.Prizes
|
||||
}
|
||||
ids := map[int64]bool{}
|
||||
if id, err := strconv.ParseInt(key, 10, 64); err == nil {
|
||||
ids[id] = true
|
||||
} else {
|
||||
for _, tag := range r.Tags {
|
||||
if strings.EqualFold(strings.TrimSpace(tag.Name), key) {
|
||||
ids[tag.ID] = true
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
for _, tag := range r.Tags {
|
||||
if strings.Contains(tag.Name, key) {
|
||||
ids[tag.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]PrizeInfo, 0, len(r.Prizes))
|
||||
for _, prize := range r.Prizes {
|
||||
if len(ids) > 0 {
|
||||
matched := false
|
||||
for id := range ids {
|
||||
if prize.InCategory(id) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
out = append(out, prize)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(prize.Tags, key) {
|
||||
out = append(out, prize)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type GetUserPrizeRecordsReq struct {
|
||||
User ActivityUserID
|
||||
SID int32
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package huya
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"huya-go/internal/taf"
|
||||
)
|
||||
|
||||
// testPrize 只写入断言用到的字段,字段顺序必须与真实响应一致(tag 递增)。
|
||||
type testPrize struct {
|
||||
id int64
|
||||
name string
|
||||
score int64
|
||||
num int64
|
||||
percent string
|
||||
tags string
|
||||
startTime int64
|
||||
endTime int64
|
||||
}
|
||||
|
||||
func (p testPrize) WriteTo(w *taf.Writer) error {
|
||||
if err := w.WriteInt64(0, p.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteString(2, p.name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt64(3, p.score); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt64(10, p.num); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt32(11, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteString(12, p.percent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt64(13, p.score); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteString(14, p.tags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt64(25, p.startTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt64(26, p.endTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteString(27, "00:00:00"); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.WriteString(28, "23:59:59")
|
||||
}
|
||||
|
||||
type testTag struct {
|
||||
id int64
|
||||
name string
|
||||
weight int32
|
||||
}
|
||||
|
||||
func (t testTag) WriteTo(w *taf.Writer) error {
|
||||
if err := w.WriteInt64(0, t.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteString(1, t.name); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.WriteInt32(2, int64(t.weight))
|
||||
}
|
||||
|
||||
// decodePrizeList 拼一个和线上同构的 getActPrizeList 响应再解码回来。
|
||||
func decodePrizeList(t *testing.T, tags, prizes []taf.Marshaler) GetActPrizeListResp {
|
||||
t.Helper()
|
||||
w := taf.NewWriter()
|
||||
if err := w.WriteStructBegin(0); err != nil {
|
||||
t.Fatalf("写入结构体失败: %v", err)
|
||||
}
|
||||
if err := w.WriteInt32(0, 200); err != nil {
|
||||
t.Fatalf("写入 status 失败: %v", err)
|
||||
}
|
||||
if err := w.WriteString(1, "请求成功"); err != nil {
|
||||
t.Fatalf("写入 msg 失败: %v", err)
|
||||
}
|
||||
if err := w.WriteListStruct(2, prizes); err != nil {
|
||||
t.Fatalf("写入奖品列表失败: %v", err)
|
||||
}
|
||||
if err := w.WriteInt32(3, 20575); err != nil {
|
||||
t.Fatalf("写入 moduleId 失败: %v", err)
|
||||
}
|
||||
if err := w.WriteListStruct(4, tags); err != nil {
|
||||
t.Fatalf("写入类目字典失败: %v", err)
|
||||
}
|
||||
if err := w.WriteStructEnd(); err != nil {
|
||||
t.Fatalf("写入结构体结尾失败: %v", err)
|
||||
}
|
||||
|
||||
reader := taf.NewReader(w.Bytes())
|
||||
if _, typ, err := reader.ReadHead(); err != nil || typ != taf.TypeStructBegin {
|
||||
t.Fatalf("响应头解析失败: typ=%v err=%v", typ, err)
|
||||
}
|
||||
var resp GetActPrizeListResp
|
||||
if err := resp.ReadFrom(reader); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func samplePrizeList(t *testing.T) GetActPrizeListResp {
|
||||
t.Helper()
|
||||
// 时间取 2026-10-16 17:00:00(限量返场实际开抢时间)与 2026-10-23 23:59:59。
|
||||
return decodePrizeList(t,
|
||||
[]taf.Marshaler{
|
||||
testTag{id: 101, name: "精英专享", weight: 2},
|
||||
testTag{id: 99, name: "限量返场", weight: 1},
|
||||
},
|
||||
[]taf.Marshaler{
|
||||
testPrize{id: 13415, name: "套装-雪山精英", score: 600, num: 50000, percent: "93", tags: "101"},
|
||||
testPrize{id: 13433, name: "怪诞乔克服装礼包", score: 8000, num: 6001, percent: "100", tags: "99", startTime: 1792141200, endTime: 1792771200},
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrizeListCategoryHelpers(t *testing.T) {
|
||||
resp := samplePrizeList(t)
|
||||
if len(resp.Prizes) != 2 {
|
||||
t.Fatalf("奖品数 = %d, 期望 2", len(resp.Prizes))
|
||||
}
|
||||
|
||||
limited := resp.Prizes[1]
|
||||
if got := limited.CategoryIDs(); len(got) != 1 || got[0] != 99 {
|
||||
t.Fatalf("CategoryIDs = %v, 期望 [99]", got)
|
||||
}
|
||||
if !limited.InCategory(99) || limited.InCategory(101) {
|
||||
t.Fatalf("InCategory 判定异常: tags=%s", limited.Tags)
|
||||
}
|
||||
|
||||
start := limited.StartsAt()
|
||||
if start.IsZero() || start.Unix() != 1792141200 {
|
||||
t.Fatalf("StartsAt = %v, 期望 epoch 1792141200", start)
|
||||
}
|
||||
if end := limited.EndsAt(); end.Unix() != 1792771200 {
|
||||
t.Fatalf("EndsAt = %d, 期望 epoch 1792771200", end.Unix())
|
||||
}
|
||||
if !limited.NotYetOnSale(start.Add(-time.Minute)) {
|
||||
t.Fatal("上架前应判定为未开售")
|
||||
}
|
||||
if limited.NotYetOnSale(start.Add(time.Minute)) {
|
||||
t.Fatal("上架后不应判定为未开售")
|
||||
}
|
||||
if resp.Prizes[0].NotYetOnSale(time.Now().Add(-100 * time.Hour)) {
|
||||
t.Fatal("未下发上架时间的商品不应判定为未开售")
|
||||
}
|
||||
|
||||
if name := resp.CategoryName(99); name != "限量返场" {
|
||||
t.Fatalf("CategoryName(99) = %q", name)
|
||||
}
|
||||
cats := resp.CategoriesOf(limited)
|
||||
if len(cats) != 1 || cats[0].Name != "限量返场" {
|
||||
t.Fatalf("CategoriesOf = %v", cats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrizeListCategoryStatsAndSummary(t *testing.T) {
|
||||
resp := samplePrizeList(t)
|
||||
stats := resp.CategoryStats()
|
||||
if len(stats) != 2 {
|
||||
t.Fatalf("类目数 = %d, 期望 2", len(stats))
|
||||
}
|
||||
if stats[0].Name != "精英专享" || stats[0].Count != 1 || stats[0].ID != 101 {
|
||||
t.Fatalf("精英专享统计异常: %+v", stats[0])
|
||||
}
|
||||
if stats[1].Name != "限量返场" || stats[1].Count != 1 || stats[1].ID != 99 {
|
||||
t.Fatalf("限量返场统计异常: %+v", stats[1])
|
||||
}
|
||||
// 剩余库存按 percent*num 估算:50000*93% = 46500、6001*100% = 6001。
|
||||
if stats[0].Left != 46500 {
|
||||
t.Fatalf("精英专享剩余 = %d, 期望 46500", stats[0].Left)
|
||||
}
|
||||
if stats[1].Left != 6001 {
|
||||
t.Fatalf("限量返场剩余 = %d, 期望 6001", stats[1].Left)
|
||||
}
|
||||
|
||||
want := "精英专享(101)=1项/限量返场(99)=1项"
|
||||
if got := resp.CategorySummary(); got != want {
|
||||
t.Fatalf("CategorySummary = %q, 期望 %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterByCategory(t *testing.T) {
|
||||
resp := samplePrizeList(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
category string
|
||||
wantIDs []int64
|
||||
}{
|
||||
{name: "不限类目", category: "", wantIDs: []int64{13415, 13433}},
|
||||
{name: "按类目名精确匹配", category: "限量返场", wantIDs: []int64{13433}},
|
||||
{name: "按类目名带空格", category: " 精英专享 ", wantIDs: []int64{13415}},
|
||||
{name: "按类目 id", category: "99", wantIDs: []int64{13433}},
|
||||
{name: "按类目名模糊匹配", category: "返场", wantIDs: []int64{13433}},
|
||||
{name: "字典里没有的类目", category: "不存在的类目", wantIDs: nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
filtered := resp.FilterByCategory(tc.category)
|
||||
if len(filtered) != len(tc.wantIDs) {
|
||||
t.Fatalf("命中 %d 项, 期望 %d 项", len(filtered), len(tc.wantIDs))
|
||||
}
|
||||
for i, want := range tc.wantIDs {
|
||||
if filtered[i].ID != want {
|
||||
t.Fatalf("第 %d 项 pid = %d, 期望 %d", i, filtered[i].ID, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterByCategoryFallsBackToRawTags(t *testing.T) {
|
||||
// 服务端新增类目但没下发字典时,用原始 tag14 文本兜底,避免把商品全过滤掉。
|
||||
resp := decodePrizeList(t,
|
||||
nil,
|
||||
[]taf.Marshaler{testPrize{id: 1, name: "新类目商品", tags: "777"}},
|
||||
)
|
||||
if got := resp.FilterByCategory("777"); len(got) != 1 {
|
||||
t.Fatalf("兜底匹配命中 %d 项, 期望 1 项", len(got))
|
||||
}
|
||||
if stats := resp.CategoryStats(); len(stats) != 0 {
|
||||
t.Fatalf("无字典时不应产出类目统计, 实际 %v", stats)
|
||||
}
|
||||
if summary := resp.CategorySummary(); summary != "未下发类目(共1项)" {
|
||||
t.Fatalf("CategorySummary = %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryIDsIgnoresInvalidValues(t *testing.T) {
|
||||
prize := PrizeInfo{Tags: "99, 101,abc,"}
|
||||
ids := prize.CategoryIDs()
|
||||
if len(ids) != 2 || ids[0] != 99 || ids[1] != 101 {
|
||||
t.Fatalf("CategoryIDs = %v", ids)
|
||||
}
|
||||
if empty := (PrizeInfo{}).CategoryIDs(); empty != nil {
|
||||
t.Fatalf("空 tags 应返回 nil, 实际 %v", empty)
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func (c *HTTPClient) GetActPrizeList(ctx context.Context, uid int64, cookie stri
|
||||
_, err := c.CallRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1})
|
||||
if err == nil {
|
||||
left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes)
|
||||
c.logf("[HTTP] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg)
|
||||
c.logf("[HTTP] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg)
|
||||
}
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
@@ -723,7 +723,7 @@ func (c *WSSClient) GetActPrizeList(ctx context.Context, uid int64, cookie strin
|
||||
_, err = session.callRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp)
|
||||
if err == nil {
|
||||
left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes)
|
||||
c.logf("[WSS] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg)
|
||||
c.logf("[WSS] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg)
|
||||
}
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ const (
|
||||
CategoryCondition = "condition"
|
||||
)
|
||||
|
||||
// CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。
|
||||
func CategoryText(category string) string {
|
||||
key := strings.TrimSpace(category)
|
||||
if key == "" {
|
||||
return "不限"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。
|
||||
func LogLevelForCategory(category string) string {
|
||||
switch category {
|
||||
@@ -57,7 +66,10 @@ type Target struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
SID int32
|
||||
SID int32
|
||||
// Category 限定只在某个类目(限量返场/精英专享,或类目 id)里挑选可兑奖品,
|
||||
// 留空表示不限制。见 huya.GetActPrizeListResp.FilterByCategory。
|
||||
Category string
|
||||
Loops int
|
||||
Delay time.Duration
|
||||
Concurrency int
|
||||
@@ -299,9 +311,11 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient,
|
||||
return
|
||||
}
|
||||
|
||||
// 只按目标类目匹配:类目为空表示不限制(限量返场/精英专享都参与)。
|
||||
prizes := list.resp.FilterByCategory(cfg.Category)
|
||||
for _, index := range listTargets {
|
||||
target := targets[index]
|
||||
prize := matchPrize(list.resp.Prizes, target)
|
||||
prize := matchPrize(prizes, target)
|
||||
if prize == nil {
|
||||
result := Result{
|
||||
Loop: loop,
|
||||
@@ -310,7 +324,7 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient,
|
||||
Target: target,
|
||||
Action: "not_found",
|
||||
Category: CategoryNotFound,
|
||||
Message: "未查询到所需物品,继续监控",
|
||||
Message: notFoundMessage(cfg.Category),
|
||||
}
|
||||
if emit != nil {
|
||||
emit(result)
|
||||
@@ -595,6 +609,7 @@ func (s *runState) allDone() bool {
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg Config) Config {
|
||||
cfg.Category = strings.TrimSpace(cfg.Category)
|
||||
if cfg.Loops <= 0 {
|
||||
cfg.Loops = 1
|
||||
}
|
||||
@@ -662,6 +677,14 @@ func shouldDirectExchange(target Target, cfg Config) bool {
|
||||
return cfg.DirectExchange && !cfg.ListBeforeExchange && target.PID > 0 && !target.Auto
|
||||
}
|
||||
|
||||
// notFoundMessage 在未命中时把类目限制写进提示,便于区分「被类目过滤掉」与「列表里确实没有」。
|
||||
func notFoundMessage(category string) string {
|
||||
if strings.TrimSpace(category) == "" {
|
||||
return "未查询到所需物品,继续监控"
|
||||
}
|
||||
return fmt.Sprintf("类目[%s]内未查询到所需物品,继续监控", strings.TrimSpace(category))
|
||||
}
|
||||
|
||||
func matchPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
|
||||
if target.Auto {
|
||||
return autoPrize(prizes, target)
|
||||
|
||||
@@ -261,6 +261,77 @@ func TestRunTargetsAutoSelectPrefersPrizeWithLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTargetsCategoryFilter 验证按类目限定后只会在该类的商品里成交。
|
||||
func TestRunTargetsCategoryFilter(t *testing.T) {
|
||||
var gotPID int32
|
||||
client := &fakeActivityClient{
|
||||
listResp: &huya.GetActPrizeListResp{
|
||||
Status: 200,
|
||||
Msg: "请求成功",
|
||||
Tags: []huya.PrizeTag{
|
||||
{ID: 101, Name: "精英专享", Weight: 2},
|
||||
{ID: 99, Name: "限量返场", Weight: 1},
|
||||
},
|
||||
Prizes: []huya.PrizeInfo{
|
||||
{ID: 13415, Name: "套装-雪山精英", NewScore: 600, LeftNum: 100, Tags: "101"},
|
||||
{ID: 13433, Name: "怪诞乔克服装礼包", NewScore: 8000, LeftNum: 50, Tags: "99"},
|
||||
},
|
||||
},
|
||||
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
|
||||
gotPID = pid
|
||||
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
|
||||
},
|
||||
}
|
||||
runner := Runner{Client: client}
|
||||
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{
|
||||
SID: 2277,
|
||||
Category: "限量返场",
|
||||
Loops: 1,
|
||||
StopAccountAfterSuccess: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPID != 13433 {
|
||||
t.Fatalf("gotPID=%d, want 13433(应只在限量返场里选品)", gotPID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTargetsCategoryFilterMisses 验证类目里没有目标时给出带类目的未命中提示。
|
||||
func TestRunTargetsCategoryFilterMisses(t *testing.T) {
|
||||
client := &fakeActivityClient{
|
||||
listResp: &huya.GetActPrizeListResp{
|
||||
Status: 200,
|
||||
Msg: "请求成功",
|
||||
Tags: []huya.PrizeTag{{ID: 99, Name: "限量返场", Weight: 1}},
|
||||
Prizes: []huya.PrizeInfo{{ID: 13415, Name: "套装-雪山精英", NewScore: 600, Tags: "101"}},
|
||||
},
|
||||
}
|
||||
results := make([]Result, 0, 4)
|
||||
runner := Runner{Client: client}
|
||||
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Name: "雪山"}}, Config{
|
||||
SID: 2277,
|
||||
Category: "限量返场",
|
||||
Loops: 1,
|
||||
ListBeforeExchange: true,
|
||||
}, func(result Result) {
|
||||
results = append(results, result)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Category != CategoryNotFound {
|
||||
t.Fatalf("results=%+v, want 单条 not_found", results)
|
||||
}
|
||||
if results[0].Message != "类目[限量返场]内未查询到所需物品,继续监控" {
|
||||
t.Fatalf("Message=%q", results[0].Message)
|
||||
}
|
||||
_, exchangeCount, _ := client.counts()
|
||||
if exchangeCount != 0 {
|
||||
t.Fatalf("exchangeCount=%d, want 0", exchangeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) {
|
||||
client := &fakeActivityClient{}
|
||||
runner := Runner{Client: client}
|
||||
|
||||
@@ -5,10 +5,10 @@ package version
|
||||
//
|
||||
// 构建时可用 ldflags 覆盖(构建脚本就是这么做的):
|
||||
//
|
||||
// go build -ldflags "-X huya-go/internal/version.Version=0.2.0"
|
||||
var Version = "0.2.0"
|
||||
// go build -ldflags "-X huya-go/internal/version.Version=0.2.1"
|
||||
var Version = "0.2.1"
|
||||
|
||||
// Display 返回带 v 前缀的展示版本号,例如 v0.2.0。
|
||||
// Display 返回带 v 前缀的展示版本号,例如 v0.2.1。
|
||||
func Display() string {
|
||||
return "v" + Version
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"info": {
|
||||
"companyName": "huya-go",
|
||||
"productName": "虎牙抢购助手",
|
||||
"productVersion": "0.2.0",
|
||||
"productVersion": "0.2.1",
|
||||
"copyright": "",
|
||||
"comments": "虎牙活动积分兑换/抢购工具"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user