积分充值全流程落地:申请审核入账、凭证上传压缩转webp、管理员调账收权
- 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分), 管理员审核通过后事务内自动入账,幂等键防重复 - 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除, 后端按魔数校验图片格式并持久化到 data/uploads - 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员 平台商户积分调整与钱包流水查看 - 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送 - 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算
This commit is contained in:
@@ -18,6 +18,11 @@
|
|||||||
reverse_proxy backend:8080
|
reverse_proxy backend:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 上传的凭证图片等静态资源 / Proxy uploads to backend
|
||||||
|
handle /uploads* {
|
||||||
|
reverse_proxy backend:8080
|
||||||
|
}
|
||||||
|
|
||||||
# 前端 SPA 静态文件 / Serve frontend SPA
|
# 前端 SPA 静态文件 / Serve frontend SPA
|
||||||
handle {
|
handle {
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
||||||
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
||||||
|
rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, cfg.UploadDir)
|
||||||
deliverySvc := service.NewDeliveryService(
|
deliverySvc := service.NewDeliveryService(
|
||||||
fulfillmentSvc,
|
fulfillmentSvc,
|
||||||
cfg.DeliveryBFFBaseURL,
|
cfg.DeliveryBFFBaseURL,
|
||||||
@@ -96,7 +97,8 @@ func main() {
|
|||||||
User: handler.NewUserHandler(userSvc),
|
User: handler.NewUserHandler(userSvc),
|
||||||
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
|
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
|
||||||
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
|
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,
|
JWT: jm,
|
||||||
Tenant: tenantSvc,
|
Tenant: tenantSvc,
|
||||||
OpenDB: db,
|
OpenDB: db,
|
||||||
@@ -105,6 +107,7 @@ func main() {
|
|||||||
OpenAPISecret: cfg.OpenAPISecret,
|
OpenAPISecret: cfg.OpenAPISecret,
|
||||||
OpenSignSkew: cfg.OpenSignSkew,
|
OpenSignSkew: cfg.OpenSignSkew,
|
||||||
OpenAPIDebug: cfg.OpenAPIDebug,
|
OpenAPIDebug: cfg.OpenAPIDebug,
|
||||||
|
UploadDir: cfg.UploadDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
go callbackSvc.Run(context.Background())
|
go callbackSvc.Run(context.Background())
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ type Config struct {
|
|||||||
// CallbackRetryScheduleSeconds 回调失败重试间隔序列(秒,逗号分隔,微信/支付宝式固定退避)。
|
// CallbackRetryScheduleSeconds 回调失败重试间隔序列(秒,逗号分隔,微信/支付宝式固定退避)。
|
||||||
// 为空时使用默认微信式序列。
|
// 为空时使用默认微信式序列。
|
||||||
CallbackRetryScheduleSeconds []int
|
CallbackRetryScheduleSeconds []int
|
||||||
|
// UploadDir 凭证等上传文件的存储目录(默认 data/uploads,通过 /uploads 公开访问)。
|
||||||
|
UploadDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||||
@@ -79,6 +81,7 @@ func Load() *Config {
|
|||||||
CallbackMaxAttempts: getEnvInt("CALLBACK_MAX_ATTEMPTS", 16),
|
CallbackMaxAttempts: getEnvInt("CALLBACK_MAX_ATTEMPTS", 16),
|
||||||
CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15),
|
CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15),
|
||||||
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
|
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
|
fulfillmentSvc *service.FulfillmentService
|
||||||
callbackSvc *service.CallbackService
|
callbackSvc *service.CallbackService
|
||||||
deliverySvc *service.DeliveryService
|
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{
|
return &MerchantHandler{
|
||||||
merchantSvc: merchantSvc,
|
merchantSvc: merchantSvc,
|
||||||
fulfillmentSvc: fulfillmentSvc,
|
fulfillmentSvc: fulfillmentSvc,
|
||||||
callbackSvc: callbackSvc,
|
callbackSvc: callbackSvc,
|
||||||
deliverySvc: deliverySvc,
|
deliverySvc: deliverySvc,
|
||||||
|
rechargeSvc: rechargeSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +276,7 @@ func (h *MerchantHandler) AdminAdjustWallet(c *gin.Context) {
|
|||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.rechargeSvc.CheckLowBalanceAndNotify(uint(id))
|
||||||
response.OK(c, wallet)
|
response.OK(c, wallet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Open *handler.OpenV1Handler
|
||||||
SourceOpen *handler.OpenHandler
|
SourceOpen *handler.OpenHandler
|
||||||
Merchant *handler.MerchantHandler
|
Merchant *handler.MerchantHandler
|
||||||
|
Recharge *handler.RechargeHandler
|
||||||
JWT *jwt.Manager
|
JWT *jwt.Manager
|
||||||
Tenant *service.TenantService
|
Tenant *service.TenantService
|
||||||
OpenDB *gorm.DB
|
OpenDB *gorm.DB
|
||||||
@@ -28,6 +29,7 @@ type Handlers struct {
|
|||||||
OpenAPISecret string
|
OpenAPISecret string
|
||||||
OpenSignSkew int64
|
OpenSignSkew int64
|
||||||
OpenAPIDebug bool
|
OpenAPIDebug bool
|
||||||
|
UploadDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestLogger 访问日志中间件:跳过 /health 健康检查(每 30 秒一次,避免刷屏)。
|
// requestLogger 访问日志中间件:跳过 /health 健康检查(每 30 秒一次,避免刷屏)。
|
||||||
@@ -58,6 +60,11 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 上传的凭证等静态文件(公开访问,文件名不可枚举)
|
||||||
|
if h.UploadDir != "" {
|
||||||
|
r.Static("/uploads", h.UploadDir)
|
||||||
|
}
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.POST("/auth/login", h.Auth.Login)
|
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.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", 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.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.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.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)
|
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", h.Merchant.AdminGetWallet)
|
||||||
admin.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger)
|
admin.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger)
|
||||||
admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet)
|
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)
|
callbackSvc := service.NewCallbackService(db, codec)
|
||||||
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
||||||
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
||||||
|
rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, "")
|
||||||
deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0)
|
deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -37,7 +38,8 @@ func TestSetupDoesNotPanic(t *testing.T) {
|
|||||||
User: handler.NewUserHandler(userSvc),
|
User: handler.NewUserHandler(userSvc),
|
||||||
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
|
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc),
|
||||||
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
|
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"),
|
JWT: jwt.NewManager("test-jwt"),
|
||||||
Tenant: tenantSvc,
|
Tenant: tenantSvc,
|
||||||
OpenDB: db,
|
OpenDB: db,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ services:
|
|||||||
- CALLBACK_RETRY_SCHEDULE=${CALLBACK_RETRY_SCHEDULE:-}
|
- CALLBACK_RETRY_SCHEDULE=${CALLBACK_RETRY_SCHEDULE:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import OpenApiDocs from './pages/OpenApiDocs'
|
|||||||
import ApiDebugger from './pages/ApiDebugger'
|
import ApiDebugger from './pages/ApiDebugger'
|
||||||
import Delivery from './pages/Delivery'
|
import Delivery from './pages/Delivery'
|
||||||
import MerchantCenter from './pages/MerchantCenter'
|
import MerchantCenter from './pages/MerchantCenter'
|
||||||
|
import MerchantRecharge from './pages/MerchantRecharge'
|
||||||
import PlatformMerchants from './pages/PlatformMerchants'
|
import PlatformMerchants from './pages/PlatformMerchants'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
function PrivateRoute({ children }: { children: ReactNode }) {
|
function PrivateRoute({ children }: { children: ReactNode }) {
|
||||||
@@ -41,6 +42,7 @@ function AppRoutes() {
|
|||||||
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品" />} />
|
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品" />} />
|
||||||
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
|
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
|
||||||
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
|
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
|
||||||
|
<Route path="merchant-recharge" element={<MerchantRecharge />} />
|
||||||
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
|
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
|
||||||
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
||||||
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
|
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import type {
|
|||||||
DeliverySubmitResult,
|
DeliverySubmitResult,
|
||||||
FulfillmentOrder,
|
FulfillmentOrder,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
|
LowBalanceAlertConfig,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
MerchantProduct,
|
MerchantProduct,
|
||||||
PageResult,
|
PageResult,
|
||||||
ProductCatalogItem,
|
ProductCatalogItem,
|
||||||
|
RechargeApplication,
|
||||||
User,
|
User,
|
||||||
WalletAccount,
|
WalletAccount,
|
||||||
WalletLedgerEntry,
|
WalletLedgerEntry,
|
||||||
@@ -99,6 +101,26 @@ export const merchantApi = {
|
|||||||
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
||||||
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
|
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
|
||||||
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
||||||
|
rechargeApplications: (params?: Record<string, unknown>) =>
|
||||||
|
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||||
|
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
|
||||||
|
// amount_cny 单位:分(1元 = 100分),积分按 1元 = 100积分 折算
|
||||||
|
request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication),
|
||||||
|
alertConfig: () =>
|
||||||
|
request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig),
|
||||||
|
saveAlertConfig: (data: LowBalanceAlertConfig) =>
|
||||||
|
request.put('/merchant/recharge/alert-config', data).then((r) => r.data.data as LowBalanceAlertConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadApi = {
|
||||||
|
file: (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return request.post('/upload', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000,
|
||||||
|
}).then((r) => r.data.data as { url: string })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deliveryApi = {
|
export const deliveryApi = {
|
||||||
@@ -158,4 +180,8 @@ export const platformApi = {
|
|||||||
request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
||||||
adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) =>
|
adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) =>
|
||||||
request.post(`/platform/merchants/${merchantId}/wallet/adjust`, data).then((r) => r.data.data as WalletAccount),
|
request.post(`/platform/merchants/${merchantId}/wallet/adjust`, data).then((r) => r.data.data as WalletAccount),
|
||||||
|
rechargeApplications: (params?: Record<string, unknown>) =>
|
||||||
|
request.get('/platform/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||||
|
reviewRechargeApplication: (id: number, data: { approved: boolean; review_note?: string }) =>
|
||||||
|
request.post(`/platform/recharge/applications/${id}/review`, data).then((r) => r.data.data as RechargeApplication),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Image, Upload, message } from 'antd'
|
||||||
|
import { CloseOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
|
import { uploadApi } from '../api'
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
value?: string[]
|
||||||
|
onChange?: (urls: string[]) => void
|
||||||
|
maxCount?: number
|
||||||
|
maxSizeMB?: number
|
||||||
|
/** 压缩后最长边像素,默认 1600 */
|
||||||
|
maxWidth?: number
|
||||||
|
/** webp 压缩质量 0~1,默认 0.8 */
|
||||||
|
quality?: number
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp)$/i
|
||||||
|
|
||||||
|
async function compressToWebp(file: File, maxWidth: number, quality: number): Promise<File> {
|
||||||
|
const bitmap = await createImageBitmap(file)
|
||||||
|
try {
|
||||||
|
const scale = Math.min(1, maxWidth / Math.max(1, bitmap.width))
|
||||||
|
const width = Math.max(1, Math.round(bitmap.width * scale))
|
||||||
|
const height = Math.max(1, Math.round(bitmap.height * scale))
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('浏览器不支持图片处理')
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||||
|
|
||||||
|
const supportsWebp = document.createElement('canvas').toDataURL('image/webp').startsWith('data:image/webp')
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, supportsWebp ? 'image/webp' : 'image/jpeg', quality)
|
||||||
|
})
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error('图片压缩失败')
|
||||||
|
}
|
||||||
|
const name = file.name.replace(IMAGE_EXT_RE, supportsWebp ? '.webp' : '.jpg')
|
||||||
|
return new File([blob], name, { type: blob.type })
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = (e) => reject(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用图片上传组件:选择后自动压缩并转 webp 再上传,缩略图预览、可删除。
|
||||||
|
* 受控组件,value 为已上传图片 URL 数组。
|
||||||
|
*/
|
||||||
|
export default function ImageUploader({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
maxCount = 5,
|
||||||
|
maxSizeMB = 5,
|
||||||
|
maxWidth = 1600,
|
||||||
|
quality = 0.8,
|
||||||
|
disabled = false,
|
||||||
|
}: ImageUploaderProps) {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const urls = value ?? []
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||||
|
message.error(`图片不能超过 ${maxSizeMB}MB`)
|
||||||
|
return Upload.LIST_IGNORE
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const compressed = await compressToWebp(file, maxWidth, quality)
|
||||||
|
// 实时生成本地 Base64 作为高优先级预览与兜底
|
||||||
|
const localBase64 = await fileToBase64(compressed)
|
||||||
|
let finalUrl = localBase64
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadApi.file(compressed)
|
||||||
|
if (res && res.url) {
|
||||||
|
finalUrl = res.url
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('后端上传接口网络未通,已使用本地 Base64 进行实时预览:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.([...urls, finalUrl])
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '图片处理失败')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
return Upload.LIST_IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUrl = (url: string) => {
|
||||||
|
onChange?.(urls.filter((item) => item !== url))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{urls.map((url) => (
|
||||||
|
<div
|
||||||
|
key={url}
|
||||||
|
style={{ position: 'relative', width: 96, height: 96 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||||
|
/>
|
||||||
|
{!disabled && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => removeUrl(url)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'rgba(15, 23, 42, 0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '0 8px 0 8px',
|
||||||
|
padding: 0,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
minWidth: 22,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{urls.length < maxCount && (
|
||||||
|
<Upload
|
||||||
|
beforeUpload={handleUpload}
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
showUploadList={false}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
border: '1px dashed #d9d9d9',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<LoadingOutlined style={{ fontSize: 20, color: '#2563eb' }} />
|
||||||
|
) : (
|
||||||
|
<PlusOutlined style={{ fontSize: 20, color: '#2563eb' }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||||||
|
{uploading ? '处理中...' : '上传'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Upload>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -82,7 +82,10 @@ const adminSections: SidebarSection[] = [
|
|||||||
key: 'funds',
|
key: 'funds',
|
||||||
label: '积分管理',
|
label: '积分管理',
|
||||||
icon: <WalletOutlined />,
|
icon: <WalletOutlined />,
|
||||||
children: [{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }],
|
children: [
|
||||||
|
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
|
||||||
|
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'open-api',
|
key: 'open-api',
|
||||||
@@ -120,7 +123,10 @@ const merchantSections: SidebarSection[] = [
|
|||||||
key: 'funds',
|
key: 'funds',
|
||||||
label: '积分管理',
|
label: '积分管理',
|
||||||
icon: <WalletOutlined />,
|
icon: <WalletOutlined />,
|
||||||
children: [{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }],
|
children: [
|
||||||
|
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
|
||||||
|
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'staff',
|
key: 'staff',
|
||||||
@@ -144,6 +150,7 @@ function getSelectedKey(pathname: string, search: string) {
|
|||||||
if (pathname.startsWith('/merchant-products')) return 'merchant-products'
|
if (pathname.startsWith('/merchant-products')) return 'merchant-products'
|
||||||
if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders'
|
if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders'
|
||||||
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
|
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
|
||||||
|
if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge'
|
||||||
if (pathname.startsWith('/merchant-members')) return 'merchant-members'
|
if (pathname.startsWith('/merchant-members')) return 'merchant-members'
|
||||||
if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks'
|
if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks'
|
||||||
if (pathname.startsWith('/merchant-api-keys')) return 'api-keys'
|
if (pathname.startsWith('/merchant-api-keys')) return 'api-keys'
|
||||||
|
|||||||
@@ -0,0 +1,663 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import ImageUploader from '../components/ImageUploader'
|
||||||
|
import { merchantApi, platformApi } from '../api'
|
||||||
|
import { useAuth } from '../store/auth'
|
||||||
|
import type { LowBalanceAlertConfig, PageResult, RechargeApplication, WalletAccount } from '../types'
|
||||||
|
import { formatDateTime } from '../utils/time'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
export default function MerchantRecharge() {
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
|
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||||
|
const [applications, setApplications] = useState<PageResult<RechargeApplication>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [alertForm] = Form.useForm()
|
||||||
|
const [filterForm] = Form.useForm()
|
||||||
|
const [createForm] = Form.useForm()
|
||||||
|
const [reviewForm] = Form.useForm()
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [createSubmitting, setCreateSubmitting] = useState(false)
|
||||||
|
const [detailItem, setDetailItem] = useState<RechargeApplication | null>(null)
|
||||||
|
const [reviewItem, setReviewItem] = useState<RechargeApplication | null>(null)
|
||||||
|
const [reviewSubmitting, setReviewSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [filterParams, setFilterParams] = useState<{ no?: string; status?: string }>({})
|
||||||
|
|
||||||
|
const currentPointsBalance = wallet?.available_balance
|
||||||
|
|
||||||
|
const filteredList = useMemo(() => {
|
||||||
|
if (!filterParams.no) {
|
||||||
|
return applications.list
|
||||||
|
}
|
||||||
|
return applications.list.filter((item) => item.application_no.includes(filterParams.no || ''))
|
||||||
|
}, [applications.list, filterParams.no])
|
||||||
|
|
||||||
|
const loadApplications = useCallback(async (page = applications.page, size = applications.size, params = filterParams) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = isAdmin
|
||||||
|
? await platformApi.rechargeApplications({ page, size, status: params.status || undefined })
|
||||||
|
: await merchantApi.rechargeApplications({ page, size, status: params.status || undefined })
|
||||||
|
setApplications(result)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [applications.page, applications.size, filterParams, isAdmin])
|
||||||
|
|
||||||
|
const loadAlertConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await merchantApi.alertConfig()
|
||||||
|
alertForm.setFieldsValue(data)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '告警配置加载失败')
|
||||||
|
}
|
||||||
|
}, [alertForm])
|
||||||
|
|
||||||
|
const loadWallet = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setWallet(await merchantApi.wallet())
|
||||||
|
} catch {
|
||||||
|
// 钱包不可用时静默降级
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApplications()
|
||||||
|
loadAlertConfig()
|
||||||
|
loadWallet()
|
||||||
|
}, [loadAlertConfig, loadApplications, loadWallet])
|
||||||
|
|
||||||
|
const handleSaveAlertConfig = async () => {
|
||||||
|
try {
|
||||||
|
const values = await alertForm.validateFields()
|
||||||
|
const newConfig: LowBalanceAlertConfig = {
|
||||||
|
enabled: values.enabled,
|
||||||
|
threshold_points: values.threshold_points,
|
||||||
|
webhook_url: values.webhook_url,
|
||||||
|
}
|
||||||
|
await merchantApi.saveAlertConfig(newConfig)
|
||||||
|
message.success('低余额告警设置已更新')
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
message.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterSubmit = (values: { no?: string; status?: string }) => {
|
||||||
|
const params = {
|
||||||
|
no: values.no?.trim() || undefined,
|
||||||
|
status: values.status || undefined,
|
||||||
|
}
|
||||||
|
setFilterParams(params)
|
||||||
|
loadApplications(1, applications.size, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterReset = () => {
|
||||||
|
filterForm.resetFields()
|
||||||
|
setFilterParams({})
|
||||||
|
loadApplications(1, applications.size, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await createForm.validateFields()
|
||||||
|
// 表单单位为元,后端 amount_cny 以分为单位(1元 = 100分 = 100积分)
|
||||||
|
const amountCny = Math.round(Number(values.amount_cny || 0) * 100)
|
||||||
|
|
||||||
|
const vouchers: string[] = values.vouchers || []
|
||||||
|
if (vouchers.length === 0) {
|
||||||
|
message.error('请上传打款凭证截图(必填)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateSubmitting(true)
|
||||||
|
await merchantApi.createRechargeApplication({
|
||||||
|
amount_cny: amountCny,
|
||||||
|
vouchers,
|
||||||
|
note: values.note,
|
||||||
|
})
|
||||||
|
message.success('充值购买申请已提交,等待审核入账!')
|
||||||
|
setCreateOpen(false)
|
||||||
|
createForm.resetFields()
|
||||||
|
loadApplications()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
message.error(e.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCreateSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReviewSubmit = async (approved: boolean) => {
|
||||||
|
if (!reviewItem) return
|
||||||
|
try {
|
||||||
|
const values = await reviewForm.validateFields()
|
||||||
|
setReviewSubmitting(true)
|
||||||
|
await platformApi.reviewRechargeApplication(reviewItem.id, {
|
||||||
|
approved,
|
||||||
|
review_note: values.review_note || undefined,
|
||||||
|
})
|
||||||
|
message.success(approved ? '已通过该笔充值申请,积分入账成功!' : '申请已驳回')
|
||||||
|
setReviewItem(null)
|
||||||
|
reviewForm.resetFields()
|
||||||
|
loadApplications()
|
||||||
|
loadWallet()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '审核失败')
|
||||||
|
} finally {
|
||||||
|
setReviewSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<RechargeApplication> = [
|
||||||
|
{
|
||||||
|
title: '申请单号',
|
||||||
|
dataIndex: 'application_no',
|
||||||
|
width: 210,
|
||||||
|
render: (v) => (
|
||||||
|
<Text code copyable={{ tooltips: false }} style={{ fontWeight: 600 }}>
|
||||||
|
{v}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(isAdmin ? [{
|
||||||
|
title: '商户',
|
||||||
|
dataIndex: ['merchant', 'name'],
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_: unknown, record: RechargeApplication) => record.merchant?.name || `商户#${record.merchant_id}`,
|
||||||
|
} as ColumnsType<RechargeApplication>[number]] : []),
|
||||||
|
{
|
||||||
|
title: '充值金额 (元)',
|
||||||
|
dataIndex: 'amount_cny',
|
||||||
|
width: 140,
|
||||||
|
render: (v) => <span style={{ fontWeight: 700, color: '#0f172a' }}>¥{(Number(v) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '折算积分',
|
||||||
|
dataIndex: 'points_amount',
|
||||||
|
width: 150,
|
||||||
|
render: (v) => (
|
||||||
|
<span style={{ fontWeight: 700, color: '#16a34a', fontSize: 13.5 }}>
|
||||||
|
+{Number(v).toLocaleString('zh-CN')} 积分
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '凭证截图',
|
||||||
|
dataIndex: 'vouchers',
|
||||||
|
width: 120,
|
||||||
|
render: (vouchers: string[]) => {
|
||||||
|
if (!vouchers || vouchers.length === 0) {
|
||||||
|
return <span style={{ color: '#94a3b8', fontSize: 12 }}>无凭证</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space size={4}>
|
||||||
|
{vouchers.map((img, idx) => (
|
||||||
|
<Image
|
||||||
|
key={idx}
|
||||||
|
src={img}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #e2e8f0' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => {
|
||||||
|
if (v === 'approved') {
|
||||||
|
return (
|
||||||
|
<span className="status-tag status-tag--green">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
已通过
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (v === 'rejected') {
|
||||||
|
return (
|
||||||
|
<span className="status-tag status-tag--red">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
已驳回
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="status-tag status-tag--blue">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
待审核
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: formatDateTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '审核时间',
|
||||||
|
dataIndex: 'reviewed_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v) => (v ? formatDateTime(v) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注说明',
|
||||||
|
dataIndex: 'note',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (v) => <Text ellipsis title={v}>{v || '-'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '审核说明',
|
||||||
|
dataIndex: 'review_note',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: { showTitle: false },
|
||||||
|
render: (v) => <Text ellipsis title={v} style={{ color: '#475569' }}>{v || '-'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 150,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => setDetailItem(record)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
{isAdmin && record.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setReviewItem(record)
|
||||||
|
reviewForm.resetFields()
|
||||||
|
reviewForm.setFieldsValue({ review_note: '审核通过,资金真实到账并注入积分' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
审核
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="积分购买与充值"
|
||||||
|
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录及配置低余额 Webhook 自动化告警"
|
||||||
|
breadcrumbs={[{ title: '积分管理' }, { title: '积分充值' }]}
|
||||||
|
extra={
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => loadApplications()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
{/* 告警与通知配置面板 */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||||
|
title={
|
||||||
|
<Space size={8}>
|
||||||
|
<BellOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||||||
|
<span style={{ fontWeight: 700, color: '#0f172a' }}>余额告警与 Webhook 通知设置</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={alertForm}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ padding: '4px 8px 0' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: '0 0 200px' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>当前积分余额</Text>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: '#16a34a', marginTop: 2 }}>
|
||||||
|
{currentPointsBalance === undefined ? '-' : currentPointsBalance.toLocaleString('zh-CN')} <span style={{ fontSize: 13, color: '#64748b', fontWeight: 500 }}>积分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="enabled"
|
||||||
|
label="启用告警"
|
||||||
|
valuePropName="checked"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="threshold_points"
|
||||||
|
label="低于多少积分提醒"
|
||||||
|
rules={[{ required: true, message: '请填写预警阈值' }]}
|
||||||
|
style={{ marginBottom: 12, minWidth: 220 }}
|
||||||
|
>
|
||||||
|
<InputNumber<number>
|
||||||
|
min={0}
|
||||||
|
step={100000}
|
||||||
|
addonAfter="积分"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="webhook_url"
|
||||||
|
label="告警 Webhook (钉钉/飞书机器人)"
|
||||||
|
rules={[{ required: true, message: '请填写 Webhook URL' }]}
|
||||||
|
style={{ marginBottom: 12, flex: 1, minWidth: 320 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ alignSelf: 'flex-end', marginBottom: 12 }}>
|
||||||
|
<Button type="primary" onClick={handleSaveAlertConfig}>
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
提示:告警机器人安全设置需包含关键词「余额」。当额度低于阈值时(充值入账/调账后触发),平台将以 JSON POST 发送通知至上述 URL。
|
||||||
|
</Typography.Text>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 申请记录表格 Card */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontWeight: 700, color: '#0f172a' }}>
|
||||||
|
{isAdmin ? '全部商户充值申请记录' : '积分购买申请记录'}
|
||||||
|
</span>
|
||||||
|
{!isAdmin && (
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
createForm.resetFields()
|
||||||
|
setCreateOpen(true)
|
||||||
|
}}>
|
||||||
|
提交充值申请
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 筛选表单 */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
|
||||||
|
<Form form={filterForm} layout="inline" onFinish={handleFilterSubmit}>
|
||||||
|
<Form.Item name="no" label="单号">
|
||||||
|
<Input placeholder="充值单号" allowClear style={{ width: 200 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select placeholder="全部状态" allowClear style={{ width: 120 }}>
|
||||||
|
<Select.Option value="pending">待审核</Select.Option>
|
||||||
|
<Select.Option value="approved">已通过</Select.Option>
|
||||||
|
<Select.Option value="rejected">已驳回</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleFilterReset}>重置</Button>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredList}
|
||||||
|
scroll={{ x: isAdmin ? 1500 : 1400 }}
|
||||||
|
pagination={{
|
||||||
|
current: applications.page,
|
||||||
|
pageSize: applications.size,
|
||||||
|
total: applications.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条申请记录`,
|
||||||
|
onChange: (page, size) => loadApplications(page, size),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 新增充值申请 Modal */}
|
||||||
|
<Modal
|
||||||
|
title="提交积分购买申请"
|
||||||
|
open={createOpen}
|
||||||
|
onOk={handleCreateSubmit}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
destroyOnClose
|
||||||
|
width={620}
|
||||||
|
okText="提交审核"
|
||||||
|
confirmLoading={createSubmitting}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="充值说明"
|
||||||
|
description="请填写实际充值金额(人民币),上传打款凭证截图(必填)。财务审核通过后,系统将按 1元 = 100积分 自动折算并实时注入商户钱包。"
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="amount_cny"
|
||||||
|
label="充值金额 (人民币 元)"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入充值金额' },
|
||||||
|
{ type: 'number', min: 10, message: '最小充值金额为 10 元' },
|
||||||
|
{
|
||||||
|
validator: (_, value) => (value == null || Number.isInteger(value))
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error('请输入整数金额(元)')),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={10}
|
||||||
|
step={100}
|
||||||
|
precision={0}
|
||||||
|
addonBefore="¥"
|
||||||
|
addonAfter="元"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="请输入填写转账金额(整数元)"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item shouldUpdate={(prev, cur) => prev.amount_cny !== cur.amount_cny}>
|
||||||
|
{() => {
|
||||||
|
const amount = Number(createForm.getFieldValue('amount_cny') || 0)
|
||||||
|
const calculatedPoints = Math.round(amount * 100)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f0fdf4',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 14px',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>预计折算入账积分:</Text>
|
||||||
|
<span style={{ fontWeight: 800, color: '#16a34a', fontSize: 16, marginLeft: 6 }}>
|
||||||
|
+{calculatedPoints.toLocaleString('zh-CN')} 积分
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="vouchers"
|
||||||
|
label="打款凭证截图 (必填,最多 5 张)"
|
||||||
|
rules={[{ required: true, message: '请上传打款凭证截图' }]}
|
||||||
|
>
|
||||||
|
<ImageUploader maxCount={5} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="note" label="备注 (可选)" rules={[{ max: 200 }]}>
|
||||||
|
<Input.TextArea rows={3} placeholder="可填写打款银行账号末四位、流水号或备注说明" maxLength={200} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 查看详情 Modal */}
|
||||||
|
<Modal
|
||||||
|
title="充值申请详情"
|
||||||
|
open={!!detailItem}
|
||||||
|
onCancel={() => setDetailItem(null)}
|
||||||
|
footer={<Button type="primary" onClick={() => setDetailItem(null)}>关闭</Button>}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{detailItem && (
|
||||||
|
<Descriptions size="small" bordered column={1} style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="申请单号">
|
||||||
|
<Text code copyable>{detailItem.application_no}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商户名称">{detailItem.merchant?.name || `商户#${detailItem.merchant_id}`}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="充值人民币">¥{(detailItem.amount_cny / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="折算积分">
|
||||||
|
<span style={{ fontWeight: 700, color: '#16a34a' }}>+{detailItem.points_amount.toLocaleString('zh-CN')} 积分</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="申请状态">
|
||||||
|
{detailItem.status === 'approved' ? (
|
||||||
|
<Tag color="green">已通过并入账</Tag>
|
||||||
|
) : detailItem.status === 'rejected' ? (
|
||||||
|
<Tag color="red">已驳回</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="blue">待财务审核</Tag>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="提交时间">{formatDateTime(detailItem.created_at)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="审核时间">{detailItem.reviewed_at ? formatDateTime(detailItem.reviewed_at) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="申请备注">{detailItem.note || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="审核说明">{detailItem.review_note || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="凭证截图">
|
||||||
|
{detailItem.vouchers && detailItem.vouchers.length > 0 ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
{detailItem.vouchers.map((img, idx) => (
|
||||||
|
<Image key={idx} src={img} width={72} height={72} style={{ borderRadius: 6, objectFit: 'cover' }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">无凭证截图</Text>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 管理员审核 Modal */}
|
||||||
|
<Modal
|
||||||
|
title="充值申请审核"
|
||||||
|
open={!!reviewItem}
|
||||||
|
onCancel={() => setReviewItem(null)}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={() => setReviewItem(null)}>取消</Button>,
|
||||||
|
<Popconfirm
|
||||||
|
key="reject"
|
||||||
|
title="确认驳回该笔申请?"
|
||||||
|
onConfirm={() => handleReviewSubmit(false)}
|
||||||
|
>
|
||||||
|
<Button danger icon={<CloseCircleOutlined />} disabled={reviewSubmitting}>驳回申请</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
<Button
|
||||||
|
key="approve"
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={reviewSubmitting}
|
||||||
|
onClick={() => handleReviewSubmit(true)}
|
||||||
|
>
|
||||||
|
通过并自动入账
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
{reviewItem && (
|
||||||
|
<Form form={reviewForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions size="small" bordered column={2} style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="充值单号">{reviewItem.application_no}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="人民币金额">¥{(reviewItem.amount_cny / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="充值积分" span={2}>
|
||||||
|
<span style={{ fontWeight: 800, color: '#16a34a' }}>+{reviewItem.points_amount.toLocaleString('zh-CN')} 积分</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Form.Item label="凭证截图" style={{ marginBottom: 16 }}>
|
||||||
|
{reviewItem.vouchers && reviewItem.vouchers.length > 0 ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
{reviewItem.vouchers.map((img, idx) => (
|
||||||
|
<Image key={idx} src={img} width={72} height={72} style={{ borderRadius: 6, objectFit: 'cover' }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">无凭证截图</Text>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="review_note" label="审核说明 / 对账备注">
|
||||||
|
<Input.TextArea rows={3} placeholder="请填写财务对账流水或审核说明" maxLength={200} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -252,3 +252,25 @@ export interface LoginResult {
|
|||||||
token: string
|
token: string
|
||||||
user: User
|
user: User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RechargeApplication {
|
||||||
|
id: number
|
||||||
|
application_no: string
|
||||||
|
merchant_id: number
|
||||||
|
merchant?: Merchant
|
||||||
|
merchant_name?: string
|
||||||
|
amount_cny: number
|
||||||
|
points_amount: number
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
vouchers: string[]
|
||||||
|
note?: string
|
||||||
|
review_note?: string
|
||||||
|
created_at: string
|
||||||
|
reviewed_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LowBalanceAlertConfig {
|
||||||
|
enabled: boolean
|
||||||
|
threshold_points: number
|
||||||
|
webhook_url: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/health': {
|
'/health': {
|
||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user