feat: 完善自动与手动派单流程

This commit is contained in:
richjohnrambo
2026-10-13 22:13:47 +08:00
parent 7eedbde4ab
commit dedacfcd9a
4 changed files with 128 additions and 29 deletions
+92 -19
View File
@@ -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 != "" {
+6 -2
View File
@@ -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:
+16 -1
View File
@@ -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)
+14 -7
View File
@@ -105,18 +105,18 @@
</section>
<section v-if="canSubmit" class="m-order-submit-panel">
<div>
<strong>任务已匹配成功</strong>
<p>请确认任务信息,提交后佣金将自动结算到可用余额。</p>
<strong>大额订单已匹配</strong>
<p>请确认订单信息。提交时将校验可用余额,余额不足需先完成储入。</p>
</div>
<button type="button" :disabled="submitting" @click="showSubmitConfirm = true">立即提交</button>
<button type="button" :disabled="submitting" @click="showSubmitConfirm = true">提交确认</button>
</section>
</div>
</AsyncState>
<n-modal v-model:show="showSubmitConfirm" :mask-closable="!submitting">
<section class="m-order-submit-confirm">
<h2>确认提交任务</h2>
<p>提交后系统将自动完成订单并发放佣金,请确认订单信息无误。</p>
<h2>确认提交订单</h2>
<p>系统将校验账户余额并完成订单;余额不足时需要先前往储入。</p>
<dl>
<div><dt>记录编号</dt><dd>{{ detail?.order_no || '--' }}</dd></div>
<div><dt>任务商品</dt><dd>{{ detail?.product?.name || '--' }}</dd></div>
@@ -124,7 +124,7 @@
</dl>
<div class="m-order-submit-actions">
<button type="button" :disabled="submitting" @click="showSubmitConfirm = false">暂不提交</button>
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '立即提交' }}</button>
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '确认提交' }}</button>
</div>
</section>
</n-modal>
@@ -133,13 +133,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { ImageOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
const route = useRoute();
const router = useRouter();
const isDetail = computed(() => Boolean(route.params.id));
const loading = ref(true);
const error = ref('');
@@ -226,6 +227,12 @@
window.$message.success('提交成功,佣金已自动入账');
await loadDetail();
} catch (err) {
if (err instanceof MobileApiError && err.reason === 'INSUFFICIENT_BALANCE') {
showSubmitConfirm.value = false;
window.$message.warning('账户余额不足,请先完成储入');
await router.push('/app/recharge');
return;
}
window.$message.error(errorText(err));
if (err instanceof MobileApiError && err.reason === 'ORDER_EXPIRED') {
showSubmitConfirm.value = false;