feat: 迁移 Admin Repository 到 MySQL 8 (#79)
This commit is contained in:
@@ -6,19 +6,10 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmautobuy/admin/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAdmin首次初始化登录退出完整流程(t *testing.T) {
|
func TestAdmin首次初始化登录退出完整流程(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db := newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
router, err := newRouter(db)
|
router, err := newRouter(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -118,14 +109,7 @@ func TestAdmin首次初始化登录退出完整流程(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestClientAPI不经过网页登录和CSRF(t *testing.T) {
|
func TestClientAPI不经过网页登录和CSRF(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db := newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
router, err := newRouter(db)
|
router, err := newRouter(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
"cmautobuy/admin/repository"
|
|
||||||
"cmautobuy/admin/service"
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,15 +165,7 @@ func TestClient提交HTTP契约_取消重派与幂等不受网页登录影响(t
|
|||||||
|
|
||||||
func newAPIRouteTestDB(t *testing.T) *sql.DB {
|
func newAPIRouteTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := repository.Open(t.TempDir())
|
return newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertAPIRouteTask(t *testing.T, db *sql.DB, taskID, clientID string, createdAt time.Time) {
|
func insertAPIRouteTask(t *testing.T, db *sql.DB, taskID, clientID string, createdAt time.Time) {
|
||||||
|
|||||||
@@ -14,14 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Test客户端归属页面与管理员写权限(t *testing.T) {
|
func Test客户端归属页面与管理员写权限(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db := newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", now); err != nil {
|
if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Package testutil 为 Admin 测试创建彼此隔离的 MySQL 8 数据库。
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cmautobuy/admin/config"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestingT 是本包需要的 testing.T 最小接口,避免生产包依赖 testing。
|
||||||
|
type TestingT interface {
|
||||||
|
Helper()
|
||||||
|
Cleanup(func())
|
||||||
|
Fatalf(format string, args ...any)
|
||||||
|
Errorf(format string, args ...any)
|
||||||
|
Skipf(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenMySQL 为一个测试创建独立数据库,测试结束后只删除该数据库。
|
||||||
|
func OpenMySQL(t TestingT) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||||
|
t.Skipf("未设置 CMAUTOBUY_MYSQL_TEST=1,跳过真实 MySQL 8 测试")
|
||||||
|
}
|
||||||
|
baseConfig, err := config.LoadDatabaseFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 MySQL 测试配置失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(baseConfig.Name, "_test") {
|
||||||
|
t.Fatalf("MySQL 测试基础库名必须以 _test 结尾,实际 %q", baseConfig.Name)
|
||||||
|
}
|
||||||
|
adminDB, err := repository.OpenMySQL(baseConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接 MySQL 测试基础库失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseName := testDatabaseName(baseConfig.Name)
|
||||||
|
quotedName := quoteIdentifier(databaseName)
|
||||||
|
if _, err := adminDB.Exec("CREATE DATABASE " + quotedName +
|
||||||
|
" CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
|
||||||
|
adminDB.Close()
|
||||||
|
t.Fatalf("创建隔离 MySQL 测试库失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testConfig := baseConfig
|
||||||
|
testConfig.Name = databaseName
|
||||||
|
db, err := repository.OpenMySQL(testConfig)
|
||||||
|
if err != nil {
|
||||||
|
adminDB.Exec("DROP DATABASE " + quotedName)
|
||||||
|
adminDB.Close()
|
||||||
|
t.Fatalf("连接隔离 MySQL 测试库失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := repository.MigrateMySQL(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
adminDB.Exec("DROP DATABASE " + quotedName)
|
||||||
|
adminDB.Close()
|
||||||
|
t.Fatalf("初始化隔离 MySQL 测试库失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Close()
|
||||||
|
if _, err := adminDB.Exec("DROP DATABASE " + quotedName); err != nil {
|
||||||
|
t.Errorf("清理隔离 MySQL 测试库 %s 失败: %v", databaseName, err)
|
||||||
|
}
|
||||||
|
adminDB.Close()
|
||||||
|
})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDatabaseName(base string) string {
|
||||||
|
random := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
panic(fmt.Sprintf("生成 MySQL 测试库随机名失败: %v", err))
|
||||||
|
}
|
||||||
|
if len(base) > 40 {
|
||||||
|
base = base[:40]
|
||||||
|
}
|
||||||
|
return base + "_" + hex.EncodeToString(random)
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteIdentifier(name string) string {
|
||||||
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||||
|
}
|
||||||
+9
-8
@@ -48,18 +48,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("数据目录: %s", dataDir)
|
log.Printf("数据目录: %s", dataDir)
|
||||||
|
|
||||||
// 2. 数据库
|
// 2. 数据库。凭据只从环境变量读取,不写进配置文件或日志。
|
||||||
db, err := repository.Open(dataDir)
|
databaseConfig, err := config.LoadDatabaseFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("打开数据库失败: %v", err)
|
log.Fatalf("读取 MySQL 配置失败: %v", err)
|
||||||
|
}
|
||||||
|
db, err := repository.OpenMySQL(databaseConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("打开 MySQL 数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
if err := repository.MigrateMySQL(db); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("MySQL 数据库迁移失败: %v", err)
|
||||||
}
|
|
||||||
if err := repository.CheckSchema(db); err != nil {
|
|
||||||
log.Fatalf("%v", err)
|
|
||||||
}
|
}
|
||||||
interrupted, err := service.InterruptRunningSybSyncRuns(db, time.Now())
|
interrupted, err := service.InterruptRunningSybSyncRuns(db, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-8
@@ -38,14 +38,7 @@ func TestPaginationCSS_桌面右对齐且窄窗口不遮挡内容(t *testing.T)
|
|||||||
// 六个主页面都走一次真实路由和模板渲染。
|
// 六个主页面都走一次真实路由和模板渲染。
|
||||||
// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。
|
// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。
|
||||||
func TestMainPagesReturnOK(t *testing.T) {
|
func TestMainPagesReturnOK(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db := newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("打开测试数据库失败: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatalf("迁移测试数据库失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
router, err := newRouter(db)
|
router, err := newRouter(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmautobuy/admin/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMySQLTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
return testutil.OpenMySQL(t)
|
||||||
|
}
|
||||||
@@ -14,14 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Test管理员自助修改密码完整流程和权限边界(t *testing.T) {
|
func Test管理员自助修改密码完整流程和权限边界(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db := newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := service.SetupInitialAdmin(db, "admin", "old-password", "old-password", now); err != nil {
|
if err := service.SetupInitialAdmin(db, "admin", "old-password", "old-password", now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
+20
-17
@@ -55,14 +55,14 @@ func UpsertClient(q Execer, c model.Client, explicit bool) error {
|
|||||||
pdd_package, capabilities,
|
pdd_package, capabilities,
|
||||||
last_seen_at, created_at, updated_at)
|
last_seen_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(client_id) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
name = CASE WHEN ? THEN ? ELSE clients.name END,
|
name = CASE WHEN ? THEN ? ELSE clients.name END,
|
||||||
device_address = excluded.device_address,
|
device_address = VALUES(device_address),
|
||||||
platform = excluded.platform,
|
platform = VALUES(platform),
|
||||||
pdd_package = excluded.pdd_package,
|
pdd_package = VALUES(pdd_package),
|
||||||
capabilities = excluded.capabilities,
|
capabilities = VALUES(capabilities),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = VALUES(last_seen_at),
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = VALUES(updated_at)`,
|
||||||
c.ClientID, insertName, c.DeviceAddress, c.Platform,
|
c.ClientID, insertName, c.DeviceAddress, c.Platform,
|
||||||
c.PddPackage, c.Capabilities, now, now, now,
|
c.PddPackage, c.Capabilities, now, now, now,
|
||||||
updateName, name)
|
updateName, name)
|
||||||
@@ -155,8 +155,11 @@ func listClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, of
|
|||||||
query := `SELECT c.client_id, c.name, c.device_address, c.platform, c.pdd_package,
|
query := `SELECT c.client_id, c.name, c.device_address, c.platform, c.pdd_package,
|
||||||
c.capabilities, c.last_seen_at, c.created_at, c.updated_at,
|
c.capabilities, c.last_seen_at, c.created_at, c.updated_at,
|
||||||
a.user_id, u.username` + clientListFrom + where +
|
a.user_id, u.username` + clientListFrom + where +
|
||||||
` ORDER BY c.last_seen_at DESC, c.client_id LIMIT ? OFFSET ?`
|
` ORDER BY c.last_seen_at DESC, c.client_id`
|
||||||
args = append(args, limit, offset)
|
if limit >= 0 {
|
||||||
|
query += ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,16 +219,16 @@ func ListClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, of
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CountOnlineClientsForUser 统计同一筛选和权限范围内的在线客户端。
|
// CountOnlineClientsForUser 统计同一筛选和权限范围内的在线客户端。
|
||||||
// julianday 解析失败会得到 NULL,自然按离线处理,与 model.Client.IsOnline 一致。
|
// last_seen_at 统一以 UTC RFC3339 保存,固定宽度字符串的字典序就是时间顺序。
|
||||||
func CountOnlineClientsForUser(db *sql.DB, keyword, visibleUserID, cutoffISO string) (int, error) {
|
func CountOnlineClientsForUser(db *sql.DB, keyword, visibleUserID, cutoffISO string) (int, error) {
|
||||||
where, args := clientListFilter(keyword, visibleUserID)
|
where, args := clientListFilter(keyword, visibleUserID)
|
||||||
args = append(args, cutoffISO)
|
args = append(args, cutoffISO)
|
||||||
var online int
|
var online int
|
||||||
query := `SELECT COUNT(*)` + clientListFrom + where
|
query := `SELECT COUNT(*)` + clientListFrom + where
|
||||||
if where == "" {
|
if where == "" {
|
||||||
query += ` WHERE julianday(c.last_seen_at) > julianday(?)`
|
query += ` WHERE c.last_seen_at > ?`
|
||||||
} else {
|
} else {
|
||||||
query += ` AND julianday(c.last_seen_at) > julianday(?)`
|
query += ` AND c.last_seen_at > ?`
|
||||||
}
|
}
|
||||||
if err := db.QueryRow(query, args...).Scan(&online); err != nil {
|
if err := db.QueryRow(query, args...).Scan(&online); err != nil {
|
||||||
return 0, fmt.Errorf("统计在线客户端失败: %w", err)
|
return 0, fmt.Errorf("统计在线客户端失败: %w", err)
|
||||||
@@ -262,13 +265,13 @@ func AssignClient(db *sql.DB, assignment model.ClientUserAssignment) (bool, bool
|
|||||||
return false, false, fmt.Errorf("开始绑定客户端事务失败: %w", err)
|
return false, false, fmt.Errorf("开始绑定客户端事务失败: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var exists int
|
var lockedClientID string
|
||||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM clients WHERE client_id = ?`, assignment.ClientID).Scan(&exists); err != nil {
|
if err := tx.QueryRow(`SELECT client_id FROM clients WHERE client_id = ? FOR UPDATE`, assignment.ClientID).Scan(&lockedClientID); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, false, ErrClientNotFound
|
||||||
|
} else if err != nil {
|
||||||
return false, false, fmt.Errorf("检查客户端失败: %w", err)
|
return false, false, fmt.Errorf("检查客户端失败: %w", err)
|
||||||
}
|
}
|
||||||
if exists == 0 {
|
var exists int
|
||||||
return false, false, ErrClientNotFound
|
|
||||||
}
|
|
||||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE user_id = ? AND role = ? AND status = ?`,
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE user_id = ? AND role = ? AND status = ?`,
|
||||||
assignment.UserID, model.RolePurchaser, model.UserActive).Scan(&exists); err != nil {
|
assignment.UserID, model.RolePurchaser, model.UserActive).Scan(&exists); err != nil {
|
||||||
return false, false, fmt.Errorf("检查采购员失败: %w", err)
|
return false, false, fmt.Errorf("检查采购员失败: %w", err)
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrIdempotencyConflict 表示同一个键提交了不同的内容。
|
// ErrIdempotencyConflict 表示同一个键提交了不同的内容。
|
||||||
//
|
//
|
||||||
// 这说明客户端弄错了——同一个键必须对应同一份内容。
|
// 这说明客户端弄错了——同一个键必须对应同一份内容。
|
||||||
// 内容真的变了,应该用新的 attempt_id 生成新的键。
|
// 内容真的变了,应该用新的 attempt_id 生成新的键。
|
||||||
var ErrIdempotencyConflict = errors.New("相同幂等键提交了不同内容")
|
var (
|
||||||
|
ErrIdempotencyConflict = errors.New("相同幂等键提交了不同内容")
|
||||||
|
ErrIdempotencyAlreadySaved = errors.New("幂等键已被并发请求保存")
|
||||||
|
)
|
||||||
|
|
||||||
// HashRequest 计算请求体的哈希,用来判断"同一个键"配的是不是"同一份内容"。
|
// HashRequest 计算请求体的哈希,用来判断"同一个键"配的是不是"同一份内容"。
|
||||||
func HashRequest(body []byte) string {
|
func HashRequest(body []byte) string {
|
||||||
@@ -31,7 +35,7 @@ func HashRequest(body []byte) string {
|
|||||||
func LookupIdempotent(q Execer, key, requestHash string) (string, bool, error) {
|
func LookupIdempotent(q Execer, key, requestHash string) (string, bool, error) {
|
||||||
var storedHash, storedBody string
|
var storedHash, storedBody string
|
||||||
err := q.QueryRow(
|
err := q.QueryRow(
|
||||||
`SELECT request_hash, response_body FROM idempotency_keys WHERE key = ?`,
|
"SELECT request_hash, response_body FROM idempotency_keys WHERE `key` = ?",
|
||||||
key).Scan(&storedHash, &storedBody)
|
key).Scan(&storedHash, &storedBody)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -52,10 +56,14 @@ func LookupIdempotent(q Execer, key, requestHash string) (string, bool, error) {
|
|||||||
// 分开写的话,业务写成功但幂等记录没写上,客户端重试就会被重复处理。
|
// 分开写的话,业务写成功但幂等记录没写上,客户端重试就会被重复处理。
|
||||||
func SaveIdempotent(q Execer, key, requestHash, responseBody string) error {
|
func SaveIdempotent(q Execer, key, requestHash, responseBody string) error {
|
||||||
_, err := q.Exec(
|
_, err := q.Exec(
|
||||||
`INSERT INTO idempotency_keys (key, request_hash, response_body, created_at)
|
"INSERT INTO idempotency_keys (`key`, request_hash, response_body, created_at)\n"+
|
||||||
VALUES (?, ?, ?, ?)`,
|
" VALUES (?, ?, ?, ?)",
|
||||||
key, requestHash, responseBody, model.NowISO())
|
key, requestHash, responseBody, model.NowISO())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var mysqlErr *mysql.MySQLError
|
||||||
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
|
return ErrIdempotencyAlreadySaved
|
||||||
|
}
|
||||||
return fmt.Errorf("保存幂等键失败: %w", err)
|
return fmt.Errorf("保存幂等键失败: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ func UpsertSKUMapping(q Execer, m model.SKUMapping) error {
|
|||||||
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
|
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
|
||||||
goods_id, mapped_at, mapped_by)
|
goods_id, mapped_at, mapped_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(shopee_sku_id, pdd_goods_id) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
pdd_option_key = excluded.pdd_option_key,
|
pdd_option_key = VALUES(pdd_option_key),
|
||||||
pdd_options = excluded.pdd_options,
|
pdd_options = VALUES(pdd_options),
|
||||||
goods_id = excluded.goods_id,
|
goods_id = VALUES(goods_id),
|
||||||
mapped_at = excluded.mapped_at,
|
mapped_at = VALUES(mapped_at),
|
||||||
mapped_by = excluded.mapped_by`,
|
mapped_by = VALUES(mapped_by)`,
|
||||||
m.ShopeeSKUID, m.PddGoodsID, m.PddOptionKey, m.PddOptions,
|
m.ShopeeSKUID, m.PddGoodsID, m.PddOptionKey, m.PddOptions,
|
||||||
m.GoodsID, m.MappedAt, nullableText(m.MappedBy))
|
m.GoodsID, m.MappedAt, nullableText(m.MappedBy))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) {
|
|||||||
Role: model.RolePurchaser, Status: model.UserActive,
|
Role: model.RolePurchaser, Status: model.UserActive,
|
||||||
PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(),
|
PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(),
|
||||||
}
|
}
|
||||||
if err := CreateInitialAdmin(db, user); err != nil {
|
if err := CreateUser(db, user); err != nil {
|
||||||
t.Fatalf("准备同步记录外键用户失败: %v", err)
|
t.Fatalf("准备同步记录外键用户失败: %v", err)
|
||||||
}
|
}
|
||||||
// 状态和 running/finished_at 对应关系必须由数据库兜底,不能只靠 Go 校验。
|
// 状态和 running/finished_at 对应关系必须由数据库兜底,不能只靠 Go 校验。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"cmautobuy/admin/config"
|
"cmautobuy/admin/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 1
|
const mysqlSchemaVersion = 2
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||||
@@ -29,6 +29,8 @@ func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
|||||||
driverConfig.ReadTimeout = 30 * time.Second
|
driverConfig.ReadTimeout = 30 * time.Second
|
||||||
driverConfig.WriteTimeout = 30 * time.Second
|
driverConfig.WriteTimeout = 30 * time.Second
|
||||||
driverConfig.RejectReadOnly = true
|
driverConfig.RejectReadOnly = true
|
||||||
|
// 业务层用 RowsAffected 判断目标行是否存在;重复写入相同值也应算匹配到。
|
||||||
|
driverConfig.ClientFoundRows = true
|
||||||
driverConfig.Params = map[string]string{
|
driverConfig.Params = map[string]string{
|
||||||
"time_zone": "'+00:00'",
|
"time_zone": "'+00:00'",
|
||||||
"sql_mode": "'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'",
|
"sql_mode": "'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'",
|
||||||
@@ -298,6 +300,17 @@ var mysqlSchemaV1 = []string{
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mysqlSchemaV2 增加首位管理员初始化的并发哨兵。MySQL DDL 会隐式提交,
|
||||||
|
// 因此每条语句都必须可重放,并在全部成功后才记录版本。
|
||||||
|
var mysqlSchemaV2 = []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS admin_initialization_lock (
|
||||||
|
id TINYINT PRIMARY KEY,
|
||||||
|
CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
|
`INSERT INTO admin_initialization_lock (id) VALUES (1)
|
||||||
|
ON DUPLICATE KEY UPDATE id = VALUES(id)`,
|
||||||
|
}
|
||||||
|
|
||||||
// MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
|
// MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
|
||||||
func MigrateMySQL(db *sql.DB) error {
|
func MigrateMySQL(db *sql.DB) error {
|
||||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
@@ -319,19 +332,39 @@ func MigrateMySQL(db *sql.DB) error {
|
|||||||
return fmt.Errorf("执行 MySQL schema v1 第 %d 条失败: %w", i+1, err)
|
return fmt.Errorf("执行 MySQL schema v1 第 %d 条失败: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := CheckMySQLSchema(db); err != nil {
|
if err := checkMySQLSchema(db, requiredTables); err != nil {
|
||||||
return fmt.Errorf("MySQL schema v1 自检失败,未记录版本: %w", err)
|
return fmt.Errorf("MySQL schema v1 自检失败,未记录版本: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||||
1, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
1, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
return fmt.Errorf("记录 MySQL schema v1 失败: %w", err)
|
return fmt.Errorf("记录 MySQL schema v1 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
current = 1
|
||||||
|
}
|
||||||
|
if current < 2 {
|
||||||
|
for i, statement := range mysqlSchemaV2 {
|
||||||
|
if _, err := db.Exec(statement); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v2 第 %d 条失败: %w", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckMySQLSchema(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v2 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||||
|
2, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v2 失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CheckMySQLSchema(db)
|
return CheckMySQLSchema(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMySQLSchema 确认所有业务表和关键追加列存在。
|
// CheckMySQLSchema 确认所有业务表和关键追加列存在。
|
||||||
func CheckMySQLSchema(db *sql.DB) error {
|
func CheckMySQLSchema(db *sql.DB) error {
|
||||||
|
mysqlRequiredTables := append(append([]string{}, requiredTables...), "admin_initialization_lock")
|
||||||
|
return checkMySQLSchema(db, mysqlRequiredTables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLSchema(db *sql.DB, tables []string) error {
|
||||||
rows, err := db.Query(`SELECT table_name FROM information_schema.tables
|
rows, err := db.Query(`SELECT table_name FROM information_schema.tables
|
||||||
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'`)
|
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,7 +383,7 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var missing []string
|
var missing []string
|
||||||
for _, table := range requiredTables {
|
for _, table := range tables {
|
||||||
if !existing[table] {
|
if !existing[table] {
|
||||||
missing = append(missing, table)
|
missing = append(missing, table)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -163,15 +163,15 @@ type PddProductRow struct {
|
|||||||
SkuCount int
|
SkuCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规格数交给 SQLite 算,不要把整个 skus_json 读进 Go 再数——
|
// 规格数交给 MySQL 算,不要把整个 skus_json 读进 Go 再数——
|
||||||
// 列表页几十上百行,每行都反序列化一遍纯属浪费。
|
// 列表页几十上百行,每行都反序列化一遍纯属浪费。
|
||||||
//
|
//
|
||||||
// json_valid 那层判断不能省:skus_json 万一存进了坏数据,
|
// JSON_VALID 那层判断不能省:skus_json 万一存进了坏数据,
|
||||||
// json_array_length 会让**整条查询报错**,页面直接打不开。
|
// JSON_LENGTH 会让**整条查询报错**,页面直接打不开。
|
||||||
const pddSkuCountExpr = `
|
const pddSkuCountExpr = `
|
||||||
CASE WHEN skus_json IS NULL OR skus_json = '' OR NOT json_valid(skus_json)
|
CASE WHEN skus_json IS NULL OR skus_json = '' OR NOT JSON_VALID(skus_json)
|
||||||
THEN -1
|
THEN -1
|
||||||
ELSE COALESCE(json_array_length(skus_json, '$.skus'), -1)
|
ELSE COALESCE(JSON_LENGTH(skus_json, '$.skus'), -1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
// pddListFilter 统一生成 PDD 列表和总数查询的筛选条件。
|
// pddListFilter 统一生成 PDD 列表和总数查询的筛选条件。
|
||||||
@@ -181,7 +181,7 @@ func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
|
|||||||
args := make([]any, 0, 3)
|
args := make([]any, 0, 3)
|
||||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
pattern := "%" + escapeLike(keyword) + "%"
|
pattern := "%" + escapeLike(keyword) + "%"
|
||||||
where += ` AND (goods_id LIKE ? ESCAPE '\' OR url LIKE ? ESCAPE '\')`
|
where += ` AND (goods_id LIKE ? ESCAPE '!' OR url LIKE ? ESCAPE '!')`
|
||||||
args = append(args, pattern, pattern)
|
args = append(args, pattern, pattern)
|
||||||
}
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
@@ -230,12 +230,11 @@ func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit
|
|||||||
return list, total, nil
|
return list, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapeLike 把 LIKE 的三个特殊字符转义掉,配合 ESCAPE '\' 使用。
|
// escapeLike 把 LIKE 的三个特殊字符转义掉,配合 ESCAPE '!' 使用。
|
||||||
// 反斜杠必须第一个换,否则会把后面刚加的反斜杠又转义一遍。
|
|
||||||
func escapeLike(s string) string {
|
func escapeLike(s string) string {
|
||||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
s = strings.ReplaceAll(s, `!`, `!!`)
|
||||||
s = strings.ReplaceAll(s, `%`, `\%`)
|
s = strings.ReplaceAll(s, `%`, `!%`)
|
||||||
s = strings.ReplaceAll(s, `_`, `\_`)
|
s = strings.ReplaceAll(s, `_`, `!_`)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -24,11 +24,11 @@ func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode str
|
|||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
|
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(goods_id) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
title = excluded.title,
|
title = VALUES(title),
|
||||||
shopee_status = excluded.shopee_status,
|
shopee_status = VALUES(shopee_status),
|
||||||
main_sku_code = excluded.main_sku_code,
|
main_sku_code = VALUES(main_sku_code),
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = VALUES(updated_at)`,
|
||||||
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
||||||
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
||||||
// 已存在时它们完全不受这条语句影响。
|
// 已存在时它们完全不受这条语句影响。
|
||||||
@@ -61,15 +61,15 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
|
|||||||
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||||||
is_manual, created_at, updated_at)
|
is_manual, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
ON CONFLICT(sku_id) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
goods_id = excluded.goods_id,
|
goods_id = VALUES(goods_id),
|
||||||
spec_raw = excluded.spec_raw,
|
spec_raw = VALUES(spec_raw),
|
||||||
color = excluded.color,
|
color = VALUES(color),
|
||||||
size = excluded.size,
|
size = VALUES(size),
|
||||||
advice = excluded.advice,
|
advice = VALUES(advice),
|
||||||
parse_ok = excluded.parse_ok,
|
parse_ok = VALUES(parse_ok),
|
||||||
sku_code = excluded.sku_code,
|
sku_code = VALUES(sku_code),
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = VALUES(updated_at)`,
|
||||||
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。
|
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。
|
||||||
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now)
|
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,7 +132,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
|||||||
|
|
||||||
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
||||||
like := "%" + escapeLike(kw) + "%"
|
like := "%" + escapeLike(kw) + "%"
|
||||||
clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\')`)
|
clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '!' OR sp.title LIKE ? ESCAPE '!')`)
|
||||||
args = append(args, like, like)
|
args = append(args, like, like)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-20
@@ -29,10 +29,10 @@ func SaveSybSession(q Execer, username, cookiesJSON, expiresAt string) error {
|
|||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
|
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(username) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
cookies = excluded.cookies,
|
cookies = VALUES(cookies),
|
||||||
expires_at = excluded.expires_at,
|
expires_at = VALUES(expires_at),
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = VALUES(updated_at)`,
|
||||||
username, cookiesJSON, expiresAt, now)
|
username, cookiesJSON, expiresAt, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("保存顺运宝会话缓存失败: %w", err)
|
return fmt.Errorf("保存顺运宝会话缓存失败: %w", err)
|
||||||
@@ -103,9 +103,9 @@ func SetSybLastSyncedAt(q Execer, at string) error {
|
|||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
||||||
VALUES (1, ?, ?)
|
VALUES (1, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
last_synced_at = excluded.last_synced_at,
|
last_synced_at = VALUES(last_synced_at),
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = VALUES(updated_at)`,
|
||||||
at, now)
|
at, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("更新顺运宝同步进度失败: %w", err)
|
return fmt.Errorf("更新顺运宝同步进度失败: %w", err)
|
||||||
@@ -254,16 +254,16 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
|||||||
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
|
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
|
||||||
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(syb_id) DO UPDATE SET
|
ON DUPLICATE KEY UPDATE
|
||||||
order_no = excluded.order_no,
|
order_no = VALUES(order_no),
|
||||||
title = excluded.title,
|
title = VALUES(title),
|
||||||
product_spec = excluded.product_spec,
|
product_spec = VALUES(product_spec),
|
||||||
shopee_goods_id = excluded.shopee_goods_id,
|
shopee_goods_id = VALUES(shopee_goods_id),
|
||||||
quantity = excluded.quantity,
|
quantity = VALUES(quantity),
|
||||||
price_twd_cent = excluded.price_twd_cent,
|
price_twd_cent = VALUES(price_twd_cent),
|
||||||
image_url = excluded.image_url,
|
image_url = VALUES(image_url),
|
||||||
syb_data = excluded.syb_data,
|
syb_data = VALUES(syb_data),
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = VALUES(updated_at)`,
|
||||||
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
|
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
|
||||||
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
|
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
|
||||||
// 这一列不受本语句影响,见上面的函数注释。
|
// 这一列不受本语句影响,见上面的函数注释。
|
||||||
@@ -297,7 +297,7 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
|||||||
var args []any
|
var args []any
|
||||||
if kw := filter.Keyword; kw != "" {
|
if kw := filter.Keyword; kw != "" {
|
||||||
like := "%" + escapeLike(kw) + "%"
|
like := "%" + escapeLike(kw) + "%"
|
||||||
clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '\' OR so.title LIKE ? ESCAPE '\')`)
|
clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '!' OR so.title LIKE ? ESCAPE '!')`)
|
||||||
args = append(args, like, like)
|
args = append(args, like, like)
|
||||||
}
|
}
|
||||||
switch filter.Stage {
|
switch filter.Stage {
|
||||||
@@ -386,8 +386,11 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
|
|||||||
AND t.syb_id = so.syb_id
|
AND t.syb_id = so.syb_id
|
||||||
AND t.status IN ('pending', 'assigned', 'claimed'))` +
|
AND t.status IN ('pending', 'assigned', 'claimed'))` +
|
||||||
sybOrderContextFrom + where + `
|
sybOrderContextFrom + where + `
|
||||||
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
|
ORDER BY so.updated_at DESC, so.syb_id DESC`
|
||||||
args = append(args, limit, offset)
|
if limit >= 0 {
|
||||||
|
query += ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
}
|
||||||
rows, err := q.Query(query, args...)
|
rows, err := q.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询顺运宝采购处理列表失败: %w", err)
|
return nil, fmt.Errorf("查询顺运宝采购处理列表失败: %w", err)
|
||||||
|
|||||||
@@ -1,22 +1,63 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"cmautobuy/admin/config"
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSybTestDB(t *testing.T) *sql.DB {
|
func newSybTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := Open(t.TempDir())
|
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||||
|
t.Skip("未启用真实 MySQL 8 集成测试")
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadDatabaseFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("打开测试库失败: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
if !strings.HasSuffix(cfg.Name, "_test") {
|
||||||
if err := Migrate(db); err != nil {
|
t.Fatalf("MySQL 测试基础库名必须以 _test 结尾")
|
||||||
t.Fatalf("迁移失败: %v", err)
|
|
||||||
}
|
}
|
||||||
|
adminDB, err := OpenMySQL(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
random := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
databaseName := cfg.Name + "_" + hex.EncodeToString(random)
|
||||||
|
quotedName := "`" + strings.ReplaceAll(databaseName, "`", "``") + "`"
|
||||||
|
if _, err := adminDB.Exec("CREATE DATABASE " + quotedName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
|
||||||
|
adminDB.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg.Name = databaseName
|
||||||
|
db, err := OpenMySQL(cfg)
|
||||||
|
if err != nil {
|
||||||
|
adminDB.Exec("DROP DATABASE " + quotedName)
|
||||||
|
adminDB.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
adminDB.Exec("DROP DATABASE " + quotedName)
|
||||||
|
adminDB.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Close()
|
||||||
|
if _, err := adminDB.Exec("DROP DATABASE " + quotedName); err != nil {
|
||||||
|
t.Errorf("清理隔离 MySQL 测试库失败: %v", err)
|
||||||
|
}
|
||||||
|
adminDB.Close()
|
||||||
|
})
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-49
@@ -1,17 +1,15 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// claimCandidateLimit 是一次最多尝试抢多少条。
|
|
||||||
// 抢不到说明被别的客户端拿走了,再试下一条;都抢不到就当作没任务。
|
|
||||||
const claimCandidateLimit = 10
|
|
||||||
|
|
||||||
// ClaimNextTask 为指定客户端领取一个任务。
|
// ClaimNextTask 为指定客户端领取一个任务。
|
||||||
//
|
//
|
||||||
// 没有可领的任务时返回 (nil, nil) —— 调用方据此返回 204。
|
// 没有可领的任务时返回 (nil, nil) —— 调用方据此返回 204。
|
||||||
@@ -30,13 +28,18 @@ const claimCandidateLimit = 10
|
|||||||
//
|
//
|
||||||
// # 防并发
|
// # 防并发
|
||||||
//
|
//
|
||||||
// 做法是**条件更新 + 检查影响行数**:先查出候选,再带着状态条件去更新,
|
// InnoDB 事务用 FOR UPDATE SKIP LOCKED 锁住一条候选任务。领取状态和
|
||||||
// 影响行数为 0 就说明被别人抢先了,换下一条。
|
// task_claims 历史在同一个事务提交,避免只改了状态却没留下领取凭据。
|
||||||
// 不用 SELECT ... FOR UPDATE,SQLite 没有那个。
|
|
||||||
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
|
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return nil, fmt.Errorf("client_id 不能为空")
|
return nil, fmt.Errorf("client_id 不能为空")
|
||||||
}
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("开始领取任务事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
query := `SELECT task_id FROM tasks
|
query := `SELECT task_id FROM tasks
|
||||||
WHERE ( (assigned_client = ? AND status = 'assigned')
|
WHERE ( (assigned_client = ? AND status = 'assigned')
|
||||||
@@ -52,57 +55,45 @@ func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// (assigned_client IS NULL) 为 0/1,0 排前面 —— 指定给本机的优先于无主的
|
// (assigned_client IS NULL) 为 0/1,0 排前面 —— 指定给本机的优先于无主的
|
||||||
query += ` ORDER BY (assigned_client IS NULL), priority DESC, created_at LIMIT ?`
|
query += ` ORDER BY (assigned_client IS NULL), priority DESC, created_at
|
||||||
args = append(args, claimCandidateLimit)
|
LIMIT 1 FOR UPDATE SKIP LOCKED`
|
||||||
|
var taskID string
|
||||||
rows, err := db.Query(query, args...)
|
if err := tx.QueryRowContext(ctx, query, args...).Scan(&taskID); errors.Is(err, sql.ErrNoRows) {
|
||||||
if err != nil {
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
return nil, fmt.Errorf("查询可领任务失败: %w", err)
|
return nil, fmt.Errorf("查询可领任务失败: %w", err)
|
||||||
}
|
}
|
||||||
var candidates []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return nil, fmt.Errorf("读取候选任务失败: %w", err)
|
|
||||||
}
|
|
||||||
candidates = append(candidates, id)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
for _, taskID := range candidates {
|
// 两种情况合成一条语句:对"指定给我的"那种,写 assigned_client
|
||||||
// 两种情况合成一条语句:对"指定给我的"那种,写 assigned_client
|
// 是写同一个值,无副作用;对无主的,这一步就是"谁领到就标记谁"。
|
||||||
// 是写同一个值,无副作用;对无主的,这一步就是"谁领到就标记谁"。
|
res, err := tx.ExecContext(ctx, `
|
||||||
res, err := db.Exec(`
|
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET status = 'claimed', assigned_client = ?,
|
SET status = 'claimed', assigned_client = ?,
|
||||||
claimed_at = ?, updated_at = ?
|
claimed_at = ?, updated_at = ?
|
||||||
WHERE task_id = ?
|
WHERE task_id = ?
|
||||||
AND ( (status = 'assigned' AND assigned_client = ?)
|
AND ( (status = 'assigned' AND assigned_client = ?)
|
||||||
OR (status = 'pending' AND assigned_client IS NULL) )`,
|
OR (status = 'pending' AND assigned_client IS NULL) )`,
|
||||||
clientID, now, now, taskID, clientID)
|
clientID, now, now, taskID, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("领取任务 %s 失败: %w", taskID, err)
|
return nil, fmt.Errorf("领取任务 %s 失败: %w", taskID, err)
|
||||||
}
|
|
||||||
n, err := res.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
continue // 被别的客户端抢先了,换下一条
|
|
||||||
}
|
|
||||||
// 记一笔领取历史。提交结果时要靠它判断这台客户端有没有领过——
|
|
||||||
// 任务重派后 assigned_client 会变,只看它就查不出来了。
|
|
||||||
if err := RecordClaim(db, taskID, clientID, now); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return GetTask(db, taskID)
|
|
||||||
}
|
}
|
||||||
return nil, nil // 没有可领的任务
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("确认领取任务 %s 结果失败: %w", taskID, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return nil, fmt.Errorf("领取任务 %s 时状态异常", taskID)
|
||||||
|
}
|
||||||
|
// 记一笔领取历史。提交结果时要靠它判断这台客户端有没有领过——
|
||||||
|
// 任务重派后 assigned_client 会变,只看它就查不出来了。
|
||||||
|
if err := RecordClaim(tx, taskID, clientID, now); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("提交领取任务事务失败: %w", err)
|
||||||
|
}
|
||||||
|
return GetTask(db, taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTask 按编号读一条任务。
|
// GetTask 按编号读一条任务。
|
||||||
@@ -159,7 +150,7 @@ func RecordClaim(q Execer, taskID, clientID, claimedAt string) error {
|
|||||||
_, err := q.Exec(`
|
_, err := q.Exec(`
|
||||||
INSERT INTO task_claims (task_id, client_id, claimed_at)
|
INSERT INTO task_claims (task_id, client_id, claimed_at)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(task_id, client_id) DO UPDATE SET claimed_at = excluded.claimed_at`,
|
ON DUPLICATE KEY UPDATE claimed_at = VALUES(claimed_at)`,
|
||||||
taskID, clientID, claimedAt)
|
taskID, clientID, claimedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("记录领取历史失败 task=%s client=%s: %w", taskID, clientID, err)
|
return fmt.Errorf("记录领取历史失败 task=%s client=%s: %w", taskID, clientID, err)
|
||||||
@@ -312,7 +303,7 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
|
|||||||
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
||||||
pattern := "%" + escapeLike(kw) + "%"
|
pattern := "%" + escapeLike(kw) + "%"
|
||||||
clauses = append(clauses,
|
clauses = append(clauses,
|
||||||
"(t.task_id LIKE ? ESCAPE '\\' OR t.order_no LIKE ? ESCAPE '\\' OR t.pdd_goods_id LIKE ? ESCAPE '\\')")
|
"(t.task_id LIKE ? ESCAPE '!' OR t.order_no LIKE ? ESCAPE '!' OR t.pdd_goods_id LIKE ? ESCAPE '!')")
|
||||||
args = append(args, pattern, pattern, pattern)
|
args = append(args, pattern, pattern, pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -63,14 +63,18 @@ func ChangePasswordAndRevokeSessions(db *sql.DB, userID, expectedHash, passwordH
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInitialAdmin 在写事务里再次确认 users 为空后创建首位管理员。
|
// CreateInitialAdmin 锁住唯一的初始化哨兵行,再确认 users 为空并创建首位管理员。
|
||||||
// Open 的 _txlock=immediate 让并发事务从一开始就串行,最多一个能成功。
|
// 并发请求会在 FOR UPDATE 处串行,最多一个能成功。
|
||||||
func CreateInitialAdmin(db *sql.DB, user model.User) error {
|
func CreateInitialAdmin(db *sql.DB, user model.User) error {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("开始初始化管理员事务失败: %w", err)
|
return fmt.Errorf("开始初始化管理员事务失败: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
var lockID int
|
||||||
|
if err := tx.QueryRow(`SELECT id FROM admin_initialization_lock WHERE id = 1 FOR UPDATE`).Scan(&lockID); err != nil {
|
||||||
|
return fmt.Errorf("锁定管理员初始化状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
count, err := CountUsers(tx)
|
count, err := CountUsers(tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,7 +193,7 @@ func DeleteExpiredSessions(q Execer, nowISO string) error {
|
|||||||
// ListUsers 按用户名搜索并分页。列表和 COUNT 共用同一段筛选,避免页码漂移。
|
// ListUsers 按用户名搜索并分页。列表和 COUNT 共用同一段筛选,避免页码漂移。
|
||||||
func ListUsers(q Execer, keyword string, status model.UserStatus, limit, offset int) ([]model.User, int, error) {
|
func ListUsers(q Execer, keyword string, status model.UserStatus, limit, offset int) ([]model.User, int, error) {
|
||||||
pattern := "%" + escapeLike(keyword) + "%"
|
pattern := "%" + escapeLike(keyword) + "%"
|
||||||
where := `WHERE username LIKE ? ESCAPE '\'`
|
where := `WHERE username LIKE ? ESCAPE '!'`
|
||||||
args := []any{pattern}
|
args := []any{pattern}
|
||||||
if status == model.UserActive || status == model.UserDisabled {
|
if status == model.UserActive || status == model.UserDisabled {
|
||||||
where += ` AND status = ?`
|
where += ` AND status = ?`
|
||||||
@@ -239,7 +243,8 @@ func CreateUser(q Execer, user model.User) error {
|
|||||||
user.UserID, user.Username, user.PasswordHash, user.Role, user.Status,
|
user.UserID, user.Username, user.PasswordHash, user.Role, user.Status,
|
||||||
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt)
|
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: users.username") {
|
var mysqlErr *mysql.MySQLError
|
||||||
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
return ErrUsernameExists
|
return ErrUsernameExists
|
||||||
}
|
}
|
||||||
return fmt.Errorf("创建 Admin 用户失败: %w", err)
|
return fmt.Errorf("创建 Admin 用户失败: %w", err)
|
||||||
@@ -258,15 +263,27 @@ func SetUserStatusAndRevokeSessions(db *sql.DB, userID string, status model.User
|
|||||||
|
|
||||||
var role model.UserRole
|
var role model.UserRole
|
||||||
var current model.UserStatus
|
var current model.UserStatus
|
||||||
if err := tx.QueryRow(`SELECT role, status FROM users WHERE user_id = ?`, userID).Scan(&role, ¤t); errors.Is(err, sql.ErrNoRows) {
|
if err := tx.QueryRow(`SELECT role, status FROM users WHERE user_id = ? FOR UPDATE`, userID).Scan(&role, ¤t); errors.Is(err, sql.ErrNoRows) {
|
||||||
return ErrUserNotFound
|
return ErrUserNotFound
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("读取待修改用户失败: %w", err)
|
return fmt.Errorf("读取待修改用户失败: %w", err)
|
||||||
}
|
}
|
||||||
if status == model.UserDisabled && current == model.UserActive && role == model.RoleAdmin {
|
if status == model.UserDisabled && current == model.UserActive && role == model.RoleAdmin {
|
||||||
var activeAdmins int
|
rows, err := tx.Query(`SELECT user_id FROM users WHERE role = 'admin' AND status = 'active' FOR UPDATE`)
|
||||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&activeAdmins); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("统计有效管理员失败: %w", err)
|
return fmt.Errorf("锁定有效管理员失败: %w", err)
|
||||||
|
}
|
||||||
|
activeAdmins := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var activeID string
|
||||||
|
if err := rows.Scan(&activeID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("读取有效管理员失败: %w", err)
|
||||||
|
}
|
||||||
|
activeAdmins++
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return fmt.Errorf("读取有效管理员失败: %w", err)
|
||||||
}
|
}
|
||||||
if activeAdmins <= 1 {
|
if activeAdmins <= 1 {
|
||||||
return ErrLastActiveAdmin
|
return ErrLastActiveAdmin
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/internal/testutil"
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
)
|
)
|
||||||
@@ -53,16 +54,7 @@ func TestListClientPageForUser_分页统计不泄露其他采购员客户端(t *
|
|||||||
// 用真实的 migrations,这样表结构变了测试会跟着失败。
|
// 用真实的 migrations,这样表结构变了测试会跟着失败。
|
||||||
func newTestDB(t *testing.T) *sql.DB {
|
func newTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
return testutil.OpenMySQL(t)
|
||||||
db, err := repository.Open(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("打开测试库失败: %v", err)
|
|
||||||
}
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatalf("迁移失败: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
return db
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// insertTask 插一条待领取的任务,供领取相关的测试使用。
|
// insertTask 插一条待领取的任务,供领取相关的测试使用。
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ func TestListPddProducts_规格数(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// skus_json 存进了坏数据时,列表页必须还能打开。
|
// skus_json 存进了坏数据时,列表页必须还能打开。
|
||||||
// json_array_length 碰到非法 JSON 会让整条查询报错,那样页面直接白屏。
|
// JSON_LENGTH 碰到非法 JSON 会让整条查询报错,那样页面直接白屏。
|
||||||
func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T) {
|
func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
createProduct(t, db, "100000000001")
|
createProduct(t, db, "100000000001")
|
||||||
@@ -843,7 +843,7 @@ func insertStaleCollectingProduct(t *testing.T, db *sql.DB, goodsID string) {
|
|||||||
INSERT INTO tasks (task_id, task_type, status, assigned_client,
|
INSERT INTO tasks (task_id, task_type, status, assigned_client,
|
||||||
pdd_goods_url, pdd_goods_id, created_at, updated_at)
|
pdd_goods_url, pdd_goods_id, created_at, updated_at)
|
||||||
VALUES (?, 'collect', 'claimed', 'client-已离线',
|
VALUES (?, 'collect', 'claimed', 'client-已离线',
|
||||||
'https://mobile.yangkeduo.com/goods.html?goods_id=' || ?, ?, ?, ?)`,
|
CONCAT('https://mobile.yangkeduo.com/goods.html?goods_id=', ?), ?, ?, ?)`,
|
||||||
"COL-STALE-"+goodsID, goodsID, goodsID, staleAt, staleAt); err != nil {
|
"COL-STALE-"+goodsID, goodsID, goodsID, staleAt, staleAt); err != nil {
|
||||||
t.Fatalf("构造卡死任务失败: %v", err)
|
t.Fatalf("构造卡死任务失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func insertShopeeSKU(t *testing.T, db *sql.DB, goodsID, skuID string) {
|
|||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
|
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
|
||||||
VALUES (?, '测试商品', ?, ?)
|
VALUES (?, '测试商品', ?, ?)
|
||||||
ON CONFLICT(goods_id) DO NOTHING`, goodsID, now, now); err != nil {
|
ON DUPLICATE KEY UPDATE goods_id = VALUES(goods_id)`, goodsID, now, now); err != nil {
|
||||||
t.Fatalf("插入蝦皮商品失败: %v", err)
|
t.Fatalf("插入蝦皮商品失败: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
|
|||||||
@@ -57,11 +57,17 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
|
|||||||
if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil {
|
if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
detail, _ = GetSybProcessingDetail(db, "SYB-1")
|
detail, err = GetSybProcessingDetail(db, "SYB-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if !detail.MappingValid {
|
if !detail.MappingValid {
|
||||||
t.Fatal("保存后相同蝦皮 SKU + 当前 PDD 商品应自动复用")
|
t.Fatal("保存后相同蝦皮 SKU + 当前 PDD 商品应自动复用")
|
||||||
}
|
}
|
||||||
list, _ := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
|
list, err := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" {
|
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" {
|
||||||
t.Fatalf("可采购阶段不对: %+v", list)
|
t.Fatalf("可采购阶段不对: %+v", list)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,22 @@ func submitInTx(
|
|||||||
// 5. 记下幂等键。和业务写入在同一个事务里——
|
// 5. 记下幂等键。和业务写入在同一个事务里——
|
||||||
// 分开写的话,业务写成功但幂等没记上,客户端重试会被重复处理。
|
// 分开写的话,业务写成功但幂等没记上,客户端重试会被重复处理。
|
||||||
if err := repository.SaveIdempotent(tx, idemKey, hash, string(respJSON)); err != nil {
|
if err := repository.SaveIdempotent(tx, idemKey, hash, string(respJSON)); err != nil {
|
||||||
|
// 两个首次请求可能同时通过前面的“不存在”检查。唯一键会让其中一个
|
||||||
|
// 等待另一个提交后返回重复键;此时回滚本事务的业务写入,再读取赢家
|
||||||
|
// 保存的响应,保证并发重试拿到完全相同的结果。
|
||||||
|
if errors.Is(err, repository.ErrIdempotencyAlreadySaved) {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||||
|
return "", fmt.Errorf("回滚重复幂等请求失败: %w", rollbackErr)
|
||||||
|
}
|
||||||
|
body, done, lookupErr := repository.LookupIdempotent(db, idemKey, hash)
|
||||||
|
if lookupErr != nil {
|
||||||
|
return "", lookupErr
|
||||||
|
}
|
||||||
|
if !done {
|
||||||
|
return "", fmt.Errorf("并发幂等请求已提交但未找到保存结果")
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -359,15 +359,7 @@ func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string,
|
|||||||
|
|
||||||
func newSyncTestDB(t *testing.T) *sql.DB {
|
func newSyncTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := repository.Open(t.TempDir())
|
return newTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("打开测试库失败: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatalf("迁移失败: %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
|
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
|
||||||
|
|||||||
@@ -13,14 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Test用户管理管理员与采购员权限边界(t *testing.T) {
|
func Test用户管理管理员与采购员权限边界(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db := newMySQLTestDB(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
if err := repository.Migrate(db); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", now); err != nil {
|
if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
| 服务端渲染 | 页面的 HTML 在服务器上拼好再发给浏览器 | 与之相对的是前端框架在浏览器里拼,本项目**不用** |
|
| 服务端渲染 | 页面的 HTML 在服务器上拼好再发给浏览器 | 与之相对的是前端框架在浏览器里拼,本项目**不用** |
|
||||||
| htmx | 一个单文件 JS 库,让 HTML 标签直接发请求换局部内容 | 需要局部刷新时可用,**没有构建步骤** |
|
| htmx | 一个单文件 JS 库,让 HTML 标签直接发请求换局部内容 | 需要局部刷新时可用,**没有构建步骤** |
|
||||||
| upsert | "有就更新、没有就新增",一次操作搞定 | Excel 导入的唯一正确做法,见 §3 |
|
| upsert | "有就更新、没有就新增",一次操作搞定 | Excel 导入的唯一正确做法,见 §3 |
|
||||||
| cgo | Go 调用 C 代码的机制。**用了就需要装 C 编译器** | 本项目**避开它**,所以 SQLite 驱动选纯 Go 的 |
|
| cgo | Go 调用 C 代码的机制。**用了就需要装 C 编译器** | 本项目**避开它**;MySQL 和历史 SQLite 迁移驱动都是纯 Go |
|
||||||
| `modernc.org/sqlite` | 纯 Go 实现的 SQLite,不需要 cgo | 固定用它,`go build` 直接出 exe |
|
| `modernc.org/sqlite` | 纯 Go 实现的 SQLite,不需要 cgo | 只用于读取历史 `admin.db` 和迁移回归,不进入生产运行时 |
|
||||||
| excelize | Go 读写 Excel 的库 | 固定用它读蝦皮报表 |
|
| excelize | Go 读写 Excel 的库 | 固定用它读蝦皮报表 |
|
||||||
| CSRF | 攻击者诱导你在已登录状态下发出非本意的请求 | 所有写操作都要防,见 [06](06-quality-security.md) §4 |
|
| CSRF | 攻击者诱导你在已登录状态下发出非本意的请求 | 所有写操作都要防,见 [06](06-quality-security.md) §4 |
|
||||||
| 参数化查询 | SQL 里用 `?` 占位、值单独传,而不是拼字符串 | 防 SQL 注入的唯一正确做法 |
|
| 参数化查询 | SQL 里用 `?` 占位、值单独传,而不是拼字符串 | 防 SQL 注入的唯一正确做法 |
|
||||||
|
|||||||
@@ -271,9 +271,9 @@ M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回
|
|||||||
前者是还没采,后者是采集出了问题(商品下架、页面改版、解析器没认出来),
|
前者是还没采,后者是采集出了问题(商品下架、页面改版、解析器没认出来),
|
||||||
都显示成 `—` 就看不出区别了。
|
都显示成 `—` 就看不出区别了。
|
||||||
|
|
||||||
`[建议]` 规格数用 SQLite 的 `json_array_length(skus_json, '$.skus')` 直接算,
|
`[建议]` 规格数用 MySQL 的 `JSON_LENGTH(skus_json, '$.skus')` 直接算,
|
||||||
不要把整个 JSON 读进 Go 再数。`[必须]` 外面要包一层 `json_valid`——
|
不要把整个 JSON 读进 Go 再数。`[必须]` 外面要包一层 `JSON_VALID`——
|
||||||
`skus_json` 万一存进了坏数据,`json_array_length` 会让**整条查询报错**,页面直接打不开。
|
`skus_json` 万一存进了坏数据,`JSON_LENGTH` 会让**整条查询报错**,页面直接打不开。
|
||||||
|
|
||||||
`[必须]` 空状态分两种文案:从没创建过 → 引导去点「创建」;筛选无结果 → 给"查看全部"的入口。
|
`[必须]` 空状态分两种文案:从没创建过 → 引导去点「创建」;筛选无结果 → 给"查看全部"的入口。
|
||||||
|
|
||||||
|
|||||||
@@ -412,9 +412,9 @@ Go 侧不用跟着调。
|
|||||||
|
|
||||||
这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:
|
这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:
|
||||||
|
|
||||||
`[必须]` **会话缓存存 SQLite,不引入 Redis。** Admin 的定位是「双击 exe 就能跑」,
|
`[必须]` **会话缓存存 Admin 的 MySQL,不引入 Redis。** 示例脚本用 Redis 是因为
|
||||||
`data/` 在 exe 旁边。示例脚本用 Redis 是因为它是反复启动的一次性脚本,
|
它是反复启动的一次性脚本,进程间要传会话;Admin 是常驻进程,没有这个需求,
|
||||||
进程间要传会话;Admin 是常驻进程,没有这个需求,持久化只为重启后免登录。
|
持久化只为重启后免登录,继续复用现有数据库即可。
|
||||||
|
|
||||||
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在工单 #47 里被推翻了,
|
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在工单 #47 里被推翻了,
|
||||||
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
||||||
|
|||||||
Reference in New Issue
Block a user