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) }