From dc401afd2647404c2cdb06e01700debd473aac30 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 19 Jun 2026 10:57:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=97=A5=E5=BF=97=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- main.go | 235 +++++++++++++++++++++++++++-- static/index.html | 368 ++++++++++++++++++++++++++++------------------ user.txt | 1 + 4 files changed, 451 insertions(+), 157 deletions(-) create mode 100644 user.txt diff --git a/.gitignore b/.gitignore index 0b293d1..66be280 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea .cscode -*.iml \ No newline at end of file +*.iml +logs/ +success_images/ diff --git a/main.go b/main.go index cb9638a..6167976 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ const ( userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" defaultAttempts = 1 defaultConcurrency = 80 + productCacheTTL = 30 * time.Second ) var cst = time.FixedZone("CST", 8*3600) @@ -70,6 +71,16 @@ type Product struct { EliteLimited int `json:"eliteLimited"` } +type ProductPayload struct { + Success bool `json:"success"` + Source string `json:"source"` + Products []Product `json:"products"` + Count int `json:"count"` + FetchedAt string `json:"fetchedAt"` + AgeSecond int `json:"ageSecond"` + Stale bool `json:"stale"` +} + type AccountView struct { Index int `json:"index"` Name string `json:"name"` @@ -88,15 +99,24 @@ type APIResponse struct { Data json.RawMessage `json:"data"` } +type LogEntry struct { + ID int64 `json:"id"` + Time string `json:"time"` + Level string `json:"level"` + Message string `json:"message"` +} + type appState struct { config Config client *DouyuClient + logs *memoryLogStore accountsMu sync.RWMutex accounts []Account productsMu sync.RWMutex products []Product + productsAt time.Time resultsMu sync.RWMutex results map[string]string @@ -128,6 +148,14 @@ type ExchangeResult struct { Message string } +type memoryLogStore struct { + mu sync.RWMutex + nextID int64 + max int + filePath string + entries []LogEntry +} + var state = &appState{ config: Config{TimeAPIURL: defaultTimeAPI}, results: make(map[string]string), @@ -135,6 +163,9 @@ var state = &appState{ func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) + state.logs = newMemoryLogStore(800) + closeLogFile := initLogging(state.logs) + defer closeLogFile() state.config = loadConfig() state.client = NewDouyuClient(state.config.TimeAPIURL) @@ -155,6 +186,115 @@ func main() { log.Fatal(http.ListenAndServe(":2169", mux)) } +// ==================== 日志系统 ==================== + +// newMemoryLogStore 创建内存日志缓冲,最多保留 max 条最新日志。 +func newMemoryLogStore(max int) *memoryLogStore { + if max <= 0 { + max = 500 + } + return &memoryLogStore{ + max: max, + entries: make([]LogEntry, 0, max), + } +} + +// initLogging 初始化日志输出:终端、日志文件、内存缓冲三处同时写入。 +func initLogging(store *memoryLogStore) func() { + if err := os.MkdirAll("logs", 0755); err != nil { + log.SetOutput(io.MultiWriter(os.Stdout, store)) + log.Printf("日志目录创建失败,仅写入终端和内存: %v", err) + return func() {} + } + + filePath := filepath.Join("logs", "douyu_"+time.Now().In(cst).Format("20060102_150405")+".log") + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + log.SetOutput(io.MultiWriter(os.Stdout, store)) + log.Printf("日志文件创建失败,仅写入终端和内存: %v", err) + return func() {} + } + + store.setFilePath(filePath) + log.SetOutput(io.MultiWriter(os.Stdout, file, store)) + log.Printf("日志文件已启用: %s", filePath) + return func() { + _ = file.Close() + } +} + +// Write 实现 io.Writer,把标准 log 输出转存为内存日志。 +func (s *memoryLogStore) Write(p []byte) (int, error) { + text := strings.TrimRight(string(p), "\r\n") + if text == "" { + return len(p), nil + } + + s.mu.Lock() + defer s.mu.Unlock() + for _, line := range strings.Split(text, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + s.nextID++ + s.entries = append(s.entries, LogEntry{ + ID: s.nextID, + Time: time.Now().In(cst).Format("2006-01-02 15:04:05.000"), + Level: detectLogLevel(line), + Message: line, + }) + if len(s.entries) > s.max { + copy(s.entries, s.entries[len(s.entries)-s.max:]) + s.entries = s.entries[:s.max] + } + } + return len(p), nil +} + +// setFilePath 保存当前日志文件路径,供前端展示或排查使用。 +func (s *memoryLogStore) setFilePath(path string) { + s.mu.Lock() + defer s.mu.Unlock() + s.filePath = path +} + +// Since 返回 ID 大于 since 的日志。 +func (s *memoryLogStore) Since(since int64) []LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]LogEntry, 0, len(s.entries)) + for _, entry := range s.entries { + if entry.ID > since { + out = append(out, entry) + } + } + return out +} + +// FilePath 返回当前日志文件路径。 +func (s *memoryLogStore) FilePath() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.filePath +} + +// detectLogLevel 根据日志文本粗略分类,方便前端染色。 +func detectLogLevel(line string) string { + lower := strings.ToLower(line) + switch { + case strings.Contains(line, "成功"): + return "success" + case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"): + return "error" + case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"): + return "warn" + default: + return "info" + } +} + // ==================== 初始化与状态 ==================== // loadConfig 读取 config.json;失败时使用默认商品详情接口做服务端时间来源。 @@ -256,21 +396,45 @@ func (s *appState) accountSnapshot() []Account { return list } -// productSnapshot 复制一份商品列表。 -func (s *appState) productSnapshot() []Product { +// productSnapshot 复制一份商品列表和对应的刷新时间。 +func (s *appState) productSnapshot() ([]Product, time.Time) { s.productsMu.RLock() defer s.productsMu.RUnlock() list := make([]Product, len(s.products)) copy(list, s.products) - return list + return list, s.productsAt } -// setProducts 替换商品缓存。 -func (s *appState) setProducts(products []Product) { +// setProducts 替换商品缓存,并记录这批库存数据的刷新时间。 +func (s *appState) setProducts(products []Product, fetchedAt time.Time) { s.productsMu.Lock() defer s.productsMu.Unlock() s.products = products + s.productsAt = fetchedAt +} + +// productPayload 根据当前缓存生成前端商品响应。 +func (s *appState) productPayload(source string) ProductPayload { + products, fetchedAt := s.productSnapshot() + payload := ProductPayload{ + Success: true, + Source: source, + Products: products, + Count: len(products), + } + if !fetchedAt.IsZero() { + age := time.Since(fetchedAt) + if age < 0 { + age = 0 + } + payload.FetchedAt = fetchedAt.In(cst).Format("2006-01-02 15:04:05.000") + payload.AgeSecond = int(age.Seconds()) + payload.Stale = age > productCacheTTL + } else { + payload.Stale = true + } + return payload } // clearResults 清空抢购结果。 @@ -301,7 +465,8 @@ func (s *appState) resultsSnapshot() map[string]string { // productName 根据商品 ID 查找商品名,找不到时返回商品 ID。 func (s *appState) productName(commodityID string) string { - for _, p := range s.productSnapshot() { + products, _ := s.productSnapshot() + for _, p := range products { if p.CommodityID == commodityID { return p.CommodityName } @@ -644,7 +809,7 @@ func (s *appState) runNow(accountProducts map[string]string) { // buildAttempts 根据前端传入的账号到商品映射,生成具体兑换尝试。 func (s *appState) buildAttempts(accountProducts map[string]string) []ExchangeAttempt { accounts := s.accountSnapshot() - products := s.productSnapshot() + products, _ := s.productSnapshot() productByID := make(map[string]Product, len(products)) for _, p := range products { productByID[p.CommodityID] = p @@ -809,27 +974,44 @@ func (s *appState) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/schedule", s.handleSchedule) mux.HandleFunc("/api/cancel-schedule", s.handleCancelSchedule) mux.HandleFunc("/api/results", s.handleResults) + mux.HandleFunc("/api/logs", s.handleLogs) mux.HandleFunc("/api/time", s.handleTime) mux.HandleFunc("/api/clear-results", s.handleClearResults) mux.HandleFunc("/api/query-score", s.handleQueryScore) mux.Handle("/", http.FileServer(http.Dir("./static"))) } -// handleProducts 返回商品缓存。 -func (s *appState) handleProducts(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, s.productSnapshot()) +// handleProducts 返回商品列表;带 refresh=1 时会从斗鱼实时刷新,失败不使用旧库存冒充成功。 +func (s *appState) handleProducts(w http.ResponseWriter, r *http.Request) { + refresh := r.URL.Query().Get("refresh") == "1" + if refresh { + products, err := s.client.Products() + if err != nil { + log.Printf("商品刷新失败: %v", err) + writeError(w, http.StatusBadGateway, err) + return + } + fetchedAt := time.Now() + s.setProducts(products, fetchedAt) + log.Printf("商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) + writeJSON(w, s.productPayload("live")) + return + } + writeJSON(w, s.productPayload("cache")) } -// handleLoadProducts 从斗鱼重新拉取商品列表。 +// handleLoadProducts 兼容旧前端入口;新前端直接调用 /api/products?refresh=1。 func (s *appState) handleLoadProducts(w http.ResponseWriter, _ *http.Request) { products, err := s.client.Products() if err != nil { + log.Printf("商品刷新失败: %v", err) writeError(w, http.StatusBadGateway, err) return } - s.setProducts(products) - log.Printf("商品加载完成: %d 个", len(products)) - writeJSON(w, map[string]any{"success": true, "count": len(products)}) + fetchedAt := time.Now() + s.setProducts(products, fetchedAt) + log.Printf("商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) + writeJSON(w, s.productPayload("live")) } // handleAccounts 返回账号列表。 @@ -893,6 +1075,31 @@ func (s *appState) handleResults(w http.ResponseWriter, _ *http.Request) { writeJSON(w, s.resultsSnapshot()) } +// handleLogs 返回后端内存日志,支持用 since 参数增量获取。 +func (s *appState) handleLogs(w http.ResponseWriter, r *http.Request) { + var since int64 + if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("since 参数错误: %w", err)) + return + } + since = parsed + } + + entries := s.logs.Since(since) + nextSince := since + if len(entries) > 0 { + nextSince = entries[len(entries)-1].ID + } + writeJSON(w, map[string]any{ + "success": true, + "logs": entries, + "since": nextSince, + "file": s.logs.FilePath(), + }) +} + // handleClearResults 清空抢购结果。 func (s *appState) handleClearResults(w http.ResponseWriter, _ *http.Request) { s.clearResults() diff --git a/static/index.html b/static/index.html index dad532e..a2c2b4b 100644 --- a/static/index.html +++ b/static/index.html @@ -18,10 +18,13 @@ .left-list .prod-item img{width:40px;height:40px;border-radius:4px;object-fit:cover;background:#0d1117} .left-list .prod-item .pinfo{flex:1;min-width:0} .left-list .prod-item .pname{font-size:13px;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - .left-list .prod-item .pmeta{font-size:11px;color:#8b949e;display:flex;gap:8px} - .pmeta .pscore{color:#d2991d} - .pmeta .pstock{color:#7ee787} - .pmeta .pstock.low{color:#f78166} + .left-list .prod-item .pmeta{font-size:11px;color:#8b949e;display:flex;gap:8px} + .pmeta .pscore{color:#d2991d} + .pmeta .pstock{color:#7ee787} + .pmeta .pstock.low{color:#f78166} + .product-meta{padding:6px 2px 0;color:#8b949e;font-size:12px;line-height:1.5} + .product-meta.good{color:#7ee787} + .product-meta.warn{color:#f0883e} /* ========== 右侧主区域 ========== */ .right{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative} @@ -89,10 +92,11 @@
-
-
- -
+
+
+ +
库存未刷新
+
点击"加载商品"获取列表
@@ -149,9 +153,10 @@
diff --git a/user.txt b/user.txt new file mode 100644 index 0000000..f95ff13 --- /dev/null +++ b/user.txt @@ -0,0 +1 @@ +测试1----pwd123456----dy_did=944d384a673e7ea5b66faf9a00081701; Hm_lvt_e99aee90ec1b2106afe7ec3b199020a7=1781834308; HMACCOUNT=5476EE8F6942A0B2; PHPSESSID=9nhg0uqbl25pdfmg1hsan2krd2; acf_auth=0703%2FCjm7ZivfSUpfXtIwfOxTFX0yh3JCy2vnhET8ltFovlK9a3ddS%2Bz%2FVVueiEw86b66k%2FY45iw9fJpa2gSmfjPO%2FhUo8jLudd8nxl%2BIYq%2FzEzDm7As09k; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjEwMDAxMTkwMywiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo1OTQ3ODQ0NCwic3RrIjoiNzUxODgzOWIwODMyZDBkYiIsImV4cCI6MTc4MjQzOTExNywiaWF0IjoxNzgxODM0MzE3LCJrZXkiOiJkeS1qd3QtbWQ1In0.NWJkOTk1NmY1ZTFkMjY4MjI1YjA2OTllZWE3Yjg4MDc; acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjEwMDAxMTkwMywiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo1OTQ3ODQ0NCwic3RrIjoiNzUxODgzOWIwODMyZDBkYiIsImV4cCI6MTc4MjQzOTExNywiaWF0IjoxNzgxODM0MzE3LCJrZXkiOiJkeS1qd3QtbWQ1In0.ODBlYzhjZjQyMjA5YWZlZmFiODM4Y2Y1MTY3MmM4YmM; dy_auth=3730Bv0NTC35FhSBwq4Dgbv9HQHiFW%2FMgNQ4nFYsWOKnkxK0nD05KbTdELoCwTMwDByVcKQuQMTf1y54fFo9wff5OYaKrXZpRxu85hICUrG65S1g2DgOpAc; wan_auth37wan=188025b16b88OdcbZFsJpwoT6D60nMeXnM27aYMOolAFceHZgxmONVYYDUoNNu2cDhQ00YrHcBm7q9fYncX%2BsiwRuUQz5Yxdw1co%2B7lr3Hr6J99qqfQ; acf_uid=100011903; acf_username=100011903; acf_nickname=%E6%9D%A8%E5%8D%8A%E4%BB%99z; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favanew%2Fface%2F201701%2F04%2F02%2F497692b08166ab43377dcfb97f418bc8_; acf_ct=0; acf_ltkid=59478444; acf_biz=1; acf_stk=7518839b0832d0db; mantine-color-scheme-value=light; dy_did=944d384a673e7ea5b66faf9a00081701; dy_teen_mode=%7B%22uid%22%3A%22100011903%22%2C%22status%22%3A0%2C%22birthday%22%3A%22%22%2C%22password%22%3A%22%22%7D; acf_ssid=1729388853998258962; acf_web_id=7652920204304914958; acf_ab_pmt=20100212%23webnewhome%23B%2C20100395%23webslidetag%23B%2C20100249%23webTagRank%23B%2C20100248%23webTagHover%23B%2C20100254%23WebTool0703%23new%2C20100272%23all_lists_sort%23c; acf_ab_ver_all=20100212%2C20100395%2C20100249%2C20100248%2C20100254%2C20100272; acf_ab_vs=webnewhome%3DB%2Cwebslidetag%3DB%2CwebTagRank%3DB%2CwebTagHover%3DB%2CWebTool0703%3Dnew%2Call_lists_sort%3Dc; acf_ccn=5ee6ac7651f3dc89abd571425e2c4f4f; acf_did=944d384a673e7ea5b66faf9a00081701; Hm_lpvt_e99aee90ec1b2106afe7ec3b199020a7=1781834382; post-csrfToken=1useglzm5he; game_did=zUF63jQB0AWN2AOylrwecMiLNA3-qnj54sW; huya_ua=outer_pc&0.0.1&websocket&&h5_/9263298; _ga=GA1.1.421749312.1781834386; _ga_5JKQ7DTEXC=GS2.1.s1781834386$o1$g0$t1781834386$j60$l0$h1960451153; guid=0a7d931b92a2346a260165196619e916