From 941e7ad79add22b8bb21fec33c674d3448baae19 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 4 Aug 2026 13:03:29 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=AF=E5=88=86=E5=85=85=E5=80=BC=E5=85=A8?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E8=90=BD=E5=9C=B0=EF=BC=9A=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E5=85=A5=E8=B4=A6=E3=80=81=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8E=8B=E7=BC=A9=E8=BD=ACwebp=E3=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E8=B0=83=E8=B4=A6=E6=94=B6=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分), 管理员审核通过后事务内自动入账,幂等键防重复 - 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除, 后端按魔数校验图片格式并持久化到 data/uploads - 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员 平台商户积分调整与钱包流水查看 - 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送 - 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算 --- Caddyfile | 5 + backend/cmd/server/main.go | 5 +- backend/internal/config/config.go | 3 + .../database/migrations/003_recharge.sql | 34 + backend/internal/handler/merchant.go | 21 +- backend/internal/handler/recharge.go | 160 +++++ backend/internal/model/recharge.go | 47 ++ backend/internal/router/router.go | 15 + backend/internal/router/router_test.go | 4 +- backend/internal/service/callback_test.go | 6 +- backend/internal/service/delivery.go | 2 +- backend/internal/service/merchant.go | 2 +- backend/internal/service/recharge.go | 315 +++++++++ backend/internal/service/recharge_test.go | 148 ++++ docker-compose.yml | 1 + frontend/src/App.tsx | 2 + frontend/src/api/index.ts | 26 + frontend/src/components/ImageUploader.tsx | 180 +++++ frontend/src/layouts/MainLayout.tsx | 11 +- frontend/src/pages/MerchantRecharge.tsx | 663 ++++++++++++++++++ frontend/src/types/index.ts | 22 + frontend/vite.config.ts | 4 + 22 files changed, 1658 insertions(+), 18 deletions(-) create mode 100644 backend/internal/database/migrations/003_recharge.sql create mode 100644 backend/internal/handler/recharge.go create mode 100644 backend/internal/model/recharge.go create mode 100644 backend/internal/service/recharge.go create mode 100644 backend/internal/service/recharge_test.go create mode 100644 frontend/src/components/ImageUploader.tsx create mode 100644 frontend/src/pages/MerchantRecharge.tsx diff --git a/Caddyfile b/Caddyfile index e6bb222..1325aa6 100644 --- a/Caddyfile +++ b/Caddyfile @@ -18,6 +18,11 @@ reverse_proxy backend:8080 } + # 上传的凭证图片等静态资源 / Proxy uploads to backend + handle /uploads* { + reverse_proxy backend:8080 + } + # 前端 SPA 静态文件 / Serve frontend SPA handle { root * /usr/share/caddy diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3fa41e1..d5d5f8a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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()) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d830223..7ca2cbb 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), } } diff --git a/backend/internal/database/migrations/003_recharge.sql b/backend/internal/database/migrations/003_recharge.sql new file mode 100644 index 0000000..f7d08a1 --- /dev/null +++ b/backend/internal/database/migrations/003_recharge.sql @@ -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 '' +); diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index e8947e0..9f75fd5 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -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 { diff --git a/backend/internal/handler/recharge.go b/backend/internal/handler/recharge.go new file mode 100644 index 0000000..b4e6610 --- /dev/null +++ b/backend/internal/handler/recharge.go @@ -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}) +} diff --git a/backend/internal/model/recharge.go b/backend/internal/model/recharge.go new file mode 100644 index 0000000..8b37a7f --- /dev/null +++ b/backend/internal/model/recharge.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 19798c1..c071ec5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 4f6f706..616271e 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -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, diff --git a/backend/internal/service/callback_test.go b/backend/internal/service/callback_test.go index 0dbd8ed..b295cec 100644 --- a/backend/internal/service/callback_test.go +++ b/backend/internal/service/callback_test.go @@ -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 { diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index eeee7ea..58a3690 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -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 := "已提交发货,等待发货结果回传" diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go index eddc625..a0bb0a0 100644 --- a/backend/internal/service/merchant.go +++ b/backend/internal/service/merchant.go @@ -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{ diff --git a/backend/internal/service/recharge.go b/backend/internal/service/recharge.go new file mode 100644 index 0000000..de0c89d --- /dev/null +++ b/backend/internal/service/recharge.go @@ -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], "-", "") +} diff --git a/backend/internal/service/recharge_test.go b/backend/internal/service/recharge_test.go new file mode 100644 index 0000000..ec13506 --- /dev/null +++ b/backend/internal/service/recharge_test.go @@ -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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5b22acd..c929679 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,7 @@ services: - CALLBACK_RETRY_SCHEDULE=${CALLBACK_RETRY_SCHEDULE:-} volumes: - ./logs:/app/logs + - ./data:/app/data restart: unless-stopped healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d5dea4..fdc561a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import OpenApiDocs from './pages/OpenApiDocs' import ApiDebugger from './pages/ApiDebugger' import Delivery from './pages/Delivery' import MerchantCenter from './pages/MerchantCenter' +import MerchantRecharge from './pages/MerchantRecharge' import PlatformMerchants from './pages/PlatformMerchants' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { @@ -41,6 +42,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b778caa..6998b04 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -12,11 +12,13 @@ import type { DeliverySubmitResult, FulfillmentOrder, LoginResult, + LowBalanceAlertConfig, Merchant, MerchantMember, MerchantProduct, PageResult, ProductCatalogItem, + RechargeApplication, User, WalletAccount, WalletLedgerEntry, @@ -99,6 +101,26 @@ export const merchantApi = { request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]), addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) => request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember), + rechargeApplications: (params?: Record) => + request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult), + 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 = { @@ -158,4 +180,8 @@ export const platformApi = { request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult), 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), + rechargeApplications: (params?: Record) => + request.get('/platform/recharge/applications', { params }).then((r) => r.data.data as PageResult), + reviewRechargeApplication: (id: number, data: { approved: boolean; review_note?: string }) => + request.post(`/platform/recharge/applications/${id}/review`, data).then((r) => r.data.data as RechargeApplication), } diff --git a/frontend/src/components/ImageUploader.tsx b/frontend/src/components/ImageUploader.tsx new file mode 100644 index 0000000..bf87627 --- /dev/null +++ b/frontend/src/components/ImageUploader.tsx @@ -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 { + 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((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 { + 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 ( +
+ {urls.map((url) => ( +
+ + {!disabled && ( +
+ ))} + {urls.length < maxCount && ( + +
+ {uploading ? ( + + ) : ( + + )} + + {uploading ? '处理中...' : '上传'} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 15aa99a..b1ae7f7 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -82,7 +82,10 @@ const adminSections: SidebarSection[] = [ key: 'funds', label: '积分管理', icon: , - 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', @@ -120,7 +123,10 @@ const merchantSections: SidebarSection[] = [ key: 'funds', label: '积分管理', icon: , - 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', @@ -144,6 +150,7 @@ function getSelectedKey(pathname: string, search: string) { if (pathname.startsWith('/merchant-products')) return 'merchant-products' if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders' 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-callbacks')) return 'api-callbacks' if (pathname.startsWith('/merchant-api-keys')) return 'api-keys' diff --git a/frontend/src/pages/MerchantRecharge.tsx b/frontend/src/pages/MerchantRecharge.tsx new file mode 100644 index 0000000..1a6b839 --- /dev/null +++ b/frontend/src/pages/MerchantRecharge.tsx @@ -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(null) + const [applications, setApplications] = useState>({ 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(null) + const [reviewItem, setReviewItem] = useState(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 = [ + { + title: '申请单号', + dataIndex: 'application_no', + width: 210, + render: (v) => ( + + {v} + + ), + }, + ...(isAdmin ? [{ + title: '商户', + dataIndex: ['merchant', 'name'], + width: 150, + ellipsis: true, + render: (_: unknown, record: RechargeApplication) => record.merchant?.name || `商户#${record.merchant_id}`, + } as ColumnsType[number]] : []), + { + title: '充值金额 (元)', + dataIndex: 'amount_cny', + width: 140, + render: (v) => ¥{(Number(v) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}, + }, + { + title: '折算积分', + dataIndex: 'points_amount', + width: 150, + render: (v) => ( + + +{Number(v).toLocaleString('zh-CN')} 积分 + + ), + }, + { + title: '凭证截图', + dataIndex: 'vouchers', + width: 120, + render: (vouchers: string[]) => { + if (!vouchers || vouchers.length === 0) { + return 无凭证 + } + return ( + + + {vouchers.map((img, idx) => ( + + ))} + + + ) + }, + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (v) => { + if (v === 'approved') { + return ( + + + 已通过 + + ) + } + if (v === 'rejected') { + return ( + + + 已驳回 + + ) + } + return ( + + + 待审核 + + ) + }, + }, + { + 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) => {v || '-'}, + }, + { + title: '审核说明', + dataIndex: 'review_note', + width: 180, + ellipsis: { showTitle: false }, + render: (v) => {v || '-'}, + }, + { + title: '操作', + key: 'action', + width: 150, + fixed: 'right', + render: (_, record) => ( + + + {isAdmin && record.status === 'pending' && ( + + )} + + ), + }, + ] + + return ( +
+ } onClick={() => loadApplications()}> + 刷新 + + } + /> + + + {/* 告警与通知配置面板 */} + + + 余额告警与 Webhook 通知设置 + + } + > +
+
+
+ 当前积分余额 +
+ {currentPointsBalance === undefined ? '-' : currentPointsBalance.toLocaleString('zh-CN')} 积分 +
+
+ + + + + + + + min={0} + step={100000} + addonAfter="积分" + style={{ width: '100%' }} + /> + + + + + + +
+ +
+
+ + 提示:告警机器人安全设置需包含关键词「余额」。当额度低于阈值时(充值入账/调账后触发),平台将以 JSON POST 发送通知至上述 URL。 + +
+ + + {/* 申请记录表格 Card */} + + + {isAdmin ? '全部商户充值申请记录' : '积分购买申请记录'} + + {!isAdmin && ( + + )} +
+ } + > + {/* 筛选表单 */} +
+
+ + + + + + + + + + + + +
+
+ + `共 ${total} 条申请记录`, + onChange: (page, size) => loadApplications(page, size), + }} + /> + + + + {/* 新增充值申请 Modal */} + setCreateOpen(false)} + destroyOnClose + width={620} + okText="提交审核" + confirmLoading={createSubmitting} + > + + +
+ (value == null || Number.isInteger(value)) + ? Promise.resolve() + : Promise.reject(new Error('请输入整数金额(元)')), + }, + ]} + > + + + + prev.amount_cny !== cur.amount_cny}> + {() => { + const amount = Number(createForm.getFieldValue('amount_cny') || 0) + const calculatedPoints = Math.round(amount * 100) + return ( +
+ 预计折算入账积分: + + +{calculatedPoints.toLocaleString('zh-CN')} 积分 + +
+ ) + }} +
+ + + + + + + + + +
+ + {/* 查看详情 Modal */} + setDetailItem(null)} + footer={} + width={600} + > + {detailItem && ( + + + {detailItem.application_no} + + {detailItem.merchant?.name || `商户#${detailItem.merchant_id}`} + ¥{(detailItem.amount_cny / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + +{detailItem.points_amount.toLocaleString('zh-CN')} 积分 + + + {detailItem.status === 'approved' ? ( + 已通过并入账 + ) : detailItem.status === 'rejected' ? ( + 已驳回 + ) : ( + 待财务审核 + )} + + {formatDateTime(detailItem.created_at)} + {detailItem.reviewed_at ? formatDateTime(detailItem.reviewed_at) : '-'} + {detailItem.note || '-'} + {detailItem.review_note || '-'} + + {detailItem.vouchers && detailItem.vouchers.length > 0 ? ( + + + {detailItem.vouchers.map((img, idx) => ( + + ))} + + + ) : ( + 无凭证截图 + )} + + + )} + + + {/* 管理员审核 Modal */} + setReviewItem(null)} + footer={[ + , + handleReviewSubmit(false)} + > + + , + , + ]} + width={560} + > + {reviewItem && ( +
+ + {reviewItem.application_no} + ¥{(reviewItem.amount_cny / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + +{reviewItem.points_amount.toLocaleString('zh-CN')} 积分 + + + + + {reviewItem.vouchers && reviewItem.vouchers.length > 0 ? ( + + + {reviewItem.vouchers.map((img, idx) => ( + + ))} + + + ) : ( + 无凭证截图 + )} + + + + + + + )} +
+ + ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e4852ff..99f3b91 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,3 +252,25 @@ export interface LoginResult { token: string 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 +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b4c36fa..c4fce5f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -17,6 +17,10 @@ export default defineConfig(({ mode }) => { target: apiTarget, changeOrigin: true, }, + '/uploads': { + target: apiTarget, + changeOrigin: true, + }, '/health': { target: apiTarget, changeOrigin: true,