21 KiB
完整支付系统功能细化实现计划
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
目标: 参考 epay 项目,为 epay-go 补全企业级聚合支付系统的完整功能,包括管理员管理、订单退款、统计报表、系统配置、安全增强等核心模块。
架构: 采用三层架构(Handler-Service-Repository),前端使用 Vue 3 + TypeScript + Arco Design,后端使用 Go + Gin + GORM + PostgreSQL。
技术栈: Go 1.21+, Gin, GORM, PostgreSQL, Redis, Vue 3, TypeScript, Arco Design, Vite
功能优先级划分
🔴 P0 - 核心业务功能(必须实现)
- 订单退款功能
- 管理员管理模块
- 商户余额调整
- 系统配置管理
🟡 P1 - 重要运营功能(优先实现)
- 操作日志记录
- 统计报表与图表
- 数据导出功能
- 订单补单功能
🟢 P2 - 增强功能(后续实现)
- 登录日志与安全增强
- 消息通知系统
- API 文档生成
Task 1: 订单退款功能
文件:
- Modify:
internal/model/order.go - Create:
internal/model/refund.go - Create:
internal/repository/refund.go - Create:
internal/service/refund.go - Create:
internal/handler/admin/refund.go - Create:
internal/handler/merchant/refund.go - Modify:
internal/router/router.go - Create:
web/src/views/admin/Refunds.vue - Create:
web/src/views/merchant/Refunds.vue - Modify:
web/src/api/admin.ts - Modify:
web/src/api/merchant.ts - Modify:
web/src/api/types.ts
步骤 1: 创建退款数据模型
在 internal/model/refund.go 创建退款模型:
// internal/model/refund.go
package model
import (
"time"
"gorm.io/gorm"
)
// Refund 退款订单
type Refund struct {
ID int64 `gorm:"primaryKey" json:"id"`
RefundNo string `gorm:"size:32;uniqueIndex;not null" json:"refund_no"` // 退款单号
TradeNo string `gorm:"size:32;index;not null" json:"trade_no"` // 原订单号
MerchantID int64 `gorm:"index;not null" json:"merchant_id"` // 商户ID
OrderID int64 `gorm:"index;not null" json:"order_id"` // 原订单ID
ChannelID int64 `gorm:"not null" json:"channel_id"` // 支付通道ID
Amount string `gorm:"type:decimal(10,2);not null" json:"amount"` // 退款金额
RefundFee string `gorm:"type:decimal(10,2);default:0.00" json:"refund_fee"` // 退款手续费
Reason string `gorm:"size:200" json:"reason"` // 退款原因
Status int8 `gorm:"default:0;index" json:"status"` // 状态:0待处理 1成功 2失败
ApiRefundNo string `gorm:"size:64" json:"api_refund_no"` // 第三方退款单号
FailReason string `gorm:"size:200" json:"fail_reason"` // 失败原因
NotifyURL string `gorm:"size:255" json:"notify_url"` // 异步通知地址
NotifyStatus int8 `gorm:"default:0" json:"notify_status"` // 通知状态:0未通知 1已通知
NotifyCount int `gorm:"default:0" json:"notify_count"` // 通知次数
NextNotifyTime *time.Time `json:"next_notify_time"` // 下次通知时间
ProcessedAt *time.Time `json:"processed_at"` // 处理完成时间
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// 退款状态常量
const (
RefundStatusPending = 0 // 待处理
RefundStatusSuccess = 1 // 成功
RefundStatusFailed = 2 // 失败
)
// TableName 指定表名
func (Refund) TableName() string {
return "refunds"
}
步骤 2: 添加退款表迁移
在 internal/database/migrate.go 的 Migrate() 函数中添加:
&model.Refund{},
步骤 3: 创建退款 Repository
在 internal/repository/refund.go 创建:
// internal/repository/refund.go
package repository
import (
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
)
type RefundRepository struct {
db *gorm.DB
}
func NewRefundRepository() *RefundRepository {
return &RefundRepository{db: database.Get()}
}
// Create 创建退款单
func (r *RefundRepository) Create(refund *model.Refund) error {
return r.db.Create(refund).Error
}
// GetByRefundNo 根据退款单号查询
func (r *RefundRepository) GetByRefundNo(refundNo string) (*model.Refund, error) {
var refund model.Refund
err := r.db.Where("refund_no = ?", refundNo).First(&refund).Error
return &refund, err
}
// GetByTradeNo 根据订单号查询退款记录
func (r *RefundRepository) GetByTradeNo(tradeNo string) ([]*model.Refund, error) {
var refunds []*model.Refund
err := r.db.Where("trade_no = ?", tradeNo).Find(&refunds).Error
return refunds, err
}
// List 分页查询退款列表
func (r *RefundRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]*model.Refund, int64, error) {
var refunds []*model.Refund
var total int64
query := r.db.Model(&model.Refund{})
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.Order("id DESC").Limit(pageSize).Offset(offset).Find(&refunds).Error
return refunds, total, err
}
// Update 更新退款单
func (r *RefundRepository) Update(refund *model.Refund) error {
return r.db.Save(refund).Error
}
步骤 4: 创建退款 Service
在 internal/service/refund.go 创建:
// internal/service/refund.go
package service
import (
"errors"
"time"
"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 RefundService struct {
refundRepo *repository.RefundRepository
orderRepo *repository.OrderRepository
merchantRepo *repository.MerchantRepository
recordRepo *repository.BalanceRecordRepository
}
func NewRefundService() *RefundService {
return &RefundService{
refundRepo: repository.NewRefundRepository(),
orderRepo: repository.NewOrderRepository(),
merchantRepo: repository.NewMerchantRepository(),
recordRepo: repository.NewBalanceRecordRepository(),
}
}
// CreateRefundRequest 创建退款请求
type CreateRefundRequest struct {
TradeNo string `json:"trade_no" binding:"required"`
Amount string `json:"amount" binding:"required"`
Reason string `json:"reason"`
NotifyURL string `json:"notify_url"`
}
// CreateRefund 创建退款单
func (s *RefundService) CreateRefund(merchantID int64, req *CreateRefundRequest) (*model.Refund, error) {
// 查询原订单
order, err := s.orderRepo.GetByTradeNo(req.TradeNo)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
return nil, err
}
// 验证商户权限
if order.MerchantID != merchantID {
return nil, errors.New("无权操作此订单")
}
// 验证订单状态
if order.Status != model.OrderStatusPaid {
return nil, errors.New("订单未支付,无法退款")
}
// 验证退款金额
refundAmount, err := decimal.NewFromString(req.Amount)
if err != nil {
return nil, errors.New("退款金额格式错误")
}
orderAmount, _ := decimal.NewFromString(order.Amount)
if refundAmount.GreaterThan(orderAmount) {
return nil, errors.New("退款金额不能大于订单金额")
}
// 检查是否已有退款记录
existingRefunds, err := s.refundRepo.GetByTradeNo(req.TradeNo)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// 计算已退款总额
totalRefunded := decimal.Zero
for _, r := range existingRefunds {
if r.Status == model.RefundStatusSuccess {
amt, _ := decimal.NewFromString(r.Amount)
totalRefunded = totalRefunded.Add(amt)
}
}
if totalRefunded.Add(refundAmount).GreaterThan(orderAmount) {
return nil, errors.New("退款总额超过订单金额")
}
// 创建退款单
refund := &model.Refund{
RefundNo: utils.GenerateOrderNo("R"),
TradeNo: order.TradeNo,
MerchantID: order.MerchantID,
OrderID: order.ID,
ChannelID: order.ChannelID,
Amount: req.Amount,
RefundFee: "0.00",
Reason: req.Reason,
Status: model.RefundStatusPending,
NotifyURL: req.NotifyURL,
}
if err := s.refundRepo.Create(refund); err != nil {
return nil, err
}
return refund, nil
}
// ProcessRefund 处理退款(管理员审核)
func (s *RefundService) ProcessRefund(refundNo string, success bool, failReason string) error {
refund, err := s.refundRepo.GetByRefundNo(refundNo)
if err != nil {
return err
}
if refund.Status != model.RefundStatusPending {
return errors.New("退款单已处理")
}
now := time.Now()
if success {
refund.Status = model.RefundStatusSuccess
refund.ProcessedAt = &now
// 退款到商户余额
amount, _ := decimal.NewFromString(refund.Amount)
merchant, err := s.merchantRepo.GetByID(refund.MerchantID)
if err != nil {
return err
}
beforeBalance, _ := decimal.NewFromString(merchant.Balance)
afterBalance := beforeBalance.Add(amount)
if err := s.merchantRepo.UpdateBalance(refund.MerchantID, afterBalance.String()); err != nil {
return err
}
// 记录资金变动
record := &model.BalanceRecord{
MerchantID: refund.MerchantID,
Action: 1, // 增加
Amount: refund.Amount,
BeforeBalance: beforeBalance.String(),
AfterBalance: afterBalance.String(),
Type: "refund",
TradeNo: refund.RefundNo,
}
s.recordRepo.Create(record)
} else {
refund.Status = model.RefundStatusFailed
refund.FailReason = failReason
refund.ProcessedAt = &now
}
return s.refundRepo.Update(refund)
}
// GetRefundByNo 根据退款单号查询
func (s *RefundService) GetRefundByNo(refundNo string) (*model.Refund, error) {
return s.refundRepo.GetByRefundNo(refundNo)
}
// List 退款列表
func (s *RefundService) List(page, pageSize int, merchantID *int64, status *int8) ([]*model.Refund, int64, error) {
return s.refundRepo.List(page, pageSize, merchantID, status)
}
步骤 5: 创建管理后台退款 Handler
在 internal/handler/admin/refund.go 创建:
// internal/handler/admin/refund.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListRefunds 退款列表
func ListRefunds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var merchantID *int64
if mid := c.Query("merchant_id"); mid != "" {
v, _ := strconv.ParseInt(mid, 10, 64)
merchantID = &v
}
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
refundService := service.NewRefundService()
refunds, total, err := refundService.List(page, pageSize, merchantID, status)
if err != nil {
response.ServerError(c, "获取退款列表失败")
return
}
response.SuccessPage(c, refunds, total, page, pageSize)
}
// ProcessRefundRequest 处理退款请求
type ProcessRefundRequest struct {
Success bool `json:"success" binding:"required"`
FailReason string `json:"fail_reason"`
}
// ProcessRefund 处理退款
func ProcessRefund(c *gin.Context) {
refundNo := c.Param("refund_no")
var req ProcessRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
refundService := service.NewRefundService()
if err := refundService.ProcessRefund(refundNo, req.Success, req.FailReason); err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, nil)
}
步骤 6: 创建商户端退款 Handler
在 internal/handler/merchant/refund.go 创建:
// internal/handler/merchant/refund.go
package merchant
import (
"strconv"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// CreateRefund 创建退款申请
func CreateRefund(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req service.CreateRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
refundService := service.NewRefundService()
refund, err := refundService.CreateRefund(merchantID, &req)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, refund)
}
// ListRefunds 退款列表
func ListRefunds(c *gin.Context) {
merchantID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
refundService := service.NewRefundService()
refunds, total, err := refundService.List(page, pageSize, &merchantID, status)
if err != nil {
response.ServerError(c, "获取退款列表失败")
return
}
response.SuccessPage(c, refunds, total, page, pageSize)
}
步骤 7: 注册路由
在 internal/router/router.go 添加:
// 管理后台 - 退款管理
adminGroup.GET("/refunds", admin.ListRefunds)
adminGroup.POST("/refunds/:refund_no/process", admin.ProcessRefund)
// 商户中心 - 退款管理
merchantGroup.POST("/refunds", merchant.CreateRefund)
merchantGroup.GET("/refunds", merchant.ListRefunds)
步骤 8: 添加前端类型定义
在 web/src/api/types.ts 添加:
export interface Refund {
id: number
refund_no: string
trade_no: string
merchant_id: number
order_id: number
channel_id: number
amount: string
refund_fee: string
reason: string
status: number // 0待处理 1成功 2失败
api_refund_no: string
fail_reason: string
notify_url: string
notify_status: number
notify_count: number
next_notify_time: string | null
processed_at: string | null
created_at: string
}
步骤 9: 添加管理后台 API
在 web/src/api/admin.ts 添加:
// 退款管理
export const getRefunds = (params: any) =>
request.get<{ list: Refund[]; total: number }>('/admin/refunds', { params })
export const processRefund = (refundNo: string, data: { success: boolean; fail_reason?: string }) =>
request.post(`/admin/refunds/${refundNo}/process`, data)
步骤 10: 添加商户端 API
在 web/src/api/merchant.ts 添加:
// 退款管理
export const createRefund = (data: {
trade_no: string
amount: string
reason?: string
notify_url?: string
}) => request.post('/merchant/refunds', data)
export const getRefunds = (params: any) =>
request.get<{ list: Refund[]; total: number }>('/merchant/refunds', { params })
步骤 11: 创建管理后台退款页面
创建 web/src/views/admin/Refunds.vue
步骤 12: 创建商户端退款页面
创建 web/src/views/merchant/Refunds.vue
步骤 13: 测试退款功能
# 重新构建后端
docker-compose build backend
docker-compose up -d backend
# 重启前端
cd web && npm run dev
测试步骤:
- 商户端创建退款申请
- 管理后台审核退款
- 验证商户余额变动
- 验证资金记录
步骤 14: 提交代码
git add .
git commit -m "feat: add refund functionality
- Add Refund model and database migration
- Implement refund repository, service, and handlers
- Add admin refund approval workflow
- Add merchant refund application
- Refund amount validation and balance refund
- Add frontend refund management pages
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
Task 2: 管理员管理模块
文件:
- Modify:
internal/model/admin.go - Create:
internal/service/admin_manage.go - Create:
internal/handler/admin/admin_manage.go - Modify:
internal/router/router.go - Create:
web/src/views/admin/Admins.vue
步骤 1: 扩展管理员模型
在 internal/model/admin.go 添加字段:
Email string `gorm:"size:100" json:"email"`
Status int8 `gorm:"default:1" json:"status"` // 状态:0禁用 1启用
步骤 2: 创建管理员管理 Service
在 internal/service/admin_manage.go 实现管理员的增删改查功能。
步骤 3: 创建管理员管理 Handler
步骤 4: 实现前端页面
步骤 5: 测试并提交
Task 3: 商户余额调整
文件:
- Create:
internal/handler/admin/balance.go - Modify:
web/src/views/admin/Merchants.vue
步骤 1: 创建余额调整 Handler
实现后台手动增加/减少商户余额的功能。
步骤 2: 添加资金变动记录
记录所有余额变动操作。
步骤 3: 实现前端界面
在商户管理页面添加余额调整按钮和对话框。
步骤 4: 测试并提交
Task 4: 系统配置管理
文件:
- Create:
internal/service/config.go - Create:
internal/handler/admin/config.go - Create:
web/src/views/admin/Settings.vue
步骤 1: 实现配置 Service
支持系统参数的读取和更新。
步骤 2: 定义配置项
- 站点名称
- 结算手续费率
- 异步通知重试次数
- 异步通知间隔时间
- 商户注册开关
- 测试模式开关
步骤 3: 实现前端配置页面
步骤 4: 测试并提交
Task 5: 操作日志记录
文件:
- Create:
internal/model/log.go - Create:
internal/repository/log.go - Create:
internal/service/log.go - Create:
internal/middleware/logger.go - Create:
internal/handler/admin/log.go - Create:
web/src/views/admin/Logs.vue
步骤 1: 创建日志模型
步骤 2: 实现日志中间件
自动记录管理员和商户的重要操作。
步骤 3: 实现日志查询界面
步骤 4: 测试并提交
Task 6: 统计报表与图表
文件:
- Create:
internal/service/statistics.go - Create:
internal/handler/admin/statistics.go - Modify:
web/src/views/admin/Dashboard.vue - Modify:
web/src/views/merchant/Dashboard.vue
步骤 1: 实现统计 Service
- 按日期统计订单
- 按通道统计订单
- 按商户统计订单
- 收入趋势统计
步骤 2: 安装图表库
cd web
npm install @arco-design/web-vue echarts
步骤 3: 实现仪表盘图表
- 订单趋势折线图
- 收入统计柱状图
- 通道占比饼图
- 商户排行榜
步骤 4: 测试并提交
Task 7: 数据导出功能
文件:
- Create:
internal/handler/admin/export.go - Modify:
web/src/views/admin/Orders.vue
步骤 1: 安装 Excel 库
go get github.com/xuri/excelize/v2
步骤 2: 实现订单导出
支持导出 Excel 格式的订单数据。
步骤 3: 添加导出按钮
步骤 4: 测试并提交
Task 8: 订单补单功能
文件:
- Create:
internal/handler/admin/order_repair.go - Modify:
web/src/views/admin/Orders.vue
步骤 1: 实现补单逻辑
手动标记订单为已支付,并触发异步通知。
步骤 2: 添加权限验证
只有超级管理员可以补单。
步骤 3: 实现前端界面
步骤 4: 测试并提交
Task 9: 登录日志与安全增强
文件:
- Create:
internal/model/login_log.go - Create:
internal/middleware/rate_limit.go - Create:
internal/middleware/ip_whitelist.go
步骤 1: 记录登录日志
记录登录时间、IP、设备信息、登录结果。
步骤 2: 实现 IP 白名单
步骤 3: 实现频率限制
防止暴力破解。
步骤 4: 测试并提交
Task 10: 消息通知系统
文件:
- Create:
internal/service/notification.go - Create:
pkg/mail/sender.go - Create:
pkg/sms/sender.go
步骤 1: 实现邮件通知
使用 SMTP 发送邮件。
步骤 2: 实现短信通知
集成短信服务商 API。
步骤 3: 定义通知场景
- 订单支付成功
- 结算审核通过
- 余额不足提醒
步骤 4: 测试并提交
Task 11: API 文档生成
文件:
- 安装 Swagger
- 添加 API 注释
步骤 1: 安装 Swagger
go install github.com/swaggo/swag/cmd/swag@latest
步骤 2: 添加 Swagger 注释
为所有 Handler 添加注释。
步骤 3: 生成文档
swag init -g cmd/server/main.go
步骤 4: 访问文档
http://localhost:8099/swagger/index.html
步骤 5: 提交代码
总结
本计划涵盖了 11 个核心功能模块,完整参考了 epay 项目的功能设计,将使 epay-go 成为一个功能完善的企业级聚合支付系统。
实施建议:
- 按优先级顺序实施(P0 → P1 → P2)
- 每个 Task 完成后进行充分测试
- 保持代码质量和架构一致性
- 及时更新文档
预计工作量:
- P0 功能:2-3 周
- P1 功能:2 周
- P2 功能:1-2 周
总计:5-7 周可完成全部功能细化。