draft
This commit is contained in:
268
backend/internal/service/merchant.go
Normal file
268
backend/internal/service/merchant.go
Normal file
@@ -0,0 +1,268 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user