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 }