新增商品类目(限量返场/精英专享)支持与版本号 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:
yml2213
2026-10-15 18:23:38 +08:00
parent 4208f1d82f
commit d21b06a311
15 changed files with 913 additions and 68 deletions
+28
View File
@@ -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
```
界面「商品列表」同样按类目分了页签(全部 / 限量返场 / 精英专享,带商品数),每行标注类目,
未上架的商品显示上架时间与倒计时;当前选中的类目会一起提交给监控,用作自动选品的范围。
+49
View File
@@ -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 编解码单测。
- 限量返场商品是「预告上架」,兑换时的服务端文案(如「未开放」)与开抢瞬间的表现需真号验证。
+15
View File
@@ -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
View File
@@ -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>
+20
View File
@@ -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;
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
+247
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+26 -3
View File
@@ -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)
+71
View File
@@ -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}
+3 -3
View File
@@ -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
View File
@@ -9,7 +9,7 @@
"info": {
"companyName": "huya-go",
"productName": "虎牙抢购助手",
"productVersion": "0.2.0",
"productVersion": "0.2.1",
"copyright": "",
"comments": "虎牙活动积分兑换/抢购工具"
}