Files
olons_server/internal/tasks/service.go
T

615 lines
20 KiB
Go

package tasks
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.118im.com/im_team/olons_server/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)
AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) 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"`
DispatchMode string `json:"dispatch_mode,omitempty"`
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"`
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
AutoDelay int
AutoMinAmount int64
AutoMaxAmount 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, 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
}
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
}
if row.OrderStatus == 1 {
policy, policyErr := s.policy(ctx, tenantID)
if policyErr != nil {
return nil, policyErr
}
if policy.Enabled {
err = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
}
if err != nil && !errors.Is(err, store.ErrNoTaskAvailable) {
return nil, err
}
if policy.Enabled && err == nil {
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 ||
!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, LedgerNo: newNumber("WL", now),
IdempotencyKey: fmt.Sprintf("task:%d:auto-settle", orderID), SettledAt: now})
if err != nil {
return nil, err
}
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "settled",
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"))),
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
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}
var snapshot map[string]any
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
result.DispatchMode, _ = snapshot["dispatch_mode"].(string)
result.ScheduledMatchAt, _ = snapshot["scheduled_match_at"].(string)
result.DispatchDelay = intValue(snapshot["dispatch_delay_seconds"])
}
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))
}