240 lines
9.4 KiB
Go
240 lines
9.4 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"cmbuyer/admin/internal/domain"
|
|
)
|
|
|
|
// ErrInvalidFilter 表示任务筛选值无效,路由应按字段重新渲染而不是泄露内部错误。
|
|
var ErrInvalidFilter = errors.New("invalid task filter")
|
|
|
|
// SetStartPolicy is called during startup; policy is explicit because authorization limits must not be implicit defaults.
|
|
func (store *SQLiteStore) SetStartPolicy(policy StartPolicy) { store.policy = policy }
|
|
|
|
func (store *SQLiteStore) ListTasks(ctx context.Context, filter TaskFilter) ([]TaskRow, error) {
|
|
if !ValidateTaskFilter(filter).Valid() {
|
|
return nil, ErrInvalidFilter
|
|
}
|
|
from, to, err := ShanghaiRange(filter.CreatedFrom, filter.CreatedTo)
|
|
if err != nil {
|
|
return nil, ErrInvalidFilter
|
|
}
|
|
clauses, args := []string{"1=1"}, []any{}
|
|
if filter.Status != "" {
|
|
clauses = append(clauses, "status = ?")
|
|
args = append(args, filter.Status)
|
|
}
|
|
if filter.Keyword != "" {
|
|
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Keyword)
|
|
clauses = append(clauses, "(title LIKE ? ESCAPE '\\' OR goods_id LIKE ? ESCAPE '\\')")
|
|
args = append(args, "%"+escaped+"%", "%"+escaped+"%")
|
|
}
|
|
if !from.IsZero() {
|
|
clauses = append(clauses, "julianday(created_at) >= julianday(?)")
|
|
args = append(args, from.Format(time.RFC3339Nano))
|
|
}
|
|
if !to.IsZero() {
|
|
clauses = append(clauses, "julianday(created_at) < julianday(?)")
|
|
args = append(args, to.Format(time.RFC3339Nano))
|
|
}
|
|
rows, err := store.database.QueryContext(ctx, "SELECT id,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at FROM tasks WHERE "+strings.Join(clauses, " AND ")+" ORDER BY julianday(created_at) DESC,rowid DESC", args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := []TaskRow{}
|
|
for rows.Next() {
|
|
var item TaskRow
|
|
var created string
|
|
if err := rows.Scan(&item.ID, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.MaxTotalPrice, &item.Status, &item.Version, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
item.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, item)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ValidateTaskFilter 返回可关联到字段的错误,使服务端页面拒绝篡改参数时仍能保留输入值。
|
|
func ValidateTaskFilter(filter TaskFilter) Errors {
|
|
validation := Errors{}
|
|
if !validTaskStatus(filter.Status) {
|
|
validation["status"] = "请选择有效的任务状态。"
|
|
}
|
|
location, err := time.LoadLocation("Asia/Shanghai")
|
|
if err != nil {
|
|
validation["created_from"] = "日期筛选暂不可用,请稍后重试。"
|
|
validation["created_to"] = "日期筛选暂不可用,请稍后重试。"
|
|
return validation
|
|
}
|
|
parseDate := func(field, value string) (time.Time, bool) {
|
|
if value == "" {
|
|
return time.Time{}, true
|
|
}
|
|
parsed, parseErr := time.ParseInLocation("2006-01-02", value, location)
|
|
if parseErr != nil {
|
|
validation[field] = "请输入有效日期。"
|
|
return time.Time{}, false
|
|
}
|
|
return parsed, true
|
|
}
|
|
from, fromOK := parseDate("created_from", filter.CreatedFrom)
|
|
to, toOK := parseDate("created_to", filter.CreatedTo)
|
|
if fromOK && toOK && !from.IsZero() && !to.IsZero() && from.After(to) {
|
|
validation["created_to"] = "结束日期不能早于开始日期。"
|
|
}
|
|
return validation
|
|
}
|
|
|
|
func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartCommand, createdBy string) (StartResult, error) {
|
|
items, err := startItems(command)
|
|
if err != nil || createdBy == "" {
|
|
return StartResult{}, ErrInvalidStart
|
|
}
|
|
if store.policy.AuthorizationTTL <= 0 || store.policy.MaxQuantity <= 0 {
|
|
return StartResult{}, ErrInvalidStart
|
|
}
|
|
_, ceiling, ok := normalizeCents(store.policy.MaxTotalPrice)
|
|
if !ok {
|
|
return StartResult{}, ErrInvalidStart
|
|
}
|
|
writeCtx, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
|
defer cancel()
|
|
select {
|
|
case store.writeGate <- struct{}{}:
|
|
defer func() { <-store.writeGate }()
|
|
case <-writeCtx.Done():
|
|
return StartResult{}, writeCtx.Err()
|
|
}
|
|
tx, err := store.database.BeginTx(writeCtx, nil)
|
|
if err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
// Replay precedes any DRAFT check. One service process serializes this check with creation; SQLite uniqueness remains the cross-transaction backstop.
|
|
result, found, err := replayStart(writeCtx, tx, command.StartKey, items)
|
|
if err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
if found {
|
|
if err := tx.Commit(); err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
now := store.now().UTC()
|
|
expires := now.Add(store.policy.AuthorizationTTL)
|
|
result = StartResult{StartKey: command.StartKey, AuthorizedCount: len(items), Tasks: make([]AuthorizedTask, 0, len(items)), PaymentAutomated: false}
|
|
for _, item := range items {
|
|
var title, goods, color, size, price, status string
|
|
var quantity, version int
|
|
if err := tx.QueryRowContext(writeCtx, "SELECT title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version FROM tasks WHERE id=?", item.TaskID).Scan(&title, &goods, &color, &size, &quantity, &price, &status, &version); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return StartResult{}, ErrStartConflict
|
|
}
|
|
return StartResult{}, err
|
|
}
|
|
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
|
|
!ValidTaskWireFields(title, goods, color, size, price) ||
|
|
quantity < 1 || quantity > store.policy.MaxQuantity {
|
|
return StartResult{}, ErrStartConflict
|
|
}
|
|
canonical, cents, ok := normalizeCents(price)
|
|
if !ok || canonical != price || cents.Cmp(ceiling) > 0 {
|
|
return StartResult{}, ErrStartConflict
|
|
}
|
|
if _, err := domain.TransitionTask(domain.TaskStatusDraft, domain.TaskStatusPending); err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
id, err := NewCreateKey()
|
|
if err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
next := version + 1
|
|
if _, err = tx.ExecContext(writeCtx, "INSERT INTO order_authorizations (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,'ACTIVE',?,?,?)", id, item.TaskID, next, command.StartKey, goods, color, size, quantity, price, createdBy, now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)); err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
updated, err := tx.ExecContext(writeCtx, "UPDATE tasks SET status='PENDING',version=version+1,updated_at=? WHERE id=? AND status='DRAFT' AND version=?", now.Format(time.RFC3339Nano), item.TaskID, version)
|
|
if err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
affected, err := updated.RowsAffected()
|
|
if err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
if affected != 1 {
|
|
return StartResult{}, ErrStartConflict
|
|
}
|
|
result.Tasks = append(result.Tasks, AuthorizedTask{TaskID: item.TaskID, TaskVersion: next, AuthorizationID: id, ExpiresAt: expires})
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
|
|
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
|
|
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
|
|
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
|
|
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
|
|
tasks.sku_size,tasks.quantity,tasks.max_total_price
|
|
FROM order_authorizations AS authorizations
|
|
JOIN tasks ON tasks.id = authorizations.task_id
|
|
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
|
|
if err != nil {
|
|
return StartResult{}, false, err
|
|
}
|
|
defer rows.Close()
|
|
result := StartResult{StartKey: startKey, PaymentAutomated: false}
|
|
for rows.Next() {
|
|
var item AuthorizedTask
|
|
var expires string
|
|
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
|
|
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
|
|
var authorizationQuantity, taskQuantity int
|
|
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
|
|
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
|
|
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
|
|
&taskPrice); err != nil {
|
|
return StartResult{}, false, err
|
|
}
|
|
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
|
|
authorizationQuantity <= 0 ||
|
|
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
|
|
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
|
|
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
|
|
return StartResult{}, false, ErrStartConflict
|
|
}
|
|
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
|
|
if err != nil {
|
|
return StartResult{}, false, err
|
|
}
|
|
result.Tasks = append(result.Tasks, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return StartResult{}, false, err
|
|
}
|
|
if len(result.Tasks) == 0 {
|
|
return StartResult{}, false, nil
|
|
}
|
|
if len(result.Tasks) != len(items) {
|
|
return StartResult{}, false, ErrStartConflict
|
|
}
|
|
for i := range items {
|
|
if result.Tasks[i].TaskID != items[i].TaskID || result.Tasks[i].TaskVersion-1 != items[i].ExpectedTaskVersion {
|
|
return StartResult{}, false, ErrStartConflict
|
|
}
|
|
}
|
|
result.AuthorizedCount = len(result.Tasks)
|
|
return result, true, nil
|
|
}
|