feat: 档口入库码后台批量回写 (#246)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user