增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+11 -1
View File
@@ -51,4 +51,14 @@ acme_directory =
# 证书落盘根目录。签发后生成 {ssl_cert_dir}/{域名}/fullchain.pem 与 privkey.pem,
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
ssl_cert_dir = /www/wwwroot/ssl-certs
ssl_cert_dir = /www/wwwroot/ssl-certs
# ==================== 支付模块 ====================
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
payment_secret_key = f656fe85ed6a1caba9f19966d8cdde46114709969bea5875cde7664d4ad9f535
# 渠道异步回调的对外基址:最终回调地址 = {payment_callback_base}/api/payment/callback/{渠道}
# TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。
# 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。
# 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。
payment_callback_base = https://api.yunzer.com.cn
+214
View File
@@ -0,0 +1,214 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendPaymentController 租户端支付接口(下单页 / 收银台)
// 租户端身份为 jwt UserType=backend,TenantId 即租户ID。
type BackendPaymentController struct {
beego.Controller
}
func (c *BackendPaymentController) backendClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "backend" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *BackendPaymentController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendPaymentController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func (c *BackendPaymentController) tenantID() string {
claims, _ := c.backendClaims()
if claims == nil {
return ""
}
return strconv.Itoa(claims.TenantId)
}
// tenantName 查询租户名称(下单快照用)
func (c *BackendPaymentController) tenantName() string {
claims, err := c.backendClaims()
if err != nil || claims.TenantId <= 0 {
return ""
}
tenant := &models.SystemTenant{}
if err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", uint64(claims.TenantId)).
Filter("delete_time__isnull", true).
One(tenant); err != nil {
return ""
}
return tenant.TenantName
}
// Channels GET /backend/payment/channels 收银台可用渠道(已启用且参数完整)
func (c *BackendPaymentController) Channels() {
if _, err := c.backendClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.PlatformPaymentChannel
_, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("enabled", 1).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询渠道失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
cfg, err := payment.LoadChannelConfig(rows[i].Channel)
if err != nil || len(cfg.Params) == 0 {
continue // 参数不完整的渠道不下发
}
extra := map[string]interface{}{}
for k, v := range cfg.Extra {
extra[k] = v
}
list = append(list, map[string]interface{}{
"channel": rows[i].Channel, "name": rows[i].Name, "extra": extra,
})
}
c.ok(map[string]interface{}{"list": list})
}
// Create POST /backend/payment/create 创建支付单并返回渠道支付参数
// 入参:{ out_trade_no, order_type, amount(分), channel, subject, return_url, pay_type, openid,
//
// promoter_id?, promoter_name?, promoter_type?, expire_minutes? }
//
// 出参:{ pay_no, channel, pay_params }
func (c *BackendPaymentController) Create() {
claims, err := c.backendClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
OutTradeNo string `json:"out_trade_no"`
OrderType string `json:"order_type"`
Amount int64 `json:"amount"`
Channel string `json:"channel"`
Subject string `json:"subject"`
ReturnURL string `json:"return_url"`
PayType string `json:"pay_type"`
OpenID string `json:"openid"`
PromoterID string `json:"promoter_id"`
PromoterName string `json:"promoter_name"`
PromoterType string `json:"promoter_type"`
ExpireMinutes int `json:"expire_minutes"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.OutTradeNo) == "" {
c.jsonErr(400, 400, "业务订单号不能为空")
return
}
if p.Amount <= 0 {
c.jsonErr(400, 400, "支付金额必须大于 0")
return
}
if strings.TrimSpace(p.Channel) == "" {
c.jsonErr(400, 400, "请选择支付渠道")
return
}
if p.OrderType == "" {
p.OrderType = models.CommissionOrderTypePlatformUsage
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
order, params, err := payment.CreatePayment(ctx, payment.CreateInput{
OutTradeNo: strings.TrimSpace(p.OutTradeNo),
OrderType: p.OrderType,
TenantID: strconv.Itoa(claims.TenantId),
TenantName: c.tenantName(),
Amount: p.Amount,
Channel: strings.TrimSpace(p.Channel),
Subject: strings.TrimSpace(p.Subject),
ReturnURL: strings.TrimSpace(p.ReturnURL),
ClientIP: c.Ctx.Input.IP(),
PayType: p.PayType,
OpenID: p.OpenID,
PromoterID: strings.TrimSpace(p.PromoterID),
PromoterName: strings.TrimSpace(p.PromoterName),
PromoterType: strings.TrimSpace(p.PromoterType),
ExpireMinutes: p.ExpireMinutes,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"pay_no": order.PayNo,
"channel": order.Channel,
"status": order.Status,
"pay_params": params,
})
}
// Query GET /backend/payment/query/:payNo 查询支付单(仅本租户)
func (c *BackendPaymentController) Query() {
if _, err := c.backendClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
row, err := payment.QueryPayment(ctx, payNo, true)
if err != nil {
c.jsonErr(404, 404, "支付单不存在")
return
}
if row.TenantID != c.tenantID() {
c.jsonErr(403, 403, "无权访问该支付单")
return
}
c.ok(map[string]interface{}{
"pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"amount": row.Amount, "channel": row.Channel, "status": row.Status,
"paid_at": row.PaidAt, "create_time": row.CreateTime,
})
}
+427
View File
@@ -0,0 +1,427 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformCommissionController 平台端推广佣金(台账 / 统计 / 规则维护)
type PlatformCommissionController struct {
beego.Controller
}
func (c *PlatformCommissionController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
func (c *PlatformCommissionController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformCommissionController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// commissionRuleDTO 规则出参。
// 比例值统一按「百分比」返回:库里存百分比×100(500 = 5%),接口返回 5,便于前端直接编辑。
func commissionRuleDTO(row *models.PlatformCommissionRule) map[string]interface{} {
dto := map[string]interface{}{
"id": row.ID, "rule_name": row.RuleName, "calc_type": row.CalcType,
"order_type": row.OrderType, "status": row.Status, "remark": row.Remark,
"create_user_id": row.CreateUserID, "create_user_name": row.CreateUserName,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
"steps": []map[string]interface{}{},
}
if row.CalcType == models.CommissionCalcFixed {
dto["value"] = row.Value // 固定金额:分
} else {
dto["value"] = float64(row.Value) / 100 // 比例:5% -> 5
}
if row.CalcType == models.CommissionCalcTiered && row.TierJSON != nil && *row.TierJSON != "" {
var tiers []models.CommissionTier
if err := json.Unmarshal([]byte(*row.TierJSON), &tiers); err == nil {
steps := make([]map[string]interface{}, 0, len(tiers))
for _, t := range tiers {
var maxV interface{}
if t.Max != nil {
maxV = *t.Max
}
steps = append(steps, map[string]interface{}{
"min": t.Min, "max": maxV, "rate": float64(t.Rate) / 100,
})
}
dto["steps"] = steps
}
}
return dto
}
// commissionOrderDTO 佣金单出参
func commissionOrderDTO(row *models.PlatformCommissionOrder) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "commission_no": row.CommissionNo,
"pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"rule_id": row.RuleID, "rule_name": row.RuleName, "calc_type": row.CalcType,
"promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType,
"base_amount": row.BaseAmount, "amount": row.Amount,
"status": row.Status, "pay_method": row.PayMethod, "pay_voucher": row.PayVoucher,
"paid_at": row.PaidAt, "paid_user_id": row.PaidUserID, "paid_user_name": row.PaidUserName,
"remark": row.Remark, "create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// List GET /platform/commission/list 佣金台账
func (c *PlatformCommissionController) List() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
rows, total, err := payment.ListCommissions(payment.CommissionListInput{
Promoter: strings.TrimSpace(c.GetString("promoter")),
Status: strings.TrimSpace(c.GetString("status")),
StartTime: strings.TrimSpace(c.GetString("start_time")),
EndTime: strings.TrimSpace(c.GetString("end_time")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformCommissionOrder{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, commissionOrderDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// Stats GET /platform/commission/stats 台账统计
func (c *PlatformCommissionController) Stats() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
stats, err := payment.GetCommissionStats()
if err != nil {
c.jsonErr(500, 500, "统计失败: "+err.Error())
return
}
c.ok(stats)
}
// MarkPaid POST /platform/commission/:id/pay 标记已付(发放登记)
func (c *PlatformCommissionController) MarkPaid() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
PayMethod string `json:"pay_method"`
PayVoucher string `json:"pay_voucher"`
Remark string `json:"remark"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.PayMethod) == "" {
c.jsonErr(400, 400, "请选择发放方式")
return
}
row, err := payment.MarkCommissionPaid(context.Background(), payment.MarkCommissionPaidInput{
ID: id, PayMethod: strings.TrimSpace(p.PayMethod), PayVoucher: strings.TrimSpace(p.PayVoucher),
Remark: strings.TrimSpace(p.Remark),
OperatorID: fmt.Sprintf("%d", claims.UserID), OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(commissionOrderDTO(row))
}
/* ---------------- 佣金规则 ---------------- */
type commissionRulePayload struct {
RuleName string `json:"rule_name"`
CalcType string `json:"calc_type"`
Value *float64 `json:"value"`
Steps []struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Rate *float64 `json:"rate"`
} `json:"steps"`
OrderType string `json:"order_type"`
Status *int8 `json:"status"`
Remark string `json:"remark"`
}
// buildTierJSON 组装阶梯配置(金额分;rate 存百分比×100)
func buildTierJSON(steps []struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Rate *float64 `json:"rate"`
}) (string, error) {
if len(steps) == 0 {
return "", fmt.Errorf("阶梯规则至少需要一档")
}
tiers := make([]models.CommissionTier, 0, len(steps))
for i, s := range steps {
if s.Min == nil || s.Rate == nil {
return "", fmt.Errorf("第 %d 档的起始金额与比例必填", i+1)
}
tier := models.CommissionTier{
Min: int64(*s.Min + 0.5),
Rate: int64(*s.Rate*100 + 0.5),
}
if s.Max != nil && *s.Max > 0 {
mx := int64(*s.Max + 0.5)
tier.Max = &mx
if mx <= tier.Min {
return "", fmt.Errorf("第 %d 档结束金额需大于起始金额", i+1)
}
}
if tier.Rate <= 0 {
return "", fmt.Errorf("第 %d 档比例需大于 0", i+1)
}
tiers = append(tiers, tier)
}
b, err := json.Marshal(tiers)
return string(b), err
}
// RulesList GET /platform/commission/rules 规则列表
func (c *PlatformCommissionController) RulesList() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "20"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 20
}
qs := models.Orm.QueryTable(new(models.PlatformCommissionRule)).Filter("delete_time__isnull", true)
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
qs = qs.Filter("rule_name__icontains", kw)
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
var rows []models.PlatformCommissionRule
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformCommissionRule{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, commissionRuleDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// RuleCreate POST /platform/commission/rules 新增规则
func (c *PlatformCommissionController) RuleCreate() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p commissionRulePayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.RuleName) == "" {
c.jsonErr(400, 400, "规则名称不能为空")
return
}
row := &models.PlatformCommissionRule{
RuleName: strings.TrimSpace(p.RuleName),
CalcType: strings.TrimSpace(p.CalcType),
OrderType: strings.TrimSpace(p.OrderType),
Status: 1,
Remark: strings.TrimSpace(p.Remark),
CreateUserID: fmt.Sprintf("%d", claims.UserID),
CreateUserName: claims.Username,
}
if p.Status != nil {
row.Status = *p.Status
}
if err := applyCommissionRuleValues(row, &p); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
if _, err := models.Orm.Insert(row); err != nil {
c.jsonErr(500, 500, "创建失败: "+err.Error())
return
}
c.ok(commissionRuleDTO(row))
}
// RuleUpdate POST /platform/commission/rules/:id 更新规则
func (c *PlatformCommissionController) RuleUpdate() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
row := &models.PlatformCommissionRule{}
if err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("id", id).Filter("delete_time__isnull", true).One(row); err != nil {
c.jsonErr(404, 404, "规则不存在")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p commissionRulePayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if name := strings.TrimSpace(p.RuleName); name != "" {
row.RuleName = name
}
if calc := strings.TrimSpace(p.CalcType); calc != "" {
row.CalcType = calc
}
if ot := strings.TrimSpace(p.OrderType); ot != "" {
row.OrderType = ot
}
row.Remark = strings.TrimSpace(p.Remark)
if p.Status != nil {
row.Status = *p.Status
}
if err := applyCommissionRuleValues(row, &p); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
if _, err := models.Orm.Update(row); err != nil {
c.jsonErr(500, 500, "更新失败: "+err.Error())
return
}
c.ok(commissionRuleDTO(row))
}
// applyCommissionRuleValues 按计算方式写入 value / tier_json
func applyCommissionRuleValues(row *models.PlatformCommissionRule, p *commissionRulePayload) error {
switch row.CalcType {
case models.CommissionCalcPercent:
if p.Value == nil || *p.Value <= 0 {
return fmt.Errorf("请输入大于 0 的比例值")
}
row.Value = int64(*p.Value*100 + 0.5)
row.TierJSON = nil
case models.CommissionCalcFixed:
if p.Value == nil || *p.Value <= 0 {
return fmt.Errorf("请输入大于 0 的固定金额(分)")
}
row.Value = int64(*p.Value + 0.5)
row.TierJSON = nil
case models.CommissionCalcTiered:
tierJSON, err := buildTierJSON(p.Steps)
if err != nil {
return err
}
row.Value = 0
row.TierJSON = &tierJSON
default:
return fmt.Errorf("计算方式不合法")
}
switch row.OrderType {
case models.CommissionOrderTypeAll, models.CommissionOrderTypePlatformUsage,
models.CommissionOrderTypeModuleShop, models.CommissionOrderTypeServiceFee:
default:
return fmt.Errorf("适用订单类型不合法")
}
return nil
}
// RuleDelete DELETE /platform/commission/rules/:id 删除规则(软删除;历史佣金单不受影响)
func (c *PlatformCommissionController) RuleDelete() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("id", id).
Update(orm.Params{"delete_time": now, "update_time": now}); err != nil {
c.jsonErr(500, 500, "删除失败: "+err.Error())
return
}
c.ok(nil)
}
// RuleToggle POST /platform/commission/rules/:id/status 启用/停用
func (c *PlatformCommissionController) RuleToggle() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Status *int8 `json:"status"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Status == nil {
c.jsonErr(400, 400, "参数错误")
return
}
if _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("id", id).
Update(orm.Params{"status": *p.Status, "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "操作失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"status": *p.Status})
}
+615
View File
@@ -0,0 +1,615 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformPaymentController 平台端支付管理(渠道配置 / 支付订单 / 兜底退回 / 渠道回调)
type PlatformPaymentController struct {
beego.Controller
}
func (c *PlatformPaymentController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
// paymentPlatformClaims 平台端鉴权(支付相关控制器共用),传各控制器内嵌的 &c.Controller
func paymentPlatformClaims(c *beego.Controller) (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *PlatformPaymentController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformPaymentController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// channelSecretKeys 各渠道的敏感参数键(接口回显时只回掩码;提交掩码/空串表示不修改)
var channelSecretKeys = map[string][]string{
payment.ChannelWechat: {"api_v3_key"},
payment.ChannelAlipay: {"app_private_key"},
payment.ChannelUnionPay: {"cert_password"},
payment.ChannelCloudPay: {"cert_password"},
payment.ChannelPayPal: {"client_secret"},
}
func channelRowDTO(row *models.PlatformPaymentChannel) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "channel": row.Channel, "name": row.Name,
"merchant_no": row.MerchantNo, "callback_url": row.CallbackURL,
"enabled": row.Enabled == 1,
"remark": row.Remark,
"last_test_time": row.LastTestTime, "last_test_result": row.LastTestResult,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// maskedConfigView 解密配置并掩码敏感字段,供前端回显
func maskedConfigView(cfg *payment.ChannelConfig, row *models.PlatformPaymentChannel) map[string]interface{} {
view := map[string]interface{}{}
for k, v := range cfg.Params {
view[k] = v
}
for _, key := range channelSecretKeys[row.Channel] {
if v, ok := view[key]; ok {
if s, ok2 := v.(string); ok2 && s != "" {
view[key] = payment.MaskSecret(s)
}
}
}
return view
}
func findChannelRow(channel string) (*models.PlatformPaymentChannel, error) {
row := &models.PlatformPaymentChannel{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
type paymentChannelPayload struct {
Name string `json:"name"`
MerchantNo string `json:"merchant_no"`
CallbackURL string `json:"callback_url"`
Remark string `json:"remark"`
Config map[string]string `json:"config"`
Extra map[string]any `json:"extra"`
}
// loadMergedConfig 加载已存配置并把「新填写且非掩码」的值合并进去(用于保存与测试连接共用)
func loadMergedConfig(channel string, p *paymentChannelPayload) (*payment.ChannelConfig, error) {
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
return nil, err
}
if p == nil {
return cfg, nil
}
for k, v := range p.Config {
v = strings.TrimSpace(v)
if v == "" || payment.IsMasked(v) {
continue // 空串 / 掩码 = 保持原值
}
cfg.Params[k] = v
}
if p.Extra != nil {
cfg.Extra = p.Extra
}
return cfg, nil
}
// ListChannels GET /platform/payment/channels 渠道概览
func (c *PlatformPaymentController) ListChannels() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.PlatformPaymentChannel
_, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询渠道失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformPaymentChannel{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, channelRowDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": len(list)})
}
// GetChannel GET /platform/payment/channels/:channel 渠道配置详情(敏感字段掩码)
func (c *PlatformPaymentController) GetChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
row, err := findChannelRow(channel)
if err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
dto := channelRowDTO(row)
dto["config"] = maskedConfigView(cfg, row)
dto["extra"] = cfg.Extra
dto["cert_files"] = cfg.CertPaths
c.ok(dto)
}
// SaveChannel POST /platform/payment/channels/:channel 保存渠道配置(敏感字段留空/掩码表示不修改)
func (c *PlatformPaymentController) SaveChannel() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
if _, err := findChannelRow(channel); err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p paymentChannelPayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
merged, err := loadMergedConfig(channel, &p)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
plain, _ := json.Marshal(merged.Params)
enc, err := payment.EncryptConfig(string(plain))
if err != nil {
c.jsonErr(500, 500, "渠道参数加密失败: "+err.Error())
return
}
extraJSON := ""
if p.Extra != nil {
b, _ := json.Marshal(p.Extra)
extraJSON = string(b)
}
now := time.Now()
updates := orm.Params{
"config_json": enc,
"merchant_no": strings.TrimSpace(p.MerchantNo),
"remark": strings.TrimSpace(p.Remark),
"update_time": now,
}
if p.Name != "" {
updates["name"] = strings.TrimSpace(p.Name)
}
if p.CallbackURL != "" {
updates["callback_url"] = strings.TrimSpace(p.CallbackURL)
}
if extraJSON != "" {
updates["extra_json"] = extraJSON
}
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(updates); err != nil {
c.jsonErr(500, 500, "保存失败: "+err.Error())
return
}
_ = claims
row, _ := findChannelRow(channel)
c.ok(channelRowDTO(row))
}
// TestChannel POST /platform/payment/channels/:channel/test 测试连接(支持未保存参数)
func (c *PlatformPaymentController) TestChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p paymentChannelPayload
_ = json.Unmarshal(body, &p) // 允许空 body:只测已保存配置
merged, err := loadMergedConfig(channel, &p)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
adapter, err := payment.GetChannelAdapter(channel)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
msg, err := adapter.TestConnect(ctx, merged)
now := time.Now()
result := msg
if err != nil {
result = err.Error()
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"last_test_time": now, "last_test_result": result, "update_time": now})
if err != nil {
c.jsonErr(400, 400, result)
return
}
c.ok(map[string]interface{}{"message": result})
}
// ToggleChannel POST /platform/payment/channels/:channel/status 启用/停用
func (c *PlatformPaymentController) ToggleChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Enabled *bool `json:"enabled"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Enabled == nil {
c.jsonErr(400, 400, "参数错误")
return
}
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
if *p.Enabled && len(cfg.Params) == 0 {
c.jsonErr(400, 400, "渠道参数未配置,无法启用")
return
}
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"enabled": b2i(*p.Enabled), "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "操作失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"enabled": *p.Enabled})
}
func b2i(v bool) int8 {
if v {
return 1
}
return 0
}
// UploadCertificate POST /platform/payment/channels/:channel/certificate 上传渠道证书
// 证书文件保存在服务端 certs/payment/<channel>/ 下(0600 权限,不对外提供访问),
// 文件路径写入 cert_json,密钥内容绝不入库、不回显。
func (c *PlatformPaymentController) UploadCertificate() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
if _, err := findChannelRow(channel); err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
certType := strings.TrimSpace(c.GetString("cert_type"))
if certType == "" {
certType = "cert"
}
file, header, err := c.GetFile("file")
if err != nil || header == nil {
c.jsonErr(400, 400, "请选择证书文件")
return
}
defer func() { _ = file.Close() }()
dir := filepath.Join("certs", "payment", channel)
if err := os.MkdirAll(dir, 0o700); err != nil {
c.jsonErr(500, 500, "创建证书目录失败: "+err.Error())
return
}
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" {
ext = ".pem"
}
name := fmt.Sprintf("%s_%d%s", certType, time.Now().UnixNano(), ext)
savePath := filepath.Join(dir, name)
out, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
c.jsonErr(500, 500, "保存证书失败: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
_ = out.Close()
c.jsonErr(500, 500, "保存证书失败: "+err.Error())
return
}
_ = out.Close()
// 更新 cert_json
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
if cfg.CertPaths == nil {
cfg.CertPaths = map[string]string{}
}
certKey := certType + "_path"
cfg.CertPaths[certKey] = savePath
if _, err := os.Stat(savePath); err == nil {
// 同步补充对应关系:cert -> cert_path,key -> key_path
if certType == "cert" {
cfg.CertPaths["cert_path"] = savePath
} else {
cfg.CertPaths["key_path"] = savePath
}
}
certJSON, _ := json.Marshal(cfg.CertPaths)
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"cert_json": string(certJSON), "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "更新证书信息失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"file": header.Filename, "path": savePath, "cert_type": certType})
}
/* ---------------- 支付订单 ---------------- */
func paymentOrderDTO(row *models.PlatformPaymentOrder) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"order_type": row.OrderType, "subject": row.Subject,
"tenant_id": row.TenantID, "tenant_name": row.TenantName,
"amount": row.Amount, "order_amount": row.OrderAmount, "currency": row.Currency,
"channel": row.Channel, "merchant_no": row.MerchantNo, "channel_trade_no": row.ChannelTradeNo,
"status": row.Status, "refund_amount": row.RefundAmount,
"return_url": row.ReturnURL, "notify_url": row.NotifyURL,
"expire_at": row.ExpireAt, "paid_at": row.PaidAt, "closed_at": row.ClosedAt,
"notify_at": row.NotifyAt, "last_query_at": row.LastQueryAt,
"order_synced": row.OrderSynced,
"promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// ListOrders GET /platform/payment/orders 支付单列表
func (c *PlatformPaymentController) ListOrders() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 10
}
qs := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).Filter("delete_time__isnull", true)
if v := strings.TrimSpace(c.GetString("tenant_id")); v != "" {
qs = qs.Filter("tenant_id", v)
}
if v := strings.TrimSpace(c.GetString("out_trade_no")); v != "" {
qs = qs.Filter("out_trade_no__icontains", v)
}
if v := strings.TrimSpace(c.GetString("pay_no")); v != "" {
qs = qs.Filter("pay_no__icontains", v)
}
if v := strings.TrimSpace(c.GetString("channel")); v != "" {
qs = qs.Filter("channel", v)
}
if v := strings.TrimSpace(c.GetString("status")); v != "" {
qs = qs.Filter("status", v)
}
if v := strings.TrimSpace(c.GetString("start_time")); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
qs = qs.Filter("create_time__gte", t)
}
}
if v := strings.TrimSpace(c.GetString("end_time")); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1))
}
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
var rows []models.PlatformPaymentOrder
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, paymentOrderDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// OrderDetail GET /platform/payment/orders/:payNo 支付单详情(含状态流转 + 最近回调原文)
func (c *PlatformPaymentController) OrderDetail() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
row, err := payment.GetOrderByPayNo(payNo)
if err != nil {
c.jsonErr(404, 404, "支付单不存在")
return
}
dto := paymentOrderDTO(row)
// 状态流转记录
var logs []models.PlatformPaymentOrderLog
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrderLog)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&logs)
records := make([]map[string]interface{}, 0, len(logs))
for i := range logs {
records = append(records, map[string]interface{}{
"to_status": logs[i].ToStatus, "operator": logs[i].Operator,
"remark": logs[i].Remark, "create_time": logs[i].CreateTime,
})
}
dto["records"] = records
// 最近一次成功回调的原文(来自 yz_platform_payment_callback_log.raw_body)
var cb models.PlatformPaymentCallbackLog
err = models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
OrderBy("-id").
One(&cb)
if err == nil && cb.RawBody != nil {
dto["callback_raw"] = *cb.RawBody
dto["notify_at"] = cb.CreateTime
}
c.ok(dto)
}
// QueryOrder POST /platform/payment/orders/:payNo/query 手动查询渠道状态(兜底回调丢失)
func (c *PlatformPaymentController) QueryOrder() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Sync *bool `json:"sync"`
}
_ = json.Unmarshal(body, &p)
sync := true
if p.Sync != nil {
sync = *p.Sync
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
row, err := payment.QueryPayment(ctx, payNo, sync)
if err != nil {
c.jsonErr(404, 404, err.Error())
return
}
c.ok(paymentOrderDTO(row))
}
// Refund POST /platform/payment/refund 手动原路退回(兜底能力)
func (c *PlatformPaymentController) Refund() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
PayNo string `json:"pay_no"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(body, &p); err != nil || strings.TrimSpace(p.PayNo) == "" {
c.jsonErr(400, 400, "参数错误")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
refund, err := payment.ManualRefund(ctx, payment.RefundInput{
PayNo: strings.TrimSpace(p.PayNo),
Amount: p.Amount,
Reason: strings.TrimSpace(p.Reason),
OperatorID: fmt.Sprintf("%d", claims.UserID),
OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"refund_no": refund.RefundNo, "status": refund.Status,
"amount": refund.Amount, "channel_refund_no": refund.ChannelRefundNo,
"fail_reason": refund.FailReason,
})
}
// Notify POST /platform/payment/callback/:channel 渠道异步通知入口(渠道服务器调用,无平台鉴权)
// 处理成功时按渠道约定返回:微信 {"code":"SUCCESS"} / 支付宝 success / 银联与 PayPal ok / 200 空体。
func (c *PlatformPaymentController) Notify() {
channel := c.Ctx.Input.Param(":channel")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ack, err := payment.HandleNotify(ctx, channel, c.Ctx.Request, c.Ctx.Input.IP())
if err != nil {
beelog.Warn("支付回调处理失败: channel=%s err=%v", channel, err)
c.Ctx.Output.SetStatus(http.StatusInternalServerError)
_, _ = c.Ctx.ResponseWriter.Write([]byte("FAIL"))
return
}
if ack == "" {
ack = "ok"
}
c.Ctx.Output.SetStatus(http.StatusOK)
_, _ = c.Ctx.ResponseWriter.Write([]byte(ack))
}
@@ -0,0 +1,188 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformPaymentReconcileController 平台端对账管理(差异列表 / 标记处理 / 账单导入 / 执行对账)
type PlatformPaymentReconcileController struct {
beego.Controller
}
func (c *PlatformPaymentReconcileController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
func (c *PlatformPaymentReconcileController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformPaymentReconcileController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func reconcileDiffDTO(row *models.PlatformPaymentReconcileDiff) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "channel": row.Channel, "bill_date": row.BillDate,
"batch_no": row.BatchNo, "local_trade_no": row.LocalTradeNo, "channel_trade_no": row.ChannelTradeNo,
"local_amount": row.LocalAmount, "channel_amount": row.ChannelAmount, "diff_amount": row.DiffAmount,
"diff_type": row.DiffType, "handle_status": row.HandleStatus,
"handle_user_id": row.HandleUserID, "handle_user_name": row.HandleUserName,
"handle_time": row.HandleTime, "remark": row.Remark,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// ListReconcile GET /platform/payment/reconcile 对账差异列表
func (c *PlatformPaymentReconcileController) ListReconcile() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
rows, total, err := payment.ListReconcileDiffs(payment.ReconcileListInput{
Channel: strings.TrimSpace(c.GetString("channel")),
BillDate: strings.TrimSpace(c.GetString("bill_date")),
DiffType: strings.TrimSpace(c.GetString("diff_type")),
HandleStatus: strings.TrimSpace(c.GetString("handle_status")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformPaymentReconcileDiff{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, reconcileDiffDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// HandleReconcile POST /platform/payment/reconcile/:id/handle 标记差异处理
func (c *PlatformPaymentReconcileController) HandleReconcile() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
HandleStatus string `json:"handle_status"`
Remark string `json:"remark"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
row, err := payment.MarkDiffHandled(context.Background(), payment.MarkDiffHandledInput{
ID: id, HandleStatus: strings.TrimSpace(p.HandleStatus), Remark: strings.TrimSpace(p.Remark),
OperatorID: fmt.Sprintf("%d", claims.UserID), OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(reconcileDiffDTO(row))
}
// RunReconcile POST /platform/payment/reconcile/run 执行对账比对
func (c *PlatformPaymentReconcileController) RunReconcile() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Channel string `json:"channel"`
BillDate string `json:"bill_date"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
summary, err := payment.RunReconcile(ctx, strings.TrimSpace(p.Channel), strings.TrimSpace(p.BillDate))
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(summary)
}
// ImportBill POST /platform/payment/reconcile/import 导入渠道账单
// 两种方式:multipart 上传 CSV(字段 file + channel + bill_date),或 JSON {channel, bill_date, rows:[...]}
func (c *PlatformPaymentReconcileController) ImportBill() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var channel, billDate string
var rows []payment.BillRow
contentType := c.Ctx.Request.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
channel = strings.TrimSpace(c.GetString("channel"))
billDate = strings.TrimSpace(c.GetString("bill_date"))
file, header, ferr := c.GetFile("file")
if ferr != nil || header == nil {
c.jsonErr(400, 400, "请选择账单文件")
return
}
defer func() { _ = file.Close() }()
data, rerr := io.ReadAll(file)
if rerr != nil {
c.jsonErr(400, 400, "读取账单文件失败: "+rerr.Error())
return
}
rows, _ = payment.ParseChannelBillCSV(data)
} else {
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Channel string `json:"channel"`
BillDate string `json:"bill_date"`
Rows []payment.BillRow `json:"rows"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
channel, billDate, rows = strings.TrimSpace(p.Channel), strings.TrimSpace(p.BillDate), p.Rows
}
if channel == "" || billDate == "" {
c.jsonErr(400, 400, "channel 与 bill_date 不能为空")
return
}
imported, err := payment.ImportChannelBill(context.Background(), channel, billDate, rows)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{"imported": imported, "channel": channel, "bill_date": billDate})
}
@@ -0,0 +1,30 @@
-- =============================================================
-- 支付单表补列:yz_platform_payment_order 增加推广方快照字段
-- 变更原因:佣金在「支付成功」事件上生成,需要知道该笔收款归属的推广方;
-- 推广方在下单时确定,随支付单落快照(后续改归属不影响历史佣金)。
-- 新增列:
-- promoter_id varchar(64) NOT NULL DEFAULT '' 推广方ID
-- promoter_name varchar(128) NOT NULL DEFAULT '' 推广方名称
-- promoter_type varchar(32) NOT NULL DEFAULT '' 推广方类型 channel/service/staff
-- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不报 1060 Duplicate column)。
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < alter_platform_payment_order_promoter.sql
-- =============================================================
-- promoter_id
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order');
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_id');
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''推广方ID(下单时快照,支付成功后据此生成佣金)'' AFTER `order_synced`', 'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- promoter_name
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_name');
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''推广方名称快照'' AFTER `promoter_id`', 'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- promoter_type
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_type');
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_type` varchar(32) NOT NULL DEFAULT '''' COMMENT ''推广方类型快照:channel渠道伙伴/service服务商/staff内部员工'' AFTER `promoter_name`', 'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 校验:
SHOW COLUMNS FROM yz_platform_payment_order LIKE 'promoter%';
@@ -0,0 +1,73 @@
-- =============================================================
-- 平台端推广佣金相关表
-- yz_platform_commission_rule 佣金规则(按比例 / 固定金额 / 阶梯)
-- yz_platform_commission_order 佣金单(台账,含发放登记)
--
-- 业务约定:
-- 1. 佣金由平台自有资金支出,与租户收款资金流解耦,不参与分账;
-- 2. 佣金单在「业务订单支付成功」事件上按启用中的规则生成,状态先为 payable(应付);
-- 3. 一笔支付单 + 同一推广方只应生成一张佣金单(uk_pay_promoter),
-- 但 MySQL 唯一索引对 NULL 不去重(delete_time),业务层仍需按 pay_no 查重后再插入;
-- 4. 金额一律以「分」为单位的 bigint 存储;
-- 5. 规则修改只影响之后生成的佣金单,历史佣金单保留规则快照字段(rule_name/calc_type)。
--
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_platform_commission.sql
-- =============================================================
-- -------------------------------------------------------------
-- 1. 佣金规则表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_commission_rule` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '规则名称,如「渠道推广-平台使用费 5%」',
`calc_type` varchar(20) NOT NULL DEFAULT 'percent' COMMENT '计算方式:percent按比例/fixed固定金额/tiered阶梯',
`value` bigint(20) NOT NULL DEFAULT '0' COMMENT '规则值:percent=百分比×100(5%存500,支持2.5%存250);fixed=固定金额(分);tiered=0且明细见 tier_json',
`tier_json` text COMMENT '阶梯配置JSON:[{"min":0,"max":1000000,"rate":300}];金额单位为分,max 为 null 表示不限,rate 同为百分比×100',
`order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '适用订单类型:all全部/platform_usage平台使用费/module_shop模块购买/service_fee服务费',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用(停用后新佣金单不再匹配该规则,历史佣金单不受影响)',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人ID',
`create_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '创建人姓名',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`,`order_type`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金规则表';
-- -------------------------------------------------------------
-- 2. 佣金单表(台账)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_commission_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`commission_no` varchar(40) NOT NULL DEFAULT '' COMMENT '佣金单号,形如 CM202609150001',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号(yz_platform_payment_order.pay_no)',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号',
`rule_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '命中的佣金规则ID',
`rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '命中规则名称快照',
`calc_type` varchar(20) NOT NULL DEFAULT '' COMMENT '计算方式快照:percent/fixed/tiered',
`promoter_id` varchar(64) NOT NULL DEFAULT '' COMMENT '推广方ID(渠道伙伴 / 服务商 / 内部员工)',
`promoter_name` varchar(128) NOT NULL DEFAULT '' COMMENT '推广方名称',
`promoter_type` varchar(32) NOT NULL DEFAULT '' COMMENT '推广方类型:channel渠道伙伴/service服务商/staff内部员工',
`base_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '计佣基数(分)= 业务订单实付金额',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '佣金金额(分)',
`status` varchar(20) NOT NULL DEFAULT 'payable' COMMENT '佣金状态:payable应付/settled已付/tax_processing税务处理中/canceled已作废',
`pay_method` varchar(20) NOT NULL DEFAULT '' COMMENT '发放方式:bank银行转账/balance余额抵扣/manual人工发放/offset下期抵扣',
`pay_voucher` varchar(255) NOT NULL DEFAULT '' COMMENT '发放凭证 / 银行流水号',
`paid_at` datetime DEFAULT NULL COMMENT '发放时间',
`paid_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '登记发放人ID',
`paid_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '登记发放人姓名',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_commission_no` (`commission_no`,`delete_time`),
UNIQUE KEY `uk_pay_promoter` (`pay_no`,`promoter_id`,`delete_time`),
KEY `idx_out_trade_no` (`out_trade_no`),
KEY `idx_status` (`status`,`create_time`),
KEY `idx_promoter` (`promoter_id`,`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金单表(台账)';
+223
View File
@@ -0,0 +1,223 @@
-- =============================================================
-- 平台端支付模块相关表(平台向租户收取平台使用费)
-- yz_platform_payment_channel 渠道配置(一渠道一行,敏感参数加密后存 JSON)
-- yz_platform_payment_order 支付单
-- yz_platform_payment_order_log 支付单状态流转流水
-- yz_platform_payment_callback_log 渠道回调日志(幂等去重 + 问题排查)
-- yz_platform_payment_channel_bill 渠道账单明细(对账源数据)
-- yz_platform_payment_reconcile_diff 对账差异
-- yz_platform_payment_refund 手动原路退回记录(兜底能力)
--
-- 设计约定(与业务约定一致):
-- 1. 资金流向:租户付款直接进平台对公账户,不涉及分账;仅保留后台「手动原路退回」兜底;
-- 2. 金额一律用「分」为单位的 bigint 存储,前端展示时 /100 转元;
-- 3. 状态类字段用 varchar 存英文枚举,前端 StatusTag 直接映射,避免数字魔法值;
-- 4. 渠道密钥 / 私钥 / ClientSecret 不入库明文,只存 config_json(写入前由后端加密);
-- 证书本体存文件(object_key),库内只留元信息(cert_json);
-- 5. 表结构与项目其他表一致:软删除 delete_time、create_time/update_time 自动维护;
-- 6. 唯一键带上 delete_time 是本项目约定(软删除后可重新插入同键数据);
-- 注意 MySQL 唯一索引对 NULL 不去重,因此「支付幂等/回调去重」仍必须在业务层
-- 用「按 pay_no 查重 + 条件更新(WHERE status <> 'paid')」兜底,不能只依赖唯一键。
-- 7. 业务订单字段(业务订单状态、订单摘要等)由业务模块持有,本模块只冗余订单号与金额,
-- 详情接口聚合返回,不在此重复落库。
--
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_platform_payment.sql
-- =============================================================
-- -------------------------------------------------------------
-- 1. 支付渠道配置表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识:wechat/alipay/unionpay/cloudpay/paypal',
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道名称(展示用)',
`merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '商户号(展示用,未配置时前端显示未配置)',
`config_json` text COMMENT '渠道参数JSON(含密钥/私钥/Secret,写入前加密;接口回显敏感字段时只回掩码)',
`cert_json` text COMMENT '证书元信息JSON:文件名、object_key、序列号、到期日(不含证书私钥内容)',
`callback_url` varchar(255) NOT NULL DEFAULT '' COMMENT '回调地址(只读):/api/payment/callback/{channel}',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用:1启用/0停用(参数不完整时前端展示为未配置)',
`extra_json` text COMMENT '渠道扩展开关JSON:云闪付 show_logo/logo_positions、PayPal env 等',
`last_test_time` datetime DEFAULT NULL COMMENT '最近一次「测试连接」时间',
`last_test_result` varchar(255) NOT NULL DEFAULT '' COMMENT '最近一次测试连接结果说明',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel` (`channel`,`delete_time`),
KEY `idx_enabled` (`enabled`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道配置表';
-- -------------------------------------------------------------
-- 2. 支付单表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(支付模块生成,形如 P202609150009)',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号(业务模块生成;同一订单可有多笔支付单)',
`order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '订单类型:platform_usage平台使用费/module_shop模块购买/service_fee服务费',
`subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题(渠道侧展示文案)',
`tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID',
`tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(冗余,便于列表展示)',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '实付金额(分)',
`order_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '业务订单金额(分)',
`currency` varchar(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道标识',
`merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '下单时商户号快照',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号',
`status` varchar(20) NOT NULL DEFAULT 'created' COMMENT '状态:created/pending/paying/paid/failed/closed/refunding/refunded',
`refund_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '已退回金额(分),仅兜底手动退回时累加',
`client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '下单客户端IP',
`return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付完成同步跳回地址(由下单参数带入)',
`notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道回调地址快照',
`expire_at` datetime DEFAULT NULL COMMENT '支付超时时间(过期由补偿任务置为 closed)',
`paid_at` datetime DEFAULT NULL COMMENT '支付完成时间',
`closed_at` datetime DEFAULT NULL COMMENT '关闭时间',
`notify_at` datetime DEFAULT NULL COMMENT '最近一次成功回调时间',
`last_query_at` datetime DEFAULT NULL COMMENT '最近一次主动查询渠道状态时间',
`order_synced` tinyint(1) NOT NULL DEFAULT '0' COMMENT '业务订单状态是否已同步:1已同步/0待同步(补偿任务扫描用)',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pay_no` (`pay_no`,`delete_time`),
KEY `idx_out_trade_no` (`out_trade_no`),
KEY `idx_tenant` (`tenant_id`,`create_time`),
KEY `idx_status` (`status`,`create_time`),
KEY `idx_channel_trade` (`channel`,`channel_trade_no`),
KEY `idx_order_synced` (`order_synced`,`status`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单表';
-- -------------------------------------------------------------
-- 3. 支付单状态流转流水(详情页时间轴数据源)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_order_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(yz_platform_payment_order.pay_no)',
`from_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更前状态(创建时为空)',
`to_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更后状态',
`operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作来源:租户端下单页/渠道回调/平台管理员/定时补偿',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '变更说明(如:支付成功,已同步业务订单为已支付)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_pay_no` (`pay_no`,`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单状态流转流水表';
-- -------------------------------------------------------------
-- 4. 渠道回调日志表(幂等去重与排查)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_callback_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`event_type` varchar(64) NOT NULL DEFAULT '' COMMENT '事件类型:TRANSACTION.SUCCESS/trade.success/PAYMENT.CAPTURE.COMPLETED 等',
`event_id` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道事件ID(微信 event id / 支付宝 notify_id / PayPal event id)',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号',
`trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态原文',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道通知金额(分)',
`verify_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '验签结果:1通过/0失败',
`is_duplicate` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否重复通知:1是(幂等命中,直接返回渠道要求的成功响应)/0否',
`handle_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '处理结果:1成功/0失败',
`handle_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '处理说明或错误信息',
`raw_body` mediumtext COMMENT '渠道原始报文(脱敏后保留,详情页「渠道回调原文」数据源)',
`client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源IP',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_event` (`channel`,`channel_trade_no`,`event_type`,`delete_time`),
KEY `idx_pay_no` (`pay_no`),
KEY `idx_event_id` (`event_id`),
KEY `idx_create_time` (`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道回调日志表(幂等去重)';
-- -------------------------------------------------------------
-- 5. 渠道账单明细表(对账源数据)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel_bill` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`bill_date` date NOT NULL COMMENT '账单日期(渠道账单自然日)',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道侧商户订单号(对应业务订单号)',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道账单金额(分)',
`fee` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道手续费(分)',
`trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态',
`trade_time` datetime DEFAULT NULL COMMENT '渠道交易时间',
`is_matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已与本地支付单匹配:1是/0否',
`matched_pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '匹配到的本地支付单号',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_trade` (`channel`,`channel_trade_no`,`delete_time`),
KEY `idx_bill_date` (`channel`,`bill_date`,`is_matched`),
KEY `idx_out_trade_no` (`out_trade_no`,`bill_date`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道账单明细表(对账源数据)';
-- -------------------------------------------------------------
-- 6. 对账差异表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_reconcile_diff` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`bill_date` date NOT NULL COMMENT '对账日期(同渠道账单日)',
`batch_no` varchar(40) NOT NULL DEFAULT '' COMMENT '对账批次号(同渠道同日一次对账一个批次,便于追溯与重跑)',
`local_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '本地支付单号(长款时为空串,不写 NULL 以便参与唯一键)',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号(短款时为空串)',
`local_amount` bigint(20) DEFAULT NULL COMMENT '本地金额(分),无本地记录时为 NULL',
`channel_amount` bigint(20) DEFAULT NULL COMMENT '渠道金额(分),无渠道记录时为 NULL',
`diff_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '差额(分)= 渠道金额 - 本地金额,负数表示短款',
`diff_type` varchar(20) NOT NULL DEFAULT '' COMMENT '差异类型:long长款(渠道有本地无)/short短款(本地有渠道无)/status_mismatch状态不一致',
`handle_status` varchar(20) NOT NULL DEFAULT 'unhandled' COMMENT '处理状态:unhandled待处理/handling处理中/handled已处理/ignored已忽略',
`handle_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '处理人ID',
`handle_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '处理人姓名',
`handle_time` datetime DEFAULT NULL COMMENT '处理时间',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '处理说明(人工填写,便于日后追溯)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_diff` (`channel`,`bill_date`,`diff_type`,`channel_trade_no`,`local_trade_no`,`delete_time`),
KEY `idx_channel_date` (`channel`,`bill_date`),
KEY `idx_handle` (`handle_status`,`bill_date`),
KEY `idx_batch_no` (`batch_no`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付对账差异表';
-- -------------------------------------------------------------
-- 7. 手动原路退回记录表(兜底能力,业务上不提供退款入口)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_refund` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`refund_no` varchar(40) NOT NULL DEFAULT '' COMMENT '退回单号(支付模块生成)',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`channel_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道退回流水号',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '退回金额(分)',
`reason` varchar(500) NOT NULL DEFAULT '' COMMENT '退回原因(必填,人工填写,用于审计)',
`status` varchar(20) NOT NULL DEFAULT 'processing' COMMENT '退回状态:processing处理中/success成功/failed失败',
`fail_reason` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因',
`operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人ID(平台管理员)',
`operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名',
`finished_at` datetime DEFAULT NULL COMMENT '退回完成时间',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_refund_no` (`refund_no`,`delete_time`),
KEY `idx_pay_no` (`pay_no`),
KEY `idx_status` (`status`,`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付手动原路退回记录表(兜底能力)';
@@ -0,0 +1,39 @@
-- =============================================================
-- 平台支付渠道:初始化五个渠道配置行
-- 表:yz_platform_payment_channel
-- 说明:
-- 五个渠道(微信支付 / 支付宝 / 银联 / 云闪付 / PayPal)与前端
-- platform/src/views/system/payment/channels/ 一一对应。
-- 这里只插入「渠道骨架」:名称、回调地址、默认开关,商户号与密钥留空,
-- 由平台管理员在渠道配置页填写(敏感参数写入 config_json 前由后端加密)。
-- 初始 enabled 一律为 0(停用),配置完成并通过「测试连接」后再由页面启用,
-- 前端会按「enabled=1 → 已启用;enabled=0 且无参数 → 未配置;enabled=0 且有参数 → 已停用」展示状态。
-- 云闪付复用银联商户参数,仅存收银台标识开关(extra_json.show_logo / logo_positions);
-- PayPal 默认沙箱环境(extra_json.env=sandbox),当前业务不跨境,保持停用。
-- 本脚本幂等:已存在同名渠道(含已软删)则跳过,可重复执行。
-- 依赖:先执行 create_platform_payment.sql 建表。
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < init_platform_payment_channel.sql
-- =============================================================
INSERT INTO `yz_platform_payment_channel`
(`channel`, `name`, `merchant_no`, `config_json`, `cert_json`, `callback_url`, `enabled`, `extra_json`, `remark`, `create_time`, `update_time`)
SELECT v.`channel`, v.`name`, '', NULL, NULL,
CONCAT('/api/payment/callback/', v.`channel`), 0, v.`extra_json`, v.`remark`, NOW(), NOW()
FROM (
SELECT 'wechat' AS `channel`, '微信支付' AS `name`, '' AS `extra_json`,
'主渠道:JSAPI / Native 扫码收款,需商户号、APPID、APIv3 密钥与商户证书' AS `remark`
UNION ALL SELECT 'alipay', '支付宝', '',
'电脑网站支付 / 手机网站支付,RSA2 签名,异步通知无需登记'
UNION ALL SELECT 'unionpay', '银联', '',
'全渠道收单,商户号与证书由收单机构下发,回调地址需向收单机构报备'
UNION ALL SELECT 'cloudpay', '云闪付', '{"show_logo":1,"logo_positions":["pc","h5"]}',
'复用银联商户参数,仅控制收银台是否展示云闪付标识'
UNION ALL SELECT 'paypal', 'PayPal', '{"env":"sandbox"}',
'跨境预留渠道,当前业务不跨境,默认停用'
) v
WHERE NOT EXISTS (
SELECT 1 FROM `yz_platform_payment_channel` c WHERE c.`channel` = v.`channel`
);
-- 校验:SELECT id, channel, name, merchant_no, enabled, callback_url, delete_time
-- FROM yz_platform_payment_channel ORDER BY id;
+14 -7
View File
@@ -6,19 +6,23 @@ require (
github.com/beego/beego/v2 v2.1.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/qiniu/go-sdk/v7 v7.18.2
golang.org/x/crypto v0.1.0
golang.org/x/crypto v0.38.0
)
require (
github.com/go-sql-driver/mysql v1.7.0
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.33.0
golang.org/x/net v0.7.0
github.com/plutov/paypal/v4 v4.12.0
github.com/smartwalle/alipay/v3 v3.2.31
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
golang.org/x/net v0.40.0
software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
@@ -30,12 +34,15 @@ require (
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.1.2 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+35 -13
View File
@@ -1,9 +1,11 @@
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/beego/beego/v2 v2.1.0 h1:Lk0FtQGvDQCx5V5yEu4XwDsIgt+QOlNjt5emUa3/ZmA=
github.com/beego/beego/v2 v2.1.0/go.mod h1:6h36ISpaxNrrpJ27siTpXBG8d/Icjzsc7pU1bWpp0EE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -57,6 +59,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/plutov/paypal/v4 v4.12.0 h1:tp6bClhe9qt3IgN7arT8RiLgDe+LPSvXA67wU+8tu5Q=
github.com/plutov/paypal/v4 v4.12.0/go.mod h1:9K/agLFwXpz5Tpuc3aNxPvzIdUo6BPL7pf5+x4ITOug=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
@@ -77,32 +81,48 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
github.com/smartwalle/alipay/v3 v3.2.31 h1:KVwWJZ5JvNxPridHrcpTwgJeMzi1IqZopid0sDJQewU=
github.com/smartwalle/alipay/v3 v3.2.31/go.mod h1:0G9wqvo1719hxo6ZWntujuPNyVQFWikS52LF62rRYDg=
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
github.com/smartwalle/ngx v1.1.2 h1:W+K262lHUvdfJ/2e762dIFwN543kxC/4qLkuCQLRS8o=
github.com/smartwalle/ngx v1.1.2/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -110,8 +130,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -121,8 +141,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -130,8 +150,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -141,3 +161,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+4
View File
@@ -4,6 +4,7 @@ import (
"server/models"
_ "server/routers"
"server/services"
"server/services/payment"
"server/version"
beego "github.com/beego/beego/v2/server/web"
@@ -33,5 +34,8 @@ func main() {
// 启动回款逾期联动任务(回款逾期 → 合同「异常」→ 关联项目「异常」)
services.StartPaybackOverdueScheduler(make(chan struct{}))
// 启动支付补偿任务(超时关单 / 业务订单同步重试,5 分钟一轮)
payment.StartPaymentScheduler(make(chan struct{}))
beego.Run()
}
+13
View File
@@ -137,6 +137,19 @@ func Init(_ string) {
new(BackendOaPayrollItem),
new(BackendEmployeeFile),
new(BackendEmployeeFileRecord),
// 平台端支付模块(docs/sql/create_platform_payment.sql)
new(PlatformPaymentChannel),
new(PlatformPaymentOrder),
new(PlatformPaymentOrderLog),
new(PlatformPaymentCallbackLog),
new(PlatformPaymentChannelBill),
new(PlatformPaymentReconcileDiff),
new(PlatformPaymentRefund),
// 平台端推广佣金(docs/sql/create_platform_commission.sql)
new(PlatformCommissionRule),
new(PlatformCommissionOrder),
)
// 创建全局 Ormer
+96
View File
@@ -0,0 +1,96 @@
package models
import "time"
// =============================================================
// 平台端推广佣金模型(表结构见 docs/sql/create_platform_commission.sql)
// 业务约定:
// 1. 佣金由平台自有资金支出,与租户收款资金流解耦;
// 2. 佣金在「业务订单支付成功」事件上按启用中的规则生成,初始状态 payable;
// 3. 一笔支付单 + 同一推广方只生成一张佣金单(uk_pay_promoter,业务层再按 pay_no 查重)。
// =============================================================
// PlatformCommissionRule 佣金规则 yz_platform_commission_rule
type PlatformCommissionRule struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
RuleName string `orm:"column(rule_name);size(128)" json:"rule_name"`
CalcType string `orm:"column(calc_type);size(20)" json:"calc_type"`
Value int64 `orm:"column(value);default(0)" json:"value"`
TierJSON *string `orm:"column(tier_json);type(text);null" json:"-"`
OrderType string `orm:"column(order_type);size(32)" json:"order_type"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateUserName string `orm:"column(create_user_name);size(128)" json:"create_user_name"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformCommissionRule) TableName() string { return "yz_platform_commission_rule" }
// 计算方式
const (
CommissionCalcPercent = "percent" // 按比例:value = 百分比 × 100(5% 存 500)
CommissionCalcFixed = "fixed" // 固定金额:value = 分
CommissionCalcTiered = "tiered" // 阶梯:value 忽略,明细见 tier_json
)
// 适用订单类型
const (
CommissionOrderTypeAll = "all"
CommissionOrderTypePlatformUsage = "platform_usage"
CommissionOrderTypeModuleShop = "module_shop"
CommissionOrderTypeServiceFee = "service_fee"
)
// CommissionTier 阶梯档位(tier_json 数组元素)
type CommissionTier struct {
Min int64 `json:"min"` // 起始金额(分,含)
Max *int64 `json:"max"` // 结束金额(分,不含);null 表示不限
Rate int64 `json:"rate"` // 比例:百分比 × 100
}
// PlatformCommissionOrder 佣金单(台账) yz_platform_commission_order
type PlatformCommissionOrder struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
CommissionNo string `orm:"column(commission_no);size(40)" json:"commission_no"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
RuleID uint64 `orm:"column(rule_id);default(0)" json:"rule_id"`
RuleName string `orm:"column(rule_name);size(128)" json:"rule_name"`
CalcType string `orm:"column(calc_type);size(20)" json:"calc_type"`
PromoterID string `orm:"column(promoter_id);size(64)" json:"promoter_id"`
PromoterName string `orm:"column(promoter_name);size(128)" json:"promoter_name"`
PromoterType string `orm:"column(promoter_type);size(32)" json:"promoter_type"`
BaseAmount int64 `orm:"column(base_amount);default(0)" json:"base_amount"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
Status string `orm:"column(status);size(20)" json:"status"`
PayMethod string `orm:"column(pay_method);size(20)" json:"pay_method"`
PayVoucher string `orm:"column(pay_voucher);size(255)" json:"pay_voucher"`
PaidAt *time.Time `orm:"column(paid_at);type(datetime);null" json:"paid_at"`
PaidUserID string `orm:"column(paid_user_id);size(64)" json:"paid_user_id"`
PaidUserName string `orm:"column(paid_user_name);size(128)" json:"paid_user_name"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformCommissionOrder) TableName() string { return "yz_platform_commission_order" }
// 佣金状态
const (
CommissionStatusPayable = "payable" // 应付
CommissionStatusSettled = "settled" // 已付
CommissionStatusTaxProcessing = "tax_processing" // 税务处理中
CommissionStatusCanceled = "canceled" // 已作废
)
// 发放方式
const (
CommissionPayBank = "bank" // 银行转账
CommissionPayBalance = "balance" // 余额抵扣
CommissionPayManual = "manual" // 人工发放
CommissionPayOffset = "offset" // 下期抵扣
)
+214
View File
@@ -0,0 +1,214 @@
package models
import "time"
// =============================================================
// 平台端支付模块模型(表结构见 docs/sql/create_platform_payment.sql)
// 约定:
// 1. 金额字段一律 int64,单位「分」;
// 2. 状态字段存英文枚举字符串(前端 StatusTag 直接映射);
// 3. 渠道密钥类参数不落明文,config_json 存 AES-GCM 密文(见 services/payment/crypto.go);
// 4. 软删除统一用 delete_time,查询时 Filter("delete_time__isnull", true)。
// =============================================================
// PlatformPaymentChannel 平台支付渠道配置 yz_platform_payment_channel
type PlatformPaymentChannel struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
Name string `orm:"column(name);size(64)" json:"name"`
MerchantNo string `orm:"column(merchant_no);size(128)" json:"merchant_no"`
ConfigJSON *string `orm:"column(config_json);type(text);null" json:"-"`
CertJSON *string `orm:"column(cert_json);type(text);null" json:"-"`
CallbackURL string `orm:"column(callback_url);size(255)" json:"callback_url"`
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
ExtraJSON *string `orm:"column(extra_json);type(text);null" json:"-"`
LastTestTime *time.Time `orm:"column(last_test_time);type(datetime);null" json:"last_test_time"`
LastTestResult string `orm:"column(last_test_result);size(255)" json:"last_test_result"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentChannel) TableName() string { return "yz_platform_payment_channel" }
// PlatformPaymentOrder 平台支付单 yz_platform_payment_order
type PlatformPaymentOrder struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
OrderType string `orm:"column(order_type);size(32)" json:"order_type"`
Subject string `orm:"column(subject);size(255)" json:"subject"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
TenantName string `orm:"column(tenant_name);size(128)" json:"tenant_name"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
OrderAmount int64 `orm:"column(order_amount);default(0)" json:"order_amount"`
Currency string `orm:"column(currency);size(8)" json:"currency"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
MerchantNo string `orm:"column(merchant_no);size(128)" json:"merchant_no"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
Status string `orm:"column(status);size(20)" json:"status"`
RefundAmount int64 `orm:"column(refund_amount);default(0)" json:"refund_amount"`
ClientIP string `orm:"column(client_ip);size(64)" json:"client_ip"`
ReturnURL string `orm:"column(return_url);size(255)" json:"return_url"`
NotifyURL string `orm:"column(notify_url);size(255)" json:"notify_url"`
ExpireAt *time.Time `orm:"column(expire_at);type(datetime);null" json:"expire_at"`
PaidAt *time.Time `orm:"column(paid_at);type(datetime);null" json:"paid_at"`
ClosedAt *time.Time `orm:"column(closed_at);type(datetime);null" json:"closed_at"`
NotifyAt *time.Time `orm:"column(notify_at);type(datetime);null" json:"notify_at"`
LastQueryAt *time.Time `orm:"column(last_query_at);type(datetime);null" json:"last_query_at"`
OrderSynced int8 `orm:"column(order_synced);default(0)" json:"order_synced"`
PromoterID string `orm:"column(promoter_id);size(64)" json:"promoter_id"`
PromoterName string `orm:"column(promoter_name);size(128)" json:"promoter_name"`
PromoterType string `orm:"column(promoter_type);size(32)" json:"promoter_type"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentOrder) TableName() string { return "yz_platform_payment_order" }
// 支付单状态枚举
const (
PayStatusCreated = "created" // 已创建
PayStatusPending = "pending" // 待支付
PayStatusPaying = "paying" // 支付中
PayStatusPaid = "paid" // 支付成功
PayStatusFailed = "failed" // 支付失败
PayStatusClosed = "closed" // 已关闭
PayStatusRefunding = "refunding" // 退回中
PayStatusRefunded = "refunded" // 已退回
)
// PlatformPaymentOrderLog 支付单状态流转流水 yz_platform_payment_order_log
type PlatformPaymentOrderLog struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
FromStatus string `orm:"column(from_status);size(20)" json:"from_status"`
ToStatus string `orm:"column(to_status);size(20)" json:"to_status"`
Operator string `orm:"column(operator);size(64)" json:"operator"`
Remark string `orm:"column(remark);size(500)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentOrderLog) TableName() string { return "yz_platform_payment_order_log" }
// PlatformPaymentCallbackLog 渠道回调日志 yz_platform_payment_callback_log
type PlatformPaymentCallbackLog struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
EventType string `orm:"column(event_type);size(64)" json:"event_type"`
EventID string `orm:"column(event_id);size(128)" json:"event_id"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
TradeState string `orm:"column(trade_state);size(32)" json:"trade_state"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
VerifyResult int8 `orm:"column(verify_result);default(0)" json:"verify_result"`
IsDuplicate int8 `orm:"column(is_duplicate);default(0)" json:"is_duplicate"`
HandleResult int8 `orm:"column(handle_result);default(0)" json:"handle_result"`
HandleMsg string `orm:"column(handle_msg);size(255)" json:"handle_msg"`
RawBody *string `orm:"column(raw_body);type(text);null" json:"raw_body"`
ClientIP string `orm:"column(client_ip);size(64)" json:"client_ip"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentCallbackLog) TableName() string {
return "yz_platform_payment_callback_log"
}
// PlatformPaymentChannelBill 渠道账单明细 yz_platform_payment_channel_bill
type PlatformPaymentChannelBill struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
BillDate time.Time `orm:"column(bill_date);type(date)" json:"bill_date"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
Fee int64 `orm:"column(fee);default(0)" json:"fee"`
TradeState string `orm:"column(trade_state);size(32)" json:"trade_state"`
TradeTime *time.Time `orm:"column(trade_time);type(datetime);null" json:"trade_time"`
IsMatched int8 `orm:"column(is_matched);default(0)" json:"is_matched"`
MatchedPayNo string `orm:"column(matched_pay_no);size(40)" json:"matched_pay_no"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentChannelBill) TableName() string {
return "yz_platform_payment_channel_bill"
}
// PlatformPaymentReconcileDiff 对账差异 yz_platform_payment_reconcile_diff
type PlatformPaymentReconcileDiff struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
BillDate time.Time `orm:"column(bill_date);type(date)" json:"bill_date"`
BatchNo string `orm:"column(batch_no);size(40)" json:"batch_no"`
LocalTradeNo string `orm:"column(local_trade_no);size(64)" json:"local_trade_no"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
LocalAmount *int64 `orm:"column(local_amount);null" json:"local_amount"`
ChannelAmount *int64 `orm:"column(channel_amount);null" json:"channel_amount"`
DiffAmount int64 `orm:"column(diff_amount);default(0)" json:"diff_amount"`
DiffType string `orm:"column(diff_type);size(20)" json:"diff_type"`
HandleStatus string `orm:"column(handle_status);size(20)" json:"handle_status"`
HandleUserID string `orm:"column(handle_user_id);size(64)" json:"handle_user_id"`
HandleUserName string `orm:"column(handle_user_name);size(128)" json:"handle_user_name"`
HandleTime *time.Time `orm:"column(handle_time);type(datetime);null" json:"handle_time"`
Remark string `orm:"column(remark);size(500)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentReconcileDiff) TableName() string {
return "yz_platform_payment_reconcile_diff"
}
// 对账差异类型 / 处理状态
const (
DiffTypeLong = "long" // 长款:渠道有、本地无
DiffTypeShort = "short" // 短款:本地有、渠道无
DiffTypeStatusMismatch = "status_mismatch" // 状态不一致
)
const (
DiffHandleUnhandled = "unhandled"
DiffHandleHandling = "handling"
DiffHandleHandled = "handled"
DiffHandleIgnored = "ignored"
)
// PlatformPaymentRefund 手动原路退回记录 yz_platform_payment_refund
type PlatformPaymentRefund struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
RefundNo string `orm:"column(refund_no);size(40)" json:"refund_no"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
ChannelRefundNo string `orm:"column(channel_refund_no);size(64)" json:"channel_refund_no"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
Reason string `orm:"column(reason);size(500)" json:"reason"`
Status string `orm:"column(status);size(20)" json:"status"`
FailReason string `orm:"column(fail_reason);size(500)" json:"fail_reason"`
OperatorID string `orm:"column(operator_id);size(64)" json:"operator_id"`
OperatorName string `orm:"column(operator_name);size(128)" json:"operator_name"`
FinishedAt *time.Time `orm:"column(finished_at);type(datetime);null" json:"finished_at"`
Remark string `orm:"column(remark);size(500)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentRefund) TableName() string { return "yz_platform_payment_refund" }
// 退回状态
const (
RefundStatusProcessing = "processing"
RefundStatusSuccess = "success"
RefundStatusFailed = "failed"
)
+8
View File
@@ -11,6 +11,14 @@ import (
func Register() {
RegisterAuthRoutes()
RegisterRoleRoutes()
RegisterPaymentRoutes()
}
// RegisterPaymentRoutes 注册租户端支付路由(下单页 / 收银台)。
func RegisterPaymentRoutes() {
beego.Router("/backend/payment/channels", &controllers.BackendPaymentController{}, "get:Channels")
beego.Router("/backend/payment/create", &controllers.BackendPaymentController{}, "post:Create")
beego.Router("/backend/payment/query/:payNo", &controllers.BackendPaymentController{}, "get:Query")
}
// RegisterRoleRoutes 注册 backend 租户角色管理路由。
+42
View File
@@ -0,0 +1,42 @@
package platform
import (
"server/controllers"
beego "github.com/beego/beego/v2/server/web"
)
// RegisterPaymentRoutes 注册平台端支付模块路由(表结构见 docs/sql/create_platform_payment.sql)。
// 注意:/reconcile/handle/:id、/commission/markPaid/:id 刻意避开与静态段同级,
// 避免 beego 路由树中通配段与静态段混用带来的匹配歧义。
func RegisterPaymentRoutes() {
// 渠道配置
beego.Router("/platform/payment/channels", &controllers.PlatformPaymentController{}, "get:ListChannels")
beego.Router("/platform/payment/channels/:channel", &controllers.PlatformPaymentController{}, "get:GetChannel;post:SaveChannel")
beego.Router("/platform/payment/channels/:channel/test", &controllers.PlatformPaymentController{}, "post:TestChannel")
beego.Router("/platform/payment/channels/:channel/status", &controllers.PlatformPaymentController{}, "post:ToggleChannel")
beego.Router("/platform/payment/channels/:channel/certificate", &controllers.PlatformPaymentController{}, "post:UploadCertificate")
// 支付订单
beego.Router("/platform/payment/orders", &controllers.PlatformPaymentController{}, "get:ListOrders")
beego.Router("/platform/payment/orders/:payNo", &controllers.PlatformPaymentController{}, "get:OrderDetail")
beego.Router("/platform/payment/orders/:payNo/query", &controllers.PlatformPaymentController{}, "post:QueryOrder")
beego.Router("/platform/payment/refund", &controllers.PlatformPaymentController{}, "post:Refund")
// 对账
beego.Router("/platform/payment/reconcile", &controllers.PlatformPaymentReconcileController{}, "get:ListReconcile")
beego.Router("/platform/payment/reconcile/run", &controllers.PlatformPaymentReconcileController{}, "post:RunReconcile")
beego.Router("/platform/payment/reconcile/import", &controllers.PlatformPaymentReconcileController{}, "post:ImportBill")
beego.Router("/platform/payment/reconcile/handle/:id", &controllers.PlatformPaymentReconcileController{}, "post:HandleReconcile")
// 渠道异步通知(渠道服务器调用,无平台鉴权;鉴权由各渠道适配器验签完成)
beego.Router("/api/payment/callback/:channel", &controllers.PlatformPaymentController{}, "post:Notify")
// 推广佣金
beego.Router("/platform/commission/list", &controllers.PlatformCommissionController{}, "get:List")
beego.Router("/platform/commission/stats", &controllers.PlatformCommissionController{}, "get:Stats")
beego.Router("/platform/commission/markPaid/:id", &controllers.PlatformCommissionController{}, "post:MarkPaid")
beego.Router("/platform/commission/rules", &controllers.PlatformCommissionController{}, "get:RulesList;post:RuleCreate")
beego.Router("/platform/commission/rules/:id", &controllers.PlatformCommissionController{}, "post:RuleUpdate;delete:RuleDelete")
beego.Router("/platform/commission/rules/:id/status", &controllers.PlatformCommissionController{}, "post:RuleToggle")
}
+3
View File
@@ -8,6 +8,9 @@ import (
// Register 注册平台端路由
func Register() {
// 支付模块(渠道配置 / 支付订单 / 对账 / 佣金 / 渠道回调)
RegisterPaymentRoutes()
// 平台登录相关
beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform")
beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser")
+261
View File
@@ -0,0 +1,261 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 渠道适配器接口与运行时配置
//
// 五个渠道的差异全部收敛在各 Channel 实现里(wechat/alipay/unionpay/cloudpay/paypal),
// 支付服务(service.go)只面向本接口编程,业务页面不出现渠道 if-else。
// =============================================================
// 渠道标识(与 yz_platform_payment_channel.channel、前端页面一一对应)
const (
ChannelWechat = "wechat"
ChannelAlipay = "alipay"
ChannelUnionPay = "unionpay"
ChannelCloudPay = "cloudpay"
ChannelPayPal = "paypal"
)
// 归一化交易状态(各渠道状态统一映射到这几个值)
const (
StateSuccess = "success"
StatePending = "pending"
StateFailed = "failed"
StateClosed = "closed"
StateRefunded = "refunded"
)
// 预下单方式
const (
PayTypeQR = "qr" // 扫码(微信 Native)
PayTypeWeb = "web" // PC 网页跳转(支付宝电脑网站支付 / 银联网关 / PayPal)
PayTypeH5 = "h5" // 手机网页跳转
PayTypeJSAPI = "jsapi" // 公众号内支付(需要 OpenID)
)
var (
ErrChannelNotConfigured = errors.New("渠道未配置或参数不完整")
ErrChannelDisabled = errors.New("渠道已停用")
ErrChannelNotFound = errors.New("渠道不存在")
)
// PayParams 渠道下单后返回给前端的拉起支付参数
type PayParams struct {
Channel string `json:"channel"`
PayType string `json:"pay_type"`
CodeURL string `json:"code_url,omitempty"` // 扫码:二维码内容
RedirectURL string `json:"redirect_url,omitempty"` // 跳转:直接 302 或前端 window.open
Form string `json:"form,omitempty"` // 表单:写入页面后自动提交(银联前台跳转等)
JSAPI map[string]string `json:"jsapi,omitempty"` // 公众号内:WeixinJSBridge 调用参数
ChannelTradeNo string `json:"channel_trade_no,omitempty"` // 渠道侧单号(如有)
}
// ChannelState 渠道侧交易状态(已归一化)
type ChannelState struct {
ChannelTradeNo string
TradeState string // StateSuccess / StatePending / StateFailed / StateClosed
Amount int64 // 分
PaidAt *time.Time // 渠道侧支付完成时间
Raw string // 渠道原始响应(排查用)
}
// NotifyResult 渠道异步通知解析结果(已验签、已解密)
type NotifyResult struct {
PayNo string
OutTradeNo string
ChannelTradeNo string
EventType string // 渠道事件类型原文
EventID string // 渠道事件/通知 ID(用于幂等去重)
TradeState string // 归一化状态
Amount int64 // 分
Paid bool // 是否支付成功事件
AckBody string // 需要原样返回给渠道的响应体(SUCCESS / success / ok / "")
Raw string // 通知报文(脱敏后入库)
}
// PrepayOption 预下单附加参数
type PrepayOption struct {
PayType string // PayTypeQR / PayTypeWeb / PayTypeH5 / PayTypeJSAPI;空值由适配器给默认
OpenID string // JSAPI 支付必填
}
// ChannelConfig 渠道运行时配置(敏感参数已解密)
type ChannelConfig struct {
ID uint64
Channel string
Name string
MerchantNo string
Enabled bool
CallbackURL string
Params map[string]string // 解密后的渠道参数
Extra map[string]any // 渠道扩展开关(云闪付标识、PayPal 环境等)
CertPaths map[string]string // 证书文件路径(cert_json)
}
// Get 读取渠道参数
func (c *ChannelConfig) Get(key string) string {
if c == nil || c.Params == nil {
return ""
}
return strings.TrimSpace(c.Params[key])
}
// ExtraBool 读取扩展开关
func (c *ChannelConfig) ExtraBool(key string, def bool) bool {
if c == nil || c.Extra == nil {
return def
}
if v, ok := c.Extra[key].(bool); ok {
return v
}
return def
}
// EnsureUsable 校验配置可用(已启用且参数非空)
func (c *ChannelConfig) EnsureUsable() error {
if c == nil || len(c.Params) == 0 {
return ErrChannelNotConfigured
}
if !c.Enabled {
return ErrChannelDisabled
}
return nil
}
// Channel 渠道适配器接口
type Channel interface {
Code() string
Name() string
// Prepay 预下单,返回拉起支付参数
Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error)
// Query 主动查询渠道侧交易状态
Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error)
// ParseNotify 解析并验签渠道异步通知
ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error)
// Refund 原路退回(兜底能力),返回渠道退回流水号
Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error)
// TestConnect 连通性/凭证校验,返回人类可读的结果说明
TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error)
}
var (
registryMu sync.RWMutex
registry = map[string]Channel{}
)
// Register 注册渠道适配器
func Register(ch Channel) {
registryMu.Lock()
defer registryMu.Unlock()
registry[ch.Code()] = ch
}
// GetChannelAdapter 取渠道适配器
func GetChannelAdapter(code string) (Channel, error) {
registryMu.RLock()
defer registryMu.RUnlock()
ch, ok := registry[code]
if !ok {
return nil, ErrChannelNotFound
}
return ch, nil
}
// Channels 返回全部已注册适配器
func Channels() []Channel {
registryMu.RLock()
defer registryMu.RUnlock()
list := make([]Channel, 0, len(registry))
for _, ch := range registry {
list = append(list, ch)
}
return list
}
// LoadChannelConfig 从库加载渠道配置(敏感参数解密)
func LoadChannelConfig(channel string) (*ChannelConfig, error) {
row := &models.PlatformPaymentChannel{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Filter("delete_time__isnull", true).
One(row)
if err != nil {
return nil, fmt.Errorf("渠道 %s 配置不存在", channel)
}
cfg := &ChannelConfig{
ID: row.ID,
Channel: row.Channel,
Name: row.Name,
MerchantNo: row.MerchantNo,
Enabled: row.Enabled == 1,
CallbackURL: row.CallbackURL,
Params: map[string]string{},
Extra: map[string]any{},
CertPaths: map[string]string{},
}
if row.ConfigJSON != nil && *row.ConfigJSON != "" {
plain, derr := DecryptConfig(*row.ConfigJSON)
if derr != nil {
return nil, fmt.Errorf("渠道 %s 参数解密失败: %w", channel, derr)
}
_ = json.Unmarshal([]byte(plain), &cfg.Params)
}
if row.ExtraJSON != nil && *row.ExtraJSON != "" {
_ = json.Unmarshal([]byte(*row.ExtraJSON), &cfg.Extra)
}
if row.CertJSON != nil && *row.CertJSON != "" {
_ = json.Unmarshal([]byte(*row.CertJSON), &cfg.CertPaths)
}
return cfg, nil
}
// LoadEnabledChannelConfig 加载「已启用且参数完整」的渠道配置
func LoadEnabledChannelConfig(channel string) (*ChannelConfig, error) {
cfg, err := LoadChannelConfig(channel)
if err != nil {
return nil, err
}
if err := cfg.EnsureUsable(); err != nil {
return nil, err
}
return cfg, nil
}
// PublicBaseURL 对外域名基址:app.conf 的 payment_callback_base(如 https://pay.example.com)
// 未配置时返回空串,回调地址形如 /api/payment/callback/wechat(由网关补齐域名)。
func PublicBaseURL() string {
v, _ := beego.AppConfig.String("payment_callback_base")
return strings.TrimRight(strings.TrimSpace(v), "/")
}
// CallbackURL 渠道回调地址(与渠道配置表 callback_url 保持一致)
func CallbackURL(channel string) string {
if cfg, err := LoadChannelConfig(channel); err == nil && cfg.CallbackURL != "" {
return cfg.CallbackURL
}
return PublicBaseURL() + "/api/payment/callback/" + channel
}
func init() {
Register(&WechatChannel{})
Register(&AlipayChannel{})
Register(&UnionPayChannel{})
Register(&CloudPayChannel{})
Register(&PayPalChannel{})
}
+194
View File
@@ -0,0 +1,194 @@
package payment
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"server/models"
"github.com/smartwalle/alipay/v3"
)
// AlipayChannel 支付宝(电脑网站支付 / 手机网站支付,RSA2)
//
// 渠道参数(config_json):
//
// appid 开放平台应用 APPID
// app_private_key 应用私钥(RSA2,PKCS8)
// alipay_public_key 支付宝公钥(回显公钥,用于验签)
// is_production "1" 正式环境 / 其他为沙箱
type AlipayChannel struct{}
func (c *AlipayChannel) Code() string { return ChannelAlipay }
func (c *AlipayChannel) Name() string { return "支付宝" }
func (c *AlipayChannel) buildClient(cfg *ChannelConfig) (*alipay.Client, error) {
appID := cfg.Get("appid")
priv := cfg.Get("app_private_key")
pub := cfg.Get("alipay_public_key")
if appID == "" || priv == "" || pub == "" {
return nil, fmt.Errorf("支付宝参数不完整:需要 appid / app_private_key / alipay_public_key")
}
client, err := alipay.New(appID, priv, cfg.Get("is_production") == "1")
if err != nil {
return nil, fmt.Errorf("创建支付宝客户端失败: %w", err)
}
if err := client.LoadAliPayPublicKey(pub); err != nil {
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
}
return client, nil
}
func (c *AlipayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
trade := alipay.Trade{
NotifyURL: cfg.CallbackURL,
ReturnURL: order.ReturnURL,
Subject: order.Subject,
OutTradeNo: order.PayNo,
TotalAmount: FenToYuan(order.Amount),
}
if order.ExpireAt != nil {
mins := int(time.Until(*order.ExpireAt).Minutes())
if mins < 1 {
mins = 1
}
trade.TimeoutExpress = fmt.Sprintf("%dm", mins)
}
if strings.EqualFold(opt.PayType, PayTypeH5) {
// 手机网站支付
trade.ProductCode = "QUICK_WAP_WAY"
u, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade})
if err != nil {
return nil, fmt.Errorf("支付宝手机网站下单失败: %w", err)
}
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeH5, RedirectURL: u.String()}, nil
}
// 默认:电脑网站支付(PC 收银台)
trade.ProductCode = "FAST_INSTANT_TRADE_PAY"
u, err := client.TradePagePay(alipay.TradePagePay{Trade: trade})
if err != nil {
return nil, fmt.Errorf("支付宝电脑网站下单失败: %w", err)
}
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeWeb, RedirectURL: u.String()}, nil
}
// normalizeAlipayStatus 支付宝交易状态 -> 归一化状态
func normalizeAlipayStatus(status alipay.TradeStatus) string {
switch status {
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
return StateSuccess
case alipay.TradeStatusClosed:
return StateClosed
case alipay.TradeStatusWaitBuyerPay:
return StatePending
default:
return StatePending
}
}
func (c *AlipayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: order.PayNo})
if err != nil {
return nil, fmt.Errorf("支付宝查询订单失败: %w", err)
}
// 交易不存在(如超时未付被渠道关闭):不视为失败,返回待支付以便走关闭逻辑
if rsp.Code != "10000" {
if rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
return &ChannelState{TradeState: StatePending, Raw: rsp.SubMsg}, nil
}
return nil, fmt.Errorf("支付宝查询失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
}
state := &ChannelState{
ChannelTradeNo: rsp.TradeNo,
TradeState: normalizeAlipayStatus(rsp.TradeStatus),
Raw: fmt.Sprintf("trade_no=%s status=%s total=%s", rsp.TradeNo, rsp.TradeStatus, rsp.TotalAmount),
}
if fen, err := YuanToFen(rsp.TotalAmount); err == nil {
state.Amount = fen
}
if state.TradeState == StateSuccess {
now := time.Now()
state.PaidAt = &now
}
return state, nil
}
func (c *AlipayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("解析支付宝通知参数失败: %w", err)
}
notification, err := client.DecodeNotification(ctx, r.Form)
if err != nil {
return nil, fmt.Errorf("支付宝通知验签失败: %w", err)
}
result := &NotifyResult{
EventType: notification.NotifyType,
EventID: notification.NotifyId,
OutTradeNo: notification.OutTradeNo,
PayNo: notification.OutTradeNo,
ChannelTradeNo: notification.TradeNo,
AckBody: "success",
}
if fen, err := YuanToFen(notification.TotalAmount); err == nil {
result.Amount = fen
}
result.TradeState = normalizeAlipayStatus(notification.TradeStatus)
result.Paid = result.TradeState == StateSuccess
return result, nil
}
func (c *AlipayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
rsp, err := client.TradeRefund(ctx, alipay.TradeRefund{
OutTradeNo: order.PayNo,
RefundAmount: FenToYuan(amount),
RefundReason: reason,
OutRequestNo: refundNo,
})
if err != nil {
return "", fmt.Errorf("支付宝退回请求失败: %w", err)
}
if rsp.Code != "10000" {
return "", fmt.Errorf("支付宝退回失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
}
return rsp.TradeNo, nil
}
func (c *AlipayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
// 查询一笔必然不存在的交易:能返回「交易不存在」即说明 APPID 与签名配置正确
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: "__connect_test__"})
if err != nil {
return "", fmt.Errorf("支付宝凭证校验失败: %w", err)
}
if rsp.Code == "10000" || rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
return "连接成功:APPID 与 RSA2 签名校验通过", nil
}
return "", fmt.Errorf("支付宝凭证校验未通过: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
}
+244
View File
@@ -0,0 +1,244 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"server/models"
"github.com/plutov/paypal/v4"
)
// PayPalChannel PayPal(Checkout v2 + Webhook)
//
// 渠道参数(config_json):
//
// client_id REST App Client ID
// client_secret REST App Secret
// webhook_id Webhook ID(用于验签)
// currency 币种,默认 USD
//
// 扩展(extra_json):
//
// env sandbox / live
type PayPalChannel struct{}
func (c *PayPalChannel) Code() string { return ChannelPayPal }
func (c *PayPalChannel) Name() string { return "PayPal" }
func (c *PayPalChannel) buildClient(cfg *ChannelConfig) (*paypal.Client, error) {
clientID := cfg.Get("client_id")
secret := cfg.Get("client_secret")
if clientID == "" || secret == "" {
return nil, fmt.Errorf("PayPal 参数不完整:需要 client_id / client_secret")
}
apiBase := paypal.APIBaseSandBox
if strings.EqualFold(fmt.Sprint(cfg.Extra["env"]), "live") {
apiBase = paypal.APIBaseLive
}
client, err := paypal.NewClient(clientID, secret, apiBase)
if err != nil {
return nil, fmt.Errorf("创建 PayPal 客户端失败: %w", err)
}
return client, nil
}
func (c *PayPalChannel) currency(cfg *ChannelConfig) string {
if v := cfg.Get("currency"); v != "" {
return v
}
return "USD"
}
func (c *PayPalChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
subject := truncateStr(order.Subject, 120)
payOrder, err := client.CreateOrder(ctx, paypal.OrderIntentCapture,
[]paypal.PurchaseUnitRequest{{
CustomID: order.PayNo,
Description: subject,
Amount: &paypal.PurchaseUnitAmount{
Currency: c.currency(cfg),
Value: FenToYuan(order.Amount),
},
}},
nil,
&paypal.ApplicationContext{
ReturnURL: order.ReturnURL,
CancelURL: order.ReturnURL,
},
)
if err != nil {
return nil, fmt.Errorf("PayPal 下单失败: %w", err)
}
// v2 的跳转链接 rel 为 payer-action(旧版为 approve)
approve := ""
for _, l := range payOrder.Links {
if l.Rel == "approve" || l.Rel == "payer-action" {
approve = l.Href
break
}
}
if approve == "" {
return nil, fmt.Errorf("PayPal 下单未返回跳转链接")
}
return &PayParams{
Channel: ChannelPayPal,
PayType: PayTypeWeb,
RedirectURL: approve,
ChannelTradeNo: payOrder.ID, // PayPal 订单号,回调后替换为 capture id
}, nil
}
func (c *PayPalChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
if order.ChannelTradeNo == "" {
return nil, fmt.Errorf("PayPal 查询需要渠道订单号(尚未下单成功)")
}
payOrder, err := client.GetOrder(ctx, order.ChannelTradeNo)
if err != nil {
return nil, fmt.Errorf("PayPal 查询订单失败: %w", err)
}
state := &ChannelState{ChannelTradeNo: payOrder.ID, Raw: fmt.Sprintf("status=%s", payOrder.Status)}
switch payOrder.Status {
case "COMPLETED":
state.TradeState = StateSuccess
now := time.Now()
state.PaidAt = &now
case "VOIDED":
state.TradeState = StateClosed
case "APPROVED":
// 用户已批准但未捕获,等待 webhook 或人工捕获
state.TradeState = StatePending
default:
state.TradeState = StatePending
}
for _, pu := range payOrder.PurchaseUnits {
if pu.Amount != nil {
if fen, err := YuanToFen(pu.Amount.Value); err == nil {
state.Amount = fen
}
break
}
}
return state, nil
}
type paypalEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
Resource struct {
ID string `json:"id"`
Status string `json:"status"`
CustomID string `json:"custom_id"`
Amount struct {
Value string `json:"value"`
CurrencyCode string `json:"currency_code"`
} `json:"amount"`
} `json:"resource"`
}
func (c *PayPalChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
webhookID := cfg.Get("webhook_id")
if webhookID == "" {
return nil, fmt.Errorf("PayPal 参数不完整:需要 webhook_id")
}
// VerifyWebhookSignature 会读取请求体,先读出来再回填,供后面解析事件内容
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("读取 PayPal 通知失败: %w", err)
}
r.Body = io.NopCloser(bytes.NewReader(raw))
verify, err := client.VerifyWebhookSignature(ctx, r, webhookID)
if err != nil {
return nil, fmt.Errorf("PayPal 验签请求失败: %w", err)
}
if verify == nil || verify.VerificationStatus != "SUCCESS" {
return nil, fmt.Errorf("PayPal 通知验签失败: %s", verify.VerificationStatus)
}
event := &paypalEvent{}
if err := json.Unmarshal(raw, event); err != nil {
return nil, fmt.Errorf("解析 PayPal 事件失败: %w", err)
}
result := &NotifyResult{
EventType: event.EventType,
EventID: event.ID,
AckBody: "",
Raw: truncateStr(string(raw), 8192),
}
result.PayNo = event.Resource.CustomID
result.OutTradeNo = event.Resource.CustomID
result.ChannelTradeNo = event.Resource.ID
if fen, ferr := YuanToFen(event.Resource.Amount.Value); ferr == nil {
result.Amount = fen
}
switch event.EventType {
case "PAYMENT.CAPTURE.COMPLETED":
result.TradeState = StateSuccess
result.Paid = true
case "PAYMENT.CAPTURE.DENIED", "PAYMENT.CAPTURE.DECLINED":
result.TradeState = StateFailed
case "PAYMENT.CAPTURE.REFUNDED", "PAYMENT.CAPTURE.REVERSED":
result.TradeState = StateRefunded
default:
result.TradeState = StatePending
}
return result, nil
}
func (c *PayPalChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
if order.ChannelTradeNo == "" {
return "", fmt.Errorf("PayPal 退回需要 capture id(支付成功回调后写入),请先确认该单已收到成功回调")
}
resp, err := client.RefundCapture(ctx, order.ChannelTradeNo, paypal.RefundCaptureRequest{
Amount: &paypal.Money{
Currency: c.currency(cfg),
Value: FenToYuan(amount),
},
NoteToPayer: truncateStr(reason, 200),
})
if err != nil {
return "", fmt.Errorf("PayPal 退回请求失败: %w", err)
}
return resp.ID, nil
}
func (c *PayPalChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
token, err := client.GetAccessToken(ctx)
if err != nil {
return "", fmt.Errorf("PayPal OAuth2 凭证校验失败: %w", err)
}
if token == nil || token.Token == "" {
return "", fmt.Errorf("PayPal OAuth2 未返回访问令牌")
}
return fmt.Sprintf("连接成功:OAuth2 凭证校验通过(环境 %s)", cfg.Extra["env"]), nil
}
+394
View File
@@ -0,0 +1,394 @@
package payment
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"server/models"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// UnionPayChannel 银联全渠道(PC 网关支付 / 手机控件支付)
//
// 银联没有官方 Go SDK,这里按「全渠道支付平台产品接口规范」实现签名与请求:
// - 签名:SHA256withRSA,字段按 key ASCII 升序拼 key=value&... 后私钥签名,base64 放入 signature;
// - 下单:POST /gateway/api/frontTransReq.do(前台跳转,返回自动提交表单);
// - 查询:POST /gateway/api/queryTrans.do(txnType=00);
// - 退货:POST /gateway/api/backTransReq.do(txnType=04)。
//
// 渠道参数(config_json):
//
// mer_id 商户号
// acq_ins_code 收单机构代码
// cert_password 商户证书密码
// is_production "1" 生产网关 / 其他为测试网关
//
// 证书(cert_json):
//
// cert_path 商户证书(.pfx / .p12,或 .cer + .pem 私钥组合)
type UnionPayChannel struct{}
func (c *UnionPayChannel) Code() string { return ChannelUnionPay }
func (c *UnionPayChannel) Name() string { return "银联" }
const (
unionPayGatewayProd = "https://gateway.95516.com"
unionPayGatewayTest = "https://gateway.test.95516.com"
unionPayVersion = "5.1.0"
unionPayCurrencyCNY = "156"
unionPayDefaultBizType = "000201" // B2C 网关支付
)
func unionPayGateway(cfg *ChannelConfig) string {
if cfg.Get("is_production") == "1" {
return unionPayGatewayProd
}
return unionPayGatewayTest
}
// unionPayKey 加载商户私钥与证书(支持 .pfx/.p12 与 .pem+.cer 两种形式)
func unionPayKey(cfg *ChannelConfig) (*rsa.PrivateKey, *x509.Certificate, error) {
certPath := cfg.CertPaths["cert_path"]
if certPath == "" {
return nil, nil, fmt.Errorf("银联缺少商户证书:请先上传商户证书")
}
data, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("读取银联证书失败: %w", err)
}
if block, _ := pem.Decode(data); block != nil && strings.Contains(block.Type, "PRIVATE KEY") {
keyData, kerr := os.ReadFile(cfg.CertPaths["key_path"])
if kerr != nil {
return nil, nil, fmt.Errorf("读取银联私钥失败: %w", kerr)
}
priv, perr := parsePKCS1Or8(string(keyData))
if perr != nil {
return nil, nil, perr
}
cert, cerr := loadCertFile(certPath)
if cerr != nil {
return nil, nil, cerr
}
return priv, cert, nil
}
privAny, cert, err := pkcs12.Decode(data, cfg.Get("cert_password"))
if err != nil {
return nil, nil, fmt.Errorf("解析银联 .pfx 证书失败(密码是否正确): %w", err)
}
priv, ok := privAny.(*rsa.PrivateKey)
if !ok {
return nil, nil, fmt.Errorf("银联证书私钥不是 RSA 私钥")
}
if cert == nil {
return nil, nil, fmt.Errorf("银联证书缺少证书信息")
}
return priv, cert, nil
}
func parsePKCS1Or8(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("私钥不是有效的 PEM")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("私钥不是 RSA 私钥")
}
return rsaKey, nil
}
func loadCertFile(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("证书不是有效的 PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// unionPayCertID 银联证书序列号(SerialNumber 十六进制大写)
func unionPayCertID(cert *x509.Certificate) string {
return strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber))
}
// unionPaySign 银联签名:key 升序拼 key=value&... 后 SHA256withRSA
func unionPaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "signature" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+"="+params[k])
}
digest := sha256.Sum256([]byte(strings.Join(pairs, "&")))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// unionPaySigned 组装带签名与 certId 的请求参数
func unionPaySigned(cfg *ChannelConfig, priv *rsa.PrivateKey, cert *x509.Certificate, extra map[string]string) (map[string]string, error) {
params := map[string]string{
"version": unionPayVersion,
"encoding": "UTF-8",
"signMethod": "01",
"accessType": "0",
"merId": cfg.Get("mer_id"),
}
for k, v := range extra {
if v != "" {
params[k] = v
}
}
sig, err := unionPaySign(params, priv)
if err != nil {
return nil, fmt.Errorf("银联签名失败: %w", err)
}
params["signature"] = sig
params["certId"] = unionPayCertID(cert)
return params, nil
}
// unionPayPostRaw 提交表单并返回原始响应
func unionPayPostRaw(ctx context.Context, endpoint string, params map[string]string) (string, error) {
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return "", fmt.Errorf("请求银联网关失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("银联网关返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
return string(body), nil
}
// unionPayPostForm 解析 form-urlencoded 响应(queryTrans / backTransReq.do)
func unionPayPostForm(ctx context.Context, endpoint string, params map[string]string) (url.Values, error) {
body, err := unionPayPostRaw(ctx, endpoint, params)
if err != nil {
return nil, err
}
vals, perr := url.ParseQuery(body)
if perr != nil || len(vals) == 0 {
return nil, fmt.Errorf("银联响应不是有效的表单数据: %s", truncateStr(body, 200))
}
return vals, nil
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func (c *UnionPayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
extra := map[string]string{
"txnType": "01",
"txnSubType": "01",
"bizType": unionPayDefaultBizType,
"orderId": order.PayNo,
"txnTime": time.Now().Format("20060102150405"),
"txnAmt": strconv.FormatInt(order.Amount, 10),
"currencyCode": unionPayCurrencyCNY,
"backUrl": cfg.CallbackURL,
"frontUrl": order.ReturnURL,
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return nil, err
}
// 前台跳转:银联返回自动提交表单,前端写入页面即可拉起收银台
html, err := unionPayPostRaw(ctx, unionPayGateway(cfg)+"/gateway/api/frontTransReq.do", params)
if err != nil {
return nil, fmt.Errorf("银联下单失败: %w", err)
}
payType := PayTypeWeb
if strings.EqualFold(opt.PayType, PayTypeH5) {
payType = PayTypeH5
}
return &PayParams{Channel: ChannelUnionPay, PayType: payType, Form: html}, nil
}
func (c *UnionPayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
extra := map[string]string{
"txnType": "00",
"txnSubType": "00",
"bizType": unionPayDefaultBizType,
"orderId": order.PayNo,
"txnTime": order.CreateTime.Format("20060102150405"),
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return nil, err
}
// TODO(银联): 查询响应字段(origRespCode/origTxnAmt/queryId 等)以入网时下发的接口文档为准,联调时校准。
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/queryTrans.do", params)
if err != nil {
return nil, err
}
state := &ChannelState{Raw: fmt.Sprint(vals)}
if vals.Get("queryId") != "" {
state.ChannelTradeNo = vals.Get("queryId")
}
if vals.Get("origRespCode") == "00" {
state.TradeState = StateSuccess
if amt, aerr := strconv.ParseInt(vals.Get("origTxnAmt"), 10, 64); aerr == nil {
state.Amount = amt
}
now := time.Now()
state.PaidAt = &now
} else {
state.TradeState = StatePending
}
return state, nil
}
func (c *UnionPayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
priv, _, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("解析银联通知失败: %w", err)
}
form := map[string]string{}
for k := range r.Form {
form[k] = r.Form.Get(k)
}
expect, err := unionPaySign(form, priv)
if err != nil {
return nil, err
}
if expect != r.Form.Get("signature") {
return nil, fmt.Errorf("银联通知验签失败")
}
result := &NotifyResult{
EventType: "UNIONPAY.NOTIFY",
AckBody: "ok",
Raw: fmt.Sprint(r.Form),
}
result.PayNo = form["orderId"]
result.OutTradeNo = form["orderId"]
result.ChannelTradeNo = form["queryId"]
if amt, aerr := strconv.ParseInt(form["txnAmt"], 10, 64); aerr == nil {
result.Amount = amt
}
if form["respCode"] == "00" {
result.TradeState = StateSuccess
result.Paid = true
} else {
result.TradeState = StateFailed
}
return result, nil
}
func (c *UnionPayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
if order.ChannelTradeNo == "" {
return "", fmt.Errorf("银联退回需要原交易流水号,请先执行「手动查询渠道状态」")
}
priv, cert, err := unionPayKey(cfg)
if err != nil {
return "", err
}
extra := map[string]string{
"txnType": "04",
"txnSubType": "00",
"bizType": unionPayDefaultBizType,
"orderId": refundNo,
"txnTime": time.Now().Format("20060102150405"),
"txnAmt": strconv.FormatInt(amount, 10),
"origQryId": order.ChannelTradeNo,
"backUrl": cfg.CallbackURL,
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return "", err
}
// TODO(银联): 退货响应字段与重试策略以入网时下发的接口文档为准,联调时校准。
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/backTransReq.do", params)
if err != nil {
return "", err
}
if vals.Get("respCode") != "00" {
return "", fmt.Errorf("银联退回失败: %s %s", vals.Get("respCode"), vals.Get("respMsg"))
}
return vals.Get("queryId"), nil
}
func (c *UnionPayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return "", err
}
if _, err := unionPaySign(map[string]string{"merId": cfg.Get("mer_id"), "orderId": "__connect_test__"}, priv); err != nil {
return "", err
}
// TODO(银联): 如需校验网关连通性,可在入网报备后用一笔查询交易确认。
return fmt.Sprintf("连接成功:证书加载与签名校验通过(证书序列号 %s,网关 %s)", unionPayCertID(cert), unionPayGateway(cfg)), nil
}
// CloudPayChannel 云闪付:走银联通道,商户参数与证书完全复用银联配置;
// 收银台是否展示云闪付标识由 extra_json.show_logo 控制(由前端收银台读取),适配器层面与银联一致。
type CloudPayChannel struct {
UnionPayChannel
}
func (c *CloudPayChannel) Code() string { return ChannelCloudPay }
func (c *CloudPayChannel) Name() string { return "云闪付" }
+316
View File
@@ -0,0 +1,316 @@
package payment
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"server/models"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// WechatChannel 微信支付(JSAPI / Native 扫码)
//
// 渠道参数(config_json):
//
// mch_id 商户号
// appid 公众号 / 小程序 APPID
// cert_serial_no 商户 API 证书序列号
// api_v3_key APIv3 密钥
//
// 证书(cert_json):
//
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
type WechatChannel struct{}
func (c *WechatChannel) Code() string { return ChannelWechat }
func (c *WechatChannel) Name() string { return "微信支付" }
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
mchID := cfg.Get("mch_id")
serial := cfg.Get("cert_serial_no")
apiV3Key := cfg.Get("api_v3_key")
if mchID == "" || serial == "" || apiV3Key == "" {
return nil, "", fmt.Errorf("微信支付参数不完整:需要 mch_id / cert_serial_no / api_v3_key")
}
keyPath := cfg.CertPaths["key_path"]
if keyPath == "" {
return nil, "", fmt.Errorf("微信支付缺少商户私钥:请先上传 apiclient_key.pem")
}
pem, err := os.ReadFile(keyPath)
if err != nil {
return nil, "", fmt.Errorf("读取商户私钥失败: %w", err)
}
privateKey, err := utils.LoadPrivateKey(string(pem))
if err != nil {
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
}
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(mchID, serial, privateKey),
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
)
if err != nil {
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", err)
}
return client, mchID, nil
}
func (c *WechatChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
appID := cfg.Get("appid")
if appID == "" {
return nil, fmt.Errorf("微信支付参数不完整:需要 appid")
}
notifyURL := cfg.CallbackURL
amount := &native.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")}
// 公众号内支付
if strings.EqualFold(opt.PayType, PayTypeJSAPI) {
if opt.OpenID == "" {
return nil, fmt.Errorf("微信 JSAPI 支付需要 OpenID")
}
svc := jsapi.JsapiApiService{Client: client}
resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(mchID),
Description: core.String(order.Subject),
OutTradeNo: core.String(order.PayNo),
NotifyUrl: core.String(notifyURL),
Amount: &jsapi.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")},
Payer: &jsapi.Payer{Openid: core.String(opt.OpenID)},
})
if err != nil {
return nil, fmt.Errorf("微信 JSAPI 下单失败: %w", err)
}
if resp == nil || resp.PrepayId == nil {
return nil, fmt.Errorf("微信 JSAPI 下单未返回 prepay_id")
}
// 按微信规范自行生成 JSAPI 调起参数(timeStamp/nonceStr/paySign)
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce, _ := randomNonce(16)
message := appID + "\n" + ts + "\n" + nonce + "\n" + "prepay_id=" + *resp.PrepayId + "\n"
sign, err := client.Sign(ctx, message)
if err != nil {
return nil, fmt.Errorf("生成 JSAPI 签名失败: %w", err)
}
return &PayParams{
Channel: ChannelWechat,
PayType: PayTypeJSAPI,
JSAPI: map[string]string{
"appId": appID,
"timeStamp": ts,
"nonceStr": nonce,
"package": "prepay_id=" + *resp.PrepayId,
"signType": "RSA",
"paySign": sign.Signature,
},
}, nil
}
// 默认:Native 扫码(PC 收银台)
req := native.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(mchID),
Description: core.String(order.Subject),
OutTradeNo: core.String(order.PayNo),
NotifyUrl: core.String(notifyURL),
Amount: amount,
}
if order.ExpireAt != nil {
req.TimeExpire = order.ExpireAt
}
svc := native.NativeApiService{Client: client}
resp, _, err := svc.Prepay(ctx, req)
if err != nil {
return nil, fmt.Errorf("微信 Native 下单失败: %w", err)
}
if resp == nil || resp.CodeUrl == nil {
return nil, fmt.Errorf("微信 Native 下单未返回 code_url")
}
return &PayParams{Channel: ChannelWechat, PayType: PayTypeQR, CodeURL: *resp.CodeUrl}, nil
}
// normalizeWechatState 微信交易状态 -> 归一化状态
func normalizeWechatState(state string) string {
switch state {
case "SUCCESS":
return StateSuccess
case "NOTPAY", "USERPAYING":
return StatePending
case "CLOSED", "REVOKED":
return StateClosed
case "PAYERROR":
return StateFailed
case "REFUND":
return StateRefunded
default:
return StatePending
}
}
func (c *WechatChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
url := consts.WechatPayAPIServer + "/v3/pay/transactions/out-trade-no/" + order.PayNo + "?mchid=" + mchID
result, err := client.Get(ctx, url)
if err != nil {
return nil, fmt.Errorf("微信查询订单失败: %w", err)
}
body, err := io.ReadAll(result.Response.Body)
if err != nil {
return nil, fmt.Errorf("读取微信查询响应失败: %w", err)
}
tx := &payments.Transaction{}
if err := json.Unmarshal(body, tx); err != nil {
return nil, fmt.Errorf("解析微信查询响应失败: %w", err)
}
state := &ChannelState{Raw: string(body)}
if tx.TransactionId != nil {
state.ChannelTradeNo = *tx.TransactionId
}
state.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
if tx.Amount != nil && tx.Amount.Total != nil {
state.Amount = *tx.Amount.Total
}
if tx.SuccessTime != nil {
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
state.PaidAt = &t
}
}
return state, nil
}
func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
mchID := cfg.Get("mch_id")
apiV3Key := cfg.Get("api_v3_key")
// 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
if err != nil {
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
}
tx := &payments.Transaction{}
req, err := handler.ParseNotifyRequest(ctx, r, tx)
if err != nil {
return nil, fmt.Errorf("微信通知验签/解密失败: %w", err)
}
result := &NotifyResult{
EventType: req.EventType,
EventID: req.ID,
AckBody: `{"code":"SUCCESS","message":"成功"}`,
Raw: req.Resource.Plaintext,
}
if tx.OutTradeNo != nil {
result.OutTradeNo = *tx.OutTradeNo
result.PayNo = *tx.OutTradeNo
}
if tx.TransactionId != nil {
result.ChannelTradeNo = *tx.TransactionId
}
if tx.Amount != nil && tx.Amount.Total != nil {
result.Amount = *tx.Amount.Total
}
if tx.SuccessTime != nil {
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
_ = t
}
}
result.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
result.Paid = result.TradeState == StateSuccess
return result, nil
}
func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, _, err := c.buildClient(cfg)
if err != nil {
return "", err
}
body := map[string]any{
"out_trade_no": order.PayNo,
"out_refund_no": refundNo,
"reason": reason,
"notify_url": "", // 退回结果以主动查询为准;如需退回回调,配置后填入
"amount": map[string]any{
"refund": amount,
"total": order.Amount,
"currency": "CNY",
},
}
result, err := client.Post(ctx, consts.WechatPayAPIServer+"/v3/refund/domestic/refunds", body)
if err != nil {
return "", fmt.Errorf("微信退回请求失败: %w", err)
}
respBody, _ := io.ReadAll(result.Response.Body)
var resp struct {
RefundID string `json:"refund_id"`
Status string `json:"status"`
}
_ = json.Unmarshal(respBody, &resp)
if resp.Status != "" && resp.Status != "SUCCESS" && resp.Status != "PROCESSING" {
return resp.RefundID, fmt.Errorf("微信退回未受理,状态: %s", resp.Status)
}
return resp.RefundID, nil
}
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, _, err := c.buildClient(cfg)
if err != nil {
return "", err
}
// 平台证书接口只做签名校验,不产生任何交易
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
if err != nil {
return "", fmt.Errorf("微信凭证校验失败: %w", err)
}
body, _ := io.ReadAll(result.Response.Body)
return fmt.Sprintf("连接成功:商户证书与 APIv3 密钥校验通过(平台证书响应 %d 字节)", len(body)), nil
}
/* ---------------- 小工具 ---------------- */
func valueOrEmpty(v *string) string {
if v == nil {
return ""
}
return *v
}
// randomNonce 生成 n 字节随机数的十六进制串
func randomNonce(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+273
View File
@@ -0,0 +1,273 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
)
/* =============================================================
* 推广佣金:规则匹配 -> 金额计算 -> 佣金单生成(幂等)-> 发放登记
* 佣金是平台自有资金支出,与租户收款资金流解耦。
* ============================================================= */
// OnOrderPaid 支付成功后生成佣金单(幂等:同一支付单 + 同一推广方只生成一张)
// 无推广方 / 无匹配规则 / 佣金金额为 0 时返回 (nil, nil)。
func OnOrderPaid(ctx context.Context, order *models.PlatformPaymentOrder) (*models.PlatformCommissionOrder, error) {
if order == nil || order.PromoterID == "" {
return nil, nil
}
// 幂等查重
exist := &models.PlatformCommissionOrder{}
err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("pay_no", order.PayNo).
Filter("promoter_id", order.PromoterID).
Filter("delete_time__isnull", true).
One(exist)
if err == nil {
return exist, nil
} else if err != orm.ErrNoRows {
return nil, fmt.Errorf("查询佣金单失败: %w", err)
}
rule, err := matchCommissionRule(order.OrderType)
if err != nil {
return nil, err
}
if rule == nil {
return nil, nil
}
amount := calcCommissionAmount(rule, order.Amount)
if amount <= 0 {
beelog.Warn("佣金规则 %s 计算金额为 0,跳过(订单 %s)", rule.RuleName, order.PayNo)
return nil, nil
}
row := &models.PlatformCommissionOrder{
CommissionNo: NextCommissionNo(),
PayNo: order.PayNo,
OutTradeNo: order.OutTradeNo,
RuleID: rule.ID,
RuleName: rule.RuleName,
CalcType: rule.CalcType,
PromoterID: order.PromoterID,
PromoterName: order.PromoterName,
PromoterType: order.PromoterType,
BaseAmount: order.Amount,
Amount: amount,
Status: models.CommissionStatusPayable,
Remark: fmt.Sprintf("支付单 %s 支付成功自动生成", order.PayNo),
}
if _, err := models.Orm.Insert(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
// 并发生成命中唯一键:查回已有记录
return exist, nil
}
return nil, fmt.Errorf("创建佣金单失败: %w", err)
}
beelog.Info("佣金单已生成: %s 支付单 %s 推广方 %s 金额 %d 分", row.CommissionNo, order.PayNo, order.PromoterName, amount)
return row, nil
}
// matchCommissionRule 匹配启用中的佣金规则(同类型规则取最新一条)
func matchCommissionRule(orderType string) (*models.PlatformCommissionRule, error) {
var rules []models.PlatformCommissionRule
_, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("status", 1).
Filter("delete_time__isnull", true).
OrderBy("-id").
All(&rules)
if err != nil {
return nil, err
}
for i := range rules {
if rules[i].OrderType == models.CommissionOrderTypeAll || rules[i].OrderType == orderType {
return &rules[i], nil
}
}
return nil, nil
}
// calcCommissionAmount 按规则计算佣金(分),向下取整
func calcCommissionAmount(rule *models.PlatformCommissionRule, baseAmount int64) int64 {
if rule == nil || baseAmount <= 0 {
return 0
}
switch rule.CalcType {
case models.CommissionCalcPercent:
return baseAmount * rule.Value / 10000 // value = 百分比 × 100
case models.CommissionCalcFixed:
return rule.Value
case models.CommissionCalcTiered:
for _, tier := range parseTiers(rule.TierJSON) {
if baseAmount < tier.Min {
continue
}
if tier.Max != nil && baseAmount >= *tier.Max {
continue
}
return baseAmount * tier.Rate / 10000
}
return 0
default:
return 0
}
}
func parseTiers(raw *string) []models.CommissionTier {
tiers := []models.CommissionTier{}
if raw == nil || *raw == "" {
return tiers
}
_ = json.Unmarshal([]byte(*raw), &tiers)
return tiers
}
/* ---------------- 佣金台账查询与发放 ---------------- */
// CommissionListInput 台账查询条件
type CommissionListInput struct {
Promoter string
Status string
StartTime string // YYYY-MM-DD
EndTime string
Page int
PageSize int
}
// ListCommissions 佣金台账分页
func ListCommissions(in CommissionListInput) ([]models.PlatformCommissionOrder, int64, error) {
qs := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).Filter("delete_time__isnull", true)
if in.Promoter != "" {
qs = qs.Filter("promoter_name__icontains", in.Promoter)
}
if in.Status != "" {
qs = qs.Filter("status", in.Status)
}
if in.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02", in.StartTime, time.Local); err == nil {
qs = qs.Filter("create_time__gte", t)
}
}
if in.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02", in.EndTime, time.Local); err == nil {
qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1))
}
}
total, err := qs.Count()
if err != nil {
return nil, 0, err
}
if in.Page <= 0 {
in.Page = 1
}
if in.PageSize <= 0 || in.PageSize > 200 {
in.PageSize = 10
}
var rows []models.PlatformCommissionOrder
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
return rows, total, err
}
// CommissionStats 台账统计(应付 / 已付 / 待处理笔数)
type CommissionStats struct {
PayableAmount int64 `json:"payable_amount"`
PaidAmount int64 `json:"paid_amount"`
PendingCount int64 `json:"pending_count"`
}
// GetCommissionStats 统计口径:
//
// 应付总额 = status=payable 的金额合计;已付总额 = status=settled;待处理笔数 = payable + tax_processing。
func GetCommissionStats() (*CommissionStats, error) {
type row struct {
Total int64 `orm:"column(total)"`
Cnt int64 `orm:"column(cnt)"`
}
stats := &CommissionStats{}
pay := row{}
if err := models.Orm.Raw(
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
models.CommissionStatusPayable).QueryRow(&pay); err != nil {
return nil, err
}
stats.PayableAmount = pay.Total
stats.PendingCount = pay.Cnt
paid := row{}
if err := models.Orm.Raw(
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
models.CommissionStatusSettled).QueryRow(&paid); err != nil {
return nil, err
}
stats.PaidAmount = paid.Total
tax := row{}
if err := models.Orm.Raw(
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
models.CommissionStatusTaxProcessing).QueryRow(&tax); err != nil {
return nil, err
}
stats.PendingCount += tax.Cnt
return stats, nil
}
// MarkCommissionPaidInput 标记已付入参
type MarkCommissionPaidInput struct {
ID uint64
PayMethod string
PayVoucher string
Remark string
OperatorID string
OperatorName string
}
// MarkCommissionPaid 发放登记:应付/税务处理中 -> 已付
func MarkCommissionPaid(ctx context.Context, in MarkCommissionPaidInput) (*models.PlatformCommissionOrder, error) {
row := &models.PlatformCommissionOrder{}
if err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("id", in.ID).
Filter("delete_time__isnull", true).
One(row); err != nil {
return nil, fmt.Errorf("佣金单不存在")
}
if row.Status == models.CommissionStatusSettled {
return nil, errors.New("该佣金单已是已付状态")
}
now := time.Now()
_, err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("id", row.ID).
Update(orm.Params{
"status": models.CommissionStatusSettled,
"pay_method": in.PayMethod,
"pay_voucher": in.PayVoucher,
"paid_at": now,
"paid_user_id": in.OperatorID,
"paid_user_name": in.OperatorName,
"remark": in.Remark,
"update_time": now,
})
if err != nil {
return nil, err
}
return GetCommissionByID(in.ID)
}
// GetCommissionByID 按 ID 查询佣金单
func GetCommissionByID(id uint64) (*models.PlatformCommissionOrder, error) {
row := &models.PlatformCommissionOrder{}
err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("id", id).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
+106
View File
@@ -0,0 +1,106 @@
package payment
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"strings"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 渠道敏感参数的对称加密与掩码
//
// yz_platform_payment_channel.config_json 中的密钥/私钥/Secret 以 AES-256-GCM 加密后存储,
// 格式:base64( "YZP1:" + nonce + ciphertext )。
// 密钥来源:app.conf 的 payment_secret_key(任意长度字符串,内部做 SHA-256 派生);
// 未配置时退回内置兜底密钥(保证开箱可用),上线前务必在 app.conf 配置该值,
// 且注意:更换密钥后,历史密文将无法解密,需要重新保存各渠道配置。
// =============================================================
const configCipherPrefix = "YZP1:"
// fallbackSecretKey 内置兜底密钥,仅用于开发/未配置场景
const fallbackSecretKey = "yunzer_payment_default_secret_key_v1"
func configKey() []byte {
raw, _ := beego.AppConfig.String("payment_secret_key")
if strings.TrimSpace(raw) == "" {
raw = fallbackSecretKey
}
sum := sha256.Sum256([]byte(raw))
return sum[:]
}
// EncryptConfig 加密渠道参数 JSON
func EncryptConfig(plain string) (string, error) {
if plain == "" {
return "", nil
}
block, err := aes.NewCipher(configKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
buf := append([]byte(configCipherPrefix), nonce...)
buf = append(buf, ciphertext...)
return base64.StdEncoding.EncodeToString(buf), nil
}
// DecryptConfig 解密渠道参数 JSON;空串原样返回
func DecryptConfig(encoded string) (string, error) {
if encoded == "" {
return "", nil
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", errors.New("渠道参数不是有效的 base64")
}
if len(raw) < len(configCipherPrefix) || string(raw[:len(configCipherPrefix)]) != configCipherPrefix {
return "", errors.New("渠道参数格式不正确(缺少加密前缀)")
}
block, err := aes.NewCipher(configKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := raw[len(configCipherPrefix) : len(configCipherPrefix)+gcm.NonceSize()]
ciphertext := raw[len(configCipherPrefix)+gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", errors.New("渠道参数解密失败(payment_secret_key 是否被更换过?)")
}
return string(plain), nil
}
// MaskSecret 生成掩码:保留末 4 位
func MaskSecret(v string) string {
if v == "" {
return ""
}
if len(v) <= 4 {
return "******"
}
return "******" + v[len(v)-4:]
}
// IsMasked 判断前端回传的值是否仍是掩码(表示「不修改原值」)
func IsMasked(v string) bool {
return strings.HasPrefix(v, "******")
}
+81
View File
@@ -0,0 +1,81 @@
package payment
import (
"crypto/rand"
"fmt"
"math/big"
"strconv"
"strings"
"sync"
"time"
)
/* ---------------- 金额工具 ---------------- */
// FenToYuan 分 -> 元(两位小数字符串),供渠道下单参数使用
func FenToYuan(fen int64) string {
if fen < 0 {
fen = 0
}
return strconv.FormatInt(fen/100, 10) + "." + fmt.Sprintf("%02d", fen%100)
}
// YuanToFen 元(字符串)-> 分
func YuanToFen(yuan string) (int64, error) {
yuan = strings.TrimSpace(yuan)
if yuan == "" {
return 0, nil
}
f, err := strconv.ParseFloat(yuan, 64)
if err != nil {
return 0, fmt.Errorf("金额格式不正确: %s", yuan)
}
if f < 0 {
return 0, fmt.Errorf("金额不能为负数: %s", yuan)
}
return int64(f*100 + 0.5), nil
}
/* ---------------- 单号生成 ----------------
规则:前缀 + yyyyMMddHHmmss + 4 位随机数字(进程内串行生成,避免同秒碰撞;
落库仍有唯一键兜底,撞号时由调用方重试一次)。
支付单 P202609151024310001
退回单 R202609151024310001
佣金单 CM202609151024310001
对账批次 RC202609151024310001
---------------------------------------- */
var (
noMu sync.Mutex
lastNoTime int64
lastNoSeq int64
)
func nextSerial(prefix string) string {
noMu.Lock()
defer noMu.Unlock()
now := time.Now().Unix()
seq := int64(0)
if now == lastNoTime {
seq = lastNoSeq + 1
}
lastNoTime = now
lastNoSeq = seq
n, _ := rand.Int(rand.Reader, big.NewInt(10000))
return fmt.Sprintf("%s%s%04d%04d", prefix, time.Now().Format("20060102150405"), seq, n.Int64())
}
// NextPayNo 生成支付单号
func NextPayNo() string { return nextSerial("P") }
// NextRefundNo 生成退回单号
func NextRefundNo() string { return nextSerial("R") }
// NextCommissionNo 生成佣金单号
func NextCommissionNo() string { return nextSerial("CM") }
// NextBatchNo 生成对账批次号
func NextBatchNo() string { return nextSerial("RC") }
+367
View File
@@ -0,0 +1,367 @@
package payment
import (
"context"
"encoding/csv"
"fmt"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
)
/* =============================================================
* 日终对账:渠道账单导入 -> 与本地支付流水比对 -> 产出差异 -> 人工标记处理
* 差异类型:long 长款(渠道有本地无)/ short 短款(本地有渠道无)/ status_mismatch 状态不一致
* ============================================================= */
// BillRow 渠道账单明细行(导入用)
type BillRow struct {
ChannelTradeNo string // 渠道流水号
OutTradeNo string // 渠道侧商户订单号(下单时传的 pay_no)
Amount int64 // 渠道金额(分)
Fee int64 // 渠道手续费(分)
TradeState string // 渠道侧交易状态
TradeTime *time.Time // 渠道交易时间
}
// ImportChannelBill 导入某渠道某日的账单明细(幂等:按 channel + channel_trade_no 更新或插入)
func ImportChannelBill(ctx context.Context, channel, billDate string, rows []BillRow) (int, error) {
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
if err != nil {
return 0, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
}
imported := 0
for _, r := range rows {
if strings.TrimSpace(r.ChannelTradeNo) == "" {
continue
}
exist := &models.PlatformPaymentChannelBill{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("channel", channel).
Filter("channel_trade_no", r.ChannelTradeNo).
Filter("delete_time__isnull", true).
One(exist)
if err == nil {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("id", exist.ID).
Update(orm.Params{
"amount": r.Amount, "fee": r.Fee, "trade_state": r.TradeState,
"update_time": time.Now(),
})
imported++
continue
}
row := &models.PlatformPaymentChannelBill{
Channel: channel,
BillDate: day,
ChannelTradeNo: r.ChannelTradeNo,
OutTradeNo: r.OutTradeNo,
Amount: r.Amount,
Fee: r.Fee,
TradeState: r.TradeState,
TradeTime: r.TradeTime,
}
if _, err := models.Orm.Insert(row); err == nil {
imported++
}
}
return imported, nil
}
// ParseChannelBillCSV 解析账单 CSV。
// 约定列:channel_trade_no, out_trade_no, amount(元), fee(元), trade_state, trade_time
// TODO(渠道): 各渠道账单文件列名/编码不同(微信为制表符分隔、支付宝为 GBK CSV),
// 对接时按渠道导出文件写各自的解析器,统一转成 BillRow。
func ParseChannelBillCSV(data []byte) ([]BillRow, error) {
reader := csv.NewReader(strings.NewReader(string(data)))
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
rows := make([]BillRow, 0, len(records))
for i, rec := range records {
if i == 0 || len(rec) == 0 {
continue
}
get := func(idx int) string {
if idx < len(rec) {
return strings.TrimSpace(rec[idx])
}
return ""
}
row := BillRow{
ChannelTradeNo: get(0),
OutTradeNo: get(1),
TradeState: get(4),
}
if row.ChannelTradeNo == "" {
continue
}
if fen, err := YuanToFen(get(2)); err == nil {
row.Amount = fen
}
if fen, err := YuanToFen(get(3)); err == nil {
row.Fee = fen
}
if t := get(5); t != "" {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, "2006-01-02"} {
if tt, err := time.ParseInLocation(layout, t, time.Local); err == nil {
row.TradeTime = &tt
break
}
}
}
rows = append(rows, row)
}
return rows, nil
}
// ReconcileSummary 一次对账的执行结果
type ReconcileSummary struct {
BatchNo string `json:"batch_no"`
Channel string `json:"channel"`
BillDate string `json:"bill_date"`
BillCount int `json:"bill_count"`
Matched int `json:"matched"`
LongCount int `json:"long_count"`
ShortCount int `json:"short_count"`
MismatchCount int `json:"mismatch_count"`
}
// RunReconcile 对某渠道某日的账单与本地支付流水做比对,产出差异(幂等:按唯一键更新已有差异)。
// 匹配策略:渠道账单的商户订单号即本地下单时传的 pay_no,优先按 pay_no 匹配,其次按 out_trade_no;
// 本地侧只统计支付成功日(paid_at)落在对账日内的支付单。
func RunReconcile(ctx context.Context, channel, billDate string) (*ReconcileSummary, error) {
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
if err != nil {
return nil, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
}
if _, err := LoadChannelConfig(channel); err != nil {
return nil, err
}
var bills []models.PlatformPaymentChannelBill
_, err = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("channel", channel).
Filter("bill_date", day).
Filter("delete_time__isnull", true).
All(&bills)
if err != nil {
return nil, err
}
var locals []models.PlatformPaymentOrder
_, err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("delete_time__isnull", true).
Filter("status", models.PayStatusPaid).
Filter("paid_at__gte", day).
Filter("paid_at__lt", day.AddDate(0, 0, 1)).
All(&locals)
if err != nil {
return nil, err
}
localByPayNo := map[string]*models.PlatformPaymentOrder{}
localByOutTrade := map[string]*models.PlatformPaymentOrder{}
for i := range locals {
localByPayNo[locals[i].PayNo] = &locals[i]
localByOutTrade[locals[i].OutTradeNo] = &locals[i]
}
batchNo := NextBatchNo()
summary := &ReconcileSummary{BatchNo: batchNo, Channel: channel, BillDate: billDate, BillCount: len(bills)}
matchedPayNos := map[string]bool{}
for i := range bills {
bill := bills[i]
local := localByPayNo[bill.OutTradeNo]
if local == nil {
local = localByOutTrade[bill.OutTradeNo]
}
if local == nil {
summary.LongCount++
_ = upsertDiff(channel, day, batchNo, "", bill.ChannelTradeNo,
models.DiffTypeLong, nil, &bill.Amount, "渠道账单存在该流水,本地无对应支付单")
continue
}
if local.Status == models.PayStatusPaid && bill.Amount == local.Amount {
summary.Matched++
matchedPayNos[local.PayNo] = true
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("id", bill.ID).
Update(orm.Params{"is_matched": 1, "matched_pay_no": local.PayNo, "update_time": time.Now()})
continue
}
summary.MismatchCount++
reason := "本地与渠道金额不一致"
if bill.Amount == local.Amount {
reason = fmt.Sprintf("渠道已成功但本地状态为 %s", local.Status)
}
_ = upsertDiff(channel, day, batchNo, local.PayNo, bill.ChannelTradeNo,
models.DiffTypeStatusMismatch, &local.Amount, &bill.Amount, reason)
}
for i := range locals {
if matchedPayNos[locals[i].PayNo] {
continue
}
summary.ShortCount++
_ = upsertDiff(channel, day, batchNo, locals[i].PayNo, "",
models.DiffTypeShort, &locals[i].Amount, nil, "本地已支付,渠道账单无对应流水")
}
beelog.Info("对账完成: 渠道=%s 日期=%s 账单 %d 笔,匹配 %d,长款 %d,短款 %d,不一致 %d(批次 %s)",
channel, billDate, summary.BillCount, summary.Matched, summary.LongCount, summary.ShortCount, summary.MismatchCount, batchNo)
return summary, nil
}
// upsertDiff 写入/更新差异记录(幂等:唯一键 channel+bill_date+diff_type+channel_trade_no+local_trade_no)
func upsertDiff(channel string, day time.Time, batchNo, localTradeNo, channelTradeNo, diffType string, localAmount, channelAmount *int64, remark string) error {
exist := &models.PlatformPaymentReconcileDiff{}
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("channel", channel).
Filter("bill_date", day).
Filter("diff_type", diffType).
Filter("channel_trade_no", channelTradeNo).
Filter("local_trade_no", localTradeNo).
Filter("delete_time__isnull", true).
One(exist)
diffAmount := int64(0)
switch {
case channelAmount != nil && localAmount != nil:
diffAmount = *channelAmount - *localAmount
case channelAmount != nil:
diffAmount = *channelAmount
case localAmount != nil:
diffAmount = -*localAmount
}
if err == nil {
_, uerr := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", exist.ID).
Update(orm.Params{
"batch_no": batchNo,
"local_amount": localAmount,
"channel_amount": channelAmount,
"diff_amount": diffAmount,
"update_time": time.Now(),
})
return uerr
}
row := &models.PlatformPaymentReconcileDiff{
Channel: channel,
BillDate: day,
BatchNo: batchNo,
LocalTradeNo: localTradeNo,
ChannelTradeNo: channelTradeNo,
LocalAmount: localAmount,
ChannelAmount: channelAmount,
DiffAmount: diffAmount,
DiffType: diffType,
HandleStatus: models.DiffHandleUnhandled,
Remark: remark,
}
_, err = models.Orm.Insert(row)
return err
}
// ReconcileListInput 差异列表查询条件
type ReconcileListInput struct {
Channel string
BillDate string
DiffType string
HandleStatus string
Page int
PageSize int
}
// ListReconcileDiffs 对账差异分页
func ListReconcileDiffs(in ReconcileListInput) ([]models.PlatformPaymentReconcileDiff, int64, error) {
qs := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).Filter("delete_time__isnull", true)
if in.Channel != "" {
qs = qs.Filter("channel", in.Channel)
}
if in.BillDate != "" {
if t, err := time.ParseInLocation("2006-01-02", in.BillDate, time.Local); err == nil {
qs = qs.Filter("bill_date", t)
}
}
if in.DiffType != "" {
qs = qs.Filter("diff_type", in.DiffType)
}
if in.HandleStatus != "" {
qs = qs.Filter("handle_status", in.HandleStatus)
}
total, err := qs.Count()
if err != nil {
return nil, 0, err
}
if in.Page <= 0 {
in.Page = 1
}
if in.PageSize <= 0 || in.PageSize > 200 {
in.PageSize = 10
}
var rows []models.PlatformPaymentReconcileDiff
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
return rows, total, err
}
// MarkDiffHandledInput 标记差异处理入参
type MarkDiffHandledInput struct {
ID uint64
HandleStatus string // handled / handling / ignored
Remark string
OperatorID string
OperatorName string
}
// MarkDiffHandled 标记对账差异处理状态
func MarkDiffHandled(ctx context.Context, in MarkDiffHandledInput) (*models.PlatformPaymentReconcileDiff, error) {
row := &models.PlatformPaymentReconcileDiff{}
if err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", in.ID).
Filter("delete_time__isnull", true).
One(row); err != nil {
return nil, fmt.Errorf("差异记录不存在")
}
switch in.HandleStatus {
case models.DiffHandleHandled, models.DiffHandleHandling, models.DiffHandleIgnored:
default:
return nil, fmt.Errorf("处理状态不合法")
}
now := time.Now()
_, err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", row.ID).
Update(orm.Params{
"handle_status": in.HandleStatus,
"remark": in.Remark,
"handle_user_id": in.OperatorID,
"handle_user_name": in.OperatorName,
"handle_time": now,
"update_time": now,
})
if err != nil {
return nil, err
}
return GetReconcileDiffByID(in.ID)
}
// GetReconcileDiffByID 按 ID 查询差异记录
func GetReconcileDiffByID(id uint64) (*models.PlatformPaymentReconcileDiff, error) {
row := &models.PlatformPaymentReconcileDiff{}
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", id).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
+613
View File
@@ -0,0 +1,613 @@
package payment
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
)
/* =============================================================
* 支付服务核心:创建支付单 / 查询 / 状态机 / 回调幂等 / 兜底退回 / 补偿任务
*
* 与业务模块的解耦点:
* 1. 业务订单的「已支付」同步通过 RegisterOrderSyncHook 注册钩子完成;
* 未注册钩子时视为无需同步(order_synced 直接置 1)。
* 2. 佣金推广方:下单入参可携带(快照到支付单),未携带时由
* RegisterPromoterResolver 注册的钩子推导(如按租户归属的渠道伙伴)。
* ============================================================= */
// OrderSyncFunc 业务订单状态同步钩子
type OrderSyncFunc func(ctx context.Context, order *models.PlatformPaymentOrder) error
// PromoterResolveFunc 推广方解析钩子
type PromoterResolveFunc func(ctx context.Context, order *models.PlatformPaymentOrder) (promoterID, promoterName, promoterType string, ok bool)
var (
orderSyncHook OrderSyncFunc
promoterResolve PromoterResolveFunc
)
// RegisterOrderSyncHook 注册业务订单同步钩子(在业务模块 init 时调用)
func RegisterOrderSyncHook(fn OrderSyncFunc) { orderSyncHook = fn }
// RegisterPromoterResolver 注册推广方解析钩子
func RegisterPromoterResolver(fn PromoterResolveFunc) { promoterResolve = fn }
// CreateInput 创建支付单入参(租户端下单页 -> POST /backend/payment/create)
type CreateInput struct {
OutTradeNo string // 业务订单号(必填)
OrderType string // platform_usage / module_shop / service_fee
TenantID string
TenantName string
Amount int64 // 分
Channel string // 渠道标识(必填)
Subject string
ReturnURL string // 支付完成同步跳回地址
ClientIP string
PayType string // qr / web / h5 / jsapi;空值由适配器给默认
OpenID string // JSAPI 必填
PromoterID string
PromoterName string
PromoterType string
ExpireMinutes int // 支付有效期(分钟),默认 30
}
const defaultExpireMinutes = 30
// GetOrderByPayNo 按支付单号查询
func GetOrderByPayNo(payNo string) (*models.PlatformPaymentOrder, error) {
row := &models.PlatformPaymentOrder{}
err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
One(row)
if err != nil {
return nil, err
}
return row, nil
}
// LogStateChange 记录状态流转流水
func LogStateChange(payNo, fromStatus, toStatus, operator, remark string) {
row := &models.PlatformPaymentOrderLog{
PayNo: payNo,
FromStatus: fromStatus,
ToStatus: toStatus,
Operator: operator,
Remark: remark,
}
if _, err := models.Orm.Insert(row); err != nil {
beelog.Warn("支付单流转日志写入失败: %s %v", payNo, err)
}
}
// CreatePayment 创建支付单并调用渠道下单
// 幂等:同一 out_trade_no + channel 存在未终态支付单时,直接复用并重新拉起支付参数。
func CreatePayment(ctx context.Context, in CreateInput) (*models.PlatformPaymentOrder, *PayParams, error) {
if strings.TrimSpace(in.OutTradeNo) == "" {
return nil, nil, errors.New("业务订单号不能为空")
}
if in.Amount <= 0 {
return nil, nil, errors.New("支付金额必须大于 0")
}
adapter, err := GetChannelAdapter(in.Channel)
if err != nil {
return nil, nil, err
}
cfg, err := LoadEnabledChannelConfig(in.Channel)
if err != nil {
return nil, nil, err
}
// 幂等:复用未终态支付单
var exist models.PlatformPaymentOrder
err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("out_trade_no", in.OutTradeNo).
Filter("channel", in.Channel).
Filter("delete_time__isnull", true).
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
One(&exist)
if err == nil {
params, perr := adapter.Prepay(ctx, &exist, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID})
if perr != nil {
return nil, nil, perr
}
return &exist, params, nil
} else if err != orm.ErrNoRows {
return nil, nil, fmt.Errorf("查询支付单失败: %w", err)
}
payNo := NextPayNo()
now := time.Now()
minutes := in.ExpireMinutes
if minutes <= 0 {
minutes = defaultExpireMinutes
}
expireAt := now.Add(time.Duration(minutes) * time.Minute)
order := &models.PlatformPaymentOrder{
PayNo: payNo,
OutTradeNo: in.OutTradeNo,
OrderType: in.OrderType,
Subject: in.Subject,
TenantID: in.TenantID,
TenantName: in.TenantName,
Amount: in.Amount,
OrderAmount: in.Amount,
Currency: "CNY",
Channel: in.Channel,
MerchantNo: cfg.MerchantNo,
Status: models.PayStatusCreated,
ClientIP: in.ClientIP,
ReturnURL: in.ReturnURL,
NotifyURL: cfg.CallbackURL,
ExpireAt: &expireAt,
PromoterID: in.PromoterID,
PromoterName: in.PromoterName,
PromoterType: in.PromoterType,
}
// 下单未携带推广方时,交给业务模块推导(如租户的签约渠道伙伴)
if order.PromoterID == "" && promoterResolve != nil {
if pid, pname, ptype, ok := promoterResolve(ctx, order); ok {
order.PromoterID, order.PromoterName, order.PromoterType = pid, pname, ptype
}
}
if _, err := models.Orm.Insert(order); err != nil {
return nil, nil, fmt.Errorf("创建支付单失败: %w", err)
}
LogStateChange(order.PayNo, "", models.PayStatusCreated, "租户端下单页", "创建支付单,渠道:"+cfg.Name)
params, err := adapter.Prepay(ctx, order, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID})
if err != nil {
// 渠道下单失败:保留支付单并置为 failed,便于排查与重下
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"status": models.PayStatusFailed, "update_time": now})
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusFailed, "支付服务", "渠道下单失败:"+err.Error())
return nil, nil, err
}
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusCreated, "支付服务", "渠道下单成功,返回支付参数")
if params.ChannelTradeNo != "" {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"channel_trade_no": params.ChannelTradeNo, "update_time": time.Now()})
order.ChannelTradeNo = params.ChannelTradeNo
}
return order, params, nil
}
// QueryPayment 查询支付单;syncChannel=true 时主动向渠道查询并同步状态(兜底回调丢失)
func QueryPayment(ctx context.Context, payNo string, syncChannel bool) (*models.PlatformPaymentOrder, error) {
order, err := GetOrderByPayNo(payNo)
if err != nil {
return nil, fmt.Errorf("支付单不存在")
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"last_query_at": now, "update_time": now})
order.LastQueryAt = &now
if !syncChannel {
return order, nil
}
adapter, aerr := GetChannelAdapter(order.Channel)
cfg, cerr := LoadChannelConfig(order.Channel)
if aerr != nil || cerr != nil {
return order, nil
}
st, qerr := adapter.Query(ctx, order, cfg)
if qerr != nil {
LogStateChange(order.PayNo, order.Status, order.Status, "手动查询", "查询渠道状态失败:"+qerr.Error())
return order, nil
}
if _, serr := applyChannelState(ctx, order, st, "手动查询", "主动查询渠道状态同步"); serr != nil {
beelog.Warn("支付单 %s 渠道状态同步失败: %v", order.PayNo, serr)
}
return GetOrderByPayNo(payNo)
}
// HandleNotify 渠道异步通知统一入口:验签 -> 幂等 -> 状态机 -> 佣金,返回需回给渠道的响应体
func HandleNotify(ctx context.Context, channel string, r *http.Request, ip string) (string, error) {
adapter, err := GetChannelAdapter(channel)
if err != nil {
return "", err
}
cfg, err := LoadChannelConfig(channel) // 停用渠道的历史回调仍需处理
if err != nil {
return "", err
}
result, err := adapter.ParseNotify(ctx, r, cfg)
if err != nil {
insertCallbackLog(channel, "", "", "", 0, 0, false, false, err.Error(), "", ip)
return "", err
}
// 幂等:同一事件(channel + event_id)已成功处理过则直接命中
duplicate := existsCallbackHandled(channel, result.EventID, result.ChannelTradeNo, result.EventType)
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, duplicate, false, "", result.Raw, ip)
if duplicate {
return result.AckBody, nil
}
order, oerr := GetOrderByPayNo(result.PayNo)
if oerr != nil {
// 再尝试按业务订单号找最近一笔(回调里可能只回传 out_trade_no)
order, oerr = findLatestOrderByOutTradeNo(result.OutTradeNo)
}
if oerr != nil {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "支付单不存在", result.Raw, ip)
return "", fmt.Errorf("支付单不存在: %s", result.PayNo)
}
// 非成功/失败/关闭事件只记录日志
if result.TradeState != StateSuccess && result.TradeState != StateFailed && result.TradeState != StateClosed {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "事件无需变更状态: "+result.EventType, result.Raw, ip)
return result.AckBody, nil
}
changed, serr := applyChannelState(ctx, order, &ChannelState{
ChannelTradeNo: result.ChannelTradeNo,
TradeState: result.TradeState,
Amount: result.Amount,
}, "渠道回调", "渠道通知同步,事件:"+result.EventType)
if serr != nil {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "状态更新失败: "+serr.Error(), result.Raw, ip)
return "", serr
}
if !changed {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态未变化(幂等)", result.Raw, ip)
} else {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态已同步为 "+result.TradeState, result.Raw, ip)
}
return result.AckBody, nil
}
// existsCallbackHandled 是否已存在处理成功的同事件回调
func existsCallbackHandled(channel, eventID, channelTradeNo, eventType string) bool {
qs := models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)).
Filter("channel", channel).
Filter("handle_result", 1).
Filter("delete_time__isnull", true)
if eventID != "" {
qs = qs.Filter("event_id", eventID)
} else {
qs = qs.Filter("channel_trade_no", channelTradeNo).Filter("event_type", eventType)
}
cnt, _ := qs.Count()
return cnt > 0
}
func insertCallbackLog(channel, payNo, outTradeNo, channelTradeNo string, amount int64, verifyResult int8, duplicate, handled bool, msg, raw, ip string) {
row := &models.PlatformPaymentCallbackLog{
Channel: channel,
PayNo: payNo,
OutTradeNo: outTradeNo,
ChannelTradeNo: channelTradeNo,
Amount: amount,
VerifyResult: verifyResult,
HandleMsg: msg,
ClientIP: ip,
}
if duplicate {
row.IsDuplicate = 1
}
if handled {
row.HandleResult = 1
}
if raw != "" {
raw = truncateStr(raw, 60000)
row.RawBody = &raw
}
if _, err := models.Orm.Insert(row); err != nil {
beelog.Warn("回调日志写入失败: %v", err)
}
}
func findLatestOrderByOutTradeNo(outTradeNo string) (*models.PlatformPaymentOrder, error) {
row := &models.PlatformPaymentOrder{}
err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("out_trade_no", outTradeNo).
Filter("delete_time__isnull", true).
OrderBy("-id").
One(row)
return row, err
}
// applyChannelState 把渠道状态落到本地(回调与主动查询共用),返回是否发生状态变更。
// 支付成功采用条件更新(仅 created/pending/paying 可置为 paid)保证并发幂等。
func applyChannelState(ctx context.Context, order *models.PlatformPaymentOrder, st *ChannelState, operator, remark string) (bool, error) {
if st == nil {
return false, nil
}
now := time.Now()
active := []interface{}{models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying}
switch st.TradeState {
case StateSuccess:
if order.Status == models.PayStatusPaid {
if order.ChannelTradeNo == "" && st.ChannelTradeNo != "" {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"channel_trade_no": st.ChannelTradeNo, "update_time": now})
}
return false, nil
}
note := remark
if st.Amount > 0 && st.Amount != order.Amount {
note = fmt.Sprintf("%s(注意:渠道金额 %d 分与本地 %d 分不一致,请核对)", remark, st.Amount, order.Amount)
}
updates := orm.Params{
"status": models.PayStatusPaid,
"paid_at": now,
"notify_at": now,
"order_synced": 0,
"update_time": now,
}
if st.ChannelTradeNo != "" {
updates["channel_trade_no"] = st.ChannelTradeNo
}
n, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Filter("status__in", active...).
Update(updates)
if err != nil {
return false, err
}
if n == 0 {
return false, nil // 并发下已被其他通知处理
}
order.Status = models.PayStatusPaid
order.PaidAt = &now
if st.ChannelTradeNo != "" {
order.ChannelTradeNo = st.ChannelTradeNo
}
LogStateChange(order.PayNo, models.PayStatusPaying, models.PayStatusPaid, operator, note)
// 1. 同步业务订单状态(钩子;失败保留 order_synced=0 由补偿任务重试)
if orderSyncHook == nil {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
} else if serr := orderSyncHook(ctx, order); serr != nil {
LogStateChange(order.PayNo, models.PayStatusPaid, models.PayStatusPaid, operator, "业务订单同步失败:"+serr.Error())
} else {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
}
// 2. 生成推广佣金(平台自有资金支出,失败不影响收款状态)
if _, cerr := OnOrderPaid(ctx, order); cerr != nil {
beelog.Warn("支付单 %s 佣金生成失败: %v", order.PayNo, cerr)
}
return true, nil
case StateClosed:
if order.Status == models.PayStatusPaid {
return false, nil
}
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Filter("status__in", active...).
Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now})
if err != nil {
return false, err
}
order.Status = models.PayStatusClosed
LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusClosed, operator, remark)
return true, nil
case StateFailed:
if order.Status == models.PayStatusPaid {
return false, nil
}
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Filter("status__in", active...).
Update(orm.Params{"status": models.PayStatusFailed, "update_time": now})
if err != nil {
return false, err
}
order.Status = models.PayStatusFailed
LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusFailed, operator, remark)
return true, nil
case StatePending:
if order.Status == models.PayStatusCreated {
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"status": models.PayStatusPending, "update_time": now})
if err != nil {
return false, err
}
order.Status = models.PayStatusPending
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusPending, operator, remark)
return true, nil
}
return false, nil
default:
return false, nil
}
}
// RefundInput 手动原路退回入参(兜底能力)
type RefundInput struct {
PayNo string
Amount int64 // 分;<=0 表示全额退回
Reason string // 必填
OperatorID string
OperatorName string
}
// ManualRefund 手动原路退回(不做业务层退款流程)
func ManualRefund(ctx context.Context, in RefundInput) (*models.PlatformPaymentRefund, error) {
if strings.TrimSpace(in.Reason) == "" {
return nil, errors.New("退回原因不能为空")
}
order, err := GetOrderByPayNo(in.PayNo)
if err != nil {
return nil, fmt.Errorf("支付单不存在")
}
if order.Status != models.PayStatusPaid {
return nil, fmt.Errorf("仅支付成功的支付单支持原路退回(当前状态 %s)", order.Status)
}
remain := order.Amount - order.RefundAmount
amount := in.Amount
if amount <= 0 {
amount = remain
}
if amount > remain {
return nil, fmt.Errorf("退回金额 %d 分超过可退余额 %d 分", amount, remain)
}
refund := &models.PlatformPaymentRefund{
RefundNo: NextRefundNo(),
PayNo: order.PayNo,
OutTradeNo: order.OutTradeNo,
Channel: order.Channel,
Amount: amount,
Reason: in.Reason,
Status: models.RefundStatusProcessing,
OperatorID: in.OperatorID,
OperatorName: in.OperatorName,
}
if _, err := models.Orm.Insert(refund); err != nil {
return nil, fmt.Errorf("创建退回单失败: %w", err)
}
adapter, aerr := GetChannelAdapter(order.Channel)
cfg, cerr := LoadChannelConfig(order.Channel)
if aerr != nil || cerr != nil {
failRefund(refund, "渠道适配器/配置不可用")
return refund, fmt.Errorf("渠道适配器/配置不可用")
}
channelRefundNo, rerr := adapter.Refund(ctx, order, refund.RefundNo, amount, in.Reason, cfg)
now := time.Now()
if rerr != nil {
failRefund(refund, rerr.Error())
LogStateChange(order.PayNo, order.Status, order.Status, "平台管理员", "手动原路退回失败:"+rerr.Error())
return refund, rerr
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)).
Filter("id", refund.ID).
Update(orm.Params{"status": models.RefundStatusSuccess, "channel_refund_no": channelRefundNo, "finished_at": now, "update_time": now})
refund.Status = models.RefundStatusSuccess
refund.ChannelRefundNo = channelRefundNo
refund.FinishedAt = &now
newRefunded := order.RefundAmount + amount
toStatus := models.PayStatusRefunding
if newRefunded >= order.Amount {
toStatus = models.PayStatusRefunded
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"refund_amount": newRefunded, "status": toStatus, "update_time": now})
order.RefundAmount = newRefunded
order.Status = toStatus
LogStateChange(order.PayNo, models.PayStatusPaid, toStatus, "平台管理员", fmt.Sprintf("手动原路退回 ¥%s:%s", FenToYuan(amount), in.Reason))
return refund, nil
}
func failRefund(refund *models.PlatformPaymentRefund, msg string) {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)).
Filter("id", refund.ID).
Update(orm.Params{"status": models.RefundStatusFailed, "fail_reason": truncateStr(msg, 480), "update_time": time.Now()})
refund.Status = models.RefundStatusFailed
refund.FailReason = msg
}
// CloseExpiredOrders 关闭超时未支付订单(补偿任务),返回关闭数量
func CloseExpiredOrders(ctx context.Context) (int, error) {
var orders []models.PlatformPaymentOrder
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("delete_time__isnull", true).
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
Filter("expire_at__lt", time.Now()).
All(&orders)
if err != nil {
return 0, err
}
closed := 0
now := time.Now()
for i := range orders {
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", orders[i].ID).
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now})
if err != nil {
continue
}
LogStateChange(orders[i].PayNo, orders[i].Status, models.PayStatusClosed, "定时补偿", "支付超时自动关闭")
closed++
}
return closed, nil
}
// SyncPendingOrders 重试同步「已支付但业务订单未更新」的支付单(补偿任务),返回处理数量
func SyncPendingOrders(ctx context.Context) (int, error) {
if orderSyncHook == nil {
return 0, nil
}
var orders []models.PlatformPaymentOrder
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("delete_time__isnull", true).
Filter("status", models.PayStatusPaid).
Filter("order_synced", 0).
All(&orders)
if err != nil {
return 0, err
}
done := 0
for i := range orders {
if serr := orderSyncHook(ctx, &orders[i]); serr != nil {
beelog.Warn("支付单 %s 业务订单同步重试失败: %v", orders[i].PayNo, serr)
continue
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", orders[i].ID).
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
LogStateChange(orders[i].PayNo, models.PayStatusPaid, models.PayStatusPaid, "定时补偿", "业务订单同步成功(重试)")
done++
}
return done, nil
}
// StartPaymentScheduler 支付补偿任务:关闭超时订单 + 重试业务订单同步(5 分钟一轮)
func StartPaymentScheduler(stop <-chan struct{}) {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
ctx := context.Background()
if n, err := CloseExpiredOrders(ctx); err != nil {
beelog.Warn("支付超时关单失败: %v", err)
} else if n > 0 {
beelog.Info("支付超时关单 %d 笔", n)
}
if n, err := SyncPendingOrders(ctx); err != nil {
beelog.Warn("业务订单同步重试失败: %v", err)
} else if n > 0 {
beelog.Info("业务订单同步重试完成 %d 笔", n)
}
}
}
}()
}