Compare commits
10
Commits
76032d372f
...
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa60da7e1 | ||
|
|
3d69d68a5d | ||
|
|
70ef3d9d04 | ||
|
|
5a5540785d | ||
|
|
4288782b2b | ||
|
|
d35cad4615 | ||
|
|
eead3dd798 | ||
|
|
2483d09c39 | ||
|
|
661486e46e | ||
|
|
3bc96f5325 |
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/grand"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -64,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),
|
||||
@@ -108,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
|
||||
}
|
||||
|
||||
@@ -125,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) + " "
|
||||
@@ -406,10 +448,73 @@ 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 {
|
||||
row.SensitiveUnavailable = true
|
||||
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 {
|
||||
row.SensitiveUnavailable = true
|
||||
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 {
|
||||
row.SensitiveUnavailable = true
|
||||
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 {
|
||||
row.SensitiveUnavailable = true
|
||||
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 {
|
||||
row.SensitiveUnavailable = true
|
||||
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
|
||||
}
|
||||
@@ -434,7 +539,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.AlipayQrAttachmentId] = in.AlipayQrAttachmentId
|
||||
}
|
||||
if in.Holder != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.Holder)
|
||||
v, e := encrypt.EncryptPaymentField(ctx, in.Holder)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -442,7 +547,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.HolderMask] = encrypt.MaskName(in.Holder)
|
||||
}
|
||||
if in.Mobile != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.Mobile)
|
||||
v, e := encrypt.EncryptPaymentField(ctx, in.Mobile)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -450,11 +555,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.MobileMask] = encrypt.MaskMobile(in.Mobile)
|
||||
}
|
||||
if in.IdentityNo != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.IdentityNo)
|
||||
v, e := encrypt.EncryptPaymentField(ctx, in.IdentityNo)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h, e := encrypt.HashField(ctx, in.IdentityNo)
|
||||
h, e := encrypt.HashPaymentField(ctx, in.IdentityNo)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -463,11 +568,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.IdentityNoMask] = encrypt.MaskAccount(in.IdentityNo)
|
||||
}
|
||||
if in.AccountNo != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.AccountNo)
|
||||
v, e := encrypt.EncryptPaymentField(ctx, in.AccountNo)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h, e := encrypt.HashField(ctx, in.AccountNo)
|
||||
h, e := encrypt.HashPaymentField(ctx, in.AccountNo)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -476,11 +581,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.AccountNoMask] = encrypt.MaskAccount(in.AccountNo)
|
||||
}
|
||||
if in.DigitalRMBCode != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.DigitalRMBCode)
|
||||
v, e := encrypt.EncryptPaymentField(ctx, in.DigitalRMBCode)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h, e := encrypt.HashField(ctx, in.DigitalRMBCode)
|
||||
h, e := encrypt.HashPaymentField(ctx, in.DigitalRMBCode)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -516,9 +621,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
|
||||
}
|
||||
@@ -629,7 +770,7 @@ func (s *sSysAppMember) Sensitive(ctx context.Context, in *sysin.AppMemberSensit
|
||||
result = 2
|
||||
}
|
||||
lc := dao.SensitiveAccessLog.Columns()
|
||||
_, logErr := dao.SensitiveAccessLog.Ctx(ctx).Fields(lc.AccessNo, lc.ActorId, lc.TargetType, lc.TargetId, lc.FieldSet, lc.PurposeCode, lc.PurposeText, lc.AuthorizationNo, lc.MfaVerified, lc.Result).Hook(hook.SaveTenant).Data(g.Map{lc.AccessNo: res.AccessNo, lc.ActorId: contexts.GetUserId(ctx), lc.TargetType: in.TargetType, lc.TargetId: in.TargetId, lc.FieldSet: gjson.New(res.Fields), lc.PurposeCode: in.PurposeCode, lc.PurposeText: in.PurposeText, lc.AuthorizationNo: res.AccessNo, lc.MfaVerified: 0, lc.Result: result}).Insert()
|
||||
_, logErr := dao.SensitiveAccessLog.Ctx(ctx).Fields(lc.AccessNo, lc.ActorId, lc.TargetType, lc.TargetId, lc.FieldSet, lc.PurposeCode, lc.PurposeText, lc.AuthorizationNo, lc.MfaVerified, lc.Result, lc.TraceId).Hook(hook.SaveTenant).Data(g.Map{lc.AccessNo: res.AccessNo, lc.ActorId: contexts.GetUserId(ctx), lc.TargetType: in.TargetType, lc.TargetId: in.TargetId, lc.FieldSet: gjson.New(res.Fields), lc.PurposeCode: in.PurposeCode, lc.PurposeText: in.PurposeText, lc.AuthorizationNo: res.AccessNo, lc.MfaVerified: 0, lc.Result: result, lc.TraceId: gctx.CtxId(ctx)}).Insert()
|
||||
if logErr != nil {
|
||||
return nil, gerror.Wrap(logErr, "记录敏感信息访问失败")
|
||||
}
|
||||
@@ -873,11 +1014,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, "创建会员通知失败")
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *sSysProduct) listModel(ctx context.Context) *gdb.Model {
|
||||
}
|
||||
func (s *sSysProduct) List(ctx context.Context, in *sysin.ProductListInp) (list []*sysin.ProductListModel, total int, err error) {
|
||||
pc := dao.Product.Columns()
|
||||
mod := s.listModel(ctx)
|
||||
mod := s.listModel(ctx).Where(col("p", pc.ProductType), consts.ProductTypeStandard)
|
||||
if in.Name != "" {
|
||||
mod = mod.WhereLike(col("p", pc.Name), "%"+in.Name+"%")
|
||||
}
|
||||
@@ -55,6 +55,24 @@ func (s *sSysProduct) List(ctx context.Context, in *sysin.ProductListInp) (list
|
||||
if in.CategoryId > 0 {
|
||||
mod = mod.Where(col("p", pc.CategoryId), in.CategoryId)
|
||||
}
|
||||
var minAmount, maxAmount int64
|
||||
if in.PriceMin != "" {
|
||||
minAmount, err = parseCents(in.PriceMin)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.New("最低价格格式不正确")
|
||||
}
|
||||
mod = mod.WhereGTE(col("p", pc.PriceAmount), minAmount)
|
||||
}
|
||||
if in.PriceMax != "" {
|
||||
maxAmount, err = parseCents(in.PriceMax)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.New("最高价格格式不正确")
|
||||
}
|
||||
mod = mod.WhereLTE(col("p", pc.PriceAmount), maxAmount)
|
||||
}
|
||||
if in.PriceMin != "" && in.PriceMax != "" && minAmount > maxAmount {
|
||||
return nil, 0, gerror.New("最低价格不能高于最高价格")
|
||||
}
|
||||
if in.PublishStatus > 0 {
|
||||
mod = mod.Where(col("p", pc.PublishStatus), in.PublishStatus)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
@@ -75,7 +78,7 @@ type AppMemberViewModel struct {
|
||||
|
||||
type AppMemberEditInp struct {
|
||||
Id uint64 `json:"id"`
|
||||
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
|
||||
Username string `json:"username" v:"required|length:2,64#用户名不能为空|用户名长度为2到64个字符"`
|
||||
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
|
||||
Password string `json:"password"`
|
||||
TradePassword string `json:"tradePassword"`
|
||||
@@ -92,6 +95,9 @@ type AppMemberEditInp struct {
|
||||
}
|
||||
|
||||
func (in *AppMemberEditInp) Filter(ctx context.Context) error {
|
||||
if err := rejectMaskedSensitiveFields(in.RealName, in.IdNo); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Id == 0 {
|
||||
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
|
||||
}
|
||||
@@ -153,16 +159,27 @@ type AppMemberPaymentListInp struct {
|
||||
func (in *AppMemberPaymentListInp) Filter(ctx context.Context) error { return nil }
|
||||
|
||||
type AppMemberPaymentModel struct {
|
||||
SensitiveUnavailable bool `json:"sensitiveUnavailable"`
|
||||
Id uint64 `json:"id"`
|
||||
MemberId uint64 `json:"memberId"`
|
||||
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"`
|
||||
@@ -194,6 +211,9 @@ type AppMemberPaymentEditInp struct {
|
||||
}
|
||||
|
||||
func (in *AppMemberPaymentEditInp) Filter(ctx context.Context) error {
|
||||
if err := rejectMaskedSensitiveFields(in.Holder, in.IdentityNo, in.Mobile, in.AccountNo, in.DigitalRMBCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dict.HasOptionKey(consts.PaymentAccountTypeOptions, in.AccountType) {
|
||||
return gerror.New("收款账户类型不合法")
|
||||
}
|
||||
@@ -216,17 +236,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"`
|
||||
@@ -241,12 +267,24 @@ type AppMemberAddressEditInp struct {
|
||||
}
|
||||
|
||||
func (in *AppMemberAddressEditInp) Filter(ctx context.Context) error {
|
||||
if err := rejectMaskedSensitiveFields(in.Receiver, in.Mobile, in.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Id == 0 && (strings.TrimSpace(in.Receiver) == "" || strings.TrimSpace(in.Mobile) == "" || strings.TrimSpace(in.Address) == "") {
|
||||
return gerror.New("新增地址时收货人、手机号和详细地址不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectMaskedSensitiveFields(values ...string) error {
|
||||
for _, value := range values {
|
||||
if strings.Contains(value, "*") {
|
||||
return gerror.New("不能保存带星号的脱敏资料,请填写完整信息或留空保留原值")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppMemberAddressDeleteInp struct {
|
||||
Id uint64 `json:"id" v:"required|min:1#地址ID不能为空|地址ID不能为空"`
|
||||
}
|
||||
@@ -414,6 +452,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("结束时间必须晚于开始时间")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type AppMemberRegisterInp struct {
|
||||
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
|
||||
Username string `json:"username" v:"required|length:2,64#用户名不能为空|用户名长度为2到64个字符"`
|
||||
Mobile string `json:"mobile" v:"required|phone-loose#手机号不能为空|手机号格式不正确"`
|
||||
Password string `json:"password" v:"required|length:6,64#密码不能为空|密码长度为6到64个字符"`
|
||||
InviteCode string `json:"inviteCode" v:"required#请输入邀请码"`
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package sysin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
func TestPaymentCipherFieldsScanWithoutJSONExposure(t *testing.T) {
|
||||
const ciphertext = "test-ciphertext-not-a-real-bank-account"
|
||||
record := gdb.Record{}
|
||||
for _, field := range []string{"holder_cipher", "identity_no_cipher", "mobile_cipher", "account_no_cipher", "digital_rmb_code_cipher"} {
|
||||
record[field] = gvar.New(ciphertext)
|
||||
}
|
||||
var row AppMemberPaymentModel
|
||||
if err := record.Struct(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, actual := range map[string]string{"holder": row.HolderCipher, "identity": row.IdentityNoCipher, "mobile": row.MobileCipher, "account": row.AccountNoCipher, "digital": row.DigitalRmbCodeCipher} {
|
||||
if actual != ciphertext {
|
||||
t.Errorf("%s ciphertext was not loaded by database scan", name)
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), ciphertext) {
|
||||
t.Fatal("internal ciphertext must not be exposed in API JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentEditRejectsMaskedValues(t *testing.T) {
|
||||
for _, field := range []string{"holder", "account", "identity", "mobile", "digital"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
in := &AppMemberPaymentEditInp{Id: 1, MemberId: 1, AccountType: 1}
|
||||
switch field {
|
||||
case "holder":
|
||||
in.Holder = "王*"
|
||||
case "account":
|
||||
in.AccountNo = "6222********5937"
|
||||
case "identity":
|
||||
in.IdentityNo = "1234********5678"
|
||||
case "mobile":
|
||||
in.Mobile = "138****1234"
|
||||
case "digital":
|
||||
in.DigitalRMBCode = "0021********8708"
|
||||
}
|
||||
if err := in.Filter(context.Background()); err == nil {
|
||||
t.Fatal("masked values must never overwrite originals")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ type ProductListInp struct {
|
||||
Name string `json:"name"`
|
||||
PaymentType string `json:"paymentType"`
|
||||
CategoryId uint64 `json:"categoryId"`
|
||||
PriceMin string `json:"priceMin" dc:"最低价格"`
|
||||
PriceMax string `json:"priceMax" dc:"最高价格"`
|
||||
PublishStatus int `json:"publishStatus"`
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,13 @@ token:
|
||||
# 业务敏感字段保护。生产环境必须替换为独立的高强度密钥,并通过密钥管理系统注入。
|
||||
security:
|
||||
dataKey: "change-me-before-production"
|
||||
# 迁移前的 HotGo dataKey,仅用于兼容读取,不能删除仍有存量密文使用的密钥。
|
||||
legacyDataKeys: []
|
||||
# 与 C 端收款资料使用相同配置;历史主密钥只用于读取,不修改原密文。
|
||||
financeMasterKey: ""
|
||||
financeLegacyMasterKeys: []
|
||||
financeDataEncryptionKey: ""
|
||||
financeLookupHmacKey: ""
|
||||
|
||||
|
||||
# 消息队列
|
||||
|
||||
@@ -54,30 +54,39 @@ func DecryptField(ctx context.Context, encoded string) (string, error) {
|
||||
if encoded == "" {
|
||||
return "", nil
|
||||
}
|
||||
key, err := fieldKey(ctx)
|
||||
raw, err := decodeFieldPayload(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "敏感字段密文格式错误")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
if len(raw) < 12+16 {
|
||||
return "", gerror.New("敏感字段密文不完整")
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "敏感字段解密失败")
|
||||
keys := financeFieldReadKeys(ctx)
|
||||
if key, keyErr := fieldKey(ctx); keyErr == nil {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return string(plain), nil
|
||||
// Imported HotGo records can use an older dataKey; never use these keys for new writes.
|
||||
for _, secret := range g.Cfg().MustGet(ctx, "security.legacyDataKeys", []string{}).Strings() {
|
||||
if secret = strings.TrimSpace(secret); secret != "" {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
keys = append(keys, sum[:])
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
block, keyErr := aes.NewCipher(key)
|
||||
if keyErr != nil {
|
||||
continue
|
||||
}
|
||||
gcm, keyErr := cipher.NewGCM(block)
|
||||
if keyErr != nil {
|
||||
continue
|
||||
}
|
||||
plain, decryptErr := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if decryptErr == nil {
|
||||
return string(plain), nil
|
||||
}
|
||||
}
|
||||
return "", gerror.New("敏感字段解密失败,请核对当前及历史密钥配置")
|
||||
}
|
||||
|
||||
// HashField creates a deterministic, non-reversible lookup key with a secret pepper.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package encrypt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// financeFieldKey uses the same key derivation as the customer-facing finance service.
|
||||
// An empty configuration retains the existing HotGo format for older installations.
|
||||
func financeFieldKey(ctx context.Context, purpose string) ([]byte, error) {
|
||||
master := strings.TrimSpace(g.Cfg().MustGet(ctx, "security.financeMasterKey", "").String())
|
||||
if master != "" {
|
||||
if len([]byte(master)) < 32 {
|
||||
return nil, gerror.New("收款资料密钥配置不正确")
|
||||
}
|
||||
return deriveFinanceFieldKey(master, purpose), nil
|
||||
}
|
||||
name := "security.financeDataEncryptionKey"
|
||||
if purpose == "lookup-hmac" {
|
||||
name = "security.financeLookupHmacKey"
|
||||
}
|
||||
encoded := strings.TrimSpace(g.Cfg().MustGet(ctx, name, "").String())
|
||||
if encoded == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := decodeFieldPayload(encoded)
|
||||
if err != nil || (purpose == "data-encryption" && len(key) != 32) || (purpose == "lookup-hmac" && len(key) < 32) {
|
||||
return nil, gerror.New("收款资料密钥配置不正确")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func deriveFinanceFieldKey(master, purpose string) []byte {
|
||||
mac := hmac.New(sha256.New, []byte(master))
|
||||
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func financeFieldReadKeys(ctx context.Context) [][]byte {
|
||||
keys := make([][]byte, 0)
|
||||
if key, err := financeFieldKey(ctx, "data-encryption"); err == nil && len(key) > 0 {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
for _, master := range g.Cfg().MustGet(ctx, "security.financeLegacyMasterKeys", []string{}).Strings() {
|
||||
if master = strings.TrimSpace(master); len([]byte(master)) >= 32 {
|
||||
keys = append(keys, deriveFinanceFieldKey(master, "data-encryption"))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func decodeFieldPayload(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, encoding := range []*base64.Encoding{base64.RawURLEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.StdEncoding} {
|
||||
if raw, err := encoding.DecodeString(value); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return nil, gerror.New("敏感字段密文格式错误")
|
||||
}
|
||||
|
||||
// EncryptPaymentField keeps new admin edits readable by the customer-facing service.
|
||||
func EncryptPaymentField(ctx context.Context, plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
key, err := financeFieldKey(ctx, "data-encryption")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return EncryptField(ctx, plaintext)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "初始化收款资料加密失败")
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "初始化收款资料加密失败")
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", gerror.Wrap(err, "生成收款资料随机数失败")
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(plaintext), nil)), nil
|
||||
}
|
||||
|
||||
func HashPaymentField(ctx context.Context, value string) (string, error) {
|
||||
key, err := financeFieldKey(ctx, "lookup-hmac")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return HashField(ctx, value)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(strings.TrimSpace(value)))
|
||||
return hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package encrypt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
)
|
||||
|
||||
func TestDecryptFieldAcceptsCustomerPaymentKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
current := "current-finance-master-key-with-at-least-32-characters"
|
||||
previous := "previous-finance-master-key-with-at-least-32-characters"
|
||||
setFieldTestConfig(t, map[string]interface{}{"dataKey": "admin-legacy-key", "financeMasterKey": current, "financeLegacyMasterKeys": []string{previous}})
|
||||
for _, master := range []string{current, previous} {
|
||||
encoded := customerPaymentFixture(t, master, "6222000000001234")
|
||||
plain, err := DecryptField(ctx, encoded)
|
||||
if err != nil || plain != "6222000000001234" {
|
||||
t.Fatalf("customer payment must decrypt with current or previous key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentWritesRemainReadableByCustomerService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
master := "current-finance-master-key-with-at-least-32-characters"
|
||||
setFieldTestConfig(t, map[string]interface{}{"dataKey": "admin-legacy-key", "financeMasterKey": master})
|
||||
const value = "6222000000001234"
|
||||
encoded, err := EncryptPaymentField(ctx, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := base64.RawStdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatal("payment edit must use the customer's standard Base64 format")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(master))
|
||||
_, _ = mac.Write([]byte("olons-finance:data-encryption:v1"))
|
||||
block, _ := aes.NewCipher(mac.Sum(nil))
|
||||
aead, _ := cipher.NewGCM(block)
|
||||
plain, err := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil)
|
||||
if err != nil || string(plain) != value {
|
||||
t.Fatalf("customer service cannot read admin edit: %v", err)
|
||||
}
|
||||
actualHash, err := HashPaymentField(ctx, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mac = hmac.New(sha256.New, []byte(master))
|
||||
_, _ = mac.Write([]byte("olons-finance:lookup-hmac:v1"))
|
||||
lookup := hmac.New(sha256.New, mac.Sum(nil))
|
||||
_, _ = lookup.Write([]byte(value))
|
||||
if actualHash != hex.EncodeToString(lookup.Sum(nil)) {
|
||||
t.Fatal("admin lookup hash must match customer service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptFieldKeepsLegacyHotGoDataReadable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setFieldTestConfig(t, map[string]interface{}{"dataKey": "admin-legacy-key", "financeMasterKey": "current-finance-master-key-with-at-least-32-characters"})
|
||||
encoded, err := EncryptField(ctx, "旧资料")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := DecryptField(ctx, encoded)
|
||||
if err != nil || plain != "旧资料" {
|
||||
t.Fatalf("legacy HotGo record unreadable: %v", err)
|
||||
}
|
||||
for _, invalid := range []string{"DEMO-NOT-CIPHERTEXT", "AAAA", customerPaymentFixture(t, "unknown-master-key-with-at-least-32-characters", "姓名")} {
|
||||
if _, err := DecryptField(ctx, invalid); err == nil {
|
||||
t.Fatal("invalid or unknown-key records must not be reported as plaintext")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptFieldReadsImportedHotGoKeyWithoutChangingCurrentKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setFieldTestConfig(t, map[string]interface{}{"dataKey": "imported-original-data-key"})
|
||||
encoded, err := EncryptField(ctx, "迁移前的资料")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setFieldTestConfig(t, map[string]interface{}{"dataKey": "current-data-key", "legacyDataKeys": []string{"imported-original-data-key"}, "financeMasterKey": "current-finance-master-key-with-at-least-32-characters"})
|
||||
plain, err := DecryptField(ctx, encoded)
|
||||
if err != nil || plain != "迁移前的资料" {
|
||||
t.Fatalf("imported record unreadable: %v", err)
|
||||
}
|
||||
current, err := EncryptField(ctx, "当前资料")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err = DecryptField(ctx, current)
|
||||
if err != nil || plain != "当前资料" {
|
||||
t.Fatalf("current key was not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setFieldTestConfig(t *testing.T, security map[string]interface{}) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(map[string]interface{}{"security": security})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter, err := gcfg.NewAdapterContent(string(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
}
|
||||
|
||||
func customerPaymentFixture(t *testing.T, master, value string) string {
|
||||
t.Helper()
|
||||
mac := hmac.New(sha256.New, []byte(master))
|
||||
_, _ = mac.Write([]byte("olons-finance:data-encryption:v1"))
|
||||
block, err := aes.NewCipher(mac.Sum(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := []byte{251, 255, 239, 255, 251, 255, 239, 255, 251, 255, 239, 255}
|
||||
return base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(value), nil))
|
||||
}
|
||||
@@ -20,12 +20,12 @@ class DeploymentContract(unittest.TestCase):
|
||||
for ignored in (".git", "web/node_modules", "web/dist", "server/temp"):
|
||||
self.assertIn(ignored, dockerignore)
|
||||
|
||||
def test_manual_workflow_builds_and_rolls_out_test_image(self):
|
||||
def test_manual_workflow_builds_and_rolls_out_pro_image(self):
|
||||
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
|
||||
self.assertIn("workflow_dispatch:", workflow)
|
||||
self.assertIn("git.118im.com/im_team/olons-admin", workflow)
|
||||
self.assertIn("deployment/olons-admin", workflow)
|
||||
self.assertIn("-n olons-admin-system", workflow)
|
||||
self.assertIn("git.olns.shop/pro/olons_admin", workflow)
|
||||
self.assertIn("docker compose pull olons-admin", workflow)
|
||||
self.assertIn("docker compose up -d --no-deps --wait olons-admin", workflow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -136,9 +136,9 @@
|
||||
<n-descriptions-item label="会员编号">{{ detail.memberNo }}</n-descriptions-item>
|
||||
<n-descriptions-item label="手机号">{{ detail.mobile }}</n-descriptions-item>
|
||||
<n-descriptions-item label="真实姓名">{{
|
||||
detail.realNameMask || '-'
|
||||
detail.realName || detail.realNameMask || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件号">{{ detail.idNoMask || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件号">{{ detail.idNo || detail.idNoMask || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="团队层级">{{ detail.level }}</n-descriptions-item>
|
||||
<n-descriptions-item label="可用余额">{{
|
||||
money(detail.availableAmount)
|
||||
@@ -165,12 +165,6 @@
|
||||
>收货地址</n-button
|
||||
>
|
||||
<n-button v-if="hasPermission(['/appMember/team'])" @click="openTeam">团队查询</n-button>
|
||||
<n-button
|
||||
v-if="hasPermission(['/appMember/sensitive'])"
|
||||
type="warning"
|
||||
@click="openSensitive('member', detail.id)"
|
||||
>授权查看明文</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
@@ -184,14 +178,10 @@
|
||||
<n-spin :show="relatedLoading">
|
||||
<n-form label-placement="left" label-width="110">
|
||||
<template v-if="relatedType === 'payment'">
|
||||
<n-alert v-if="paymentForm.id" type="info" :show-icon="false">
|
||||
当前默认显示脱敏资料;留空保存不会覆盖原敏感字段或收款码。
|
||||
<n-button
|
||||
v-if="hasPermission(['/appMember/sensitive'])"
|
||||
text
|
||||
type="primary"
|
||||
@click="openSensitive('payment', paymentForm.id)"
|
||||
>授权查看明文</n-button>
|
||||
<n-alert v-if="paymentForm.id" :type="paymentForm.sensitiveUnavailable ? 'error' : 'warning'" :show-icon="false">
|
||||
{{ paymentForm.sensitiveUnavailable
|
||||
? '部分历史资料暂无法解密。脱敏值只作占位,不会写回原数据;请留空保留原值。'
|
||||
: '资料已明文展示。留空保存不会覆盖原字段或收款码。' }}
|
||||
</n-alert>
|
||||
<n-form-item label="开户人"
|
||||
><n-input v-model:value="paymentForm.holder" :placeholder="paymentForm.holderMask || '留空不修改'"
|
||||
@@ -284,35 +274,6 @@
|
||||
<n-empty v-if="!ledgerLoading && ledgerRows.length === 0" description="暂无账变记录" />
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showSensitive"
|
||||
preset="dialog"
|
||||
title="授权查看敏感信息"
|
||||
:show-icon="false"
|
||||
>
|
||||
<n-alert type="error"
|
||||
>明文仅用于当前业务目的,请勿复制传播。本次操作将记录查看人、对象、用途和结果。</n-alert
|
||||
>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="用途编码"
|
||||
><n-input
|
||||
v-model:value="sensitiveForm.purposeCode"
|
||||
placeholder="如 customer_service_verify"
|
||||
/></n-form-item>
|
||||
<n-form-item label="用途说明"
|
||||
><n-input v-model:value="sensitiveForm.purposeText" type="textarea"
|
||||
/></n-form-item>
|
||||
<n-checkbox v-model:checked="sensitiveForm.confirm">我确认业务需要并接受留痕</n-checkbox>
|
||||
</n-form>
|
||||
<n-code v-if="sensitiveResult" :code="sensitiveResult" language="json" word-wrap />
|
||||
<template #action
|
||||
><n-space
|
||||
><n-button @click="showSensitive = false">关闭</n-button
|
||||
><n-button type="error" @click="revealSensitive">确认查看</n-button></n-space
|
||||
></template
|
||||
>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showStatus" preset="dialog" title="信息" :show-icon="false">
|
||||
<div>确定{{ statusForm.toStatus === 1 ? '启用' : '禁用' }}此用户吗?</div>
|
||||
<template #action
|
||||
@@ -341,8 +302,11 @@
|
||||
>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showNotice" preset="card" title="发送弹幕" class="related-modal">
|
||||
<n-modal v-model:show="showNotice" preset="card" title="发送系统通知" class="related-modal">
|
||||
<n-form label-placement="left" label-width="100">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
仅当前会员可见;会员在任意已登录页面通常15秒内收到系统通知弹窗,首页公告栏继续轮询。
|
||||
</n-alert>
|
||||
<n-form-item label="用户ID"
|
||||
><n-input :value="String(noticeMember?.id || '')" disabled
|
||||
/></n-form-item>
|
||||
@@ -352,8 +316,18 @@
|
||||
<n-form-item label="用户手机"
|
||||
><n-input :value="noticeMember?.mobile || ''" disabled
|
||||
/></n-form-item>
|
||||
<n-form-item label="发送内容"
|
||||
><n-input v-model:value="noticeForm.content" placeholder="请输入发送内容"
|
||||
<n-form-item label="通知标题"
|
||||
><n-input v-model:value="noticeForm.title" maxlength="120" placeholder="请输入通知标题"
|
||||
/></n-form-item>
|
||||
<n-form-item label="通知内容"
|
||||
><n-input v-model:value="noticeForm.content" type="textarea" maxlength="500" placeholder="请输入通知内容"
|
||||
/></n-form-item>
|
||||
<n-form-item label="有效期"
|
||||
><n-date-picker
|
||||
v-model:formatted-value="noticeDateRange"
|
||||
type="datetimerange"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
clearable
|
||||
/></n-form-item>
|
||||
</n-form>
|
||||
<template #action
|
||||
@@ -389,7 +363,6 @@
|
||||
AddressList,
|
||||
AddressEdit,
|
||||
AddressDelete,
|
||||
Sensitive,
|
||||
Team,
|
||||
StatusRequest,
|
||||
LedgerList,
|
||||
@@ -429,7 +402,7 @@
|
||||
editFormRef = ref<any>(),
|
||||
memberTableWrapRef = ref<HTMLElement | null>(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 +412,6 @@
|
||||
showRelatedForm = ref(false),
|
||||
showTeam = ref(false),
|
||||
showLedger = ref(false),
|
||||
showSensitive = ref(false),
|
||||
showStatus = ref(false),
|
||||
showWallet = ref(false),
|
||||
showNotice = ref(false),
|
||||
@@ -454,20 +426,12 @@
|
||||
teamRows = ref<any[]>([]),
|
||||
relatedType = ref<'payment' | 'address'>('payment'),
|
||||
teamLevel = ref(3),
|
||||
sensitiveResult = ref(''),
|
||||
walletYuan = ref<number | null>(null),
|
||||
memberLevelOptions = ref<any[]>([]),
|
||||
noticeMember = ref<MemberRecord | null>(null);
|
||||
const editForm = reactive<any>({}),
|
||||
paymentForm = reactive<any>({}),
|
||||
addressForm = reactive<any>({}),
|
||||
sensitiveForm = reactive<any>({
|
||||
targetType: 'member',
|
||||
targetId: 0,
|
||||
purposeCode: '',
|
||||
purposeText: '',
|
||||
confirm: false,
|
||||
}),
|
||||
statusForm = reactive<any>({ memberId: 0, toStatus: 2, reasonCode: '', reasonText: '' }),
|
||||
walletForm = reactive<any>({
|
||||
memberId: 0,
|
||||
@@ -479,7 +443,7 @@
|
||||
const editRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名称', trigger: ['input', 'blur'] },
|
||||
{ min: 3, max: 64, message: '用户名长度为3到64个字符', trigger: ['input', 'blur'] },
|
||||
{ min: 2, max: 64, message: '用户名长度为2到64个字符', trigger: ['input', 'blur'] },
|
||||
],
|
||||
mobile: {
|
||||
validator: () => true,
|
||||
@@ -508,8 +472,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<any[]>([]),
|
||||
ledgerMember = ref<MemberRecord | null>(null);
|
||||
const reset = (obj, value) => {
|
||||
@@ -580,7 +544,7 @@
|
||||
auth: ['/appMember/walletRequest'],
|
||||
},
|
||||
{
|
||||
label: '弹幕',
|
||||
label: '弹窗',
|
||||
color: '#009688',
|
||||
onClick: () => openNotice(record),
|
||||
auth: ['/appMember/noticeEdit'],
|
||||
@@ -772,16 +736,17 @@
|
||||
reset(paymentForm, {
|
||||
memberId,
|
||||
id: bank?.id || 0,
|
||||
holder: '',
|
||||
holder: editableSensitive(bank?.holder),
|
||||
sensitiveUnavailable: bank?.sensitiveUnavailable || false,
|
||||
holderMask: bank?.holderMask || '',
|
||||
identityNo: '',
|
||||
identityNo: editableSensitive(bank?.identityNo),
|
||||
identityNoMask: bank?.identityNoMask || '',
|
||||
bankName: bank?.bankName || '',
|
||||
mobile: '',
|
||||
mobile: editableSensitive(bank?.mobile),
|
||||
mobileMask: bank?.mobileMask || '',
|
||||
bankAccount: '',
|
||||
bankAccount: editableSensitive(bank?.accountNo),
|
||||
accountNoMask: bank?.accountNoMask || '',
|
||||
digitalWallet: '',
|
||||
digitalWallet: editableSensitive(bank?.digitalRmbCode),
|
||||
digitalRmbCodeMask: bank?.digitalRmbCodeMask || '',
|
||||
alipayQrCode: bank?.alipayQrUrl || '',
|
||||
wechatQrCode: bank?.wechatQrUrl || '',
|
||||
@@ -794,14 +759,14 @@
|
||||
reset(addressForm, {
|
||||
id: row?.id || 0,
|
||||
memberId,
|
||||
receiver: '',
|
||||
mobile: '',
|
||||
receiver: editableSensitive(row?.receiver),
|
||||
mobile: editableSensitive(row?.mobile),
|
||||
region,
|
||||
originalRegion: region,
|
||||
province: row?.province || '',
|
||||
city: row?.city || '',
|
||||
district: row?.district || '',
|
||||
address: '',
|
||||
address: editableSensitive(row?.address),
|
||||
isDefault: 1,
|
||||
});
|
||||
}
|
||||
@@ -816,6 +781,9 @@
|
||||
: await AddressList({ memberId: detail.value.id });
|
||||
relatedRows.value = r.list || [];
|
||||
}
|
||||
function editableSensitive(value: unknown) {
|
||||
return typeof value === 'string' && !value.includes('*') ? value : '';
|
||||
}
|
||||
function startRelatedAdd() {
|
||||
showRelatedForm.value = true;
|
||||
if (relatedType.value === 'payment')
|
||||
@@ -826,7 +794,8 @@
|
||||
bankName: '',
|
||||
holder: '',
|
||||
mobile: '',
|
||||
accountNo: '',
|
||||
bankAccount: '',
|
||||
digitalWallet: '',
|
||||
verifyStatus: 1,
|
||||
isDefault: 2,
|
||||
});
|
||||
@@ -893,9 +862,16 @@
|
||||
memberId: row.memberId,
|
||||
accountType: row.accountType,
|
||||
bankName: row.bankName,
|
||||
holder: '',
|
||||
mobile: '',
|
||||
accountNo: '',
|
||||
holder: editableSensitive(row.holder),
|
||||
sensitiveUnavailable: row.sensitiveUnavailable || false,
|
||||
identityNo: editableSensitive(row.identityNo),
|
||||
mobile: editableSensitive(row.mobile),
|
||||
bankAccount: editableSensitive(row.accountNo),
|
||||
digitalWallet: editableSensitive(row.digitalRmbCode),
|
||||
wechatQrCode: row.wechatQrUrl || '',
|
||||
alipayQrCode: row.alipayQrUrl || '',
|
||||
wechatQrAttachmentId: row.wechatQrAttachmentId || 0,
|
||||
alipayQrAttachmentId: row.alipayQrAttachmentId || 0,
|
||||
verifyStatus: row.verifyStatus,
|
||||
isDefault: row.isDefault,
|
||||
});
|
||||
@@ -903,30 +879,15 @@
|
||||
reset(addressForm, {
|
||||
id: row.id,
|
||||
memberId: row.memberId,
|
||||
receiver: '',
|
||||
mobile: '',
|
||||
receiver: editableSensitive(row.receiver),
|
||||
mobile: editableSensitive(row.mobile),
|
||||
province: row.province,
|
||||
city: row.city,
|
||||
district: row.district,
|
||||
address: '',
|
||||
address: editableSensitive(row.address),
|
||||
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 +961,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 +983,7 @@
|
||||
startAt: noticeDateRange.value[0],
|
||||
endAt: noticeDateRange.value[1],
|
||||
});
|
||||
message.success('弹幕已发送');
|
||||
message.success('系统通知已发送');
|
||||
showNotice.value = false;
|
||||
}
|
||||
const relatedColumns = computed(() =>
|
||||
@@ -1025,8 +994,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 +1003,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 +1012,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) },
|
||||
|
||||
@@ -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 || '-' },
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchRef" @register="register" @submit="reload" @keyup.enter="reload" />
|
||||
<BasicForm ref="searchRef" @register="register" @submit="reload" @keyup.enter="reload">
|
||||
<template #priceRangeSlot="{ model }">
|
||||
<n-space :wrap="false" align="center">
|
||||
<n-input-number
|
||||
v-model:value="model.priceMin"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:show-button="false"
|
||||
clearable
|
||||
placeholder="最低价"
|
||||
/>
|
||||
<span>-</span>
|
||||
<n-input-number
|
||||
v-model:value="model.priceMax"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:show-button="false"
|
||||
clearable
|
||||
placeholder="最高价"
|
||||
/>
|
||||
</n-space>
|
||||
</template>
|
||||
</BasicForm>
|
||||
<BasicTable
|
||||
ref="tableRef"
|
||||
openChecked
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { h, ref } from 'vue';
|
||||
import { NImage, NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
@@ -13,14 +13,9 @@ export const schemas = ref<FormSchema[]>([
|
||||
componentProps: { clearable: true },
|
||||
},
|
||||
{
|
||||
field: 'categoryId',
|
||||
component: 'NSelect',
|
||||
label: '商品分类',
|
||||
componentProps: {
|
||||
options: computed(() => dict.getOptionUnRef('productCategoryOption')),
|
||||
clearable: true,
|
||||
placeholder: '请选择',
|
||||
},
|
||||
field: 'priceRange',
|
||||
label: '价格范围',
|
||||
slot: 'priceRangeSlot',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -62,4 +57,4 @@ export const columns = [
|
||||
},
|
||||
];
|
||||
|
||||
export const loadOptions = () => dict.loadOptions(['productCategoryOption', 'bizPublishStatus']);
|
||||
export const loadOptions = () => dict.loadOptions(['bizPublishStatus']);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="订单列表">手动单匹配后立即完成并允许余额为负;自动单可在预约时间前人工匹配,逾期未处理则由系统自动匹配。</n-card></div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="订单列表">匹配时会校验会员可用余额;余额不足的订单保留为待处理,充值审核通过且余额足够后自动完成并结算本金及佣金。</n-card></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row)=>row.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000">
|
||||
@@ -45,6 +45,9 @@
|
||||
<n-form-item label="商品数量">
|
||||
<n-input-number v-model:value="matchForm.quantity" :min="1" :max="999" />
|
||||
</n-form-item>
|
||||
<n-alert v-if="matchShortfall > 0" type="warning" :bordered="false">
|
||||
会员余额不足:当前可用 {{ yuan(matchMember.availableAmount) }},订单金额 {{ yuan(selectedProduct.priceAmount * matchForm.quantity) }},还差 {{ yuan(matchShortfall) }}。订单将保留为待处理,充值审核通过且余额足够后自动完成。
|
||||
</n-alert>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
<template #action>
|
||||
@@ -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<any[]>([]),selectedProduct=ref<any>();
|
||||
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});
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user