Files
cmautobuy/admin/repository/syb.go
T

363 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 顺运宝会话缓存、同步进度和货运单明细的读写。
//
// 改动前必读 admin/AGENTS.md:只有本文件(和 db.go)能写 SQL,
// service/syb.go 和 handler/web/others.go 都不许拼 SQL。
package repository
import (
"database/sql"
"errors"
"fmt"
"cmautobuy/admin/model"
)
// ---------- 会话缓存 ----------
// SaveSybSession 写入或更新顺运宝登录会话缓存(按用户名 upsert)。
//
// `[必须]` 缓存写失败不能让已经登录的顺运宝会话失效——调用方拿到错误后
// 只应该记日志,不应该把内存里刚登录成功的会话也扔掉,
// 见 docs/admin/08-顺运宝接口.md §8。这条约束在 service 层落实,
// 这里只负责"写失败就如实返回错误"。
func SaveSybSession(q Execer, username, cookiesJSON, expiresAt string) error {
if username == "" {
return fmt.Errorf("username 不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
cookies = excluded.cookies,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at`,
username, cookiesJSON, expiresAt, now)
if err != nil {
return fmt.Errorf("保存顺运宝会话缓存失败: %w", err)
}
return nil
}
// SybSessionCache 是缓存里的一条顺运宝会话。
type SybSessionCache struct {
Username string
Cookies string // JSON 数组,原样保留,交给 syb.Client 解析
ExpiresAt string
}
// GetSybSession 按用户名查缓存的会话,查不到返回 (nil, nil)。
//
// `[必须]` 这里只负责取数据,**不判断是否过期**——过期时间的比较、
// 是否需要重新登录,是 service 层的业务判断(要用到"现在几点"这个
// 会变化的量,放这里测试起来还要控制时间,不如交给上层)。
func GetSybSession(q Execer, username string) (*SybSessionCache, error) {
var c SybSessionCache
err := q.QueryRow(`
SELECT username, cookies, expires_at FROM syb_session WHERE username = ?`, username,
).Scan(&c.Username, &c.Cookies, &c.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询顺运宝会话缓存失败: %w", err)
}
return &c, nil
}
// DeleteSybSession 清除某个用户名的会话缓存(会话确认失效后调用)。
func DeleteSybSession(q Execer, username string) error {
if _, err := q.Exec(`DELETE FROM syb_session WHERE username = ?`, username); err != nil {
return fmt.Errorf("清除顺运宝会话缓存失败: %w", err)
}
return nil
}
// ---------- 同步进度 ----------
// GetSybLastSyncedAt 查上次同步完成的时间(精确到秒的 ISO 字符串)。
// 从没同步过时返回 ("", false, nil)。
func GetSybLastSyncedAt(q Execer) (lastSyncedAt string, found bool, err error) {
var s sql.NullString
err = q.QueryRow(`SELECT last_synced_at FROM syb_sync_state WHERE id = 1`).Scan(&s)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("查询顺运宝同步进度失败: %w", err)
}
if !s.Valid || s.String == "" {
return "", false, nil
}
return s.String, true, nil
}
// SetSybLastSyncedAt 更新"上次同步到哪"。
//
// `[必须]` 只应该在一次同步**全部成功**之后调用——调用方(service 层)
// 负责这个时机;这里只负责写,不判断"是否该写"。中途失败不调用这个函数,
// 见工单 #46「中途失败不更新 last_synced_at」。
func SetSybLastSyncedAt(q Execer, at string) error {
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
last_synced_at = excluded.last_synced_at,
updated_at = excluded.updated_at`,
at, now)
if err != nil {
return fmt.Errorf("更新顺运宝同步进度失败: %w", err)
}
return nil
}
// ---------- 同步记录 ----------
// CreateSybSyncRun 在真正启动后台同步前写入一条 running 记录。
func CreateSybSyncRun(q Execer, run model.SybSyncRun) error {
if run.RunID == "" || run.UserID == "" || run.DateFrom == "" || run.DateTo == "" || run.StartedAt == "" {
return fmt.Errorf("同步记录缺少编号、操作人、日期范围或开始时间")
}
_, err := q.Exec(`
INSERT INTO syb_sync_runs
(run_id, user_id, date_from, date_to, status, started_at)
VALUES (?, ?, ?, ?, 'running', ?)`,
run.RunID, run.UserID, run.DateFrom, run.DateTo, run.StartedAt)
if err != nil {
return fmt.Errorf("创建顺运宝同步记录失败: %w", err)
}
return nil
}
// FinishSybSyncRun 把 running 记录更新为最终状态。
func FinishSybSyncRun(q Execer, run model.SybSyncRun) error {
if run.Status != model.SybSyncSucceeded && run.Status != model.SybSyncFailed {
return fmt.Errorf("同步完成状态不合法: %s", run.Status)
}
result, err := q.Exec(`
UPDATE syb_sync_runs
SET status = ?, stock_count = ?, detail_count = ?, created_count = ?,
updated_count = ?, skipped_count = ?, error_message = ?,
cursor_advanced = ?, finished_at = ?
WHERE run_id = ? AND status = 'running'`,
run.Status, run.StockCount, run.DetailCount, run.Created, run.Updated, run.Skipped,
nullableText(run.ErrorMessage), run.CursorAdvanced, run.FinishedAt, run.RunID)
if err != nil {
return fmt.Errorf("完成顺运宝同步记录失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("确认顺运宝同步记录完成结果失败: %w", err)
}
if affected != 1 {
return fmt.Errorf("同步记录 %s 不存在或已经结束", run.RunID)
}
return nil
}
// InterruptRunningSybSyncRuns 在 Admin 启动时收敛上次进程遗留的 running 记录。
func InterruptRunningSybSyncRuns(q Execer, finishedAt string) (int, error) {
result, err := q.Exec(`
UPDATE syb_sync_runs
SET status = 'interrupted', finished_at = ?,
error_message = 'Admin 在同步完成前退出,请重新同步该日期范围'
WHERE status = 'running'`, finishedAt)
if err != nil {
return 0, fmt.Errorf("标记中断的顺运宝同步记录失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("统计中断的顺运宝同步记录失败: %w", err)
}
return int(affected), nil
}
// ListSybSyncRuns 按开始时间倒序分页查询同步记录。
func ListSybSyncRuns(q Execer, limit, offset int) ([]model.SybSyncRun, error) {
rows, err := q.Query(`
SELECT r.run_id, r.user_id, u.username, r.date_from, r.date_to, r.status,
r.stock_count, r.detail_count, r.created_count, r.updated_count,
r.skipped_count, r.error_message, r.cursor_advanced,
r.started_at, r.finished_at
FROM syb_sync_runs r
JOIN users u ON u.user_id = r.user_id
ORDER BY r.started_at DESC, r.run_id DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, fmt.Errorf("查询顺运宝同步记录失败: %w", err)
}
defer rows.Close()
var list []model.SybSyncRun
for rows.Next() {
var run model.SybSyncRun
var errorMessage, finishedAt sql.NullString
var cursorAdvanced int
if err := rows.Scan(&run.RunID, &run.UserID, &run.Username, &run.DateFrom, &run.DateTo,
&run.Status, &run.StockCount, &run.DetailCount, &run.Created, &run.Updated,
&run.Skipped, &errorMessage, &cursorAdvanced, &run.StartedAt, &finishedAt); err != nil {
return nil, fmt.Errorf("读取顺运宝同步记录失败: %w", err)
}
run.ErrorMessage = errorMessage.String
run.CursorAdvanced = cursorAdvanced == 1
run.FinishedAt = finishedAt.String
list = append(list, run)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取顺运宝同步记录失败: %w", err)
}
return list, nil
}
// CountSybSyncRuns 返回同步记录总数,供弹窗分页。
func CountSybSyncRuns(q Execer) (int, error) {
var count int
if err := q.QueryRow(`SELECT COUNT(*) FROM syb_sync_runs`).Scan(&count); err != nil {
return 0, fmt.Errorf("统计顺运宝同步记录失败: %w", err)
}
return count, nil
}
// ---------- 货运单明细 ----------
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
//
// `[必须]` DO UPDATE SET 里绝不允许出现 shopee_sku_id。它是规格匹配的
// 结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值——写进去
// 就是写 NULL,把人工攒的匹配成果洗掉,而且不报错。这和 #38 里
// pdd_goods_url 不能被 Excel 导入覆盖是同一类问题,见工单 #46、
// docs/admin/08-顺运宝接口.md §6.2。
//
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
// detail.productId,来自顺运宝,不是人工填的。
//
// 返回 created 表示这一行是不是本次新插入的(供上层统计"新增/更新")。
func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
if o.SybID == "" {
return false, fmt.Errorf("syb_id 不能为空")
}
var exists int
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
switch {
case errors.Is(err, sql.ErrNoRows):
created = true
case err != nil:
return false, fmt.Errorf("查询顺运宝货运单明细 %s 失败: %w", o.SybID, err)
}
now := model.NowISO()
_, err = q.Exec(`
INSERT INTO syb_orders
(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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(syb_id) DO UPDATE SET
order_no = excluded.order_no,
title = excluded.title,
product_spec = excluded.product_spec,
shopee_goods_id = excluded.shopee_goods_id,
quantity = excluded.quantity,
price_twd_cent = excluded.price_twd_cent,
image_url = excluded.image_url,
syb_data = excluded.syb_data,
updated_at = excluded.updated_at`,
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
// 这一列不受本语句影响,见上面的函数注释。
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), nullableText(o.ShopeeGoodsID),
nullableText(o.ShopeeSKUID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
o.SybData, now, now)
if err != nil {
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
}
return created, nil
}
// nullableText 把空字符串转成 SQL NULL,非空字符串原样写入。
// syb_orders 的这几列在建表语句里都允许 NULL,空字符串和 NULL
// 在页面上显示效果一样,统一存 NULL 更符合"这个字段还没有值"的语义。
func nullableText(s string) any {
if s == "" {
return nil
}
return s
}
// SybOrderFilter 是货运单列表页支持的筛选条件。
type SybOrderFilter struct {
Keyword string // 匹配订单号或商品标题
}
func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
kw := filter.Keyword
if kw == "" {
return "", nil
}
like := "%" + escapeLike(kw) + "%"
return ` WHERE (order_no LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\')`, []any{like, like}
}
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `
SELECT 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
FROM syb_orders` + where + `
ORDER BY updated_at DESC, syb_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询顺运宝货运单列表失败: %w", err)
}
defer rows.Close()
var list []model.SybOrder
for rows.Next() {
var o model.SybOrder
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
var priceCent sql.NullInt64
if err := rows.Scan(
&o.SybID, &o.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
}
o.Title = title.String
o.ProductSpec = productSpec.String
o.ShopeeGoodsID = shopeeGoodsID.String
o.ShopeeSKUID = shopeeSKUID.String
o.PriceTwdCent = priceCent.Int64
o.ImageURL = imageURL.String
list = append(list, o)
}
return list, rows.Err()
}
// CountSybOrders 统计当前筛选条件下的货运单明细总数。
//
// `[必须]` 用和 ListSybOrders **完全相同**的筛选条件——分页和底部统计
// 靠它,写成两份筛选条件迟早有一天会不一致(工单 #43 的教训)。
func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `SELECT COUNT(*) FROM syb_orders` + where
var n int
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
}
return n, nil
}
// CountSybOrdersTotal 统计全部货运单明细数量(不带筛选),
// 供列表页判断"是否已经同步过任何数据"。
func CountSybOrdersTotal(q Execer) (int, error) {
var n int
if err := q.QueryRow(`SELECT COUNT(*) FROM syb_orders`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
}
return n, nil
}