优化日志系统:网络请求降噪并按接口聚合,补齐双时间说明与实例标识,发布0.3.2
- 逐条网络日志(请求完成 / 接口返回异常 / 响应刷新CSRF已同步)从 info/warn 降为 debug: 实测一次 6 账号的模拟抢购里 233 条日志有 174 条(74%)属于这类;真实抢购会产生几千条, 把界面仅 800 条的内存日志冲掉,反而看不到关键信息。HTTP/解析/传输层错误仍为 error 级 - 新增按接口聚合的网络统计,抢购任务结束时输出一行,替代逐条刷屏: "接口统计: 锁定兑换商品 ×341 失败341 平均=95ms 最慢=312ms 最后错误=活动火爆,请稍后再试" - 日志文件开头新增表头:实例名、数据目录、双时间格式说明(本地时间 + D 斗鱼估算时间)、 并说明"校准完成前偏移为 0,只显示本地时间"。双时间一直存在却没有任何地方解释过, 多实例日志复制或汇总后也分不清来源 - 界面日志面板:显示日志级别并按级别着色(此前级别只存在于 CSS class,复制出去就丢); 新增"复制"按钮,按与日志文件一致的单行格式导出(此前直接复制会变成 5 行一条、 1204 行里其实只有 233 条日志) - 设置页"日志显示等级"补充说明:Info 起只显示关键日志,需要逐请求细节时切到 Debug - 新增测试:TestRequestStatsAggregatesByLabel、TestLogFileHeaderDocumentsFormat、 TestRunNowLogsAggregateInsteadOfPerRequest(断言必须有聚合行、且不得出现逐条请求日志)
This commit is contained in:
@@ -1,10 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { ClipboardSetText } from '@wails/runtime/runtime'
|
||||
import { useLogs } from '@/composables/useLogs'
|
||||
|
||||
const { logs, clearLogs } = useLogs()
|
||||
const { logs, clearLogs, exportLogsText } = useLogs()
|
||||
const expanded = ref(false)
|
||||
const logBodyRef = ref<HTMLElement | null>(null)
|
||||
const copyState = ref('复制')
|
||||
|
||||
const copyLogs = async () => {
|
||||
if (logs.value.length === 0) return
|
||||
try {
|
||||
// 复制成与后端日志文件一致的单行格式,避免面板的多 span 变成 5 行一条、丢失级别。
|
||||
const ok = await ClipboardSetText(exportLogsText())
|
||||
copyState.value = ok ? '已复制' : '复制失败'
|
||||
} catch {
|
||||
copyState.value = '复制失败'
|
||||
}
|
||||
setTimeout(() => { copyState.value = '复制' }, 1500)
|
||||
}
|
||||
|
||||
watch(logs, async () => {
|
||||
if (!expanded.value) return
|
||||
@@ -21,7 +35,10 @@ watch(logs, async () => {
|
||||
<span>日志</span>
|
||||
<span v-if="logs.length" class="log-count">{{ logs.length }}</span>
|
||||
</button>
|
||||
<button v-if="expanded" class="btn btn-compact" @click="clearLogs">清空</button>
|
||||
<div v-if="expanded" class="log-actions">
|
||||
<button class="btn btn-compact" @click="copyLogs">{{ copyState }}</button>
|
||||
<button class="btn btn-compact" @click="clearLogs">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expanded" ref="logBodyRef" class="log-body">
|
||||
<div v-if="logs.length === 0" class="log-i">[系统] 就绪,等待操作...</div>
|
||||
@@ -31,6 +48,7 @@ watch(logs, async () => {
|
||||
<span v-if="log.time" class="log-time">[{{ log.time }}]</span>
|
||||
<span v-if="log.douyuTime" class="log-douyu-time">D:{{ log.douyuTime }}</span>
|
||||
<span v-if="log.category" class="log-cat">{{ log.category }}</span>
|
||||
<span class="log-level">[{{ log.level || 'info' }}]</span>
|
||||
<span v-if="log.account" class="log-account">{{ log.account }}</span>
|
||||
<span class="log-msg">{{ log.message }}</span>
|
||||
</template>
|
||||
@@ -51,6 +69,11 @@ watch(logs, async () => {
|
||||
.log-line { display: flex; align-items: flex-start; gap: 4px; padding: 2px 0; word-break: break-all; }
|
||||
.log-prefix, .log-time { color: var(--muted); flex-shrink: 0; font-size: 10px; }
|
||||
.log-douyu-time { color: var(--accent); flex-shrink: 0; font-size: 10px; font-weight: 600; }
|
||||
.log-actions { display: inline-flex; gap: 6px; }
|
||||
.log-level { color: var(--muted-2); flex-shrink: 0; font-size: 10px; }
|
||||
.log-line.log-w .log-level { color: var(--warning); }
|
||||
.log-line.log-e .log-level { color: var(--danger); }
|
||||
.log-line.log-s .log-level { color: var(--success); }
|
||||
.log-cat, .log-account { display: inline-block; padding: 0 4px; border-radius: 3px; font-size: 10px; line-height: 1.4; flex-shrink: 0; }
|
||||
.log-cat { border: 1px solid var(--line); color: var(--muted); background: var(--surface); }
|
||||
.log-account { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 1px solid var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
@@ -130,6 +130,10 @@ defineExpose({ open, close })
|
||||
<option value="warn">Warn</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<div class="form-hint">
|
||||
Info 起只显示关键日志;需要看每个请求的耗时与错误码时切到 Debug
|
||||
(抢购任务结束时仍会输出各类接口的<strong>聚合统计</strong>)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface LogEntry {
|
||||
|
||||
export interface LogMessage {
|
||||
time: string
|
||||
rawTime?: string
|
||||
douyuTime?: string
|
||||
level?: string
|
||||
type: 's' | 'e' | 'i' | 'w' | 'd'
|
||||
message: string
|
||||
category?: string
|
||||
@@ -57,6 +59,21 @@ const cleanServerLogMessage = (message: string): string => {
|
||||
.replace(/^\[账号:[^\]]+\]\s*/i, '')
|
||||
}
|
||||
|
||||
// formatLogLine 把一条日志还原成与后端日志文件一致的单行格式。
|
||||
// 面板用多个 span 渲染,直接复制会变成 5 行一条、还丢掉级别,
|
||||
// 所以"复制"走这里:时间(D斗鱼时间) [类别][级别][账号] 正文。
|
||||
const formatLogLine = (log: LogMessage): string => {
|
||||
if (log.source !== 'server') {
|
||||
return `[${log.time}] ${log.message}`
|
||||
}
|
||||
const time = log.rawTime || log.time || ''
|
||||
const douyu = log.douyuTime ? `(D${log.douyuTime})` : ''
|
||||
const category = log.category ? `[${log.category}]` : ''
|
||||
const level = `[${log.level || 'info'}]`
|
||||
const account = log.account ? `[账号:${log.account}]` : ''
|
||||
return `${time}${douyu} ${category}${level}${account} ${log.message}`.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// 后端结果按“账号_第N包”返回,账号表按账号名展示,这里做一次归并。
|
||||
const normalizeResultStatuses = (data: Record<string, string>): Record<string, AccountStatus> => {
|
||||
const next: Record<string, AccountStatus> = {}
|
||||
@@ -111,7 +128,9 @@ export function useLogs() {
|
||||
|
||||
logs.value.push({
|
||||
time: formatServerLogTime(item.time || ''),
|
||||
rawTime: item.time || '',
|
||||
douyuTime: dyTime || undefined,
|
||||
level: item.level,
|
||||
type,
|
||||
message: cleanMsg,
|
||||
category: item.category,
|
||||
@@ -162,9 +181,13 @@ export function useLogs() {
|
||||
clearLocalResults()
|
||||
}
|
||||
|
||||
// exportLogsText 导出全部日志为单行文本,供"复制"按钮使用。
|
||||
const exportLogsText = (): string => logs.value.map(formatLogLine).join('\n')
|
||||
|
||||
return {
|
||||
logs,
|
||||
results,
|
||||
exportLogsText,
|
||||
addLocalLog,
|
||||
fetchServerLogs,
|
||||
checkResults,
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ func (a *App) Startup(ctx context.Context) {
|
||||
}
|
||||
configMigrated, configMigrationErr := config.MigrateLegacyFile(a.paths.ConfigFile, a.paths.LegacyConfigFile)
|
||||
a.logStore = logger.NewMemoryLogStore(800)
|
||||
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir)
|
||||
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir, a.instanceName, a.paths.DataDir)
|
||||
if instanceErr != nil {
|
||||
// 数据目录仍然是本实例独享的(临时实例),但隔离能力已降级,必须让人看见。
|
||||
logger.LogEvent("系统", "warn", "实例隔离已降级(%s,数据目录仍独立): %v", a.instanceName, instanceErr)
|
||||
|
||||
@@ -32,6 +32,22 @@ type DouyuClient struct {
|
||||
|
||||
xiaodianEmbedMu sync.Mutex
|
||||
xiaodianEmbedCache map[string]xiaodianEmbedCacheItem
|
||||
|
||||
statsMu sync.Mutex
|
||||
reqStats map[string]*RequestStat
|
||||
}
|
||||
|
||||
// RequestStat 累计某一类接口的调用情况。
|
||||
//
|
||||
// 逐条网络日志现在降为 debug 级(一次真实抢购会产生几千条),靠这份聚合结果
|
||||
// 保留诊断价值:哪类接口调了多少次、失败多少、平均/最慢多久、最后一次错在哪。
|
||||
type RequestStat struct {
|
||||
Label string
|
||||
Count int
|
||||
Failed int
|
||||
Total time.Duration
|
||||
Slowest time.Duration
|
||||
LastErr string
|
||||
}
|
||||
|
||||
type xiaodianEmbedCacheItem struct {
|
||||
@@ -1698,7 +1714,7 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri
|
||||
if resp != nil {
|
||||
if nextCSRF := csrfFromResponse(resp); nextCSRF != "" && csrf != nil {
|
||||
if nextCSRF != *csrf {
|
||||
logger.LogEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req))
|
||||
logger.LogEvent("网络", "debug", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req))
|
||||
}
|
||||
if ctnVal != "" {
|
||||
*csrf = nextCSRF + "\x00" + ctnVal
|
||||
@@ -1788,7 +1804,57 @@ func shouldLogNetworkSuccess(label string) bool {
|
||||
return label != "服务时间"
|
||||
}
|
||||
|
||||
// recordRequest 记录一次请求结果(每类接口一条聚合)。
|
||||
func (c *DouyuClient) recordRequest(label string, cost time.Duration, failed bool, errMsg string) {
|
||||
c.statsMu.Lock()
|
||||
defer c.statsMu.Unlock()
|
||||
if c.reqStats == nil {
|
||||
c.reqStats = make(map[string]*RequestStat)
|
||||
}
|
||||
stat := c.reqStats[label]
|
||||
if stat == nil {
|
||||
stat = &RequestStat{Label: label}
|
||||
c.reqStats[label] = stat
|
||||
}
|
||||
stat.Count++
|
||||
stat.Total += cost
|
||||
if cost > stat.Slowest {
|
||||
stat.Slowest = cost
|
||||
}
|
||||
if failed {
|
||||
stat.Failed++
|
||||
if errMsg != "" {
|
||||
stat.LastErr = utils.TrimForLog(errMsg, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResetRequestStats 清空聚合统计(每次抢购任务开始时调用)。
|
||||
func (c *DouyuClient) ResetRequestStats() {
|
||||
c.statsMu.Lock()
|
||||
c.reqStats = make(map[string]*RequestStat)
|
||||
c.statsMu.Unlock()
|
||||
}
|
||||
|
||||
// RequestStats 返回按调用次数降序排列的聚合统计。
|
||||
func (c *DouyuClient) RequestStats() []RequestStat {
|
||||
c.statsMu.Lock()
|
||||
out := make([]RequestStat, 0, len(c.reqStats))
|
||||
for _, stat := range c.reqStats {
|
||||
out = append(out, *stat)
|
||||
}
|
||||
c.statsMu.Unlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count != out[j].Count {
|
||||
return out[i].Count > out[j].Count
|
||||
}
|
||||
return out[i].Label < out[j].Label
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *DouyuClient) logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||||
c.recordRequest(label, cost, apiError != 0, apiMsg)
|
||||
if !shouldLogNetworkSuccess(label) {
|
||||
return
|
||||
}
|
||||
@@ -1796,25 +1862,29 @@ func (c *DouyuClient) logNetworkSuccess(label string, req *http.Request, cost ti
|
||||
if apiMsg != "" || apiError != 0 {
|
||||
extra = fmt.Sprintf(" apiError=%d msg=%s", apiError, utils.TrimForLog(apiMsg, 80))
|
||||
}
|
||||
logger.LogEvent("网络", "info", "请求完成: %s 耗时=%s%s", c.networkDiagnostic(label, req), utils.RoundDuration(cost), extra)
|
||||
logger.LogEvent("网络", "debug", "请求完成: %s 耗时=%s%s", c.networkDiagnostic(label, req), utils.RoundDuration(cost), extra)
|
||||
}
|
||||
|
||||
func (c *DouyuClient) logNetworkAPIResult(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||||
logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s apiError=%d msg=%s",
|
||||
c.recordRequest(label, cost, true, apiMsg)
|
||||
logger.LogEvent("网络", "debug", "接口返回异常: %s 耗时=%s apiError=%d msg=%s",
|
||||
c.networkDiagnostic(label, req), utils.RoundDuration(cost), apiError, utils.TrimForLog(apiMsg, 120))
|
||||
}
|
||||
|
||||
func (c *DouyuClient) logNetworkHTTPError(label string, req *http.Request, status int, cost time.Duration, body []byte) {
|
||||
c.recordRequest(label, cost, true, fmt.Sprintf("HTTP %d", status))
|
||||
logger.LogEvent("网络", "error", "HTTP异常: %s 状态=%d 耗时=%s 响应=%s",
|
||||
c.networkDiagnostic(label, req), status, utils.RoundDuration(cost), utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
|
||||
func (c *DouyuClient) logNetworkParseError(label string, req *http.Request, cost time.Duration, body []byte, err error) {
|
||||
c.recordRequest(label, cost, true, err.Error())
|
||||
logger.LogEvent("网络", "error", "响应解析失败: %s 耗时=%s 错误=%v 响应=%s",
|
||||
c.networkDiagnostic(label, req), utils.RoundDuration(cost), err, utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
|
||||
func (c *DouyuClient) logNetworkError(label string, req *http.Request, cost time.Duration, err error) {
|
||||
c.recordRequest(label, cost, true, err.Error())
|
||||
logger.LogEvent("网络", "error", "请求失败: %s 耗时=%s 错误=%v",
|
||||
c.networkDiagnostic(label, req), utils.RoundDuration(cost), err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/activity"
|
||||
"douyu_go/internal/trace"
|
||||
@@ -499,3 +500,44 @@ func testCTNClient(t *testing.T, ctnURL string) *DouyuClient {
|
||||
cli.HTTPClient = http.DefaultClient
|
||||
return cli
|
||||
}
|
||||
|
||||
// TestRequestStatsAggregatesByLabel 验证网络日志降噪后仍保留诊断价值:
|
||||
// 同类接口聚合成一条统计(次数/失败/平均/最慢/最后错误),并可按任务重置。
|
||||
func TestRequestStatsAggregatesByLabel(t *testing.T) {
|
||||
cli := &DouyuClient{}
|
||||
|
||||
cli.recordRequest("锁定兑换商品", 80*time.Millisecond, true, "活动火爆,请稍后再试")
|
||||
cli.recordRequest("锁定兑换商品", 120*time.Millisecond, true, "当前网络繁忙,请稍后再试")
|
||||
cli.recordRequest("锁定兑换商品", 60*time.Millisecond, false, "")
|
||||
cli.recordRequest("支付锁单", 300*time.Millisecond, false, "")
|
||||
|
||||
stats := cli.RequestStats()
|
||||
if len(stats) != 2 {
|
||||
t.Fatalf("统计条目 = %d, want 2: %#v", len(stats), stats)
|
||||
}
|
||||
// 次数多的排前面
|
||||
if stats[0].Label != "锁定兑换商品" {
|
||||
t.Fatalf("首条应为调用最多的接口, got %q", stats[0].Label)
|
||||
}
|
||||
lock := stats[0]
|
||||
if lock.Count != 3 || lock.Failed != 2 {
|
||||
t.Fatalf("锁定兑换商品 次数=%d 失败=%d, want 3/2", lock.Count, lock.Failed)
|
||||
}
|
||||
if lock.Slowest != 120*time.Millisecond {
|
||||
t.Fatalf("最慢 = %v, want 120ms", lock.Slowest)
|
||||
}
|
||||
if lock.Total != 260*time.Millisecond {
|
||||
t.Fatalf("总耗时 = %v, want 260ms", lock.Total)
|
||||
}
|
||||
if lock.LastErr != "当前网络繁忙,请稍后再试" {
|
||||
t.Fatalf("最后一次错误 = %q", lock.LastErr)
|
||||
}
|
||||
if stats[1].Failed != 0 || stats[1].LastErr != "" {
|
||||
t.Fatalf("支付锁单不应有失败记录: %#v", stats[1])
|
||||
}
|
||||
|
||||
cli.ResetRequestStats()
|
||||
if got := cli.RequestStats(); len(got) != 0 {
|
||||
t.Fatalf("重置后应为空, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package constants
|
||||
import "time"
|
||||
|
||||
// Version 应用版本号,与 wails.json version 同步。
|
||||
const Version = "0.3.1"
|
||||
const Version = "0.3.2"
|
||||
|
||||
// BuildTime 构建时间,由编译时 ldflags 注入。
|
||||
var BuildTime = "unknown"
|
||||
|
||||
@@ -141,7 +141,10 @@ var globalSink *sink
|
||||
|
||||
// InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。
|
||||
// 终端输出通过 sink 同步写入(Windows GUI 应用无控制台时自动跳过)。
|
||||
func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||||
//
|
||||
// instanceName / dataDir 只用于写文件表头,便于把日志复制/汇总后仍能看出
|
||||
// 是哪台机器的哪个实例。
|
||||
func InitLogging(store *MemoryLogStore, logDir, instanceName, dataDir string) func() {
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
|
||||
@@ -166,6 +169,10 @@ func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// 表头同步写入,确保排在任何日志之前(异步写入器还没启动)。
|
||||
// 写失败不影响日志本身。
|
||||
_, _ = file.WriteString(logFileHeader(instanceName, dataDir))
|
||||
|
||||
store.SetFilePath(filePath)
|
||||
globalSink = &sink{
|
||||
async: NewAsyncLogWriter(file, asyncLogBufferSize),
|
||||
@@ -181,6 +188,28 @@ func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// logFileHeader 说明日志格式与运行上下文。
|
||||
//
|
||||
// 双时间(本地时间 + 斗鱼估算时间)一直存在,但日志里没有任何地方解释过;
|
||||
// 而且时间校准完成前偏移为 0、只显示本地时间,看起来像格式不一致。
|
||||
// 把格式、实例名、数据目录写在文件开头,复制或汇总多实例日志时就分得清。
|
||||
func logFileHeader(instanceName, dataDir string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "未知实例"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("==================================================================\n")
|
||||
fmt.Fprintf(&b, "实例: %s\n", instanceName)
|
||||
if dataDir != "" {
|
||||
fmt.Fprintf(&b, "数据目录: %s\n", dataDir)
|
||||
}
|
||||
b.WriteString("时间格式: 本地时间(D斗鱼估算时间) [类别][级别][账号] 正文\n")
|
||||
b.WriteString(" D 为经服务端校准的斗鱼时间估算;校准完成前偏移为 0,只显示本地时间\n")
|
||||
b.WriteString("网络逐条请求为 debug 级,默认不显示;排查时把配置 logLevel 改成 debug\n")
|
||||
b.WriteString("==================================================================\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// createUniqueLogFile 以 O_EXCL 递增后缀创建日志文件。
|
||||
//
|
||||
// 早期实现用 O_CREATE|O_APPEND + 秒级时间戳:同一目录下同秒启动的两个进程会
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
// LogEntry 的 category/level/account 应直接由调用方填入,而非字符串反解析。
|
||||
func TestStructuredLogging(t *testing.T) {
|
||||
store := NewMemoryLogStore(100)
|
||||
cleanup := InitLogging(store, t.TempDir())
|
||||
cleanup := InitLogging(store, t.TempDir(), "测试实例", "/tmp/douyu-go-test")
|
||||
defer func() {
|
||||
cleanup()
|
||||
globalSink = nil
|
||||
@@ -98,3 +99,39 @@ func TestCreateUniqueLogFileAvoidsCollision(t *testing.T) {
|
||||
t.Fatalf("second = %q, want 同目录 -2 后缀", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogFileHeaderDocumentsFormat 日志文件开头必须说明双时间含义、实例名与数据目录,
|
||||
// 否则把日志复制或汇总后看不出两个时间分别是什么、来自哪台机器的哪个实例。
|
||||
func TestLogFileHeaderDocumentsFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewMemoryLogStore(20)
|
||||
cleanup := InitLogging(store, dir, "实例 2", `C:\Users\Administrator\AppData\Roaming\douyu-go\instances\instance-2`)
|
||||
LogEvent("系统", "success", "已启动实例 2")
|
||||
cleanup()
|
||||
globalSink = nil
|
||||
|
||||
path := store.FilePath()
|
||||
if path == "" {
|
||||
t.Fatal("日志文件路径为空")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"实例: 实例 2",
|
||||
"数据目录: ",
|
||||
"时间格式: 本地时间(D斗鱼估算时间)",
|
||||
"校准完成前偏移为 0",
|
||||
"logLevel 改成 debug",
|
||||
"已启动实例 2",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("日志文件缺少 %q\n实际内容:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(content, "====") {
|
||||
t.Errorf("表头应在文件最开始:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +437,12 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
|
||||
logger.LogEvent("抢购", "info", "抢购目标: %s", target)
|
||||
}
|
||||
|
||||
// 逐条网络日志已降为 debug 级,这里开始统计每类接口的调用情况,
|
||||
// 任务结束时输出一行聚合结果。
|
||||
if cli := s.clientSnapshot(); cli != nil {
|
||||
cli.ResetRequestStats()
|
||||
}
|
||||
|
||||
outcomes := make([]accountOutcome, len(tasks))
|
||||
var wg sync.WaitGroup
|
||||
for i, task := range tasks {
|
||||
@@ -449,6 +455,27 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
|
||||
wg.Wait()
|
||||
|
||||
s.logRunSummary(outcomes, ctl, time.Since(start))
|
||||
s.logNetworkSummary(s.clientSnapshot())
|
||||
}
|
||||
|
||||
// logNetworkSummary 输出本次任务期间各类接口的聚合统计,替代逐条刷屏的网络日志。
|
||||
func (s *Scheduler) logNetworkSummary(cli *client.DouyuClient) {
|
||||
if cli == nil {
|
||||
return
|
||||
}
|
||||
for _, stat := range cli.RequestStats() {
|
||||
avg := time.Duration(0)
|
||||
if stat.Count > 0 {
|
||||
avg = stat.Total / time.Duration(stat.Count)
|
||||
}
|
||||
if stat.LastErr != "" {
|
||||
logger.LogEvent("网络", "info", "接口统计: %s ×%d 失败%d 平均=%s 最慢=%s 最后错误=%s",
|
||||
stat.Label, stat.Count, stat.Failed, utils.RoundDuration(avg), utils.RoundDuration(stat.Slowest), stat.LastErr)
|
||||
continue
|
||||
}
|
||||
logger.LogEvent("网络", "info", "接口统计: %s ×%d 失败%d 平均=%s 最慢=%s",
|
||||
stat.Label, stat.Count, stat.Failed, utils.RoundDuration(avg), utils.RoundDuration(stat.Slowest))
|
||||
}
|
||||
}
|
||||
|
||||
// 账号最终结束状态。
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"douyu_go/internal/activity"
|
||||
"douyu_go/internal/client"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/trace"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
@@ -928,3 +929,64 @@ func TestFilterCooldownAttempts(t *testing.T) {
|
||||
t.Errorf("nextReady should be after now")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNowLogsAggregateInsteadOfPerRequest 锁住日志降噪:
|
||||
// 默认 info 级下,逐条网络请求不应进入日志,但任务结束要有一行按接口聚合的统计。
|
||||
func TestRunNowLogsAggregateInsteadOfPerRequest(t *testing.T) {
|
||||
speedUpPeace(t)
|
||||
seckillMaxDuration = 1200 * time.Millisecond
|
||||
|
||||
scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/create":
|
||||
_, _ = w.Write([]byte(`{"error":1644402023,"msg":"商品本次活动库存已耗完"}`))
|
||||
case "/bind":
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
|
||||
}
|
||||
})
|
||||
scheduler.paths = writeAccountFile(t, "a", "b")
|
||||
scheduler.accounts = NewAccountStore(scheduler.paths.AccountFile)
|
||||
scheduler.LoadAccounts()
|
||||
scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
|
||||
|
||||
store := logger.NewMemoryLogStore(4000)
|
||||
cleanup := logger.InitLogging(store, t.TempDir(), "测试实例", "/tmp/douyu-go-test")
|
||||
defer cleanup()
|
||||
|
||||
scheduler.RunNow(map[string]string{"a": "goods-1", "b": "goods-1"})
|
||||
|
||||
var aggregate, perRequest, summary string
|
||||
for _, entry := range store.Since(0) {
|
||||
switch {
|
||||
case strings.Contains(entry.Message, "接口统计: 锁定兑换商品"):
|
||||
aggregate = entry.Message
|
||||
case strings.Contains(entry.Message, "请求完成:"):
|
||||
perRequest = entry.Message
|
||||
case strings.Contains(entry.Message, "抢购任务结束:"):
|
||||
summary = entry.Message
|
||||
}
|
||||
}
|
||||
|
||||
if aggregate == "" {
|
||||
t.Fatalf("缺少按接口聚合的统计行;实际日志:\n%s", dumpMessages(store))
|
||||
}
|
||||
if !strings.Contains(aggregate, "失败") || !strings.Contains(aggregate, "最慢=") {
|
||||
t.Fatalf("聚合行信息不足: %q", aggregate)
|
||||
}
|
||||
if perRequest != "" {
|
||||
t.Fatalf("逐条网络请求日志不应在 info 级出现: %q", perRequest)
|
||||
}
|
||||
if summary == "" {
|
||||
t.Fatalf("缺少任务汇总行;实际日志:\n%s", dumpMessages(store))
|
||||
}
|
||||
}
|
||||
|
||||
func dumpMessages(store *logger.MemoryLogStore) string {
|
||||
var b strings.Builder
|
||||
for _, entry := range store.Since(0) {
|
||||
b.WriteString(entry.Message)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "douyu_go",
|
||||
"outputfilename": "douyu_go",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"frontend:dir": "frontend",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
@@ -15,6 +15,6 @@
|
||||
"email": "yml@example.com"
|
||||
},
|
||||
"info": {
|
||||
"productVersion": "0.3.1"
|
||||
"productVersion": "0.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user