Files
affiliate_dash/backend/internal/handler/recharge.go
T
yml2213 7f984b0f4e fix(security): 告警渠道 webhook 与 Bark Device Key 响应脱敏
AlertNotifyChannel.Config 原先以真值返回。钉钉/飞书/企微的 webhook URL 与 Bark
的 device key 本身就是凭据,泄露即可直接向对应群或设备推送消息,而列表接口对
具备 recharge:manage 的商户成员可见。

- 新增 service.MaskAlertChannel(s):在 HTTP 响应层脱敏,只保留密钥末 4 位。
  webhook 保留域名与路径以便辨认是哪一台机器人(钉钉/企微的 token 在查询串,
  飞书的 uuid 在路径末段),Server 属主机地址不遮蔽
- UpdateAlertChannel 识别回传的脱敏占位值并沿用库中真实密钥,使「只改名称/开关」
  不会被掩码覆盖;填写新值则正常替换
- 脱敏刻意不放在 service 层:CheckLowBalanceAndNotify 复用 ListAlertChannels
  的结果真正去发消息,在那一层脱敏会直接打断告警发送
- 前端表单补充说明文案

验证:新增 6 组测试,覆盖脱敏规则、三种机器人 URL 形态、脱敏结果仍能通过校验、
掩码回传不覆盖真实密钥,以及「service 层必须返回真值」这条关键边界。
2026-10-06 19:49:11 +08:00

263 lines
7.6 KiB
Go

package handler
import (
"strconv"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/model"
"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"`
NotifyIntervalMinutes int `json:"notify_interval_minutes"`
MaxNotifications int `json:"max_notifications"`
}
// 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,
NotifyIntervalMinutes: req.NotifyIntervalMinutes,
MaxNotifications: req.MaxNotifications,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, cfg)
}
// ListAlertChannels GET /api/merchant/alert-channels
func (h *RechargeHandler) ListAlertChannels(c *gin.Context) {
list, err := h.rechargeSvc.ListAlertChannels(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, service.MaskAlertChannels(list))
}
type alertChannelReq struct {
ChannelType string `json:"channel_type" binding:"required"`
Name string `json:"name"`
WebhookURL string `json:"webhook_url"`
Server string `json:"server"`
Key string `json:"key"`
Enabled *bool `json:"enabled"`
}
func (r alertChannelReq) toInput() service.AlertChannelInput {
enabled := true
if r.Enabled != nil {
enabled = *r.Enabled
}
return service.AlertChannelInput{
ChannelType: r.ChannelType,
Name: r.Name,
Enabled: enabled,
Config: model.AlertChannelConfig{
WebhookURL: r.WebhookURL,
Server: r.Server,
Key: r.Key,
},
}
}
// CreateAlertChannel POST /api/merchant/alert-channels
func (h *RechargeHandler) CreateAlertChannel(c *gin.Context) {
var req alertChannelReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:channel_type 必填")
return
}
channel, err := h.rechargeSvc.CreateAlertChannel(middleware.GetMerchantID(c), req.toInput())
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, service.MaskAlertChannel(*channel))
}
// UpdateAlertChannel PUT /api/merchant/alert-channels/:id
func (h *RechargeHandler) UpdateAlertChannel(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id == 0 {
response.BadRequest(c, "渠道 ID 无效")
return
}
var req alertChannelReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:channel_type 必填")
return
}
channel, err := h.rechargeSvc.UpdateAlertChannel(middleware.GetMerchantID(c), uint(id), req.toInput())
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, service.MaskAlertChannel(*channel))
}
// DeleteAlertChannel DELETE /api/merchant/alert-channels/:id
func (h *RechargeHandler) DeleteAlertChannel(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id == 0 {
response.BadRequest(c, "渠道 ID 无效")
return
}
if err := h.rechargeSvc.DeleteAlertChannel(middleware.GetMerchantID(c), uint(id)); err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, nil)
}
// TestAlertChannel POST /api/merchant/alert-channels/:id/test
func (h *RechargeHandler) TestAlertChannel(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id == 0 {
response.BadRequest(c, "渠道 ID 无效")
return
}
if err := h.rechargeSvc.TestAlertChannel(middleware.GetMerchantID(c), uint(id)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, nil)
}
// 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})
}