feat: 档口入库码后台批量回写 (#246)

This commit is contained in:
chengma
2026-08-15 17:08:17 +08:00
parent 6b30594f4c
commit f8f22a15c7
21 changed files with 708 additions and 134 deletions
+90 -29
View File
@@ -2,7 +2,9 @@ package service
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
@@ -30,51 +32,110 @@ type InnerCodeApplyResult struct {
NeedsCheck int
}
// InnerCodeApplyBatch 是一次已经落库的后台回写请求。
type InnerCodeApplyBatch struct {
ID string
Count int
}
const innerCodeApplyChunkSize = 20
type innerCodeApplyOutcome struct {
Status model.InnerCodeStatus
Message string
RemoteCode string
}
// ApplyInnerCodes 逐条原子领取并回写。每条写请求只发送一次,单条失败不阻断后续行。
func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, ids []int64, actorUserID string) (*InnerCodeApplyResult, error) {
// QueueInnerCodeApplyBatch 把任意数量的已选记录原子排队,远端请求由后台执行器处理。
func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) {
ids = uniquePositiveInnerCodeIDs(ids)
if len(ids) == 0 {
return nil, fmt.Errorf("没有选择可回写记录")
}
if len(ids) > InnerCodeApplyBatchLimit {
return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeApplyBatchLimit)
batchID, err := newInnerCodeApplyBatchID()
if err != nil {
return nil, err
}
result := &InnerCodeApplyResult{Requested: len(ids)}
for _, id := range ids {
now := model.NowISO()
record, claimed, err := repository.ClaimInnerCodeForApply(db, id, actorUserID, now)
count, err := repository.QueueInnerCodeApplyBatch(db, ids, batchID, actorUserID, model.NowISO())
if err != nil {
if errors.Is(err, repository.ErrInnerCodeApplyConflict) {
return nil, fmt.Errorf("所选记录中有记录已不再可回写;本批没有部分入队,请刷新后重新选择")
}
return nil, err
}
return &InnerCodeApplyBatch{ID: batchID, Count: count}, nil
}
// RunInnerCodeApplyBatch 分组读取后台队列,再逐条执行原有安全回写门禁。
// 分组大小只用于控制一次数据库读取,远端请求始终逐条发送且不会自动重试。
func RunInnerCodeApplyBatch(ctx context.Context, db *sql.DB, writer InnerCodeWriter, batchID, actorUserID string) (*InnerCodeApplyResult, error) {
result := &InnerCodeApplyResult{}
for {
if err := ctx.Err(); err != nil {
return result, err
}
ids, err := repository.ListQueuedInnerCodeIDs(db, batchID, innerCodeApplyChunkSize)
if err != nil {
return nil, err
return result, err
}
if !claimed {
result.Skipped++
continue
if len(ids) == 0 {
return result, nil
}
outcome := applyClaimedInnerCode(ctx, writer, *record)
finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return nil, err
}
switch outcome.Status {
case model.InnerCodeUpdated:
result.Updated++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeNeedsCheck:
result.NeedsCheck++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
result.Requested += len(ids)
for _, id := range ids {
if err := ctx.Err(); err != nil {
return result, err
}
now := model.NowISO()
record, claimed, err := repository.ClaimQueuedInnerCodeForApply(db, id, batchID, actorUserID, now)
if err != nil {
return result, err
}
if !claimed {
result.Skipped++
continue
}
outcome := applyClaimedInnerCode(ctx, writer, *record)
finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return result, err
}
switch outcome.Status {
case model.InnerCodeUpdated:
result.Updated++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeNeedsCheck:
result.NeedsCheck++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
}
}
}
return result, nil
}
// InterruptInnerCodeApplyBatch 在后台异常时收敛当前批次,不自动重试任何远端请求。
func InterruptInnerCodeApplyBatch(db *sql.DB, batchID string) (int, int, error) {
return repository.InterruptInnerCodeApplyBatch(db, strings.TrimSpace(batchID), model.NowISO())
}
// GetInnerCodeApplyBatchProgress 返回页面使用的单表聚合进度。
func GetInnerCodeApplyBatchProgress(db *sql.DB, batchID string) (*repository.InnerCodeApplyBatchProgress, error) {
batchID = strings.TrimSpace(batchID)
if batchID == "" || len(batchID) > 191 {
return nil, nil
}
return repository.GetInnerCodeApplyBatchProgress(db, batchID)
}
func newInnerCodeApplyBatchID() (string, error) {
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("生成档口入库码后台批次失败: %w", err)
}
return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
}
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome {
+34 -4
View File
@@ -2,9 +2,12 @@ package service
import (
"context"
"database/sql"
"errors"
"testing"
_ "modernc.org/sqlite"
"cmautobuy/admin/model"
"cmautobuy/admin/syb"
)
@@ -20,13 +23,40 @@ type fakeInnerCodeWriter struct {
updatedCode string
}
func TestApplyInnerCodes_远程批量上限不随页面容量放宽(t *testing.T) {
ids := make([]int64, InnerCodeApplyBatchLimit+1)
func TestQueueInnerCodeApplyBatch_超过20条可一次排队(t *testing.T) {
if innerCodeApplyChunkSize != 20 {
t.Fatalf("后台数据库读取分组应固定为 20,实际 %d", innerCodeApplyChunkSize)
}
db, err := sql.Open("sqlite", "file:inner_code_queue_over_20?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE syb_inner_code_records (
id INTEGER PRIMARY KEY,status TEXT NOT NULL,apply_batch_id TEXT,apply_queued_at TEXT,
applied_by_user_id TEXT,result_message TEXT,apply_started_at TEXT,updated_at TEXT,
deleted_at TEXT
)`); err != nil {
t.Fatal(err)
}
ids := make([]int64, 45)
for i := range ids {
ids[i] = int64(i + 1)
if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES(?,'ready','old')`, ids[i]); err != nil {
t.Fatal(err)
}
}
if _, err := ApplyInnerCodes(context.Background(), nil, nil, ids, "user-1"); err == nil {
t.Fatal("超过 20 条时应该在访问数据库或顺运宝前拒绝")
batch, err := QueueInnerCodeApplyBatch(db, ids, "user-1")
if err != nil || batch.Count != 45 || batch.ID == "" {
t.Fatalf("batch=%+v err=%v", batch, err)
}
var queued, batches int
if err := db.QueryRow(`SELECT COUNT(*),COUNT(DISTINCT apply_batch_id)
FROM syb_inner_code_records WHERE status='queued'`).Scan(&queued, &batches); err != nil {
t.Fatal(err)
}
if queued != 45 || batches != 1 {
t.Fatalf("queued=%d batches=%d", queued, batches)
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ func innerCodeReservedDetails(records []model.InnerCodeRecord, selected map[int6
func innerCodeStatusReservesDetail(status model.InnerCodeStatus) bool {
switch status {
case model.InnerCodeReady, model.InnerCodeApplying, model.InnerCodeUpdated,
case model.InnerCodeReady, model.InnerCodeQueued, model.InnerCodeApplying, model.InnerCodeUpdated,
model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck:
return true
default:
+7 -8
View File
@@ -19,6 +19,7 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
{"", "全部状态"},
{string(model.InnerCodePending), "待匹配"},
{string(model.InnerCodeReady), "可回写"},
{string(model.InnerCodeQueued), "排队中"},
{string(model.InnerCodeApplying), "回写中"},
{string(model.InnerCodeUpdated), "已回写"},
{string(model.InnerCodeAlreadyFilled), "已存在"},
@@ -27,12 +28,8 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
{string(model.InnerCodeNeedsCheck), "需核对"},
}
// 删除只改本地数据库,可覆盖最大单页;回写会修改顺运宝,继续限制 20 条。
// 只读匹配不使用这个写操作上限,见 inner_code_match.go 的远端读取分批。
const (
InnerCodeDeleteBatchLimit = 100
InnerCodeApplyBatchLimit = 20
)
// 删除只改本地数据库,可覆盖最大单页。回写已改为后台队列,不再限制选择 20 条。
const InnerCodeDeleteBatchLimit = 100
// InnerCodeRowView 是正式页面的一行。
type InnerCodeRowView struct {
@@ -123,6 +120,7 @@ func validInnerCodeStatusFilter(status string) bool {
func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
statusText := map[model.InnerCodeStatus]string{
model.InnerCodePending: "待匹配", model.InnerCodeReady: "可回写",
model.InnerCodeQueued: "排队中",
model.InnerCodeApplying: "回写中", model.InnerCodeUpdated: "已回写",
model.InnerCodeAlreadyFilled: "已存在", model.InnerCodeSkipped: "已跳过",
model.InnerCodeFailed: "失败", model.InnerCodeNeedsCheck: "需核对",
@@ -158,6 +156,7 @@ func displayInnerCodeValue(value string) string {
// InnerCodeStatusMessage 生成底栏可读统计。
func InnerCodeStatusMessage(page *InnerCodeListPage) string {
return fmt.Sprintf("共 %d 条,可回写 %d 条,需核对 %d 条;当前筛选 %d 条",
page.Counts.Total, page.Counts.Ready, page.Counts.NeedsCheck, page.Total)
return fmt.Sprintf("共 %d 条,可回写 %d 条,排队中 %d 条,回写中 %d 条,需核对 %d 条;当前筛选 %d 条",
page.Counts.Total, page.Counts.Ready, page.Counts.Queued, page.Counts.Applying,
page.Counts.NeedsCheck, page.Total)
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
func TestInnerCodeRowView_按状态区分匹配与回写能力(t *testing.T) {
for _, status := range []model.InnerCodeStatus{
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeApplying,
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeQueued, model.InnerCodeApplying,
model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeSkipped,
model.InnerCodeFailed, model.InnerCodeNeedsCheck,
} {
@@ -29,7 +29,7 @@ func TestInnerCodeRowView_按状态区分匹配与回写能力(t *testing.T) {
func TestInnerCodeStatusOptions_覆盖所有状态(t *testing.T) {
options := InnerCodeStatusOptions()
if len(options) != 9 || options[0].Value != "" {
if len(options) != 10 || options[0].Value != "" {
t.Fatalf("状态筛选项不完整: %+v", options)
}
}