积分充值全流程落地:申请审核入账、凭证上传压缩转webp、管理员调账收权
- 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分), 管理员审核通过后事务内自动入账,幂等键防重复 - 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除, 后端按魔数校验图片格式并持久化到 data/uploads - 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员 平台商户积分调整与钱包流水查看 - 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送 - 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算
This commit is contained in:
@@ -74,6 +74,7 @@ func main() {
|
||||
}))
|
||||
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
||||
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
||||
rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, cfg.UploadDir)
|
||||
deliverySvc := service.NewDeliveryService(
|
||||
fulfillmentSvc,
|
||||
cfg.DeliveryBFFBaseURL,
|
||||
@@ -96,7 +97,8 @@ func main() {
|
||||
User: handler.NewUserHandler(userSvc),
|
||||
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
|
||||
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
|
||||
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc),
|
||||
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc),
|
||||
Recharge: handler.NewRechargeHandler(rechargeSvc),
|
||||
JWT: jm,
|
||||
Tenant: tenantSvc,
|
||||
OpenDB: db,
|
||||
@@ -105,6 +107,7 @@ func main() {
|
||||
OpenAPISecret: cfg.OpenAPISecret,
|
||||
OpenSignSkew: cfg.OpenSignSkew,
|
||||
OpenAPIDebug: cfg.OpenAPIDebug,
|
||||
UploadDir: cfg.UploadDir,
|
||||
}
|
||||
|
||||
go callbackSvc.Run(context.Background())
|
||||
|
||||
@@ -49,6 +49,8 @@ type Config struct {
|
||||
// CallbackRetryScheduleSeconds 回调失败重试间隔序列(秒,逗号分隔,微信/支付宝式固定退避)。
|
||||
// 为空时使用默认微信式序列。
|
||||
CallbackRetryScheduleSeconds []int
|
||||
// UploadDir 凭证等上传文件的存储目录(默认 data/uploads,通过 /uploads 公开访问)。
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||
@@ -79,6 +81,7 @@ func Load() *Config {
|
||||
CallbackMaxAttempts: getEnvInt("CALLBACK_MAX_ATTEMPTS", 16),
|
||||
CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15),
|
||||
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
|
||||
UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- ============================================================================
|
||||
-- 积分充值申请与低余额告警配置
|
||||
-- 充值需上传打款凭证,由平台管理员审核通过后自动注入积分
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recharge_applications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
merchant_id BIGINT NOT NULL,
|
||||
application_no VARCHAR(64) NOT NULL UNIQUE,
|
||||
amount_cny BIGINT NOT NULL,
|
||||
points_amount BIGINT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
vouchers TEXT NOT NULL DEFAULT '[]',
|
||||
note VARCHAR(512) NOT NULL DEFAULT '',
|
||||
review_note VARCHAR(512) NOT NULL DEFAULT '',
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recharge_applications_merchant ON recharge_applications (merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recharge_applications_status ON recharge_applications (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS merchant_alert_configs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
merchant_id BIGINT NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
threshold_points BIGINT NOT NULL DEFAULT 0,
|
||||
webhook_url VARCHAR(1024) NOT NULL DEFAULT ''
|
||||
);
|
||||
@@ -17,14 +17,16 @@ type MerchantHandler struct {
|
||||
fulfillmentSvc *service.FulfillmentService
|
||||
callbackSvc *service.CallbackService
|
||||
deliverySvc *service.DeliveryService
|
||||
rechargeSvc *service.RechargeService
|
||||
}
|
||||
|
||||
func NewMerchantHandler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, callbackSvc *service.CallbackService, deliverySvc *service.DeliveryService) *MerchantHandler {
|
||||
func NewMerchantHandler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, callbackSvc *service.CallbackService, deliverySvc *service.DeliveryService, rechargeSvc *service.RechargeService) *MerchantHandler {
|
||||
return &MerchantHandler{
|
||||
merchantSvc: merchantSvc,
|
||||
fulfillmentSvc: fulfillmentSvc,
|
||||
callbackSvc: callbackSvc,
|
||||
deliverySvc: deliverySvc,
|
||||
rechargeSvc: rechargeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +276,7 @@ func (h *MerchantHandler) AdminAdjustWallet(c *gin.Context) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
h.rechargeSvc.CheckLowBalanceAndNotify(uint(id))
|
||||
response.OK(c, wallet)
|
||||
}
|
||||
|
||||
@@ -359,10 +362,10 @@ func (h *MerchantHandler) ListCallbacks(c *gin.Context) {
|
||||
}
|
||||
|
||||
type callbackReq struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Events string `json:"events" binding:"required"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Events string `json:"events" binding:"required"`
|
||||
Status string `json:"status"`
|
||||
RotateSecret bool `json:"rotate_secret"`
|
||||
}
|
||||
|
||||
@@ -373,10 +376,10 @@ func (h *MerchantHandler) CreateCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
credential, err := h.callbackSvc.CreateSubscription(middleware.GetMerchantID(c), service.CreateCallbackInput{
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Events: req.Events,
|
||||
Status: req.Status,
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Events: req.Events,
|
||||
Status: req.Status,
|
||||
RotateSecret: req.RotateSecret,
|
||||
}, middleware.GetUserID(c))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"affiliate_dash/internal/middleware"
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const uploadMaxBytes = 5 * 1024 * 1024 // 单张图片最大 5MB
|
||||
|
||||
// RechargeHandler 提供积分充值申请(商户侧)与审核入账(管理员侧)能力。
|
||||
type RechargeHandler struct {
|
||||
rechargeSvc *service.RechargeService
|
||||
}
|
||||
|
||||
func NewRechargeHandler(rechargeSvc *service.RechargeService) *RechargeHandler {
|
||||
return &RechargeHandler{rechargeSvc: rechargeSvc}
|
||||
}
|
||||
|
||||
type rechargeCreateReq struct {
|
||||
AmountCNY int64 `json:"amount_cny" binding:"required"` // 人民币,单位:分
|
||||
Vouchers []string `json:"vouchers" binding:"required"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// CreateApplication POST /api/merchant/recharge/applications
|
||||
func (h *RechargeHandler) CreateApplication(c *gin.Context) {
|
||||
var req rechargeCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:amount_cny 与 vouchers 必填")
|
||||
return
|
||||
}
|
||||
app, err := h.rechargeSvc.CreateRecharge(service.CreateRechargeInput{
|
||||
MerchantID: middleware.GetMerchantID(c),
|
||||
ActorUserID: middleware.GetUserID(c),
|
||||
AmountCNY: req.AmountCNY,
|
||||
Vouchers: req.Vouchers,
|
||||
Note: req.Note,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, app)
|
||||
}
|
||||
|
||||
// ListApplications GET /api/merchant/recharge/applications(当前商户)
|
||||
func (h *RechargeHandler) ListApplications(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
list, total, err := h.rechargeSvc.ListRechargeApplications(middleware.GetMerchantID(c), page, size, c.Query("status"))
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
// AdminListApplications GET /api/platform/recharge/applications(全部商户)
|
||||
func (h *RechargeHandler) AdminListApplications(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
list, total, err := h.rechargeSvc.ListAllRechargeApplications(page, size, c.Query("status"))
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
type rechargeReviewReq struct {
|
||||
Approved bool `json:"approved" binding:"required"`
|
||||
ReviewNote string `json:"review_note"`
|
||||
}
|
||||
|
||||
// AdminReviewApplication POST /api/platform/recharge/applications/:id/review
|
||||
// 通过后由后端按 1 元 = 100 积分自动入账到商户钱包,幂等键为申请单号。
|
||||
func (h *RechargeHandler) AdminReviewApplication(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id == 0 {
|
||||
response.BadRequest(c, "申请 ID 无效")
|
||||
return
|
||||
}
|
||||
var req rechargeReviewReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:approved 必填")
|
||||
return
|
||||
}
|
||||
app, err := h.rechargeSvc.ReviewRecharge(service.ReviewRechargeInput{
|
||||
ApplicationID: uint(id),
|
||||
Approved: req.Approved,
|
||||
ReviewNote: req.ReviewNote,
|
||||
ActorUserID: middleware.GetUserID(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
h.rechargeSvc.CheckLowBalanceAndNotify(app.MerchantID)
|
||||
response.OK(c, app)
|
||||
}
|
||||
|
||||
// GetAlertConfig GET /api/merchant/recharge/alert-config
|
||||
func (h *RechargeHandler) GetAlertConfig(c *gin.Context) {
|
||||
cfg, err := h.rechargeSvc.GetAlertConfig(middleware.GetMerchantID(c))
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, cfg)
|
||||
}
|
||||
|
||||
type alertConfigReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ThresholdPoints int64 `json:"threshold_points"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
}
|
||||
|
||||
// SaveAlertConfig PUT /api/merchant/recharge/alert-config
|
||||
func (h *RechargeHandler) SaveAlertConfig(c *gin.Context) {
|
||||
var req alertConfigReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
cfg, err := h.rechargeSvc.SaveAlertConfig(middleware.GetMerchantID(c), service.SaveAlertInput{
|
||||
Enabled: req.Enabled,
|
||||
ThresholdPoints: req.ThresholdPoints,
|
||||
WebhookURL: req.WebhookURL,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, cfg)
|
||||
}
|
||||
|
||||
// Upload POST /api/upload 上传凭证图片(multipart 字段名 file)。
|
||||
func (h *RechargeHandler) Upload(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择要上传的图片")
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.BadRequest(c, "读取上传文件失败")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
url, err := h.rechargeSvc.SaveUploadFile(file, fileHeader.Filename, uploadMaxBytes)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"url": url})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 充值申请状态
|
||||
const (
|
||||
RechargeStatusPending = "pending"
|
||||
RechargeStatusApproved = "approved"
|
||||
RechargeStatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// RechargeApplication 商户提交的人民币充值申请,凭证图片必填,由平台管理员审核后入账。
|
||||
type RechargeApplication struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
MerchantID uint `gorm:"not null;index" json:"merchant_id"`
|
||||
ApplicationNo string `gorm:"uniqueIndex;size:64;not null" json:"application_no"`
|
||||
AmountCNY int64 `gorm:"not null" json:"amount_cny"` // 人民币金额,单位:分
|
||||
PointsAmount int64 `gorm:"not null" json:"points_amount"`
|
||||
Status string `gorm:"size:16;not null;default:pending;index" json:"status"`
|
||||
Vouchers []string `gorm:"type:text;serializer:json" json:"vouchers"` // 打款凭证图片 URL
|
||||
Note string `gorm:"size:512" json:"note"`
|
||||
ReviewNote string `gorm:"size:512" json:"review_note"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
ReviewedBy *uint `json:"reviewed_by"`
|
||||
|
||||
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
|
||||
}
|
||||
|
||||
// MerchantAlertConfig 低余额 Webhook 告警配置(每商户一份)。
|
||||
type MerchantAlertConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
MerchantID uint `gorm:"not null;uniqueIndex" json:"merchant_id"`
|
||||
Enabled bool `gorm:"not null;default:false" json:"enabled"`
|
||||
ThresholdPoints int64 `gorm:"not null;default:0" json:"threshold_points"`
|
||||
WebhookURL string `gorm:"size:1024" json:"webhook_url"`
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Handlers struct {
|
||||
Open *handler.OpenV1Handler
|
||||
SourceOpen *handler.OpenHandler
|
||||
Merchant *handler.MerchantHandler
|
||||
Recharge *handler.RechargeHandler
|
||||
JWT *jwt.Manager
|
||||
Tenant *service.TenantService
|
||||
OpenDB *gorm.DB
|
||||
@@ -28,6 +29,7 @@ type Handlers struct {
|
||||
OpenAPISecret string
|
||||
OpenSignSkew int64
|
||||
OpenAPIDebug bool
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
// requestLogger 访问日志中间件:跳过 /health 健康检查(每 30 秒一次,避免刷屏)。
|
||||
@@ -58,6 +60,11 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 上传的凭证等静态文件(公开访问,文件名不可枚举)
|
||||
if h.UploadDir != "" {
|
||||
r.Static("/uploads", h.UploadDir)
|
||||
}
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/auth/login", h.Auth.Login)
|
||||
@@ -126,6 +133,10 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RestoreDeliveryLink)
|
||||
merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
|
||||
merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
|
||||
merchant.POST("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.CreateApplication)
|
||||
merchant.GET("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Recharge.ListApplications)
|
||||
merchant.GET("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.GetAlertConfig)
|
||||
merchant.PUT("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.SaveAlertConfig)
|
||||
merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), h.Merchant.ListAPIClients)
|
||||
merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient)
|
||||
merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus)
|
||||
@@ -153,7 +164,11 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
admin.GET("/platform/merchants/:id/wallet", h.Merchant.AdminGetWallet)
|
||||
admin.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger)
|
||||
admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet)
|
||||
admin.GET("/platform/recharge/applications", h.Recharge.AdminListApplications)
|
||||
admin.POST("/platform/recharge/applications/:id/review", h.Recharge.AdminReviewApplication)
|
||||
}
|
||||
|
||||
auth.POST("/upload", h.Recharge.Upload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestSetupDoesNotPanic(t *testing.T) {
|
||||
callbackSvc := service.NewCallbackService(db, codec)
|
||||
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
||||
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
||||
rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, "")
|
||||
deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0)
|
||||
|
||||
defer func() {
|
||||
@@ -37,7 +38,8 @@ func TestSetupDoesNotPanic(t *testing.T) {
|
||||
User: handler.NewUserHandler(userSvc),
|
||||
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
|
||||
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
|
||||
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc),
|
||||
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc),
|
||||
Recharge: handler.NewRechargeHandler(rechargeSvc),
|
||||
JWT: jwt.NewManager("test-jwt"),
|
||||
Tenant: tenantSvc,
|
||||
OpenDB: db,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := "已提交发货,等待发货结果回传"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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], "-", "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user