diff --git a/server/internal/logic/sys/app_member.go b/server/internal/logic/sys/app_member.go index adfda5e..72927ef 100644 --- a/server/internal/logic/sys/app_member.go +++ b/server/internal/logic/sys/app_member.go @@ -65,7 +65,7 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l LeftJoin(dao.UrlPool.Table()+" up", col("up", uc.Id)+"="+col("ub", bc.UrlPoolId)). Fields( col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.Mobile), - col("p", pc.RealNameMask), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), + col("p", pc.RealNameCipher), col("p", pc.RealNameMask), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.ParentId), fmt.Sprintf("COALESCE(%s,'') parent_username", col("parent", mc.Username)), col("m", mc.InviteCode), col("m", mc.MemberStatus), col("m", mc.RiskStatus), col("m", mc.AutoDispatch), col("m", mc.IsTestAccount), @@ -109,6 +109,19 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l if err != nil { return nil, 0, gerror.Wrap(err, "获取会员列表失败") } + for _, row := range list { + if row.RealNameCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.RealNameCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "会员真实姓名解密失败,列表回退为脱敏值 memberId:%d err:%+v", row.Id, decryptErr) + row.RealName = row.RealNameMask + continue + } + row.RealName = plain + } else { + row.RealName = row.RealNameMask + } + } return } @@ -126,6 +139,34 @@ func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (r if res.Id == 0 { return nil, gerror.New("会员不存在") } + var profile *entity.AppMemberProfile + if err = s.profileModel(ctx).Where(dao.AppMemberProfile.Columns().MemberId, in.Id).Scan(&profile); err != nil { + return nil, gerror.Wrap(err, "读取会员实名资料失败") + } + if profile != nil { + if profile.RealNameCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, profile.RealNameCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "会员姓名解密失败,详情回退为脱敏值 memberId:%d err:%+v", in.Id, decryptErr) + res.RealName = profile.RealNameMask + } else { + res.RealName = plain + } + } else { + res.RealName = profile.RealNameMask + } + if profile.IdNoCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, profile.IdNoCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "会员证件号解密失败,详情回退为脱敏值 memberId:%d err:%+v", in.Id, decryptErr) + res.IdNo = profile.IdNoMask + } else { + res.IdNo = plain + } + } else { + res.IdNo = profile.IdNoMask + } + } res.PaymentAccountCount, _ = s.paymentModel(ctx).Where(dao.AppMemberPaymentAccount.Columns().MemberId, in.Id).Count() res.AddressCount, _ = s.addressModel(ctx).Where(dao.AppMemberAddress.Columns().MemberId, in.Id).Count() token := "tr_" + fmt.Sprint(in.Id) + " " @@ -407,10 +448,68 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym err = s.paymentModel(ctx).As("p"). LeftJoin(dao.SysAttachment.Table()+" wq", col("wq", ac.Id)+"="+col("p", c.WechatQrAttachmentId)). LeftJoin(dao.SysAttachment.Table()+" aq", col("aq", ac.Id)+"="+col("p", c.AlipayQrAttachmentId)). - Fields(col("p", c.Id), col("p", c.MemberId), col("p", c.AccountType), col("p", c.BankCode), col("p", c.BankName), col("p", c.HolderMask), col("p", c.IdentityNoMask), col("p", c.MobileMask), col("p", c.AccountNoMask), col("p", c.DigitalRmbCodeMask), col("p", c.QrAttachmentId), col("p", c.WechatQrAttachmentId), col("p", c.AlipayQrAttachmentId), fmt.Sprintf("COALESCE(%s,'') wechat_qr_url", col("wq", ac.FileUrl)), fmt.Sprintf("COALESCE(%s,'') alipay_qr_url", col("aq", ac.FileUrl)), col("p", c.VerifyStatus), col("p", c.IsDefault), col("p", c.CreatedAt), col("p", c.UpdatedAt)). + Fields(col("p", c.Id), col("p", c.MemberId), col("p", c.AccountType), col("p", c.BankCode), col("p", c.BankName), col("p", c.HolderCipher), col("p", c.IdentityNoCipher), col("p", c.MobileCipher), col("p", c.AccountNoCipher), col("p", c.DigitalRmbCodeCipher), col("p", c.HolderMask), col("p", c.IdentityNoMask), col("p", c.MobileMask), col("p", c.AccountNoMask), col("p", c.DigitalRmbCodeMask), col("p", c.QrAttachmentId), col("p", c.WechatQrAttachmentId), col("p", c.AlipayQrAttachmentId), fmt.Sprintf("COALESCE(%s,'') wechat_qr_url", col("wq", ac.FileUrl)), fmt.Sprintf("COALESCE(%s,'') alipay_qr_url", col("aq", ac.FileUrl)), col("p", c.VerifyStatus), col("p", c.IsDefault), col("p", c.CreatedAt), col("p", c.UpdatedAt)). Where(col("p", c.MemberId), in.MemberId).OrderDesc(col("p", c.IsDefault)).OrderDesc(col("p", c.Id)).Scan(&list) if err != nil { err = gerror.Wrap(err, "获取收款账户失败") + return + } + for _, row := range list { + if row.HolderCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.HolderCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收款人姓名解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.Holder = row.HolderMask + } else { + row.Holder = plain + } + } else { + row.Holder = row.HolderMask + } + if row.IdentityNoCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.IdentityNoCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收款人证件号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.IdentityNo = row.IdentityNoMask + } else { + row.IdentityNo = plain + } + } else { + row.IdentityNo = row.IdentityNoMask + } + if row.MobileCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.MobileCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收款手机号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.Mobile = row.MobileMask + } else { + row.Mobile = plain + } + } else { + row.Mobile = row.MobileMask + } + if row.AccountNoCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.AccountNoCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收款账号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.AccountNo = row.AccountNoMask + } else { + row.AccountNo = plain + } + } else { + row.AccountNo = row.AccountNoMask + } + if row.DigitalRmbCodeCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.DigitalRmbCodeCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "数字钱包编码解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.DigitalRMBCode = row.DigitalRMBCodeMask + } else { + row.DigitalRMBCode = plain + } + } else { + row.DigitalRMBCode = row.DigitalRMBCodeMask + } } return } @@ -517,9 +616,45 @@ func (s *sSysAppMember) PaymentDelete(ctx context.Context, in *sysin.AppMemberPa func (s *sSysAppMember) AddressList(ctx context.Context, in *sysin.AppMemberAddressListInp) (list []*sysin.AppMemberAddressModel, err error) { c := dao.AppMemberAddress.Columns() - err = s.addressModel(ctx).Fields(c.Id, c.MemberId, c.ReceiverMask, c.MobileMask, c.Province, c.City, c.District, c.AddressMask, c.IsDefault, c.CreatedAt, c.UpdatedAt).Where(c.MemberId, in.MemberId).OrderDesc(c.IsDefault).OrderDesc(c.Id).Scan(&list) + err = s.addressModel(ctx).Fields(c.Id, c.MemberId, c.ReceiverCipher, c.MobileCipher, c.AddressCipher, c.ReceiverMask, c.MobileMask, c.Province, c.City, c.District, c.AddressMask, c.IsDefault, c.CreatedAt, c.UpdatedAt).Where(c.MemberId, in.MemberId).OrderDesc(c.IsDefault).OrderDesc(c.Id).Scan(&list) if err != nil { err = gerror.Wrap(err, "获取会员地址失败") + return + } + for _, row := range list { + if row.ReceiverCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.ReceiverCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收货人姓名解密失败,地址信息回退为脱敏值 memberId:%d addressId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.Receiver = row.ReceiverMask + } else { + row.Receiver = plain + } + } else { + row.Receiver = row.ReceiverMask + } + if row.MobileCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.MobileCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收货手机号解密失败,地址信息回退为脱敏值 memberId:%d addressId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.Mobile = row.MobileMask + } else { + row.Mobile = plain + } + } else { + row.Mobile = row.MobileMask + } + if row.AddressCipher != "" { + plain, decryptErr := encrypt.DecryptField(ctx, row.AddressCipher) + if decryptErr != nil { + g.Log().Warningf(ctx, "收货地址解密失败,地址信息回退为脱敏值 memberId:%d addressId:%d err:%+v", row.MemberId, row.Id, decryptErr) + row.Address = row.AddressMask + } else { + row.Address = plain + } + } else { + row.Address = row.AddressMask + } } return } @@ -874,11 +1009,18 @@ func (s *sSysAppMember) NoticeList(ctx context.Context, in *sysin.MemberNoticeLi return } func (s *sSysAppMember) NoticeEdit(ctx context.Context, in *sysin.MemberNoticeEditInp) error { + memberCount, err := s.memberModel(ctx).WherePri(in.MemberId).Count() + if err != nil { + return gerror.Wrap(err, "校验公告接收会员失败") + } + if memberCount == 0 { + return gerror.New("接收会员不存在或不属于当前租户") + } if in.SourceType == consts.MemberNoticeSimulation && g.Cfg().MustGet(ctx, "system.mode").String() == "product" { return gerror.New("生产环境禁止创建运营模拟通知") } c := dao.AppMemberNotice.Columns() - _, err := s.noticeModel(ctx).Fields(c.MemberId, c.Title, c.Content, c.SourceType, c.StartAt, c.EndAt, c.NoticeStatus, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.MemberId: in.MemberId, c.Title: in.Title, c.Content: in.Content, c.SourceType: in.SourceType, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.NoticeStatus: consts.MemberNoticeActive, c.CreatedBy: contexts.GetUserId(ctx), c.UpdatedBy: contexts.GetUserId(ctx)}).Insert() + _, err = s.noticeModel(ctx).Fields(c.MemberId, c.Title, c.Content, c.SourceType, c.StartAt, c.EndAt, c.NoticeStatus, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.MemberId: in.MemberId, c.Title: in.Title, c.Content: in.Content, c.SourceType: in.SourceType, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.NoticeStatus: consts.MemberNoticeActive, c.CreatedBy: contexts.GetUserId(ctx), c.UpdatedBy: contexts.GetUserId(ctx)}).Insert() if err != nil { return gerror.Wrap(err, "创建会员通知失败") } diff --git a/server/internal/logic/sys/recharge_order.go b/server/internal/logic/sys/recharge_order.go index d39ab27..001b82f 100644 --- a/server/internal/logic/sys/recharge_order.go +++ b/server/internal/logic/sys/recharge_order.go @@ -3,6 +3,7 @@ package sys import ( "context" "fmt" + "math" "hotgo/internal/consts" "hotgo/internal/dao" @@ -173,6 +174,10 @@ func (s *sSysRechargeOrder) Review(ctx context.Context, in *sysin.RechargeReview rc.UpdatedBy: contexts.GetUserId(ctx), } if in.Decision == consts.FinanceDecisionApprove { + tradeOrders, lockErr := NewSysTradeOrder().lockBalancePendingOrders(ctx, order.MemberId) + if lockErr != nil { + return lockErr + } wc := dao.WalletAccount.Columns() var account *entity.WalletAccount if scanErr := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).Where(wc.MemberId, order.MemberId).Where(wc.Currency, order.Currency).LockUpdate().Scan(&account); scanErr != nil { @@ -181,6 +186,9 @@ func (s *sSysRechargeOrder) Review(ctx context.Context, in *sysin.RechargeReview if account == nil || account.AccountStatus != 1 { return gerror.New("会员钱包不存在或不可入账") } + if in.PaidAmount > 0 && account.AvailableAmount > math.MaxInt64-in.PaidAmount { + return gerror.New("充值金额超出钱包处理范围") + } after := account.AvailableAmount + in.PaidAmount lc := dao.WalletLedger.Columns() result, insertErr := dao.WalletLedger.Ctx(ctx). @@ -205,6 +213,9 @@ func (s *sSysRechargeOrder) Review(ctx context.Context, in *sysin.RechargeReview if rows != 1 { return gerror.New("钱包余额已变化,请重试") } + if err = NewSysTradeOrder().completeBalancePendingOrders(ctx, tradeOrders); err != nil { + return err + } status = consts.RechargeSuccess updateData[rc.RechargeStatus] = status updateData[rc.PaidAmount] = in.PaidAmount @@ -251,6 +262,10 @@ func (s *sSysRechargeOrder) Execute(ctx context.Context, in *sysin.FinanceExecut now := gtime.Now() ledgerId := uint64(0) if input.Decision == consts.FinanceDecisionApprove { + tradeOrders, lockErr := NewSysTradeOrder().lockBalancePendingOrders(ctx, order.MemberId) + if lockErr != nil { + return lockErr + } 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, order.Currency).LockUpdate().Scan(&account); err != nil { @@ -259,6 +274,9 @@ func (s *sSysRechargeOrder) Execute(ctx context.Context, in *sysin.FinanceExecut if account == nil || account.AccountStatus != 1 { return gerror.New("会员钱包不存在或不可入账") } + if input.PaidAmount > 0 && account.AvailableAmount > math.MaxInt64-input.PaidAmount { + return gerror.New("充值金额超出钱包处理范围") + } after := account.AvailableAmount + input.PaidAmount lc := dao.WalletLedger.Columns() 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: "WL" + gtime.Now().Format("YmdHis") + grand.S(7), lc.AccountId: account.Id, lc.MemberId: order.MemberId, lc.BizType: "recharge", lc.BizNo: order.RechargeNo, lc.EntryType: "available", lc.Direction: consts.WalletDirectionIncome, lc.Amount: input.PaidAmount, lc.BeforeAmount: account.AvailableAmount, lc.AfterAmount: after, lc.Currency: account.Currency, lc.IdempotencyKey: fmt.Sprintf("recharge:%d", order.Id), lc.Remark: input.ReasonText, lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: now}).Insert() @@ -275,6 +293,9 @@ func (s *sSysRechargeOrder) Execute(ctx context.Context, in *sysin.FinanceExecut if rows != 1 { return gerror.New("钱包余额已变化,请重试") } + if err = NewSysTradeOrder().completeBalancePendingOrders(ctx, tradeOrders); err != nil { + return err + } _, err = s.model(ctx).WherePri(order.Id).Data(g.Map{rc.RechargeStatus: consts.RechargeSuccess, rc.PaidAmount: input.PaidAmount, rc.ExternalNo: input.ExternalNo, rc.LedgerId: ledgerId, rc.ReviewedAt: now, rc.CreditedAt: now, rc.UpdatedBy: contexts.GetUserId(ctx)}).Update() if err != nil { return err diff --git a/server/internal/logic/sys/trade_order.go b/server/internal/logic/sys/trade_order.go index 5850a79..c7f5917 100644 --- a/server/internal/logic/sys/trade_order.go +++ b/server/internal/logic/sys/trade_order.go @@ -77,7 +77,7 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) } for _, row := range list { 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 + manualPending := row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderPending && row.FreezeStatus == consts.TradeFreezeNormal && row.PaymentStatus != "balance_pending" row.CanChangeAmount = manualPending row.CanForcePay = manualPending row.CanFreeze = manualPending @@ -276,19 +276,34 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp snapshotData["member_level_id"], snapshotData["level_version_id"] = levelId, levelVersion.Id 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) + settlement, err := s.settleMatchedOrder(ctx, order, dispatchMode, totalAmount, commissionAmount, now) if err != nil { return err } + balancePending := !settlement.Settled + orderStatus, message := consts.TradeOrderCompleted, "后台已匹配并完成订单,本金已返还,佣金已结算" + var completedAt *gtime.Time = now + if balancePending { + orderStatus = consts.TradeOrderPending + message = balanceShortageMessage(settlement.AvailableAmount, totalAmount) + completedAt = nil + snapshotData["balance_shortage_pending"] = true + snapshotData["balance_available_amount"] = settlement.AvailableAmount + snapshotData["balance_shortfall_amount"] = settlement.ShortfallAmount + snapshotData["manual_payment_status"] = "balance_pending" + } else { + snapshotData["balance_shortage_pending"] = false + snapshotData["balance_shortfall_amount"] = int64(0) + snapshotData["settled_by"], snapshotData["settled_at"] = "admin", time.Now().UTC().Format(time.RFC3339) + } + snapshot := gjson.New(snapshotData) 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.TradeOrderCompleted, - oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx), + oc.RuleSnapshot: snapshot, oc.Message: message, oc.OrderStatus: orderStatus, + oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: completedAt, oc.UpdatedBy: contexts.GetUserId(ctx), } result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderMatching).Data(data).Update() if err != nil { @@ -298,43 +313,54 @@ 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 + if !balancePending { + if err = s.insertMatchedSubmission(ctx, order, levelId, commissionAmount, settlement.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}), - Reason: "后台人工匹配商品", + Reason: message, }) }) } -func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (uint64, error) { +type matchedOrderSettlement struct { + CommissionLedgerId uint64 + AvailableAmount int64 + ShortfallAmount int64 + Settled bool +} + +func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (*matchedOrderSettlement, 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, "读取会员钱包失败") + return nil, gerror.Wrap(err, "读取会员钱包失败") } if account == nil || account.AccountStatus != 1 { - return 0, gerror.New("会员钱包不存在或状态异常") + return nil, gerror.New("会员钱包不存在或状态异常") } if totalAmount <= 0 || account.AvailableAmount < math.MinInt64+totalAmount { - return 0, gerror.New("订单金额超出钱包处理范围") + return nil, gerror.New("订单金额超出钱包处理范围") } if commissionAmount < 0 || account.AvailableAmount > math.MaxInt64-commissionAmount { - return 0, gerror.New("订单佣金超出钱包处理范围") + return nil, gerror.New("订单佣金超出钱包处理范围") } - if dispatchMode == "auto" && account.AvailableAmount < totalAmount { - return 0, gerror.New("自动派单商品金额不能超过会员可用余额") + if account.AvailableAmount < totalAmount { + if account.AvailableAmount < 0 && totalAmount > math.MaxInt64+account.AvailableAmount { + return nil, gerror.New("余额差额超出可计算范围") + } + return &matchedOrderSettlement{AvailableAmount: account.AvailableAmount, ShortfallAmount: totalAmount - account.AvailableAmount}, nil } principalAfter := account.AvailableAmount - totalAmount - settledAfter := principalAfter + commissionAmount + settledAfter := account.AvailableAmount + commissionAmount if dispatchMode == "auto" { - settledAfter = account.AvailableAmount + commissionAmount if account.FrozenAmount > math.MaxInt64-totalAmount { - return 0, gerror.New("订单金额超出钱包冻结范围") + return nil, gerror.New("订单金额超出钱包冻结范围") } } accountData := g.Map{ @@ -342,15 +368,12 @@ func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.T 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, "结算订单余额失败") + return nil, gerror.Wrap(err, "结算订单余额失败") } if affected, _ := result.RowsAffected(); affected != 1 { - return 0, gerror.New("会员余额已变化,请刷新后重试") + return nil, gerror.New("会员余额已变化,请刷新后重试") } if dispatchMode == "auto" { @@ -368,22 +391,116 @@ func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.T } 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 + return nil, 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 + return nil, err + } + if _, err = insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMR", order.Id), "task_principal_return", order.OrderNo, "available", consts.WalletDirectionIncome, totalAmount, principalAfter, account.AvailableAmount, "admin-manual:return-available", "手动单完成返还订单本金", now); err != nil { + return nil, err } } if commissionAmount == 0 { - return 0, nil + return &matchedOrderSettlement{AvailableAmount: account.AvailableAmount, Settled: true}, nil } - commissionBefore := principalAfter - if dispatchMode == "auto" { - commissionBefore = account.AvailableAmount + ledgerId, err := insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMC", order.Id), "commission", fmt.Sprintf("TSADMIN%d", order.Id), "available", consts.WalletDirectionIncome, commissionAmount, account.AvailableAmount, settledAfter, "admin-match:commission", "后台匹配订单完成返佣", now) + if err != nil { + return nil, err } - 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) + return &matchedOrderSettlement{CommissionLedgerId: ledgerId, AvailableAmount: settledAfter, Settled: true}, nil +} + +func balanceShortageMessage(availableAmount, requiredAmount int64) string { + shortfall := requiredAmount - availableAmount + return fmt.Sprintf("余额不足:当前可用 ¥%s,订单需要 ¥%s,还差 ¥%s。充值审核通过且余额足够后,订单将自动完成。", + formatSignedTradeCents(availableAmount), formatCents(requiredAmount), formatCents(shortfall)) +} + +func formatSignedTradeCents(amount int64) string { + if amount >= 0 { + return formatCents(amount) + } + magnitude := uint64(-(amount + 1)) + 1 + return fmt.Sprintf("-%d.%02d", magnitude/100, magnitude%100) +} + +func (s *sSysTradeOrder) lockBalancePendingOrders(ctx context.Context, memberId uint64) ([]*entity.TradeOrder, error) { + oc := dao.TradeOrder.Columns() + orders := make([]*entity.TradeOrder, 0) + err := s.model(ctx). + Where(oc.MemberId, memberId). + Where(oc.OrderStatus, consts.TradeOrderPending). + Where("JSON_UNQUOTE(JSON_EXTRACT("+oc.RuleSnapshot+", '$.balance_shortage_pending')) = ?", "true"). + OrderAsc(oc.Id).LockUpdate().Scan(&orders) + if err != nil { + return nil, gerror.Wrap(err, "读取余额不足待处理订单失败") + } + return orders, nil +} + +func (s *sSysTradeOrder) completeBalancePendingOrders(ctx context.Context, orders []*entity.TradeOrder) error { + if len(orders) == 0 { + return nil + } + oc := dao.TradeOrder.Columns() + for _, order := range orders { + if order == nil || order.OrderStatus != consts.TradeOrderPending || order.TotalAmount <= 0 { + continue + } + snapshot := cloneOrderSnapshot(order) + dispatchMode := gjson.New(snapshot).Get("dispatch_mode").String() + if dispatchMode == "" { + dispatchMode = "manual" + } + now := gtime.Now() + settlement, err := s.settleMatchedOrder(ctx, order, dispatchMode, order.TotalAmount, order.CommissionAmount, now) + if err != nil { + return err + } + data := g.Map{oc.UpdatedBy: contexts.GetUserId(ctx)} + if !settlement.Settled { + snapshot["balance_available_amount"] = settlement.AvailableAmount + snapshot["balance_shortfall_amount"] = settlement.ShortfallAmount + data[oc.RuleSnapshot] = gjson.New(snapshot) + data[oc.Message] = balanceShortageMessage(settlement.AvailableAmount, order.TotalAmount) + if _, err = s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新余额不足提示失败") + } + continue + } + + snapshot["balance_shortage_pending"] = false + snapshot["balance_available_amount"] = settlement.AvailableAmount + snapshot["balance_shortfall_amount"] = int64(0) + snapshot["manual_payment_status"] = "settled" + snapshot["settled_by"] = "recharge_auto" + snapshot["settled_at"] = time.Now().UTC().Format(time.RFC3339) + message := "充值审核通过后余额充足,订单已自动完成,本金已返还,佣金已结算" + data[oc.RuleSnapshot] = gjson.New(snapshot) + data[oc.Message] = message + data[oc.OrderStatus] = consts.TradeOrderCompleted + data[oc.CompletedAt] = now + result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update() + if err != nil { + return gerror.Wrap(err, "自动完成余额已充足的订单失败") + } + if affected, _ := result.RowsAffected(); affected != 1 { + return gerror.New("待处理订单状态已变化,请重试") + } + if err = s.insertMatchedSubmission(ctx, order, order.MemberLevelId, order.CommissionAmount, settlement.CommissionLedgerId, now, dispatchMode); err != nil { + return err + } + if err = service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{ + Module: "trade", Action: "trade_order.complete_after_recharge", TargetType: "trade_order", TargetId: order.Id, + Before: order, After: data, ChangeJson: gjson.New(g.Map{"availableAmount": settlement.AvailableAmount, "totalAmount": order.TotalAmount}), + Reason: message, + }); err != nil { + return err + } + } + return nil } func (s *sSysTradeOrder) insertMatchedSubmission(ctx context.Context, order *entity.TradeOrder, levelId uint64, commissionAmount int64, ledgerId uint64, now *gtime.Time, dispatchMode string) error { @@ -595,6 +712,9 @@ func validateManualPaymentOrder(order *entity.TradeOrder, requireUnpaid bool) er if order.OrderStatus != consts.TradeOrderPending || order.FreezeStatus != consts.TradeFreezeNormal || order.RuleSnapshot == nil || order.RuleSnapshot.Get("dispatch_mode").String() != "manual" { return gerror.New("当前订单状态不允许此操作") } + if order.RuleSnapshot.Get("balance_shortage_pending").Bool() { + return gerror.New("订单因余额不足待处理,充值审核通过且余额足够后将自动完成") + } if requireUnpaid && order.RuleSnapshot.Get("manual_payment_status").String() == "paid" { return gerror.New("订单已经付款") } diff --git a/server/internal/logic/sys/trade_order_test.go b/server/internal/logic/sys/trade_order_test.go index 07dde87..3065327 100644 --- a/server/internal/logic/sys/trade_order_test.go +++ b/server/internal/logic/sys/trade_order_test.go @@ -1,6 +1,7 @@ package sys import ( + "strings" "testing" "time" @@ -35,3 +36,12 @@ func TestCanAdminMatchOrder(t *testing.T) { }) } } + +func TestBalanceShortageMessageShowsExactDifference(t *testing.T) { + message := balanceShortageMessage(30000, 50000) + for _, expected := range []string{"余额不足", "¥300.00", "¥500.00", "还差 ¥200.00", "充值审核通过"} { + if !strings.Contains(message, expected) { + t.Fatalf("balanceShortageMessage() = %q, missing %q", message, expected) + } + } +} diff --git a/server/internal/model/input/sysin/app_member.go b/server/internal/model/input/sysin/app_member.go index 036385b..206fa61 100644 --- a/server/internal/model/input/sysin/app_member.go +++ b/server/internal/model/input/sysin/app_member.go @@ -33,6 +33,8 @@ type AppMemberListModel struct { MemberNo string `json:"memberNo"` Username string `json:"username"` Mobile string `json:"mobile"` + RealName string `json:"realName"` + RealNameCipher string `json:"-"` RealNameMask string `json:"realNameMask"` AvailableAmount int64 `json:"availableAmount"` FrozenAmount int64 `json:"frozenAmount"` @@ -63,6 +65,7 @@ func (in *AppMemberViewInp) Filter(ctx context.Context) error { return nil } type AppMemberViewModel struct { AppMemberListModel + IdNo string `json:"idNo"` LevelId uint64 `json:"levelId"` Level int `json:"level"` CreditScore int `json:"creditScore"` @@ -158,11 +161,21 @@ type AppMemberPaymentModel struct { AccountType int `json:"accountType"` BankCode string `json:"bankCode"` BankName string `json:"bankName"` + Holder string `json:"holder"` + IdentityNo string `json:"identityNo"` + Mobile string `json:"mobile"` + AccountNo string `json:"accountNo"` + DigitalRMBCode string `json:"digitalRmbCode"` HolderMask string `json:"holderMask"` + HolderCipher string `json:"-"` IdentityNoMask string `json:"identityNoMask"` + IdentityNoCipher string `json:"-"` MobileMask string `json:"mobileMask"` + MobileCipher string `json:"-"` AccountNoMask string `json:"accountNoMask"` + AccountNoCipher string `json:"-"` DigitalRMBCodeMask string `json:"digitalRmbCodeMask"` + DigitalRmbCodeCipher string `json:"-"` QrAttachmentId uint64 `json:"qrAttachmentId"` WechatQrAttachmentId uint64 `json:"wechatQrAttachmentId"` AlipayQrAttachmentId uint64 `json:"alipayQrAttachmentId"` @@ -216,17 +229,23 @@ type AppMemberAddressListInp struct { func (in *AppMemberAddressListInp) Filter(ctx context.Context) error { return nil } type AppMemberAddressModel struct { - Id uint64 `json:"id"` - MemberId uint64 `json:"memberId"` - ReceiverMask string `json:"receiverMask"` - MobileMask string `json:"mobileMask"` - Province string `json:"province"` - City string `json:"city"` - District string `json:"district"` - AddressMask string `json:"addressMask"` - IsDefault int `json:"isDefault"` - CreatedAt *gtime.Time `json:"createdAt"` - UpdatedAt *gtime.Time `json:"updatedAt"` + Id uint64 `json:"id"` + MemberId uint64 `json:"memberId"` + Receiver string `json:"receiver"` + Mobile string `json:"mobile"` + Address string `json:"address"` + ReceiverCipher string `json:"-"` + MobileCipher string `json:"-"` + AddressCipher string `json:"-"` + ReceiverMask string `json:"receiverMask"` + MobileMask string `json:"mobileMask"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + AddressMask string `json:"addressMask"` + IsDefault int `json:"isDefault"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` } type AppMemberAddressEditInp struct { Id uint64 `json:"id"` @@ -414,6 +433,12 @@ type MemberNoticeEditInp struct { } func (in *MemberNoticeEditInp) Filter(ctx context.Context) error { + if in.MemberId == 0 { + return gerror.New("请选择接收公告的会员") + } + if in.StartAt == nil || in.EndAt == nil { + return gerror.New("请设置公告有效期") + } if in.StartAt.After(in.EndAt) { return gerror.New("结束时间必须晚于开始时间") } diff --git a/server/internal/model/input/sysin/app_member_notice_test.go b/server/internal/model/input/sysin/app_member_notice_test.go new file mode 100644 index 0000000..0a6fd44 --- /dev/null +++ b/server/internal/model/input/sysin/app_member_notice_test.go @@ -0,0 +1,20 @@ +package sysin + +import ( + "context" + "testing" +) + +func TestMemberNoticeEditInpRequiresRecipient(t *testing.T) { + in := &MemberNoticeEditInp{} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("expected a missing recipient to be rejected") + } +} + +func TestMemberNoticeEditInpRequiresValidityRange(t *testing.T) { + in := &MemberNoticeEditInp{MemberId: 1} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("expected a missing validity range to be rejected") + } +} diff --git a/web/src/views/app-member/list/index.vue b/web/src/views/app-member/list/index.vue index 90edd4a..e481d46 100644 --- a/web/src/views/app-member/list/index.vue +++ b/web/src/views/app-member/list/index.vue @@ -136,9 +136,9 @@ {{ detail.memberNo }} {{ detail.mobile }} {{ - detail.realNameMask || '-' + detail.realName || detail.realNameMask || '-' }} - {{ detail.idNoMask || '-' }} + {{ detail.idNo || detail.idNoMask || '-' }} {{ detail.level }} {{ money(detail.availableAmount) @@ -165,12 +165,6 @@ >收货地址 团队查询 - 授权查看明文 @@ -184,14 +178,8 @@ - - 当前默认显示脱敏资料;留空保存不会覆盖原敏感字段或收款码。 - 授权查看明文 + + 敏感资料已明文展示;请仅用于当前业务处理。留空保存不会覆盖原敏感字段或收款码。 - - 明文仅用于当前业务目的,请勿复制传播。本次操作将记录查看人、对象、用途和结果。 - - - - 我确认业务需要并接受留痕 - - - 关闭确认查看 - - 确定{{ statusForm.toStatus === 1 ? '启用' : '禁用' }}此用户吗? - + + + 仅当前会员可见;会员在任意已登录页面通常15秒内收到系统通知弹窗,首页公告栏继续轮询。 + @@ -352,8 +314,18 @@ - + + (), memberTableWrapRef = ref(null); const [register] = useForm({ - gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' }, + gridProps: { cols: '1 s:2 m:4 l:6 xl:8 2xl:8' }, labelWidth: 80, schemas, }); @@ -439,7 +410,6 @@ showRelatedForm = ref(false), showTeam = ref(false), showLedger = ref(false), - showSensitive = ref(false), showStatus = ref(false), showWallet = ref(false), showNotice = ref(false), @@ -454,20 +424,12 @@ teamRows = ref([]), relatedType = ref<'payment' | 'address'>('payment'), teamLevel = ref(3), - sensitiveResult = ref(''), walletYuan = ref(null), memberLevelOptions = ref([]), noticeMember = ref(null); const editForm = reactive({}), paymentForm = reactive({}), addressForm = reactive({}), - sensitiveForm = reactive({ - targetType: 'member', - targetId: 0, - purposeCode: '', - purposeText: '', - confirm: false, - }), statusForm = reactive({ memberId: 0, toStatus: 2, reasonCode: '', reasonText: '' }), walletForm = reactive({ memberId: 0, @@ -508,8 +470,8 @@ trigger: ['input', 'blur'], }, }; - const noticeForm = reactive({ memberId: 0, title: '', content: '', sourceType: 2 }); - const noticeDateRange = ref<[string, string]>(['', '']); + const noticeForm = reactive({ memberId: 0, title: '', content: '', sourceType: 1 }); + const noticeDateRange = ref<[string, string] | null>(null); const ledgerRows = ref([]), ledgerMember = ref(null); const reset = (obj, value) => { @@ -580,7 +542,7 @@ auth: ['/appMember/walletRequest'], }, { - label: '弹幕', + label: '弹窗', color: '#009688', onClick: () => openNotice(record), auth: ['/appMember/noticeEdit'], @@ -772,16 +734,16 @@ reset(paymentForm, { memberId, id: bank?.id || 0, - holder: '', + holder: bank?.holder || bank?.holderMask || '', holderMask: bank?.holderMask || '', - identityNo: '', + identityNo: bank?.identityNo || bank?.identityNoMask || '', identityNoMask: bank?.identityNoMask || '', bankName: bank?.bankName || '', - mobile: '', + mobile: bank?.mobile || bank?.mobileMask || '', mobileMask: bank?.mobileMask || '', - bankAccount: '', + bankAccount: bank?.accountNo || bank?.accountNoMask || '', accountNoMask: bank?.accountNoMask || '', - digitalWallet: '', + digitalWallet: bank?.digitalRmbCode || bank?.digitalRmbCodeMask || '', digitalRmbCodeMask: bank?.digitalRmbCodeMask || '', alipayQrCode: bank?.alipayQrUrl || '', wechatQrCode: bank?.wechatQrUrl || '', @@ -794,8 +756,8 @@ reset(addressForm, { id: row?.id || 0, memberId, - receiver: '', - mobile: '', + receiver: row?.receiver || row?.receiverMask || '', + mobile: row?.mobile || row?.mobileMask || '', region, originalRegion: region, province: row?.province || '', @@ -839,7 +801,7 @@ province: '', city: '', district: '', - address: '', + address: row?.address || row?.addressMask || '', isDefault: 2, }); } @@ -893,9 +855,11 @@ memberId: row.memberId, accountType: row.accountType, bankName: row.bankName, - holder: '', - mobile: '', - accountNo: '', + holder: row.holder || row.holderMask || '', + identityNo: row.identityNo || row.identityNoMask || '', + mobile: row.mobile || row.mobileMask || '', + accountNo: row.accountNo || row.accountNoMask || '', + digitalRmbCode: row.digitalRmbCode || row.digitalRmbCodeMask || '', verifyStatus: row.verifyStatus, isDefault: row.isDefault, }); @@ -903,30 +867,15 @@ reset(addressForm, { id: row.id, memberId: row.memberId, - receiver: '', - mobile: '', + receiver: row.receiver || row.receiverMask || '', + mobile: row.mobile || row.mobileMask || '', province: row.province, city: row.city, district: row.district, - address: '', + address: row.address || row.addressMask || '', isDefault: row.isDefault, }); } - function openSensitive(type, targetId) { - reset(sensitiveForm, { - targetType: type, - targetId, - purposeCode: '', - purposeText: '', - confirm: false, - }); - sensitiveResult.value = ''; - showSensitive.value = true; - } - async function revealSensitive() { - const r: any = await Sensitive(sensitiveForm); - sensitiveResult.value = JSON.stringify({ accessNo: r.accessNo, ...r.fields }, null, 2); - } async function openTeam(memberId = detail.value.id) { detail.value = { ...detail.value, id: memberId }; showTeam.value = true; @@ -1000,13 +949,21 @@ const start = new Date(), end = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000); noticeMember.value = row; - reset(noticeForm, { memberId: row.id, title: '会员弹幕', content: '', sourceType: 2 }); + reset(noticeForm, { memberId: row.id, title: '系统通知', content: '', sourceType: 1 }); noticeDateRange.value = [formatDate(start), formatDate(end)]; showNotice.value = true; } async function submitNotice() { + if (!noticeForm.title.trim()) { + message.warning('请填写通知标题'); + return; + } if (!noticeForm.content.trim()) { - message.warning('请填写弹幕内容'); + message.warning('请填写通知内容'); + return; + } + if (!noticeDateRange.value) { + message.warning('请选择通知有效期'); return; } await NoticeEdit({ @@ -1014,7 +971,7 @@ startAt: noticeDateRange.value[0], endAt: noticeDateRange.value[1], }); - message.success('弹幕已发送'); + message.success('系统通知已发送'); showNotice.value = false; } const relatedColumns = computed(() => @@ -1025,8 +982,8 @@ key: 'accountType', render: (r) => dict.getLabel('paymentAccountType', r.accountType), }, - { title: '持有人', key: 'holderMask' }, - { title: '账号', key: 'accountNoMask' }, + { title: '持有人', key: 'holder' }, + { title: '账号', key: 'accountNo' }, { title: '银行', key: 'bankName' }, { title: '操作', @@ -1034,11 +991,6 @@ render: (r) => h(NSpace, {}, () => [ h(NButton, { size: 'small', onClick: () => editRelated(r) }, () => '编辑'), - h( - NButton, - { size: 'small', onClick: () => openSensitive('payment', r.id) }, - () => '查看明文' - ), h( NButton, { size: 'small', type: 'error', onClick: () => deleteRelated(r.id) }, @@ -1048,21 +1000,16 @@ }, ] : [ - { title: '收货人', key: 'receiverMask' }, - { title: '手机号', key: 'mobileMask' }, + { title: '收货人', key: 'receiver' }, + { title: '手机号', key: 'mobile' }, { title: '地区', key: 'province', render: (r) => r.province + r.city + r.district }, - { title: '地址', key: 'addressMask' }, + { title: '地址', key: 'address' }, { title: '操作', key: 'action', render: (r) => h(NSpace, {}, () => [ h(NButton, { size: 'small', onClick: () => editRelated(r) }, () => '编辑'), - h( - NButton, - { size: 'small', onClick: () => openSensitive('address', r.id) }, - () => '查看明文' - ), h( NButton, { size: 'small', type: 'error', onClick: () => deleteRelated(r.id) }, diff --git a/web/src/views/app-member/list/model.ts b/web/src/views/app-member/list/model.ts index 191311d..b084fc7 100644 --- a/web/src/views/app-member/list/model.ts +++ b/web/src/views/app-member/list/model.ts @@ -7,7 +7,7 @@ import { defRangeShortcuts } from '@/utils/dateUtil'; const dict = useDictStore(); export interface MemberRecord { - id: number; memberNo: string; username: string; mobile: string; realNameMask: string; + id: number; memberNo: string; username: string; mobile: string; realName: string; realNameMask: string; availableAmount: number; frozenAmount: number; parentId: number; parentUsername: string; inviteCode: string; memberStatus: number; riskStatus: number; autoDispatch: number; memberCategory: number; isTestAccount: number; lastLoginAt?: string; lastLoginIpMask: string; lastLoginRegion: string; @@ -34,7 +34,7 @@ export const columns = [ { title: '会员编号', key: 'memberNo', width: 170 }, { title: '账号', key: 'username', width: 130 }, { title: '手机号', key: 'mobile', width: 130 }, - { title: '绑卡姓名', key: 'realNameMask', width: 100 }, + { title: '真实姓名', key: 'realName', width: 100 }, { title: '可用余额', key: 'availableAmount', width: 120, render: (row: MemberRecord) => money(row.availableAmount) }, { title: '冻结余额', key: 'frozenAmount', width: 120, render: (row: MemberRecord) => money(row.frozenAmount) }, { title: '上级用户', key: 'parentUsername', width: 120, render: (row: MemberRecord) => row.parentUsername || '-' }, diff --git a/web/src/views/trade/order/index.vue b/web/src/views/trade/order/index.vue index a242353..8ffb961 100644 --- a/web/src/views/trade/order/index.vue +++ b/web/src/views/trade/order/index.vue @@ -1,6 +1,6 @@ - 手动单匹配后立即完成并允许余额为负;自动单可在预约时间前人工匹配,逾期未处理则由系统自动匹配。 + 匹配时会校验会员可用余额;余额不足的订单保留为待处理,充值审核通过且余额足够后自动完成并结算本金及佣金。 @@ -45,6 +45,9 @@ + + 会员余额不足:当前可用 {{ yuan(matchMember.availableAmount) }},订单金额 {{ yuan(selectedProduct.priceAmount * matchForm.quantity) }},还差 {{ yuan(matchShortfall) }}。订单将保留为待处理,充值审核通过且余额足够后自动完成。 + @@ -99,6 +102,7 @@ const approvalColumns=[{title:'节点',key:'stepNo',width:70},{title:'操作人I const matchLoading=ref(false),productLoading=ref(false),matchSubmitting=ref(false),products=ref([]),selectedProduct=ref(); const matchMember=reactive({id:0,username:'',availableAmount:0,frozenAmount:0}); const matchForm=reactive({id:0,productId:0,quantity:1}); +const matchShortfall=computed(()=>Math.max(0,(selectedProduct.value?.priceAmount||0)*matchForm.quantity-matchMember.availableAmount)); const amountMember=reactive({username:'',availableAmount:0}); const amountForm=reactive<{id:number;amount:number|null;quantity:number}>({id:0,amount:null,quantity:1}); const amountSubmitting=ref(false); @@ -123,7 +127,7 @@ function clearProductFilter(){Object.assign(productFilter,{name:'',priceMin:null function selectProduct(row){selectedProduct.value=row;matchForm.productId=row.id} async function confirmMatch(){ if(!matchForm.productId){message.error('请先选择商品');return} - matchSubmitting.value=true;try{await TradeOrderMatch(matchForm);message.success('订单已匹配并完成');showMatch.value=false;reloadTable()}finally{matchSubmitting.value=false} + matchSubmitting.value=true;try{await TradeOrderMatch(matchForm);message.success('订单已匹配;如余额不足将保持待处理,充值审核通过且余额足够后自动完成');showMatch.value=false;reloadTable()}finally{matchSubmitting.value=false} } async function view(row){detail.value=await TradeOrderView({id:row.id});showDetail.value=true} function openAmount(row){Object.assign(amountMember,{username:row.username,availableAmount:row.availableAmount});Object.assign(amountForm,{id:row.id,amount:Number(row.totalAmount||0)/100,quantity:row.quantity||1});showAmount.value=true} @@ -137,7 +141,8 @@ const actionColumn=reactive({width:300,title:'操作',key:'action',fixed:'right' if(row.canForcePay)actions.push({label:'强制付款',type:'primary',onClick:()=>forcePay(row)}); if(row.canFreeze)actions.push({label:'冻结订单',type:'warning',onClick:()=>freezeOrder(row)}); const hasManualActions=row.canChangeAmount||row.canForcePay||row.canFreeze; - if(!hasManualActions&&row.orderType===2&&row.orderStatus===2&&row.canReview)actions.push({label:'审核任务',type:'warning',onClick:()=>router.push({name:'task_submission_list',query:{tradeOrderId:String(row.id)}})}); + if(!hasManualActions&&row.paymentStatus==='balance_pending')actions.push({label:'余额不足待处理',type:'error',disabled:true,title:row.message||'充值审核通过且余额足够后将自动完成'}); + else if(!hasManualActions&&row.orderType===2&&row.orderStatus===2&&row.canReview)actions.push({label:'审核任务',type:'warning',onClick:()=>router.push({name:'task_submission_list',query:{tradeOrderId:String(row.id)}})}); else if(!hasManualActions&&row.orderType===2&&row.orderStatus===2)actions.push({label:'等待会员付款',disabled:true,title:'等待会员完成付款'}); if(!hasManualActions)actions.push({label:'详情',onClick:()=>view(row)}); return h(TableAction as any,{style:'button',actions}); diff --git a/web/src/views/trade/order/model.ts b/web/src/views/trade/order/model.ts index 21304a9..2ef1eba 100644 --- a/web/src/views/trade/order/model.ts +++ b/web/src/views/trade/order/model.ts @@ -30,9 +30,13 @@ export const columns = [ { title: '佣金', key: 'commissionAmount', width: 110, render: (r) => yuan(r.commissionAmount) }, { title: '下单时间', key: 'createdAt', width: 180 }, { title: '解冻时间', key: 'unfrozenAt', width: 180 }, - { title: '消息', key: 'message', width: 180, ellipsis: { tooltip: true } }, - { title: '订单状态', key: 'orderStatus', width: 110, render: (r) => r.orderType === 2 && r.orderStatus === 2 - ? h(NTag, { type: 'warning', bordered: false }, { default: () => '待付款' }) + { title: '消息', key: 'message', width: 280, ellipsis: { tooltip: true }, render: (r) => r.paymentStatus === 'balance_pending' + ? h(NTag, { type: 'error', bordered: false }, { default: () => r.message || '余额不足,待处理' }) + : r.message }, + { title: '订单状态', key: 'orderStatus', width: 130, render: (r) => r.paymentStatus === 'balance_pending' + ? h(NTag, { type: 'error', bordered: false }, { default: () => '余额不足待处理' }) + : r.orderType === 2 && r.orderStatus === 2 + ? h(NTag, { type: 'warning', bordered: false }, { default: () => '待付款' }) : tag('tradeOrderStatus', r.orderStatus) }, { title: '冻结状态', key: 'freezeStatus', width: 110, render: (r) => tag('tradeFreezeStatus', r.freezeStatus) }, ];