2026-08-09 11:49:13 +08:00
package repository
import (
2026-08-10 02:21:31 +08:00
"crypto/rand"
2026-08-09 11:49:13 +08:00
"database/sql"
2026-08-10 02:21:31 +08:00
"encoding/hex"
"os"
"strings"
2026-08-09 11:49:13 +08:00
"testing"
2026-08-10 02:21:31 +08:00
"cmautobuy/admin/config"
2026-08-09 11:49:13 +08:00
"cmautobuy/admin/model"
2026-08-13 10:39:21 +08:00
"cmautobuy/admin/spec"
2026-08-09 11:49:13 +08:00
)
func newSybTestDB ( t * testing . T ) * sql . DB {
t . Helper ()
2026-08-10 02:21:31 +08:00
if os . Getenv ( "CMAUTOBUY_MYSQL_TEST" ) != "1" {
t . Skip ( "未启用真实 MySQL 8 集成测试" )
}
cfg , err := config . LoadDatabaseFromEnv ()
2026-08-09 11:49:13 +08:00
if err != nil {
2026-08-10 02:21:31 +08:00
t . Fatal ( err )
2026-08-09 11:49:13 +08:00
}
2026-08-10 02:21:31 +08:00
if ! strings . HasSuffix ( cfg . Name , "_test" ) {
t . Fatalf ( "MySQL 测试基础库名必须以 _test 结尾" )
2026-08-09 11:49:13 +08:00
}
2026-08-10 02:21:31 +08:00
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 ()
})
2026-08-09 11:49:13 +08:00
return db
}
func TestUpsertSybOrder_已有的ShopeeSKUID同步后仍在 ( t * testing . T ) {
// 这是工单 #46 最要紧的一条:shopee_sku_id 是人工规格匹配的结果,
// 顺运宝同步绝不能把它覆盖成空值——覆盖了不会报错,等到建采购任务
// 才会发现匹配成果被洗掉,那时已经找不回来了。
db := newSybTestDB ( t )
first := model . SybOrder {
SybID : "SYB-1" , OrderNo : "ORDER-1" , Title : "初始标题" ,
ProductSpec : "黑色,M" , ShopeeGoodsID : "50209124255" ,
Quantity : 1 , PriceTwdCent : 23900 , ImageURL : "https://x/img.jpg" ,
SybData : `{"stock":{}}` ,
}
created , err := UpsertSybOrder ( db , first )
if err != nil {
t . Fatalf ( "首次写入失败: %v" , err )
}
if ! created {
t . Fatal ( "首次写入应该是新建" )
}
// 模拟操作员在界面上完成了规格匹配,手工把 shopee_sku_id 填上。
if _ , err := db . Exec ( `UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?` ,
"TEST-SKU-123" , "SYB-1" ); err != nil {
t . Fatalf ( "模拟人工匹配失败: %v" , err )
}
// 再次同步:标题、数量、价格都变了(模拟顺运宝那边数据更新),
// 但 upsert 调用方不会传 shopee_sku_id 的新值(顺运宝根本没有这个字段)。
second := model . SybOrder {
SybID : "SYB-1" , OrderNo : "ORDER-1" , Title : "更新后的标题" ,
ProductSpec : "黑色,M" , ShopeeGoodsID : "50209124255" ,
Quantity : 3 , PriceTwdCent : 25900 , ImageURL : "https://x/img2.jpg" ,
SybData : `{"stock":{"updated":true}}` ,
// 注意:这里刻意不设置 ShopeeSKUID(零值,即空字符串),
// 模拟"同步流程从来不知道匹配结果,只管顺运宝返回的字段"。
}
created2 , err := UpsertSybOrder ( db , second )
if err != nil {
t . Fatalf ( "二次写入失败: %v" , err )
}
if created2 {
t . Fatal ( "二次写入应该是更新,不是新建" )
}
var skuID , title string
var qty int
if err := db . QueryRow ( `SELECT shopee_sku_id, title, quantity FROM syb_orders WHERE syb_id = ?` ,
"SYB-1" ). Scan ( & skuID , & title , & qty ); err != nil {
t . Fatalf ( "查询失败: %v" , err )
}
if skuID != "TEST-SKU-123" {
t . Fatalf ( "shopee_sku_id 应该还是人工匹配的 TEST-SKU-123,实际 %q" +
"——同步把匹配成果覆盖掉了,这是本工单最不能接受的问题" , skuID )
}
// 其余允许覆盖的字段应该已经更新,证明这不是"upsert 整个没生效"的假通过。
if title != "更新后的标题" || qty != 3 {
t . Errorf ( "title/quantity 应该被同步更新,实际 title=%q quantity=%d" , title , qty )
}
}
2026-08-11 10:46:11 +08:00
func TestUpsertSybOrder_只更新Syb骨架标题 ( t * testing . T ) {
db := newSybTestDB ( t )
now := model . NowISO ()
if _ , err := db . Exec ( `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SYB-P','旧骨架','syb',?,?),('API-P','正式标题','api',?,?)` , now , now , now , now ); err != nil {
t . Fatal ( err )
}
for _ , o := range [] model . SybOrder {{ SybID : "S1" , OrderNo : "O1" , Title : "新骨架" , ProductSpec : "黑色,M" , ShopeeGoodsID : "SYB-P" , Quantity : 1 , PriceTwdCent : 100 , SybData : "{}" }, { SybID : "S2" , OrderNo : "O2" , Title : "顺运宝标题" , ProductSpec : "白色,L" , ShopeeGoodsID : "API-P" , Quantity : 1 , PriceTwdCent : 200 , SybData : "{}" }} {
if _ , err := UpsertSybOrder ( db , o ); err != nil {
t . Fatal ( err )
}
}
var sybTitle , apiTitle string
_ = db . QueryRow ( `SELECT title FROM shopee_products WHERE goods_id='SYB-P'` ). Scan ( & sybTitle )
_ = db . QueryRow ( `SELECT title FROM shopee_products WHERE goods_id='API-P'` ). Scan ( & apiTitle )
if sybTitle != "新骨架" || apiTitle != "正式标题" {
t . Fatalf ( "来源保护失败:syb=%q api=%q" , sybTitle , apiTitle )
}
}
2026-08-13 10:39:21 +08:00
func TestUpsertSybOrder_店铺图片按字段来源保护 ( t * testing . T ) {
db := newSybTestDB ( t )
order := model . SybOrder { SybID : "META-1" , OrderNo : "O-META" , Title : "商品" , ShopeeGoodsID : "SP-META" , ProductSpec : "黑色,M" , ShopName : "店铺一" , ImageURL : "https://example.com/1.jpg" , Quantity : 1 , SybData : "{}" }
if _ , err := UpsertSybOrder ( db , order ); err != nil {
t . Fatal ( err )
}
var shop , image , shopSource , imageSource string
if err := db . QueryRow ( `SELECT shopee_shop_name,image_url,shop_name_source,image_source FROM shopee_products WHERE goods_id='SP-META'` ). Scan ( & shop , & image , & shopSource , & imageSource ); err != nil {
t . Fatal ( err )
}
if shop != "店铺一" || image != "https://example.com/1.jpg" || shopSource != "syb" || imageSource != "syb" {
t . Fatalf ( "首次补全不正确:shop=%q image=%q sources=%q/%q" , shop , image , shopSource , imageSource )
}
order . ShopName , order . ImageURL = "店铺二" , "https://example.com/2.jpg"
if _ , err := UpsertSybOrder ( db , order ); err != nil {
t . Fatal ( err )
}
if _ , err := db . Exec ( `UPDATE shopee_products SET image_url='https://example.com/api.jpg',image_source='api',shop_name_is_manual=1 WHERE goods_id='SP-META'` ); err != nil {
t . Fatal ( err )
}
order . ShopName , order . ImageURL = "不应覆盖的店铺" , "https://example.com/3.jpg"
if _ , err := UpsertSybOrder ( db , order ); err != nil {
t . Fatal ( err )
}
if err := db . QueryRow ( `SELECT shopee_shop_name,image_url FROM shopee_products WHERE goods_id='SP-META'` ). Scan ( & shop , & image ); err != nil {
t . Fatal ( err )
}
if shop != "店铺二" || image != "https://example.com/api.jpg" {
t . Fatalf ( "字段保护失败:shop=%q image=%q" , shop , image )
}
}
func TestUpsertSybShopeeSKU_确定性解析并允许权威来源升级 ( t * testing . T ) {
db := newSybTestDB ( t )
now := model . NowISO ()
if _ , err := db . Exec ( `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SP-SKU','商品','syb',?,?)` , now , now ); err != nil {
t . Fatal ( err )
}
parsed , err := UpsertSybShopeeSKU ( db , "SP-SKU" , "白色,L【建議50-60公斤】" , now )
if err != nil || ! parsed {
t . Fatalf ( "SYB 规格应解析成功:parsed=%v err=%v" , parsed , err )
}
if parsed , err := UpsertSybShopeeSKU ( db , "SP-SKU" , "不明确规格" , now ); err != nil || parsed {
t . Fatalf ( "不明确规格不应写入:parsed=%v err=%v" , parsed , err )
}
key , _ := spec . SpecKey ( "白色,L【建議50-60公斤】" )
outcome , err := UpsertCatalogShopeeSKU ( db , CatalogShopeeSKUInput { RecordID : "API-NEW" , ShopeeSKUID : "REAL-1" , GoodsID : "SP-SKU" , SpecRaw : "白色,L【建議50-60公斤】" , SpecKey : key , Color : "象牙白" , Size : "L" , Advice : "建议50-60公斤" , ParseOK : true , Source : "partner" , ObservedAt : now , Now : now , UpdatePolicy : "fill_missing" })
if err != nil || outcome != CatalogSKUFilled {
t . Fatalf ( "权威来源升级失败:outcome=%s err=%v" , outcome , err )
}
var count int
var external , color , sourceJSON string
if err := db . QueryRow ( `SELECT COUNT(*),MAX(COALESCE(shopee_sku_id,'')),MAX(COALESCE(color,'')),MAX(COALESCE(field_sources,'')) FROM shopee_skus WHERE goods_id='SP-SKU'` ). Scan ( & count , & external , & color , & sourceJSON ); err != nil {
t . Fatal ( err )
}
if count != 1 || external != "REAL-1" || color != "象牙白" || ! strings . Contains ( sourceJSON , `"color":"partner"` ) {
t . Fatalf ( "升级后数据不正确:count=%d external=%q color=%q sources=%s" , count , external , color , sourceJSON )
}
}
2026-08-09 11:49:13 +08:00
func TestUpsertSybOrder_ShopeeGoodsID会被同步更新 ( t * testing . T ) {
db := newSybTestDB ( t )
if _ , err := UpsertSybOrder ( db , model . SybOrder {
SybID : "SYB-2" , OrderNo : "ORDER-2" , ShopeeGoodsID : "11111111111" , Quantity : 1 ,
SybData : "{}" ,
}); err != nil {
t . Fatalf ( "首次写入失败: %v" , err )
}
if _ , err := UpsertSybOrder ( db , model . SybOrder {
SybID : "SYB-2" , OrderNo : "ORDER-2" , ShopeeGoodsID : "22222222222" , Quantity : 1 ,
SybData : "{}" ,
}); err != nil {
t . Fatalf ( "二次写入失败: %v" , err )
}
var goodsID string
if err := db . QueryRow ( `SELECT shopee_goods_id FROM syb_orders WHERE syb_id = ?` ,
"SYB-2" ). Scan ( & goodsID ); err != nil {
t . Fatalf ( "查询失败: %v" , err )
}
if goodsID != "22222222222" {
t . Errorf ( "shopee_goods_id 应该被同步更新为顺运宝返回的新值,实际 %q" , goodsID )
}
}
func TestUpsertSybOrder_重复同步同一订单行数不翻倍 ( t * testing . T ) {
db := newSybTestDB ( t )
for i := 0 ; i < 3 ; i ++ {
if _ , err := UpsertSybOrder ( db , model . SybOrder {
SybID : "SYB-3" , OrderNo : "ORDER-3" , Quantity : 1 , SybData : "{}" ,
}); err != nil {
t . Fatalf ( "第 %d 次写入失败: %v" , i + 1 , err )
}
}
n , err := CountSybOrdersTotal ( db )
if err != nil {
t . Fatalf ( "统计失败: %v" , err )
}
if n != 1 {
t . Fatalf ( "重复同步同一个 syb_id 应该只有 1 行,实际 %d 行" , n )
}
}
2026-08-10 12:24:23 +08:00
func TestUpsertSybOrder_骨架失败时同事务不写订单 ( t * testing . T ) {
db := newSybTestDB ( t )
tx , err := db . Begin ()
if err != nil {
t . Fatal ( err )
}
if _ , err := UpsertSybOrder ( tx , model . SybOrder {
SybID : "SYB-REJECT" , OrderNo : "ORDER-REJECT" , Title : "测试" ,
ShopeeGoodsID : strings . Repeat ( "G" , 192 ), ProductSpec : "黑色,M" , Quantity : 1 , SybData : "{}" ,
}); err == nil {
t . Fatal ( "骨架失败必须让订单写入失败" )
}
tx . Rollback ()
var count int
if err := db . QueryRow ( `SELECT COUNT(*) FROM syb_orders WHERE syb_id='SYB-REJECT'` ). Scan ( & count ); err != nil || count != 0 {
t . Fatalf ( "失败后不应留下订单 count=%d err=%v" , count , err )
}
}
2026-08-09 11:49:13 +08:00
func TestSybSession_保存读取和清除 ( t * testing . T ) {
db := newSybTestDB ( t )
if got , err := GetSybSession ( db , "tester" ); err != nil || got != nil {
t . Fatalf ( "没有会话时应该返回 (nil, nil),实际 got=%v err=%v" , got , err )
}
if err := SaveSybSession ( db , "tester" , `[{"name":"JSESSIONID","value":"abc"}]` , "2026-08-10T00:00:00Z" ); err != nil {
t . Fatalf ( "保存会话失败: %v" , err )
}
got , err := GetSybSession ( db , "tester" )
if err != nil {
t . Fatalf ( "读取会话失败: %v" , err )
}
if got == nil || got . ExpiresAt != "2026-08-10T00:00:00Z" {
t . Fatalf ( "读取到的会话不对: %+v" , got )
}
// upsert:同一用户名再保存一次应该覆盖,不是新增一行。
if err := SaveSybSession ( db , "tester" , `[{"name":"JSESSIONID","value":"xyz"}]` , "2026-08-11T00:00:00Z" ); err != nil {
t . Fatalf ( "二次保存会话失败: %v" , err )
}
got2 , err := GetSybSession ( db , "tester" )
if err != nil || got2 == nil || got2 . ExpiresAt != "2026-08-11T00:00:00Z" {
t . Fatalf ( "二次保存后应该读到新值: got=%+v err=%v" , got2 , err )
}
if err := DeleteSybSession ( db , "tester" ); err != nil {
t . Fatalf ( "删除会话失败: %v" , err )
}
if got3 , err := GetSybSession ( db , "tester" ); err != nil || got3 != nil {
t . Fatalf ( "删除后应该查不到,实际 got=%v err=%v" , got3 , err )
}
}
func TestSybSyncState_首次为空之后可更新 ( t * testing . T ) {
db := newSybTestDB ( t )
_ , found , err := GetSybLastSyncedAt ( db )
if err != nil {
t . Fatalf ( "查询失败: %v" , err )
}
if found {
t . Fatal ( "从没同步过时 found 应该是 false" )
}
if err := SetSybLastSyncedAt ( db , "2026-08-09T14:30:00Z" ); err != nil {
t . Fatalf ( "更新失败: %v" , err )
}
at , found , err := GetSybLastSyncedAt ( db )
if err != nil || ! found || at != "2026-08-09T14:30:00Z" {
t . Fatalf ( "读取错误: at=%q found=%v err=%v" , at , found , err )
}
// 再更新一次,确认是 upsert 而不是报主键冲突。
if err := SetSybLastSyncedAt ( db , "2026-08-10T09:00:00Z" ); err != nil {
t . Fatalf ( "二次更新失败: %v" , err )
}
at2 , _ , err := GetSybLastSyncedAt ( db )
if err != nil || at2 != "2026-08-10T09:00:00Z" {
t . Fatalf ( "二次更新后应该读到新值: at2=%q err=%v" , at2 , err )
}
}
2026-08-09 18:22:58 +08:00
func createSybSyncTestUser ( t * testing . T , db * sql . DB ) model . User {
t . Helper ()
user := model . User {
UserID : "USR-SYNC" , Username : "buyer" , PasswordHash : "test-hash" ,
Role : model . RolePurchaser , Status : model . UserActive ,
PasswordChangedAt : "2026-08-09T00:00:00Z" , CreatedAt : "2026-08-09T00:00:00Z" ,
UpdatedAt : "2026-08-09T00:00:00Z" ,
}
if err := CreateInitialAdmin ( db , user ); err != nil {
t . Fatalf ( "创建同步记录测试用户失败: %v" , err )
}
return user
}
func TestSybSyncRun_创建完成并分页读取 ( t * testing . T ) {
db := newSybTestDB ( t )
user := createSybSyncTestUser ( t , db )
run := model . SybSyncRun {
RunID : "SYB-RUN-1" , UserID : user . UserID , DateFrom : "2026-08-07" ,
DateTo : "2026-08-09" , StartedAt : "2026-08-09T01:00:00Z" ,
}
if err := CreateSybSyncRun ( db , run ); err != nil {
t . Fatalf ( "创建同步记录失败: %v" , err )
}
if err := FinishSybSyncRun ( db , model . SybSyncRun {
RunID : "SYB-RUN-1" , Status : model . SybSyncSucceeded ,
StockCount : 3 , DetailCount : 4 , Created : 2 , Updated : 2 , Skipped : 1 ,
CursorAdvanced : true , FinishedAt : "2026-08-09T01:02:00Z" ,
}); err != nil {
t . Fatalf ( "完成同步记录失败: %v" , err )
}
rows , err := ListSybSyncRuns ( db , 10 , 0 )
if err != nil || len ( rows ) != 1 {
t . Fatalf ( "读取同步记录失败: rows=%+v err=%v" , rows , err )
}
got := rows [ 0 ]
if got . Username != "buyer" || got . Status != model . SybSyncSucceeded ||
got . DetailCount != 4 || ! got . CursorAdvanced || got . FinishedAt == "" {
t . Fatalf ( "同步记录字段不正确: %+v" , got )
}
if count , err := CountSybSyncRuns ( db ); err != nil || count != 1 {
t . Fatalf ( "同步记录总数错误: count=%d err=%v" , count , err )
}
}
func TestInterruptRunningSybSyncRuns_只中断未完成记录 ( t * testing . T ) {
db := newSybTestDB ( t )
user := createSybSyncTestUser ( t , db )
for _ , id := range [] string { "RUNNING" , "DONE" } {
if err := CreateSybSyncRun ( db , model . SybSyncRun {
RunID : id , UserID : user . UserID , DateFrom : "2026-08-09" , DateTo : "2026-08-09" ,
StartedAt : "2026-08-09T01:00:00Z" ,
}); err != nil {
t . Fatal ( err )
}
}
if err := FinishSybSyncRun ( db , model . SybSyncRun {
RunID : "DONE" , Status : model . SybSyncSucceeded , FinishedAt : "2026-08-09T01:01:00Z" ,
}); err != nil {
t . Fatal ( err )
}
affected , err := InterruptRunningSybSyncRuns ( db , "2026-08-09T02:00:00Z" )
if err != nil || affected != 1 {
t . Fatalf ( "应该只中断一条 running 记录: affected=%d err=%v" , affected , err )
}
rows , _ := ListSybSyncRuns ( db , 10 , 0 )
statuses := map [ string ] model . SybSyncRunStatus {}
for _ , row := range rows {
statuses [ row . RunID ] = row . Status
}
if statuses [ "RUNNING" ] != model . SybSyncInterrupted || statuses [ "DONE" ] != model . SybSyncSucceeded {
t . Fatalf ( "中断状态错误: %+v" , statuses )
}
}
2026-08-11 14:39:13 +08:00
func TestListSybOrders_订单标题店铺组合筛选 ( t * testing . T ) {
2026-08-09 11:49:13 +08:00
db := newSybTestDB ( t )
2026-08-11 14:39:13 +08:00
mustUpsert := func ( sybID , orderNo , shop , title string ) {
2026-08-09 11:49:13 +08:00
if _ , err := UpsertSybOrder ( db , model . SybOrder {
2026-08-11 14:39:13 +08:00
SybID : sybID , OrderNo : orderNo , ShopName : shop , Title : title , Quantity : 1 , SybData : "{}" ,
2026-08-09 11:49:13 +08:00
}); err != nil {
t . Fatalf ( "写入 %s 失败: %v" , sybID , err )
}
}
2026-08-11 14:39:13 +08:00
mustUpsert ( "SYB-A" , "260728AAA" , "台北服饰旗舰店" , "纯棉上衣" )
mustUpsert ( "SYB-B" , "260728BBB" , "台南生活馆" , "牛仔裤" )
2026-08-09 11:49:13 +08:00
rows , err := ListSybOrders ( db , SybOrderFilter { Keyword : "AAA" }, 20 , 0 )
if err != nil {
t . Fatalf ( "查询失败: %v" , err )
}
if len ( rows ) != 1 || rows [ 0 ]. SybID != "SYB-A" {
t . Fatalf ( "按订单号筛选应该只查到 SYB-A,实际 %+v" , rows )
}
rows2 , err := ListSybOrders ( db , SybOrderFilter { Keyword : "牛仔" }, 20 , 0 )
if err != nil {
t . Fatalf ( "查询失败: %v" , err )
}
if len ( rows2 ) != 1 || rows2 [ 0 ]. SybID != "SYB-B" {
t . Fatalf ( "按标题筛选应该只查到 SYB-B,实际 %+v" , rows2 )
}
2026-08-11 14:39:13 +08:00
rows3 , err := ListSybOrders ( db , SybOrderFilter { Shop : " 服饰 " }, 20 , 0 )
if err != nil || len ( rows3 ) != 1 || rows3 [ 0 ]. SybID != "SYB-A" || rows3 [ 0 ]. ShopName != "台北服饰旗舰店" {
t . Fatalf ( "按店铺模糊筛选错误:rows=%+v err=%v" , rows3 , err )
}
count , err := CountSybOrders ( db , SybOrderFilter { Keyword : "牛仔" , Shop : "台南" })
if err != nil || count != 1 {
t . Fatalf ( "标题与店铺组合 COUNT 错误:count=%d err=%v" , count , err )
}
2026-08-09 11:49:13 +08:00
}