fix: 退出前安全关闭淘宝专属 Chrome (#36)

This commit is contained in:
QiuSW
2026-10-06 15:39:08 +08:00
parent 6366462be2
commit 05003da609
9 changed files with 424 additions and 7 deletions
+30 -1
View File
@@ -56,6 +56,7 @@ type App struct {
uploadProgressMu sync.Mutex
uploadProgress UploadProgress
uploadStarted time.Time
exitNoticeOnce sync.Once
}
// NewApp 创建应用对象。真正的初始化在 startup 里做。
@@ -119,7 +120,35 @@ func (a *App) startup(ctx context.Context) {
}
}
// shutdown 在窗口关闭时被 Wails 调用,用来收尾。
// beforeClose 在关闭窗口前安全停止取视频任务并清理专属 Chrome。
func (a *App) beforeClose(ctx context.Context) bool {
a.videoTaskMu.Lock()
runner := a.videoTask
a.videoTaskMu.Unlock()
if runner != nil && runner.Snapshot().State == task.StateRunning {
runner.Stop()
a.log.Warn("取视频任务仍在运行,已请求停止;当前下载完成后请再次关闭程序")
a.exitNoticeOnce.Do(func() {
go func() {
_, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.WarningDialog, Title: "正在停止取视频任务",
Message: "已请求停止任务。请等待当前下载完成,再关闭程序。",
})
}()
})
return true
}
stateFile := config.ResolveDataPath("浏览器运行状态.json")
closed, err := taobao.CloseBrowserOnExit(stateFile, a.cfg.Taobao.UserDataDir)
if err != nil {
a.log.Warn("退出时保留未能确认归属或关闭的淘宝专属 Chrome:%v", err)
} else if closed {
a.log.Info("退出前已关闭淘宝专属 Chrome")
}
return false
}
// shutdown 在窗口关闭后释放 SQLite。
func (a *App) shutdown(ctx context.Context) {
if a.db != nil {
if err := a.db.Close(); err != nil {
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"testing"
"time"
"cmsp/internal/task"
)
func Test退出前先停止取视频任务(t *testing.T) {
started := make(chan struct{})
runner := task.NewRunner(task.Options{
Load: func(_ context.Context, id string) (task.Product, error) {
return task.Product{ID: id}, nil
},
Prepare: func(ctx context.Context, _ task.Product) (task.Work, error) {
close(started)
<-ctx.Done()
return task.Work{}, ctx.Err()
},
})
if err := runner.Start(context.Background(), []string{"fictional-product"}); err != nil {
t.Fatal(err)
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("取视频任务未启动")
}
a := NewApp()
a.videoTask = runner
// 此测试没有 Wails 前端;只检查退出门,不打开系统消息框。
a.exitNoticeOnce.Do(func() {})
if !a.beforeClose(context.Background()) {
t.Fatal("任务仍在运行时必须阻止本次退出")
}
deadline := time.Now().Add(2 * time.Second)
for runner.Snapshot().State == task.StateRunning && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := runner.Snapshot().State; got != task.StateStopped {
t.Fatalf("退出前未停止任务:%s", got)
}
}
+6 -2
View File
@@ -2,12 +2,16 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Business-Rules-and-Glossary.-
wiki_revision: bbb42cb37cc4703c3953c021a7054f862ae312e0
synchronized_at: 2026-10-05T10:01:40Z
wiki_revision: 6deec49c2ba48aaf96a5842b4d525352afd6cf87
synchronized_at: 2026-10-06T07:37:40Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
## 程序退出与淘宝专属 Chrome(2026-10-06)
关闭 cmsp 时,若取视频任务仍在运行,先请求停止并阻止本次退出;当前商品处理结束后,使用者再次关闭程序。没有运行中的取视频任务时,仅当运行状态文件中的 PID、Chrome 路径、专属 Profile、调试端口与当前进程及 CDP 页面均可验证,才通过 CDP 请求 Chrome 正常退出;超时后重新核对 PID 归属,再强制结束该专属进程树。归属不明或调试端口不可验证时保留浏览器并记录原因,不影响 cmsp 退出。不会关闭日常 Chrome,也不删除 Chrome Profile 或读取登录凭据。
## 指定商品视频上传规则(2026-09-30)
单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。ERPGo 预检返回 200 且 `video: []` 时才确认远端当前无视频;返回 502 / `HHH_UPSTREAM_ERROR` / `stage=check` 表示视频状态未知,显示“视频状态无法确认,上传未提交”,不创建上传操作、不发送 PUT,保留本地 MP4 与 requestId。批量路径的该商品上传状态为 `unconfirmed`,不记作上传失败。`tempVideoUrl` 或 `videoUploadIdStr` 非空表示远端仍有处理中标记:单商品预览停止,执行前再次检查;批量遇到该商品标为待处理,不创建新操作、不发送 PUT。只有普通已存在视频且没有处理中标记时,单商品才可在明确覆盖确认后继续。用户确认后弹窗立即关闭,上传按钮在调用期间继续显示运行中;弹窗关闭不代表远端成功。单商品已有本地未决操作时,确认框改为“恢复原上传操作”,只查询或恢复原键,不提示覆盖、不发新 PUT;操作明确失败后才按新上传流程检查当前视频。远端操作 processing/unknown 或操作查询异常保留原键,商品上传状态为 `unconfirmed`,列表显示“待确认”;只有本次操作返回 `succeeded` 才显示“已上传”。
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Local-Development-and-Verification.-
wiki_revision: 69f9ab4712b8efd0aa6f31e08d39b51503c88957
synchronized_at: 2026-10-06T07:03:18Z
wiki_revision: 697d2e353c3b4b3f6648b7a08e4752235c42c7e7
synchronized_at: 2026-10-06T07:37:41Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
## 退出前关闭淘宝专属 Chrome 的验证(2026-10-06,#36)
`app_close_test.go` 验证运行中的取视频任务先请求停止且本次窗口关闭被阻止;`internal/taobao/chrome_test.go` 验证专属 PID/Profile/调试端口匹配时正常关闭、超时后重新核对 PID 才强制结束,以及归属或 CDP 不明时保留浏览器;`internal/taobao/cdp_test.go` 验证 `Browser.close` 发送至本机调试端口,拒绝外部 WebSocket 地址。从仓库根目录执行 `go test ./... -count=1`、`go test -race ./...`、`go vet ./...`、`python dev_scripts/harness.py check --strict` 和 Wails Windows 构建。
真机验收:启动 cmsp 专属 Chrome,关闭 cmsp 后确认该专属进程退出、日常 Chrome 仍在、Profile 保留;取视频任务中关闭程序时确认先停任务、待当前处理结束再次关闭。模拟测试和构建不能代替真机进程退出及 Wails 窗口交互验证。
## 批量上传进度验证(2026-10-06)
`UploadVideos` 使用后端内存快照与 `upload:progress` 事件报告当前批次,前端每秒轮询 `GetUploadProgress` 兜底。每个商品处理返回后从 SQLite 读取上传状态并累计一次;并发事件用批次号和已处理数避免旧事件回退显示,列表刷新共用串行合并队列并在批次结束时等待最后一次刷新。`TestUploadBatchRespectsConfiguredConcurrency` 覆盖并发 1/3 与去重总数,`TestUploadProgressClassifiesSQLiteOutcomesConcurrently` 覆盖终态分类,`TestUploadProgressAdvancesBeforeBatchFinishes` 覆盖未完成批次的逐商品进度及待确认不计入已上传。运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、`npm --prefix frontend run build` 和 Wails Windows 构建;真实店铺上传仍需使用者明确确认并单独验收。
+26
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
@@ -248,6 +249,31 @@ func cdpVersionAvailable(port int) bool {
return fetchDebugJSON(port, "json/version", &payload) == nil
}
// requestBrowserClose 只连接已验证的本机调试端口,请求 Chrome 正常退出。
func requestBrowserClose(port int) error {
var version struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
if err := fetchDebugJSON(port, "json/version", &version); err != nil {
return err
}
endpoint, err := url.Parse(version.WebSocketDebuggerURL)
if err != nil || endpoint.Scheme != "ws" || (endpoint.Hostname() != "127.0.0.1" && endpoint.Hostname() != "localhost") || endpoint.Port() != strconv.Itoa(port) {
return fmt.Errorf("Chrome 浏览器调试地址不属于已验证的本机端口")
}
dialer := *websocket.DefaultDialer
dialer.HandshakeTimeout = cdpRequestTimeout
conn, _, err := dialer.Dial(endpoint.String(), nil)
if err != nil {
return fmt.Errorf("连接 Chrome 浏览器调试端口失败:%w", err)
}
defer conn.Close()
if err := conn.SetWriteDeadline(time.Now().Add(cdpRequestTimeout)); err != nil {
return err
}
return conn.WriteJSON(map[string]any{"id": 1, "method": "Browser.close"})
}
func cdpHasPage(port int) bool {
targets, err := fetchDebugTargets(port)
if err != nil {
+62
View File
@@ -2,9 +2,71 @@ package taobao
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/gorilla/websocket"
)
func Test正常关闭Chrome通过浏览器CDP端点(t *testing.T) {
methods := make(chan string, 1)
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/json/version":
fmt.Fprintf(w, `{"webSocketDebuggerUrl":"ws://%s/devtools/browser/test"}`, r.Host)
case "/devtools/browser/test":
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("WebSocket 升级失败:%v", err)
return
}
defer conn.Close()
var request struct {
Method string `json:"method"`
}
if err := conn.ReadJSON(&request); err != nil {
t.Errorf("读取关闭请求失败:%v", err)
return
}
methods <- request.Method
default:
t.Errorf("访问了意外端点:%s", r.URL.Path)
}
}))
defer server.Close()
parsed, _ := url.Parse(server.URL)
port, _ := strconv.Atoi(parsed.Port())
if err := requestBrowserClose(port); err != nil {
t.Fatal(err)
}
select {
case method := <-methods:
if method != "Browser.close" {
t.Fatalf("关闭命令错误:%s", method)
}
case <-time.After(2 * time.Second):
t.Fatal("未收到 Browser.close 命令")
}
}
func Test关闭Chrome拒绝外部WebSocket地址(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"webSocketDebuggerUrl":"ws://example.invalid/devtools/browser/test"}`)
}))
defer server.Close()
parsed, _ := url.Parse(server.URL)
port, _ := strconv.Atoi(parsed.Port())
if err := requestBrowserClose(port); err == nil {
t.Fatal("浏览器关闭请求不能连接外部地址")
}
}
func TestCDP响应跳过事件并匹配目标ID(t *testing.T) {
messages := [][]byte{
[]byte(`{"method":"Page.loadEventFired","params":{"timestamp":1}}`),
+101
View File
@@ -146,6 +146,107 @@ func CloseBrowser(stateFile, profile string) (bool, error) {
return true, nil
}
type exitBrowserOps struct {
exists func(int) bool
command func(int) (string, error)
cdpReady func(int) bool
graceful func(int) error
force func(int) error
waitExit func(int, time.Duration) bool
}
// CloseBrowserOnExit 只在退出前关闭可验证归属的专属 Chrome。
// 正常关闭超时后再验证一次 PID,才允许强制结束该进程树。
func CloseBrowserOnExit(stateFile, profile string) (bool, error) {
return closeBrowserOnExitWith(stateFile, profile, exitBrowserOps{
exists: processExists,
command: processCommandLine,
cdpReady: func(port int) bool { return cdpVersionAvailable(port) && cdpHasPage(port) },
graceful: requestBrowserClose,
force: killProcessTree,
waitExit: waitForProcessExit,
})
}
func closeBrowserOnExitWith(stateFile, profile string, ops exitBrowserOps) (bool, error) {
state, found, err := readBrowserState(stateFile)
if err != nil || !found {
return false, err
}
if !ops.exists(state.PID) {
return false, nil
}
if err := verifyExitBrowserOwner(state, profile, ops.command); err != nil {
return false, err
}
if state.Port <= 0 || !ops.cdpReady(state.Port) {
return false, fmt.Errorf("无法确认专属 Chrome 的调试端口和页面,未关闭浏览器")
}
_ = ops.graceful(state.Port)
if !ops.waitExit(state.PID, 3*time.Second) && ops.exists(state.PID) {
// 正常关闭期间 PID 可能退出并被复用,强制结束前必须重新核对命令行。
if err := verifyExitBrowserOwner(state, profile, ops.command); err != nil {
return false, err
}
if err := ops.force(state.PID); err != nil {
return false, fmt.Errorf("关闭专属 Chrome 失败:%w", err)
}
if !ops.waitExit(state.PID, 2*time.Second) {
return false, fmt.Errorf("专属 Chrome 在强制关闭后仍未退出")
}
}
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
return true, fmt.Errorf("浏览器已关闭,但删除运行状态文件失败:%w", err)
}
return true, nil
}
func verifyExitBrowserOwner(state BrowserState, profile string, command func(int) (string, error)) error {
if state.PID <= 0 || strings.TrimSpace(profile) == "" || strings.TrimSpace(state.ProfilePath) == "" || strings.TrimSpace(state.ChromePath) == "" || normalizeWindowsPath(state.ProfilePath) != normalizeWindowsPath(profile) {
return fmt.Errorf("浏览器运行状态与当前专属 Profile 不匹配,未关闭浏览器")
}
line, err := command(state.PID)
if err != nil {
return fmt.Errorf("无法校验专属 Chrome 的进程归属,未关闭浏览器:%w", err)
}
chrome := normalizeWindowsPath(state.ChromePath)
normalized := strings.TrimSpace(normalizeWindowsText(line))
if !commandBelongsToProfile(line, profile) || !commandHasDebugPort(line, state.Port) || !(normalized == chrome || strings.HasPrefix(normalized, chrome+" ")) {
return fmt.Errorf("PID %d 不属于本程序专属 Chrome,未关闭浏览器", state.PID)
}
return nil
}
func commandHasDebugPort(line string, port int) bool {
if port <= 0 {
return false
}
command := normalizeWindowsText(line)
target := "--remote-debugging-port=" + strconv.Itoa(port)
for offset := 0; ; {
index := strings.Index(command[offset:], target)
if index < 0 {
return false
}
end := offset + index + len(target)
if end == len(command) || command[end] == ' ' || command[end] == '\t' {
return true
}
offset = end
}
}
func waitForProcessExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for processExists(pid) {
if time.Now().After(deadline) {
return false
}
time.Sleep(250 * time.Millisecond)
}
return true
}
func validateBrowserConfig(cfg config.TaobaoConfig, openURL string) error {
if strings.TrimSpace(cfg.ChromePath) == "" {
return fmt.Errorf("Chrome 可执行文件路径不能为空")
+143
View File
@@ -1,10 +1,153 @@
package taobao
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func Test退出时只关闭确认归属的专属Chrome(t *testing.T) {
profile := `C:\Users\测试\淘宝专属浏览器`
chrome := `C:\Program Files\Google\Chrome\Application\chrome.exe`
stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json")
if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: chrome}); err != nil {
t.Fatal(err)
}
commandLine := `"` + chrome + `" --user-data-dir="` + profile + `" --remote-debugging-port=19666`
commandCalls, gracefulCalls, forceCalls := 0, 0, 0
ops := exitBrowserOps{
exists: func(int) bool { return true },
command: func(int) (string, error) {
commandCalls++
return commandLine, nil
},
cdpReady: func(int) bool { return true },
graceful: func(int) error {
gracefulCalls++
return nil
},
force: func(int) error {
forceCalls++
return nil
},
waitExit: func(_ int, timeout time.Duration) bool { return timeout == 3*time.Second },
}
closed, err := closeBrowserOnExitWith(stateFile, profile, ops)
if err != nil || !closed || commandCalls != 1 || gracefulCalls != 1 || forceCalls != 0 {
t.Fatalf("正常关闭路径错误:closed=%v err=%v command=%d graceful=%d force=%d", closed, err, commandCalls, gracefulCalls, forceCalls)
}
if _, err := os.Stat(stateFile); !os.IsNotExist(err) {
t.Fatalf("成功关闭后应删除运行状态文件:%v", err)
}
}
func Test退出强制关闭前再次核对PID归属(t *testing.T) {
profile := `C:\Users\测试\淘宝专属浏览器`
chrome := `C:\Program Files\Google\Chrome\Application\chrome.exe`
for _, changed := range []bool{false, true} {
t.Run(map[bool]string{false: "仍归属本程序", true: "PID已复用"}[changed], func(t *testing.T) {
stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json")
if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: chrome}); err != nil {
t.Fatal(err)
}
checks, forced := 0, 0
ops := exitBrowserOps{
exists: func(int) bool { return true },
command: func(int) (string, error) {
checks++
if changed && checks == 2 {
return `"C:\Program Files\Google\Chrome\Application\chrome.exe" --user-data-dir=C:\Users\其他用户`, nil
}
return `"` + chrome + `" --user-data-dir="` + profile + `" --remote-debugging-port=19666`, nil
},
cdpReady: func(int) bool { return true },
graceful: func(int) error { return errors.New("CDP 关闭失败") },
force: func(int) error { forced++; return nil },
waitExit: func(_ int, timeout time.Duration) bool { return timeout == 2*time.Second },
}
closed, err := closeBrowserOnExitWith(stateFile, profile, ops)
if checks != 2 || forced != map[bool]int{false: 1, true: 0}[changed] {
t.Fatalf("强制关闭前归属校验错误:closed=%v err=%v checks=%d forced=%d", closed, err, checks, forced)
}
if changed && (closed || err == nil) {
t.Fatalf("PID 复用时必须保留浏览器:closed=%v err=%v", closed, err)
}
if !changed && (!closed || err != nil) {
t.Fatalf("归属一致时应关闭:closed=%v err=%v", closed, err)
}
})
}
}
func Test退出时归属不明不发送关闭命令(t *testing.T) {
stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json")
if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: `C:\Users\甲\专属`, ChromePath: `C:\Chrome\chrome.exe`}); err != nil {
t.Fatal(err)
}
called := false
ops := exitBrowserOps{
exists: func(int) bool { return true },
command: func(int) (string, error) { return `C:\Chrome\chrome.exe --user-data-dir=C:\Users\甲\专属`, nil },
cdpReady: func(int) bool { called = true; return true },
graceful: func(int) error { called = true; return nil },
force: func(int) error { called = true; return nil },
waitExit: func(int, time.Duration) bool { called = true; return true },
}
closed, err := closeBrowserOnExitWith(stateFile, `C:\Users\乙\专属`, ops)
if closed || err == nil || called {
t.Fatalf("Profile 不匹配不应关闭:closed=%v err=%v called=%v", closed, err, called)
}
}
func Test退出时CDP不可验证则保留Chrome(t *testing.T) {
profile := `C:\Users\测试\淘宝专属浏览器`
stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json")
if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: `C:\Chrome\chrome.exe`}); err != nil {
t.Fatal(err)
}
called := false
ops := exitBrowserOps{
exists: func(int) bool { return true },
command: func(int) (string, error) {
return `C:\Chrome\chrome.exe --user-data-dir=` + profile + ` --remote-debugging-port=19666`, nil
},
cdpReady: func(int) bool { return false },
graceful: func(int) error { called = true; return nil },
force: func(int) error { called = true; return nil },
waitExit: func(int, time.Duration) bool { called = true; return true },
}
closed, err := closeBrowserOnExitWith(stateFile, profile, ops)
if closed || err == nil || called {
t.Fatalf("CDP 不可验证时不得关闭:closed=%v err=%v called=%v", closed, err, called)
}
}
func Test退出时调试端口不匹配则保留Chrome(t *testing.T) {
profile := `C:\Users\测试\淘宝专属浏览器`
stateFile := filepath.Join(t.TempDir(), "浏览器运行状态.json")
if err := writeBrowserState(stateFile, BrowserState{PID: 1234, Port: 19666, ProfilePath: profile, ChromePath: `C:\Chrome\chrome.exe`}); err != nil {
t.Fatal(err)
}
called := false
ops := exitBrowserOps{
exists: func(int) bool { return true },
command: func(int) (string, error) {
return `C:\Chrome\chrome.exe --user-data-dir=` + profile + ` --remote-debugging-port=19667`, nil
},
cdpReady: func(int) bool { called = true; return true },
graceful: func(int) error { called = true; return nil },
force: func(int) error { called = true; return nil },
waitExit: func(int, time.Duration) bool { called = true; return true },
}
closed, err := closeBrowserOnExitWith(stateFile, profile, ops)
if closed || err == nil || called {
t.Fatalf("调试端口不匹配时不得关闭:closed=%v err=%v called=%v", closed, err, called)
}
}
func Test命令行归属判断(t *testing.T) {
profile := `C:\Users\测试 用户\淘宝浏览器\默认账号`
cases := []struct {
+3 -2
View File
@@ -60,8 +60,9 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: app.startup,
OnShutdown: app.shutdown,
OnStartup: app.startup,
OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown,
// 打开浏览器默认右键菜单,这样在商品主图弹窗上右键才有「图片另存为」。
//
// 代价:整个界面右键都会出现浏览器菜单,其中包含「检查」,