feat: publish member notices and balance-aware order settlement
Deploy olons-admin production / deploy (push) Successful in 8m50s
Deploy olons-admin production / deploy (push) Successful in 8m50s
This commit is contained in:
@@ -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, "创建会员通知失败")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("订单已经付款")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("结束时间必须晚于开始时间")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user