积分充值全流程落地:申请审核入账、凭证上传压缩转webp、管理员调账收权

- 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分),
  管理员审核通过后事务内自动入账,幂等键防重复
- 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除,
  后端按魔数校验图片格式并持久化到 data/uploads
- 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员
  平台商户积分调整与钱包流水查看
- 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送
- 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算
This commit is contained in:
yml2213
2026-08-04 13:03:29 +08:00
parent 54f05c15a4
commit 941e7ad79a
22 changed files with 1658 additions and 18 deletions
+3 -3
View File
@@ -125,9 +125,9 @@ func TestCallbackSubscriptionRotateSecret(t *testing.T) {
// 显式重置密钥:返回新 secret,且与旧 secret 不同
rotated, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/cb",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
URL: "https://example.com/cb",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
RotateSecret: true,
}, 7)
if err != nil {
+1 -1
View File
@@ -399,7 +399,7 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
"game_uid": gameAccount,
"role_name": stringFromMap(boundAccount, "game_account_role_name"),
"game_channel": gameChannelText(boundAccount),
"delivery": upstreamOrder,
"delivery": upstreamOrder,
})
nextStatus := model.OrderStatusDelivering
message := "已提交发货,等待发货结果回传"
+1 -1
View File
@@ -91,7 +91,7 @@ func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uin
if err := tx.Create(merchant).Error; err != nil {
return err
}
if err := tx.Create(&model.WalletAccount{MerchantID: merchant.ID, Currency: "POINT"}).Error; err != nil {
if err := tx.Create(&model.WalletAccount{MerchantID: merchant.ID, Currency: "POINT"}).Error; err != nil {
return err
}
if err := tx.Create(&model.MerchantMember{
+315
View File
@@ -0,0 +1,315 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 充值约束:最小 10 元;1 元 = 100 积分,即 1 分人民币 = 1 积分。
const (
RechargeMinAmountCNYCents = 1000 // 10 元(单位:分)
RechargePointsPerCNYCent = 1 // 每 1 分人民币兑换积分
)
type RechargeService struct {
db *gorm.DB
fulfill *FulfillmentService
uploadDir string
}
func NewRechargeService(db *gorm.DB, fulfill *FulfillmentService, uploadDir string) *RechargeService {
return &RechargeService{db: db, fulfill: fulfill, uploadDir: uploadDir}
}
type CreateRechargeInput struct {
MerchantID uint
ActorUserID uint
AmountCNY int64 // 人民币,单位:分
Vouchers []string
Note string
}
func (s *RechargeService) CreateRecharge(in CreateRechargeInput) (*model.RechargeApplication, error) {
if in.MerchantID == 0 {
return nil, errors.New("无效的商户")
}
if in.AmountCNY < RechargeMinAmountCNYCents {
return nil, errors.New("最小充值金额为 10 元")
}
if len(in.Vouchers) == 0 {
return nil, errors.New("请上传打款凭证截图")
}
cleaned := make([]string, 0, len(in.Vouchers))
for _, v := range in.Vouchers {
v = strings.TrimSpace(v)
if v != "" {
cleaned = append(cleaned, v)
}
}
if len(cleaned) == 0 {
return nil, errors.New("请上传打款凭证截图")
}
if len(cleaned) > 5 {
return nil, errors.New("凭证截图最多 5 张")
}
if len(in.Note) > 512 {
return nil, errors.New("备注最长 512 个字符")
}
app := &model.RechargeApplication{
MerchantID: in.MerchantID,
ApplicationNo: newRechargeApplicationNo(),
AmountCNY: in.AmountCNY,
PointsAmount: in.AmountCNY * RechargePointsPerCNYCent,
Status: model.RechargeStatusPending,
Vouchers: cleaned,
Note: strings.TrimSpace(in.Note),
}
if err := s.db.Create(app).Error; err != nil {
return nil, err
}
_ = writeAudit(s.db, &app.MerchantID, &in.ActorUserID, nil, "recharge.create", "recharge_application", fmt.Sprint(app.ID), map[string]interface{}{"application_no": app.ApplicationNo, "amount_cny": app.AmountCNY})
return app, nil
}
func (s *RechargeService) ListRechargeApplications(merchantID uint, page, size int, status string) ([]model.RechargeApplication, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.RechargeApplication{}).Where("merchant_id = ?", merchantID)
if status != "" {
tx = tx.Where("status = ?", status)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.RechargeApplication
err := tx.Preload("Merchant").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
func (s *RechargeService) ListAllRechargeApplications(page, size int, status string) ([]model.RechargeApplication, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.RechargeApplication{})
if status != "" {
tx = tx.Where("status = ?", status)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.RechargeApplication
err := tx.Preload("Merchant").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
type ReviewRechargeInput struct {
ApplicationID uint
Approved bool
ReviewNote string
ActorUserID uint
}
// ReviewRecharge 审核充值申请;通过时在申请与入账共用一个事务内调用 AdjustWallet
// (幂等键 = 申请单号),保证审核状态与钱包入账一致。
func (s *RechargeService) ReviewRecharge(in ReviewRechargeInput) (*model.RechargeApplication, error) {
var out model.RechargeApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
var app model.RechargeApplication
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&app, in.ApplicationID).Error; err != nil {
return errors.New("充值申请不存在")
}
if app.Status != model.RechargeStatusPending {
return errors.New("该申请已审核,不能重复操作")
}
status := model.RechargeStatusRejected
if in.Approved {
status = model.RechargeStatusApproved
}
now := time.Now()
updates := map[string]interface{}{
"status": status,
"review_note": strings.TrimSpace(in.ReviewNote),
"reviewed_at": now,
"reviewed_by": in.ActorUserID,
}
if err := tx.Model(&app).Updates(updates).Error; err != nil {
return err
}
if in.Approved {
wallet, err := s.fulfill.AdjustWallet(WalletAdjustInput{
MerchantID: app.MerchantID,
ActorUserID: in.ActorUserID,
Amount: app.PointsAmount,
IdempotencyKey: app.ApplicationNo,
Note: fmt.Sprintf("充值入账 %s%s", app.ApplicationNo, app.Note),
})
if err != nil {
return fmt.Errorf("积分入账失败:%w", err)
}
_ = wallet
}
out = app
out.Status = status
out.ReviewNote = strings.TrimSpace(in.ReviewNote)
out.ReviewedAt = &now
return writeAudit(tx, &app.MerchantID, &in.ActorUserID, nil, "recharge.review", "recharge_application", fmt.Sprint(app.ID), map[string]interface{}{"application_no": app.ApplicationNo, "approved": in.Approved, "points_amount": app.PointsAmount})
})
if err != nil {
return nil, err
}
return &out, nil
}
type SaveAlertInput struct {
Enabled bool
ThresholdPoints int64
WebhookURL string
}
func (s *RechargeService) GetAlertConfig(merchantID uint) (*model.MerchantAlertConfig, error) {
var cfg model.MerchantAlertConfig
err := s.db.Where("merchant_id = ?", merchantID).First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &model.MerchantAlertConfig{MerchantID: merchantID}, nil
}
if err != nil {
return nil, err
}
return &cfg, nil
}
func (s *RechargeService) SaveAlertConfig(merchantID uint, in SaveAlertInput) (*model.MerchantAlertConfig, error) {
if in.ThresholdPoints < 0 {
return nil, errors.New("预警阈值不能为负")
}
if in.WebhookURL == "" || len(in.WebhookURL) > 1024 {
return nil, errors.New("请填写 Webhook URL")
}
if in.Enabled {
if !strings.HasPrefix(in.WebhookURL, "http://") && !strings.HasPrefix(in.WebhookURL, "https://") {
return nil, errors.New("Webhook URL 必须以 http(s):// 开头")
}
}
var cfg model.MerchantAlertConfig
err := s.db.Where("merchant_id = ?", merchantID).First(&cfg).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
cfg = model.MerchantAlertConfig{MerchantID: merchantID, Enabled: in.Enabled, ThresholdPoints: in.ThresholdPoints, WebhookURL: strings.TrimSpace(in.WebhookURL)}
if err := s.db.Create(&cfg).Error; err != nil {
return nil, err
}
case err != nil:
return nil, err
default:
cfg.Enabled = in.Enabled
cfg.ThresholdPoints = in.ThresholdPoints
cfg.WebhookURL = strings.TrimSpace(in.WebhookURL)
if err := s.db.Save(&cfg).Error; err != nil {
return nil, err
}
}
return &cfg, nil
}
// CheckLowBalanceAndNotify 余额低于阈值时向配置的 Webhook 推送告警。
func (s *RechargeService) CheckLowBalanceAndNotify(merchantID uint) {
cfg, err := s.GetAlertConfig(merchantID)
if err != nil || !cfg.Enabled || cfg.ThresholdPoints <= 0 {
return
}
wallet, err := s.fulfill.GetWallet(merchantID)
if err != nil {
return
}
if wallet.AvailableBalance >= cfg.ThresholdPoints {
return
}
var merchant model.Merchant
if err := s.db.Select("code", "name").First(&merchant, merchantID).Error; err != nil {
merchant.Name = fmt.Sprintf("商户#%d", merchantID)
}
payload := map[string]interface{}{
"msgtype": "text",
"text": map[string]string{
"content": fmt.Sprintf("余额告警:商户 %s(#%d)当前积分余额 %d,已低于阈值 %d,请及时处理充值。", merchant.Name, merchantID, wallet.AvailableBalance, cfg.ThresholdPoints),
},
}
raw, _ := json.Marshal(payload)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(cfg.WebhookURL, "application/json", bytes.NewReader(raw))
if err != nil {
log.Printf("低余额告警推送失败 merchant=%d: %v", merchantID, err)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("低余额告警推送非 2xx merchant=%d status=%d", merchantID, resp.StatusCode)
}
}
// SaveUploadFile 保存上传的图片到 uploadDir,返回可通过 /uploads 访问的 URL。
// 通过文件魔数校验真实图片格式(jpg/png/gif/webp),存储扩展名以实际格式为准。
func (s *RechargeService) SaveUploadFile(r io.Reader, _ string, maxBytes int64) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return "", errors.New("读取上传文件失败")
}
if len(data) == 0 {
return "", errors.New("上传文件为空")
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("图片大小不能超过 %dMB", maxBytes/1024/1024)
}
ext := detectImageExt(data)
if ext == "" {
return "", errors.New("仅支持 jpg/png/gif/webp 图片")
}
name := uuid.NewString() + "." + ext
if err := os.MkdirAll(s.uploadDir, 0o755); err != nil {
return "", errors.New("创建上传目录失败")
}
if err := os.WriteFile(filepath.Join(s.uploadDir, name), data, 0o644); err != nil {
return "", errors.New("保存上传文件失败")
}
return "/uploads/" + name, nil
}
// detectImageExt 根据文件头魔数识别图片格式;非法文件返回空串。
func detectImageExt(data []byte) string {
switch {
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
return "jpg"
case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G':
return "png"
case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8':
return "gif"
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P':
return "webp"
default:
return ""
}
}
func newRechargeApplicationNo() string {
return "RC" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
}
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestCreateRechargeRequiresVoucher(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-novoucher", 0, -1, 100)
svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir())
if _, err := svc.CreateRecharge(CreateRechargeInput{
MerchantID: merchantID,
AmountCNY: 10000,
}); err == nil || !strings.Contains(err.Error(), "凭证") {
t.Fatalf("empty voucher should be rejected, got %v", err)
}
if _, err := svc.CreateRecharge(CreateRechargeInput{
MerchantID: merchantID,
AmountCNY: 100,
Vouchers: []string{"/uploads/a.png"},
}); err == nil || !strings.Contains(err.Error(), "10 元") {
t.Fatalf("amount below minimum should be rejected, got %v", err)
}
app, err := svc.CreateRecharge(CreateRechargeInput{
MerchantID: merchantID,
ActorUserID: 7,
AmountCNY: 50000,
Vouchers: []string{"/uploads/a.png"},
Note: "对公转账",
})
if err != nil {
t.Fatalf("create recharge: %v", err)
}
if app.Status != model.RechargeStatusPending || app.PointsAmount != 50000 {
t.Fatalf("unexpected application: %+v", app)
}
}
func TestReviewRechargeCreditsWalletOnce(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-review", 0, -1, 100)
fulfillment := NewFulfillmentService(db, nil)
svc := NewRechargeService(db, fulfillment, t.TempDir())
app, err := svc.CreateRecharge(CreateRechargeInput{
MerchantID: merchantID,
ActorUserID: 7,
AmountCNY: 100000, // 1000 元 = 100000 积分
Vouchers: []string{"/uploads/a.png"},
})
if err != nil {
t.Fatalf("create recharge: %v", err)
}
reviewed, err := svc.ReviewRecharge(ReviewRechargeInput{
ApplicationID: app.ID,
Approved: true,
ReviewNote: "已核实到账",
ActorUserID: 1,
})
if err != nil {
t.Fatalf("review approve: %v", err)
}
if reviewed.Status != model.RechargeStatusApproved || reviewed.PointsAmount != 100000 {
t.Fatalf("unexpected reviewed app: %+v", reviewed)
}
wallet, err := fulfillment.GetWallet(merchantID)
if err != nil {
t.Fatalf("get wallet: %v", err)
}
if wallet.AvailableBalance != 100000 {
t.Fatalf("approved recharge should credit wallet, got %d", wallet.AvailableBalance)
}
// 重复审核应被拒绝且不再入账
if _, err := svc.ReviewRecharge(ReviewRechargeInput{
ApplicationID: app.ID,
Approved: true,
ActorUserID: 1,
}); err == nil || !strings.Contains(err.Error(), "已审核") {
t.Fatalf("repeat review should be rejected, got %v", err)
}
wallet, _ = fulfillment.GetWallet(merchantID)
if wallet.AvailableBalance != 100000 {
t.Fatalf("repeat review should not credit again, got %d", wallet.AvailableBalance)
}
}
func TestDetectImageExt(t *testing.T) {
cases := []struct {
name string
data []byte
want string
}{
{"jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00}, "jpg"},
{"png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, "png"},
{"gif", []byte{'G', 'I', 'F', '8', '9', 'a'}, "gif"},
{"webp", []byte{'R', 'I', 'F', 'F', 0x00, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P'}, "webp"},
{"fake", []byte{'<', 's', 'c', 'r', 'i', 'p', 't', '>'}, ""},
{"empty", []byte{}, ""},
}
for _, tc := range cases {
if got := detectImageExt(tc.data); got != tc.want {
t.Fatalf("%s: detectImageExt = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestReviewRechargeReject(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-recharge-reject", 0, -1, 100)
fulfillment := NewFulfillmentService(db, nil)
svc := NewRechargeService(db, fulfillment, t.TempDir())
app, err := svc.CreateRecharge(CreateRechargeInput{
MerchantID: merchantID,
ActorUserID: 7,
AmountCNY: 10000,
Vouchers: []string{"/uploads/a.png"},
})
if err != nil {
t.Fatalf("create recharge: %v", err)
}
reviewed, err := svc.ReviewRecharge(ReviewRechargeInput{
ApplicationID: app.ID,
Approved: false,
ReviewNote: "凭证不清晰",
ActorUserID: 1,
})
if err != nil {
t.Fatalf("review reject: %v", err)
}
if reviewed.Status != model.RechargeStatusRejected {
t.Fatalf("expected rejected, got %+v", reviewed)
}
wallet, _ := fulfillment.GetWallet(merchantID)
if wallet.AvailableBalance != 0 {
t.Fatalf("rejected recharge should not credit, got %d", wallet.AvailableBalance)
}
}