增加支付功能
This commit is contained in:
+11
-1
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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='平台推广佣金单表(台账)';
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" // 下期抵扣
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 租户角色管理路由。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "云闪付" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "******")
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user