Files
pay-bridge/backend/internal/service/merchant.go
2026-03-13 15:51:59 +08:00

269 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// merchantRepo 定义 MerchantService 所需的数据访问方法,便于测试时注入 mock
type merchantRepo interface {
Create(ctx context.Context, m *model.Merchant) error
GetByMerchantID(ctx context.Context, merchantID string) (*model.Merchant, error)
GetByMerchantIDAndAppID(ctx context.Context, merchantID, appID string) (*model.Merchant, error)
UpdateStatus(ctx context.Context, merchantID string, status model.MerchantStatus, updates map[string]any) error
List(ctx context.Context, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error)
ListByAppID(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error)
ListAnomalous(ctx context.Context) ([]*model.Merchant, error)
CreateApplication(ctx context.Context, app *model.MerchantApplication) error
GetLatestApplication(ctx context.Context, merchantID string) (*model.MerchantApplication, error)
GetApprovedApplicationByChannel(ctx context.Context, merchantID, channelCode string) (*model.MerchantApplication, error)
UpdateApplication(ctx context.Context, applicationID string, updates map[string]any) error
}
// MerchantService 商户进件与管理服务
type MerchantService struct {
merchantRepo merchantRepo
channelSvc *ChannelService
}
func NewMerchantService(
merchantRepo *repository.MerchantRepository,
channelSvc *ChannelService,
) *MerchantService {
return &MerchantService{
merchantRepo: merchantRepo,
channelSvc: channelSvc,
}
}
func genApplicationID() string {
b := make([]byte, 16)
rand.Read(b)
return "APP" + hex.EncodeToString(b)[:16]
}
// Apply 提交商户进件申请
// bizContent 为完整的入网申请业务参数(对应 001 文档的 biz_content 结构)
func (s *MerchantService) Apply(ctx context.Context, merchantID, channelCode string, bizContent map[string]any) (string, error) {
merchant, err := s.merchantRepo.GetByMerchantID(ctx, merchantID)
if err != nil {
return "", err
}
if merchant == nil {
return "", errors.New("merchant not found")
}
if merchant.Status == model.MerchantStatusFrozen {
return "", errors.New("merchant is frozen")
}
ch, err := s.channelSvc.GetChannel(ctx, "", channelCode)
if err != nil {
return "", err
}
resp, err := ch.MerchantApply(ctx, &channel.MerchantApplyReq{
MerchantID: merchantID,
BizContent: bizContent,
})
if err != nil {
return "", err
}
applicationID := genApplicationID()
app := &model.MerchantApplication{
ApplicationID: applicationID,
MerchantID: merchantID,
ChannelCode: channelCode,
SubmitData: model.JSONMap(bizContent),
AuditStatus: model.AuditStatusSubmitting,
SubmittedAt: time.Now(),
}
// 持久化渠道返回的 request_no用于后续查询/修改
if resp.RequestNo != "" {
app.SubmitData["_channel_request_no"] = resp.RequestNo
}
if err := s.merchantRepo.CreateApplication(ctx, app); err != nil {
return "", err
}
slog.InfoContext(ctx, "merchant application submitted",
"merchant_id", merchantID,
"application_id", applicationID,
"channel_code", channelCode,
"channel_request_no", resp.RequestNo,
)
return applicationID, nil
}
// UploadFile 上传文件到指定渠道,返回渠道 file_id
func (s *MerchantService) UploadFile(ctx context.Context, channelCode string, req *channel.UploadFileReq) (string, error) {
ch, err := s.channelSvc.GetChannel(ctx, "", channelCode)
if err != nil {
return "", err
}
resp, err := ch.UploadFile(ctx, req)
if err != nil {
return "", err
}
return resp.FileID, nil
}
// QueryAuditStatus 查询进件审核状态
func (s *MerchantService) QueryAuditStatus(ctx context.Context, merchantID string) (*model.MerchantApplication, error) {
app, err := s.merchantRepo.GetLatestApplication(ctx, merchantID)
if err != nil {
return nil, err
}
if app == nil {
return nil, nil
}
// 如果仍在审核中,向渠道查询最新状态
if app.AuditStatus == model.AuditStatusSubmitting || app.AuditStatus == model.AuditStatusReviewing {
// 从 submit_data 中读取渠道返回的 request_no
channelRequestNo, _ := app.SubmitData["_channel_request_no"].(string)
if channelRequestNo != "" {
ch, err := s.channelSvc.GetChannel(ctx, "", app.ChannelCode)
if err == nil {
resp, err := ch.QueryMerchantStatus(ctx, channelRequestNo)
if err == nil {
merchant, _ := s.merchantRepo.GetByMerchantID(ctx, merchantID)
s.syncMerchantStatus(ctx, merchantID, app.ApplicationID, merchant, resp)
app, _ = s.merchantRepo.GetLatestApplication(ctx, merchantID)
}
}
}
}
return app, nil
}
// syncMerchantStatus 同步渠道返回的审核状态到本地
func (s *MerchantService) syncMerchantStatus(ctx context.Context, merchantID, applicationID string,
merchant *model.Merchant, resp *channel.MerchantStatusResp) {
now := time.Now()
appUpdates := map[string]any{}
switch resp.Status {
case "APPROVED":
appUpdates["audit_status"] = model.AuditStatusApproved
appUpdates["audited_at"] = now
if resp.ChannelMerchantID != "" {
appUpdates["channel_merchant_id"] = resp.ChannelMerchantID
}
s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusActive, nil)
case "REJECTED":
appUpdates["audit_status"] = model.AuditStatusRejected
appUpdates["reject_reason"] = resp.RejectReason
appUpdates["audited_at"] = now
s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusRejected, nil)
case "REVIEWING":
appUpdates["audit_status"] = model.AuditStatusReviewing
case "FROZEN":
s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusFrozen, nil)
}
if len(appUpdates) > 0 {
s.merchantRepo.UpdateApplication(ctx, applicationID, appUpdates)
}
}
// GetChannelMerchantID 返回指定商户在指定渠道进件审核通过后的渠道商户ID
// 若该商户未在该渠道进件或审核未通过,返回空字符串
func (s *MerchantService) GetChannelMerchantID(ctx context.Context, merchantID, channelCode string) (string, error) {
app, err := s.merchantRepo.GetApprovedApplicationByChannel(ctx, merchantID, channelCode)
if err != nil {
return "", err
}
if app == nil {
return "", nil
}
return app.ChannelMerchantID, nil
}
// CreateMerchantForApp 业务侧创建商户,强制绑定 appID
func (s *MerchantService) CreateMerchantForApp(ctx context.Context, appID string, m *model.Merchant) error {
m.AppID = appID
return s.merchantRepo.Create(ctx, m)
}
// GetMerchantForApp 业务侧查询,校验 appID 归属
func (s *MerchantService) GetMerchantForApp(ctx context.Context, appID, merchantID string) (*model.Merchant, error) {
m, err := s.merchantRepo.GetByMerchantIDAndAppID(ctx, merchantID, appID)
if err != nil {
return nil, err
}
if m == nil {
return nil, errors.New("30001") // merchant not found
}
return m, nil
}
// ListMerchantsForApp 业务侧列表,只返回该 appID 下的商户
func (s *MerchantService) ListMerchantsForApp(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
return s.merchantRepo.ListByAppID(ctx, appID, status, limit, offset)
}
// ApplyForApp 业务侧进件,校验 appID 归属后委托 Apply
func (s *MerchantService) ApplyForApp(ctx context.Context, appID, merchantID, channelCode string, bizContent map[string]any) (string, error) {
if _, err := s.GetMerchantForApp(ctx, appID, merchantID); err != nil {
return "", err
}
return s.Apply(ctx, merchantID, channelCode, bizContent)
}
// QueryAuditStatusForApp 业务侧查审核状态,校验 appID 归属
func (s *MerchantService) QueryAuditStatusForApp(ctx context.Context, appID, merchantID string) (*model.MerchantApplication, error) {
if _, err := s.GetMerchantForApp(ctx, appID, merchantID); err != nil {
return nil, err
}
return s.QueryAuditStatus(ctx, merchantID)
}
// CheckAnomalies 检查状态异常的商户(由 cron 调用)
func (s *MerchantService) CheckAnomalies(ctx context.Context) error {
merchants, err := s.merchantRepo.ListAnomalous(ctx)
if err != nil {
return err
}
slog.InfoContext(ctx, "anomalous merchants found", "count", len(merchants))
// 实际业务中可在此发送告警通知
return nil
}
// CreateMerchant 创建商户基础信息
func (s *MerchantService) CreateMerchant(ctx context.Context, m *model.Merchant) error {
return s.merchantRepo.Create(ctx, m)
}
// GetMerchant 查询商户信息
func (s *MerchantService) GetMerchant(ctx context.Context, merchantID string) (*model.Merchant, error) {
return s.merchantRepo.GetByMerchantID(ctx, merchantID)
}
// ListMerchants 查询商户列表
func (s *MerchantService) ListMerchants(ctx context.Context, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
return s.merchantRepo.List(ctx, status, limit, offset)
}
// FreezeMerchant 冻结商户
func (s *MerchantService) FreezeMerchant(ctx context.Context, merchantID string) error {
return s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusFrozen, nil)
}
// UnfreezeMerchant 解冻商户
func (s *MerchantService) UnfreezeMerchant(ctx context.Context, merchantID string) error {
return s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusActive, nil)
}