增加日志系统等
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
.idea
|
||||
.cscode
|
||||
*.iml
|
||||
*.iml
|
||||
logs/
|
||||
success_images/
|
||||
|
||||
@@ -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()
|
||||
|
||||
+226
-142
@@ -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 @@
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- ==================== 左侧商品列表 ==================== -->
|
||||
<div class="left">
|
||||
<div class="left-header">
|
||||
<button class="btn btn-blue" onclick="loadProducts()" style="width:100%">🔄 加载商品</button>
|
||||
</div>
|
||||
<div class="left">
|
||||
<div class="left-header">
|
||||
<button class="btn btn-blue" id="btnLoadProducts" onclick="loadProducts(true)" style="width:100%">🔄 刷新商品</button>
|
||||
<div class="product-meta" id="productMeta">库存未刷新</div>
|
||||
</div>
|
||||
<div class="left-list" id="prodList">
|
||||
<div style="text-align:center;color:#8b949e;padding:40px 10px;font-size:13px">点击"加载商品"获取列表</div>
|
||||
</div>
|
||||
@@ -149,9 +153,10 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let accounts=[], products=[], selProdId=null, selProdName='', selAccs=new Set();
|
||||
let scheduleActive=false, countdownTimer=null, lastResults={};
|
||||
let statusMap={}; // ← 加这行
|
||||
let accounts=[], products=[], productInfo=null, selProdId=null, selProdName='', selAccs=new Set();
|
||||
let scheduleActive=false, countdownTimer=null, lastResults={};
|
||||
let lastLogId=0;
|
||||
let statusMap={}; // ← 加这行
|
||||
|
||||
// init 初始化页面状态:恢复本地缓存、加载账号和商品、启动时间与结果轮询。
|
||||
async function init(){
|
||||
@@ -161,10 +166,11 @@
|
||||
let savedProdName=localStorage.getItem('selProdName');
|
||||
let savedTime=localStorage.getItem('targetTime');
|
||||
let savedAdv=localStorage.getItem('advance');
|
||||
let savedStatus=localStorage.getItem('statusMap'); // ← 提前读取
|
||||
|
||||
if(savedAccs) selAccs=new Set(JSON.parse(savedAccs));
|
||||
if(savedProd){ selProdId=savedProd; selProdName=savedProdName||''; document.getElementById('selProd').textContent=selProdName; }
|
||||
let savedStatus=localStorage.getItem('statusMap'); // ← 提前读取
|
||||
|
||||
clearLegacyProductBindings();
|
||||
if(savedAccs) selAccs=new Set(JSON.parse(savedAccs));
|
||||
if(savedProd){ selProdId=savedProd; selProdName=savedProdName||''; document.getElementById('selProd').textContent=selProdName; }
|
||||
if(savedTime) document.getElementById('targetTime').value=savedTime;
|
||||
if(savedAdv) document.getElementById('advance').value=savedAdv;
|
||||
if(savedStatus) statusMap=JSON.parse(savedStatus); // ← 提前恢复
|
||||
@@ -172,7 +178,7 @@
|
||||
|
||||
await loadAccounts();
|
||||
|
||||
await loadProducts(); // ← 加这行,启动自动加载商品
|
||||
await loadProducts(false);
|
||||
|
||||
updateTime();
|
||||
|
||||
@@ -183,11 +189,13 @@
|
||||
// statusMap={}; // ← 加这行 这两行删除就保存成功失败得状态
|
||||
|
||||
// 清空后端旧结果
|
||||
await fetch('/api/clear-results', {method:'POST'});
|
||||
await apiJSON('/api/clear-results', {method:'POST'});
|
||||
|
||||
setInterval(updateTime,1000);
|
||||
setInterval(checkResults,2000);
|
||||
}
|
||||
setInterval(updateTime,1000);
|
||||
setInterval(checkResults,2000);
|
||||
setInterval(fetchServerLogs,2000);
|
||||
fetchServerLogs();
|
||||
}
|
||||
|
||||
// updateTime 从后端获取斗鱼服务端时间,并刷新顶部时间展示。
|
||||
function updateTime(){
|
||||
@@ -195,15 +203,25 @@
|
||||
fetch('/api/time').then(r=>r.json()).then(d=>{
|
||||
document.getElementById('svrTime').textContent=d.serverTime||'--';
|
||||
}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
// apiJSON 统一处理本地接口响应,HTTP 失败时抛出后端返回的错误文案。
|
||||
async function apiJSON(url, options) {
|
||||
let r = await fetch(url, options);
|
||||
let text = await r.text();
|
||||
let data = text ? JSON.parse(text) : {};
|
||||
if (!r.ok) {
|
||||
throw new Error(data.error || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// reloadAccounts 通知后端重新读取 user.txt,然后刷新前端账号表格。
|
||||
async function reloadAccounts(){
|
||||
let r=await fetch('/api/reload-accounts');
|
||||
let d=await r.json();
|
||||
await loadAccounts();
|
||||
toast('✅ 账号已刷新',`共 ${d.count} 个账号`,'success');
|
||||
}
|
||||
let d=await apiJSON('/api/reload-accounts');
|
||||
await loadAccounts();
|
||||
toast('✅ 账号已刷新',`共 ${d.count} 个账号`,'success');
|
||||
}
|
||||
|
||||
// queryAllScore 并发查询所有账号积分,并把积分按账号名缓存到 localStorage。
|
||||
async function queryAllScore() {
|
||||
@@ -217,13 +235,12 @@
|
||||
console.log('账号对象:', JSON.stringify(a));
|
||||
console.log('Cookie长度:', a.cookie?.length, '前50字符:', a.cookie?.substring(0, 50));
|
||||
|
||||
let r = await fetch('/api/query-score', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: a.cookie
|
||||
});
|
||||
let d = await r.json();
|
||||
let score = (d.score !== undefined && d.score !== null && d.score !== '') ? d.score : 'N/A';
|
||||
let d = await apiJSON('/api/query-score', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: a.cookie
|
||||
});
|
||||
let score = (d.score !== undefined && d.score !== null && d.score !== '') ? d.score : 'N/A';
|
||||
// ✅ 用账号名存积分
|
||||
localStorage.setItem('accScore_' + a.name, score); // ← 改成 a.name
|
||||
let el = document.getElementById('score_' + idx);
|
||||
@@ -257,52 +274,89 @@
|
||||
}
|
||||
|
||||
// ==================== 商品 ====================
|
||||
// loadProducts 触发后端加载斗鱼商品接口,然后拉取缓存商品并重新渲染列表。
|
||||
async function loadProducts(){
|
||||
addLog('正在从斗鱼加载商品...','i');
|
||||
try{
|
||||
await fetch('/api/load-products');
|
||||
let r=await fetch('/api/products'); products=await r.json();
|
||||
renderProducts();
|
||||
addLog(`✅ 加载完成,共 ${products.length} 个商品`,'s');
|
||||
toast('✅ 加载成功',`共加载 ${products.length} 个商品`,'success');
|
||||
}catch(e){
|
||||
addLog('加载失败,请检查网络','e');
|
||||
toast('❌ 加载失败','请检查网络连接','error');
|
||||
}
|
||||
}
|
||||
// loadProducts 从后端实时刷新斗鱼商品;失败时不读取旧缓存,避免库存误判。
|
||||
async function loadProducts(showToast = true){
|
||||
let btn = document.getElementById('btnLoadProducts');
|
||||
let meta = document.getElementById('productMeta');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '刷新中...';
|
||||
meta.className = 'product-meta';
|
||||
meta.textContent = '正在请求斗鱼商品接口';
|
||||
addLog('正在实时刷新商品...','i');
|
||||
try{
|
||||
let d = await apiJSON('/api/products?refresh=1');
|
||||
products = Array.isArray(d.products) ? d.products : [];
|
||||
productInfo = d;
|
||||
if (selProdId && !products.some(p => p.commodityId === selProdId)) {
|
||||
clearSelectedProduct();
|
||||
}
|
||||
renderProducts();
|
||||
renderProductMeta();
|
||||
renderAccounts();
|
||||
addLog(`✅ 商品刷新完成,共 ${products.length} 个,库存时间 ${d.fetchedAt || '--'}`,'s');
|
||||
if (showToast) toast('✅ 商品已刷新',`共 ${products.length} 个,库存时间 ${d.fetchedAt || '--'}`,'success');
|
||||
}catch(e){
|
||||
products = [];
|
||||
productInfo = null;
|
||||
clearSelectedProduct();
|
||||
renderProducts();
|
||||
renderProductMeta(e.message);
|
||||
addLog('❌ 商品刷新失败: ' + e.message,'e');
|
||||
toast('❌ 商品刷新失败', e.message || '请检查网络连接','error');
|
||||
}finally{
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 刷新商品';
|
||||
}
|
||||
}
|
||||
// renderProducts 根据当前商品数组绘制左侧商品列表,并标记已选商品。
|
||||
function renderProducts(){
|
||||
let h='';
|
||||
// 遍历商品缓存,生成每个商品的点击项和库存、积分信息。
|
||||
products.forEach(p=>{
|
||||
h+=`<div class="prod-item${selProdId==p.commodityId?' active':''}" onclick="selProduct('${p.commodityId}','${escHtml(p.commodityName)}')">
|
||||
<img src="${p.pic}" onerror="this.style.display='none'">
|
||||
<div class="pinfo"><div class="pname">${p.commodityName}</div>
|
||||
<div class="pmeta"><span class="pscore">💰${p.score}</span><span class="pstock${p.count<500?' low':''}">📦${p.count}</span></div></div>
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('prodList').innerHTML=h||'<div style="text-align:center;color:#8b949e;padding:40px">暂无商品</div>';
|
||||
}
|
||||
|
||||
// selProduct 记录当前选择的商品,并把它绑定到所有已勾选账号。
|
||||
h+=`<div class="prod-item${selProdId==p.commodityId?' active':''}" onclick="selProduct('${jsAttr(p.commodityId)}','${jsAttr(p.commodityName)}')">
|
||||
<img src="${p.pic}" onerror="this.style.display='none'">
|
||||
<div class="pinfo"><div class="pname">${escHtml(p.commodityName)}</div>
|
||||
<div class="pmeta"><span class="pscore">💰${p.score}</span><span class="pstock${p.count<500?' low':''}">📦${p.count}</span></div></div>
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('prodList').innerHTML=h||'<div style="text-align:center;color:#8b949e;padding:40px 10px;font-size:13px">暂无实时商品数据</div>';
|
||||
}
|
||||
// renderProductMeta 显示库存来源和刷新时间,避免把旧库存当实时库存。
|
||||
function renderProductMeta(errorMsg) {
|
||||
let meta = document.getElementById('productMeta');
|
||||
if (errorMsg) {
|
||||
meta.className = 'product-meta warn';
|
||||
meta.textContent = '刷新失败,未使用旧库存';
|
||||
return;
|
||||
}
|
||||
if (!productInfo || !productInfo.fetchedAt) {
|
||||
meta.className = 'product-meta';
|
||||
meta.textContent = '库存未刷新';
|
||||
return;
|
||||
}
|
||||
meta.className = 'product-meta ' + (productInfo.stale ? 'warn' : 'good');
|
||||
meta.textContent = `实时库存 ${productInfo.count} 个,刷新于 ${productInfo.fetchedAt}`;
|
||||
}
|
||||
|
||||
// selProduct 记录当前选择的商品,提交时会应用到所有已勾选账号。
|
||||
function selProduct(id, name) {
|
||||
selProdId = id;
|
||||
selProdId = id;
|
||||
selProdName = name;
|
||||
document.getElementById('selProd').textContent = name;
|
||||
// ✅ 用账号名存商品绑定
|
||||
// 该回调把当前商品写入每个已选账号的本地绑定记录。
|
||||
selAccs.forEach(i => {
|
||||
let accountName = accounts[i].name; // 用账号名作为 key
|
||||
localStorage.setItem('accProd_' + accountName, id);
|
||||
localStorage.setItem('accProdName_' + accountName, name);
|
||||
});
|
||||
|
||||
// selAccs.clear();
|
||||
// localStorage.setItem('selAccs', JSON.stringify([...selAccs]));
|
||||
renderProducts();
|
||||
renderAccounts();
|
||||
}
|
||||
localStorage.setItem('selProd', id);
|
||||
localStorage.setItem('selProdName', name);
|
||||
renderProducts();
|
||||
renderAccounts();
|
||||
}
|
||||
|
||||
// clearSelectedProduct 清理已经不存在的商品选择。
|
||||
function clearSelectedProduct() {
|
||||
selProdId = null;
|
||||
selProdName = '';
|
||||
localStorage.removeItem('selProd');
|
||||
localStorage.removeItem('selProdName');
|
||||
document.getElementById('selProd').textContent = '未选';
|
||||
}
|
||||
|
||||
// ==================== 账号 ====================
|
||||
// async function loadAccounts(){
|
||||
@@ -312,18 +366,17 @@
|
||||
|
||||
// loadAccounts 从后端获取账号列表,清理无效本地缓存后重新渲染账号表格。
|
||||
async function loadAccounts() {
|
||||
let r = await fetch('/api/accounts');
|
||||
accounts = await r.json();
|
||||
accounts = await apiJSON('/api/accounts');
|
||||
|
||||
// map 回调抽取账号名,用于判断 localStorage 中的旧缓存是否仍然有效。
|
||||
// map 回调抽取账号名,用于判断 localStorage 中的旧积分缓存是否仍然有效。
|
||||
let validNames = new Set(accounts.map(a => a.name));
|
||||
|
||||
for (let key in localStorage) {
|
||||
// 只清理商品和积分缓存,保留 selAccs、statusMap、targetTime、advance
|
||||
if (key.startsWith('accProd_') || key.startsWith('accProdName_') || key.startsWith('accScore_')) {
|
||||
let name = key.substring(key.indexOf('_') + 1);
|
||||
if (!validNames.has(name)) {
|
||||
localStorage.removeItem(key);
|
||||
|
||||
for (let key in localStorage) {
|
||||
// 只清理积分缓存,商品选择改为全局当前商品。
|
||||
if (key.startsWith('accScore_')) {
|
||||
let name = key.substring(key.indexOf('_') + 1);
|
||||
if (!validNames.has(name)) {
|
||||
localStorage.removeItem(key);
|
||||
console.log('清理无效缓存:', key);
|
||||
}
|
||||
}
|
||||
@@ -337,10 +390,9 @@
|
||||
document.getElementById('accCnt').textContent = accounts.length;
|
||||
// 将账号数组映射为表格行 HTML,行内状态从 localStorage 和 statusMap 读取。
|
||||
let h = accounts.map((a, i) => {
|
||||
let isSel = selAccs.has(i);
|
||||
// ✅ 用账号名读取商品、积分、状态
|
||||
let prodName = localStorage.getItem('accProdName_' + a.name) || '-';
|
||||
let prodTag = prodName !== '-' ? `<span class="prod-tag">${prodName}</span>` : '-';
|
||||
let isSel = selAccs.has(i);
|
||||
// 商品列展示当前选中商品,提交时会应用到所有勾选账号。
|
||||
let prodTag = selProdName ? `<span class="prod-tag">${escHtml(selProdName)}</span>` : '-';
|
||||
let scoreVal = localStorage.getItem('accScore_' + a.name) || '-'; // ← 改成 a.name
|
||||
let st = statusMap[a.name]; // ← 已经是 a.name,保持
|
||||
let statusStr = '-';
|
||||
@@ -390,15 +442,14 @@
|
||||
if(!ap) return;
|
||||
addLog(`⚡ 立即抢购: ${Object.keys(ap).length}个账号 → ${selProdName}`,'i');
|
||||
toast('⚡ 抢购中...',`${Object.keys(ap).length} 个账号正在抢购 ${selProdName}`,'info',3000);
|
||||
let r=await fetch('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})});
|
||||
let d=await r.json();
|
||||
if(d.success){
|
||||
try {
|
||||
let d=await apiJSON('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})});
|
||||
addLog(d.msg||'已提交','i');
|
||||
}else{
|
||||
addLog('❌ 提交失败: '+(d.error||'未知错误'),'e');
|
||||
toast('❌ 提交失败',d.error||'未知错误','error');
|
||||
} catch(e) {
|
||||
addLog('❌ 提交失败: '+e.message,'e');
|
||||
toast('❌ 提交失败',e.message,'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 定时抢购 ====================
|
||||
// startSchedule 构建定时参数并提交给后端,由后端按服务端时间安排抢购。
|
||||
@@ -414,30 +465,29 @@
|
||||
let adv=parseInt(document.getElementById('advance').value)||300;
|
||||
|
||||
addLog(`⏰ 设置定时: ${fullTime}, 提前=${adv}ms`,'i');
|
||||
let r=await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({accountProducts:ap,targetTime:fullTime,advance:adv})});
|
||||
let d=await r.json();
|
||||
if(d.success){
|
||||
scheduleActive=true;
|
||||
// 保存定时信息
|
||||
localStorage.setItem('targetTime',timeStr);
|
||||
try {
|
||||
let d=await apiJSON('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({accountProducts:ap,targetTime:fullTime,advance:adv})});
|
||||
scheduleActive=true;
|
||||
// 保存定时信息
|
||||
localStorage.setItem('targetTime',timeStr);
|
||||
localStorage.setItem('advance',adv);
|
||||
|
||||
// 锁定:关闭自动,固定当前值
|
||||
document.getElementById('btnSchedule').style.display='none';
|
||||
document.getElementById('btnCancel').style.display='inline-block';
|
||||
addLog(`✅ 定时已开启! 触发: ${d.triggerTime} (剩余${d.remainSecond}秒)`,'s');
|
||||
toast('⏰ 定时已开启',`触发时刻: ${d.triggerTime}<br>剩余时间: <b>${formatRemain(d.remainSecond)}</b><br>商品: ${selProdName}<br>账号: ${Object.keys(ap).length} 个`,'success',5000);
|
||||
startCountdown(d.remainSecond);
|
||||
} else {
|
||||
addLog('❌ 设置失败: '+(d.error||'未知'),'e');
|
||||
toast('❌ 定时失败',d.error||'未知错误','error');
|
||||
}
|
||||
}
|
||||
addLog(`✅ 定时已开启! 触发: ${d.triggerTime} (剩余${d.remainSecond}秒)`,'s');
|
||||
toast('⏰ 定时已开启',`触发时刻: ${d.triggerTime}<br>剩余时间: <b>${formatRemain(d.remainSecond)}</b><br>商品: ${selProdName}<br>账号: ${Object.keys(ap).length} 个`,'success',5000);
|
||||
startCountdown(d.remainSecond);
|
||||
} catch(e) {
|
||||
addLog('❌ 设置失败: '+e.message,'e');
|
||||
toast('❌ 定时失败',e.message,'error');
|
||||
}
|
||||
}
|
||||
// cancelSchedule 调用后端取消尚未触发的定时任务,并恢复前端按钮状态。
|
||||
async function cancelSchedule(){
|
||||
try{
|
||||
await fetch('/api/cancel-schedule',{method:'POST'});
|
||||
await apiJSON('/api/cancel-schedule',{method:'POST'});
|
||||
}catch(e){}
|
||||
scheduleActive=false;
|
||||
// 清除缓存的定时
|
||||
@@ -490,12 +540,13 @@
|
||||
return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
// ==================== 结果刷新 ====================
|
||||
// ==================== 结果刷新 ====================
|
||||
// checkResults 轮询后端抢购结果,更新日志区、账号状态和本地状态缓存。
|
||||
async function checkResults() {
|
||||
try {
|
||||
let r = await fetch('/api/results');
|
||||
let d = await r.json();
|
||||
let r = await fetch('/api/results');
|
||||
if (!r.ok) throw new Error('结果接口异常');
|
||||
let d = await r.json();
|
||||
if (JSON.stringify(d) !== JSON.stringify(lastResults) && Object.keys(d).length > 0) {
|
||||
lastResults = d;
|
||||
let newStatusMap = {};
|
||||
@@ -516,41 +567,72 @@
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
// fetchServerLogs 增量拉取后端日志,把真实接口链路日志显示到前端日志区。
|
||||
async function fetchServerLogs() {
|
||||
try {
|
||||
let d = await apiJSON('/api/logs?since=' + lastLogId);
|
||||
if (!d.success || !Array.isArray(d.logs) || d.logs.length === 0) return;
|
||||
d.logs.forEach(item => {
|
||||
lastLogId = Math.max(lastLogId, item.id || 0);
|
||||
let type = 'i';
|
||||
if (item.level === 'success') type = 's';
|
||||
else if (item.level === 'error') type = 'e';
|
||||
else if (item.level === 'warn') type = 'w';
|
||||
addServerLog(item.message, type);
|
||||
});
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// addServerLog 追加一条后端日志,和本地操作日志共用同一个日志区域。
|
||||
function addServerLog(msg, type = 'i') {
|
||||
let el = document.getElementById('logBody');
|
||||
el.innerHTML += `<div class="log-${type}">[后端] ${escHtml(msg)}</div>`;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
|
||||
// buildAP 根据已勾选账号和账号绑定商品,生成后端需要的账号到商品 ID 映射。
|
||||
function buildAP() {
|
||||
if (selAccs.size === 0) {
|
||||
toast('⚠️ 提示', '请勾选要抢购的账号', 'warn');
|
||||
if (selAccs.size === 0) {
|
||||
toast('⚠️ 提示', '请勾选要抢购的账号', 'warn');
|
||||
return null;
|
||||
}
|
||||
if (!selProdId) {
|
||||
toast('⚠️ 提示', '请先选择商品', 'warn');
|
||||
return null;
|
||||
}
|
||||
if (!productInfo || !products.some(p => p.commodityId === selProdId)) {
|
||||
toast('⚠️ 提示', '请先刷新商品,确认库存是实时数据', 'warn');
|
||||
return null;
|
||||
}
|
||||
let ap = {};
|
||||
// 遍历已选账号,把存在商品绑定的账号转换成后端需要的映射。
|
||||
// 遍历已选账号,把当前选中商品应用到每个账号。
|
||||
selAccs.forEach(i => {
|
||||
let accountName = accounts[i].name; // ← 先取账号名
|
||||
let cid = localStorage.getItem('accProd_' + accountName); // ← 用账号名读
|
||||
if (cid) {
|
||||
ap[accountName] = cid;
|
||||
}
|
||||
});
|
||||
if (Object.keys(ap).length === 0) {
|
||||
toast('⚠️ 提示', '勾选的账号未绑定商品', 'warn');
|
||||
return null;
|
||||
}
|
||||
return ap;
|
||||
}
|
||||
|
||||
|
||||
// hasAnyAccProd 判断当前账号列表中是否存在任意商品绑定。
|
||||
function hasAnyAccProd(){
|
||||
for(let i=0; i<accounts.length; i++){
|
||||
if (localStorage.getItem('accProd_' + accounts[i].name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let account = accounts[i];
|
||||
if (account) {
|
||||
ap[account.name] = selProdId;
|
||||
}
|
||||
});
|
||||
if (Object.keys(ap).length === 0) {
|
||||
toast('⚠️ 提示', '没有有效账号可提交', 'warn');
|
||||
return null;
|
||||
}
|
||||
return ap;
|
||||
}
|
||||
|
||||
// clearLegacyProductBindings 删除旧版按账号保存商品的缓存,避免残留旧商品误导界面。
|
||||
function clearLegacyProductBindings() {
|
||||
for (let key in localStorage) {
|
||||
if (key.startsWith('accProd_') || key.startsWith('accProdName_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// addLog 向底部日志区追加一条带毫秒时间戳的运行日志。
|
||||
function addLog(msg, type = 'i') {
|
||||
let el = document.getElementById('logBody');
|
||||
@@ -567,8 +649,10 @@
|
||||
lastResults={};
|
||||
}
|
||||
|
||||
// escHtml 转义商品名中的 HTML 特殊字符,避免拼接到 onclick 时破坏页面结构。
|
||||
function escHtml(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
// escHtml 转义 HTML 特殊字符,避免接口数据破坏页面结构。
|
||||
function escHtml(s){ return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
// jsAttr 转义写入 onclick 字符串参数的数据。
|
||||
function jsAttr(s){ return escHtml(String(s ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,' ')); }
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user