建立可运行、可构建的 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
183 lines
4.4 KiB
Go
183 lines
4.4 KiB
Go
package logx
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// 这是本包最重要的测试:敏感内容绝不能出现在日志里。
|
|
// 每新增一条脱敏规则,都要在这里补一个用例。
|
|
func TestMaskHidesSecrets(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
// leak 是绝对不能出现在结果里的片段
|
|
leak string
|
|
}{
|
|
{
|
|
name: "淘宝 MTOP token",
|
|
in: "cookie 里 _m_h5_tk=abc123def456_1699999999999 已读取",
|
|
leak: "abc123def456",
|
|
},
|
|
{
|
|
name: "淘宝 token enc",
|
|
in: "_m_h5_tk_enc=zzz999yyy888",
|
|
leak: "zzz999yyy888",
|
|
},
|
|
{
|
|
name: "淘宝 tb_token",
|
|
in: "_tb_token_=e3b0c44298fc; path=/",
|
|
leak: "e3b0c44298fc",
|
|
},
|
|
{
|
|
name: "Authorization 头",
|
|
in: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.fake.token",
|
|
leak: "eyJhbGciOiJIUzI1NiJ9",
|
|
},
|
|
{
|
|
name: "Cookie 请求头",
|
|
in: "Cookie: a=1; b=2; secret=3",
|
|
leak: "secret=3",
|
|
},
|
|
{
|
|
name: "JSON 里的密码",
|
|
in: `{"account":"13500000000","password":"不该出现的密码"}`,
|
|
leak: "不该出现的密码",
|
|
},
|
|
{
|
|
name: "等号形式的密码",
|
|
in: "password=hunter2&next=1",
|
|
leak: "hunter2",
|
|
},
|
|
{
|
|
name: "中文冒号密码",
|
|
in: "登录失败,密码:明文密码值",
|
|
leak: "明文密码值",
|
|
},
|
|
{
|
|
name: "图片 base64",
|
|
in: "上传 data:image/jpeg;base64,AAAABBBBCCCCDDDD 完成",
|
|
leak: "AAAABBBBCCCCDDDD",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := Mask(tc.in)
|
|
if strings.Contains(got, tc.leak) {
|
|
t.Fatalf("脱敏后仍然泄漏了 %q\n原文:%s\n结果:%s", tc.leak, tc.in, got)
|
|
}
|
|
if !strings.Contains(got, "***") {
|
|
t.Fatalf("应当出现 *** 占位符,结果是:%s", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 普通文本不应该被误伤。
|
|
func TestMaskKeepsNormalText(t *testing.T) {
|
|
in := "[3/6] 商品 40571188442 图搜返回 48 个同款,取前 20 个"
|
|
if got := Mask(in); got != in {
|
|
t.Fatalf("普通文本不应被改动\n原文:%s\n结果:%s", in, got)
|
|
}
|
|
}
|
|
|
|
// 写日志时也必须脱敏,不能只有直接调用 Mask 才生效。
|
|
func TestLoggerMasksOnWrite(t *testing.T) {
|
|
l := New(10)
|
|
l.Info("登录请求 password=真实密码")
|
|
|
|
entries := l.Entries()
|
|
if len(entries) != 1 {
|
|
t.Fatalf("应当有 1 条日志,实际 %d 条", len(entries))
|
|
}
|
|
if strings.Contains(entries[0].Message, "真实密码") {
|
|
t.Fatalf("日志里泄漏了密码:%s", entries[0].Message)
|
|
}
|
|
}
|
|
|
|
// 超过上限时要丢掉最旧的,防止长时间任务把内存吃光。
|
|
func TestLoggerRespectsLimit(t *testing.T) {
|
|
l := New(3)
|
|
for i := 0; i < 10; i++ {
|
|
l.Info("第 %d 条", i)
|
|
}
|
|
|
|
entries := l.Entries()
|
|
if len(entries) != 3 {
|
|
t.Fatalf("应当只保留 3 条,实际 %d 条", len(entries))
|
|
}
|
|
// 保留的应当是最后 3 条。
|
|
if !strings.Contains(entries[0].Message, "第 7 条") {
|
|
t.Fatalf("应当保留最新的日志,第一条却是:%s", entries[0].Message)
|
|
}
|
|
}
|
|
|
|
// 下载任务会有多个协程同时写日志,不能崩。
|
|
func TestLoggerIsConcurrentSafe(t *testing.T) {
|
|
l := New(1000)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
for j := 0; j < 50; j++ {
|
|
l.Info("协程 %d 第 %d 条", n, j)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if len(l.Entries()) != 1000 {
|
|
t.Fatalf("应当有 1000 条日志,实际 %d 条", len(l.Entries()))
|
|
}
|
|
}
|
|
|
|
func TestLoggerLevels(t *testing.T) {
|
|
l := New(10)
|
|
l.Info("信息")
|
|
l.Success("成功")
|
|
l.Warn("警告")
|
|
l.Error("失败")
|
|
|
|
want := []Level{LevelInfo, LevelSuccess, LevelWarn, LevelError}
|
|
entries := l.Entries()
|
|
for i, level := range want {
|
|
if entries[i].Level != level {
|
|
t.Fatalf("第 %d 条级别应当是 %s,实际 %s", i, level, entries[i].Level)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoggerClearAndText(t *testing.T) {
|
|
l := New(10)
|
|
l.Info("第一条")
|
|
l.Info("第二条")
|
|
|
|
text := l.Text()
|
|
if !strings.Contains(text, "第一条") || !strings.Contains(text, "第二条") {
|
|
t.Fatalf("导出文本应当包含全部日志,实际:%s", text)
|
|
}
|
|
|
|
l.Clear()
|
|
if len(l.Entries()) != 0 {
|
|
t.Fatalf("清空后不应还有日志")
|
|
}
|
|
}
|
|
|
|
// 日志写入后要能推给前端。
|
|
func TestLoggerHandler(t *testing.T) {
|
|
l := New(10)
|
|
var got []Entry
|
|
l.SetHandler(func(e Entry) { got = append(got, e) })
|
|
|
|
l.Info("一条日志")
|
|
if len(got) != 1 {
|
|
t.Fatalf("回调应当被调用 1 次,实际 %d 次", len(got))
|
|
}
|
|
if got[0].Message != "一条日志" {
|
|
t.Fatalf("回调收到的内容不对:%s", got[0].Message)
|
|
}
|
|
}
|