建立可运行、可构建的 Go 骨架,供后续 R1~R5 在其上叠加。 后端: - main.go / app.go:Wails v2 窗口与 14 个前端可调方法。Wails 专有 API 只出现在这两个文件,internal/ 对 Wails 零依赖,便于将来迁移 v3 - internal/config:config.yaml 的读写、校验、脱敏。保存时按固定模板 渲染以保留注释,不用 yaml.Marshal - internal/store:SQLite(modernc.org/sqlite,无需 CGO)。建表迁移、 商品仓储、KV。重复同步不会覆盖本地下载与上传状态 - internal/logx:9 条脱敏规则,覆盖淘宝 token、Cookie、Authorization、 密码和图片 base64;并发安全的内存日志缓冲 前端(Vue 3 + Naive UI,按已确认原型 v4): - Sidebar:176px 可折叠,仅商品列表与参数设置两项,底部是登录状态的 唯一显示位置 - ProductListView:两行工具栏(四项筛选 + 重置搜索 / 四个操作按钮)、 商品表格、分页 - SettingsView:四张卡片,路径用文件与目录选择框 - LogWindow:运行日志子窗口,按级别过滤、自动滚动、导出 配置改用 YAML:分 huohanhan / taobao / download 三段。taobao 段刻意 没有账号密码字段,并有单元测试挡住后续加入。 构建相关: - .gitignore 只忽略 build/bin/,不忽略 build/。Wails 把 build/ 当源码 目录(图标、manifest),忽略掉会让别人克隆后构建失败 - .gitignore 忽略 config.yaml、payloads/ 和 *.har(含真实手机号与凭据) 未实现的下载数据、下载视频、上传数据三个方法返回明确的「还没实现」, 不静默失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
165 lines
5.3 KiB
Go
165 lines
5.3 KiB
Go
// Package logx 提供带脱敏的日志。
|
||
//
|
||
// 为什么要专门写一个包,而不是直接用标准库 log:
|
||
// 本项目会接触淘宝 Cookie、货憨憨 token 和账号密码。这些东西
|
||
// 一旦进了日志文件,就等于泄漏了。所以所有日志都必须经过这里,
|
||
// 由 Mask() 统一把敏感内容替换掉。
|
||
//
|
||
// 规则很简单:往日志里写东西时,永远调用本包的方法,不要直接
|
||
// 用 fmt.Println 或 log.Printf。
|
||
package logx
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Level 是日志级别。界面上的运行日志按它上色和过滤。
|
||
type Level string
|
||
|
||
const (
|
||
LevelInfo Level = "info" // 普通进度
|
||
LevelSuccess Level = "success" // 成功完成一步
|
||
LevelWarn Level = "warn" // 有问题但还能继续
|
||
LevelError Level = "error" // 失败
|
||
)
|
||
|
||
// Entry 是一条日志。前端「运行日志」窗口直接显示它。
|
||
type Entry struct {
|
||
Time string `json:"time"` // 形如 15:04:05,只给人看
|
||
Level Level `json:"level"` // 前端按它上色和过滤
|
||
Message string `json:"message"` // 已经脱敏过的正文
|
||
}
|
||
|
||
// 下面这些正则用来找出不该出现在日志里的内容。
|
||
//
|
||
// 新增敏感字段时,在这里加一条,并在 logx_test.go 里补一个用例。
|
||
var maskPatterns = []struct {
|
||
name string
|
||
pattern *regexp.Regexp
|
||
replace string
|
||
}{
|
||
// 淘宝 MTOP 令牌,形如 _m_h5_tk=abc123_1699...
|
||
{"淘宝 token", regexp.MustCompile(`(?i)(_m_h5_tk(_enc)?=)[^;&\s]+`), "${1}***"},
|
||
// 淘宝其它登录相关 Cookie
|
||
{"淘宝 cookie", regexp.MustCompile(`(?i)(_tb_token_=|tracknick=)[^;&\s]+`), "${1}***"},
|
||
// HTTP 认证头
|
||
{"Authorization", regexp.MustCompile(`(?i)(authorization:\s*bearer\s+)\S+`), "${1}***"},
|
||
// 整个 Cookie 请求头
|
||
{"Cookie 头", regexp.MustCompile(`(?i)(cookie:\s*)\S.*`), "${1}***"},
|
||
// 常见的密码字段,覆盖 password=xxx、"password":"xxx"、密码:xxx
|
||
{"密码", regexp.MustCompile(`(?i)((password|passwd|pwd)\s*[":=]+\s*"?)[^"\s,}]+`), "${1}***"},
|
||
{"中文密码", regexp.MustCompile(`(密码[::]\s*)\S+`), "${1}***"},
|
||
// Base64 图片数据,打印出来既没用又刷屏
|
||
{"图片 base64", regexp.MustCompile(`data:image/[a-z]+;base64,[A-Za-z0-9+/=]+`), "data:image/*;base64,***"},
|
||
}
|
||
|
||
// Mask 把文本里的敏感内容替换成 ***。
|
||
//
|
||
// 它只做替换,不判断“这条日志该不该记”。要不要记由调用方决定。
|
||
func Mask(text string) string {
|
||
for _, p := range maskPatterns {
|
||
text = p.pattern.ReplaceAllString(text, p.replace)
|
||
}
|
||
return text
|
||
}
|
||
|
||
// Logger 是一个内存日志缓冲区。
|
||
//
|
||
// 为什么放内存:界面上的「运行日志」窗口需要随时能拿到最近的日志。
|
||
// 为什么有上限:任务可能跑几个小时,不限制会把内存吃光。
|
||
//
|
||
// 它是并发安全的,多个下载协程可以同时往里写。
|
||
type Logger struct {
|
||
mu sync.Mutex
|
||
entries []Entry
|
||
limit int
|
||
// onEntry 在每条日志写入后被调用,用来推送给前端。
|
||
// 为 nil 时不推送,方便在单元测试里使用。
|
||
onEntry func(Entry)
|
||
}
|
||
|
||
// New 创建一个日志缓冲区。limit 是最多保留多少条,超出后丢弃最旧的。
|
||
func New(limit int) *Logger {
|
||
if limit <= 0 {
|
||
limit = 1000
|
||
}
|
||
return &Logger{limit: limit}
|
||
}
|
||
|
||
// SetHandler 设置日志推送回调。Wails 启动后用它把日志发给前端。
|
||
func (l *Logger) SetHandler(handler func(Entry)) {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
l.onEntry = handler
|
||
}
|
||
|
||
func (l *Logger) write(level Level, format string, args ...any) {
|
||
entry := Entry{
|
||
Time: time.Now().Format("15:04:05"),
|
||
Level: level,
|
||
Message: Mask(fmt.Sprintf(format, args...)),
|
||
}
|
||
|
||
l.mu.Lock()
|
||
l.entries = append(l.entries, entry)
|
||
if len(l.entries) > l.limit {
|
||
// 丢掉最旧的那些。用 copy 而不是切片头部截取,
|
||
// 是为了让底层数组能被回收,避免内存一直涨。
|
||
drop := len(l.entries) - l.limit
|
||
l.entries = append(l.entries[:0], l.entries[drop:]...)
|
||
}
|
||
handler := l.onEntry
|
||
l.mu.Unlock()
|
||
|
||
if handler != nil {
|
||
handler(entry)
|
||
}
|
||
}
|
||
|
||
// Info 记录一条普通进度。
|
||
func (l *Logger) Info(format string, args ...any) { l.write(LevelInfo, format, args...) }
|
||
|
||
// Success 记录一条成功。
|
||
func (l *Logger) Success(format string, args ...any) { l.write(LevelSuccess, format, args...) }
|
||
|
||
// Warn 记录一条警告:有问题但任务还能继续。
|
||
func (l *Logger) Warn(format string, args ...any) { l.write(LevelWarn, format, args...) }
|
||
|
||
// Error 记录一条失败。
|
||
func (l *Logger) Error(format string, args ...any) { l.write(LevelError, format, args...) }
|
||
|
||
// Entries 返回当前保留的全部日志副本。
|
||
//
|
||
// 返回副本而不是内部切片,是为了防止调用方在外面改动它,
|
||
// 导致并发读写崩溃。
|
||
func (l *Logger) Entries() []Entry {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
out := make([]Entry, len(l.entries))
|
||
copy(out, l.entries)
|
||
return out
|
||
}
|
||
|
||
// Clear 清空日志。对应界面上「运行日志」窗口的清空按钮。
|
||
func (l *Logger) Clear() {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
l.entries = nil
|
||
}
|
||
|
||
// Text 把全部日志拼成纯文本,用于「导出日志」。
|
||
func (l *Logger) Text() string {
|
||
var sb strings.Builder
|
||
for _, e := range l.Entries() {
|
||
sb.WriteString(e.Time)
|
||
sb.WriteString(" ")
|
||
sb.WriteString(e.Message)
|
||
sb.WriteString("\n")
|
||
}
|
||
return sb.String()
|
||
}
|