Files
epay-go/docs/plans/2026-02-07-epay-go-phase2-backend-core.md
2026-03-28 14:55:39 +08:00

68 KiB
Raw Permalink Blame History

EPay Go 重构 - 阶段二:后端核心业务功能

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 实现商户模块、支付通道管理、订单模块、支付适配器层(支付宝、微信)、异步回调处理和结算模块。

Architecture: 采用 handler → service → repository 三层架构,支付渠道使用适配器模式实现统一接口,使用 gopay SDK 对接支付宝和微信支付。

Tech Stack: Go, Gin, GORM, gopay, bcrypt, UUID

前置条件: 阶段一已完成,项目基础框架已搭建。


Task 1: 创建通用工具函数

Files:

  • Create: epay-go/pkg/utils/utils.go
  • Create: epay-go/pkg/utils/password.go
  • Create: epay-go/pkg/utils/trade.go

Step 1: 创建通用工具函数

// pkg/utils/utils.go
package utils

import (
	"crypto/md5"
	"encoding/hex"
	"net"
	"strings"

	"github.com/gin-gonic/gin"
)

// GetClientIP 获取客户端真实IP
func GetClientIP(c *gin.Context) string {
	// 优先从 X-Forwarded-For 获取
	xff := c.GetHeader("X-Forwarded-For")
	if xff != "" {
		ips := strings.Split(xff, ",")
		if len(ips) > 0 {
			ip := strings.TrimSpace(ips[0])
			if net.ParseIP(ip) != nil {
				return ip
			}
		}
	}

	// 其次从 X-Real-IP 获取
	xri := c.GetHeader("X-Real-IP")
	if xri != "" && net.ParseIP(xri) != nil {
		return xri
	}

	// 最后使用 RemoteAddr
	ip, _, _ := net.SplitHostPort(c.Request.RemoteAddr)
	return ip
}

// MD5 计算MD5哈希
func MD5(str string) string {
	h := md5.New()
	h.Write([]byte(str))
	return hex.EncodeToString(h.Sum(nil))
}

// ContainsString 检查字符串切片是否包含指定字符串
func ContainsString(slice []string, str string) bool {
	for _, s := range slice {
		if s == str {
			return true
		}
	}
	return false
}

Step 2: 创建密码工具

// pkg/utils/password.go
package utils

import (
	"golang.org/x/crypto/bcrypt"
)

// HashPassword 使用bcrypt加密密码
func HashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	return string(bytes), err
}

// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

Step 3: 创建订单号生成工具

// pkg/utils/trade.go
package utils

import (
	"crypto/rand"
	"fmt"
	"time"
)

// GenerateTradeNo 生成订单号 (时间戳 + 随机数,共24位)
func GenerateTradeNo() string {
	timestamp := time.Now().Format("20060102150405")
	random := make([]byte, 5)
	rand.Read(random)
	return fmt.Sprintf("%s%x", timestamp, random)
}

// GenerateSettleNo 生成结算单号
func GenerateSettleNo() string {
	timestamp := time.Now().Format("20060102150405")
	random := make([]byte, 4)
	rand.Read(random)
	return fmt.Sprintf("S%s%x", timestamp, random)
}

// GenerateAPIKey 生成商户API密钥 (32位)
func GenerateAPIKey() string {
	b := make([]byte, 16)
	rand.Read(b)
	return fmt.Sprintf("%x", b)
}

Step 4: 提交

cd d:/project/payment/epay-go
git add pkg/utils/
git commit -m "feat: add utility functions for password, trade no generation"

Task 2: 创建 Repository 层 - 商户

Files:

  • Create: epay-go/internal/repository/merchant.go

Step 1: 创建商户 Repository

// internal/repository/merchant.go
package repository

import (
	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"gorm.io/gorm"
)

type MerchantRepository struct {
	db *gorm.DB
}

func NewMerchantRepository() *MerchantRepository {
	return &MerchantRepository{db: database.Get()}
}

// Create 创建商户
func (r *MerchantRepository) Create(merchant *model.Merchant) error {
	return r.db.Create(merchant).Error
}

// GetByID 根据ID获取商户
func (r *MerchantRepository) GetByID(id int64) (*model.Merchant, error) {
	var merchant model.Merchant
	err := r.db.First(&merchant, id).Error
	if err != nil {
		return nil, err
	}
	return &merchant, nil
}

// GetByUsername 根据用户名获取商户
func (r *MerchantRepository) GetByUsername(username string) (*model.Merchant, error) {
	var merchant model.Merchant
	err := r.db.Where("username = ?", username).First(&merchant).Error
	if err != nil {
		return nil, err
	}
	return &merchant, nil
}

// GetByAPIKey 根据API Key获取商户
func (r *MerchantRepository) GetByAPIKey(apiKey string) (*model.Merchant, error) {
	var merchant model.Merchant
	err := r.db.Where("api_key = ?", apiKey).First(&merchant).Error
	if err != nil {
		return nil, err
	}
	return &merchant, nil
}

// Update 更新商户
func (r *MerchantRepository) Update(merchant *model.Merchant) error {
	return r.db.Save(merchant).Error
}

// UpdateBalance 更新余额 (使用事务)
func (r *MerchantRepository) UpdateBalance(tx *gorm.DB, id int64, amount float64) error {
	return tx.Model(&model.Merchant{}).Where("id = ?", id).
		Update("balance", gorm.Expr("balance + ?", amount)).Error
}

// List 分页查询商户列表
func (r *MerchantRepository) List(page, pageSize int, status *int8) ([]model.Merchant, int64, error) {
	var merchants []model.Merchant
	var total int64

	query := r.db.Model(&model.Merchant{})
	if status != nil {
		query = query.Where("status = ?", *status)
	}

	err := query.Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	offset := (page - 1) * pageSize
	err = query.Offset(offset).Limit(pageSize).Order("id DESC").Find(&merchants).Error
	if err != nil {
		return nil, 0, err
	}

	return merchants, total, nil
}

// ExistsByUsername 检查用户名是否存在
func (r *MerchantRepository) ExistsByUsername(username string) (bool, error) {
	var count int64
	err := r.db.Model(&model.Merchant{}).Where("username = ?", username).Count(&count).Error
	return count > 0, err
}

Step 2: 提交

git add internal/repository/merchant.go
git commit -m "feat: add merchant repository"

Task 3: 创建 Repository 层 - 管理员、通道、订单

Files:

  • Create: epay-go/internal/repository/admin.go
  • Create: epay-go/internal/repository/channel.go
  • Create: epay-go/internal/repository/order.go

Step 1: 创建管理员 Repository

// internal/repository/admin.go
package repository

import (
	"time"

	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"gorm.io/gorm"
)

type AdminRepository struct {
	db *gorm.DB
}

func NewAdminRepository() *AdminRepository {
	return &AdminRepository{db: database.Get()}
}

// Create 创建管理员
func (r *AdminRepository) Create(admin *model.Admin) error {
	return r.db.Create(admin).Error
}

// GetByID 根据ID获取管理员
func (r *AdminRepository) GetByID(id int64) (*model.Admin, error) {
	var admin model.Admin
	err := r.db.First(&admin, id).Error
	if err != nil {
		return nil, err
	}
	return &admin, nil
}

// GetByUsername 根据用户名获取管理员
func (r *AdminRepository) GetByUsername(username string) (*model.Admin, error) {
	var admin model.Admin
	err := r.db.Where("username = ?", username).First(&admin).Error
	if err != nil {
		return nil, err
	}
	return &admin, nil
}

// UpdateLastLogin 更新最后登录时间
func (r *AdminRepository) UpdateLastLogin(id int64) error {
	now := time.Now()
	return r.db.Model(&model.Admin{}).Where("id = ?", id).Update("last_login_at", &now).Error
}

// ExistsByUsername 检查用户名是否存在
func (r *AdminRepository) ExistsByUsername(username string) (bool, error) {
	var count int64
	err := r.db.Model(&model.Admin{}).Where("username = ?", username).Count(&count).Error
	return count > 0, err
}

// Count 统计管理员数量
func (r *AdminRepository) Count() (int64, error) {
	var count int64
	err := r.db.Model(&model.Admin{}).Count(&count).Error
	return count, err
}

Step 2: 创建通道 Repository

// internal/repository/channel.go
package repository

import (
	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"gorm.io/gorm"
)

type ChannelRepository struct {
	db *gorm.DB
}

func NewChannelRepository() *ChannelRepository {
	return &ChannelRepository{db: database.Get()}
}

// Create 创建通道
func (r *ChannelRepository) Create(channel *model.Channel) error {
	return r.db.Create(channel).Error
}

// GetByID 根据ID获取通道
func (r *ChannelRepository) GetByID(id int64) (*model.Channel, error) {
	var channel model.Channel
	err := r.db.First(&channel, id).Error
	if err != nil {
		return nil, err
	}
	return &channel, nil
}

// Update 更新通道
func (r *ChannelRepository) Update(channel *model.Channel) error {
	return r.db.Save(channel).Error
}

// Delete 删除通道
func (r *ChannelRepository) Delete(id int64) error {
	return r.db.Delete(&model.Channel{}, id).Error
}

// List 分页查询通道列表
func (r *ChannelRepository) List(page, pageSize int) ([]model.Channel, int64, error) {
	var channels []model.Channel
	var total int64

	err := r.db.Model(&model.Channel{}).Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	offset := (page - 1) * pageSize
	err = r.db.Offset(offset).Limit(pageSize).Order("sort ASC, id ASC").Find(&channels).Error
	if err != nil {
		return nil, 0, err
	}

	return channels, total, nil
}

// ListEnabled 获取所有启用的通道
func (r *ChannelRepository) ListEnabled() ([]model.Channel, error) {
	var channels []model.Channel
	err := r.db.Where("status = ?", 1).Order("sort ASC, id ASC").Find(&channels).Error
	return channels, err
}

// GetByPluginAndPayType 根据插件和支付类型获取可用通道
func (r *ChannelRepository) GetByPluginAndPayType(plugin, payType string) (*model.Channel, error) {
	var channel model.Channel
	err := r.db.Where("plugin = ? AND status = 1 AND pay_types LIKE ?", plugin, "%"+payType+"%").
		Order("sort ASC").First(&channel).Error
	if err != nil {
		return nil, err
	}
	return &channel, nil
}

// GetAvailableByPayType 根据支付类型获取可用通道
func (r *ChannelRepository) GetAvailableByPayType(payType string) (*model.Channel, error) {
	var channel model.Channel
	err := r.db.Where("status = 1 AND pay_types LIKE ?", "%"+payType+"%").
		Order("sort ASC").First(&channel).Error
	if err != nil {
		return nil, err
	}
	return &channel, nil
}

Step 3: 创建订单 Repository

// internal/repository/order.go
package repository

import (
	"time"

	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"github.com/shopspring/decimal"
	"gorm.io/gorm"
)

type OrderRepository struct {
	db *gorm.DB
}

func NewOrderRepository() *OrderRepository {
	return &OrderRepository{db: database.Get()}
}

// Create 创建订单
func (r *OrderRepository) Create(order *model.Order) error {
	return r.db.Create(order).Error
}

// GetByID 根据ID获取订单
func (r *OrderRepository) GetByID(id int64) (*model.Order, error) {
	var order model.Order
	err := r.db.Preload("Merchant").Preload("Channel").First(&order, id).Error
	if err != nil {
		return nil, err
	}
	return &order, nil
}

// GetByTradeNo 根据系统订单号获取订单
func (r *OrderRepository) GetByTradeNo(tradeNo string) (*model.Order, error) {
	var order model.Order
	err := r.db.Preload("Merchant").Preload("Channel").
		Where("trade_no = ?", tradeNo).First(&order).Error
	if err != nil {
		return nil, err
	}
	return &order, nil
}

// GetByOutTradeNo 根据商户订单号获取订单
func (r *OrderRepository) GetByOutTradeNo(merchantID int64, outTradeNo string) (*model.Order, error) {
	var order model.Order
	err := r.db.Where("merchant_id = ? AND out_trade_no = ?", merchantID, outTradeNo).
		First(&order).Error
	if err != nil {
		return nil, err
	}
	return &order, nil
}

// Update 更新订单
func (r *OrderRepository) Update(order *model.Order) error {
	return r.db.Save(order).Error
}

// UpdateStatus 更新订单状态
func (r *OrderRepository) UpdateStatus(tradeNo string, status int8) error {
	updates := map[string]interface{}{"status": status}
	if status == model.OrderStatusPaid {
		now := time.Now()
		updates["paid_at"] = &now
	}
	return r.db.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(updates).Error
}

// UpdateNotifyStatus 更新通知状态
func (r *OrderRepository) UpdateNotifyStatus(tradeNo string, status int8, nextNotifyAt *time.Time) error {
	updates := map[string]interface{}{
		"notify_status": status,
		"notify_count":  gorm.Expr("notify_count + 1"),
	}
	if nextNotifyAt != nil {
		updates["next_notify_at"] = nextNotifyAt
	}
	return r.db.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(updates).Error
}

// UpdatePayInfo 更新支付信息
func (r *OrderRepository) UpdatePayInfo(tradeNo, apiTradeNo, buyer string) error {
	now := time.Now()
	return r.db.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(map[string]interface{}{
		"api_trade_no": apiTradeNo,
		"buyer":        buyer,
		"status":       model.OrderStatusPaid,
		"paid_at":      &now,
	}).Error
}

// List 分页查询订单列表
func (r *OrderRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Order, int64, error) {
	var orders []model.Order
	var total int64

	query := r.db.Model(&model.Order{})
	if merchantID != nil {
		query = query.Where("merchant_id = ?", *merchantID)
	}
	if status != nil {
		query = query.Where("status = ?", *status)
	}

	err := query.Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	offset := (page - 1) * pageSize
	err = query.Preload("Merchant").Preload("Channel").
		Offset(offset).Limit(pageSize).Order("id DESC").Find(&orders).Error
	if err != nil {
		return nil, 0, err
	}

	return orders, total, nil
}

// GetPendingNotifyOrders 获取待通知的订单
func (r *OrderRepository) GetPendingNotifyOrders(limit int) ([]model.Order, error) {
	var orders []model.Order
	err := r.db.Where("status = ? AND notify_status < ? AND (next_notify_at IS NULL OR next_notify_at <= ?)",
		model.OrderStatusPaid, model.NotifyStatusSuccess, time.Now()).
		Limit(limit).Find(&orders).Error
	return orders, err
}

// GetTodayStats 获取今日统计
func (r *OrderRepository) GetTodayStats(merchantID *int64) (int64, decimal.Decimal, error) {
	var count int64
	var amount decimal.Decimal

	today := time.Now().Format("2006-01-02")
	query := r.db.Model(&model.Order{}).
		Where("status = ? AND DATE(created_at) = ?", model.OrderStatusPaid, today)

	if merchantID != nil {
		query = query.Where("merchant_id = ?", *merchantID)
	}

	err := query.Count(&count).Error
	if err != nil {
		return 0, decimal.Zero, err
	}

	var result struct {
		Total decimal.Decimal
	}
	err = query.Select("COALESCE(SUM(amount), 0) as total").Scan(&result).Error
	if err != nil {
		return 0, decimal.Zero, err
	}

	return count, result.Total, nil
}

Step 4: 提交

git add internal/repository/
git commit -m "feat: add admin, channel, order repositories"

Task 4: 创建 Repository 层 - 结算、资金记录

Files:

  • Create: epay-go/internal/repository/settlement.go
  • Create: epay-go/internal/repository/record.go

Step 1: 创建结算 Repository

// internal/repository/settlement.go
package repository

import (
	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"gorm.io/gorm"
)

type SettlementRepository struct {
	db *gorm.DB
}

func NewSettlementRepository() *SettlementRepository {
	return &SettlementRepository{db: database.Get()}
}

// Create 创建结算记录
func (r *SettlementRepository) Create(settlement *model.Settlement) error {
	return r.db.Create(settlement).Error
}

// GetByID 根据ID获取结算记录
func (r *SettlementRepository) GetByID(id int64) (*model.Settlement, error) {
	var settlement model.Settlement
	err := r.db.Preload("Merchant").First(&settlement, id).Error
	if err != nil {
		return nil, err
	}
	return &settlement, nil
}

// GetBySettleNo 根据结算单号获取记录
func (r *SettlementRepository) GetBySettleNo(settleNo string) (*model.Settlement, error) {
	var settlement model.Settlement
	err := r.db.Where("settle_no = ?", settleNo).First(&settlement).Error
	if err != nil {
		return nil, err
	}
	return &settlement, nil
}

// Update 更新结算记录
func (r *SettlementRepository) Update(settlement *model.Settlement) error {
	return r.db.Save(settlement).Error
}

// UpdateStatus 更新结算状态
func (r *SettlementRepository) UpdateStatus(id int64, status int8, remark string) error {
	updates := map[string]interface{}{
		"status": status,
		"remark": remark,
	}
	return r.db.Model(&model.Settlement{}).Where("id = ?", id).Updates(updates).Error
}

// List 分页查询结算列表
func (r *SettlementRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Settlement, int64, error) {
	var settlements []model.Settlement
	var total int64

	query := r.db.Model(&model.Settlement{})
	if merchantID != nil {
		query = query.Where("merchant_id = ?", *merchantID)
	}
	if status != nil {
		query = query.Where("status = ?", *status)
	}

	err := query.Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	offset := (page - 1) * pageSize
	err = query.Preload("Merchant").Offset(offset).Limit(pageSize).Order("id DESC").Find(&settlements).Error
	if err != nil {
		return nil, 0, err
	}

	return settlements, total, nil
}

// HasPendingSettlement 检查是否有待处理的结算
func (r *SettlementRepository) HasPendingSettlement(merchantID int64) (bool, error) {
	var count int64
	err := r.db.Model(&model.Settlement{}).
		Where("merchant_id = ? AND status IN ?", merchantID, []int8{model.SettleStatusPending, model.SettleStatusProcessing}).
		Count(&count).Error
	return count > 0, err
}

Step 2: 创建资金记录 Repository

// internal/repository/record.go
package repository

import (
	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"github.com/shopspring/decimal"
	"gorm.io/gorm"
)

type BalanceRecordRepository struct {
	db *gorm.DB
}

func NewBalanceRecordRepository() *BalanceRecordRepository {
	return &BalanceRecordRepository{db: database.Get()}
}

// Create 创建资金记录
func (r *BalanceRecordRepository) Create(record *model.BalanceRecord) error {
	return r.db.Create(record).Error
}

// CreateWithTx 在事务中创建资金记录
func (r *BalanceRecordRepository) CreateWithTx(tx *gorm.DB, record *model.BalanceRecord) error {
	return tx.Create(record).Error
}

// List 分页查询资金记录
func (r *BalanceRecordRepository) List(page, pageSize int, merchantID int64) ([]model.BalanceRecord, int64, error) {
	var records []model.BalanceRecord
	var total int64

	query := r.db.Model(&model.BalanceRecord{}).Where("merchant_id = ?", merchantID)

	err := query.Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	offset := (page - 1) * pageSize
	err = query.Offset(offset).Limit(pageSize).Order("id DESC").Find(&records).Error
	if err != nil {
		return nil, 0, err
	}

	return records, total, nil
}

// AddBalanceRecord 添加资金变动记录 (带事务)
func AddBalanceRecord(tx *gorm.DB, merchantID int64, action int8, amount decimal.Decimal, beforeBalance, afterBalance decimal.Decimal, recordType, tradeNo string) error {
	record := &model.BalanceRecord{
		MerchantID:    merchantID,
		Action:        action,
		Amount:        amount,
		BeforeBalance: beforeBalance,
		AfterBalance:  afterBalance,
		Type:          recordType,
		TradeNo:       tradeNo,
	}
	return tx.Create(record).Error
}

Step 3: 提交

git add internal/repository/
git commit -m "feat: add settlement and balance record repositories"

Task 5: 创建 Service 层 - 商户服务

Files:

  • Create: epay-go/internal/service/merchant.go

Step 1: 创建商户服务

// internal/service/merchant.go
package service

import (
	"errors"

	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
	"github.com/example/epay-go/pkg/utils"
	"github.com/shopspring/decimal"
	"gorm.io/gorm"
)

type MerchantService struct {
	repo *repository.MerchantRepository
}

func NewMerchantService() *MerchantService {
	return &MerchantService{
		repo: repository.NewMerchantRepository(),
	}
}

// RegisterRequest 注册请求
type RegisterRequest struct {
	Username string `json:"username" binding:"required,min=4,max=32"`
	Password string `json:"password" binding:"required,min=6,max=32"`
	Email    string `json:"email" binding:"omitempty,email"`
	Phone    string `json:"phone" binding:"omitempty"`
}

// Register 商户注册
func (s *MerchantService) Register(req *RegisterRequest) (*model.Merchant, error) {
	// 检查用户名是否存在
	exists, err := s.repo.ExistsByUsername(req.Username)
	if err != nil {
		return nil, err
	}
	if exists {
		return nil, errors.New("用户名已存在")
	}

	// 加密密码
	hashedPassword, err := utils.HashPassword(req.Password)
	if err != nil {
		return nil, err
	}

	// 创建商户
	merchant := &model.Merchant{
		Username: req.Username,
		Password: hashedPassword,
		Email:    req.Email,
		Phone:    req.Phone,
		ApiKey:   utils.GenerateAPIKey(),
		Balance:  decimal.Zero,
		Status:   1,
	}

	if err := s.repo.Create(merchant); err != nil {
		return nil, err
	}

	return merchant, nil
}

// LoginRequest 登录请求
type LoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

// Login 商户登录
func (s *MerchantService) Login(req *LoginRequest) (*model.Merchant, error) {
	merchant, err := s.repo.GetByUsername(req.Username)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, errors.New("用户名或密码错误")
		}
		return nil, err
	}

	if merchant.Status != 1 {
		return nil, errors.New("账号已被禁用")
	}

	if !utils.CheckPassword(req.Password, merchant.Password) {
		return nil, errors.New("用户名或密码错误")
	}

	return merchant, nil
}

// GetByID 根据ID获取商户
func (s *MerchantService) GetByID(id int64) (*model.Merchant, error) {
	return s.repo.GetByID(id)
}

// GetByAPIKey 根据API Key获取商户
func (s *MerchantService) GetByAPIKey(apiKey string) (*model.Merchant, error) {
	return s.repo.GetByAPIKey(apiKey)
}

// List 分页查询商户列表
func (s *MerchantService) List(page, pageSize int, status *int8) ([]model.Merchant, int64, error) {
	return s.repo.List(page, pageSize, status)
}

// UpdateStatus 更新商户状态
func (s *MerchantService) UpdateStatus(id int64, status int8) error {
	merchant, err := s.repo.GetByID(id)
	if err != nil {
		return err
	}
	merchant.Status = status
	return s.repo.Update(merchant)
}

// ResetAPIKey 重置API密钥
func (s *MerchantService) ResetAPIKey(id int64) (string, error) {
	merchant, err := s.repo.GetByID(id)
	if err != nil {
		return "", err
	}
	merchant.ApiKey = utils.GenerateAPIKey()
	if err := s.repo.Update(merchant); err != nil {
		return "", err
	}
	return merchant.ApiKey, nil
}

// UpdatePassword 更新密码
func (s *MerchantService) UpdatePassword(id int64, oldPassword, newPassword string) error {
	merchant, err := s.repo.GetByID(id)
	if err != nil {
		return err
	}

	if !utils.CheckPassword(oldPassword, merchant.Password) {
		return errors.New("原密码错误")
	}

	hashedPassword, err := utils.HashPassword(newPassword)
	if err != nil {
		return err
	}

	merchant.Password = hashedPassword
	return s.repo.Update(merchant)
}

Step 2: 提交

git add internal/service/merchant.go
git commit -m "feat: add merchant service with register, login, profile management"

Task 6: 创建 Service 层 - 管理员服务

Files:

  • Create: epay-go/internal/service/admin.go

Step 1: 创建管理员服务

// internal/service/admin.go
package service

import (
	"errors"

	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
	"github.com/example/epay-go/pkg/utils"
	"gorm.io/gorm"
)

type AdminService struct {
	repo *repository.AdminRepository
}

func NewAdminService() *AdminService {
	return &AdminService{
		repo: repository.NewAdminRepository(),
	}
}

// InitDefaultAdmin 初始化默认管理员(如果不存在)
func (s *AdminService) InitDefaultAdmin() error {
	count, err := s.repo.Count()
	if err != nil {
		return err
	}

	if count > 0 {
		return nil // 已有管理员,跳过
	}

	// 创建默认管理员
	hashedPassword, err := utils.HashPassword("admin123")
	if err != nil {
		return err
	}

	admin := &model.Admin{
		Username: "admin",
		Password: hashedPassword,
		Role:     "super",
	}

	return s.repo.Create(admin)
}

// AdminLoginRequest 管理员登录请求
type AdminLoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

// Login 管理员登录
func (s *AdminService) Login(req *AdminLoginRequest) (*model.Admin, error) {
	admin, err := s.repo.GetByUsername(req.Username)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, errors.New("用户名或密码错误")
		}
		return nil, err
	}

	if !utils.CheckPassword(req.Password, admin.Password) {
		return nil, errors.New("用户名或密码错误")
	}

	// 更新最后登录时间
	_ = s.repo.UpdateLastLogin(admin.ID)

	return admin, nil
}

// GetByID 根据ID获取管理员
func (s *AdminService) GetByID(id int64) (*model.Admin, error) {
	return s.repo.GetByID(id)
}

// UpdatePassword 更新密码
func (s *AdminService) UpdatePassword(id int64, oldPassword, newPassword string) error {
	admin, err := s.repo.GetByID(id)
	if err != nil {
		return err
	}

	if !utils.CheckPassword(oldPassword, admin.Password) {
		return errors.New("原密码错误")
	}

	hashedPassword, err := utils.HashPassword(newPassword)
	if err != nil {
		return err
	}

	admin.Password = hashedPassword
	return s.repo.Create(admin) // 使用 Create 更新会触发 Save
}

Step 2: 提交

git add internal/service/admin.go
git commit -m "feat: add admin service with login and password management"

Task 7: 创建 Service 层 - 通道服务

Files:

  • Create: epay-go/internal/service/channel.go

Step 1: 创建通道服务

// internal/service/channel.go
package service

import (
	"encoding/json"

	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
)

type ChannelService struct {
	repo *repository.ChannelRepository
}

func NewChannelService() *ChannelService {
	return &ChannelService{
		repo: repository.NewChannelRepository(),
	}
}

// CreateChannelRequest 创建通道请求
type CreateChannelRequest struct {
	Name       string                 `json:"name" binding:"required"`
	Plugin     string                 `json:"plugin" binding:"required"`
	PayTypes   string                 `json:"pay_types"`
	Config     map[string]interface{} `json:"config"`
	Rate       float64                `json:"rate"`
	DailyLimit float64                `json:"daily_limit"`
	Status     int8                   `json:"status"`
	Sort       int                    `json:"sort"`
}

// Create 创建通道
func (s *ChannelService) Create(req *CreateChannelRequest) (*model.Channel, error) {
	configJSON, err := json.Marshal(req.Config)
	if err != nil {
		return nil, err
	}

	channel := &model.Channel{
		Name:       req.Name,
		Plugin:     req.Plugin,
		PayTypes:   req.PayTypes,
		Config:     configJSON,
		Rate:       decimal.NewFromFloat(req.Rate),
		DailyLimit: decimal.NewFromFloat(req.DailyLimit),
		Status:     req.Status,
		Sort:       req.Sort,
	}

	if err := s.repo.Create(channel); err != nil {
		return nil, err
	}

	return channel, nil
}

// GetByID 根据ID获取通道
func (s *ChannelService) GetByID(id int64) (*model.Channel, error) {
	return s.repo.GetByID(id)
}

// UpdateChannelRequest 更新通道请求
type UpdateChannelRequest struct {
	Name       string                 `json:"name"`
	PayTypes   string                 `json:"pay_types"`
	Config     map[string]interface{} `json:"config"`
	Rate       float64                `json:"rate"`
	DailyLimit float64                `json:"daily_limit"`
	Status     int8                   `json:"status"`
	Sort       int                    `json:"sort"`
}

// Update 更新通道
func (s *ChannelService) Update(id int64, req *UpdateChannelRequest) error {
	channel, err := s.repo.GetByID(id)
	if err != nil {
		return err
	}

	if req.Name != "" {
		channel.Name = req.Name
	}
	if req.PayTypes != "" {
		channel.PayTypes = req.PayTypes
	}
	if req.Config != nil {
		configJSON, err := json.Marshal(req.Config)
		if err != nil {
			return err
		}
		channel.Config = configJSON
	}
	channel.Rate = decimal.NewFromFloat(req.Rate)
	channel.DailyLimit = decimal.NewFromFloat(req.DailyLimit)
	channel.Status = req.Status
	channel.Sort = req.Sort

	return s.repo.Update(channel)
}

// Delete 删除通道
func (s *ChannelService) Delete(id int64) error {
	return s.repo.Delete(id)
}

// List 分页查询通道列表
func (s *ChannelService) List(page, pageSize int) ([]model.Channel, int64, error) {
	return s.repo.List(page, pageSize)
}

// ListEnabled 获取所有启用的通道
func (s *ChannelService) ListEnabled() ([]model.Channel, error) {
	return s.repo.ListEnabled()
}

// GetAvailableChannel 根据支付类型获取可用通道
func (s *ChannelService) GetAvailableChannel(payType string) (*model.Channel, error) {
	return s.repo.GetAvailableByPayType(payType)
}

需要在文件顶部添加 decimal 导入:

import (
	"encoding/json"

	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
	"github.com/shopspring/decimal"
)

Step 2: 提交

git add internal/service/channel.go
git commit -m "feat: add channel service"

Task 8: 创建支付适配器接口

Files:

  • Create: epay-go/internal/payment/adapter.go
  • Create: epay-go/internal/payment/factory.go

Step 1: 创建支付适配器接口

// internal/payment/adapter.go
package payment

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/shopspring/decimal"
)

// PaymentAdapter 统一支付适配器接口
type PaymentAdapter interface {
	// CreateOrder 创建支付订单,返回支付参数
	CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error)

	// QueryOrder 查询订单状态
	QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error)

	// Refund 退款
	Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error)

	// ParseNotify 解析异步回调通知
	ParseNotify(ctx context.Context, r *http.Request) (*NotifyResult, error)

	// NotifySuccess 返回回调成功响应
	NotifySuccess() string
}

// CreateOrderRequest 统一下单请求
type CreateOrderRequest struct {
	TradeNo   string            `json:"trade_no"`   // 系统订单号
	Amount    decimal.Decimal   `json:"amount"`     // 金额(元)
	Subject   string            `json:"subject"`    // 商品名称
	ClientIP  string            `json:"client_ip"`  // 客户端IP
	NotifyURL string            `json:"notify_url"` // 异步通知地址
	ReturnURL string            `json:"return_url"` // 同步跳转地址
	PayMethod string            `json:"pay_method"` // 支付方式: scan/h5/jsapi/app/web
	Extra     map[string]string `json:"extra"`      // 扩展参数
}

// CreateOrderResponse 统一下单响应
type CreateOrderResponse struct {
	PayType   string `json:"pay_type"`   // redirect(跳转) / qrcode(二维码) / jsapi(JS调起)
	PayURL    string `json:"pay_url"`    // 支付链接或二维码内容
	PayParams string `json:"pay_params"` // JSAPI 支付参数(JSON)
}

// QueryOrderResponse 查询订单响应
type QueryOrderResponse struct {
	TradeNo    string          `json:"trade_no"`
	ApiTradeNo string          `json:"api_trade_no"`
	Amount     decimal.Decimal `json:"amount"`
	Status     string          `json:"status"` // pending/paid/refunded/closed
	PaidAt     string          `json:"paid_at"`
}

// RefundRequest 退款请求
type RefundRequest struct {
	TradeNo    string          `json:"trade_no"`
	RefundNo   string          `json:"refund_no"`
	Amount     decimal.Decimal `json:"amount"`
	RefundDesc string          `json:"refund_desc"`
}

// RefundResponse 退款响应
type RefundResponse struct {
	RefundNo     string `json:"refund_no"`
	ApiRefundNo  string `json:"api_refund_no"`
	Status       string `json:"status"` // success/processing/failed
	ErrorMessage string `json:"error_message,omitempty"`
}

// NotifyResult 统一回调结果
type NotifyResult struct {
	TradeNo    string          `json:"trade_no"`     // 系统订单号
	ApiTradeNo string          `json:"api_trade_no"` // 上游订单号
	Amount     decimal.Decimal `json:"amount"`       // 支付金额
	Buyer      string          `json:"buyer"`        // 买家标识
	Status     string          `json:"status"`       // success / fail
}

// ChannelConfig 通道配置解析
type ChannelConfig struct {
	Raw json.RawMessage
}

func (c *ChannelConfig) Unmarshal(v interface{}) error {
	return json.Unmarshal(c.Raw, v)
}

Step 2: 创建适配器工厂

// internal/payment/factory.go
package payment

import (
	"encoding/json"
	"fmt"
)

// AdapterFactory 适配器工厂函数类型
type AdapterFactory func(config json.RawMessage) (PaymentAdapter, error)

// 注册的适配器工厂
var adapters = make(map[string]AdapterFactory)

// Register 注册适配器
func Register(plugin string, factory AdapterFactory) {
	adapters[plugin] = factory
}

// NewAdapter 创建适配器实例
func NewAdapter(plugin string, config json.RawMessage) (PaymentAdapter, error) {
	factory, ok := adapters[plugin]
	if !ok {
		return nil, fmt.Errorf("unsupported payment plugin: %s", plugin)
	}
	return factory(config)
}

// GetSupportedPlugins 获取支持的插件列表
func GetSupportedPlugins() []string {
	plugins := make([]string, 0, len(adapters))
	for plugin := range adapters {
		plugins = append(plugins, plugin)
	}
	return plugins
}

Step 3: 提交

git add internal/payment/
git commit -m "feat: add payment adapter interface and factory"

Task 9: 创建支付宝适配器

Files:

  • Create: epay-go/internal/payment/alipay.go

Step 1: 创建支付宝适配器

// internal/payment/alipay.go
package payment

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"

	"github.com/go-pay/gopay"
	"github.com/go-pay/gopay/alipay"
	"github.com/shopspring/decimal"
)

// AlipayConfig 支付宝配置
type AlipayConfig struct {
	AppID        string `json:"app_id"`
	PrivateKey   string `json:"private_key"`
	PublicKey    string `json:"public_key"`       // 支付宝公钥
	AppPublicKey string `json:"app_public_key"`   // 应用公钥证书(可选)
	IsProd       bool   `json:"is_prod"`          // 是否生产环境
	SignType     string `json:"sign_type"`        // RSA2
}

// AlipayAdapter 支付宝适配器
type AlipayAdapter struct {
	client *alipay.Client
	config *AlipayConfig
}

// NewAlipayAdapter 创建支付宝适配器
func NewAlipayAdapter(configJSON json.RawMessage) (PaymentAdapter, error) {
	var cfg AlipayConfig
	if err := json.Unmarshal(configJSON, &cfg); err != nil {
		return nil, err
	}

	client, err := alipay.NewClient(cfg.AppID, cfg.PrivateKey, cfg.IsProd)
	if err != nil {
		return nil, err
	}

	// 设置支付宝公钥
	if err := client.SetCertSnByContent(nil, nil, []byte(cfg.PublicKey)); err != nil {
		// 如果证书方式失败,尝试公钥方式
		client.SetAliPayPublicKey(cfg.PublicKey)
	}

	return &AlipayAdapter{
		client: client,
		config: &cfg,
	}, nil
}

// CreateOrder 创建支付订单
func (a *AlipayAdapter) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error) {
	amount := req.Amount.StringFixed(2)

	bm := make(gopay.BodyMap)
	bm.Set("out_trade_no", req.TradeNo)
	bm.Set("total_amount", amount)
	bm.Set("subject", req.Subject)
	bm.Set("notify_url", req.NotifyURL)

	switch req.PayMethod {
	case "scan", "qrcode":
		// 扫码支付
		bm.Set("product_code", "FACE_TO_FACE_PAYMENT")
		resp, err := a.client.TradePrecreate(ctx, bm)
		if err != nil {
			return nil, err
		}
		if resp.Response.Code != "10000" {
			return nil, errors.New(resp.Response.SubMsg)
		}
		return &CreateOrderResponse{
			PayType: "qrcode",
			PayURL:  resp.Response.QrCode,
		}, nil

	case "h5", "wap":
		// H5支付
		bm.Set("product_code", "QUICK_WAP_WAY")
		bm.Set("return_url", req.ReturnURL)
		payURL, err := a.client.TradeWapPay(ctx, bm)
		if err != nil {
			return nil, err
		}
		return &CreateOrderResponse{
			PayType: "redirect",
			PayURL:  payURL,
		}, nil

	case "web", "pc":
		// PC网页支付
		bm.Set("product_code", "FAST_INSTANT_TRADE_PAY")
		bm.Set("return_url", req.ReturnURL)
		payURL, err := a.client.TradePagePay(ctx, bm)
		if err != nil {
			return nil, err
		}
		return &CreateOrderResponse{
			PayType: "redirect",
			PayURL:  payURL,
		}, nil

	default:
		return nil, errors.New("unsupported pay method: " + req.PayMethod)
	}
}

// QueryOrder 查询订单
func (a *AlipayAdapter) QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error) {
	bm := make(gopay.BodyMap)
	bm.Set("out_trade_no", tradeNo)

	resp, err := a.client.TradeQuery(ctx, bm)
	if err != nil {
		return nil, err
	}

	if resp.Response.Code != "10000" {
		return nil, errors.New(resp.Response.SubMsg)
	}

	status := "pending"
	switch resp.Response.TradeStatus {
	case "TRADE_SUCCESS", "TRADE_FINISHED":
		status = "paid"
	case "TRADE_CLOSED":
		status = "closed"
	}

	amount, _ := decimal.NewFromString(resp.Response.TotalAmount)

	return &QueryOrderResponse{
		TradeNo:    tradeNo,
		ApiTradeNo: resp.Response.TradeNo,
		Amount:     amount,
		Status:     status,
		PaidAt:     resp.Response.SendPayDate,
	}, nil
}

// Refund 退款
func (a *AlipayAdapter) Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error) {
	bm := make(gopay.BodyMap)
	bm.Set("out_trade_no", req.TradeNo)
	bm.Set("out_request_no", req.RefundNo)
	bm.Set("refund_amount", req.Amount.StringFixed(2))
	if req.RefundDesc != "" {
		bm.Set("refund_reason", req.RefundDesc)
	}

	resp, err := a.client.TradeRefund(ctx, bm)
	if err != nil {
		return nil, err
	}

	if resp.Response.Code != "10000" {
		return &RefundResponse{
			RefundNo:     req.RefundNo,
			Status:       "failed",
			ErrorMessage: resp.Response.SubMsg,
		}, nil
	}

	return &RefundResponse{
		RefundNo:    req.RefundNo,
		ApiRefundNo: resp.Response.TradeNo,
		Status:      "success",
	}, nil
}

// ParseNotify 解析回调通知
func (a *AlipayAdapter) ParseNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
	notifyReq, err := alipay.ParseNotifyToBodyMap(r)
	if err != nil {
		return nil, err
	}

	// 验签
	ok, err := alipay.VerifySign(a.config.PublicKey, notifyReq)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, errors.New("签名验证失败")
	}

	tradeStatus := notifyReq.Get("trade_status")
	status := "fail"
	if tradeStatus == "TRADE_SUCCESS" || tradeStatus == "TRADE_FINISHED" {
		status = "success"
	}

	amount, _ := decimal.NewFromString(notifyReq.Get("total_amount"))

	return &NotifyResult{
		TradeNo:    notifyReq.Get("out_trade_no"),
		ApiTradeNo: notifyReq.Get("trade_no"),
		Amount:     amount,
		Buyer:      notifyReq.Get("buyer_id"),
		Status:     status,
	}, nil
}

// NotifySuccess 返回成功响应
func (a *AlipayAdapter) NotifySuccess() string {
	return "success"
}

func init() {
	Register("alipay", NewAlipayAdapter)
}

Step 2: 提交

git add internal/payment/alipay.go
git commit -m "feat: add alipay payment adapter with gopay"

Task 10: 创建微信支付适配器

Files:

  • Create: epay-go/internal/payment/wechat.go

Step 1: 创建微信支付适配器

// internal/payment/wechat.go
package payment

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"

	"github.com/go-pay/gopay"
	"github.com/go-pay/gopay/wechat/v3"
	"github.com/shopspring/decimal"
)

// WechatConfig 微信支付配置
type WechatConfig struct {
	MchID               string `json:"mch_id"`                 // 商户号
	AppID               string `json:"app_id"`                 // 应用ID
	APIv3Key            string `json:"api_v3_key"`             // APIv3密钥
	SerialNo            string `json:"serial_no"`              // 证书序列号
	PrivateKey          string `json:"private_key"`            // 私钥内容
	PlatformSerialNo    string `json:"platform_serial_no"`     // 平台证书序列号
	PlatformCertContent string `json:"platform_cert_content"`  // 平台证书内容
}

// WechatAdapter 微信支付适配器
type WechatAdapter struct {
	client *wechat.ClientV3
	config *WechatConfig
}

// NewWechatAdapter 创建微信支付适配器
func NewWechatAdapter(configJSON json.RawMessage) (PaymentAdapter, error) {
	var cfg WechatConfig
	if err := json.Unmarshal(configJSON, &cfg); err != nil {
		return nil, err
	}

	client, err := wechat.NewClientV3(cfg.MchID, cfg.SerialNo, cfg.APIv3Key, cfg.PrivateKey)
	if err != nil {
		return nil, err
	}

	// 设置平台证书
	if cfg.PlatformCertContent != "" {
		client.SetPlatformCert([]byte(cfg.PlatformCertContent), cfg.PlatformSerialNo)
	}

	return &WechatAdapter{
		client: client,
		config: &cfg,
	}, nil
}

// CreateOrder 创建支付订单
func (w *WechatAdapter) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error) {
	// 金额转为分
	amountFen := req.Amount.Mul(decimal.NewFromInt(100)).IntPart()

	bm := make(gopay.BodyMap)
	bm.Set("appid", w.config.AppID)
	bm.Set("mchid", w.config.MchID)
	bm.Set("description", req.Subject)
	bm.Set("out_trade_no", req.TradeNo)
	bm.Set("notify_url", req.NotifyURL)
	bm.SetBodyMap("amount", func(b gopay.BodyMap) {
		b.Set("total", amountFen)
		b.Set("currency", "CNY")
	})

	switch req.PayMethod {
	case "scan", "qrcode":
		// Native 扫码支付
		resp, err := w.client.V3TransactionNative(ctx, bm)
		if err != nil {
			return nil, err
		}
		if resp.Code != wechat.Success {
			return nil, errors.New(resp.Error)
		}
		return &CreateOrderResponse{
			PayType: "qrcode",
			PayURL:  resp.Response.CodeUrl,
		}, nil

	case "h5", "wap":
		// H5 支付
		bm.SetBodyMap("scene_info", func(b gopay.BodyMap) {
			b.Set("payer_client_ip", req.ClientIP)
			b.SetBodyMap("h5_info", func(h gopay.BodyMap) {
				h.Set("type", "Wap")
			})
		})
		resp, err := w.client.V3TransactionH5(ctx, bm)
		if err != nil {
			return nil, err
		}
		if resp.Code != wechat.Success {
			return nil, errors.New(resp.Error)
		}
		// 拼接 redirect_url
		payURL := resp.Response.H5Url
		if req.ReturnURL != "" {
			payURL += "&redirect_url=" + req.ReturnURL
		}
		return &CreateOrderResponse{
			PayType: "redirect",
			PayURL:  payURL,
		}, nil

	case "jsapi":
		// JSAPI 支付(需要 openid)
		openid := req.Extra["openid"]
		if openid == "" {
			return nil, errors.New("jsapi pay requires openid")
		}
		bm.SetBodyMap("payer", func(b gopay.BodyMap) {
			b.Set("openid", openid)
		})
		resp, err := w.client.V3TransactionJsapi(ctx, bm)
		if err != nil {
			return nil, err
		}
		if resp.Code != wechat.Success {
			return nil, errors.New(resp.Error)
		}
		// 生成 JSAPI 调起参数
		jsapiParams, err := w.client.PaySignOfJSAPI(w.config.AppID, resp.Response.PrepayId)
		if err != nil {
			return nil, err
		}
		paramsJSON, _ := json.Marshal(jsapiParams)
		return &CreateOrderResponse{
			PayType:   "jsapi",
			PayParams: string(paramsJSON),
		}, nil

	default:
		return nil, errors.New("unsupported pay method: " + req.PayMethod)
	}
}

// QueryOrder 查询订单
func (w *WechatAdapter) QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error) {
	resp, err := w.client.V3TransactionQueryOrder(ctx, wechat.OutTradeNo, tradeNo)
	if err != nil {
		return nil, err
	}
	if resp.Code != wechat.Success {
		return nil, errors.New(resp.Error)
	}

	status := "pending"
	switch resp.Response.TradeState {
	case "SUCCESS":
		status = "paid"
	case "CLOSED", "REVOKED", "PAYERROR":
		status = "closed"
	case "REFUND":
		status = "refunded"
	}

	// 金额从分转为元
	amount := decimal.NewFromInt(int64(resp.Response.Amount.Total)).Div(decimal.NewFromInt(100))

	return &QueryOrderResponse{
		TradeNo:    tradeNo,
		ApiTradeNo: resp.Response.TransactionId,
		Amount:     amount,
		Status:     status,
		PaidAt:     resp.Response.SuccessTime,
	}, nil
}

// Refund 退款
func (w *WechatAdapter) Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error) {
	// 金额转为分
	amountFen := req.Amount.Mul(decimal.NewFromInt(100)).IntPart()

	bm := make(gopay.BodyMap)
	bm.Set("out_trade_no", req.TradeNo)
	bm.Set("out_refund_no", req.RefundNo)
	bm.Set("reason", req.RefundDesc)
	bm.SetBodyMap("amount", func(b gopay.BodyMap) {
		b.Set("refund", amountFen)
		b.Set("total", amountFen) // 简化处理,实际应查询原订单金额
		b.Set("currency", "CNY")
	})

	resp, err := w.client.V3Refund(ctx, bm)
	if err != nil {
		return nil, err
	}

	if resp.Code != wechat.Success {
		return &RefundResponse{
			RefundNo:     req.RefundNo,
			Status:       "failed",
			ErrorMessage: resp.Error,
		}, nil
	}

	status := "processing"
	if resp.Response.Status == "SUCCESS" {
		status = "success"
	}

	return &RefundResponse{
		RefundNo:    req.RefundNo,
		ApiRefundNo: resp.Response.RefundId,
		Status:      status,
	}, nil
}

// ParseNotify 解析回调通知
func (w *WechatAdapter) ParseNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
	notifyReq, err := wechat.V3ParseNotify(r)
	if err != nil {
		return nil, err
	}

	// 解密回调内容
	result, err := notifyReq.DecryptCipherText(w.config.APIv3Key)
	if err != nil {
		return nil, err
	}

	status := "fail"
	if result.TradeState == "SUCCESS" {
		status = "success"
	}

	// 金额从分转为元
	amount := decimal.NewFromInt(int64(result.Amount.Total)).Div(decimal.NewFromInt(100))

	return &NotifyResult{
		TradeNo:    result.OutTradeNo,
		ApiTradeNo: result.TransactionId,
		Amount:     amount,
		Buyer:      result.Payer.Openid,
		Status:     status,
	}, nil
}

// NotifySuccess 返回成功响应
func (w *WechatAdapter) NotifySuccess() string {
	return `{"code":"SUCCESS","message":"成功"}`
}

func init() {
	Register("wechat", NewWechatAdapter)
}

Step 2: 提交

git add internal/payment/wechat.go
git commit -m "feat: add wechat payment adapter with gopay v3"

Task 11: 创建订单服务

Files:

  • Create: epay-go/internal/service/order.go

Step 1: 创建订单服务

// internal/service/order.go
package service

import (
	"context"
	"errors"

	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/payment"
	"github.com/example/epay-go/internal/repository"
	"github.com/example/epay-go/pkg/utils"
	"github.com/shopspring/decimal"
)

type OrderService struct {
	orderRepo    *repository.OrderRepository
	channelRepo  *repository.ChannelRepository
	merchantRepo *repository.MerchantRepository
	recordRepo   *repository.BalanceRecordRepository
}

func NewOrderService() *OrderService {
	return &OrderService{
		orderRepo:    repository.NewOrderRepository(),
		channelRepo:  repository.NewChannelRepository(),
		merchantRepo: repository.NewMerchantRepository(),
		recordRepo:   repository.NewBalanceRecordRepository(),
	}
}

// CreateOrderRequest 创建订单请求
type CreateOrderRequest struct {
	MerchantID  int64           `json:"-"`
	OutTradeNo  string          `json:"out_trade_no" binding:"required"`
	Amount      decimal.Decimal `json:"money" binding:"required"`
	Name        string          `json:"name" binding:"required"`
	PayType     string          `json:"type" binding:"required"` // alipay, wxpay
	NotifyURL   string          `json:"notify_url" binding:"required,url"`
	ReturnURL   string          `json:"return_url" binding:"omitempty,url"`
	ClientIP    string          `json:"-"`
	PayMethod   string          `json:"pay_method"` // scan, h5, jsapi, web
	Extra       map[string]string `json:"extra"`
}

// CreateOrderResponse 创建订单响应
type CreateOrderResponse struct {
	TradeNo   string `json:"trade_no"`
	PayType   string `json:"pay_type"`
	PayURL    string `json:"pay_url,omitempty"`
	PayParams string `json:"pay_params,omitempty"`
}

// Create 创建订单
func (s *OrderService) Create(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error) {
	// 检查商户订单号是否重复
	existOrder, _ := s.orderRepo.GetByOutTradeNo(req.MerchantID, req.OutTradeNo)
	if existOrder != nil {
		return nil, errors.New("商户订单号已存在")
	}

	// 获取可用通道
	channel, err := s.channelRepo.GetAvailableByPayType(req.PayType)
	if err != nil {
		return nil, errors.New("暂无可用的支付通道")
	}

	// 创建支付适配器
	adapter, err := payment.NewAdapter(channel.Plugin, channel.Config)
	if err != nil {
		return nil, errors.New("支付通道配置错误")
	}

	// 生成订单号
	tradeNo := utils.GenerateTradeNo()

	// 计算手续费
	fee := req.Amount.Mul(channel.Rate).Round(2)
	realAmount := req.Amount

	// 创建订单记录
	order := &model.Order{
		TradeNo:      tradeNo,
		OutTradeNo:   req.OutTradeNo,
		MerchantID:   req.MerchantID,
		ChannelID:    channel.ID,
		PayType:      req.PayType,
		Amount:       req.Amount,
		RealAmount:   realAmount,
		Fee:          fee,
		Name:         req.Name,
		NotifyURL:    req.NotifyURL,
		ReturnURL:    req.ReturnURL,
		ClientIP:     req.ClientIP,
		Status:       model.OrderStatusUnpaid,
		NotifyStatus: model.NotifyStatusPending,
	}

	if err := s.orderRepo.Create(order); err != nil {
		return nil, err
	}

	// 调用支付接口
	payMethod := req.PayMethod
	if payMethod == "" {
		payMethod = "scan" // 默认扫码
	}

	payReq := &payment.CreateOrderRequest{
		TradeNo:   tradeNo,
		Amount:    realAmount,
		Subject:   req.Name,
		ClientIP:  req.ClientIP,
		NotifyURL: req.NotifyURL,
		ReturnURL: req.ReturnURL,
		PayMethod: payMethod,
		Extra:     req.Extra,
	}

	payResp, err := adapter.CreateOrder(ctx, payReq)
	if err != nil {
		return nil, err
	}

	return &CreateOrderResponse{
		TradeNo:   tradeNo,
		PayType:   payResp.PayType,
		PayURL:    payResp.PayURL,
		PayParams: payResp.PayParams,
	}, nil
}

// GetByTradeNo 根据订单号获取订单
func (s *OrderService) GetByTradeNo(tradeNo string) (*model.Order, error) {
	return s.orderRepo.GetByTradeNo(tradeNo)
}

// List 分页查询订单
func (s *OrderService) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Order, int64, error) {
	return s.orderRepo.List(page, pageSize, merchantID, status)
}

// ProcessPayNotify 处理支付回调
func (s *OrderService) ProcessPayNotify(tradeNo, apiTradeNo, buyer string, amount decimal.Decimal) error {
	order, err := s.orderRepo.GetByTradeNo(tradeNo)
	if err != nil {
		return errors.New("订单不存在")
	}

	if order.Status != model.OrderStatusUnpaid {
		return nil // 订单已处理,跳过
	}

	// 验证金额
	if !order.Amount.Equal(amount) {
		return errors.New("支付金额不匹配")
	}

	// 开启事务
	tx := database.Get().Begin()
	defer func() {
		if r := recover(); r != nil {
			tx.Rollback()
		}
	}()

	// 更新订单状态
	if err := s.orderRepo.UpdatePayInfo(tradeNo, apiTradeNo, buyer); err != nil {
		tx.Rollback()
		return err
	}

	// 获取商户信息
	merchant, err := s.merchantRepo.GetByID(order.MerchantID)
	if err != nil {
		tx.Rollback()
		return err
	}

	// 计算商户收入(订单金额 - 手续费)
	income := order.Amount.Sub(order.Fee)
	newBalance := merchant.Balance.Add(income)

	// 更新商户余额
	if err := s.merchantRepo.UpdateBalance(tx, merchant.ID, income.InexactFloat64()); err != nil {
		tx.Rollback()
		return err
	}

	// 添加资金记录
	if err := repository.AddBalanceRecord(tx, merchant.ID, model.RecordActionIncome, income, merchant.Balance, newBalance, "order_income", tradeNo); err != nil {
		tx.Rollback()
		return err
	}

	tx.Commit()
	return nil
}

// GetTodayStats 获取今日统计
func (s *OrderService) GetTodayStats(merchantID *int64) (int64, decimal.Decimal, error) {
	return s.orderRepo.GetTodayStats(merchantID)
}

Step 2: 提交

git add internal/service/order.go
git commit -m "feat: add order service with create, query, notify processing"

Task 12: 创建结算服务

Files:

  • Create: epay-go/internal/service/settlement.go

Step 1: 创建结算服务

// internal/service/settlement.go
package service

import (
	"errors"

	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
	"github.com/example/epay-go/pkg/utils"
	"github.com/shopspring/decimal"
)

type SettlementService struct {
	settleRepo   *repository.SettlementRepository
	merchantRepo *repository.MerchantRepository
}

func NewSettlementService() *SettlementService {
	return &SettlementService{
		settleRepo:   repository.NewSettlementRepository(),
		merchantRepo: repository.NewMerchantRepository(),
	}
}

// ApplyRequest 申请结算请求
type ApplyRequest struct {
	MerchantID  int64           `json:"-"`
	Amount      decimal.Decimal `json:"amount" binding:"required"`
	AccountType string          `json:"account_type" binding:"required,oneof=alipay bank"`
	AccountNo   string          `json:"account_no" binding:"required"`
	AccountName string          `json:"account_name" binding:"required"`
}

// Apply 申请结算
func (s *SettlementService) Apply(req *ApplyRequest) (*model.Settlement, error) {
	// 检查是否有待处理的结算
	hasPending, err := s.settleRepo.HasPendingSettlement(req.MerchantID)
	if err != nil {
		return nil, err
	}
	if hasPending {
		return nil, errors.New("您有待处理的结算申请,请等待处理完成")
	}

	// 获取商户信息
	merchant, err := s.merchantRepo.GetByID(req.MerchantID)
	if err != nil {
		return nil, err
	}

	// 检查余额是否充足
	if merchant.Balance.LessThan(req.Amount) {
		return nil, errors.New("余额不足")
	}

	// 最小结算金额检查(假设最小10元)
	minAmount := decimal.NewFromInt(10)
	if req.Amount.LessThan(minAmount) {
		return nil, errors.New("最小结算金额为10元")
	}

	// 计算手续费(假设2%)
	feeRate := decimal.NewFromFloat(0.02)
	fee := req.Amount.Mul(feeRate).Round(2)
	actualAmount := req.Amount.Sub(fee)

	// 创建结算记录
	settlement := &model.Settlement{
		SettleNo:     utils.GenerateSettleNo(),
		MerchantID:   req.MerchantID,
		Amount:       req.Amount,
		Fee:          fee,
		ActualAmount: actualAmount,
		AccountType:  req.AccountType,
		AccountNo:    req.AccountNo,
		AccountName:  req.AccountName,
		Status:       model.SettleStatusPending,
	}

	// 开启事务
	tx := database.Get().Begin()
	defer func() {
		if r := recover(); r != nil {
			tx.Rollback()
		}
	}()

	// 冻结余额
	newBalance := merchant.Balance.Sub(req.Amount)
	newFrozen := merchant.FrozenBalance.Add(req.Amount)

	if err := tx.Model(&model.Merchant{}).Where("id = ?", req.MerchantID).Updates(map[string]interface{}{
		"balance":        newBalance,
		"frozen_balance": newFrozen,
	}).Error; err != nil {
		tx.Rollback()
		return nil, err
	}

	// 添加资金记录
	if err := repository.AddBalanceRecord(tx, req.MerchantID, model.RecordActionExpense, req.Amount, merchant.Balance, newBalance, "settle_freeze", settlement.SettleNo); err != nil {
		tx.Rollback()
		return nil, err
	}

	// 创建结算记录
	if err := tx.Create(settlement).Error; err != nil {
		tx.Rollback()
		return nil, err
	}

	tx.Commit()
	return settlement, nil
}

// Approve 审核通过
func (s *SettlementService) Approve(id int64) error {
	settlement, err := s.settleRepo.GetByID(id)
	if err != nil {
		return err
	}

	if settlement.Status != model.SettleStatusPending {
		return errors.New("结算状态不正确")
	}

	// 更新状态为处理中
	return s.settleRepo.UpdateStatus(id, model.SettleStatusProcessing, "审核通过,处理中")
}

// Complete 完成结算
func (s *SettlementService) Complete(id int64) error {
	settlement, err := s.settleRepo.GetByID(id)
	if err != nil {
		return err
	}

	if settlement.Status != model.SettleStatusProcessing {
		return errors.New("结算状态不正确")
	}

	// 开启事务
	tx := database.Get().Begin()
	defer func() {
		if r := recover(); r != nil {
			tx.Rollback()
		}
	}()

	// 扣除冻结余额
	if err := tx.Model(&model.Merchant{}).Where("id = ?", settlement.MerchantID).
		Update("frozen_balance", gorm.Expr("frozen_balance - ?", settlement.Amount)).Error; err != nil {
		tx.Rollback()
		return err
	}

	// 更新结算状态
	if err := s.settleRepo.UpdateStatus(id, model.SettleStatusCompleted, "结算完成"); err != nil {
		tx.Rollback()
		return err
	}

	tx.Commit()
	return nil
}

// Reject 驳回结算
func (s *SettlementService) Reject(id int64, remark string) error {
	settlement, err := s.settleRepo.GetByID(id)
	if err != nil {
		return err
	}

	if settlement.Status != model.SettleStatusPending {
		return errors.New("结算状态不正确")
	}

	// 开启事务
	tx := database.Get().Begin()
	defer func() {
		if r := recover(); r != nil {
			tx.Rollback()
		}
	}()

	// 获取商户
	merchant, err := s.merchantRepo.GetByID(settlement.MerchantID)
	if err != nil {
		tx.Rollback()
		return err
	}

	// 解冻余额
	newBalance := merchant.Balance.Add(settlement.Amount)
	newFrozen := merchant.FrozenBalance.Sub(settlement.Amount)

	if err := tx.Model(&model.Merchant{}).Where("id = ?", settlement.MerchantID).Updates(map[string]interface{}{
		"balance":        newBalance,
		"frozen_balance": newFrozen,
	}).Error; err != nil {
		tx.Rollback()
		return err
	}

	// 添加资金记录
	if err := repository.AddBalanceRecord(tx, settlement.MerchantID, model.RecordActionIncome, settlement.Amount, merchant.Balance, newBalance, "settle_unfreeze", settlement.SettleNo); err != nil {
		tx.Rollback()
		return err
	}

	// 更新结算状态
	if err := s.settleRepo.UpdateStatus(id, model.SettleStatusRejected, remark); err != nil {
		tx.Rollback()
		return err
	}

	tx.Commit()
	return nil
}

// List 分页查询结算列表
func (s *SettlementService) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Settlement, int64, error) {
	return s.settleRepo.List(page, pageSize, merchantID, status)
}

// GetByID 根据ID获取结算记录
func (s *SettlementService) GetByID(id int64) (*model.Settlement, error) {
	return s.settleRepo.GetByID(id)
}

需要在顶部添加 gorm 导入:

import (
	"errors"

	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
	"github.com/example/epay-go/pkg/utils"
	"github.com/shopspring/decimal"
	"gorm.io/gorm"
)

Step 2: 提交

git add internal/service/settlement.go
git commit -m "feat: add settlement service with apply, approve, reject, complete"

Task 13: 创建异步通知服务

Files:

  • Create: epay-go/internal/service/notify.go

Step 1: 创建通知服务

// internal/service/notify.go
package service

import (
	"context"
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"time"

	"github.com/example/epay-go/internal/model"
	"github.com/example/epay-go/internal/repository"
)

type NotifyService struct {
	orderRepo    *repository.OrderRepository
	merchantRepo *repository.MerchantRepository
	httpClient   *http.Client
}

func NewNotifyService() *NotifyService {
	return &NotifyService{
		orderRepo:    repository.NewOrderRepository(),
		merchantRepo: repository.NewMerchantRepository(),
		httpClient: &http.Client{
			Timeout: 10 * time.Second,
		},
	}
}

// NotifyRetryIntervals 通知重试间隔
var NotifyRetryIntervals = []time.Duration{
	0,                // 立即
	1 * time.Minute,  // 1分钟后
	3 * time.Minute,  // 3分钟后
	20 * time.Minute, // 20分钟后
	1 * time.Hour,    // 1小时后
	2 * time.Hour,    // 2小时后
}

// SendNotify 发送回调通知
func (s *NotifyService) SendNotify(order *model.Order) error {
	// 获取商户信息
	merchant, err := s.merchantRepo.GetByID(order.MerchantID)
	if err != nil {
		return err
	}

	// 构建通知参数
	params := s.buildNotifyParams(order, merchant)

	// 发送请求
	success := s.doNotify(order.NotifyURL, params)

	// 更新通知状态
	if success {
		return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusSuccess, nil)
	}

	// 通知失败,计算下次重试时间
	nextCount := order.NotifyCount + 1
	if nextCount >= len(NotifyRetryIntervals) {
		// 重试次数用尽
		return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusFailed, nil)
	}

	nextTime := time.Now().Add(NotifyRetryIntervals[nextCount])
	return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusSending, &nextTime)
}

// buildNotifyParams 构建通知参数
func (s *NotifyService) buildNotifyParams(order *model.Order, merchant *model.Merchant) url.Values {
	params := url.Values{}
	params.Set("pid", fmt.Sprintf("%d", order.MerchantID))
	params.Set("trade_no", order.TradeNo)
	params.Set("out_trade_no", order.OutTradeNo)
	params.Set("type", order.PayType)
	params.Set("name", order.Name)
	params.Set("money", order.Amount.String())
	params.Set("trade_status", "TRADE_SUCCESS")

	if order.ApiTradeNo != "" {
		params.Set("api_trade_no", order.ApiTradeNo)
	}
	if order.Buyer != "" {
		params.Set("buyer", order.Buyer)
	}

	// 生成签名
	sign := s.generateSign(params, merchant.ApiKey)
	params.Set("sign", sign)
	params.Set("sign_type", "MD5")

	return params
}

// generateSign 生成MD5签名(与原epay兼容)
func (s *NotifyService) generateSign(params url.Values, key string) string {
	// 按key排序
	var keys []string
	for k := range params {
		if k != "sign" && k != "sign_type" && params.Get(k) != "" {
			keys = append(keys, k)
		}
	}
	sort.Strings(keys)

	// 拼接字符串
	var buf strings.Builder
	for i, k := range keys {
		if i > 0 {
			buf.WriteString("&")
		}
		buf.WriteString(k)
		buf.WriteString("=")
		buf.WriteString(params.Get(k))
	}
	buf.WriteString(key)

	// MD5
	hash := md5.Sum([]byte(buf.String()))
	return hex.EncodeToString(hash[:])
}

// doNotify 执行通知请求
func (s *NotifyService) doNotify(notifyURL string, params url.Values) bool {
	// 构建完整URL
	fullURL := notifyURL
	if strings.Contains(notifyURL, "?") {
		fullURL += "&" + params.Encode()
	} else {
		fullURL += "?" + params.Encode()
	}

	resp, err := s.httpClient.Get(fullURL)
	if err != nil {
		log.Printf("Notify request failed: %v", err)
		return false
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	response := strings.ToLower(strings.TrimSpace(string(body)))

	// 检查响应是否为 success
	return response == "success"
}

// StartNotifyWorker 启动通知工作协程
func (s *NotifyService) StartNotifyWorker(ctx context.Context) {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			log.Println("Notify worker stopped")
			return
		case <-ticker.C:
			s.processNotifyQueue()
		}
	}
}

// processNotifyQueue 处理通知队列
func (s *NotifyService) processNotifyQueue() {
	orders, err := s.orderRepo.GetPendingNotifyOrders(50)
	if err != nil {
		log.Printf("Get pending notify orders failed: %v", err)
		return
	}

	for _, order := range orders {
		if err := s.SendNotify(&order); err != nil {
			log.Printf("Send notify failed for %s: %v", order.TradeNo, err)
		}
	}
}

Step 2: 提交

git add internal/service/notify.go
git commit -m "feat: add async notify service with retry mechanism"

Task 14: 更新主程序入口,集成所有服务

Files:

  • Modify: epay-go/cmd/server/main.go

Step 1: 更新主程序

// cmd/server/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/example/epay-go/internal/cache"
	"github.com/example/epay-go/internal/config"
	"github.com/example/epay-go/internal/database"
	"github.com/example/epay-go/internal/middleware"
	"github.com/example/epay-go/internal/service"
	"github.com/gin-gonic/gin"

	// 注册支付适配器
	_ "github.com/example/epay-go/internal/payment"
)

func main() {
	// 加载配置
	if err := config.Load("config.yaml"); err != nil {
		log.Fatalf("Failed to load config: %v", err)
	}
	cfg := config.Get()

	// 初始化数据库
	if err := database.Init(); err != nil {
		log.Fatalf("Failed to init database: %v", err)
	}
	defer database.Close()

	// 数据库迁移
	if err := database.Migrate(); err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	// 初始化 Redis
	if err := cache.Init(); err != nil {
		log.Fatalf("Failed to init redis: %v", err)
	}
	defer cache.Close()

	// 初始化默认管理员
	adminService := service.NewAdminService()
	if err := adminService.InitDefaultAdmin(); err != nil {
		log.Printf("Failed to init default admin: %v", err)
	}

	// 设置 Gin 模式
	gin.SetMode(cfg.Server.Mode)

	// 创建 Gin 引擎
	r := gin.New()

	// 全局中间件
	r.Use(middleware.Recovery())
	r.Use(middleware.Logger())
	r.Use(middleware.Cors())

	// 健康检查
	r.GET("/health", func(c *gin.Context) {
		c.JSON(200, gin.H{"status": "ok"})
	})

	// TODO: 在阶段三注册具体路由

	// 启动异步通知工作协程
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	notifyService := service.NewNotifyService()
	go notifyService.StartNotifyWorker(ctx)

	// 优雅关闭
	go func() {
		quit := make(chan os.Signal, 1)
		signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
		<-quit
		log.Println("Shutting down server...")
		cancel()
	}()

	// 启动服务
	addr := fmt.Sprintf(":%d", cfg.Server.Port)
	log.Printf("Server starting on %s", addr)
	if err := r.Run(addr); err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}
}

Step 2: 验证编译

Run: cd d:/project/payment/epay-go && go build -o epay-server ./cmd/server Expected: 编译成功

Step 3: 提交

git add cmd/server/main.go
git commit -m "feat: integrate all services and notify worker into main"

阶段二完成检查清单

  • 通用工具函数(密码、订单号生成)
  • Repository 层(商户、管理员、通道、订单、结算、资金记录)
  • 商户服务(注册、登录、信息管理)
  • 管理员服务(登录、密码管理)
  • 通道服务(CRUD)
  • 支付适配器接口和工厂
  • 支付宝适配器(gopay)
  • 微信支付适配器(gopay v3)
  • 订单服务(创建、查询、回调处理)
  • 结算服务(申请、审核、驳回、完成)
  • 异步通知服务(重试机制)
  • 主程序集成

下一阶段: 阶段三将实现 API Handler 层,包括管理后台 API、商户端 API 和对外支付 API。