package tasks import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strconv" "strings" "time" "git.118im.com/im_team/im_workbench/internal/store" ) const ( defaultPageSize = 20 maxPageSize = 100 defaultDeadlineMinute = 15 ) var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60) type Repository interface { CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) TaskStats(context.Context, int64, int64) (*store.TaskStats, error) SubmitTask(context.Context, store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) } type Service struct { repository Repository now func() time.Time } type Hall struct { ID int64 `json:"id"` Code string `json:"code"` Name string `json:"name"` IconURL string `json:"icon_url"` Summary string `json:"summary"` CommissionRate int `json:"commission_rate"` RateUnit string `json:"rate_unit"` ClientRoute string `json:"client_route"` } type Eligibility struct { Eligible bool `json:"eligible"` Reason string `json:"reason"` Message string `json:"message"` AvailableAmount int64 `json:"available_amount"` MinimumAmount int64 `json:"minimum_amount"` TodayCount int `json:"today_count"` DailyLimit int `json:"daily_limit"` PendingOrderID int64 `json:"pending_order_id"` BusinessStartAt string `json:"business_start_at"` BusinessEndAt string `json:"business_end_at"` } type Order struct { ID int64 `json:"id"` OrderNo string `json:"order_no"` TaskID int64 `json:"task_id"` Product Product `json:"product"` Quantity int `json:"quantity"` UnitPriceAmount int64 `json:"unit_price_amount"` TotalAmount int64 `json:"total_amount"` CommissionAmount int64 `json:"commission_amount"` Currency string `json:"currency"` Status string `json:"status"` StatusText string `json:"status_text"` Message string `json:"message"` DeadlineAt string `json:"deadline_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Task *TaskDetail `json:"task,omitempty"` RuleSnapshot json.RawMessage `json:"rule_snapshot,omitempty"` Submission *Submission `json:"submission,omitempty"` Timeline []TimelineEntry `json:"timeline,omitempty"` Ledgers []map[string]any `json:"ledgers,omitempty"` AllowedActions []string `json:"allowed_actions,omitempty"` } type Product struct { ID int64 `json:"id"` Name string `json:"name"` CoverURL string `json:"cover_url"` } type TaskDetail struct { Title string `json:"title"` Description string `json:"description"` DetailHTML string `json:"detail_html"` } type Submission struct { SubmissionNo string `json:"submission_no"` Status string `json:"status"` StatusText string `json:"status_text"` EvidenceText string `json:"evidence_text"` EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"` ReviewReasonCode string `json:"review_reason_code"` ReviewReason string `json:"review_reason"` AllowResubmit bool `json:"allow_resubmit"` SubmittedAt string `json:"submitted_at"` ReviewedAt string `json:"reviewed_at"` SettledAt string `json:"settled_at"` } type TimelineEntry struct { Status string `json:"status"` Text string `json:"text"` At string `json:"at"` } type OrderPage struct { Items []Order `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` } type Stats struct { AvailableAmount int64 `json:"available_amount"` FrozenAmount int64 `json:"frozen_amount"` TotalAssetAmount int64 `json:"total_asset_amount"` TodayCommissionAmount int64 `json:"today_commission_amount"` YesterdayCommissionAmount int64 `json:"yesterday_commission_amount"` TodayClaimCount int `json:"today_claim_count"` TodayCompletedCount int `json:"today_completed_count"` } type SubmitInput struct { EvidenceText string `json:"evidence_text"` EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"` } type SubmitResult struct { SubmissionNo string `json:"submission_no"` Status string `json:"status"` SubmittedAt string `json:"submitted_at"` OrderStatus string `json:"order_status"` } type taskPolicy struct { Enabled bool MinimumAmount int64 Start string End string Deadline time.Duration } func New(repository Repository) *Service { return &Service{repository: repository, now: time.Now} } func (s *Service) Halls(ctx context.Context, tenantID, memberID int64) ([]Hall, error) { rows, err := s.repository.TaskHalls(ctx, tenantID, memberID) if err != nil { return nil, err } items := make([]Hall, 0, len(rows)) for _, row := range rows { items = append(items, Hall{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL, Summary: row.Summary, CommissionRate: row.CommissionRate, RateUnit: row.RateUnit, ClientRoute: row.ClientRoute}) } return items, nil } func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID int64) (*Eligibility, error) { if hallID <= 0 { return nil, ErrHallIDInvalid } policy, err := s.policy(ctx, tenantID) if err != nil { return nil, err } row, err := s.repository.TaskEligibility(ctx, tenantID, memberID, hallID) if err != nil { return nil, err } result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount, TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID, BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"} switch { case !policy.Enabled: result.reject("TASK_DISABLED", "任务功能未开启") case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End): result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间") case row.MemberStatus != 1: result.reject("ACCOUNT_RESTRICTED", "账户状态受限") case !row.HallEligible: result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅") case row.WalletStatus != 1: result.reject("ACCOUNT_RESTRICTED", "账户状态受限") case row.AvailableAmount < policy.MinimumAmount: result.reject("INSUFFICIENT_BALANCE", "可用余额不足") case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit: result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限") case row.PendingOrderID > 0: result.reject("PENDING_ORDER_EXISTS", "存在未完成订单") } return result, nil } func (e *Eligibility) reject(reason, message string) { e.Eligible = false e.Reason = reason e.Message = message } func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (*Order, error) { if hallID <= 0 { return nil, ErrHallIDInvalid } policy, err := s.policy(ctx, tenantID) if err != nil { return nil, err } if !policy.Enabled { return nil, ErrTaskDisabled } if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) { return nil, ErrOutsideBusinessHour } 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)}) if err != nil { return nil, err } result := orderFromStore(*row, false) return &result, nil } func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) { if page == 0 { page = 1 } if pageSize == 0 { pageSize = defaultPageSize } if page < 1 { return nil, ErrPageInvalid } if pageSize < 1 || pageSize > maxPageSize { return nil, ErrPageSizeInvalid } statusCode, ok := orderStatusCode(status) if !ok { return nil, ErrStatusInvalid } rows, err := s.repository.TaskOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize) if err != nil { return nil, err } items := make([]Order, 0, len(rows.Items)) for _, row := range rows.Items { items = append(items, orderFromStore(row, false)) } return &OrderPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil } func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64) (*Order, error) { if orderID <= 0 { return nil, ErrOrderIDInvalid } row, err := s.repository.TaskOrder(ctx, tenantID, memberID, orderID) if err != nil { return nil, err } ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo}) if err != nil { return nil, err } result := orderFromStore(*row, true) result.Ledgers = ledgers return &result, nil } func (s *Service) Stats(ctx context.Context, tenantID, memberID int64) (*Stats, error) { row, err := s.repository.TaskStats(ctx, tenantID, memberID) if err != nil { return nil, err } return &Stats{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount, TotalAssetAmount: row.AvailableAmount + row.FrozenAmount, TodayCommissionAmount: row.TodayCommissionAmount, YesterdayCommissionAmount: row.YesterdayCommissionAmount, TodayClaimCount: row.TodayClaimCount, TodayCompletedCount: row.TodayCompletedCount}, nil } func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64, input SubmitInput) (*SubmitResult, error) { if orderID <= 0 { return nil, ErrOrderIDInvalid } input.EvidenceText = strings.TrimSpace(input.EvidenceText) if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 || (input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) { return nil, ErrEvidenceInvalid } now := s.now().UTC() row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID, OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText, AttachmentIDs: input.EvidenceAttachmentIDs}) if err != nil { return nil, err } return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing", SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil } func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error) { raw, err := s.repository.CurrentSiteConfig(ctx, tenantID) if err != nil { return taskPolicy{}, err } var config map[string]any if json.Unmarshal(raw, &config) != nil { config = map[string]any{} } minutes := intValue(findValue(config, "task_deadline_minutes", "order_deadline_minutes", "taskOrderDeadlineMinutes")) if minutes <= 0 || minutes > 24*60 { minutes = defaultDeadlineMinute } start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00") end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59") return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")), MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))), Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil } func orderFromStore(row store.TaskOrder, detail bool) Order { status := orderStatusName(row.OrderStatus) result := Order{ID: row.ID, OrderNo: row.OrderNo, TaskID: row.TaskID, Product: Product{ID: row.ProductID, Name: row.ProductName, CoverURL: row.CoverURL}, Quantity: row.Quantity, 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} if !detail { return result } result.Task = &TaskDetail{Title: row.TaskTitle, Description: row.TaskDescription, DetailHTML: row.TaskDetailHTML} result.RuleSnapshot = row.RuleSnapshot result.Timeline = orderTimeline(row) result.AllowedActions = allowedActions(row) if row.SubmissionNo != "" { ids := make([]int64, 0) _ = json.Unmarshal(row.EvidenceIDs, &ids) result.Submission = &Submission{SubmissionNo: row.SubmissionNo, Status: submissionStatusName(row.SubmissionStatus), StatusText: submissionStatusText(row.SubmissionStatus), EvidenceText: row.EvidenceText, EvidenceAttachmentIDs: ids, ReviewReasonCode: row.ReviewReasonCode, ReviewReason: row.ReviewReason, AllowResubmit: row.AllowResubmit == 1, SubmittedAt: row.SubmittedAt, ReviewedAt: row.ReviewedAt, SettledAt: row.SettledAt} } return result } func orderTimeline(row store.TaskOrder) []TimelineEntry { items := []TimelineEntry{{Status: "created", Text: "订单已创建", At: row.CreatedAt}} if row.MatchedAt != "" { items = append(items, TimelineEntry{Status: "matched", Text: "任务匹配成功", At: row.MatchedAt}) } if row.SubmittedAt != "" { items = append(items, TimelineEntry{Status: "submitted", Text: "任务凭证已提交", At: row.SubmittedAt}) } if row.ReviewedAt != "" { items = append(items, TimelineEntry{Status: submissionStatusName(row.SubmissionStatus), Text: submissionStatusText(row.SubmissionStatus), At: row.ReviewedAt}) } if row.SettledAt != "" { items = append(items, TimelineEntry{Status: "settled", Text: "佣金已结算", At: row.SettledAt}) } if row.CompletedAt != "" { items = append(items, TimelineEntry{Status: "completed", Text: "订单已完成", At: row.CompletedAt}) } return items } func allowedActions(row store.TaskOrder) []string { switch row.OrderStatus { case 2: return []string{"submit", "contact_support"} case 3, 5, 6, 8: return []string{"contact_support"} default: return []string{} } } func orderStatusCode(value string) (int, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": return 0, true case "matching": return 1, true case "pending": return 2, true case "processing": return 3, true case "completed": return 4, true case "expired": return 5, true case "frozen": return 6, true case "cancelled": return 7, true case "failed": return 8, true default: return 0, false } } func orderStatusName(status int) string { names := map[int]string{1: "matching", 2: "pending", 3: "processing", 4: "completed", 5: "expired", 6: "frozen", 7: "cancelled", 8: "failed"} if value, ok := names[status]; ok { return value } return "unknown" } func orderStatusText(status int) string { names := map[int]string{1: "匹配中", 2: "待处理", 3: "审核中", 4: "已完成", 5: "已超时", 6: "已冻结", 7: "已取消", 8: "失败"} if value, ok := names[status]; ok { return value } return "未知状态" } func submissionStatusName(status int) string { names := map[int]string{1: "reviewing", 2: "approved", 3: "rejected", 4: "settled", 5: "reversed"} return names[status] } func submissionStatusText(status int) string { names := map[int]string{1: "待审核", 2: "已通过", 3: "已驳回", 4: "已结算", 5: "已冲正"} return names[status] } func withinBusinessHours(now time.Time, start, end string) bool { current := now.Hour()*3600 + now.Minute()*60 + now.Second() startSecond := parseClock(start) endSecond := parseClock(end) if startSecond <= endSecond { return current >= startSecond && current <= endSecond } return current >= startSecond || current <= endSecond } func parseClock(value string) int { parsed, err := time.Parse("15:04:05", value) if err != nil { return 0 } return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second() } func clockValue(value any, fallback string) string { raw, ok := value.(string) if !ok { return fallback } raw = strings.TrimSpace(raw) for _, layout := range []string{"15:04:05", "15:04"} { if parsed, err := time.Parse(layout, raw); err == nil { return parsed.Format("15:04:05") } } return fallback } func findValue(config map[string]any, keys ...string) any { for _, key := range keys { for candidate, value := range config { if strings.EqualFold(candidate, key) { return value } } } for _, value := range config { if nested, ok := value.(map[string]any); ok { if found := findValue(nested, keys...); found != nil { return found } } } return nil } func boolValue(value any) bool { switch typed := value.(type) { case bool: return typed case float64: return typed == 1 case string: return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") default: return false } } func intValue(value any) int { return int(int64Value(value)) } func int64Value(value any) int64 { switch typed := value.(type) { case float64: return int64(typed) case json.Number: parsed, _ := typed.Int64() return parsed case string: parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) return parsed default: return 0 } } func max64(left, right int64) int64 { if left > right { return left } return right } func validUniqueIDs(ids []int64) bool { seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { if id <= 0 { return false } if _, exists := seen[id]; exists { return false } seen[id] = struct{}{} } return true } func newNumber(prefix string, now time.Time) string { bytes := make([]byte, 4) if _, err := rand.Read(bytes); err != nil { return fmt.Sprintf("%s%s%06d", prefix, now.Format("20060102150405"), now.UnixNano()%1_000_000) } return prefix + now.Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes)) }