This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

View File

@@ -0,0 +1,373 @@
package handler
import (
"io"
"net/http"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/service"
)
// AdminHandler 管理后台接口处理器
type AdminHandler struct {
matchSvc *service.PaymentMatchService
merchantSvc *service.MerchantService
reconSvc *service.ReconciliationService
channelSvc *service.ChannelService
appSvc *service.AppService
}
func NewAdminHandler(
matchSvc *service.PaymentMatchService,
merchantSvc *service.MerchantService,
reconSvc *service.ReconciliationService,
channelSvc *service.ChannelService,
appSvc *service.AppService,
) *AdminHandler {
return &AdminHandler{
matchSvc: matchSvc,
merchantSvc: merchantSvc,
reconSvc: reconSvc,
channelSvc: channelSvc,
appSvc: appSvc,
}
}
// --- 请求结构体 ---
type createAppReq struct {
AppName string `json:"app_name" binding:"required"`
}
type manualBindOrderReq struct {
MatchID uint64 `json:"match_id" binding:"required"`
TradeNo string `json:"trade_no" binding:"required"`
Operator string `json:"operator" binding:"required"`
}
type applyMerchantReq struct {
ChannelCode string `json:"channel_code" binding:"required"`
SubmitData map[string]any `json:"submit_data"`
}
// appVO 应用列表视图(不含加密 secret
type appVO struct {
AppID string `json:"app_id"`
AppName string `json:"app_name"`
Status int8 `json:"status"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
// --- 应用管理 ---
// CreateApp 创建下游接入应用
func (h *AdminHandler) CreateApp(c *gin.Context) {
var req createAppReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "10001", err.Error())
return
}
result, err := h.appSvc.CreateApp(c.Request.Context(), req.AppName)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
// 明文 secret 仅在创建时返回一次,之后无法再查看
OK(c, gin.H{
"app_id": result.App.AppID,
"app_name": result.App.AppName,
"app_secret": result.PlainSecret,
"status": result.App.Status,
"created_at": result.App.CreatedAt,
"secret_tip": "请妥善保存 app_secret此后将无法再次查看",
})
}
// ListApps 查询应用列表
func (h *AdminHandler) ListApps(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
apps, err := h.appSvc.ListApps(c.Request.Context(), limit, offset)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
list := make([]appVO, 0, len(apps))
for _, a := range apps {
list = append(list, appVO{
AppID: a.AppID,
AppName: a.AppName,
Status: a.Status,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
}
OK(c, gin.H{"list": list, "limit": limit, "offset": offset})
}
// DisableApp 禁用应用
func (h *AdminHandler) DisableApp(c *gin.Context) {
appID := c.Param("appID")
if err := h.appSvc.DisableApp(c.Request.Context(), appID); err != nil {
if err.Error() == "20002" {
c.JSON(http.StatusNotFound, Response{Code: "20002", Message: "应用不存在"})
return
}
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// EnableApp 启用应用
func (h *AdminHandler) EnableApp(c *gin.Context) {
appID := c.Param("appID")
if err := h.appSvc.EnableApp(c.Request.Context(), appID); err != nil {
if err.Error() == "20002" {
c.JSON(http.StatusNotFound, Response{Code: "20002", Message: "应用不存在"})
return
}
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// ResetAppSecret 重置应用密钥
func (h *AdminHandler) ResetAppSecret(c *gin.Context) {
appID := c.Param("appID")
plainSecret, err := h.appSvc.ResetSecret(c.Request.Context(), appID)
if err != nil {
if err.Error() == "20002" {
c.JSON(http.StatusNotFound, Response{Code: "20002", Message: "应用不存在"})
return
}
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{
"app_id": appID,
"app_secret": plainSecret,
"secret_tip": "请妥善保存 app_secret此后将无法再次查看",
})
}
// --- 收款匹配管理 ---
// ListPendingMatches 查询待人工确认的收款记录
func (h *AdminHandler) ListPendingMatches(c *gin.Context) {
appID := c.Query("app_id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
logs, err := h.matchSvc.ListPendingManual(c.Request.Context(), appID, limit, offset)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"list": logs, "limit": limit, "offset": offset})
}
// ManualBindOrder 人工关联收款与订单
func (h *AdminHandler) ManualBindOrder(c *gin.Context) {
var req manualBindOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "10001", err.Error())
return
}
if err := h.matchSvc.ManualBindOrder(c.Request.Context(), req.MatchID, req.TradeNo, req.Operator); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// --- 商户管理 ---
// CreateMerchant 创建商户
func (h *AdminHandler) CreateMerchant(c *gin.Context) {
var m model.Merchant
if err := c.ShouldBindJSON(&m); err != nil {
BadRequest(c, "10001", err.Error())
return
}
if err := h.merchantSvc.CreateMerchant(c.Request.Context(), &m); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, m)
}
// GetMerchant 查询商户信息
func (h *AdminHandler) GetMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
m, err := h.merchantSvc.GetMerchant(c.Request.Context(), merchantID)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
if m == nil {
c.JSON(http.StatusNotFound, Response{Code: "30001", Message: "merchant not found"})
return
}
OK(c, m)
}
// ListMerchants 查询商户列表
func (h *AdminHandler) ListMerchants(c *gin.Context) {
status := model.MerchantStatus(c.Query("status"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
merchants, err := h.merchantSvc.ListMerchants(c.Request.Context(), status, limit, offset)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"list": merchants, "limit": limit, "offset": offset})
}
// FreezeMerchant 冻结商户
func (h *AdminHandler) FreezeMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
if err := h.merchantSvc.FreezeMerchant(c.Request.Context(), merchantID); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// UnfreezeMerchant 解冻商户
func (h *AdminHandler) UnfreezeMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
if err := h.merchantSvc.UnfreezeMerchant(c.Request.Context(), merchantID); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// UploadMerchantFile 上传进件所需文件,返回 file_id
// POST /api/v1/admin/merchant/upload-file
// multipart/form-data: file=<binary>, channel_code=HEEPAY, file_media_type=01
func (h *AdminHandler) UploadMerchantFile(c *gin.Context) {
channelCode := c.PostForm("channel_code")
if channelCode == "" {
channelCode = "HEEPAY"
}
fileMediaType := c.PostForm("file_media_type")
if fileMediaType == "" {
BadRequest(c, "10001", "file_media_type is required")
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
BadRequest(c, "10001", "file is required: "+err.Error())
return
}
f, err := fileHeader.Open()
if err != nil {
InternalError(c, "50001", "open file: "+err.Error())
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
InternalError(c, "50001", "read file: "+err.Error())
return
}
fileID, err := h.merchantSvc.UploadFile(c.Request.Context(), channelCode, &channel.UploadFileReq{
FileContent: content,
FileName: filepath.Base(fileHeader.Filename),
FileMediaType: fileMediaType,
})
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"file_id": fileID})
}
// ApplyMerchant 商户进件申请
func (h *AdminHandler) ApplyMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
var req applyMerchantReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "10001", err.Error())
return
}
applicationID, err := h.merchantSvc.Apply(c.Request.Context(), merchantID, req.ChannelCode, req.SubmitData)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"application_id": applicationID})
}
// QueryAuditStatus 查询进件审核状态
func (h *AdminHandler) QueryAuditStatus(c *gin.Context) {
merchantID := c.Param("merchantID")
app, err := h.merchantSvc.QueryAuditStatus(c.Request.Context(), merchantID)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, app)
}
// --- 对账管理 ---
// TriggerReconciliation 手动触发对账
func (h *AdminHandler) TriggerReconciliation(c *gin.Context) {
if err := h.reconSvc.RunDailyReconciliation(c.Request.Context()); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// GetReconciliationReport 查询对账报告
func (h *AdminHandler) GetReconciliationReport(c *gin.Context) {
appID := c.Query("app_id")
billDate := c.Query("bill_date")
channelCode := c.Query("channel_code")
report, err := h.reconSvc.GetReport(c.Request.Context(), appID, billDate, channelCode)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, report)
}
// GetReconciliationExceptions 查询对账异常明细
func (h *AdminHandler) GetReconciliationExceptions(c *gin.Context) {
reportIDStr := c.Param("reportID")
reportID, err := strconv.ParseUint(reportIDStr, 10, 64)
if err != nil {
BadRequest(c, "10001", "invalid report_id")
return
}
exs, err := h.reconSvc.GetExceptions(c.Request.Context(), reportID)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, exs)
}

View File

@@ -0,0 +1,49 @@
package handler
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
)
type adminAuthSvc interface {
Login(ctx context.Context, username, password string) (string, error)
}
type AuthHandler struct {
authSvc adminAuthSvc
}
func NewAuthHandler(authSvc adminAuthSvc) *AuthHandler {
return &AuthHandler{authSvc: authSvc}
}
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "400", "message": "参数错误"})
return
}
token, err := h.authSvc.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": "UNAUTHORIZED", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": "0",
"message": "ok",
"data": gin.H{"token": token},
})
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": "0", "message": "ok"})
}

View File

@@ -0,0 +1,188 @@
package handler
import (
"context"
"io"
"net/http"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"pay-bridge/internal/channel"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
)
// merchantService 定义 MerchantHandler 依赖的 service 方法,便于测试时注入 mock
type merchantService interface {
CreateMerchantForApp(ctx context.Context, appID string, m *model.Merchant) error
GetMerchantForApp(ctx context.Context, appID, merchantID string) (*model.Merchant, error)
ListMerchantsForApp(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error)
ApplyForApp(ctx context.Context, appID, merchantID, channelCode string, bizContent map[string]any) (string, error)
QueryAuditStatusForApp(ctx context.Context, appID, merchantID string) (*model.MerchantApplication, error)
UploadFile(ctx context.Context, channelCode string, req *channel.UploadFileReq) (string, error)
}
// MerchantHandler 业务侧商户进件接口处理器HMAC 鉴权appID 隔离)
type MerchantHandler struct {
merchantSvc merchantService
}
func NewMerchantHandler(svc merchantService) *MerchantHandler {
return &MerchantHandler{merchantSvc: svc}
}
// --- 请求结构体 ---
type createMerchantReq struct {
MerchantID string `json:"merchant_id" binding:"required"`
MerchantName string `json:"merchant_name" binding:"required"`
LicenseNo string `json:"license_no"`
LegalPerson string `json:"legal_person"`
BankAccount string `json:"bank_account"`
}
type merchantApplyReq struct {
ChannelCode string `json:"channel_code" binding:"required"`
SubmitData map[string]any `json:"submit_data"`
}
// --- Handlers ---
// CreateMerchant POST /api/v1/merchant
func (h *MerchantHandler) CreateMerchant(c *gin.Context) {
var req createMerchantReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
m := &model.Merchant{
MerchantID: req.MerchantID,
MerchantName: req.MerchantName,
LicenseNo: req.LicenseNo,
LegalPerson: req.LegalPerson,
BankAccount: req.BankAccount,
}
if err := h.merchantSvc.CreateMerchantForApp(c.Request.Context(), appID, m); err != nil {
InternalError(c, errcode.ErrInternalDB, err.Error())
return
}
OK(c, gin.H{"merchant_id": m.MerchantID})
}
// ListMerchants GET /api/v1/merchant
func (h *MerchantHandler) ListMerchants(c *gin.Context) {
appID := c.GetString("app_id")
status := model.MerchantStatus(c.Query("status"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
merchants, err := h.merchantSvc.ListMerchantsForApp(c.Request.Context(), appID, status, limit, offset)
if err != nil {
InternalError(c, errcode.ErrInternalDB, err.Error())
return
}
OK(c, gin.H{"list": merchants, "limit": limit, "offset": offset})
}
// GetMerchant GET /api/v1/merchant/:merchantID
func (h *MerchantHandler) GetMerchant(c *gin.Context) {
appID := c.GetString("app_id")
merchantID := c.Param("merchantID")
m, err := h.merchantSvc.GetMerchantForApp(c.Request.Context(), appID, merchantID)
if err != nil {
if err.Error() == errcode.ErrOrderNotFound {
c.JSON(http.StatusNotFound, Response{Code: errcode.ErrOrderNotFound, Message: "merchant not found"})
return
}
InternalError(c, errcode.ErrInternalDB, err.Error())
return
}
OK(c, m)
}
// UploadFile POST /api/v1/merchant/upload-file
func (h *MerchantHandler) UploadFile(c *gin.Context) {
channelCode := c.PostForm("channel_code")
if channelCode == "" {
channelCode = "HEEPAY"
}
fileMediaType := c.PostForm("file_media_type")
if fileMediaType == "" {
BadRequest(c, errcode.ErrInvalidParam, "file_media_type is required")
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
BadRequest(c, errcode.ErrInvalidParam, "file is required: "+err.Error())
return
}
f, err := fileHeader.Open()
if err != nil {
InternalError(c, errcode.ErrInternalSystem, "open file: "+err.Error())
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
InternalError(c, errcode.ErrInternalSystem, "read file: "+err.Error())
return
}
fileID, err := h.merchantSvc.UploadFile(c.Request.Context(), channelCode, &channel.UploadFileReq{
FileContent: content,
FileName: filepath.Base(fileHeader.Filename),
FileMediaType: fileMediaType,
})
if err != nil {
InternalError(c, errcode.ErrInternalSystem, err.Error())
return
}
OK(c, gin.H{"file_id": fileID})
}
// Apply POST /api/v1/merchant/:merchantID/apply
func (h *MerchantHandler) Apply(c *gin.Context) {
appID := c.GetString("app_id")
merchantID := c.Param("merchantID")
var req merchantApplyReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
applicationID, err := h.merchantSvc.ApplyForApp(c.Request.Context(), appID, merchantID, req.ChannelCode, req.SubmitData)
if err != nil {
if err.Error() == errcode.ErrOrderNotFound {
c.JSON(http.StatusNotFound, Response{Code: errcode.ErrOrderNotFound, Message: "merchant not found"})
return
}
InternalError(c, errcode.ErrInternalSystem, err.Error())
return
}
OK(c, gin.H{"application_id": applicationID})
}
// QueryAuditStatus GET /api/v1/merchant/:merchantID/audit
func (h *MerchantHandler) QueryAuditStatus(c *gin.Context) {
appID := c.GetString("app_id")
merchantID := c.Param("merchantID")
app, err := h.merchantSvc.QueryAuditStatusForApp(c.Request.Context(), appID, merchantID)
if err != nil {
if err.Error() == errcode.ErrOrderNotFound {
c.JSON(http.StatusNotFound, Response{Code: errcode.ErrOrderNotFound, Message: "merchant not found"})
return
}
InternalError(c, errcode.ErrInternalSystem, err.Error())
return
}
OK(c, app)
}

View File

@@ -0,0 +1,216 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// mockMerchantSvc 实现 merchantService interface
type mockMerchantSvc struct {
mock.Mock
}
func (m *mockMerchantSvc) CreateMerchantForApp(ctx context.Context, appID string, merchant *model.Merchant) error {
return m.Called(ctx, appID, merchant).Error(0)
}
func (m *mockMerchantSvc) GetMerchantForApp(ctx context.Context, appID, merchantID string) (*model.Merchant, error) {
args := m.Called(ctx, appID, merchantID)
v, _ := args.Get(0).(*model.Merchant)
return v, args.Error(1)
}
func (m *mockMerchantSvc) ListMerchantsForApp(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
args := m.Called(ctx, appID, status, limit, offset)
return args.Get(0).([]*model.Merchant), args.Error(1)
}
func (m *mockMerchantSvc) ApplyForApp(ctx context.Context, appID, merchantID, channelCode string, bizContent map[string]any) (string, error) {
args := m.Called(ctx, appID, merchantID, channelCode, bizContent)
return args.String(0), args.Error(1)
}
func (m *mockMerchantSvc) QueryAuditStatusForApp(ctx context.Context, appID, merchantID string) (*model.MerchantApplication, error) {
args := m.Called(ctx, appID, merchantID)
v, _ := args.Get(0).(*model.MerchantApplication)
return v, args.Error(1)
}
func (m *mockMerchantSvc) UploadFile(ctx context.Context, channelCode string, req *channel.UploadFileReq) (string, error) {
args := m.Called(ctx, channelCode, req)
return args.String(0), args.Error(1)
}
// newMerchantTestRouter 构建测试路由,注入固定 app_id 模拟鉴权
func newMerchantTestRouter(svc *mockMerchantSvc) *gin.Engine {
r := gin.New()
h := &MerchantHandler{merchantSvc: svc}
auth := func(c *gin.Context) {
c.Set("app_id", "app_test")
c.Next()
}
g := r.Group("/api/v1/merchant", auth)
g.POST("", h.CreateMerchant)
g.GET("", h.ListMerchants)
g.GET("/:merchantID", h.GetMerchant)
g.POST("/:merchantID/apply", h.Apply)
g.GET("/:merchantID/audit", h.QueryAuditStatus)
return r
}
// --- CreateMerchant ---
func TestCreateMerchant_OK(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("CreateMerchantForApp", mock.Anything, "app_test", mock.MatchedBy(func(m *model.Merchant) bool {
return m.MerchantID == "m001" && m.MerchantName == "测试公司"
})).Return(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant",
strings.NewReader(`{"merchant_id":"m001","merchant_name":"测试公司"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "0", resp["code"])
assert.Equal(t, "m001", resp["data"].(map[string]any)["merchant_id"])
svc.AssertExpectations(t)
}
func TestCreateMerchant_MissingName(t *testing.T) {
svc := new(mockMerchantSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant",
strings.NewReader(`{"merchant_id":"m001"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
svc.AssertNotCalled(t, "CreateMerchantForApp")
}
func TestCreateMerchant_MissingID(t *testing.T) {
svc := new(mockMerchantSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant",
strings.NewReader(`{"merchant_name":"测试公司"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// --- GetMerchant ---
func TestGetMerchant_OK(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("GetMerchantForApp", mock.Anything, "app_test", "m001").
Return(&model.Merchant{MerchantID: "m001", AppID: "app_test"}, nil)
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant/m001", nil))
assert.Equal(t, http.StatusOK, w.Code)
svc.AssertExpectations(t)
}
func TestGetMerchant_NotFound(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("GetMerchantForApp", mock.Anything, "app_test", "m999").
Return((*model.Merchant)(nil), errors.New("30001"))
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant/m999", nil))
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestGetMerchant_WrongApp(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("GetMerchantForApp", mock.Anything, "app_test", "other_m").
Return((*model.Merchant)(nil), errors.New("30001"))
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant/other_m", nil))
// 跨 app 访问应返回 404而不是 403避免信息泄露
assert.Equal(t, http.StatusNotFound, w.Code)
}
// --- ListMerchants ---
func TestListMerchants_DefaultPagination(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("ListMerchantsForApp", mock.Anything, "app_test", model.MerchantStatus(""), 20, 0).
Return([]*model.Merchant{}, nil)
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant", nil))
assert.Equal(t, http.StatusOK, w.Code)
svc.AssertExpectations(t)
}
// --- Apply ---
func TestApply_OK(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("ApplyForApp", mock.Anything, "app_test", "m001", "HEEPAY", mock.Anything).
Return("APP123", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant/m001/apply",
strings.NewReader(`{"channel_code":"HEEPAY","submit_data":{"name":"测试公司"}}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "APP123", resp["data"].(map[string]any)["application_id"])
}
func TestApply_MissingChannelCode(t *testing.T) {
svc := new(mockMerchantSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant/m001/apply",
strings.NewReader(`{"submit_data":{}}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
svc.AssertNotCalled(t, "ApplyForApp")
}
func TestApply_MerchantNotBelongToApp(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("ApplyForApp", mock.Anything, "app_test", "m_other", "HEEPAY", mock.Anything).
Return("", errors.New("30001"))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant/m_other/apply",
strings.NewReader(`{"channel_code":"HEEPAY"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -0,0 +1,242 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
"pay-bridge/internal/service"
)
// PayHandler 支付相关 Handler
type PayHandler struct {
tradeSvc *service.TradeService
refundSvc *service.RefundService
}
func NewPayHandler(tradeSvc *service.TradeService, refundSvc *service.RefundService) *PayHandler {
return &PayHandler{tradeSvc: tradeSvc, refundSvc: refundSvc}
}
type unifiedOrderReq struct {
ChannelCode string `json:"channel_code"`
MerchantOrderNo string `json:"merchant_order_no" binding:"required"`
PayMethod model.PayMethod `json:"pay_method" binding:"required"`
Amount int64 `json:"amount" binding:"required,min=1"`
ProfitSharingAmount int64 `json:"profit_sharing_amount"`
Subject string `json:"subject" binding:"required"`
NotifyURL string `json:"notify_url" binding:"required,url"`
ExpireMinutes int `json:"expire_minutes"`
Extra map[string]any `json:"extra"`
MerchantID string `json:"merchant_id"` // 可选,指定收款商户
}
type closeOrderReq struct {
TradeNo string `json:"trade_no" binding:"required"`
}
type refundReq struct {
TradeNo string `json:"trade_no" binding:"required"`
RefundAmount int64 `json:"refund_amount" binding:"required,min=1"`
Reason string `json:"reason"`
NotifyURL string `json:"notify_url"`
}
// UnifiedOrder POST /api/v1/pay/unified-order
func (h *PayHandler) UnifiedOrder(c *gin.Context) {
var req unifiedOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
resp, err := h.tradeSvc.CreateOrder(c.Request.Context(), &service.CreateOrderReq{
AppID: appID,
ChannelCode: req.ChannelCode,
MerchantOrderNo: req.MerchantOrderNo,
PayMethod: req.PayMethod,
Amount: req.Amount,
ProfitSharingAmount: req.ProfitSharingAmount,
Subject: req.Subject,
NotifyURL: req.NotifyURL,
ExpireMinutes: req.ExpireMinutes,
Extra: req.Extra,
MerchantID: req.MerchantID,
})
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"trade_no": resp.TradeNo,
"pay_credential": resp.PayCredential,
"is_idempotent": resp.IsIdempotent,
})
}
// QueryOrder GET /api/v1/pay/query/:tradeNo
func (h *PayHandler) QueryOrder(c *gin.Context) {
tradeNo := c.Param("tradeNo")
if tradeNo == "" {
BadRequest(c, errcode.ErrMissingParam, "trade_no is required")
return
}
appID := c.GetString("app_id")
order, err := h.tradeSvc.QueryOrder(c.Request.Context(), appID, tradeNo)
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"trade_no": order.TradeNo,
"merchant_order_no": order.MerchantOrderNo,
"pay_method": order.PayMethod,
"amount": order.Amount,
"status": order.Status,
"channel_trade_no": order.ChannelTradeNo,
"pay_time": order.PayTime,
"created_at": order.CreatedAt,
})
}
// CloseOrder POST /api/v1/pay/close
func (h *PayHandler) CloseOrder(c *gin.Context) {
var req closeOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
if err := h.tradeSvc.CloseOrder(c.Request.Context(), appID, req.TradeNo); err != nil {
handleBizError(c, err)
return
}
OK(c, nil)
}
// Refund POST /api/v1/pay/refund
func (h *PayHandler) Refund(c *gin.Context) {
var req refundReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
refund, err := h.refundSvc.CreateRefund(c.Request.Context(), &service.CreateRefundReq{
AppID: appID,
TradeNo: req.TradeNo,
RefundAmount: req.RefundAmount,
Reason: req.Reason,
NotifyURL: req.NotifyURL,
})
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"refund_no": refund.RefundNo,
"trade_no": refund.TradeNo,
"refund_amount": refund.RefundAmount,
"status": refund.Status,
"channel_refund_no": refund.ChannelRefundNo,
})
}
// QueryRefund GET /api/v1/pay/refund/query/:refundNo
func (h *PayHandler) QueryRefund(c *gin.Context) {
refundNo := c.Param("refundNo")
if refundNo == "" {
BadRequest(c, errcode.ErrMissingParam, "refund_no is required")
return
}
appID := c.GetString("app_id")
refund, err := h.refundSvc.QueryRefund(c.Request.Context(), appID, refundNo)
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"refund_no": refund.RefundNo,
"trade_no": refund.TradeNo,
"refund_amount": refund.RefundAmount,
"status": refund.Status,
"channel_refund_no": refund.ChannelRefundNo,
"refund_time": refund.RefundTime,
})
}
// handleBizError 将业务错误映射到 HTTP 响应
func handleBizError(c *gin.Context, err error) {
code := err.Error()
msg := errcode.Message(code)
if msg == "未知错误" {
msg = err.Error()
code = errcode.ErrInternalSystem
}
switch code {
case errcode.ErrInvalidParam, errcode.ErrMissingParam, errcode.ErrInvalidPayMethod, errcode.ErrInvalidAmount:
BadRequest(c, code, msg)
case errcode.ErrUnauthorized, errcode.ErrAppNotFound:
Unauthorized(c, code, msg)
case errcode.ErrPermissionDenied:
Forbidden(c, code, msg)
case errcode.ErrOrderNotFound, errcode.ErrOrderAlreadyPaid, errcode.ErrOrderClosed,
errcode.ErrRefundAmountExceed, errcode.ErrSharingAmountExceed, errcode.ErrOrderNotPaid,
errcode.ErrSharingNotConfig, errcode.ErrSharingFeeExceed, errcode.ErrRefundNotFound:
UnprocessableEntity(c, code, msg)
case errcode.ErrChannelCreateFail, errcode.ErrChannelRefundFail,
errcode.ErrChannelTimeout, errcode.ErrChannelNotSupport, errcode.ErrChannelVerifyFail:
BadGateway(c, code, msg)
default:
_ = errors.New(code) // suppress unused
InternalError(c, errcode.ErrInternalSystem, errcode.Message(errcode.ErrInternalSystem))
}
}
// NotifyHandler 渠道回调 Handler
type NotifyHandler struct {
tradeSvc *service.TradeService
}
func NewNotifyHandler(tradeSvc *service.TradeService) *NotifyHandler {
return &NotifyHandler{tradeSvc: tradeSvc}
}
// PaymentCallback POST /api/v1/notify/payment/:channelCode
func (h *NotifyHandler) PaymentCallback(c *gin.Context) {
channelCode := c.Param("channelCode")
var rawBody []byte
if v, exists := c.Get("raw_body"); exists {
rawBody, _ = v.([]byte)
}
result, err := h.tradeSvc.HandleUpstreamNotify(c.Request.Context(), channelCode, rawBody, headersMap(c))
if err != nil {
c.String(http.StatusOK, "fail")
return
}
c.String(http.StatusOK, result)
}
func headersMap(c *gin.Context) map[string]string {
m := make(map[string]string)
for k, v := range c.Request.Header {
if len(v) > 0 {
m[k] = v[0]
}
}
return m
}

View File

@@ -0,0 +1,73 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response 统一响应格式
type Response struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
// OK 成功响应
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, Response{
Code: "0",
Message: "success",
Data: data,
TraceID: traceID(c),
})
}
// Fail 失败响应
func Fail(c *gin.Context, httpStatus int, code, message string) {
c.JSON(httpStatus, Response{
Code: code,
Message: message,
TraceID: traceID(c),
})
}
// BadRequest 400
func BadRequest(c *gin.Context, code, message string) {
Fail(c, http.StatusBadRequest, code, message)
}
// Unauthorized 401
func Unauthorized(c *gin.Context, code, message string) {
Fail(c, http.StatusUnauthorized, code, message)
}
// Forbidden 403
func Forbidden(c *gin.Context, code, message string) {
Fail(c, http.StatusForbidden, code, message)
}
// UnprocessableEntity 422业务规则错误
func UnprocessableEntity(c *gin.Context, code, message string) {
Fail(c, http.StatusUnprocessableEntity, code, message)
}
// InternalError 500
func InternalError(c *gin.Context, code, message string) {
Fail(c, http.StatusInternalServerError, code, message)
}
// BadGateway 502渠道错误
func BadGateway(c *gin.Context, code, message string) {
Fail(c, http.StatusBadGateway, code, message)
}
func traceID(c *gin.Context) string {
if id, exists := c.Get("trace_id"); exists {
if s, ok := id.(string); ok {
return s
}
}
return ""
}

View File

@@ -0,0 +1,99 @@
package middleware
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"pay-bridge/internal/api/handler"
"pay-bridge/internal/errcode"
)
// AppLoader 根据 appId 加载 app 信息的接口
type AppLoader interface {
GetAppSecret(ctx context.Context, appID string) (string, error)
}
// Auth 鉴权中间件
// 请求头X-App-Id、X-Timestamp、X-Sign
// 签名算法HMAC-SHA256(appId + timestamp + body, appSecret)
func Auth(loader AppLoader) gin.HandlerFunc {
return func(c *gin.Context) {
appID := c.GetHeader("X-App-Id")
timestamp := c.GetHeader("X-Timestamp")
sign := c.GetHeader("X-Sign")
if appID == "" || timestamp == "" || sign == "" {
handler.Unauthorized(c, errcode.ErrUnauthorized, errcode.Message(errcode.ErrUnauthorized))
c.Abort()
return
}
// 时间戳防重放5分钟内有效
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs(time.Now().Unix()-ts) > 300 {
handler.Unauthorized(c, errcode.ErrUnauthorized, "请求已过期")
c.Abort()
return
}
appSecret, err := loader.GetAppSecret(c.Request.Context(), appID)
if err != nil {
handler.Unauthorized(c, errcode.ErrAppNotFound, errcode.Message(errcode.ErrAppNotFound))
c.Abort()
return
}
// 读取 body注意body 只能读一次,需要提前 cache
body := bodyFromContext(c)
expectedSign := sign256(appID+timestamp+string(body), appSecret)
if !hmac.Equal([]byte(expectedSign), []byte(sign)) {
handler.Unauthorized(c, errcode.ErrUnauthorized, errcode.Message(errcode.ErrUnauthorized))
c.Abort()
return
}
c.Set("app_id", appID)
c.Next()
}
}
// ChannelCallback 渠道回调鉴权(由渠道适配器验签,此中间件只做基础检查)
func ChannelCallback() gin.HandlerFunc {
return func(c *gin.Context) {
channelCode := c.Param("channelCode")
if channelCode == "" {
c.AbortWithStatus(http.StatusBadRequest)
return
}
c.Next()
}
}
func sign256(payload, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}
func bodyFromContext(c *gin.Context) []byte {
if v, exists := c.Get("raw_body"); exists {
if b, ok := v.([]byte); ok {
return b
}
}
return nil
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"bytes"
"io"
"github.com/gin-gonic/gin"
)
// CacheBody 缓存 request bodybody 只能读一次,中间件提前读取并缓存)
func CacheBody() gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
body = []byte{}
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
c.Set("raw_body", body)
c.Next()
}
}

View File

@@ -0,0 +1,48 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// TokenParser 解析 JWT token 的接口
type TokenParser interface {
ParseToken(tokenStr string) (string, error)
}
// JWTAuth 管理后台 JWT 鉴权中间件
func JWTAuth(parser TokenParser) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "401",
"message": "未登录,请先登录",
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "401",
"message": "Token 格式错误",
})
return
}
username, err := parser.ParseToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "401",
"message": "Token 无效或已过期",
})
return
}
c.Set("username", username)
c.Next()
}
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
)
// Trace 注入 trace_id 中间件
func Trace() gin.HandlerFunc {
return func(c *gin.Context) {
traceID := c.GetHeader("X-Trace-Id")
if traceID == "" {
b := make([]byte, 8)
_, _ = rand.Read(b)
traceID = hex.EncodeToString(b)
}
c.Set("trace_id", traceID)
c.Header("X-Trace-Id", traceID)
c.Next()
}
}

View File

@@ -0,0 +1,107 @@
package api
import (
"github.com/gin-gonic/gin"
"pay-bridge/internal/api/handler"
"pay-bridge/internal/api/middleware"
"pay-bridge/internal/app"
)
// SetupRouter 注册所有路由
func SetupRouter(a *app.App) *gin.Engine {
payHandler := handler.NewPayHandler(a.TradeSvc, a.RefundSvc)
notifyHandler := handler.NewNotifyHandler(a.TradeSvc)
adminHandler := handler.NewAdminHandler(a.MatchSvc, a.MerchantSvc, a.ReconSvc, a.ChannelSvc, a.AppSvc)
authHandler := handler.NewAuthHandler(a.AdminAuthSvc)
merchantHandler := handler.NewMerchantHandler(a.MerchantSvc)
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.Trace())
r.Use(middleware.CacheBody())
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 上游渠道回调(渠道验签,不走 appId 鉴权)
notify := r.Group("/api/v1/notify")
{
notify.POST("/payment/:channelCode", notifyHandler.PaymentCallback)
}
// 下游系统调用接口appId+appSecret 签名鉴权)
v1 := r.Group("/api/v1", middleware.Auth(a.AppSvc))
{
pay := v1.Group("/pay")
{
pay.POST("/unified-order", payHandler.UnifiedOrder)
pay.GET("/query/:tradeNo", payHandler.QueryOrder)
pay.POST("/close", payHandler.CloseOrder)
pay.POST("/refund", payHandler.Refund)
pay.GET("/refund/query/:refundNo", payHandler.QueryRefund)
}
merchantGroup := v1.Group("/merchant")
{
merchantGroup.POST("", merchantHandler.CreateMerchant)
merchantGroup.GET("", merchantHandler.ListMerchants)
merchantGroup.POST("/upload-file", merchantHandler.UploadFile)
merchantGroup.GET("/:merchantID", merchantHandler.GetMerchant)
merchantGroup.POST("/:merchantID/apply", merchantHandler.Apply)
merchantGroup.GET("/:merchantID/audit", merchantHandler.QueryAuditStatus)
}
}
// 管理后台接口
adminPublic := r.Group("/api/v1/admin")
{
adminPublic.POST("/login", authHandler.Login)
}
admin := r.Group("/api/v1/admin", middleware.JWTAuth(a.AdminAuthSvc))
{
admin.POST("/logout", authHandler.Logout)
// 应用管理
appGroup := admin.Group("/app")
{
appGroup.POST("", adminHandler.CreateApp)
appGroup.GET("", adminHandler.ListApps)
appGroup.POST("/:appID/disable", adminHandler.DisableApp)
appGroup.POST("/:appID/enable", adminHandler.EnableApp)
appGroup.POST("/:appID/reset-secret", adminHandler.ResetAppSecret)
}
// 收款匹配
match := admin.Group("/match")
{
match.GET("/pending", adminHandler.ListPendingMatches)
match.POST("/bind", adminHandler.ManualBindOrder)
}
// 商户管理
merchant := admin.Group("/merchant")
{
merchant.POST("", adminHandler.CreateMerchant)
merchant.GET("", adminHandler.ListMerchants)
merchant.POST("/upload-file", adminHandler.UploadMerchantFile)
merchant.GET("/:merchantID", adminHandler.GetMerchant)
merchant.POST("/:merchantID/freeze", adminHandler.FreezeMerchant)
merchant.POST("/:merchantID/unfreeze", adminHandler.UnfreezeMerchant)
merchant.POST("/:merchantID/apply", adminHandler.ApplyMerchant)
merchant.GET("/:merchantID/audit", adminHandler.QueryAuditStatus)
}
// 对账管理
recon := admin.Group("/reconciliation")
{
recon.POST("/trigger", adminHandler.TriggerReconciliation)
recon.GET("/report", adminHandler.GetReconciliationReport)
recon.GET("/report/:reportID/exceptions", adminHandler.GetReconciliationExceptions)
}
}
return r
}