From ac152d2f10e2896c60200cc2bcab4957aa9af199 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 18 Oct 2026 20:17:17 +0800 Subject: [PATCH] feat: adjust manual and automatic order matching --- server/internal/logic/sys/trade_order.go | 176 ++++++++++++++++-- server/internal/logic/sys/trade_order_test.go | 37 ++++ .../internal/model/input/sysin/app_member.go | 3 - server/internal/model/input/sysin/trade.go | 1 + ...1018_001_wallet_allow_negative_balance.sql | 17 ++ web/src/views/app-member/list/index.vue | 5 +- web/src/views/trade/order/index.vue | 4 +- 7 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 server/internal/logic/sys/trade_order_test.go create mode 100644 server/storage/data/migrations/20261018_001_wallet_allow_negative_balance.sql diff --git a/server/internal/logic/sys/trade_order.go b/server/internal/logic/sys/trade_order.go index ed805aa..5850a79 100644 --- a/server/internal/logic/sys/trade_order.go +++ b/server/internal/logic/sys/trade_order.go @@ -36,12 +36,13 @@ func (s *sSysTradeOrder) listModel(ctx context.Context) *gdb.Model { canReview := fmt.Sprintf("EXISTS(SELECT 1 FROM %s ts WHERE ts.%s=%s AND ts.%s=%d) can_review", dao.TaskSubmission.Table(), sc.TradeOrderId, col("o", oc.Id), sc.SubmissionStatus, consts.TaskSubmissionPending) dispatchMode := fmt.Sprintf("CASE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.dispatch_mode')),'') WHEN 'auto' THEN %d WHEN 'manual' THEN %d ELSE %s END auto_dispatch", col("o", oc.RuleSnapshot), consts.TradeAutoDispatchEnabled, consts.TradeAutoDispatchDisabled, col("m", mc.AutoDispatch)) paymentStatus := fmt.Sprintf("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.manual_payment_status')),'') payment_status", col("o", oc.RuleSnapshot)) + scheduledMatchAt := fmt.Sprintf("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.scheduled_match_at')),'') scheduled_match_at", col("o", oc.RuleSnapshot)) return s.model(ctx).As("o"). LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("o", oc.MemberId)). LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("o", oc.MemberId)). LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("o", oc.MemberId)+" AND "+col("w", wc.Currency)+"='CNY'"). LeftJoin(dao.ApprovalRequest.Table()+" a", col("a", ac.Id)+"="+col("o", oc.ApprovalRequestId)). - Fields(col("o", oc.Id), col("o", oc.OrderNo), col("o", oc.MemberId), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), dispatchMode, paymentStatus, fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), todayCount, canReview, col("o", oc.ProductName), col("o", oc.OrderType), col("o", oc.Quantity), col("o", oc.UnitPriceAmount), col("o", oc.TotalAmount), col("o", oc.CommissionAmount), col("o", oc.Currency), col("o", oc.Message), col("o", oc.OrderStatus), col("o", oc.FreezeStatus), col("o", oc.ApprovalRequestId), fmt.Sprintf("COALESCE(%s,0) approval_status", col("a", ac.ApprovalStatus)), col("o", oc.MatchedAt), col("o", oc.CompletedAt), col("o", oc.UnfrozenAt), col("o", oc.CreatedAt)) + Fields(col("o", oc.Id), col("o", oc.OrderNo), col("o", oc.MemberId), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), dispatchMode, paymentStatus, scheduledMatchAt, fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), todayCount, canReview, col("o", oc.ProductName), col("o", oc.OrderType), col("o", oc.Quantity), col("o", oc.UnitPriceAmount), col("o", oc.TotalAmount), col("o", oc.CommissionAmount), col("o", oc.Currency), col("o", oc.Message), col("o", oc.OrderStatus), col("o", oc.FreezeStatus), col("o", oc.ApprovalRequestId), fmt.Sprintf("COALESCE(%s,0) approval_status", col("a", ac.ApprovalStatus)), col("o", oc.MatchedAt), col("o", oc.CompletedAt), col("o", oc.UnfrozenAt), col("o", oc.CreatedAt)) } func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) (list []*sysin.TradeOrderListModel, total int, err error) { @@ -75,7 +76,7 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) return } for _, row := range list { - row.CanMatch = row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderMatching && row.ApprovalRequestId == 0 + row.CanMatch = canAdminMatchOrder(row.AutoDispatch, row.OrderStatus, row.ApprovalRequestId, row.ScheduledMatchAt, time.Now()) manualPending := row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderPending && row.FreezeStatus == consts.TradeFreezeNormal row.CanChangeAmount = manualPending row.CanForcePay = manualPending @@ -86,6 +87,17 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) return } +func canAdminMatchOrder(dispatchMode, orderStatus int, approvalRequestId uint64, scheduledMatchAt string, now time.Time) bool { + if orderStatus != consts.TradeOrderMatching || approvalRequestId > 0 { + return false + } + if dispatchMode != consts.TradeAutoDispatchEnabled || scheduledMatchAt == "" { + return true + } + matchAt, err := time.Parse(time.RFC3339, scheduledMatchAt) + return err == nil && now.UTC().Before(matchAt.UTC()) +} + func (s *sSysTradeOrder) View(ctx context.Context, in *sysin.TradeOrderViewInp) (res *sysin.TradeOrderViewModel, err error) { var base *sysin.TradeOrderListModel if err = s.listModel(ctx).Where(col("o", dao.TradeOrder.Columns().Id), in.Id).Scan(&base); err != nil { @@ -187,8 +199,20 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp dispatchMode = "manual" } } - if dispatchMode != "manual" { - return gerror.New("自动派单订单由系统匹配,不能人工匹配") + if dispatchMode == "" { + dispatchMode = "auto" + } + if dispatchMode == "auto" && order.RuleSnapshot != nil { + scheduledMatchAt := order.RuleSnapshot.Get("scheduled_match_at").String() + if scheduledMatchAt != "" { + matchAt, parseErr := time.Parse(time.RFC3339, scheduledMatchAt) + if parseErr != nil { + return gerror.New("自动派单时间格式不正确,请刷新订单后重试") + } + if !time.Now().UTC().Before(matchAt.UTC()) { + return gerror.New("自动派单已到期,订单正在由系统自动匹配") + } + } } var product *entity.Product pc := dao.Product.Columns() @@ -207,7 +231,7 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp if product.StartAt != nil && product.StartAt.After(now) || product.EndAt != nil && product.EndAt.Before(now) { return gerror.New("商品当前不在可匹配时间范围内") } - if product.PriceAmount <= 0 || product.PriceAmount > math.MaxInt64/int64(in.Quantity) { + if in.Quantity == 0 || product.PriceAmount <= 0 || product.PriceAmount > math.MaxInt64/int64(in.Quantity) { return gerror.New("商品价格或数量不合法") } levelId := order.MemberLevelId @@ -236,6 +260,9 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp return gerror.New("会员等级生效版本不存在") } totalAmount := product.PriceAmount * int64(in.Quantity) + if levelVersion.CommissionRate > 0 && uint64(levelVersion.CommissionRate) > uint64(math.MaxInt64/totalAmount) { + return gerror.New("佣金规则超出可计算范围") + } commissionAmount := totalAmount * int64(levelVersion.CommissionRate) / 10000 snapshotData := g.Map{} if order.RuleSnapshot != nil { @@ -243,20 +270,25 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp snapshotData[key] = value } } - snapshotData["source"], snapshotData["dispatch_mode"] = "admin_manual_match", "manual" + snapshotData["source"], snapshotData["match_source"], snapshotData["dispatch_mode"] = "admin_manual_match", "admin_manual_match", dispatchMode snapshotData["product_id"], snapshotData["product_no"] = product.Id, product.ProductNo snapshotData["unit_price_amount"], snapshotData["quantity"] = product.PriceAmount, in.Quantity snapshotData["member_level_id"], snapshotData["level_version_id"] = levelId, levelVersion.Id - snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "pending" - snapshotData["confirmation_required"], snapshotData["deadline_at"] = true, "" + snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "settled" + snapshotData["confirmation_required"], snapshotData["deadline_at"] = false, "" + snapshotData["settled_by"], snapshotData["settled_at"] = "admin", time.Now().UTC().Format(time.RFC3339) snapshot := gjson.New(snapshotData) + commissionLedgerId, err := s.settleMatchedOrder(ctx, order, dispatchMode, totalAmount, commissionAmount, now) + if err != nil { + return err + } oc := dao.TradeOrder.Columns() data := g.Map{ oc.ProductId: product.Id, oc.ProductName: product.Name, oc.Quantity: in.Quantity, oc.UnitPriceAmount: product.PriceAmount, oc.TotalAmount: totalAmount, oc.CommissionAmount: commissionAmount, oc.Currency: product.Currency, oc.MemberLevelId: levelId, - oc.RuleSnapshot: snapshot, oc.Message: "后台已匹配大额订单,等待会员确认付款", oc.OrderStatus: consts.TradeOrderPending, - oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx), + oc.RuleSnapshot: snapshot, oc.Message: "后台已匹配并完成订单,余额及佣金已结算", oc.OrderStatus: consts.TradeOrderCompleted, + oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx), } result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderMatching).Data(data).Update() if err != nil { @@ -266,6 +298,9 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp if rows != 1 { return gerror.New("订单状态已变化,请刷新后重试") } + if err = s.insertMatchedSubmission(ctx, order, levelId, commissionAmount, commissionLedgerId, now, dispatchMode); err != nil { + return err + } return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{ Module: "trade", Action: "trade_order.match", TargetType: "trade_order", TargetId: order.Id, Before: order, After: data, ChangeJson: gjson.New(g.Map{"productId": product.Id, "quantity": in.Quantity, "totalAmount": totalAmount}), @@ -274,6 +309,107 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp }) } +func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (uint64, error) { + wc := dao.WalletAccount.Columns() + var account *entity.WalletAccount + if err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}). + Where(wc.MemberId, order.MemberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil { + return 0, gerror.Wrap(err, "读取会员钱包失败") + } + if account == nil || account.AccountStatus != 1 { + return 0, gerror.New("会员钱包不存在或状态异常") + } + if totalAmount <= 0 || account.AvailableAmount < math.MinInt64+totalAmount { + return 0, gerror.New("订单金额超出钱包处理范围") + } + if commissionAmount < 0 || account.AvailableAmount > math.MaxInt64-commissionAmount { + return 0, gerror.New("订单佣金超出钱包处理范围") + } + if dispatchMode == "auto" && account.AvailableAmount < totalAmount { + return 0, gerror.New("自动派单商品金额不能超过会员可用余额") + } + + principalAfter := account.AvailableAmount - totalAmount + settledAfter := principalAfter + commissionAmount + if dispatchMode == "auto" { + settledAfter = account.AvailableAmount + commissionAmount + if account.FrozenAmount > math.MaxInt64-totalAmount { + return 0, gerror.New("订单金额超出钱包冻结范围") + } + } + accountData := g.Map{ + wc.AvailableAmount: settledAfter, + wc.TotalIncome: gdb.Raw(wc.TotalIncome + "+" + fmt.Sprint(commissionAmount)), + wc.Version: gdb.Raw(wc.Version + "+1"), + } + if dispatchMode == "manual" { + accountData[wc.TotalExpense] = gdb.Raw(wc.TotalExpense + "+" + fmt.Sprint(totalAmount)) + } + result, err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(account.Id).Where(wc.Version, account.Version).Data(accountData).Update() + if err != nil { + return 0, gerror.Wrap(err, "结算订单余额失败") + } + if affected, _ := result.RowsAffected(); affected != 1 { + return 0, gerror.New("会员余额已变化,请刷新后重试") + } + + if dispatchMode == "auto" { + holdFrozen := account.FrozenAmount + totalAmount + entries := []struct { + code, bizType, entryType string + direction int + before, after int64 + suffix, remark string + }{ + {"AA", "task_principal_hold", "available", consts.WalletDirectionExpense, account.AvailableAmount, principalAfter, "admin-auto:available", "人工提前匹配自动单,扣除可用余额"}, + {"AF", "task_principal_hold", "frozen", consts.WalletDirectionIncome, account.FrozenAmount, holdFrozen, "admin-auto:frozen", "人工提前匹配自动单,冻结订单本金"}, + {"RF", "task_principal_release", "frozen", consts.WalletDirectionExpense, holdFrozen, account.FrozenAmount, "admin-auto:release-frozen", "自动单完成释放冻结本金"}, + {"RA", "task_principal_return", "available", consts.WalletDirectionIncome, principalAfter, account.AvailableAmount, "admin-auto:return-available", "自动单完成返回本金"}, + } + for _, entry := range entries { + if _, err = insertMatchedOrderLedger(ctx, order, account, "WL"+fmt.Sprint(order.Id)+entry.code, entry.bizType, order.OrderNo, entry.entryType, entry.direction, totalAmount, entry.before, entry.after, entry.suffix, entry.remark, now); err != nil { + return 0, err + } + } + } else { + if _, err = insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMP", order.Id), "task_principal_expense", order.OrderNo, "available", consts.WalletDirectionExpense, totalAmount, account.AvailableAmount, principalAfter, "admin-manual:principal", "手动单匹配完成扣除订单金额(允许负余额)", now); err != nil { + return 0, err + } + } + if commissionAmount == 0 { + return 0, nil + } + commissionBefore := principalAfter + if dispatchMode == "auto" { + commissionBefore = account.AvailableAmount + } + return insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMC", order.Id), "commission", fmt.Sprintf("TSADMIN%d", order.Id), "available", consts.WalletDirectionIncome, commissionAmount, commissionBefore, settledAfter, "admin-match:commission", "后台匹配订单完成返佣", now) +} + +func (s *sSysTradeOrder) insertMatchedSubmission(ctx context.Context, order *entity.TradeOrder, levelId uint64, commissionAmount int64, ledgerId uint64, now *gtime.Time, dispatchMode string) error { + sc := dao.TaskSubmission.Columns() + uid := contexts.GetUserId(ctx) + reason := "手动派单由后台匹配并直接完成" + if dispatchMode == "auto" { + reason = "自动派单到期前由后台匹配并直接完成" + } + _, err := dao.TaskSubmission.Ctx(ctx). + Fields(sc.SubmissionNo, sc.TradeOrderId, sc.TaskId, sc.MemberId, sc.MemberLevelId, sc.EvidenceText, sc.EvidenceAttachmentIds, + sc.SubmissionStatus, sc.CommissionAmount, sc.SuccessRate, sc.ReviewedBy, sc.ReviewedAt, sc.ReviewReasonCode, + sc.ReviewReason, sc.AllowResubmit, sc.LedgerId, sc.SettledAt, sc.CreatedBy, sc.UpdatedBy). + Hook(hook.SaveTenant).Data(g.Map{ + sc.SubmissionNo: fmt.Sprintf("TSADMIN%d", order.Id), sc.TradeOrderId: order.Id, sc.TaskId: order.TaskId, + sc.MemberId: order.MemberId, sc.MemberLevelId: levelId, sc.EvidenceText: "", sc.EvidenceAttachmentIds: gjson.New(g.Array{}), + sc.SubmissionStatus: consts.TaskSubmissionSettled, sc.CommissionAmount: commissionAmount, sc.SuccessRate: 10000, + sc.ReviewedBy: uid, sc.ReviewedAt: now, sc.ReviewReasonCode: "ADMIN_MATCHED", sc.ReviewReason: reason, + sc.AllowResubmit: consts.StatusDisable, sc.LedgerId: ledgerId, sc.SettledAt: now, sc.CreatedBy: uid, sc.UpdatedBy: uid, + }).Insert() + if err != nil { + return gerror.Wrap(err, "创建已结算任务记录失败") + } + return nil +} + func (s *sSysTradeOrder) UpdateAmount(ctx context.Context, in *sysin.TradeOrderUpdateAmountInp) error { amount, err := parseCents(in.Amount) if err != nil || amount <= 0 || amount < int64(in.Quantity) { @@ -477,20 +613,30 @@ func cloneOrderSnapshot(order *entity.TradeOrder) g.Map { func insertTradeMatchLedger(ctx context.Context, order *entity.TradeOrder, account *entity.WalletAccount, ledgerNo, entryType string, direction int, amount, beforeAmount, afterAmount int64, suffix, remark string) error { + _, err := insertMatchedOrderLedger(ctx, order, account, ledgerNo, "task_principal_hold", order.OrderNo, entryType, direction, amount, beforeAmount, afterAmount, suffix, remark, gtime.Now()) + return err +} + +func insertMatchedOrderLedger(ctx context.Context, order *entity.TradeOrder, account *entity.WalletAccount, + ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64, suffix, remark string, occurredAt *gtime.Time) (uint64, error) { lc := dao.WalletLedger.Columns() - _, err := dao.WalletLedger.Ctx(ctx). + result, err := dao.WalletLedger.Ctx(ctx). Fields(lc.LedgerNo, lc.AccountId, lc.MemberId, lc.BizType, lc.BizNo, lc.EntryType, lc.Direction, lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Currency, lc.IdempotencyKey, lc.Remark, lc.OperatorId, lc.OccurredAt). Hook(hook.SaveTenant). Data(g.Map{lc.LedgerNo: ledgerNo, lc.AccountId: account.Id, lc.MemberId: order.MemberId, - lc.BizType: "task_principal_hold", lc.BizNo: order.OrderNo, lc.EntryType: entryType, lc.Direction: direction, + lc.BizType: bizType, lc.BizNo: bizNo, lc.EntryType: entryType, lc.Direction: direction, lc.Amount: amount, lc.BeforeAmount: beforeAmount, lc.AfterAmount: afterAmount, lc.Currency: account.Currency, lc.IdempotencyKey: fmt.Sprintf("task:%d:%s", order.Id, suffix), lc.Remark: remark, - lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: gtime.Now()}).Insert() + lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: occurredAt}).Insert() if err != nil { - return gerror.Wrap(err, "记录订单扣款流水失败") + return 0, gerror.Wrap(err, "记录订单扣款流水失败") } - return nil + ledgerId, err := result.LastInsertId() + if err != nil { + return 0, gerror.Wrap(err, "读取订单流水ID失败") + } + return uint64(ledgerId), nil } func (s *sSysTradeOrder) Apply(ctx context.Context, in *sysin.TradeOrderApplyInp) (res *sysin.FinanceApprovalModel, err error) { diff --git a/server/internal/logic/sys/trade_order_test.go b/server/internal/logic/sys/trade_order_test.go new file mode 100644 index 0000000..07dde87 --- /dev/null +++ b/server/internal/logic/sys/trade_order_test.go @@ -0,0 +1,37 @@ +package sys + +import ( + "testing" + "time" + + "hotgo/internal/consts" +) + +func TestCanAdminMatchOrder(t *testing.T) { + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + dispatchMode int + orderStatus int + approvalRequestId uint64 + scheduledMatchAt string + want bool + }{ + {name: "manual matching order", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderMatching, want: true}, + {name: "auto before schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Add(time.Minute).Format(time.RFC3339), want: true}, + {name: "auto at schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Format(time.RFC3339), want: false}, + {name: "auto after schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Add(-time.Minute).Format(time.RFC3339), want: false}, + {name: "legacy auto without schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, want: true}, + {name: "invalid schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: "invalid", want: false}, + {name: "already completed", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderCompleted, want: false}, + {name: "approval pending", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderMatching, approvalRequestId: 9, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canAdminMatchOrder(tt.dispatchMode, tt.orderStatus, tt.approvalRequestId, tt.scheduledMatchAt, now) + if got != tt.want { + t.Fatalf("canAdminMatchOrder() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/server/internal/model/input/sysin/app_member.go b/server/internal/model/input/sysin/app_member.go index 6893389..d6f97ec 100644 --- a/server/internal/model/input/sysin/app_member.go +++ b/server/internal/model/input/sysin/app_member.go @@ -101,9 +101,6 @@ func (in *AppMemberEditInp) Filter(ctx context.Context) error { if in.TradePassword != "" && len(in.TradePassword) < 6 { return gerror.New("交易密码不能少于6位") } - if in.AvailableAmount != nil && *in.AvailableAmount < 0 { - return gerror.New("账户余额不能小于0") - } if !dict.HasOptionKey(consts.AppMemberRiskStatusOptions, in.RiskStatus) { return gerror.New("风控状态不合法") } diff --git a/server/internal/model/input/sysin/trade.go b/server/internal/model/input/sysin/trade.go index 490acd9..da7a915 100644 --- a/server/internal/model/input/sysin/trade.go +++ b/server/internal/model/input/sysin/trade.go @@ -54,6 +54,7 @@ type TradeOrderListModel struct { CreatedAt *gtime.Time `json:"createdAt"` ApprovalStatus int `json:"approvalStatus"` PaymentStatus string `json:"paymentStatus"` + ScheduledMatchAt string `json:"scheduledMatchAt"` CanMatch bool `json:"canMatch"` CanChangeAmount bool `json:"canChangeAmount"` CanForcePay bool `json:"canForcePay"` diff --git a/server/storage/data/migrations/20261018_001_wallet_allow_negative_balance.sql b/server/storage/data/migrations/20261018_001_wallet_allow_negative_balance.sql new file mode 100644 index 0000000..b37c782 --- /dev/null +++ b/server/storage/data/migrations/20261018_001_wallet_allow_negative_balance.sql @@ -0,0 +1,17 @@ +-- 手动派单允许后台匹配后直接完成,会员可用余额可因订单扣款变为负数。 +SET @wallet_available_check_exists = ( + SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'hg_wallet_account' + AND CONSTRAINT_NAME = 'chk_wallet_available_nonnegative' + AND CONSTRAINT_TYPE = 'CHECK' +); +SET @drop_wallet_available_check = IF( + @wallet_available_check_exists > 0, + 'ALTER TABLE `hg_wallet_account` DROP CHECK `chk_wallet_available_nonnegative`', + 'SELECT 1' +); +PREPARE drop_wallet_available_check_stmt FROM @drop_wallet_available_check; +EXECUTE drop_wallet_available_check_stmt; +DEALLOCATE PREPARE drop_wallet_available_check_stmt; diff --git a/web/src/views/app-member/list/index.vue b/web/src/views/app-member/list/index.vue index 0bdb99a..1a653b6 100644 --- a/web/src/views/app-member/list/index.vue +++ b/web/src/views/app-member/list/index.vue @@ -59,7 +59,6 @@ - value !== null && Number.isFinite(Number(value)) && Number(value) >= 0 + value !== null && Number.isFinite(Number(value)) ? true - : new Error('账户余额不能为空或小于0'), + : new Error('账户余额不能为空'), trigger: ['input', 'blur'], }, password: { diff --git a/web/src/views/trade/order/index.vue b/web/src/views/trade/order/index.vue index 6f27072..a242353 100644 --- a/web/src/views/trade/order/index.vue +++ b/web/src/views/trade/order/index.vue @@ -1,6 +1,6 @@