diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 4502b3f..f12926d 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -2,10 +2,12 @@ package store import ( "context" + cryptorand "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" + "math/big" "time" "github.com/jmoiron/sqlx" @@ -49,6 +51,7 @@ type ClaimTaskInput struct { MemberID int64 HallID int64 MinimumAmount int64 + AutoDelay int DeadlineAt time.Time OrderNo string } @@ -212,8 +215,12 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder if pending > 0 { return nil, ErrPendingOrderExists } - var dailyLimit int - err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l + var levelRule struct { + DailyLimit int `db:"daily_order_limit"` + RuleJSON json.RawMessage `db:"rule_json"` + } + err = tx.GetContext(ctx, &levelRule, `SELECT COALESCE(v.daily_order_limit,0) AS daily_order_limit, + COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json FROM hg_app_member_level l LEFT JOIN hg_app_member_level_version v ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3 WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, input.TenantID, member.LevelID) if errors.Is(err, sql.ErrNoRows) { @@ -222,26 +229,53 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder if err != nil { return nil, err } - if dailyLimit > 0 { + if levelRule.DailyLimit > 0 { var todayCount int if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND created_at>=UTC_DATE() AND created_at= dailyLimit { + if todayCount >= levelRule.DailyLimit { return nil, ErrDailyLimitReached } } dispatchMode, message := "manual", "等待后台匹配订单" + matchAt := time.Now().UTC() + deadlineAt := input.DeadlineAt.UTC() + delaySeconds := 0 if member.AutoDispatch == 1 { dispatchMode, message = "auto", "正在自动匹配订单" + delayMin, delayMax := input.AutoDelay, input.AutoDelay + var levelRules map[string]any + if json.Unmarshal(levelRule.RuleJSON, &levelRules) == nil { + if value, ok := jsonInt(levelRules["auto_order_delay_min_seconds"]); ok { + delayMin = value + } + if value, ok := jsonInt(levelRules["auto_order_delay_max_seconds"]); ok { + delayMax = value + } + } + if delayMin < 0 { + delayMin = 0 + } + if delayMax < delayMin { + delayMax = delayMin + } + delaySeconds = randomDelaySeconds(delayMin, delayMax) + matchAt = matchAt.Add(time.Duration(delaySeconds) * time.Second) + completionWindow := input.DeadlineAt.UTC().Sub(time.Now().UTC()) + if completionWindow > 0 { + deadlineAt = matchAt.Add(completionWindow) + } } rules, err := json.Marshal(map[string]any{ - "source": "member_auto_claim", - "dispatch_mode": dispatchMode, - "hall_id": input.HallID, - "deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339), - "member_level_id": member.LevelID, + "source": "member_auto_claim", + "dispatch_mode": dispatchMode, + "hall_id": input.HallID, + "deadline_at": deadlineAt.Format(time.RFC3339), + "scheduled_match_at": matchAt.Format(time.RFC3339), + "dispatch_delay_seconds": delaySeconds, + "member_level_id": member.LevelID, }) if err != nil { return nil, err @@ -267,7 +301,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder } return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY", MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message, - OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339), + OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: deadlineAt.Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil } @@ -298,7 +332,16 @@ func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orde if order.Status != orderStatusMatching { return tx.Commit() } - if order.AutoDispatch != 1 || delaySeconds > 0 && time.Now().UTC().Before(order.CreatedAt.UTC().Add(time.Duration(delaySeconds)*time.Second)) { + matchAt := order.CreatedAt.UTC().Add(time.Duration(delaySeconds) * time.Second) + snapshot := map[string]any{} + if len(order.RuleSnapshot) > 0 && json.Unmarshal(order.RuleSnapshot, &snapshot) == nil { + if raw, ok := snapshot["scheduled_match_at"].(string); ok { + if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil { + matchAt = parsed.UTC() + } + } + } + if order.AutoDispatch != 1 || time.Now().UTC().Before(matchAt) { return tx.Commit() } @@ -357,10 +400,6 @@ func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orde return err } - snapshot := map[string]any{} - if len(order.RuleSnapshot) > 0 { - _ = json.Unmarshal(order.RuleSnapshot, &snapshot) - } snapshot["match_source"] = "system_auto_match" snapshot["product_id"] = product.ID snapshot["product_no"] = product.ProductNo @@ -409,6 +448,29 @@ func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orde return tx.Commit() } +func jsonInt(value any) (int, bool) { + switch typed := value.(type) { + case float64: + return int(typed), true + case json.Number: + parsed, err := typed.Int64() + return int(parsed), err == nil + default: + return 0, false + } +} + +func randomDelaySeconds(minimum, maximum int) int { + if maximum <= minimum { + return minimum + } + value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(maximum-minimum+1))) + if err != nil { + return minimum + } + return minimum + int(value.Int64()) +} + func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) { where := ` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2` args := []any{tenantID, memberID} diff --git a/internal/store/tasks_test.go b/internal/store/tasks_test.go new file mode 100644 index 0000000..f366bdb --- /dev/null +++ b/internal/store/tasks_test.go @@ -0,0 +1,15 @@ +package store + +import "testing" + +func TestRandomDelaySeconds(t *testing.T) { + for index := 0; index < 100; index++ { + value := randomDelaySeconds(900, 1200) + if value < 900 || value > 1200 { + t.Fatalf("delay out of range: %d", value) + } + } + if value := randomDelaySeconds(30, 30); value != 30 { + t.Fatalf("fixed delay = %d, want 30", value) + } +} diff --git a/internal/tasks/service.go b/internal/tasks/service.go index 9d300b3..fc7aa96 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -77,6 +77,8 @@ type Order struct { Status string `json:"status"` StatusText string `json:"status_text"` Message string `json:"message"` + ScheduledMatchAt string `json:"scheduled_match_at,omitempty"` + DispatchDelay int `json:"dispatch_delay_seconds,omitempty"` DeadlineAt string `json:"deadline_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -235,14 +237,39 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) ( } now := s.now().UTC() row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID, - HallID: hallID, MinimumAmount: policy.MinimumAmount, DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)}) + HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay, + DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)}) if err != nil { return nil, err } result := orderFromStore(*row, false) + s.scheduleAutoMatch(tenantID, memberID, row.ID, row.RuleSnapshot, policy) return &result, nil } +func (s *Service) scheduleAutoMatch(tenantID, memberID, orderID int64, raw json.RawMessage, policy taskPolicy) { + var snapshot map[string]any + if json.Unmarshal(raw, &snapshot) != nil || snapshot["dispatch_mode"] != "auto" { + return + } + matchAt, err := time.Parse(time.RFC3339, fmt.Sprint(snapshot["scheduled_match_at"])) + if err != nil { + return + } + delay := time.Until(matchAt) + if delay < 0 { + delay = 0 + } + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + <-timer.C + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount) + }() +} + func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) { if page == 0 { page = 1 @@ -368,6 +395,11 @@ func orderFromStore(row store.TaskOrder, detail bool) Order { UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount, Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message, DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} + var snapshot map[string]any + if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil { + result.ScheduledMatchAt, _ = snapshot["scheduled_match_at"].(string) + result.DispatchDelay = intValue(snapshot["dispatch_delay_seconds"]) + } if !detail { return result } diff --git a/web/src/mobile/views/ClaimView.vue b/web/src/mobile/views/ClaimView.vue index d976f84..13797f2 100644 --- a/web/src/mobile/views/ClaimView.vue +++ b/web/src/mobile/views/ClaimView.vue @@ -58,7 +58,7 @@ ✓{{ step }} - +