From dedacfcd9a2f1738d00b4be6145a6424df315222 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 13 Oct 2026 22:13:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=B8=8E=E6=89=8B=E5=8A=A8=E6=B4=BE=E5=8D=95=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/tasks.go | 111 +++++++++++++++++++++++----- internal/tasks/service.go | 8 +- internal/tasks/service_test.go | 17 ++++- web/src/mobile/views/OrdersView.vue | 21 ++++-- 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 64cafbd..0b1ca0e 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -40,6 +40,7 @@ type TaskHall struct { type TaskEligibility struct { MemberLevelID int64 `db:"member_level_id"` MemberStatus int `db:"member_status"` + AutoDispatch int `db:"auto_dispatch"` HallEligible bool `db:"hall_eligible"` AvailableAmount int64 `db:"available_amount"` WalletStatus int `db:"wallet_status"` @@ -139,7 +140,7 @@ func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]Task func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) { var row TaskEligibility - err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status, + err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status,m.auto_dispatch, EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible, @@ -204,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder if wallet.Status != 1 { return nil, ErrAccountRestricted } - if wallet.AvailableAmount < input.MinimumAmount { + if member.AutoDispatch == 1 && wallet.AvailableAmount < input.MinimumAmount { return nil, ErrInsufficientBalance } var pending int64 @@ -416,11 +417,13 @@ func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orde return err } commissionAmount := product.PriceAmount * int64(levelRule.CommissionRate) / 10000 - afterAvailable := account.AvailableAmount - product.PriceAmount - afterFrozen := account.FrozenAmount + product.PriceAmount - walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1, + holdAvailable := account.AvailableAmount - product.PriceAmount + holdFrozen := account.FrozenAmount + product.PriceAmount + settledAvailable := account.AvailableAmount + commissionAmount + now := time.Now().UTC() + walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,version=version+1, updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=? AND available_amount=? AND frozen_amount=?`, - afterAvailable, afterFrozen, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount) + settledAvailable, account.FrozenAmount, commissionAmount, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount) if err != nil { return err } @@ -428,21 +431,47 @@ func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orde return ErrConcurrentUpdate } if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMA", orderID), - "task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, afterAvailable, + "task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, holdAvailable, "match:available", "自动派单扣除可用余额"); err != nil { return err } if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMF", orderID), - "task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, afterFrozen, + "task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, holdFrozen, "match:frozen", "自动派单冻结订单本金"); err != nil { return err } + if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSF", orderID), + "task_principal_release", order.OrderNo, "frozen", 2, product.PriceAmount, holdFrozen, account.FrozenAmount, + "settle:frozen", "自动订单完成释放冻结本金"); err != nil { + return err + } + if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSA", orderID), + "task_principal_return", order.OrderNo, "available", 1, product.PriceAmount, holdAvailable, account.AvailableAmount, + "settle:available", "自动订单完成返回本金"); err != nil { + return err + } + submissionNo := fmt.Sprintf("TSAUTO%d", orderID) + commissionLedgerID, err := insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSC", orderID), + "commission", submissionNo, "available", 1, commissionAmount, account.AvailableAmount, settledAvailable, + "settle:commission", "自动订单完成返佣") + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission + (tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text, + evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at, + review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,'',JSON_ARRAY(),4,?,10000,0,?,'AUTO_MATCHED','自动派单完成',2,?,?,0,0,?,?)`, + tenantID, submissionNo, orderID, 0, memberID, order.MemberLevelID, commissionAmount, now, + commissionLedgerID, now, now, now); err != nil { + return err + } result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET product_id=?,product_name=?,quantity=1, unit_price_amount=?,total_amount=?,commission_amount=?,currency=?,rule_snapshot=?,message=?, - order_status=2,freeze_status=1,matched_at=UTC_TIMESTAMP(3),updated_at=UTC_TIMESTAMP(3) + order_status=4,freeze_status=1,matched_at=?,completed_at=?,updated_at=? WHERE tenant_id=? AND member_id=? AND id=? AND order_type=2 AND order_status=1`, product.ID, product.Name, product.PriceAmount, product.PriceAmount, commissionAmount, product.Currency, rules, - "系统已自动匹配订单,订单本金已扣除,请在截止时间前完成", tenantID, memberID, orderID) + "系统已自动匹配并完成订单,佣金已入账", now, now, now, tenantID, memberID, orderID) if err != nil { return err } @@ -556,15 +585,16 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas } defer tx.Rollback() var order struct { - OrderNo string `db:"order_no"` - TaskID int64 `db:"task_id"` - MemberLevelID int64 `db:"member_level_id"` - TotalAmount int64 `db:"total_amount"` - CommissionAmount int64 `db:"commission_amount"` - OrderStatus int `db:"order_status"` - DeadlineAt string `db:"deadline_at"` + OrderNo string `db:"order_no"` + TaskID int64 `db:"task_id"` + MemberLevelID int64 `db:"member_level_id"` + TotalAmount int64 `db:"total_amount"` + CommissionAmount int64 `db:"commission_amount"` + OrderStatus int `db:"order_status"` + DeadlineAt string `db:"deadline_at"` + RuleSnapshot json.RawMessage `db:"rule_snapshot"` } - err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status, + err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status,rule_snapshot, COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.OrderID) @@ -592,7 +622,50 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas if err != nil { return nil, err } - if account.Status != 1 || account.FrozenAmount < order.TotalAmount { + if account.Status != 1 { + return nil, ErrAccountRestricted + } + snapshot := map[string]any{} + _ = json.Unmarshal(order.RuleSnapshot, &snapshot) + dispatchMode, _ := snapshot["dispatch_mode"].(string) + paymentStatus, _ := snapshot["manual_payment_status"].(string) + if dispatchMode == "manual" && paymentStatus != "paid" { + if account.AvailableAmount < order.TotalAmount { + return nil, ErrInsufficientBalance + } + holdAvailable, holdFrozen := account.AvailableAmount-order.TotalAmount, account.FrozenAmount+order.TotalAmount + walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?, + version=version+1,updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=?`, holdAvailable, + holdFrozen, input.TenantID, account.ID, account.Version) + if updateErr != nil { + return nil, updateErr + } + if affected, _ := walletResult.RowsAffected(); affected != 1 { + return nil, ErrConcurrentUpdate + } + if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID, + fmt.Sprintf("WL%dMA", input.OrderID), "task_principal_hold", order.OrderNo, "available", 2, + order.TotalAmount, account.AvailableAmount, holdAvailable, "match:available", "会员确认手动订单扣除可用余额"); updateErr != nil { + return nil, updateErr + } + if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID, + fmt.Sprintf("WL%dMF", input.OrderID), "task_principal_hold", order.OrderNo, "frozen", 1, + order.TotalAmount, account.FrozenAmount, holdFrozen, "match:frozen", "会员确认手动订单冻结本金"); updateErr != nil { + return nil, updateErr + } + snapshot["manual_payment_status"], snapshot["payment_confirmed_by"] = "paid", "member" + snapshot["payment_confirmed_at"] = time.Now().UTC().Format(time.RFC3339) + rules, marshalErr := json.Marshal(snapshot) + if marshalErr != nil { + return nil, marshalErr + } + if _, updateErr = tx.ExecContext(ctx, `UPDATE hg_trade_order SET rule_snapshot=?,message='会员已确认付款,正在提交订单',updated_at=UTC_TIMESTAMP(3) + WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, rules, input.TenantID, input.OrderID, input.MemberID); updateErr != nil { + return nil, updateErr + } + account.AvailableAmount, account.FrozenAmount, account.Version = holdAvailable, holdFrozen, account.Version+1 + } + if account.FrozenAmount < order.TotalAmount { return nil, ErrAccountRestricted } if order.DeadlineAt != "" { diff --git a/internal/tasks/service.go b/internal/tasks/service.go index 7b8c08c..5060fcb 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -206,7 +206,7 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅") case row.WalletStatus != 1: result.reject("ACCOUNT_RESTRICTED", "账户状态受限") - case row.AvailableAmount < policy.MinimumAmount: + case row.AutoDispatch == 1 && row.AvailableAmount < policy.MinimumAmount: result.reject("INSUFFICIENT_BALANCE", "可用余额不足") case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit: result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限") @@ -443,7 +443,11 @@ func orderTimeline(row store.TaskOrder) []TimelineEntry { func allowedActions(row store.TaskOrder) []string { switch row.OrderStatus { case 2: - return []string{"submit", "contact_support"} + var snapshot map[string]any + if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" { + return []string{"submit", "contact_support"} + } + return []string{"contact_support"} case 3, 5, 6, 8: return []string{"contact_support"} default: diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go index 44bd70f..260e366 100644 --- a/internal/tasks/service_test.go +++ b/internal/tasks/service_test.go @@ -64,7 +64,7 @@ func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ( func TestEligibilityAppliesPublishedPolicy(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), - eligibility: &store.TaskEligibility{MemberStatus: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} + eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} service := New(repository) service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) } @@ -77,6 +77,21 @@ func TestEligibilityAppliesPublishedPolicy(t *testing.T) { } } +func TestEligibilityAllowsManualDispatchBelowMinimumBalance(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), + eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}} + service := New(repository) + service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) } + + result, err := service.Eligibility(context.Background(), 0, 10, 1) + if err != nil { + t.Fatal(err) + } + if !result.Eligible { + t.Fatalf("manual dispatch should ignore the automatic minimum balance gate: %#v", result) + } +} + func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{}`)} service := New(repository) diff --git a/web/src/mobile/views/OrdersView.vue b/web/src/mobile/views/OrdersView.vue index 1b489ab..f2e9c90 100644 --- a/web/src/mobile/views/OrdersView.vue +++ b/web/src/mobile/views/OrdersView.vue @@ -105,18 +105,18 @@
- 任务已匹配成功 -

请确认任务信息,提交后佣金将自动结算到可用余额。

+ 大额订单已匹配 +

请确认订单信息。提交时将校验可用余额,余额不足需先完成储入。

- +
-

确认提交任务

-

提交后系统将自动完成订单并发放佣金,请确认订单信息无误。

+

确认提交订单

+

系统将校验账户余额并完成订单;余额不足时需要先前往储入。

记录编号
{{ detail?.order_no || '--' }}
任务商品
{{ detail?.product?.name || '--' }}
@@ -124,7 +124,7 @@
- +
@@ -133,13 +133,14 @@