增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+11 -1
View File
@@ -51,4 +51,14 @@ acme_directory =
# 证书落盘根目录。签发后生成 {ssl_cert_dir}/{域名}/fullchain.pem 与 privkey.pem,
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
ssl_cert_dir = /www/wwwroot/ssl-certs
ssl_cert_dir = /www/wwwroot/ssl-certs
# ==================== 支付模块 ====================
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
payment_secret_key = f656fe85ed6a1caba9f19966d8cdde46114709969bea5875cde7664d4ad9f535
# 渠道异步回调的对外基址:最终回调地址 = {payment_callback_base}/api/payment/callback/{渠道}
# TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。
# 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。
# 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。
payment_callback_base = https://api.yunzer.com.cn
+214
View File
@@ -0,0 +1,214 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendPaymentController 租户端支付接口(下单页 / 收银台)
// 租户端身份为 jwt UserType=backend,TenantId 即租户ID。
type BackendPaymentController struct {
beego.Controller
}
func (c *BackendPaymentController) backendClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "backend" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *BackendPaymentController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendPaymentController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func (c *BackendPaymentController) tenantID() string {
claims, _ := c.backendClaims()
if claims == nil {
return ""
}
return strconv.Itoa(claims.TenantId)
}
// tenantName 查询租户名称(下单快照用)
func (c *BackendPaymentController) tenantName() string {
claims, err := c.backendClaims()
if err != nil || claims.TenantId <= 0 {
return ""
}
tenant := &models.SystemTenant{}
if err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", uint64(claims.TenantId)).
Filter("delete_time__isnull", true).
One(tenant); err != nil {
return ""
}
return tenant.TenantName
}
// Channels GET /backend/payment/channels 收银台可用渠道(已启用且参数完整)
func (c *BackendPaymentController) Channels() {
if _, err := c.backendClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.PlatformPaymentChannel
_, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("enabled", 1).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询渠道失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
cfg, err := payment.LoadChannelConfig(rows[i].Channel)
if err != nil || len(cfg.Params) == 0 {
continue // 参数不完整的渠道不下发
}
extra := map[string]interface{}{}
for k, v := range cfg.Extra {
extra[k] = v
}
list = append(list, map[string]interface{}{
"channel": rows[i].Channel, "name": rows[i].Name, "extra": extra,
})
}
c.ok(map[string]interface{}{"list": list})
}
// Create POST /backend/payment/create 创建支付单并返回渠道支付参数
// 入参:{ out_trade_no, order_type, amount(分), channel, subject, return_url, pay_type, openid,
//
// promoter_id?, promoter_name?, promoter_type?, expire_minutes? }
//
// 出参:{ pay_no, channel, pay_params }
func (c *BackendPaymentController) Create() {
claims, err := c.backendClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
OutTradeNo string `json:"out_trade_no"`
OrderType string `json:"order_type"`
Amount int64 `json:"amount"`
Channel string `json:"channel"`
Subject string `json:"subject"`
ReturnURL string `json:"return_url"`
PayType string `json:"pay_type"`
OpenID string `json:"openid"`
PromoterID string `json:"promoter_id"`
PromoterName string `json:"promoter_name"`
PromoterType string `json:"promoter_type"`
ExpireMinutes int `json:"expire_minutes"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.OutTradeNo) == "" {
c.jsonErr(400, 400, "业务订单号不能为空")
return
}
if p.Amount <= 0 {
c.jsonErr(400, 400, "支付金额必须大于 0")
return
}
if strings.TrimSpace(p.Channel) == "" {
c.jsonErr(400, 400, "请选择支付渠道")
return
}
if p.OrderType == "" {
p.OrderType = models.CommissionOrderTypePlatformUsage
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
order, params, err := payment.CreatePayment(ctx, payment.CreateInput{
OutTradeNo: strings.TrimSpace(p.OutTradeNo),
OrderType: p.OrderType,
TenantID: strconv.Itoa(claims.TenantId),
TenantName: c.tenantName(),
Amount: p.Amount,
Channel: strings.TrimSpace(p.Channel),
Subject: strings.TrimSpace(p.Subject),
ReturnURL: strings.TrimSpace(p.ReturnURL),
ClientIP: c.Ctx.Input.IP(),
PayType: p.PayType,
OpenID: p.OpenID,
PromoterID: strings.TrimSpace(p.PromoterID),
PromoterName: strings.TrimSpace(p.PromoterName),
PromoterType: strings.TrimSpace(p.PromoterType),
ExpireMinutes: p.ExpireMinutes,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"pay_no": order.PayNo,
"channel": order.Channel,
"status": order.Status,
"pay_params": params,
})
}
// Query GET /backend/payment/query/:payNo 查询支付单(仅本租户)
func (c *BackendPaymentController) Query() {
if _, err := c.backendClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
row, err := payment.QueryPayment(ctx, payNo, true)
if err != nil {
c.jsonErr(404, 404, "支付单不存在")
return
}
if row.TenantID != c.tenantID() {
c.jsonErr(403, 403, "无权访问该支付单")
return
}
c.ok(map[string]interface{}{
"pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"amount": row.Amount, "channel": row.Channel, "status": row.Status,
"paid_at": row.PaidAt, "create_time": row.CreateTime,
})
}
+427
View File
@@ -0,0 +1,427 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformCommissionController 平台端推广佣金(台账 / 统计 / 规则维护)
type PlatformCommissionController struct {
beego.Controller
}
func (c *PlatformCommissionController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
func (c *PlatformCommissionController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformCommissionController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// commissionRuleDTO 规则出参。
// 比例值统一按「百分比」返回:库里存百分比×100(500 = 5%),接口返回 5,便于前端直接编辑。
func commissionRuleDTO(row *models.PlatformCommissionRule) map[string]interface{} {
dto := map[string]interface{}{
"id": row.ID, "rule_name": row.RuleName, "calc_type": row.CalcType,
"order_type": row.OrderType, "status": row.Status, "remark": row.Remark,
"create_user_id": row.CreateUserID, "create_user_name": row.CreateUserName,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
"steps": []map[string]interface{}{},
}
if row.CalcType == models.CommissionCalcFixed {
dto["value"] = row.Value // 固定金额:分
} else {
dto["value"] = float64(row.Value) / 100 // 比例:5% -> 5
}
if row.CalcType == models.CommissionCalcTiered && row.TierJSON != nil && *row.TierJSON != "" {
var tiers []models.CommissionTier
if err := json.Unmarshal([]byte(*row.TierJSON), &tiers); err == nil {
steps := make([]map[string]interface{}, 0, len(tiers))
for _, t := range tiers {
var maxV interface{}
if t.Max != nil {
maxV = *t.Max
}
steps = append(steps, map[string]interface{}{
"min": t.Min, "max": maxV, "rate": float64(t.Rate) / 100,
})
}
dto["steps"] = steps
}
}
return dto
}
// commissionOrderDTO 佣金单出参
func commissionOrderDTO(row *models.PlatformCommissionOrder) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "commission_no": row.CommissionNo,
"pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"rule_id": row.RuleID, "rule_name": row.RuleName, "calc_type": row.CalcType,
"promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType,
"base_amount": row.BaseAmount, "amount": row.Amount,
"status": row.Status, "pay_method": row.PayMethod, "pay_voucher": row.PayVoucher,
"paid_at": row.PaidAt, "paid_user_id": row.PaidUserID, "paid_user_name": row.PaidUserName,
"remark": row.Remark, "create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// List GET /platform/commission/list 佣金台账
func (c *PlatformCommissionController) List() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
rows, total, err := payment.ListCommissions(payment.CommissionListInput{
Promoter: strings.TrimSpace(c.GetString("promoter")),
Status: strings.TrimSpace(c.GetString("status")),
StartTime: strings.TrimSpace(c.GetString("start_time")),
EndTime: strings.TrimSpace(c.GetString("end_time")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformCommissionOrder{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, commissionOrderDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// Stats GET /platform/commission/stats 台账统计
func (c *PlatformCommissionController) Stats() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
stats, err := payment.GetCommissionStats()
if err != nil {
c.jsonErr(500, 500, "统计失败: "+err.Error())
return
}
c.ok(stats)
}
// MarkPaid POST /platform/commission/:id/pay 标记已付(发放登记)
func (c *PlatformCommissionController) MarkPaid() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
PayMethod string `json:"pay_method"`
PayVoucher string `json:"pay_voucher"`
Remark string `json:"remark"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.PayMethod) == "" {
c.jsonErr(400, 400, "请选择发放方式")
return
}
row, err := payment.MarkCommissionPaid(context.Background(), payment.MarkCommissionPaidInput{
ID: id, PayMethod: strings.TrimSpace(p.PayMethod), PayVoucher: strings.TrimSpace(p.PayVoucher),
Remark: strings.TrimSpace(p.Remark),
OperatorID: fmt.Sprintf("%d", claims.UserID), OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(commissionOrderDTO(row))
}
/* ---------------- 佣金规则 ---------------- */
type commissionRulePayload struct {
RuleName string `json:"rule_name"`
CalcType string `json:"calc_type"`
Value *float64 `json:"value"`
Steps []struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Rate *float64 `json:"rate"`
} `json:"steps"`
OrderType string `json:"order_type"`
Status *int8 `json:"status"`
Remark string `json:"remark"`
}
// buildTierJSON 组装阶梯配置(金额分;rate 存百分比×100)
func buildTierJSON(steps []struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Rate *float64 `json:"rate"`
}) (string, error) {
if len(steps) == 0 {
return "", fmt.Errorf("阶梯规则至少需要一档")
}
tiers := make([]models.CommissionTier, 0, len(steps))
for i, s := range steps {
if s.Min == nil || s.Rate == nil {
return "", fmt.Errorf("第 %d 档的起始金额与比例必填", i+1)
}
tier := models.CommissionTier{
Min: int64(*s.Min + 0.5),
Rate: int64(*s.Rate*100 + 0.5),
}
if s.Max != nil && *s.Max > 0 {
mx := int64(*s.Max + 0.5)
tier.Max = &mx
if mx <= tier.Min {
return "", fmt.Errorf("第 %d 档结束金额需大于起始金额", i+1)
}
}
if tier.Rate <= 0 {
return "", fmt.Errorf("第 %d 档比例需大于 0", i+1)
}
tiers = append(tiers, tier)
}
b, err := json.Marshal(tiers)
return string(b), err
}
// RulesList GET /platform/commission/rules 规则列表
func (c *PlatformCommissionController) RulesList() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "20"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 20
}
qs := models.Orm.QueryTable(new(models.PlatformCommissionRule)).Filter("delete_time__isnull", true)
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
qs = qs.Filter("rule_name__icontains", kw)
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
var rows []models.PlatformCommissionRule
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformCommissionRule{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, commissionRuleDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// RuleCreate POST /platform/commission/rules 新增规则
func (c *PlatformCommissionController) RuleCreate() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p commissionRulePayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.RuleName) == "" {
c.jsonErr(400, 400, "规则名称不能为空")
return
}
row := &models.PlatformCommissionRule{
RuleName: strings.TrimSpace(p.RuleName),
CalcType: strings.TrimSpace(p.CalcType),
OrderType: strings.TrimSpace(p.OrderType),
Status: 1,
Remark: strings.TrimSpace(p.Remark),
CreateUserID: fmt.Sprintf("%d", claims.UserID),
CreateUserName: claims.Username,
}
if p.Status != nil {
row.Status = *p.Status
}
if err := applyCommissionRuleValues(row, &p); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
if _, err := models.Orm.Insert(row); err != nil {
c.jsonErr(500, 500, "创建失败: "+err.Error())
return
}
c.ok(commissionRuleDTO(row))
}
// RuleUpdate POST /platform/commission/rules/:id 更新规则
func (c *PlatformCommissionController) RuleUpdate() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
row := &models.PlatformCommissionRule{}
if err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("id", id).Filter("delete_time__isnull", true).One(row); err != nil {
c.jsonErr(404, 404, "规则不存在")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p commissionRulePayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if name := strings.TrimSpace(p.RuleName); name != "" {
row.RuleName = name
}
if calc := strings.TrimSpace(p.CalcType); calc != "" {
row.CalcType = calc
}
if ot := strings.TrimSpace(p.OrderType); ot != "" {
row.OrderType = ot
}
row.Remark = strings.TrimSpace(p.Remark)
if p.Status != nil {
row.Status = *p.Status
}
if err := applyCommissionRuleValues(row, &p); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
if _, err := models.Orm.Update(row); err != nil {
c.jsonErr(500, 500, "更新失败: "+err.Error())
return
}
c.ok(commissionRuleDTO(row))
}
// applyCommissionRuleValues 按计算方式写入 value / tier_json
func applyCommissionRuleValues(row *models.PlatformCommissionRule, p *commissionRulePayload) error {
switch row.CalcType {
case models.CommissionCalcPercent:
if p.Value == nil || *p.Value <= 0 {
return fmt.Errorf("请输入大于 0 的比例值")
}
row.Value = int64(*p.Value*100 + 0.5)
row.TierJSON = nil
case models.CommissionCalcFixed:
if p.Value == nil || *p.Value <= 0 {
return fmt.Errorf("请输入大于 0 的固定金额(分)")
}
row.Value = int64(*p.Value + 0.5)
row.TierJSON = nil
case models.CommissionCalcTiered:
tierJSON, err := buildTierJSON(p.Steps)
if err != nil {
return err
}
row.Value = 0
row.TierJSON = &tierJSON
default:
return fmt.Errorf("计算方式不合法")
}
switch row.OrderType {
case models.CommissionOrderTypeAll, models.CommissionOrderTypePlatformUsage,
models.CommissionOrderTypeModuleShop, models.CommissionOrderTypeServiceFee:
default:
return fmt.Errorf("适用订单类型不合法")
}
return nil
}
// RuleDelete DELETE /platform/commission/rules/:id 删除规则(软删除;历史佣金单不受影响)
func (c *PlatformCommissionController) RuleDelete() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("id", id).
Update(orm.Params{"delete_time": now, "update_time": now}); err != nil {
c.jsonErr(500, 500, "删除失败: "+err.Error())
return
}
c.ok(nil)
}
// RuleToggle POST /platform/commission/rules/:id/status 启用/停用
func (c *PlatformCommissionController) RuleToggle() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Status *int8 `json:"status"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Status == nil {
c.jsonErr(400, 400, "参数错误")
return
}
if _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("id", id).
Update(orm.Params{"status": *p.Status, "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "操作失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"status": *p.Status})
}
+615
View File
@@ -0,0 +1,615 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformPaymentController 平台端支付管理(渠道配置 / 支付订单 / 兜底退回 / 渠道回调)
type PlatformPaymentController struct {
beego.Controller
}
func (c *PlatformPaymentController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
// paymentPlatformClaims 平台端鉴权(支付相关控制器共用),传各控制器内嵌的 &c.Controller
func paymentPlatformClaims(c *beego.Controller) (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *PlatformPaymentController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformPaymentController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// channelSecretKeys 各渠道的敏感参数键(接口回显时只回掩码;提交掩码/空串表示不修改)
var channelSecretKeys = map[string][]string{
payment.ChannelWechat: {"api_v3_key"},
payment.ChannelAlipay: {"app_private_key"},
payment.ChannelUnionPay: {"cert_password"},
payment.ChannelCloudPay: {"cert_password"},
payment.ChannelPayPal: {"client_secret"},
}
func channelRowDTO(row *models.PlatformPaymentChannel) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "channel": row.Channel, "name": row.Name,
"merchant_no": row.MerchantNo, "callback_url": row.CallbackURL,
"enabled": row.Enabled == 1,
"remark": row.Remark,
"last_test_time": row.LastTestTime, "last_test_result": row.LastTestResult,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// maskedConfigView 解密配置并掩码敏感字段,供前端回显
func maskedConfigView(cfg *payment.ChannelConfig, row *models.PlatformPaymentChannel) map[string]interface{} {
view := map[string]interface{}{}
for k, v := range cfg.Params {
view[k] = v
}
for _, key := range channelSecretKeys[row.Channel] {
if v, ok := view[key]; ok {
if s, ok2 := v.(string); ok2 && s != "" {
view[key] = payment.MaskSecret(s)
}
}
}
return view
}
func findChannelRow(channel string) (*models.PlatformPaymentChannel, error) {
row := &models.PlatformPaymentChannel{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
type paymentChannelPayload struct {
Name string `json:"name"`
MerchantNo string `json:"merchant_no"`
CallbackURL string `json:"callback_url"`
Remark string `json:"remark"`
Config map[string]string `json:"config"`
Extra map[string]any `json:"extra"`
}
// loadMergedConfig 加载已存配置并把「新填写且非掩码」的值合并进去(用于保存与测试连接共用)
func loadMergedConfig(channel string, p *paymentChannelPayload) (*payment.ChannelConfig, error) {
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
return nil, err
}
if p == nil {
return cfg, nil
}
for k, v := range p.Config {
v = strings.TrimSpace(v)
if v == "" || payment.IsMasked(v) {
continue // 空串 / 掩码 = 保持原值
}
cfg.Params[k] = v
}
if p.Extra != nil {
cfg.Extra = p.Extra
}
return cfg, nil
}
// ListChannels GET /platform/payment/channels 渠道概览
func (c *PlatformPaymentController) ListChannels() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.PlatformPaymentChannel
_, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询渠道失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformPaymentChannel{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, channelRowDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": len(list)})
}
// GetChannel GET /platform/payment/channels/:channel 渠道配置详情(敏感字段掩码)
func (c *PlatformPaymentController) GetChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
row, err := findChannelRow(channel)
if err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
dto := channelRowDTO(row)
dto["config"] = maskedConfigView(cfg, row)
dto["extra"] = cfg.Extra
dto["cert_files"] = cfg.CertPaths
c.ok(dto)
}
// SaveChannel POST /platform/payment/channels/:channel 保存渠道配置(敏感字段留空/掩码表示不修改)
func (c *PlatformPaymentController) SaveChannel() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
if _, err := findChannelRow(channel); err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p paymentChannelPayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
merged, err := loadMergedConfig(channel, &p)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
plain, _ := json.Marshal(merged.Params)
enc, err := payment.EncryptConfig(string(plain))
if err != nil {
c.jsonErr(500, 500, "渠道参数加密失败: "+err.Error())
return
}
extraJSON := ""
if p.Extra != nil {
b, _ := json.Marshal(p.Extra)
extraJSON = string(b)
}
now := time.Now()
updates := orm.Params{
"config_json": enc,
"merchant_no": strings.TrimSpace(p.MerchantNo),
"remark": strings.TrimSpace(p.Remark),
"update_time": now,
}
if p.Name != "" {
updates["name"] = strings.TrimSpace(p.Name)
}
if p.CallbackURL != "" {
updates["callback_url"] = strings.TrimSpace(p.CallbackURL)
}
if extraJSON != "" {
updates["extra_json"] = extraJSON
}
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(updates); err != nil {
c.jsonErr(500, 500, "保存失败: "+err.Error())
return
}
_ = claims
row, _ := findChannelRow(channel)
c.ok(channelRowDTO(row))
}
// TestChannel POST /platform/payment/channels/:channel/test 测试连接(支持未保存参数)
func (c *PlatformPaymentController) TestChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p paymentChannelPayload
_ = json.Unmarshal(body, &p) // 允许空 body:只测已保存配置
merged, err := loadMergedConfig(channel, &p)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
adapter, err := payment.GetChannelAdapter(channel)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
msg, err := adapter.TestConnect(ctx, merged)
now := time.Now()
result := msg
if err != nil {
result = err.Error()
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"last_test_time": now, "last_test_result": result, "update_time": now})
if err != nil {
c.jsonErr(400, 400, result)
return
}
c.ok(map[string]interface{}{"message": result})
}
// ToggleChannel POST /platform/payment/channels/:channel/status 启用/停用
func (c *PlatformPaymentController) ToggleChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Enabled *bool `json:"enabled"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Enabled == nil {
c.jsonErr(400, 400, "参数错误")
return
}
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
if *p.Enabled && len(cfg.Params) == 0 {
c.jsonErr(400, 400, "渠道参数未配置,无法启用")
return
}
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"enabled": b2i(*p.Enabled), "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "操作失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"enabled": *p.Enabled})
}
func b2i(v bool) int8 {
if v {
return 1
}
return 0
}
// UploadCertificate POST /platform/payment/channels/:channel/certificate 上传渠道证书
// 证书文件保存在服务端 certs/payment/<channel>/ 下(0600 权限,不对外提供访问),
// 文件路径写入 cert_json,密钥内容绝不入库、不回显。
func (c *PlatformPaymentController) UploadCertificate() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
if _, err := findChannelRow(channel); err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
certType := strings.TrimSpace(c.GetString("cert_type"))
if certType == "" {
certType = "cert"
}
file, header, err := c.GetFile("file")
if err != nil || header == nil {
c.jsonErr(400, 400, "请选择证书文件")
return
}
defer func() { _ = file.Close() }()
dir := filepath.Join("certs", "payment", channel)
if err := os.MkdirAll(dir, 0o700); err != nil {
c.jsonErr(500, 500, "创建证书目录失败: "+err.Error())
return
}
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" {
ext = ".pem"
}
name := fmt.Sprintf("%s_%d%s", certType, time.Now().UnixNano(), ext)
savePath := filepath.Join(dir, name)
out, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
c.jsonErr(500, 500, "保存证书失败: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
_ = out.Close()
c.jsonErr(500, 500, "保存证书失败: "+err.Error())
return
}
_ = out.Close()
// 更新 cert_json
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
if cfg.CertPaths == nil {
cfg.CertPaths = map[string]string{}
}
certKey := certType + "_path"
cfg.CertPaths[certKey] = savePath
if _, err := os.Stat(savePath); err == nil {
// 同步补充对应关系:cert -> cert_path,key -> key_path
if certType == "cert" {
cfg.CertPaths["cert_path"] = savePath
} else {
cfg.CertPaths["key_path"] = savePath
}
}
certJSON, _ := json.Marshal(cfg.CertPaths)
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"cert_json": string(certJSON), "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "更新证书信息失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"file": header.Filename, "path": savePath, "cert_type": certType})
}
/* ---------------- 支付订单 ---------------- */
func paymentOrderDTO(row *models.PlatformPaymentOrder) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"order_type": row.OrderType, "subject": row.Subject,
"tenant_id": row.TenantID, "tenant_name": row.TenantName,
"amount": row.Amount, "order_amount": row.OrderAmount, "currency": row.Currency,
"channel": row.Channel, "merchant_no": row.MerchantNo, "channel_trade_no": row.ChannelTradeNo,
"status": row.Status, "refund_amount": row.RefundAmount,
"return_url": row.ReturnURL, "notify_url": row.NotifyURL,
"expire_at": row.ExpireAt, "paid_at": row.PaidAt, "closed_at": row.ClosedAt,
"notify_at": row.NotifyAt, "last_query_at": row.LastQueryAt,
"order_synced": row.OrderSynced,
"promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// ListOrders GET /platform/payment/orders 支付单列表
func (c *PlatformPaymentController) ListOrders() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 10
}
qs := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).Filter("delete_time__isnull", true)
if v := strings.TrimSpace(c.GetString("tenant_id")); v != "" {
qs = qs.Filter("tenant_id", v)
}
if v := strings.TrimSpace(c.GetString("out_trade_no")); v != "" {
qs = qs.Filter("out_trade_no__icontains", v)
}
if v := strings.TrimSpace(c.GetString("pay_no")); v != "" {
qs = qs.Filter("pay_no__icontains", v)
}
if v := strings.TrimSpace(c.GetString("channel")); v != "" {
qs = qs.Filter("channel", v)
}
if v := strings.TrimSpace(c.GetString("status")); v != "" {
qs = qs.Filter("status", v)
}
if v := strings.TrimSpace(c.GetString("start_time")); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
qs = qs.Filter("create_time__gte", t)
}
}
if v := strings.TrimSpace(c.GetString("end_time")); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1))
}
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
var rows []models.PlatformPaymentOrder
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, paymentOrderDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// OrderDetail GET /platform/payment/orders/:payNo 支付单详情(含状态流转 + 最近回调原文)
func (c *PlatformPaymentController) OrderDetail() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
row, err := payment.GetOrderByPayNo(payNo)
if err != nil {
c.jsonErr(404, 404, "支付单不存在")
return
}
dto := paymentOrderDTO(row)
// 状态流转记录
var logs []models.PlatformPaymentOrderLog
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrderLog)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&logs)
records := make([]map[string]interface{}, 0, len(logs))
for i := range logs {
records = append(records, map[string]interface{}{
"to_status": logs[i].ToStatus, "operator": logs[i].Operator,
"remark": logs[i].Remark, "create_time": logs[i].CreateTime,
})
}
dto["records"] = records
// 最近一次成功回调的原文(来自 yz_platform_payment_callback_log.raw_body)
var cb models.PlatformPaymentCallbackLog
err = models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
OrderBy("-id").
One(&cb)
if err == nil && cb.RawBody != nil {
dto["callback_raw"] = *cb.RawBody
dto["notify_at"] = cb.CreateTime
}
c.ok(dto)
}
// QueryOrder POST /platform/payment/orders/:payNo/query 手动查询渠道状态(兜底回调丢失)
func (c *PlatformPaymentController) QueryOrder() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Sync *bool `json:"sync"`
}
_ = json.Unmarshal(body, &p)
sync := true
if p.Sync != nil {
sync = *p.Sync
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
row, err := payment.QueryPayment(ctx, payNo, sync)
if err != nil {
c.jsonErr(404, 404, err.Error())
return
}
c.ok(paymentOrderDTO(row))
}
// Refund POST /platform/payment/refund 手动原路退回(兜底能力)
func (c *PlatformPaymentController) Refund() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
PayNo string `json:"pay_no"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(body, &p); err != nil || strings.TrimSpace(p.PayNo) == "" {
c.jsonErr(400, 400, "参数错误")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
refund, err := payment.ManualRefund(ctx, payment.RefundInput{
PayNo: strings.TrimSpace(p.PayNo),
Amount: p.Amount,
Reason: strings.TrimSpace(p.Reason),
OperatorID: fmt.Sprintf("%d", claims.UserID),
OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"refund_no": refund.RefundNo, "status": refund.Status,
"amount": refund.Amount, "channel_refund_no": refund.ChannelRefundNo,
"fail_reason": refund.FailReason,
})
}
// Notify POST /platform/payment/callback/:channel 渠道异步通知入口(渠道服务器调用,无平台鉴权)
// 处理成功时按渠道约定返回:微信 {"code":"SUCCESS"} / 支付宝 success / 银联与 PayPal ok / 200 空体。
func (c *PlatformPaymentController) Notify() {
channel := c.Ctx.Input.Param(":channel")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ack, err := payment.HandleNotify(ctx, channel, c.Ctx.Request, c.Ctx.Input.IP())
if err != nil {
beelog.Warn("支付回调处理失败: channel=%s err=%v", channel, err)
c.Ctx.Output.SetStatus(http.StatusInternalServerError)
_, _ = c.Ctx.ResponseWriter.Write([]byte("FAIL"))
return
}
if ack == "" {
ack = "ok"
}
c.Ctx.Output.SetStatus(http.StatusOK)
_, _ = c.Ctx.ResponseWriter.Write([]byte(ack))
}
@@ -0,0 +1,188 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformPaymentReconcileController 平台端对账管理(差异列表 / 标记处理 / 账单导入 / 执行对账)
type PlatformPaymentReconcileController struct {
beego.Controller
}
func (c *PlatformPaymentReconcileController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
func (c *PlatformPaymentReconcileController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformPaymentReconcileController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func reconcileDiffDTO(row *models.PlatformPaymentReconcileDiff) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "channel": row.Channel, "bill_date": row.BillDate,
"batch_no": row.BatchNo, "local_trade_no": row.LocalTradeNo, "channel_trade_no": row.ChannelTradeNo,
"local_amount": row.LocalAmount, "channel_amount": row.ChannelAmount, "diff_amount": row.DiffAmount,
"diff_type": row.DiffType, "handle_status": row.HandleStatus,
"handle_user_id": row.HandleUserID, "handle_user_name": row.HandleUserName,
"handle_time": row.HandleTime, "remark": row.Remark,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// ListReconcile GET /platform/payment/reconcile 对账差异列表
func (c *PlatformPaymentReconcileController) ListReconcile() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
rows, total, err := payment.ListReconcileDiffs(payment.ReconcileListInput{
Channel: strings.TrimSpace(c.GetString("channel")),
BillDate: strings.TrimSpace(c.GetString("bill_date")),
DiffType: strings.TrimSpace(c.GetString("diff_type")),
HandleStatus: strings.TrimSpace(c.GetString("handle_status")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformPaymentReconcileDiff{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, reconcileDiffDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// HandleReconcile POST /platform/payment/reconcile/:id/handle 标记差异处理
func (c *PlatformPaymentReconcileController) HandleReconcile() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
HandleStatus string `json:"handle_status"`
Remark string `json:"remark"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
row, err := payment.MarkDiffHandled(context.Background(), payment.MarkDiffHandledInput{
ID: id, HandleStatus: strings.TrimSpace(p.HandleStatus), Remark: strings.TrimSpace(p.Remark),
OperatorID: fmt.Sprintf("%d", claims.UserID), OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(reconcileDiffDTO(row))
}
// RunReconcile POST /platform/payment/reconcile/run 执行对账比对
func (c *PlatformPaymentReconcileController) RunReconcile() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Channel string `json:"channel"`
BillDate string `json:"bill_date"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
summary, err := payment.RunReconcile(ctx, strings.TrimSpace(p.Channel), strings.TrimSpace(p.BillDate))
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(summary)
}
// ImportBill POST /platform/payment/reconcile/import 导入渠道账单
// 两种方式:multipart 上传 CSV(字段 file + channel + bill_date),或 JSON {channel, bill_date, rows:[...]}
func (c *PlatformPaymentReconcileController) ImportBill() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var channel, billDate string
var rows []payment.BillRow
contentType := c.Ctx.Request.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
channel = strings.TrimSpace(c.GetString("channel"))
billDate = strings.TrimSpace(c.GetString("bill_date"))
file, header, ferr := c.GetFile("file")
if ferr != nil || header == nil {
c.jsonErr(400, 400, "请选择账单文件")
return
}
defer func() { _ = file.Close() }()
data, rerr := io.ReadAll(file)
if rerr != nil {
c.jsonErr(400, 400, "读取账单文件失败: "+rerr.Error())
return
}
rows, _ = payment.ParseChannelBillCSV(data)
} else {
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Channel string `json:"channel"`
BillDate string `json:"bill_date"`
Rows []payment.BillRow `json:"rows"`
}
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
channel, billDate, rows = strings.TrimSpace(p.Channel), strings.TrimSpace(p.BillDate), p.Rows
}
if channel == "" || billDate == "" {
c.jsonErr(400, 400, "channel 与 bill_date 不能为空")
return
}
imported, err := payment.ImportChannelBill(context.Background(), channel, billDate, rows)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{"imported": imported, "channel": channel, "bill_date": billDate})
}
@@ -0,0 +1,30 @@
-- =============================================================
-- 支付单表补列:yz_platform_payment_order 增加推广方快照字段
-- 变更原因:佣金在「支付成功」事件上生成,需要知道该笔收款归属的推广方;
-- 推广方在下单时确定,随支付单落快照(后续改归属不影响历史佣金)。
-- 新增列:
-- promoter_id varchar(64) NOT NULL DEFAULT '' 推广方ID
-- promoter_name varchar(128) NOT NULL DEFAULT '' 推广方名称
-- promoter_type varchar(32) NOT NULL DEFAULT '' 推广方类型 channel/service/staff
-- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不报 1060 Duplicate column)。
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < alter_platform_payment_order_promoter.sql
-- =============================================================
-- promoter_id
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order');
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_id');
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''推广方ID(下单时快照,支付成功后据此生成佣金)'' AFTER `order_synced`', 'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- promoter_name
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_name');
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''推广方名称快照'' AFTER `promoter_id`', 'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- promoter_type
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_type');
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_type` varchar(32) NOT NULL DEFAULT '''' COMMENT ''推广方类型快照:channel渠道伙伴/service服务商/staff内部员工'' AFTER `promoter_name`', 'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 校验:
SHOW COLUMNS FROM yz_platform_payment_order LIKE 'promoter%';
@@ -0,0 +1,73 @@
-- =============================================================
-- 平台端推广佣金相关表
-- yz_platform_commission_rule 佣金规则(按比例 / 固定金额 / 阶梯)
-- yz_platform_commission_order 佣金单(台账,含发放登记)
--
-- 业务约定:
-- 1. 佣金由平台自有资金支出,与租户收款资金流解耦,不参与分账;
-- 2. 佣金单在「业务订单支付成功」事件上按启用中的规则生成,状态先为 payable(应付);
-- 3. 一笔支付单 + 同一推广方只应生成一张佣金单(uk_pay_promoter),
-- 但 MySQL 唯一索引对 NULL 不去重(delete_time),业务层仍需按 pay_no 查重后再插入;
-- 4. 金额一律以「分」为单位的 bigint 存储;
-- 5. 规则修改只影响之后生成的佣金单,历史佣金单保留规则快照字段(rule_name/calc_type)。
--
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_platform_commission.sql
-- =============================================================
-- -------------------------------------------------------------
-- 1. 佣金规则表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_commission_rule` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '规则名称,如「渠道推广-平台使用费 5%」',
`calc_type` varchar(20) NOT NULL DEFAULT 'percent' COMMENT '计算方式:percent按比例/fixed固定金额/tiered阶梯',
`value` bigint(20) NOT NULL DEFAULT '0' COMMENT '规则值:percent=百分比×100(5%存500,支持2.5%存250);fixed=固定金额(分);tiered=0且明细见 tier_json',
`tier_json` text COMMENT '阶梯配置JSON:[{"min":0,"max":1000000,"rate":300}];金额单位为分,max 为 null 表示不限,rate 同为百分比×100',
`order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '适用订单类型:all全部/platform_usage平台使用费/module_shop模块购买/service_fee服务费',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用(停用后新佣金单不再匹配该规则,历史佣金单不受影响)',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人ID',
`create_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '创建人姓名',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`,`order_type`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金规则表';
-- -------------------------------------------------------------
-- 2. 佣金单表(台账)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_commission_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`commission_no` varchar(40) NOT NULL DEFAULT '' COMMENT '佣金单号,形如 CM202609150001',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号(yz_platform_payment_order.pay_no)',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号',
`rule_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '命中的佣金规则ID',
`rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '命中规则名称快照',
`calc_type` varchar(20) NOT NULL DEFAULT '' COMMENT '计算方式快照:percent/fixed/tiered',
`promoter_id` varchar(64) NOT NULL DEFAULT '' COMMENT '推广方ID(渠道伙伴 / 服务商 / 内部员工)',
`promoter_name` varchar(128) NOT NULL DEFAULT '' COMMENT '推广方名称',
`promoter_type` varchar(32) NOT NULL DEFAULT '' COMMENT '推广方类型:channel渠道伙伴/service服务商/staff内部员工',
`base_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '计佣基数(分)= 业务订单实付金额',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '佣金金额(分)',
`status` varchar(20) NOT NULL DEFAULT 'payable' COMMENT '佣金状态:payable应付/settled已付/tax_processing税务处理中/canceled已作废',
`pay_method` varchar(20) NOT NULL DEFAULT '' COMMENT '发放方式:bank银行转账/balance余额抵扣/manual人工发放/offset下期抵扣',
`pay_voucher` varchar(255) NOT NULL DEFAULT '' COMMENT '发放凭证 / 银行流水号',
`paid_at` datetime DEFAULT NULL COMMENT '发放时间',
`paid_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '登记发放人ID',
`paid_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '登记发放人姓名',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_commission_no` (`commission_no`,`delete_time`),
UNIQUE KEY `uk_pay_promoter` (`pay_no`,`promoter_id`,`delete_time`),
KEY `idx_out_trade_no` (`out_trade_no`),
KEY `idx_status` (`status`,`create_time`),
KEY `idx_promoter` (`promoter_id`,`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金单表(台账)';
+223
View File
@@ -0,0 +1,223 @@
-- =============================================================
-- 平台端支付模块相关表(平台向租户收取平台使用费)
-- yz_platform_payment_channel 渠道配置(一渠道一行,敏感参数加密后存 JSON)
-- yz_platform_payment_order 支付单
-- yz_platform_payment_order_log 支付单状态流转流水
-- yz_platform_payment_callback_log 渠道回调日志(幂等去重 + 问题排查)
-- yz_platform_payment_channel_bill 渠道账单明细(对账源数据)
-- yz_platform_payment_reconcile_diff 对账差异
-- yz_platform_payment_refund 手动原路退回记录(兜底能力)
--
-- 设计约定(与业务约定一致):
-- 1. 资金流向:租户付款直接进平台对公账户,不涉及分账;仅保留后台「手动原路退回」兜底;
-- 2. 金额一律用「分」为单位的 bigint 存储,前端展示时 /100 转元;
-- 3. 状态类字段用 varchar 存英文枚举,前端 StatusTag 直接映射,避免数字魔法值;
-- 4. 渠道密钥 / 私钥 / ClientSecret 不入库明文,只存 config_json(写入前由后端加密);
-- 证书本体存文件(object_key),库内只留元信息(cert_json);
-- 5. 表结构与项目其他表一致:软删除 delete_time、create_time/update_time 自动维护;
-- 6. 唯一键带上 delete_time 是本项目约定(软删除后可重新插入同键数据);
-- 注意 MySQL 唯一索引对 NULL 不去重,因此「支付幂等/回调去重」仍必须在业务层
-- 用「按 pay_no 查重 + 条件更新(WHERE status <> 'paid')」兜底,不能只依赖唯一键。
-- 7. 业务订单字段(业务订单状态、订单摘要等)由业务模块持有,本模块只冗余订单号与金额,
-- 详情接口聚合返回,不在此重复落库。
--
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_platform_payment.sql
-- =============================================================
-- -------------------------------------------------------------
-- 1. 支付渠道配置表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识:wechat/alipay/unionpay/cloudpay/paypal',
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道名称(展示用)',
`merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '商户号(展示用,未配置时前端显示未配置)',
`config_json` text COMMENT '渠道参数JSON(含密钥/私钥/Secret,写入前加密;接口回显敏感字段时只回掩码)',
`cert_json` text COMMENT '证书元信息JSON:文件名、object_key、序列号、到期日(不含证书私钥内容)',
`callback_url` varchar(255) NOT NULL DEFAULT '' COMMENT '回调地址(只读):/api/payment/callback/{channel}',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用:1启用/0停用(参数不完整时前端展示为未配置)',
`extra_json` text COMMENT '渠道扩展开关JSON:云闪付 show_logo/logo_positions、PayPal env 等',
`last_test_time` datetime DEFAULT NULL COMMENT '最近一次「测试连接」时间',
`last_test_result` varchar(255) NOT NULL DEFAULT '' COMMENT '最近一次测试连接结果说明',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel` (`channel`,`delete_time`),
KEY `idx_enabled` (`enabled`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道配置表';
-- -------------------------------------------------------------
-- 2. 支付单表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(支付模块生成,形如 P202609150009)',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号(业务模块生成;同一订单可有多笔支付单)',
`order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '订单类型:platform_usage平台使用费/module_shop模块购买/service_fee服务费',
`subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题(渠道侧展示文案)',
`tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID',
`tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(冗余,便于列表展示)',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '实付金额(分)',
`order_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '业务订单金额(分)',
`currency` varchar(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道标识',
`merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '下单时商户号快照',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号',
`status` varchar(20) NOT NULL DEFAULT 'created' COMMENT '状态:created/pending/paying/paid/failed/closed/refunding/refunded',
`refund_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '已退回金额(分),仅兜底手动退回时累加',
`client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '下单客户端IP',
`return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付完成同步跳回地址(由下单参数带入)',
`notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道回调地址快照',
`expire_at` datetime DEFAULT NULL COMMENT '支付超时时间(过期由补偿任务置为 closed)',
`paid_at` datetime DEFAULT NULL COMMENT '支付完成时间',
`closed_at` datetime DEFAULT NULL COMMENT '关闭时间',
`notify_at` datetime DEFAULT NULL COMMENT '最近一次成功回调时间',
`last_query_at` datetime DEFAULT NULL COMMENT '最近一次主动查询渠道状态时间',
`order_synced` tinyint(1) NOT NULL DEFAULT '0' COMMENT '业务订单状态是否已同步:1已同步/0待同步(补偿任务扫描用)',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pay_no` (`pay_no`,`delete_time`),
KEY `idx_out_trade_no` (`out_trade_no`),
KEY `idx_tenant` (`tenant_id`,`create_time`),
KEY `idx_status` (`status`,`create_time`),
KEY `idx_channel_trade` (`channel`,`channel_trade_no`),
KEY `idx_order_synced` (`order_synced`,`status`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单表';
-- -------------------------------------------------------------
-- 3. 支付单状态流转流水(详情页时间轴数据源)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_order_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(yz_platform_payment_order.pay_no)',
`from_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更前状态(创建时为空)',
`to_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更后状态',
`operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作来源:租户端下单页/渠道回调/平台管理员/定时补偿',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '变更说明(如:支付成功,已同步业务订单为已支付)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_pay_no` (`pay_no`,`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单状态流转流水表';
-- -------------------------------------------------------------
-- 4. 渠道回调日志表(幂等去重与排查)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_callback_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`event_type` varchar(64) NOT NULL DEFAULT '' COMMENT '事件类型:TRANSACTION.SUCCESS/trade.success/PAYMENT.CAPTURE.COMPLETED 等',
`event_id` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道事件ID(微信 event id / 支付宝 notify_id / PayPal event id)',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号',
`trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态原文',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道通知金额(分)',
`verify_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '验签结果:1通过/0失败',
`is_duplicate` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否重复通知:1是(幂等命中,直接返回渠道要求的成功响应)/0否',
`handle_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '处理结果:1成功/0失败',
`handle_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '处理说明或错误信息',
`raw_body` mediumtext COMMENT '渠道原始报文(脱敏后保留,详情页「渠道回调原文」数据源)',
`client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源IP',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_event` (`channel`,`channel_trade_no`,`event_type`,`delete_time`),
KEY `idx_pay_no` (`pay_no`),
KEY `idx_event_id` (`event_id`),
KEY `idx_create_time` (`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道回调日志表(幂等去重)';
-- -------------------------------------------------------------
-- 5. 渠道账单明细表(对账源数据)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel_bill` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`bill_date` date NOT NULL COMMENT '账单日期(渠道账单自然日)',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道侧商户订单号(对应业务订单号)',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道账单金额(分)',
`fee` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道手续费(分)',
`trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态',
`trade_time` datetime DEFAULT NULL COMMENT '渠道交易时间',
`is_matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已与本地支付单匹配:1是/0否',
`matched_pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '匹配到的本地支付单号',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_trade` (`channel`,`channel_trade_no`,`delete_time`),
KEY `idx_bill_date` (`channel`,`bill_date`,`is_matched`),
KEY `idx_out_trade_no` (`out_trade_no`,`bill_date`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道账单明细表(对账源数据)';
-- -------------------------------------------------------------
-- 6. 对账差异表
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_reconcile_diff` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`bill_date` date NOT NULL COMMENT '对账日期(同渠道账单日)',
`batch_no` varchar(40) NOT NULL DEFAULT '' COMMENT '对账批次号(同渠道同日一次对账一个批次,便于追溯与重跑)',
`local_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '本地支付单号(长款时为空串,不写 NULL 以便参与唯一键)',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号(短款时为空串)',
`local_amount` bigint(20) DEFAULT NULL COMMENT '本地金额(分),无本地记录时为 NULL',
`channel_amount` bigint(20) DEFAULT NULL COMMENT '渠道金额(分),无渠道记录时为 NULL',
`diff_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '差额(分)= 渠道金额 - 本地金额,负数表示短款',
`diff_type` varchar(20) NOT NULL DEFAULT '' COMMENT '差异类型:long长款(渠道有本地无)/short短款(本地有渠道无)/status_mismatch状态不一致',
`handle_status` varchar(20) NOT NULL DEFAULT 'unhandled' COMMENT '处理状态:unhandled待处理/handling处理中/handled已处理/ignored已忽略',
`handle_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '处理人ID',
`handle_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '处理人姓名',
`handle_time` datetime DEFAULT NULL COMMENT '处理时间',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '处理说明(人工填写,便于日后追溯)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_diff` (`channel`,`bill_date`,`diff_type`,`channel_trade_no`,`local_trade_no`,`delete_time`),
KEY `idx_channel_date` (`channel`,`bill_date`),
KEY `idx_handle` (`handle_status`,`bill_date`),
KEY `idx_batch_no` (`batch_no`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付对账差异表';
-- -------------------------------------------------------------
-- 7. 手动原路退回记录表(兜底能力,业务上不提供退款入口)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `yz_platform_payment_refund` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`refund_no` varchar(40) NOT NULL DEFAULT '' COMMENT '退回单号(支付模块生成)',
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号',
`out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号',
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识',
`channel_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道退回流水号',
`amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '退回金额(分)',
`reason` varchar(500) NOT NULL DEFAULT '' COMMENT '退回原因(必填,人工填写,用于审计)',
`status` varchar(20) NOT NULL DEFAULT 'processing' COMMENT '退回状态:processing处理中/success成功/failed失败',
`fail_reason` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因',
`operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人ID(平台管理员)',
`operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名',
`finished_at` datetime DEFAULT NULL COMMENT '退回完成时间',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_refund_no` (`refund_no`,`delete_time`),
KEY `idx_pay_no` (`pay_no`),
KEY `idx_status` (`status`,`create_time`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付手动原路退回记录表(兜底能力)';
@@ -0,0 +1,39 @@
-- =============================================================
-- 平台支付渠道:初始化五个渠道配置行
-- 表:yz_platform_payment_channel
-- 说明:
-- 五个渠道(微信支付 / 支付宝 / 银联 / 云闪付 / PayPal)与前端
-- platform/src/views/system/payment/channels/ 一一对应。
-- 这里只插入「渠道骨架」:名称、回调地址、默认开关,商户号与密钥留空,
-- 由平台管理员在渠道配置页填写(敏感参数写入 config_json 前由后端加密)。
-- 初始 enabled 一律为 0(停用),配置完成并通过「测试连接」后再由页面启用,
-- 前端会按「enabled=1 → 已启用;enabled=0 且无参数 → 未配置;enabled=0 且有参数 → 已停用」展示状态。
-- 云闪付复用银联商户参数,仅存收银台标识开关(extra_json.show_logo / logo_positions);
-- PayPal 默认沙箱环境(extra_json.env=sandbox),当前业务不跨境,保持停用。
-- 本脚本幂等:已存在同名渠道(含已软删)则跳过,可重复执行。
-- 依赖:先执行 create_platform_payment.sql 建表。
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < init_platform_payment_channel.sql
-- =============================================================
INSERT INTO `yz_platform_payment_channel`
(`channel`, `name`, `merchant_no`, `config_json`, `cert_json`, `callback_url`, `enabled`, `extra_json`, `remark`, `create_time`, `update_time`)
SELECT v.`channel`, v.`name`, '', NULL, NULL,
CONCAT('/api/payment/callback/', v.`channel`), 0, v.`extra_json`, v.`remark`, NOW(), NOW()
FROM (
SELECT 'wechat' AS `channel`, '微信支付' AS `name`, '' AS `extra_json`,
'主渠道:JSAPI / Native 扫码收款,需商户号、APPID、APIv3 密钥与商户证书' AS `remark`
UNION ALL SELECT 'alipay', '支付宝', '',
'电脑网站支付 / 手机网站支付,RSA2 签名,异步通知无需登记'
UNION ALL SELECT 'unionpay', '银联', '',
'全渠道收单,商户号与证书由收单机构下发,回调地址需向收单机构报备'
UNION ALL SELECT 'cloudpay', '云闪付', '{"show_logo":1,"logo_positions":["pc","h5"]}',
'复用银联商户参数,仅控制收银台是否展示云闪付标识'
UNION ALL SELECT 'paypal', 'PayPal', '{"env":"sandbox"}',
'跨境预留渠道,当前业务不跨境,默认停用'
) v
WHERE NOT EXISTS (
SELECT 1 FROM `yz_platform_payment_channel` c WHERE c.`channel` = v.`channel`
);
-- 校验:SELECT id, channel, name, merchant_no, enabled, callback_url, delete_time
-- FROM yz_platform_payment_channel ORDER BY id;
+14 -7
View File
@@ -6,19 +6,23 @@ require (
github.com/beego/beego/v2 v2.1.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/qiniu/go-sdk/v7 v7.18.2
golang.org/x/crypto v0.1.0
golang.org/x/crypto v0.38.0
)
require (
github.com/go-sql-driver/mysql v1.7.0
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.33.0
golang.org/x/net v0.7.0
github.com/plutov/paypal/v4 v4.12.0
github.com/smartwalle/alipay/v3 v3.2.31
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
golang.org/x/net v0.40.0
software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
@@ -30,12 +34,15 @@ require (
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.1.2 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+35 -13
View File
@@ -1,9 +1,11 @@
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/beego/beego/v2 v2.1.0 h1:Lk0FtQGvDQCx5V5yEu4XwDsIgt+QOlNjt5emUa3/ZmA=
github.com/beego/beego/v2 v2.1.0/go.mod h1:6h36ISpaxNrrpJ27siTpXBG8d/Icjzsc7pU1bWpp0EE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -57,6 +59,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/plutov/paypal/v4 v4.12.0 h1:tp6bClhe9qt3IgN7arT8RiLgDe+LPSvXA67wU+8tu5Q=
github.com/plutov/paypal/v4 v4.12.0/go.mod h1:9K/agLFwXpz5Tpuc3aNxPvzIdUo6BPL7pf5+x4ITOug=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
@@ -77,32 +81,48 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
github.com/smartwalle/alipay/v3 v3.2.31 h1:KVwWJZ5JvNxPridHrcpTwgJeMzi1IqZopid0sDJQewU=
github.com/smartwalle/alipay/v3 v3.2.31/go.mod h1:0G9wqvo1719hxo6ZWntujuPNyVQFWikS52LF62rRYDg=
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
github.com/smartwalle/ngx v1.1.2 h1:W+K262lHUvdfJ/2e762dIFwN543kxC/4qLkuCQLRS8o=
github.com/smartwalle/ngx v1.1.2/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -110,8 +130,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -121,8 +141,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -130,8 +150,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -141,3 +161,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+4
View File
@@ -4,6 +4,7 @@ import (
"server/models"
_ "server/routers"
"server/services"
"server/services/payment"
"server/version"
beego "github.com/beego/beego/v2/server/web"
@@ -33,5 +34,8 @@ func main() {
// 启动回款逾期联动任务(回款逾期 → 合同「异常」→ 关联项目「异常」)
services.StartPaybackOverdueScheduler(make(chan struct{}))
// 启动支付补偿任务(超时关单 / 业务订单同步重试,5 分钟一轮)
payment.StartPaymentScheduler(make(chan struct{}))
beego.Run()
}
+13
View File
@@ -137,6 +137,19 @@ func Init(_ string) {
new(BackendOaPayrollItem),
new(BackendEmployeeFile),
new(BackendEmployeeFileRecord),
// 平台端支付模块(docs/sql/create_platform_payment.sql)
new(PlatformPaymentChannel),
new(PlatformPaymentOrder),
new(PlatformPaymentOrderLog),
new(PlatformPaymentCallbackLog),
new(PlatformPaymentChannelBill),
new(PlatformPaymentReconcileDiff),
new(PlatformPaymentRefund),
// 平台端推广佣金(docs/sql/create_platform_commission.sql)
new(PlatformCommissionRule),
new(PlatformCommissionOrder),
)
// 创建全局 Ormer
+96
View File
@@ -0,0 +1,96 @@
package models
import "time"
// =============================================================
// 平台端推广佣金模型(表结构见 docs/sql/create_platform_commission.sql)
// 业务约定:
// 1. 佣金由平台自有资金支出,与租户收款资金流解耦;
// 2. 佣金在「业务订单支付成功」事件上按启用中的规则生成,初始状态 payable;
// 3. 一笔支付单 + 同一推广方只生成一张佣金单(uk_pay_promoter,业务层再按 pay_no 查重)。
// =============================================================
// PlatformCommissionRule 佣金规则 yz_platform_commission_rule
type PlatformCommissionRule struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
RuleName string `orm:"column(rule_name);size(128)" json:"rule_name"`
CalcType string `orm:"column(calc_type);size(20)" json:"calc_type"`
Value int64 `orm:"column(value);default(0)" json:"value"`
TierJSON *string `orm:"column(tier_json);type(text);null" json:"-"`
OrderType string `orm:"column(order_type);size(32)" json:"order_type"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateUserName string `orm:"column(create_user_name);size(128)" json:"create_user_name"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformCommissionRule) TableName() string { return "yz_platform_commission_rule" }
// 计算方式
const (
CommissionCalcPercent = "percent" // 按比例:value = 百分比 × 100(5% 存 500)
CommissionCalcFixed = "fixed" // 固定金额:value = 分
CommissionCalcTiered = "tiered" // 阶梯:value 忽略,明细见 tier_json
)
// 适用订单类型
const (
CommissionOrderTypeAll = "all"
CommissionOrderTypePlatformUsage = "platform_usage"
CommissionOrderTypeModuleShop = "module_shop"
CommissionOrderTypeServiceFee = "service_fee"
)
// CommissionTier 阶梯档位(tier_json 数组元素)
type CommissionTier struct {
Min int64 `json:"min"` // 起始金额(分,含)
Max *int64 `json:"max"` // 结束金额(分,不含);null 表示不限
Rate int64 `json:"rate"` // 比例:百分比 × 100
}
// PlatformCommissionOrder 佣金单(台账) yz_platform_commission_order
type PlatformCommissionOrder struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
CommissionNo string `orm:"column(commission_no);size(40)" json:"commission_no"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
RuleID uint64 `orm:"column(rule_id);default(0)" json:"rule_id"`
RuleName string `orm:"column(rule_name);size(128)" json:"rule_name"`
CalcType string `orm:"column(calc_type);size(20)" json:"calc_type"`
PromoterID string `orm:"column(promoter_id);size(64)" json:"promoter_id"`
PromoterName string `orm:"column(promoter_name);size(128)" json:"promoter_name"`
PromoterType string `orm:"column(promoter_type);size(32)" json:"promoter_type"`
BaseAmount int64 `orm:"column(base_amount);default(0)" json:"base_amount"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
Status string `orm:"column(status);size(20)" json:"status"`
PayMethod string `orm:"column(pay_method);size(20)" json:"pay_method"`
PayVoucher string `orm:"column(pay_voucher);size(255)" json:"pay_voucher"`
PaidAt *time.Time `orm:"column(paid_at);type(datetime);null" json:"paid_at"`
PaidUserID string `orm:"column(paid_user_id);size(64)" json:"paid_user_id"`
PaidUserName string `orm:"column(paid_user_name);size(128)" json:"paid_user_name"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformCommissionOrder) TableName() string { return "yz_platform_commission_order" }
// 佣金状态
const (
CommissionStatusPayable = "payable" // 应付
CommissionStatusSettled = "settled" // 已付
CommissionStatusTaxProcessing = "tax_processing" // 税务处理中
CommissionStatusCanceled = "canceled" // 已作废
)
// 发放方式
const (
CommissionPayBank = "bank" // 银行转账
CommissionPayBalance = "balance" // 余额抵扣
CommissionPayManual = "manual" // 人工发放
CommissionPayOffset = "offset" // 下期抵扣
)
+214
View File
@@ -0,0 +1,214 @@
package models
import "time"
// =============================================================
// 平台端支付模块模型(表结构见 docs/sql/create_platform_payment.sql)
// 约定:
// 1. 金额字段一律 int64,单位「分」;
// 2. 状态字段存英文枚举字符串(前端 StatusTag 直接映射);
// 3. 渠道密钥类参数不落明文,config_json 存 AES-GCM 密文(见 services/payment/crypto.go);
// 4. 软删除统一用 delete_time,查询时 Filter("delete_time__isnull", true)。
// =============================================================
// PlatformPaymentChannel 平台支付渠道配置 yz_platform_payment_channel
type PlatformPaymentChannel struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
Name string `orm:"column(name);size(64)" json:"name"`
MerchantNo string `orm:"column(merchant_no);size(128)" json:"merchant_no"`
ConfigJSON *string `orm:"column(config_json);type(text);null" json:"-"`
CertJSON *string `orm:"column(cert_json);type(text);null" json:"-"`
CallbackURL string `orm:"column(callback_url);size(255)" json:"callback_url"`
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
ExtraJSON *string `orm:"column(extra_json);type(text);null" json:"-"`
LastTestTime *time.Time `orm:"column(last_test_time);type(datetime);null" json:"last_test_time"`
LastTestResult string `orm:"column(last_test_result);size(255)" json:"last_test_result"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentChannel) TableName() string { return "yz_platform_payment_channel" }
// PlatformPaymentOrder 平台支付单 yz_platform_payment_order
type PlatformPaymentOrder struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
OrderType string `orm:"column(order_type);size(32)" json:"order_type"`
Subject string `orm:"column(subject);size(255)" json:"subject"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
TenantName string `orm:"column(tenant_name);size(128)" json:"tenant_name"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
OrderAmount int64 `orm:"column(order_amount);default(0)" json:"order_amount"`
Currency string `orm:"column(currency);size(8)" json:"currency"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
MerchantNo string `orm:"column(merchant_no);size(128)" json:"merchant_no"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
Status string `orm:"column(status);size(20)" json:"status"`
RefundAmount int64 `orm:"column(refund_amount);default(0)" json:"refund_amount"`
ClientIP string `orm:"column(client_ip);size(64)" json:"client_ip"`
ReturnURL string `orm:"column(return_url);size(255)" json:"return_url"`
NotifyURL string `orm:"column(notify_url);size(255)" json:"notify_url"`
ExpireAt *time.Time `orm:"column(expire_at);type(datetime);null" json:"expire_at"`
PaidAt *time.Time `orm:"column(paid_at);type(datetime);null" json:"paid_at"`
ClosedAt *time.Time `orm:"column(closed_at);type(datetime);null" json:"closed_at"`
NotifyAt *time.Time `orm:"column(notify_at);type(datetime);null" json:"notify_at"`
LastQueryAt *time.Time `orm:"column(last_query_at);type(datetime);null" json:"last_query_at"`
OrderSynced int8 `orm:"column(order_synced);default(0)" json:"order_synced"`
PromoterID string `orm:"column(promoter_id);size(64)" json:"promoter_id"`
PromoterName string `orm:"column(promoter_name);size(128)" json:"promoter_name"`
PromoterType string `orm:"column(promoter_type);size(32)" json:"promoter_type"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentOrder) TableName() string { return "yz_platform_payment_order" }
// 支付单状态枚举
const (
PayStatusCreated = "created" // 已创建
PayStatusPending = "pending" // 待支付
PayStatusPaying = "paying" // 支付中
PayStatusPaid = "paid" // 支付成功
PayStatusFailed = "failed" // 支付失败
PayStatusClosed = "closed" // 已关闭
PayStatusRefunding = "refunding" // 退回中
PayStatusRefunded = "refunded" // 已退回
)
// PlatformPaymentOrderLog 支付单状态流转流水 yz_platform_payment_order_log
type PlatformPaymentOrderLog struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
FromStatus string `orm:"column(from_status);size(20)" json:"from_status"`
ToStatus string `orm:"column(to_status);size(20)" json:"to_status"`
Operator string `orm:"column(operator);size(64)" json:"operator"`
Remark string `orm:"column(remark);size(500)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentOrderLog) TableName() string { return "yz_platform_payment_order_log" }
// PlatformPaymentCallbackLog 渠道回调日志 yz_platform_payment_callback_log
type PlatformPaymentCallbackLog struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
EventType string `orm:"column(event_type);size(64)" json:"event_type"`
EventID string `orm:"column(event_id);size(128)" json:"event_id"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
TradeState string `orm:"column(trade_state);size(32)" json:"trade_state"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
VerifyResult int8 `orm:"column(verify_result);default(0)" json:"verify_result"`
IsDuplicate int8 `orm:"column(is_duplicate);default(0)" json:"is_duplicate"`
HandleResult int8 `orm:"column(handle_result);default(0)" json:"handle_result"`
HandleMsg string `orm:"column(handle_msg);size(255)" json:"handle_msg"`
RawBody *string `orm:"column(raw_body);type(text);null" json:"raw_body"`
ClientIP string `orm:"column(client_ip);size(64)" json:"client_ip"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentCallbackLog) TableName() string {
return "yz_platform_payment_callback_log"
}
// PlatformPaymentChannelBill 渠道账单明细 yz_platform_payment_channel_bill
type PlatformPaymentChannelBill struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
BillDate time.Time `orm:"column(bill_date);type(date)" json:"bill_date"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
Fee int64 `orm:"column(fee);default(0)" json:"fee"`
TradeState string `orm:"column(trade_state);size(32)" json:"trade_state"`
TradeTime *time.Time `orm:"column(trade_time);type(datetime);null" json:"trade_time"`
IsMatched int8 `orm:"column(is_matched);default(0)" json:"is_matched"`
MatchedPayNo string `orm:"column(matched_pay_no);size(40)" json:"matched_pay_no"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentChannelBill) TableName() string {
return "yz_platform_payment_channel_bill"
}
// PlatformPaymentReconcileDiff 对账差异 yz_platform_payment_reconcile_diff
type PlatformPaymentReconcileDiff struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
BillDate time.Time `orm:"column(bill_date);type(date)" json:"bill_date"`
BatchNo string `orm:"column(batch_no);size(40)" json:"batch_no"`
LocalTradeNo string `orm:"column(local_trade_no);size(64)" json:"local_trade_no"`
ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"`
LocalAmount *int64 `orm:"column(local_amount);null" json:"local_amount"`
ChannelAmount *int64 `orm:"column(channel_amount);null" json:"channel_amount"`
DiffAmount int64 `orm:"column(diff_amount);default(0)" json:"diff_amount"`
DiffType string `orm:"column(diff_type);size(20)" json:"diff_type"`
HandleStatus string `orm:"column(handle_status);size(20)" json:"handle_status"`
HandleUserID string `orm:"column(handle_user_id);size(64)" json:"handle_user_id"`
HandleUserName string `orm:"column(handle_user_name);size(128)" json:"handle_user_name"`
HandleTime *time.Time `orm:"column(handle_time);type(datetime);null" json:"handle_time"`
Remark string `orm:"column(remark);size(500)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentReconcileDiff) TableName() string {
return "yz_platform_payment_reconcile_diff"
}
// 对账差异类型 / 处理状态
const (
DiffTypeLong = "long" // 长款:渠道有、本地无
DiffTypeShort = "short" // 短款:本地有、渠道无
DiffTypeStatusMismatch = "status_mismatch" // 状态不一致
)
const (
DiffHandleUnhandled = "unhandled"
DiffHandleHandling = "handling"
DiffHandleHandled = "handled"
DiffHandleIgnored = "ignored"
)
// PlatformPaymentRefund 手动原路退回记录 yz_platform_payment_refund
type PlatformPaymentRefund struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
RefundNo string `orm:"column(refund_no);size(40)" json:"refund_no"`
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"`
Channel string `orm:"column(channel);size(32)" json:"channel"`
ChannelRefundNo string `orm:"column(channel_refund_no);size(64)" json:"channel_refund_no"`
Amount int64 `orm:"column(amount);default(0)" json:"amount"`
Reason string `orm:"column(reason);size(500)" json:"reason"`
Status string `orm:"column(status);size(20)" json:"status"`
FailReason string `orm:"column(fail_reason);size(500)" json:"fail_reason"`
OperatorID string `orm:"column(operator_id);size(64)" json:"operator_id"`
OperatorName string `orm:"column(operator_name);size(128)" json:"operator_name"`
FinishedAt *time.Time `orm:"column(finished_at);type(datetime);null" json:"finished_at"`
Remark string `orm:"column(remark);size(500)" json:"remark"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformPaymentRefund) TableName() string { return "yz_platform_payment_refund" }
// 退回状态
const (
RefundStatusProcessing = "processing"
RefundStatusSuccess = "success"
RefundStatusFailed = "failed"
)
+8
View File
@@ -11,6 +11,14 @@ import (
func Register() {
RegisterAuthRoutes()
RegisterRoleRoutes()
RegisterPaymentRoutes()
}
// RegisterPaymentRoutes 注册租户端支付路由(下单页 / 收银台)。
func RegisterPaymentRoutes() {
beego.Router("/backend/payment/channels", &controllers.BackendPaymentController{}, "get:Channels")
beego.Router("/backend/payment/create", &controllers.BackendPaymentController{}, "post:Create")
beego.Router("/backend/payment/query/:payNo", &controllers.BackendPaymentController{}, "get:Query")
}
// RegisterRoleRoutes 注册 backend 租户角色管理路由。
+42
View File
@@ -0,0 +1,42 @@
package platform
import (
"server/controllers"
beego "github.com/beego/beego/v2/server/web"
)
// RegisterPaymentRoutes 注册平台端支付模块路由(表结构见 docs/sql/create_platform_payment.sql)。
// 注意:/reconcile/handle/:id、/commission/markPaid/:id 刻意避开与静态段同级,
// 避免 beego 路由树中通配段与静态段混用带来的匹配歧义。
func RegisterPaymentRoutes() {
// 渠道配置
beego.Router("/platform/payment/channels", &controllers.PlatformPaymentController{}, "get:ListChannels")
beego.Router("/platform/payment/channels/:channel", &controllers.PlatformPaymentController{}, "get:GetChannel;post:SaveChannel")
beego.Router("/platform/payment/channels/:channel/test", &controllers.PlatformPaymentController{}, "post:TestChannel")
beego.Router("/platform/payment/channels/:channel/status", &controllers.PlatformPaymentController{}, "post:ToggleChannel")
beego.Router("/platform/payment/channels/:channel/certificate", &controllers.PlatformPaymentController{}, "post:UploadCertificate")
// 支付订单
beego.Router("/platform/payment/orders", &controllers.PlatformPaymentController{}, "get:ListOrders")
beego.Router("/platform/payment/orders/:payNo", &controllers.PlatformPaymentController{}, "get:OrderDetail")
beego.Router("/platform/payment/orders/:payNo/query", &controllers.PlatformPaymentController{}, "post:QueryOrder")
beego.Router("/platform/payment/refund", &controllers.PlatformPaymentController{}, "post:Refund")
// 对账
beego.Router("/platform/payment/reconcile", &controllers.PlatformPaymentReconcileController{}, "get:ListReconcile")
beego.Router("/platform/payment/reconcile/run", &controllers.PlatformPaymentReconcileController{}, "post:RunReconcile")
beego.Router("/platform/payment/reconcile/import", &controllers.PlatformPaymentReconcileController{}, "post:ImportBill")
beego.Router("/platform/payment/reconcile/handle/:id", &controllers.PlatformPaymentReconcileController{}, "post:HandleReconcile")
// 渠道异步通知(渠道服务器调用,无平台鉴权;鉴权由各渠道适配器验签完成)
beego.Router("/api/payment/callback/:channel", &controllers.PlatformPaymentController{}, "post:Notify")
// 推广佣金
beego.Router("/platform/commission/list", &controllers.PlatformCommissionController{}, "get:List")
beego.Router("/platform/commission/stats", &controllers.PlatformCommissionController{}, "get:Stats")
beego.Router("/platform/commission/markPaid/:id", &controllers.PlatformCommissionController{}, "post:MarkPaid")
beego.Router("/platform/commission/rules", &controllers.PlatformCommissionController{}, "get:RulesList;post:RuleCreate")
beego.Router("/platform/commission/rules/:id", &controllers.PlatformCommissionController{}, "post:RuleUpdate;delete:RuleDelete")
beego.Router("/platform/commission/rules/:id/status", &controllers.PlatformCommissionController{}, "post:RuleToggle")
}
+3
View File
@@ -8,6 +8,9 @@ import (
// Register 注册平台端路由
func Register() {
// 支付模块(渠道配置 / 支付订单 / 对账 / 佣金 / 渠道回调)
RegisterPaymentRoutes()
// 平台登录相关
beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform")
beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser")
+261
View File
@@ -0,0 +1,261 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 渠道适配器接口与运行时配置
//
// 五个渠道的差异全部收敛在各 Channel 实现里(wechat/alipay/unionpay/cloudpay/paypal),
// 支付服务(service.go)只面向本接口编程,业务页面不出现渠道 if-else。
// =============================================================
// 渠道标识(与 yz_platform_payment_channel.channel、前端页面一一对应)
const (
ChannelWechat = "wechat"
ChannelAlipay = "alipay"
ChannelUnionPay = "unionpay"
ChannelCloudPay = "cloudpay"
ChannelPayPal = "paypal"
)
// 归一化交易状态(各渠道状态统一映射到这几个值)
const (
StateSuccess = "success"
StatePending = "pending"
StateFailed = "failed"
StateClosed = "closed"
StateRefunded = "refunded"
)
// 预下单方式
const (
PayTypeQR = "qr" // 扫码(微信 Native)
PayTypeWeb = "web" // PC 网页跳转(支付宝电脑网站支付 / 银联网关 / PayPal)
PayTypeH5 = "h5" // 手机网页跳转
PayTypeJSAPI = "jsapi" // 公众号内支付(需要 OpenID)
)
var (
ErrChannelNotConfigured = errors.New("渠道未配置或参数不完整")
ErrChannelDisabled = errors.New("渠道已停用")
ErrChannelNotFound = errors.New("渠道不存在")
)
// PayParams 渠道下单后返回给前端的拉起支付参数
type PayParams struct {
Channel string `json:"channel"`
PayType string `json:"pay_type"`
CodeURL string `json:"code_url,omitempty"` // 扫码:二维码内容
RedirectURL string `json:"redirect_url,omitempty"` // 跳转:直接 302 或前端 window.open
Form string `json:"form,omitempty"` // 表单:写入页面后自动提交(银联前台跳转等)
JSAPI map[string]string `json:"jsapi,omitempty"` // 公众号内:WeixinJSBridge 调用参数
ChannelTradeNo string `json:"channel_trade_no,omitempty"` // 渠道侧单号(如有)
}
// ChannelState 渠道侧交易状态(已归一化)
type ChannelState struct {
ChannelTradeNo string
TradeState string // StateSuccess / StatePending / StateFailed / StateClosed
Amount int64 // 分
PaidAt *time.Time // 渠道侧支付完成时间
Raw string // 渠道原始响应(排查用)
}
// NotifyResult 渠道异步通知解析结果(已验签、已解密)
type NotifyResult struct {
PayNo string
OutTradeNo string
ChannelTradeNo string
EventType string // 渠道事件类型原文
EventID string // 渠道事件/通知 ID(用于幂等去重)
TradeState string // 归一化状态
Amount int64 // 分
Paid bool // 是否支付成功事件
AckBody string // 需要原样返回给渠道的响应体(SUCCESS / success / ok / "")
Raw string // 通知报文(脱敏后入库)
}
// PrepayOption 预下单附加参数
type PrepayOption struct {
PayType string // PayTypeQR / PayTypeWeb / PayTypeH5 / PayTypeJSAPI;空值由适配器给默认
OpenID string // JSAPI 支付必填
}
// ChannelConfig 渠道运行时配置(敏感参数已解密)
type ChannelConfig struct {
ID uint64
Channel string
Name string
MerchantNo string
Enabled bool
CallbackURL string
Params map[string]string // 解密后的渠道参数
Extra map[string]any // 渠道扩展开关(云闪付标识、PayPal 环境等)
CertPaths map[string]string // 证书文件路径(cert_json)
}
// Get 读取渠道参数
func (c *ChannelConfig) Get(key string) string {
if c == nil || c.Params == nil {
return ""
}
return strings.TrimSpace(c.Params[key])
}
// ExtraBool 读取扩展开关
func (c *ChannelConfig) ExtraBool(key string, def bool) bool {
if c == nil || c.Extra == nil {
return def
}
if v, ok := c.Extra[key].(bool); ok {
return v
}
return def
}
// EnsureUsable 校验配置可用(已启用且参数非空)
func (c *ChannelConfig) EnsureUsable() error {
if c == nil || len(c.Params) == 0 {
return ErrChannelNotConfigured
}
if !c.Enabled {
return ErrChannelDisabled
}
return nil
}
// Channel 渠道适配器接口
type Channel interface {
Code() string
Name() string
// Prepay 预下单,返回拉起支付参数
Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error)
// Query 主动查询渠道侧交易状态
Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error)
// ParseNotify 解析并验签渠道异步通知
ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error)
// Refund 原路退回(兜底能力),返回渠道退回流水号
Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error)
// TestConnect 连通性/凭证校验,返回人类可读的结果说明
TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error)
}
var (
registryMu sync.RWMutex
registry = map[string]Channel{}
)
// Register 注册渠道适配器
func Register(ch Channel) {
registryMu.Lock()
defer registryMu.Unlock()
registry[ch.Code()] = ch
}
// GetChannelAdapter 取渠道适配器
func GetChannelAdapter(code string) (Channel, error) {
registryMu.RLock()
defer registryMu.RUnlock()
ch, ok := registry[code]
if !ok {
return nil, ErrChannelNotFound
}
return ch, nil
}
// Channels 返回全部已注册适配器
func Channels() []Channel {
registryMu.RLock()
defer registryMu.RUnlock()
list := make([]Channel, 0, len(registry))
for _, ch := range registry {
list = append(list, ch)
}
return list
}
// LoadChannelConfig 从库加载渠道配置(敏感参数解密)
func LoadChannelConfig(channel string) (*ChannelConfig, error) {
row := &models.PlatformPaymentChannel{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Filter("delete_time__isnull", true).
One(row)
if err != nil {
return nil, fmt.Errorf("渠道 %s 配置不存在", channel)
}
cfg := &ChannelConfig{
ID: row.ID,
Channel: row.Channel,
Name: row.Name,
MerchantNo: row.MerchantNo,
Enabled: row.Enabled == 1,
CallbackURL: row.CallbackURL,
Params: map[string]string{},
Extra: map[string]any{},
CertPaths: map[string]string{},
}
if row.ConfigJSON != nil && *row.ConfigJSON != "" {
plain, derr := DecryptConfig(*row.ConfigJSON)
if derr != nil {
return nil, fmt.Errorf("渠道 %s 参数解密失败: %w", channel, derr)
}
_ = json.Unmarshal([]byte(plain), &cfg.Params)
}
if row.ExtraJSON != nil && *row.ExtraJSON != "" {
_ = json.Unmarshal([]byte(*row.ExtraJSON), &cfg.Extra)
}
if row.CertJSON != nil && *row.CertJSON != "" {
_ = json.Unmarshal([]byte(*row.CertJSON), &cfg.CertPaths)
}
return cfg, nil
}
// LoadEnabledChannelConfig 加载「已启用且参数完整」的渠道配置
func LoadEnabledChannelConfig(channel string) (*ChannelConfig, error) {
cfg, err := LoadChannelConfig(channel)
if err != nil {
return nil, err
}
if err := cfg.EnsureUsable(); err != nil {
return nil, err
}
return cfg, nil
}
// PublicBaseURL 对外域名基址:app.conf 的 payment_callback_base(如 https://pay.example.com)
// 未配置时返回空串,回调地址形如 /api/payment/callback/wechat(由网关补齐域名)。
func PublicBaseURL() string {
v, _ := beego.AppConfig.String("payment_callback_base")
return strings.TrimRight(strings.TrimSpace(v), "/")
}
// CallbackURL 渠道回调地址(与渠道配置表 callback_url 保持一致)
func CallbackURL(channel string) string {
if cfg, err := LoadChannelConfig(channel); err == nil && cfg.CallbackURL != "" {
return cfg.CallbackURL
}
return PublicBaseURL() + "/api/payment/callback/" + channel
}
func init() {
Register(&WechatChannel{})
Register(&AlipayChannel{})
Register(&UnionPayChannel{})
Register(&CloudPayChannel{})
Register(&PayPalChannel{})
}
+194
View File
@@ -0,0 +1,194 @@
package payment
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"server/models"
"github.com/smartwalle/alipay/v3"
)
// AlipayChannel 支付宝(电脑网站支付 / 手机网站支付,RSA2)
//
// 渠道参数(config_json):
//
// appid 开放平台应用 APPID
// app_private_key 应用私钥(RSA2,PKCS8)
// alipay_public_key 支付宝公钥(回显公钥,用于验签)
// is_production "1" 正式环境 / 其他为沙箱
type AlipayChannel struct{}
func (c *AlipayChannel) Code() string { return ChannelAlipay }
func (c *AlipayChannel) Name() string { return "支付宝" }
func (c *AlipayChannel) buildClient(cfg *ChannelConfig) (*alipay.Client, error) {
appID := cfg.Get("appid")
priv := cfg.Get("app_private_key")
pub := cfg.Get("alipay_public_key")
if appID == "" || priv == "" || pub == "" {
return nil, fmt.Errorf("支付宝参数不完整:需要 appid / app_private_key / alipay_public_key")
}
client, err := alipay.New(appID, priv, cfg.Get("is_production") == "1")
if err != nil {
return nil, fmt.Errorf("创建支付宝客户端失败: %w", err)
}
if err := client.LoadAliPayPublicKey(pub); err != nil {
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
}
return client, nil
}
func (c *AlipayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
trade := alipay.Trade{
NotifyURL: cfg.CallbackURL,
ReturnURL: order.ReturnURL,
Subject: order.Subject,
OutTradeNo: order.PayNo,
TotalAmount: FenToYuan(order.Amount),
}
if order.ExpireAt != nil {
mins := int(time.Until(*order.ExpireAt).Minutes())
if mins < 1 {
mins = 1
}
trade.TimeoutExpress = fmt.Sprintf("%dm", mins)
}
if strings.EqualFold(opt.PayType, PayTypeH5) {
// 手机网站支付
trade.ProductCode = "QUICK_WAP_WAY"
u, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade})
if err != nil {
return nil, fmt.Errorf("支付宝手机网站下单失败: %w", err)
}
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeH5, RedirectURL: u.String()}, nil
}
// 默认:电脑网站支付(PC 收银台)
trade.ProductCode = "FAST_INSTANT_TRADE_PAY"
u, err := client.TradePagePay(alipay.TradePagePay{Trade: trade})
if err != nil {
return nil, fmt.Errorf("支付宝电脑网站下单失败: %w", err)
}
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeWeb, RedirectURL: u.String()}, nil
}
// normalizeAlipayStatus 支付宝交易状态 -> 归一化状态
func normalizeAlipayStatus(status alipay.TradeStatus) string {
switch status {
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
return StateSuccess
case alipay.TradeStatusClosed:
return StateClosed
case alipay.TradeStatusWaitBuyerPay:
return StatePending
default:
return StatePending
}
}
func (c *AlipayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: order.PayNo})
if err != nil {
return nil, fmt.Errorf("支付宝查询订单失败: %w", err)
}
// 交易不存在(如超时未付被渠道关闭):不视为失败,返回待支付以便走关闭逻辑
if rsp.Code != "10000" {
if rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
return &ChannelState{TradeState: StatePending, Raw: rsp.SubMsg}, nil
}
return nil, fmt.Errorf("支付宝查询失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
}
state := &ChannelState{
ChannelTradeNo: rsp.TradeNo,
TradeState: normalizeAlipayStatus(rsp.TradeStatus),
Raw: fmt.Sprintf("trade_no=%s status=%s total=%s", rsp.TradeNo, rsp.TradeStatus, rsp.TotalAmount),
}
if fen, err := YuanToFen(rsp.TotalAmount); err == nil {
state.Amount = fen
}
if state.TradeState == StateSuccess {
now := time.Now()
state.PaidAt = &now
}
return state, nil
}
func (c *AlipayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("解析支付宝通知参数失败: %w", err)
}
notification, err := client.DecodeNotification(ctx, r.Form)
if err != nil {
return nil, fmt.Errorf("支付宝通知验签失败: %w", err)
}
result := &NotifyResult{
EventType: notification.NotifyType,
EventID: notification.NotifyId,
OutTradeNo: notification.OutTradeNo,
PayNo: notification.OutTradeNo,
ChannelTradeNo: notification.TradeNo,
AckBody: "success",
}
if fen, err := YuanToFen(notification.TotalAmount); err == nil {
result.Amount = fen
}
result.TradeState = normalizeAlipayStatus(notification.TradeStatus)
result.Paid = result.TradeState == StateSuccess
return result, nil
}
func (c *AlipayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
rsp, err := client.TradeRefund(ctx, alipay.TradeRefund{
OutTradeNo: order.PayNo,
RefundAmount: FenToYuan(amount),
RefundReason: reason,
OutRequestNo: refundNo,
})
if err != nil {
return "", fmt.Errorf("支付宝退回请求失败: %w", err)
}
if rsp.Code != "10000" {
return "", fmt.Errorf("支付宝退回失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
}
return rsp.TradeNo, nil
}
func (c *AlipayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
// 查询一笔必然不存在的交易:能返回「交易不存在」即说明 APPID 与签名配置正确
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: "__connect_test__"})
if err != nil {
return "", fmt.Errorf("支付宝凭证校验失败: %w", err)
}
if rsp.Code == "10000" || rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
return "连接成功:APPID 与 RSA2 签名校验通过", nil
}
return "", fmt.Errorf("支付宝凭证校验未通过: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
}
+244
View File
@@ -0,0 +1,244 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"server/models"
"github.com/plutov/paypal/v4"
)
// PayPalChannel PayPal(Checkout v2 + Webhook)
//
// 渠道参数(config_json):
//
// client_id REST App Client ID
// client_secret REST App Secret
// webhook_id Webhook ID(用于验签)
// currency 币种,默认 USD
//
// 扩展(extra_json):
//
// env sandbox / live
type PayPalChannel struct{}
func (c *PayPalChannel) Code() string { return ChannelPayPal }
func (c *PayPalChannel) Name() string { return "PayPal" }
func (c *PayPalChannel) buildClient(cfg *ChannelConfig) (*paypal.Client, error) {
clientID := cfg.Get("client_id")
secret := cfg.Get("client_secret")
if clientID == "" || secret == "" {
return nil, fmt.Errorf("PayPal 参数不完整:需要 client_id / client_secret")
}
apiBase := paypal.APIBaseSandBox
if strings.EqualFold(fmt.Sprint(cfg.Extra["env"]), "live") {
apiBase = paypal.APIBaseLive
}
client, err := paypal.NewClient(clientID, secret, apiBase)
if err != nil {
return nil, fmt.Errorf("创建 PayPal 客户端失败: %w", err)
}
return client, nil
}
func (c *PayPalChannel) currency(cfg *ChannelConfig) string {
if v := cfg.Get("currency"); v != "" {
return v
}
return "USD"
}
func (c *PayPalChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
subject := truncateStr(order.Subject, 120)
payOrder, err := client.CreateOrder(ctx, paypal.OrderIntentCapture,
[]paypal.PurchaseUnitRequest{{
CustomID: order.PayNo,
Description: subject,
Amount: &paypal.PurchaseUnitAmount{
Currency: c.currency(cfg),
Value: FenToYuan(order.Amount),
},
}},
nil,
&paypal.ApplicationContext{
ReturnURL: order.ReturnURL,
CancelURL: order.ReturnURL,
},
)
if err != nil {
return nil, fmt.Errorf("PayPal 下单失败: %w", err)
}
// v2 的跳转链接 rel 为 payer-action(旧版为 approve)
approve := ""
for _, l := range payOrder.Links {
if l.Rel == "approve" || l.Rel == "payer-action" {
approve = l.Href
break
}
}
if approve == "" {
return nil, fmt.Errorf("PayPal 下单未返回跳转链接")
}
return &PayParams{
Channel: ChannelPayPal,
PayType: PayTypeWeb,
RedirectURL: approve,
ChannelTradeNo: payOrder.ID, // PayPal 订单号,回调后替换为 capture id
}, nil
}
func (c *PayPalChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
if order.ChannelTradeNo == "" {
return nil, fmt.Errorf("PayPal 查询需要渠道订单号(尚未下单成功)")
}
payOrder, err := client.GetOrder(ctx, order.ChannelTradeNo)
if err != nil {
return nil, fmt.Errorf("PayPal 查询订单失败: %w", err)
}
state := &ChannelState{ChannelTradeNo: payOrder.ID, Raw: fmt.Sprintf("status=%s", payOrder.Status)}
switch payOrder.Status {
case "COMPLETED":
state.TradeState = StateSuccess
now := time.Now()
state.PaidAt = &now
case "VOIDED":
state.TradeState = StateClosed
case "APPROVED":
// 用户已批准但未捕获,等待 webhook 或人工捕获
state.TradeState = StatePending
default:
state.TradeState = StatePending
}
for _, pu := range payOrder.PurchaseUnits {
if pu.Amount != nil {
if fen, err := YuanToFen(pu.Amount.Value); err == nil {
state.Amount = fen
}
break
}
}
return state, nil
}
type paypalEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
Resource struct {
ID string `json:"id"`
Status string `json:"status"`
CustomID string `json:"custom_id"`
Amount struct {
Value string `json:"value"`
CurrencyCode string `json:"currency_code"`
} `json:"amount"`
} `json:"resource"`
}
func (c *PayPalChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
client, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
webhookID := cfg.Get("webhook_id")
if webhookID == "" {
return nil, fmt.Errorf("PayPal 参数不完整:需要 webhook_id")
}
// VerifyWebhookSignature 会读取请求体,先读出来再回填,供后面解析事件内容
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("读取 PayPal 通知失败: %w", err)
}
r.Body = io.NopCloser(bytes.NewReader(raw))
verify, err := client.VerifyWebhookSignature(ctx, r, webhookID)
if err != nil {
return nil, fmt.Errorf("PayPal 验签请求失败: %w", err)
}
if verify == nil || verify.VerificationStatus != "SUCCESS" {
return nil, fmt.Errorf("PayPal 通知验签失败: %s", verify.VerificationStatus)
}
event := &paypalEvent{}
if err := json.Unmarshal(raw, event); err != nil {
return nil, fmt.Errorf("解析 PayPal 事件失败: %w", err)
}
result := &NotifyResult{
EventType: event.EventType,
EventID: event.ID,
AckBody: "",
Raw: truncateStr(string(raw), 8192),
}
result.PayNo = event.Resource.CustomID
result.OutTradeNo = event.Resource.CustomID
result.ChannelTradeNo = event.Resource.ID
if fen, ferr := YuanToFen(event.Resource.Amount.Value); ferr == nil {
result.Amount = fen
}
switch event.EventType {
case "PAYMENT.CAPTURE.COMPLETED":
result.TradeState = StateSuccess
result.Paid = true
case "PAYMENT.CAPTURE.DENIED", "PAYMENT.CAPTURE.DECLINED":
result.TradeState = StateFailed
case "PAYMENT.CAPTURE.REFUNDED", "PAYMENT.CAPTURE.REVERSED":
result.TradeState = StateRefunded
default:
result.TradeState = StatePending
}
return result, nil
}
func (c *PayPalChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
if order.ChannelTradeNo == "" {
return "", fmt.Errorf("PayPal 退回需要 capture id(支付成功回调后写入),请先确认该单已收到成功回调")
}
resp, err := client.RefundCapture(ctx, order.ChannelTradeNo, paypal.RefundCaptureRequest{
Amount: &paypal.Money{
Currency: c.currency(cfg),
Value: FenToYuan(amount),
},
NoteToPayer: truncateStr(reason, 200),
})
if err != nil {
return "", fmt.Errorf("PayPal 退回请求失败: %w", err)
}
return resp.ID, nil
}
func (c *PayPalChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, err := c.buildClient(cfg)
if err != nil {
return "", err
}
token, err := client.GetAccessToken(ctx)
if err != nil {
return "", fmt.Errorf("PayPal OAuth2 凭证校验失败: %w", err)
}
if token == nil || token.Token == "" {
return "", fmt.Errorf("PayPal OAuth2 未返回访问令牌")
}
return fmt.Sprintf("连接成功:OAuth2 凭证校验通过(环境 %s)", cfg.Extra["env"]), nil
}
+394
View File
@@ -0,0 +1,394 @@
package payment
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"server/models"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// UnionPayChannel 银联全渠道(PC 网关支付 / 手机控件支付)
//
// 银联没有官方 Go SDK,这里按「全渠道支付平台产品接口规范」实现签名与请求:
// - 签名:SHA256withRSA,字段按 key ASCII 升序拼 key=value&... 后私钥签名,base64 放入 signature;
// - 下单:POST /gateway/api/frontTransReq.do(前台跳转,返回自动提交表单);
// - 查询:POST /gateway/api/queryTrans.do(txnType=00);
// - 退货:POST /gateway/api/backTransReq.do(txnType=04)。
//
// 渠道参数(config_json):
//
// mer_id 商户号
// acq_ins_code 收单机构代码
// cert_password 商户证书密码
// is_production "1" 生产网关 / 其他为测试网关
//
// 证书(cert_json):
//
// cert_path 商户证书(.pfx / .p12,或 .cer + .pem 私钥组合)
type UnionPayChannel struct{}
func (c *UnionPayChannel) Code() string { return ChannelUnionPay }
func (c *UnionPayChannel) Name() string { return "银联" }
const (
unionPayGatewayProd = "https://gateway.95516.com"
unionPayGatewayTest = "https://gateway.test.95516.com"
unionPayVersion = "5.1.0"
unionPayCurrencyCNY = "156"
unionPayDefaultBizType = "000201" // B2C 网关支付
)
func unionPayGateway(cfg *ChannelConfig) string {
if cfg.Get("is_production") == "1" {
return unionPayGatewayProd
}
return unionPayGatewayTest
}
// unionPayKey 加载商户私钥与证书(支持 .pfx/.p12 与 .pem+.cer 两种形式)
func unionPayKey(cfg *ChannelConfig) (*rsa.PrivateKey, *x509.Certificate, error) {
certPath := cfg.CertPaths["cert_path"]
if certPath == "" {
return nil, nil, fmt.Errorf("银联缺少商户证书:请先上传商户证书")
}
data, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("读取银联证书失败: %w", err)
}
if block, _ := pem.Decode(data); block != nil && strings.Contains(block.Type, "PRIVATE KEY") {
keyData, kerr := os.ReadFile(cfg.CertPaths["key_path"])
if kerr != nil {
return nil, nil, fmt.Errorf("读取银联私钥失败: %w", kerr)
}
priv, perr := parsePKCS1Or8(string(keyData))
if perr != nil {
return nil, nil, perr
}
cert, cerr := loadCertFile(certPath)
if cerr != nil {
return nil, nil, cerr
}
return priv, cert, nil
}
privAny, cert, err := pkcs12.Decode(data, cfg.Get("cert_password"))
if err != nil {
return nil, nil, fmt.Errorf("解析银联 .pfx 证书失败(密码是否正确): %w", err)
}
priv, ok := privAny.(*rsa.PrivateKey)
if !ok {
return nil, nil, fmt.Errorf("银联证书私钥不是 RSA 私钥")
}
if cert == nil {
return nil, nil, fmt.Errorf("银联证书缺少证书信息")
}
return priv, cert, nil
}
func parsePKCS1Or8(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("私钥不是有效的 PEM")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("私钥不是 RSA 私钥")
}
return rsaKey, nil
}
func loadCertFile(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("证书不是有效的 PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// unionPayCertID 银联证书序列号(SerialNumber 十六进制大写)
func unionPayCertID(cert *x509.Certificate) string {
return strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber))
}
// unionPaySign 银联签名:key 升序拼 key=value&... 后 SHA256withRSA
func unionPaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "signature" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+"="+params[k])
}
digest := sha256.Sum256([]byte(strings.Join(pairs, "&")))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// unionPaySigned 组装带签名与 certId 的请求参数
func unionPaySigned(cfg *ChannelConfig, priv *rsa.PrivateKey, cert *x509.Certificate, extra map[string]string) (map[string]string, error) {
params := map[string]string{
"version": unionPayVersion,
"encoding": "UTF-8",
"signMethod": "01",
"accessType": "0",
"merId": cfg.Get("mer_id"),
}
for k, v := range extra {
if v != "" {
params[k] = v
}
}
sig, err := unionPaySign(params, priv)
if err != nil {
return nil, fmt.Errorf("银联签名失败: %w", err)
}
params["signature"] = sig
params["certId"] = unionPayCertID(cert)
return params, nil
}
// unionPayPostRaw 提交表单并返回原始响应
func unionPayPostRaw(ctx context.Context, endpoint string, params map[string]string) (string, error) {
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return "", fmt.Errorf("请求银联网关失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("银联网关返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
return string(body), nil
}
// unionPayPostForm 解析 form-urlencoded 响应(queryTrans / backTransReq.do)
func unionPayPostForm(ctx context.Context, endpoint string, params map[string]string) (url.Values, error) {
body, err := unionPayPostRaw(ctx, endpoint, params)
if err != nil {
return nil, err
}
vals, perr := url.ParseQuery(body)
if perr != nil || len(vals) == 0 {
return nil, fmt.Errorf("银联响应不是有效的表单数据: %s", truncateStr(body, 200))
}
return vals, nil
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func (c *UnionPayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
extra := map[string]string{
"txnType": "01",
"txnSubType": "01",
"bizType": unionPayDefaultBizType,
"orderId": order.PayNo,
"txnTime": time.Now().Format("20060102150405"),
"txnAmt": strconv.FormatInt(order.Amount, 10),
"currencyCode": unionPayCurrencyCNY,
"backUrl": cfg.CallbackURL,
"frontUrl": order.ReturnURL,
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return nil, err
}
// 前台跳转:银联返回自动提交表单,前端写入页面即可拉起收银台
html, err := unionPayPostRaw(ctx, unionPayGateway(cfg)+"/gateway/api/frontTransReq.do", params)
if err != nil {
return nil, fmt.Errorf("银联下单失败: %w", err)
}
payType := PayTypeWeb
if strings.EqualFold(opt.PayType, PayTypeH5) {
payType = PayTypeH5
}
return &PayParams{Channel: ChannelUnionPay, PayType: payType, Form: html}, nil
}
func (c *UnionPayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
extra := map[string]string{
"txnType": "00",
"txnSubType": "00",
"bizType": unionPayDefaultBizType,
"orderId": order.PayNo,
"txnTime": order.CreateTime.Format("20060102150405"),
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return nil, err
}
// TODO(银联): 查询响应字段(origRespCode/origTxnAmt/queryId 等)以入网时下发的接口文档为准,联调时校准。
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/queryTrans.do", params)
if err != nil {
return nil, err
}
state := &ChannelState{Raw: fmt.Sprint(vals)}
if vals.Get("queryId") != "" {
state.ChannelTradeNo = vals.Get("queryId")
}
if vals.Get("origRespCode") == "00" {
state.TradeState = StateSuccess
if amt, aerr := strconv.ParseInt(vals.Get("origTxnAmt"), 10, 64); aerr == nil {
state.Amount = amt
}
now := time.Now()
state.PaidAt = &now
} else {
state.TradeState = StatePending
}
return state, nil
}
func (c *UnionPayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
priv, _, err := unionPayKey(cfg)
if err != nil {
return nil, err
}
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("解析银联通知失败: %w", err)
}
form := map[string]string{}
for k := range r.Form {
form[k] = r.Form.Get(k)
}
expect, err := unionPaySign(form, priv)
if err != nil {
return nil, err
}
if expect != r.Form.Get("signature") {
return nil, fmt.Errorf("银联通知验签失败")
}
result := &NotifyResult{
EventType: "UNIONPAY.NOTIFY",
AckBody: "ok",
Raw: fmt.Sprint(r.Form),
}
result.PayNo = form["orderId"]
result.OutTradeNo = form["orderId"]
result.ChannelTradeNo = form["queryId"]
if amt, aerr := strconv.ParseInt(form["txnAmt"], 10, 64); aerr == nil {
result.Amount = amt
}
if form["respCode"] == "00" {
result.TradeState = StateSuccess
result.Paid = true
} else {
result.TradeState = StateFailed
}
return result, nil
}
func (c *UnionPayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
if order.ChannelTradeNo == "" {
return "", fmt.Errorf("银联退回需要原交易流水号,请先执行「手动查询渠道状态」")
}
priv, cert, err := unionPayKey(cfg)
if err != nil {
return "", err
}
extra := map[string]string{
"txnType": "04",
"txnSubType": "00",
"bizType": unionPayDefaultBizType,
"orderId": refundNo,
"txnTime": time.Now().Format("20060102150405"),
"txnAmt": strconv.FormatInt(amount, 10),
"origQryId": order.ChannelTradeNo,
"backUrl": cfg.CallbackURL,
}
params, err := unionPaySigned(cfg, priv, cert, extra)
if err != nil {
return "", err
}
// TODO(银联): 退货响应字段与重试策略以入网时下发的接口文档为准,联调时校准。
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/backTransReq.do", params)
if err != nil {
return "", err
}
if vals.Get("respCode") != "00" {
return "", fmt.Errorf("银联退回失败: %s %s", vals.Get("respCode"), vals.Get("respMsg"))
}
return vals.Get("queryId"), nil
}
func (c *UnionPayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
priv, cert, err := unionPayKey(cfg)
if err != nil {
return "", err
}
if _, err := unionPaySign(map[string]string{"merId": cfg.Get("mer_id"), "orderId": "__connect_test__"}, priv); err != nil {
return "", err
}
// TODO(银联): 如需校验网关连通性,可在入网报备后用一笔查询交易确认。
return fmt.Sprintf("连接成功:证书加载与签名校验通过(证书序列号 %s,网关 %s)", unionPayCertID(cert), unionPayGateway(cfg)), nil
}
// CloudPayChannel 云闪付:走银联通道,商户参数与证书完全复用银联配置;
// 收银台是否展示云闪付标识由 extra_json.show_logo 控制(由前端收银台读取),适配器层面与银联一致。
type CloudPayChannel struct {
UnionPayChannel
}
func (c *CloudPayChannel) Code() string { return ChannelCloudPay }
func (c *CloudPayChannel) Name() string { return "云闪付" }
+316
View File
@@ -0,0 +1,316 @@
package payment
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"server/models"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// WechatChannel 微信支付(JSAPI / Native 扫码)
//
// 渠道参数(config_json):
//
// mch_id 商户号
// appid 公众号 / 小程序 APPID
// cert_serial_no 商户 API 证书序列号
// api_v3_key APIv3 密钥
//
// 证书(cert_json):
//
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
type WechatChannel struct{}
func (c *WechatChannel) Code() string { return ChannelWechat }
func (c *WechatChannel) Name() string { return "微信支付" }
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
mchID := cfg.Get("mch_id")
serial := cfg.Get("cert_serial_no")
apiV3Key := cfg.Get("api_v3_key")
if mchID == "" || serial == "" || apiV3Key == "" {
return nil, "", fmt.Errorf("微信支付参数不完整:需要 mch_id / cert_serial_no / api_v3_key")
}
keyPath := cfg.CertPaths["key_path"]
if keyPath == "" {
return nil, "", fmt.Errorf("微信支付缺少商户私钥:请先上传 apiclient_key.pem")
}
pem, err := os.ReadFile(keyPath)
if err != nil {
return nil, "", fmt.Errorf("读取商户私钥失败: %w", err)
}
privateKey, err := utils.LoadPrivateKey(string(pem))
if err != nil {
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
}
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(mchID, serial, privateKey),
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
)
if err != nil {
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", err)
}
return client, mchID, nil
}
func (c *WechatChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
appID := cfg.Get("appid")
if appID == "" {
return nil, fmt.Errorf("微信支付参数不完整:需要 appid")
}
notifyURL := cfg.CallbackURL
amount := &native.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")}
// 公众号内支付
if strings.EqualFold(opt.PayType, PayTypeJSAPI) {
if opt.OpenID == "" {
return nil, fmt.Errorf("微信 JSAPI 支付需要 OpenID")
}
svc := jsapi.JsapiApiService{Client: client}
resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(mchID),
Description: core.String(order.Subject),
OutTradeNo: core.String(order.PayNo),
NotifyUrl: core.String(notifyURL),
Amount: &jsapi.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")},
Payer: &jsapi.Payer{Openid: core.String(opt.OpenID)},
})
if err != nil {
return nil, fmt.Errorf("微信 JSAPI 下单失败: %w", err)
}
if resp == nil || resp.PrepayId == nil {
return nil, fmt.Errorf("微信 JSAPI 下单未返回 prepay_id")
}
// 按微信规范自行生成 JSAPI 调起参数(timeStamp/nonceStr/paySign)
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce, _ := randomNonce(16)
message := appID + "\n" + ts + "\n" + nonce + "\n" + "prepay_id=" + *resp.PrepayId + "\n"
sign, err := client.Sign(ctx, message)
if err != nil {
return nil, fmt.Errorf("生成 JSAPI 签名失败: %w", err)
}
return &PayParams{
Channel: ChannelWechat,
PayType: PayTypeJSAPI,
JSAPI: map[string]string{
"appId": appID,
"timeStamp": ts,
"nonceStr": nonce,
"package": "prepay_id=" + *resp.PrepayId,
"signType": "RSA",
"paySign": sign.Signature,
},
}, nil
}
// 默认:Native 扫码(PC 收银台)
req := native.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(mchID),
Description: core.String(order.Subject),
OutTradeNo: core.String(order.PayNo),
NotifyUrl: core.String(notifyURL),
Amount: amount,
}
if order.ExpireAt != nil {
req.TimeExpire = order.ExpireAt
}
svc := native.NativeApiService{Client: client}
resp, _, err := svc.Prepay(ctx, req)
if err != nil {
return nil, fmt.Errorf("微信 Native 下单失败: %w", err)
}
if resp == nil || resp.CodeUrl == nil {
return nil, fmt.Errorf("微信 Native 下单未返回 code_url")
}
return &PayParams{Channel: ChannelWechat, PayType: PayTypeQR, CodeURL: *resp.CodeUrl}, nil
}
// normalizeWechatState 微信交易状态 -> 归一化状态
func normalizeWechatState(state string) string {
switch state {
case "SUCCESS":
return StateSuccess
case "NOTPAY", "USERPAYING":
return StatePending
case "CLOSED", "REVOKED":
return StateClosed
case "PAYERROR":
return StateFailed
case "REFUND":
return StateRefunded
default:
return StatePending
}
}
func (c *WechatChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
url := consts.WechatPayAPIServer + "/v3/pay/transactions/out-trade-no/" + order.PayNo + "?mchid=" + mchID
result, err := client.Get(ctx, url)
if err != nil {
return nil, fmt.Errorf("微信查询订单失败: %w", err)
}
body, err := io.ReadAll(result.Response.Body)
if err != nil {
return nil, fmt.Errorf("读取微信查询响应失败: %w", err)
}
tx := &payments.Transaction{}
if err := json.Unmarshal(body, tx); err != nil {
return nil, fmt.Errorf("解析微信查询响应失败: %w", err)
}
state := &ChannelState{Raw: string(body)}
if tx.TransactionId != nil {
state.ChannelTradeNo = *tx.TransactionId
}
state.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
if tx.Amount != nil && tx.Amount.Total != nil {
state.Amount = *tx.Amount.Total
}
if tx.SuccessTime != nil {
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
state.PaidAt = &t
}
}
return state, nil
}
func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
mchID := cfg.Get("mch_id")
apiV3Key := cfg.Get("api_v3_key")
// 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
if err != nil {
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
}
tx := &payments.Transaction{}
req, err := handler.ParseNotifyRequest(ctx, r, tx)
if err != nil {
return nil, fmt.Errorf("微信通知验签/解密失败: %w", err)
}
result := &NotifyResult{
EventType: req.EventType,
EventID: req.ID,
AckBody: `{"code":"SUCCESS","message":"成功"}`,
Raw: req.Resource.Plaintext,
}
if tx.OutTradeNo != nil {
result.OutTradeNo = *tx.OutTradeNo
result.PayNo = *tx.OutTradeNo
}
if tx.TransactionId != nil {
result.ChannelTradeNo = *tx.TransactionId
}
if tx.Amount != nil && tx.Amount.Total != nil {
result.Amount = *tx.Amount.Total
}
if tx.SuccessTime != nil {
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
_ = t
}
}
result.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
result.Paid = result.TradeState == StateSuccess
return result, nil
}
func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, _, err := c.buildClient(cfg)
if err != nil {
return "", err
}
body := map[string]any{
"out_trade_no": order.PayNo,
"out_refund_no": refundNo,
"reason": reason,
"notify_url": "", // 退回结果以主动查询为准;如需退回回调,配置后填入
"amount": map[string]any{
"refund": amount,
"total": order.Amount,
"currency": "CNY",
},
}
result, err := client.Post(ctx, consts.WechatPayAPIServer+"/v3/refund/domestic/refunds", body)
if err != nil {
return "", fmt.Errorf("微信退回请求失败: %w", err)
}
respBody, _ := io.ReadAll(result.Response.Body)
var resp struct {
RefundID string `json:"refund_id"`
Status string `json:"status"`
}
_ = json.Unmarshal(respBody, &resp)
if resp.Status != "" && resp.Status != "SUCCESS" && resp.Status != "PROCESSING" {
return resp.RefundID, fmt.Errorf("微信退回未受理,状态: %s", resp.Status)
}
return resp.RefundID, nil
}
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, _, err := c.buildClient(cfg)
if err != nil {
return "", err
}
// 平台证书接口只做签名校验,不产生任何交易
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
if err != nil {
return "", fmt.Errorf("微信凭证校验失败: %w", err)
}
body, _ := io.ReadAll(result.Response.Body)
return fmt.Sprintf("连接成功:商户证书与 APIv3 密钥校验通过(平台证书响应 %d 字节)", len(body)), nil
}
/* ---------------- 小工具 ---------------- */
func valueOrEmpty(v *string) string {
if v == nil {
return ""
}
return *v
}
// randomNonce 生成 n 字节随机数的十六进制串
func randomNonce(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+273
View File
@@ -0,0 +1,273 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
)
/* =============================================================
* 推广佣金:规则匹配 -> 金额计算 -> 佣金单生成(幂等)-> 发放登记
* 佣金是平台自有资金支出,与租户收款资金流解耦。
* ============================================================= */
// OnOrderPaid 支付成功后生成佣金单(幂等:同一支付单 + 同一推广方只生成一张)
// 无推广方 / 无匹配规则 / 佣金金额为 0 时返回 (nil, nil)。
func OnOrderPaid(ctx context.Context, order *models.PlatformPaymentOrder) (*models.PlatformCommissionOrder, error) {
if order == nil || order.PromoterID == "" {
return nil, nil
}
// 幂等查重
exist := &models.PlatformCommissionOrder{}
err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("pay_no", order.PayNo).
Filter("promoter_id", order.PromoterID).
Filter("delete_time__isnull", true).
One(exist)
if err == nil {
return exist, nil
} else if err != orm.ErrNoRows {
return nil, fmt.Errorf("查询佣金单失败: %w", err)
}
rule, err := matchCommissionRule(order.OrderType)
if err != nil {
return nil, err
}
if rule == nil {
return nil, nil
}
amount := calcCommissionAmount(rule, order.Amount)
if amount <= 0 {
beelog.Warn("佣金规则 %s 计算金额为 0,跳过(订单 %s)", rule.RuleName, order.PayNo)
return nil, nil
}
row := &models.PlatformCommissionOrder{
CommissionNo: NextCommissionNo(),
PayNo: order.PayNo,
OutTradeNo: order.OutTradeNo,
RuleID: rule.ID,
RuleName: rule.RuleName,
CalcType: rule.CalcType,
PromoterID: order.PromoterID,
PromoterName: order.PromoterName,
PromoterType: order.PromoterType,
BaseAmount: order.Amount,
Amount: amount,
Status: models.CommissionStatusPayable,
Remark: fmt.Sprintf("支付单 %s 支付成功自动生成", order.PayNo),
}
if _, err := models.Orm.Insert(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
// 并发生成命中唯一键:查回已有记录
return exist, nil
}
return nil, fmt.Errorf("创建佣金单失败: %w", err)
}
beelog.Info("佣金单已生成: %s 支付单 %s 推广方 %s 金额 %d 分", row.CommissionNo, order.PayNo, order.PromoterName, amount)
return row, nil
}
// matchCommissionRule 匹配启用中的佣金规则(同类型规则取最新一条)
func matchCommissionRule(orderType string) (*models.PlatformCommissionRule, error) {
var rules []models.PlatformCommissionRule
_, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
Filter("status", 1).
Filter("delete_time__isnull", true).
OrderBy("-id").
All(&rules)
if err != nil {
return nil, err
}
for i := range rules {
if rules[i].OrderType == models.CommissionOrderTypeAll || rules[i].OrderType == orderType {
return &rules[i], nil
}
}
return nil, nil
}
// calcCommissionAmount 按规则计算佣金(分),向下取整
func calcCommissionAmount(rule *models.PlatformCommissionRule, baseAmount int64) int64 {
if rule == nil || baseAmount <= 0 {
return 0
}
switch rule.CalcType {
case models.CommissionCalcPercent:
return baseAmount * rule.Value / 10000 // value = 百分比 × 100
case models.CommissionCalcFixed:
return rule.Value
case models.CommissionCalcTiered:
for _, tier := range parseTiers(rule.TierJSON) {
if baseAmount < tier.Min {
continue
}
if tier.Max != nil && baseAmount >= *tier.Max {
continue
}
return baseAmount * tier.Rate / 10000
}
return 0
default:
return 0
}
}
func parseTiers(raw *string) []models.CommissionTier {
tiers := []models.CommissionTier{}
if raw == nil || *raw == "" {
return tiers
}
_ = json.Unmarshal([]byte(*raw), &tiers)
return tiers
}
/* ---------------- 佣金台账查询与发放 ---------------- */
// CommissionListInput 台账查询条件
type CommissionListInput struct {
Promoter string
Status string
StartTime string // YYYY-MM-DD
EndTime string
Page int
PageSize int
}
// ListCommissions 佣金台账分页
func ListCommissions(in CommissionListInput) ([]models.PlatformCommissionOrder, int64, error) {
qs := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).Filter("delete_time__isnull", true)
if in.Promoter != "" {
qs = qs.Filter("promoter_name__icontains", in.Promoter)
}
if in.Status != "" {
qs = qs.Filter("status", in.Status)
}
if in.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02", in.StartTime, time.Local); err == nil {
qs = qs.Filter("create_time__gte", t)
}
}
if in.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02", in.EndTime, time.Local); err == nil {
qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1))
}
}
total, err := qs.Count()
if err != nil {
return nil, 0, err
}
if in.Page <= 0 {
in.Page = 1
}
if in.PageSize <= 0 || in.PageSize > 200 {
in.PageSize = 10
}
var rows []models.PlatformCommissionOrder
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
return rows, total, err
}
// CommissionStats 台账统计(应付 / 已付 / 待处理笔数)
type CommissionStats struct {
PayableAmount int64 `json:"payable_amount"`
PaidAmount int64 `json:"paid_amount"`
PendingCount int64 `json:"pending_count"`
}
// GetCommissionStats 统计口径:
//
// 应付总额 = status=payable 的金额合计;已付总额 = status=settled;待处理笔数 = payable + tax_processing。
func GetCommissionStats() (*CommissionStats, error) {
type row struct {
Total int64 `orm:"column(total)"`
Cnt int64 `orm:"column(cnt)"`
}
stats := &CommissionStats{}
pay := row{}
if err := models.Orm.Raw(
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
models.CommissionStatusPayable).QueryRow(&pay); err != nil {
return nil, err
}
stats.PayableAmount = pay.Total
stats.PendingCount = pay.Cnt
paid := row{}
if err := models.Orm.Raw(
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
models.CommissionStatusSettled).QueryRow(&paid); err != nil {
return nil, err
}
stats.PaidAmount = paid.Total
tax := row{}
if err := models.Orm.Raw(
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
models.CommissionStatusTaxProcessing).QueryRow(&tax); err != nil {
return nil, err
}
stats.PendingCount += tax.Cnt
return stats, nil
}
// MarkCommissionPaidInput 标记已付入参
type MarkCommissionPaidInput struct {
ID uint64
PayMethod string
PayVoucher string
Remark string
OperatorID string
OperatorName string
}
// MarkCommissionPaid 发放登记:应付/税务处理中 -> 已付
func MarkCommissionPaid(ctx context.Context, in MarkCommissionPaidInput) (*models.PlatformCommissionOrder, error) {
row := &models.PlatformCommissionOrder{}
if err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("id", in.ID).
Filter("delete_time__isnull", true).
One(row); err != nil {
return nil, fmt.Errorf("佣金单不存在")
}
if row.Status == models.CommissionStatusSettled {
return nil, errors.New("该佣金单已是已付状态")
}
now := time.Now()
_, err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("id", row.ID).
Update(orm.Params{
"status": models.CommissionStatusSettled,
"pay_method": in.PayMethod,
"pay_voucher": in.PayVoucher,
"paid_at": now,
"paid_user_id": in.OperatorID,
"paid_user_name": in.OperatorName,
"remark": in.Remark,
"update_time": now,
})
if err != nil {
return nil, err
}
return GetCommissionByID(in.ID)
}
// GetCommissionByID 按 ID 查询佣金单
func GetCommissionByID(id uint64) (*models.PlatformCommissionOrder, error) {
row := &models.PlatformCommissionOrder{}
err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
Filter("id", id).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
+106
View File
@@ -0,0 +1,106 @@
package payment
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"strings"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 渠道敏感参数的对称加密与掩码
//
// yz_platform_payment_channel.config_json 中的密钥/私钥/Secret 以 AES-256-GCM 加密后存储,
// 格式:base64( "YZP1:" + nonce + ciphertext )。
// 密钥来源:app.conf 的 payment_secret_key(任意长度字符串,内部做 SHA-256 派生);
// 未配置时退回内置兜底密钥(保证开箱可用),上线前务必在 app.conf 配置该值,
// 且注意:更换密钥后,历史密文将无法解密,需要重新保存各渠道配置。
// =============================================================
const configCipherPrefix = "YZP1:"
// fallbackSecretKey 内置兜底密钥,仅用于开发/未配置场景
const fallbackSecretKey = "yunzer_payment_default_secret_key_v1"
func configKey() []byte {
raw, _ := beego.AppConfig.String("payment_secret_key")
if strings.TrimSpace(raw) == "" {
raw = fallbackSecretKey
}
sum := sha256.Sum256([]byte(raw))
return sum[:]
}
// EncryptConfig 加密渠道参数 JSON
func EncryptConfig(plain string) (string, error) {
if plain == "" {
return "", nil
}
block, err := aes.NewCipher(configKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
buf := append([]byte(configCipherPrefix), nonce...)
buf = append(buf, ciphertext...)
return base64.StdEncoding.EncodeToString(buf), nil
}
// DecryptConfig 解密渠道参数 JSON;空串原样返回
func DecryptConfig(encoded string) (string, error) {
if encoded == "" {
return "", nil
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", errors.New("渠道参数不是有效的 base64")
}
if len(raw) < len(configCipherPrefix) || string(raw[:len(configCipherPrefix)]) != configCipherPrefix {
return "", errors.New("渠道参数格式不正确(缺少加密前缀)")
}
block, err := aes.NewCipher(configKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := raw[len(configCipherPrefix) : len(configCipherPrefix)+gcm.NonceSize()]
ciphertext := raw[len(configCipherPrefix)+gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", errors.New("渠道参数解密失败(payment_secret_key 是否被更换过?)")
}
return string(plain), nil
}
// MaskSecret 生成掩码:保留末 4 位
func MaskSecret(v string) string {
if v == "" {
return ""
}
if len(v) <= 4 {
return "******"
}
return "******" + v[len(v)-4:]
}
// IsMasked 判断前端回传的值是否仍是掩码(表示「不修改原值」)
func IsMasked(v string) bool {
return strings.HasPrefix(v, "******")
}
+81
View File
@@ -0,0 +1,81 @@
package payment
import (
"crypto/rand"
"fmt"
"math/big"
"strconv"
"strings"
"sync"
"time"
)
/* ---------------- 金额工具 ---------------- */
// FenToYuan 分 -> 元(两位小数字符串),供渠道下单参数使用
func FenToYuan(fen int64) string {
if fen < 0 {
fen = 0
}
return strconv.FormatInt(fen/100, 10) + "." + fmt.Sprintf("%02d", fen%100)
}
// YuanToFen 元(字符串)-> 分
func YuanToFen(yuan string) (int64, error) {
yuan = strings.TrimSpace(yuan)
if yuan == "" {
return 0, nil
}
f, err := strconv.ParseFloat(yuan, 64)
if err != nil {
return 0, fmt.Errorf("金额格式不正确: %s", yuan)
}
if f < 0 {
return 0, fmt.Errorf("金额不能为负数: %s", yuan)
}
return int64(f*100 + 0.5), nil
}
/* ---------------- 单号生成 ----------------
规则:前缀 + yyyyMMddHHmmss + 4 位随机数字(进程内串行生成,避免同秒碰撞;
落库仍有唯一键兜底,撞号时由调用方重试一次)。
支付单 P202609151024310001
退回单 R202609151024310001
佣金单 CM202609151024310001
对账批次 RC202609151024310001
---------------------------------------- */
var (
noMu sync.Mutex
lastNoTime int64
lastNoSeq int64
)
func nextSerial(prefix string) string {
noMu.Lock()
defer noMu.Unlock()
now := time.Now().Unix()
seq := int64(0)
if now == lastNoTime {
seq = lastNoSeq + 1
}
lastNoTime = now
lastNoSeq = seq
n, _ := rand.Int(rand.Reader, big.NewInt(10000))
return fmt.Sprintf("%s%s%04d%04d", prefix, time.Now().Format("20060102150405"), seq, n.Int64())
}
// NextPayNo 生成支付单号
func NextPayNo() string { return nextSerial("P") }
// NextRefundNo 生成退回单号
func NextRefundNo() string { return nextSerial("R") }
// NextCommissionNo 生成佣金单号
func NextCommissionNo() string { return nextSerial("CM") }
// NextBatchNo 生成对账批次号
func NextBatchNo() string { return nextSerial("RC") }
+367
View File
@@ -0,0 +1,367 @@
package payment
import (
"context"
"encoding/csv"
"fmt"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
)
/* =============================================================
* 日终对账:渠道账单导入 -> 与本地支付流水比对 -> 产出差异 -> 人工标记处理
* 差异类型:long 长款(渠道有本地无)/ short 短款(本地有渠道无)/ status_mismatch 状态不一致
* ============================================================= */
// BillRow 渠道账单明细行(导入用)
type BillRow struct {
ChannelTradeNo string // 渠道流水号
OutTradeNo string // 渠道侧商户订单号(下单时传的 pay_no)
Amount int64 // 渠道金额(分)
Fee int64 // 渠道手续费(分)
TradeState string // 渠道侧交易状态
TradeTime *time.Time // 渠道交易时间
}
// ImportChannelBill 导入某渠道某日的账单明细(幂等:按 channel + channel_trade_no 更新或插入)
func ImportChannelBill(ctx context.Context, channel, billDate string, rows []BillRow) (int, error) {
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
if err != nil {
return 0, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
}
imported := 0
for _, r := range rows {
if strings.TrimSpace(r.ChannelTradeNo) == "" {
continue
}
exist := &models.PlatformPaymentChannelBill{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("channel", channel).
Filter("channel_trade_no", r.ChannelTradeNo).
Filter("delete_time__isnull", true).
One(exist)
if err == nil {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("id", exist.ID).
Update(orm.Params{
"amount": r.Amount, "fee": r.Fee, "trade_state": r.TradeState,
"update_time": time.Now(),
})
imported++
continue
}
row := &models.PlatformPaymentChannelBill{
Channel: channel,
BillDate: day,
ChannelTradeNo: r.ChannelTradeNo,
OutTradeNo: r.OutTradeNo,
Amount: r.Amount,
Fee: r.Fee,
TradeState: r.TradeState,
TradeTime: r.TradeTime,
}
if _, err := models.Orm.Insert(row); err == nil {
imported++
}
}
return imported, nil
}
// ParseChannelBillCSV 解析账单 CSV。
// 约定列:channel_trade_no, out_trade_no, amount(元), fee(元), trade_state, trade_time
// TODO(渠道): 各渠道账单文件列名/编码不同(微信为制表符分隔、支付宝为 GBK CSV),
// 对接时按渠道导出文件写各自的解析器,统一转成 BillRow。
func ParseChannelBillCSV(data []byte) ([]BillRow, error) {
reader := csv.NewReader(strings.NewReader(string(data)))
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
rows := make([]BillRow, 0, len(records))
for i, rec := range records {
if i == 0 || len(rec) == 0 {
continue
}
get := func(idx int) string {
if idx < len(rec) {
return strings.TrimSpace(rec[idx])
}
return ""
}
row := BillRow{
ChannelTradeNo: get(0),
OutTradeNo: get(1),
TradeState: get(4),
}
if row.ChannelTradeNo == "" {
continue
}
if fen, err := YuanToFen(get(2)); err == nil {
row.Amount = fen
}
if fen, err := YuanToFen(get(3)); err == nil {
row.Fee = fen
}
if t := get(5); t != "" {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, "2006-01-02"} {
if tt, err := time.ParseInLocation(layout, t, time.Local); err == nil {
row.TradeTime = &tt
break
}
}
}
rows = append(rows, row)
}
return rows, nil
}
// ReconcileSummary 一次对账的执行结果
type ReconcileSummary struct {
BatchNo string `json:"batch_no"`
Channel string `json:"channel"`
BillDate string `json:"bill_date"`
BillCount int `json:"bill_count"`
Matched int `json:"matched"`
LongCount int `json:"long_count"`
ShortCount int `json:"short_count"`
MismatchCount int `json:"mismatch_count"`
}
// RunReconcile 对某渠道某日的账单与本地支付流水做比对,产出差异(幂等:按唯一键更新已有差异)。
// 匹配策略:渠道账单的商户订单号即本地下单时传的 pay_no,优先按 pay_no 匹配,其次按 out_trade_no;
// 本地侧只统计支付成功日(paid_at)落在对账日内的支付单。
func RunReconcile(ctx context.Context, channel, billDate string) (*ReconcileSummary, error) {
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
if err != nil {
return nil, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
}
if _, err := LoadChannelConfig(channel); err != nil {
return nil, err
}
var bills []models.PlatformPaymentChannelBill
_, err = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("channel", channel).
Filter("bill_date", day).
Filter("delete_time__isnull", true).
All(&bills)
if err != nil {
return nil, err
}
var locals []models.PlatformPaymentOrder
_, err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("delete_time__isnull", true).
Filter("status", models.PayStatusPaid).
Filter("paid_at__gte", day).
Filter("paid_at__lt", day.AddDate(0, 0, 1)).
All(&locals)
if err != nil {
return nil, err
}
localByPayNo := map[string]*models.PlatformPaymentOrder{}
localByOutTrade := map[string]*models.PlatformPaymentOrder{}
for i := range locals {
localByPayNo[locals[i].PayNo] = &locals[i]
localByOutTrade[locals[i].OutTradeNo] = &locals[i]
}
batchNo := NextBatchNo()
summary := &ReconcileSummary{BatchNo: batchNo, Channel: channel, BillDate: billDate, BillCount: len(bills)}
matchedPayNos := map[string]bool{}
for i := range bills {
bill := bills[i]
local := localByPayNo[bill.OutTradeNo]
if local == nil {
local = localByOutTrade[bill.OutTradeNo]
}
if local == nil {
summary.LongCount++
_ = upsertDiff(channel, day, batchNo, "", bill.ChannelTradeNo,
models.DiffTypeLong, nil, &bill.Amount, "渠道账单存在该流水,本地无对应支付单")
continue
}
if local.Status == models.PayStatusPaid && bill.Amount == local.Amount {
summary.Matched++
matchedPayNos[local.PayNo] = true
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
Filter("id", bill.ID).
Update(orm.Params{"is_matched": 1, "matched_pay_no": local.PayNo, "update_time": time.Now()})
continue
}
summary.MismatchCount++
reason := "本地与渠道金额不一致"
if bill.Amount == local.Amount {
reason = fmt.Sprintf("渠道已成功但本地状态为 %s", local.Status)
}
_ = upsertDiff(channel, day, batchNo, local.PayNo, bill.ChannelTradeNo,
models.DiffTypeStatusMismatch, &local.Amount, &bill.Amount, reason)
}
for i := range locals {
if matchedPayNos[locals[i].PayNo] {
continue
}
summary.ShortCount++
_ = upsertDiff(channel, day, batchNo, locals[i].PayNo, "",
models.DiffTypeShort, &locals[i].Amount, nil, "本地已支付,渠道账单无对应流水")
}
beelog.Info("对账完成: 渠道=%s 日期=%s 账单 %d 笔,匹配 %d,长款 %d,短款 %d,不一致 %d(批次 %s)",
channel, billDate, summary.BillCount, summary.Matched, summary.LongCount, summary.ShortCount, summary.MismatchCount, batchNo)
return summary, nil
}
// upsertDiff 写入/更新差异记录(幂等:唯一键 channel+bill_date+diff_type+channel_trade_no+local_trade_no)
func upsertDiff(channel string, day time.Time, batchNo, localTradeNo, channelTradeNo, diffType string, localAmount, channelAmount *int64, remark string) error {
exist := &models.PlatformPaymentReconcileDiff{}
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("channel", channel).
Filter("bill_date", day).
Filter("diff_type", diffType).
Filter("channel_trade_no", channelTradeNo).
Filter("local_trade_no", localTradeNo).
Filter("delete_time__isnull", true).
One(exist)
diffAmount := int64(0)
switch {
case channelAmount != nil && localAmount != nil:
diffAmount = *channelAmount - *localAmount
case channelAmount != nil:
diffAmount = *channelAmount
case localAmount != nil:
diffAmount = -*localAmount
}
if err == nil {
_, uerr := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", exist.ID).
Update(orm.Params{
"batch_no": batchNo,
"local_amount": localAmount,
"channel_amount": channelAmount,
"diff_amount": diffAmount,
"update_time": time.Now(),
})
return uerr
}
row := &models.PlatformPaymentReconcileDiff{
Channel: channel,
BillDate: day,
BatchNo: batchNo,
LocalTradeNo: localTradeNo,
ChannelTradeNo: channelTradeNo,
LocalAmount: localAmount,
ChannelAmount: channelAmount,
DiffAmount: diffAmount,
DiffType: diffType,
HandleStatus: models.DiffHandleUnhandled,
Remark: remark,
}
_, err = models.Orm.Insert(row)
return err
}
// ReconcileListInput 差异列表查询条件
type ReconcileListInput struct {
Channel string
BillDate string
DiffType string
HandleStatus string
Page int
PageSize int
}
// ListReconcileDiffs 对账差异分页
func ListReconcileDiffs(in ReconcileListInput) ([]models.PlatformPaymentReconcileDiff, int64, error) {
qs := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).Filter("delete_time__isnull", true)
if in.Channel != "" {
qs = qs.Filter("channel", in.Channel)
}
if in.BillDate != "" {
if t, err := time.ParseInLocation("2006-01-02", in.BillDate, time.Local); err == nil {
qs = qs.Filter("bill_date", t)
}
}
if in.DiffType != "" {
qs = qs.Filter("diff_type", in.DiffType)
}
if in.HandleStatus != "" {
qs = qs.Filter("handle_status", in.HandleStatus)
}
total, err := qs.Count()
if err != nil {
return nil, 0, err
}
if in.Page <= 0 {
in.Page = 1
}
if in.PageSize <= 0 || in.PageSize > 200 {
in.PageSize = 10
}
var rows []models.PlatformPaymentReconcileDiff
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
return rows, total, err
}
// MarkDiffHandledInput 标记差异处理入参
type MarkDiffHandledInput struct {
ID uint64
HandleStatus string // handled / handling / ignored
Remark string
OperatorID string
OperatorName string
}
// MarkDiffHandled 标记对账差异处理状态
func MarkDiffHandled(ctx context.Context, in MarkDiffHandledInput) (*models.PlatformPaymentReconcileDiff, error) {
row := &models.PlatformPaymentReconcileDiff{}
if err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", in.ID).
Filter("delete_time__isnull", true).
One(row); err != nil {
return nil, fmt.Errorf("差异记录不存在")
}
switch in.HandleStatus {
case models.DiffHandleHandled, models.DiffHandleHandling, models.DiffHandleIgnored:
default:
return nil, fmt.Errorf("处理状态不合法")
}
now := time.Now()
_, err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", row.ID).
Update(orm.Params{
"handle_status": in.HandleStatus,
"remark": in.Remark,
"handle_user_id": in.OperatorID,
"handle_user_name": in.OperatorName,
"handle_time": now,
"update_time": now,
})
if err != nil {
return nil, err
}
return GetReconcileDiffByID(in.ID)
}
// GetReconcileDiffByID 按 ID 查询差异记录
func GetReconcileDiffByID(id uint64) (*models.PlatformPaymentReconcileDiff, error) {
row := &models.PlatformPaymentReconcileDiff{}
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
Filter("id", id).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
+613
View File
@@ -0,0 +1,613 @@
package payment
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
)
/* =============================================================
* 支付服务核心:创建支付单 / 查询 / 状态机 / 回调幂等 / 兜底退回 / 补偿任务
*
* 与业务模块的解耦点:
* 1. 业务订单的「已支付」同步通过 RegisterOrderSyncHook 注册钩子完成;
* 未注册钩子时视为无需同步(order_synced 直接置 1)。
* 2. 佣金推广方:下单入参可携带(快照到支付单),未携带时由
* RegisterPromoterResolver 注册的钩子推导(如按租户归属的渠道伙伴)。
* ============================================================= */
// OrderSyncFunc 业务订单状态同步钩子
type OrderSyncFunc func(ctx context.Context, order *models.PlatformPaymentOrder) error
// PromoterResolveFunc 推广方解析钩子
type PromoterResolveFunc func(ctx context.Context, order *models.PlatformPaymentOrder) (promoterID, promoterName, promoterType string, ok bool)
var (
orderSyncHook OrderSyncFunc
promoterResolve PromoterResolveFunc
)
// RegisterOrderSyncHook 注册业务订单同步钩子(在业务模块 init 时调用)
func RegisterOrderSyncHook(fn OrderSyncFunc) { orderSyncHook = fn }
// RegisterPromoterResolver 注册推广方解析钩子
func RegisterPromoterResolver(fn PromoterResolveFunc) { promoterResolve = fn }
// CreateInput 创建支付单入参(租户端下单页 -> POST /backend/payment/create)
type CreateInput struct {
OutTradeNo string // 业务订单号(必填)
OrderType string // platform_usage / module_shop / service_fee
TenantID string
TenantName string
Amount int64 // 分
Channel string // 渠道标识(必填)
Subject string
ReturnURL string // 支付完成同步跳回地址
ClientIP string
PayType string // qr / web / h5 / jsapi;空值由适配器给默认
OpenID string // JSAPI 必填
PromoterID string
PromoterName string
PromoterType string
ExpireMinutes int // 支付有效期(分钟),默认 30
}
const defaultExpireMinutes = 30
// GetOrderByPayNo 按支付单号查询
func GetOrderByPayNo(payNo string) (*models.PlatformPaymentOrder, error) {
row := &models.PlatformPaymentOrder{}
err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
One(row)
if err != nil {
return nil, err
}
return row, nil
}
// LogStateChange 记录状态流转流水
func LogStateChange(payNo, fromStatus, toStatus, operator, remark string) {
row := &models.PlatformPaymentOrderLog{
PayNo: payNo,
FromStatus: fromStatus,
ToStatus: toStatus,
Operator: operator,
Remark: remark,
}
if _, err := models.Orm.Insert(row); err != nil {
beelog.Warn("支付单流转日志写入失败: %s %v", payNo, err)
}
}
// CreatePayment 创建支付单并调用渠道下单
// 幂等:同一 out_trade_no + channel 存在未终态支付单时,直接复用并重新拉起支付参数。
func CreatePayment(ctx context.Context, in CreateInput) (*models.PlatformPaymentOrder, *PayParams, error) {
if strings.TrimSpace(in.OutTradeNo) == "" {
return nil, nil, errors.New("业务订单号不能为空")
}
if in.Amount <= 0 {
return nil, nil, errors.New("支付金额必须大于 0")
}
adapter, err := GetChannelAdapter(in.Channel)
if err != nil {
return nil, nil, err
}
cfg, err := LoadEnabledChannelConfig(in.Channel)
if err != nil {
return nil, nil, err
}
// 幂等:复用未终态支付单
var exist models.PlatformPaymentOrder
err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("out_trade_no", in.OutTradeNo).
Filter("channel", in.Channel).
Filter("delete_time__isnull", true).
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
One(&exist)
if err == nil {
params, perr := adapter.Prepay(ctx, &exist, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID})
if perr != nil {
return nil, nil, perr
}
return &exist, params, nil
} else if err != orm.ErrNoRows {
return nil, nil, fmt.Errorf("查询支付单失败: %w", err)
}
payNo := NextPayNo()
now := time.Now()
minutes := in.ExpireMinutes
if minutes <= 0 {
minutes = defaultExpireMinutes
}
expireAt := now.Add(time.Duration(minutes) * time.Minute)
order := &models.PlatformPaymentOrder{
PayNo: payNo,
OutTradeNo: in.OutTradeNo,
OrderType: in.OrderType,
Subject: in.Subject,
TenantID: in.TenantID,
TenantName: in.TenantName,
Amount: in.Amount,
OrderAmount: in.Amount,
Currency: "CNY",
Channel: in.Channel,
MerchantNo: cfg.MerchantNo,
Status: models.PayStatusCreated,
ClientIP: in.ClientIP,
ReturnURL: in.ReturnURL,
NotifyURL: cfg.CallbackURL,
ExpireAt: &expireAt,
PromoterID: in.PromoterID,
PromoterName: in.PromoterName,
PromoterType: in.PromoterType,
}
// 下单未携带推广方时,交给业务模块推导(如租户的签约渠道伙伴)
if order.PromoterID == "" && promoterResolve != nil {
if pid, pname, ptype, ok := promoterResolve(ctx, order); ok {
order.PromoterID, order.PromoterName, order.PromoterType = pid, pname, ptype
}
}
if _, err := models.Orm.Insert(order); err != nil {
return nil, nil, fmt.Errorf("创建支付单失败: %w", err)
}
LogStateChange(order.PayNo, "", models.PayStatusCreated, "租户端下单页", "创建支付单,渠道:"+cfg.Name)
params, err := adapter.Prepay(ctx, order, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID})
if err != nil {
// 渠道下单失败:保留支付单并置为 failed,便于排查与重下
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"status": models.PayStatusFailed, "update_time": now})
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusFailed, "支付服务", "渠道下单失败:"+err.Error())
return nil, nil, err
}
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusCreated, "支付服务", "渠道下单成功,返回支付参数")
if params.ChannelTradeNo != "" {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"channel_trade_no": params.ChannelTradeNo, "update_time": time.Now()})
order.ChannelTradeNo = params.ChannelTradeNo
}
return order, params, nil
}
// QueryPayment 查询支付单;syncChannel=true 时主动向渠道查询并同步状态(兜底回调丢失)
func QueryPayment(ctx context.Context, payNo string, syncChannel bool) (*models.PlatformPaymentOrder, error) {
order, err := GetOrderByPayNo(payNo)
if err != nil {
return nil, fmt.Errorf("支付单不存在")
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"last_query_at": now, "update_time": now})
order.LastQueryAt = &now
if !syncChannel {
return order, nil
}
adapter, aerr := GetChannelAdapter(order.Channel)
cfg, cerr := LoadChannelConfig(order.Channel)
if aerr != nil || cerr != nil {
return order, nil
}
st, qerr := adapter.Query(ctx, order, cfg)
if qerr != nil {
LogStateChange(order.PayNo, order.Status, order.Status, "手动查询", "查询渠道状态失败:"+qerr.Error())
return order, nil
}
if _, serr := applyChannelState(ctx, order, st, "手动查询", "主动查询渠道状态同步"); serr != nil {
beelog.Warn("支付单 %s 渠道状态同步失败: %v", order.PayNo, serr)
}
return GetOrderByPayNo(payNo)
}
// HandleNotify 渠道异步通知统一入口:验签 -> 幂等 -> 状态机 -> 佣金,返回需回给渠道的响应体
func HandleNotify(ctx context.Context, channel string, r *http.Request, ip string) (string, error) {
adapter, err := GetChannelAdapter(channel)
if err != nil {
return "", err
}
cfg, err := LoadChannelConfig(channel) // 停用渠道的历史回调仍需处理
if err != nil {
return "", err
}
result, err := adapter.ParseNotify(ctx, r, cfg)
if err != nil {
insertCallbackLog(channel, "", "", "", 0, 0, false, false, err.Error(), "", ip)
return "", err
}
// 幂等:同一事件(channel + event_id)已成功处理过则直接命中
duplicate := existsCallbackHandled(channel, result.EventID, result.ChannelTradeNo, result.EventType)
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, duplicate, false, "", result.Raw, ip)
if duplicate {
return result.AckBody, nil
}
order, oerr := GetOrderByPayNo(result.PayNo)
if oerr != nil {
// 再尝试按业务订单号找最近一笔(回调里可能只回传 out_trade_no)
order, oerr = findLatestOrderByOutTradeNo(result.OutTradeNo)
}
if oerr != nil {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "支付单不存在", result.Raw, ip)
return "", fmt.Errorf("支付单不存在: %s", result.PayNo)
}
// 非成功/失败/关闭事件只记录日志
if result.TradeState != StateSuccess && result.TradeState != StateFailed && result.TradeState != StateClosed {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "事件无需变更状态: "+result.EventType, result.Raw, ip)
return result.AckBody, nil
}
changed, serr := applyChannelState(ctx, order, &ChannelState{
ChannelTradeNo: result.ChannelTradeNo,
TradeState: result.TradeState,
Amount: result.Amount,
}, "渠道回调", "渠道通知同步,事件:"+result.EventType)
if serr != nil {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "状态更新失败: "+serr.Error(), result.Raw, ip)
return "", serr
}
if !changed {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态未变化(幂等)", result.Raw, ip)
} else {
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态已同步为 "+result.TradeState, result.Raw, ip)
}
return result.AckBody, nil
}
// existsCallbackHandled 是否已存在处理成功的同事件回调
func existsCallbackHandled(channel, eventID, channelTradeNo, eventType string) bool {
qs := models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)).
Filter("channel", channel).
Filter("handle_result", 1).
Filter("delete_time__isnull", true)
if eventID != "" {
qs = qs.Filter("event_id", eventID)
} else {
qs = qs.Filter("channel_trade_no", channelTradeNo).Filter("event_type", eventType)
}
cnt, _ := qs.Count()
return cnt > 0
}
func insertCallbackLog(channel, payNo, outTradeNo, channelTradeNo string, amount int64, verifyResult int8, duplicate, handled bool, msg, raw, ip string) {
row := &models.PlatformPaymentCallbackLog{
Channel: channel,
PayNo: payNo,
OutTradeNo: outTradeNo,
ChannelTradeNo: channelTradeNo,
Amount: amount,
VerifyResult: verifyResult,
HandleMsg: msg,
ClientIP: ip,
}
if duplicate {
row.IsDuplicate = 1
}
if handled {
row.HandleResult = 1
}
if raw != "" {
raw = truncateStr(raw, 60000)
row.RawBody = &raw
}
if _, err := models.Orm.Insert(row); err != nil {
beelog.Warn("回调日志写入失败: %v", err)
}
}
func findLatestOrderByOutTradeNo(outTradeNo string) (*models.PlatformPaymentOrder, error) {
row := &models.PlatformPaymentOrder{}
err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("out_trade_no", outTradeNo).
Filter("delete_time__isnull", true).
OrderBy("-id").
One(row)
return row, err
}
// applyChannelState 把渠道状态落到本地(回调与主动查询共用),返回是否发生状态变更。
// 支付成功采用条件更新(仅 created/pending/paying 可置为 paid)保证并发幂等。
func applyChannelState(ctx context.Context, order *models.PlatformPaymentOrder, st *ChannelState, operator, remark string) (bool, error) {
if st == nil {
return false, nil
}
now := time.Now()
active := []interface{}{models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying}
switch st.TradeState {
case StateSuccess:
if order.Status == models.PayStatusPaid {
if order.ChannelTradeNo == "" && st.ChannelTradeNo != "" {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"channel_trade_no": st.ChannelTradeNo, "update_time": now})
}
return false, nil
}
note := remark
if st.Amount > 0 && st.Amount != order.Amount {
note = fmt.Sprintf("%s(注意:渠道金额 %d 分与本地 %d 分不一致,请核对)", remark, st.Amount, order.Amount)
}
updates := orm.Params{
"status": models.PayStatusPaid,
"paid_at": now,
"notify_at": now,
"order_synced": 0,
"update_time": now,
}
if st.ChannelTradeNo != "" {
updates["channel_trade_no"] = st.ChannelTradeNo
}
n, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Filter("status__in", active...).
Update(updates)
if err != nil {
return false, err
}
if n == 0 {
return false, nil // 并发下已被其他通知处理
}
order.Status = models.PayStatusPaid
order.PaidAt = &now
if st.ChannelTradeNo != "" {
order.ChannelTradeNo = st.ChannelTradeNo
}
LogStateChange(order.PayNo, models.PayStatusPaying, models.PayStatusPaid, operator, note)
// 1. 同步业务订单状态(钩子;失败保留 order_synced=0 由补偿任务重试)
if orderSyncHook == nil {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
} else if serr := orderSyncHook(ctx, order); serr != nil {
LogStateChange(order.PayNo, models.PayStatusPaid, models.PayStatusPaid, operator, "业务订单同步失败:"+serr.Error())
} else {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
}
// 2. 生成推广佣金(平台自有资金支出,失败不影响收款状态)
if _, cerr := OnOrderPaid(ctx, order); cerr != nil {
beelog.Warn("支付单 %s 佣金生成失败: %v", order.PayNo, cerr)
}
return true, nil
case StateClosed:
if order.Status == models.PayStatusPaid {
return false, nil
}
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Filter("status__in", active...).
Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now})
if err != nil {
return false, err
}
order.Status = models.PayStatusClosed
LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusClosed, operator, remark)
return true, nil
case StateFailed:
if order.Status == models.PayStatusPaid {
return false, nil
}
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Filter("status__in", active...).
Update(orm.Params{"status": models.PayStatusFailed, "update_time": now})
if err != nil {
return false, err
}
order.Status = models.PayStatusFailed
LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusFailed, operator, remark)
return true, nil
case StatePending:
if order.Status == models.PayStatusCreated {
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"status": models.PayStatusPending, "update_time": now})
if err != nil {
return false, err
}
order.Status = models.PayStatusPending
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusPending, operator, remark)
return true, nil
}
return false, nil
default:
return false, nil
}
}
// RefundInput 手动原路退回入参(兜底能力)
type RefundInput struct {
PayNo string
Amount int64 // 分;<=0 表示全额退回
Reason string // 必填
OperatorID string
OperatorName string
}
// ManualRefund 手动原路退回(不做业务层退款流程)
func ManualRefund(ctx context.Context, in RefundInput) (*models.PlatformPaymentRefund, error) {
if strings.TrimSpace(in.Reason) == "" {
return nil, errors.New("退回原因不能为空")
}
order, err := GetOrderByPayNo(in.PayNo)
if err != nil {
return nil, fmt.Errorf("支付单不存在")
}
if order.Status != models.PayStatusPaid {
return nil, fmt.Errorf("仅支付成功的支付单支持原路退回(当前状态 %s)", order.Status)
}
remain := order.Amount - order.RefundAmount
amount := in.Amount
if amount <= 0 {
amount = remain
}
if amount > remain {
return nil, fmt.Errorf("退回金额 %d 分超过可退余额 %d 分", amount, remain)
}
refund := &models.PlatformPaymentRefund{
RefundNo: NextRefundNo(),
PayNo: order.PayNo,
OutTradeNo: order.OutTradeNo,
Channel: order.Channel,
Amount: amount,
Reason: in.Reason,
Status: models.RefundStatusProcessing,
OperatorID: in.OperatorID,
OperatorName: in.OperatorName,
}
if _, err := models.Orm.Insert(refund); err != nil {
return nil, fmt.Errorf("创建退回单失败: %w", err)
}
adapter, aerr := GetChannelAdapter(order.Channel)
cfg, cerr := LoadChannelConfig(order.Channel)
if aerr != nil || cerr != nil {
failRefund(refund, "渠道适配器/配置不可用")
return refund, fmt.Errorf("渠道适配器/配置不可用")
}
channelRefundNo, rerr := adapter.Refund(ctx, order, refund.RefundNo, amount, in.Reason, cfg)
now := time.Now()
if rerr != nil {
failRefund(refund, rerr.Error())
LogStateChange(order.PayNo, order.Status, order.Status, "平台管理员", "手动原路退回失败:"+rerr.Error())
return refund, rerr
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)).
Filter("id", refund.ID).
Update(orm.Params{"status": models.RefundStatusSuccess, "channel_refund_no": channelRefundNo, "finished_at": now, "update_time": now})
refund.Status = models.RefundStatusSuccess
refund.ChannelRefundNo = channelRefundNo
refund.FinishedAt = &now
newRefunded := order.RefundAmount + amount
toStatus := models.PayStatusRefunding
if newRefunded >= order.Amount {
toStatus = models.PayStatusRefunded
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", order.ID).
Update(orm.Params{"refund_amount": newRefunded, "status": toStatus, "update_time": now})
order.RefundAmount = newRefunded
order.Status = toStatus
LogStateChange(order.PayNo, models.PayStatusPaid, toStatus, "平台管理员", fmt.Sprintf("手动原路退回 ¥%s:%s", FenToYuan(amount), in.Reason))
return refund, nil
}
func failRefund(refund *models.PlatformPaymentRefund, msg string) {
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)).
Filter("id", refund.ID).
Update(orm.Params{"status": models.RefundStatusFailed, "fail_reason": truncateStr(msg, 480), "update_time": time.Now()})
refund.Status = models.RefundStatusFailed
refund.FailReason = msg
}
// CloseExpiredOrders 关闭超时未支付订单(补偿任务),返回关闭数量
func CloseExpiredOrders(ctx context.Context) (int, error) {
var orders []models.PlatformPaymentOrder
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("delete_time__isnull", true).
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
Filter("expire_at__lt", time.Now()).
All(&orders)
if err != nil {
return 0, err
}
closed := 0
now := time.Now()
for i := range orders {
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", orders[i].ID).
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now})
if err != nil {
continue
}
LogStateChange(orders[i].PayNo, orders[i].Status, models.PayStatusClosed, "定时补偿", "支付超时自动关闭")
closed++
}
return closed, nil
}
// SyncPendingOrders 重试同步「已支付但业务订单未更新」的支付单(补偿任务),返回处理数量
func SyncPendingOrders(ctx context.Context) (int, error) {
if orderSyncHook == nil {
return 0, nil
}
var orders []models.PlatformPaymentOrder
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("delete_time__isnull", true).
Filter("status", models.PayStatusPaid).
Filter("order_synced", 0).
All(&orders)
if err != nil {
return 0, err
}
done := 0
for i := range orders {
if serr := orderSyncHook(ctx, &orders[i]); serr != nil {
beelog.Warn("支付单 %s 业务订单同步重试失败: %v", orders[i].PayNo, serr)
continue
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
Filter("id", orders[i].ID).
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
LogStateChange(orders[i].PayNo, models.PayStatusPaid, models.PayStatusPaid, "定时补偿", "业务订单同步成功(重试)")
done++
}
return done, nil
}
// StartPaymentScheduler 支付补偿任务:关闭超时订单 + 重试业务订单同步(5 分钟一轮)
func StartPaymentScheduler(stop <-chan struct{}) {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
ctx := context.Background()
if n, err := CloseExpiredOrders(ctx); err != nil {
beelog.Warn("支付超时关单失败: %v", err)
} else if n > 0 {
beelog.Info("支付超时关单 %d 笔", n)
}
if n, err := SyncPendingOrders(ctx); err != nil {
beelog.Warn("业务订单同步重试失败: %v", err)
} else if n > 0 {
beelog.Info("业务订单同步重试完成 %d 笔", n)
}
}
}
}()
}
+331
View File
@@ -0,0 +1,331 @@
import request from "@/utils/request";
/*************************************************
*************** 平台端支付模块接口 ***************
* 业务:平台向租户收取平台使用费,资金直接进平台对公账户;
* 不涉及退款流程(仅保留后台手动原路退回兜底);
* 推广佣金为平台自有资金支出,与收款解耦;不跨境。
*************************************************/
/**
* 支付模块接口前缀。
* 需求约定里接口写作 /api/payment/*、/api/commission/*,
* 但本项目平台端接口统一走 /platform 前缀(vite.config.js 的 dev 代理也只转发 /platform、/backend)。
* 若后端最终把支付路由挂在 /api 下,只需要改下面这两个常量。
*/
const PAY = "/platform/payment";
const COMMISSION = "/platform/commission";
/* ================= 支付单 ================= */
/**
* 创建支付单
* TODO: 后端实现 POST /api/payment/create
* 约定入参:{ out_trade_no, tenant_id, order_type, amount(分), channel, subject, return_url, client_ip }
* 约定返回:{ pay_no, channel, pay_params } pay_params 交给前端拉起渠道支付
* @param {Object} data 下单参数
* @returns {Promise}
*/
export function createPayment(data) {
return request({
url: `${PAY}/create`,
method: "post",
data,
});
}
/**
* 查询支付单(本地库)
* TODO: 后端实现 GET /api/payment/query/:payNo
* @param {string} payNo 支付单号
* @param {Object} [params] { sync: true } 时同步渠道最新状态后再返回
* @returns {Promise}
*/
export function queryPayment(payNo, params = {}) {
return request({
url: `${PAY}/query/${payNo}`,
method: "get",
params,
});
}
/**
* 手动原路退回(兜底,不做业务层退款流程)
* TODO: 后端实现 POST /api/payment/refund
* @param {Object} data { pay_no, amount(分), reason }
* @returns {Promise}
*/
export function refundPayment(data) {
return request({
url: `${PAY}/refund`,
method: "post",
data,
});
}
/**
* 支付单列表
* TODO: 后端实现 GET /api/payment/orders
* @param {Object} params { page, pageSize, tenant_id, channel, status, start_time, end_time }
* @returns {Promise}
*/
export function getOrders(params) {
return request({
url: `${PAY}/orders`,
method: "get",
params,
});
}
/**
* 支付单详情(含状态流转记录)
* TODO: 后端实现 GET /api/payment/orders/:payNo
* @param {string} payNo 支付单号
* @returns {Promise}
*/
export function getOrderDetail(payNo) {
return request({
url: `${PAY}/orders/${payNo}`,
method: "get",
});
}
/* ================= 渠道配置 ================= */
/**
* 渠道配置列表(首页概览用)
* TODO: 后端实现 GET /api/payment/channels
* @returns {Promise}
*/
export function getChannelList() {
return request({
url: `${PAY}/channels`,
method: "get",
});
}
/**
* 获取单个渠道配置
* TODO: 后端实现 GET /api/payment/channels/:channel
* 注意:敏感字段(密钥/私钥/Secret)后端只返回掩码,前端提交时掩码值应原样回传表示“不修改”
* @param {string} channel 渠道标识 wechat / alipay / unionpay / cloudpay / paypal
* @returns {Promise}
*/
export function getChannelConfig(channel) {
return request({
url: `${PAY}/channels/${channel}`,
method: "get",
});
}
/**
* 保存渠道配置
* TODO: 后端实现 POST /api/payment/channels/:channel
* @param {string} channel 渠道标识
* @param {Object} data 渠道参数
* @returns {Promise}
*/
export function saveChannelConfig(channel, data) {
return request({
url: `${PAY}/channels/${channel}`,
method: "post",
data,
});
}
/**
* 测试渠道连通性(校验参数 + 渠道鉴权)
* TODO: 后端实现 POST /api/payment/channels/:channel/test
* @param {string} channel 渠道标识
* @param {Object} [data] 未保存的参数可一并提交
* @returns {Promise}
*/
export function testChannelConnection(channel, data) {
return request({
url: `${PAY}/channels/${channel}/test`,
method: "post",
data,
});
}
/**
* 启用 / 停用渠道
* TODO: 后端实现 POST /api/payment/channels/:channel/status
* @param {string} channel 渠道标识
* @param {boolean} enabled 是否启用
* @returns {Promise}
*/
export function toggleChannel(channel, enabled) {
return request({
url: `${PAY}/channels/${channel}/status`,
method: "post",
data: { enabled },
});
}
/**
* 上传渠道证书(微信 apiclient_cert.pem / apiclient_key.pem、银联证书等)
* TODO: 后端实现 POST /api/payment/channels/:channel/certificate
* @param {string} channel 渠道标识
* @param {File} file 证书文件
* @param {string} [certType] 证书类型 cert / key
* @returns {Promise}
*/
export function uploadChannelCert(channel, file, certType = "cert") {
const formData = new FormData();
formData.append("file", file);
formData.append("cert_type", certType);
return request({
url: `${PAY}/channels/${channel}/certificate`,
method: "post",
data: formData,
timeout: 0,
});
}
/* ================= 推广佣金 ================= */
/**
* 佣金台账列表
* TODO: 后端实现 GET /api/commission/list
* @param {Object} params { page, pageSize, promoter, status, start_time, end_time }
* @returns {Promise}
*/
export function getCommissionList(params) {
return request({
url: `${COMMISSION}/list`,
method: "get",
params,
});
}
/**
* 佣金统计(应付总额 / 已付总额 / 待处理笔数)
* TODO: 后端实现 GET /api/commission/stats
* @param {Object} [params] 统计维度(时间范围等)
* @returns {Promise}
*/
export function getCommissionStats(params) {
return request({
url: `${COMMISSION}/stats`,
method: "get",
params,
});
}
/**
* 标记佣金已发放
* TODO: 后端实现 POST /api/commission/:id/pay
* @param {number|string} id 佣金单 id
* @param {Object} [data] { pay_method, remark }
* @returns {Promise}
*/
export function markCommissionPaid(id, data = {}) {
return request({
url: `${COMMISSION}/markPaid/${id}`,
method: "post",
data,
});
}
/**
* 佣金规则列表
* TODO: 后端实现 GET /api/commission/rules
* @param {Object} [params] { page, pageSize, keyword }
* @returns {Promise}
*/
export function getCommissionRules(params) {
return request({
url: `${COMMISSION}/rules`,
method: "get",
params,
});
}
/**
* 新增佣金规则
* TODO: 后端实现 POST /api/commission/rules
* @param {Object} data 规则数据
* @returns {Promise}
*/
export function saveCommissionRule(data) {
return request({
url: `${COMMISSION}/rules`,
method: "post",
data,
});
}
/**
* 更新佣金规则
* TODO: 后端实现 POST /api/commission/rules/:id
* @param {number|string} id 规则 id
* @param {Object} data 规则数据
* @returns {Promise}
*/
export function updateCommissionRule(id, data) {
return request({
url: `${COMMISSION}/rules/${id}`,
method: "post",
data,
});
}
/**
* 删除佣金规则
* TODO: 后端实现 DELETE /api/commission/rules/:id
* @param {number|string} id 规则 id
* @returns {Promise}
*/
export function deleteCommissionRule(id) {
return request({
url: `${COMMISSION}/rules/${id}`,
method: "delete",
});
}
/**
* 启用 / 停用佣金规则
* TODO: 后端实现 POST /api/commission/rules/:id/status
* @param {number|string} id 规则 id
* @param {number} status 1 启用 / 0 停用
* @returns {Promise}
*/
export function toggleCommissionRule(id, status) {
return request({
url: `${COMMISSION}/rules/${id}/status`,
method: "post",
data: { status },
});
}
/* ================= 对账 ================= */
/**
* 对账差异列表
* TODO: 后端实现 GET /api/payment/reconcile
* @param {Object} params { page, pageSize, channel, reconcile_date, diff_type, handle_status }
* @returns {Promise}
*/
export function getReconcileList(params) {
return request({
url: `${PAY}/reconcile`,
method: "get",
params,
});
}
/**
* 标记对账差异已处理
* TODO: 后端实现 POST /api/payment/reconcile/:id/handle
* @param {number|string} id 差异记录 id
* @param {Object} [data] { handle_type, remark }
* @returns {Promise}
*/
export function markReconcileHandled(id, data = {}) {
return request({
url: `${PAY}/reconcile/handle/${id}`,
method: "post",
data,
});
}
@@ -0,0 +1,341 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>支付宝配置</h2>
<p class="sub-title">平台使用费收款渠道,支持电脑网站支付与手机网站支付(RSA2 签名)。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>登录支付宝开放平台
<a href="https://open.alipay.com" target="_blank" rel="noopener">open.alipay.com</a>
→ 「控制台 → 网页/移动应用」取<b>APPID</b>;「开发设置 → 接口加签方式」选择<b>公钥模式</b>,
使用工具生成应用私钥后上传应用公钥,页面会回显<b>支付宝公钥</b>;
再到「产品绑定」开通<b>电脑网站支付</b>与<b>手机网站支付</b>。
</li>
<li>
<b>回调怎么配:</b>支付宝异步通知地址(notify_url)随下单请求携带,<b>无需在开放平台登记</b>;
只需保证本页<b>回调地址</b>公网可访问、处理成功返回字符串 <code>success</code>(全小写)。
若需登录态同步跳回,由下单时的 return_url 决定。
</li>
<li>
<b>怎么验证:</b>保存后点「测试连接」,系统会调用 <code>alipay.trade.query</code> 校验 APPID 与签名配置;
通过后启用渠道,建议先用 1 分钱真实下单验证异步通知能正常更新订单状态。
</li>
</ol>
<el-alert
type="info"
:closable="false"
show-icon
title="应用私钥用于本地签名,必须保密;支付宝公钥用于验签,属公开信息,可明文核对。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="APPID" prop="appid">
<el-input v-model="form.appid" placeholder="开放平台应用 APPID,如 2021003123456789" clearable />
</el-form-item>
<el-form-item label="应用私钥" prop="app_private_key">
<div class="secret-row">
<el-input
v-if="editing.app_private_key"
v-model="form.app_private_key"
type="password"
show-password
:rows="4"
placeholder="RSA2 应用私钥(PKCS8,单行或 -----BEGIN 形式)"
/>
<el-input v-else :model-value="maskSecret(form.app_private_key)" disabled placeholder="未配置" />
<el-button link type="primary" @click="toggleSecret('app_private_key')">
{{ editing.app_private_key ? '取消' : '修改' }}
</el-button>
</div>
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原私钥不变。</div>
</el-form-item>
<el-form-item label="支付宝公钥" prop="alipay_public_key">
<el-input
v-model="form.alipay_public_key"
type="textarea"
:rows="4"
placeholder="支付宝公钥,用于校验渠道回调签名"
/>
<div class="field-tip">该值为开放平台回显的公钥,非密钥,可明文保存与核对。</div>
</el-form-item>
<el-form-item label="签名类型">
<el-radio-group v-model="form.sign_type">
<el-radio value="RSA2">RSA2(推荐)</el-radio>
<el-radio value="RSA">RSA</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/alipay,需公网 HTTPS 且返回 success。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后收银台不再展示支付宝。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
const CHANNEL = 'alipay'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
appid: '',
app_private_key: '',
alipay_public_key: '',
sign_type: 'RSA2',
enabled: true
})
const rules: FormRules = {
appid: [{ required: true, message: '请输入 APPID', trigger: 'blur' }],
app_private_key: [{ required: true, message: '请输入应用私钥', trigger: 'blur' }],
alipay_public_key: [{ required: true, message: '请输入支付宝公钥', trigger: 'blur' }]
}
/* ---------------- 敏感字段掩码编辑 ---------------- */
const editing = reactive<Record<string, boolean>>({ app_private_key: false })
const secretBackup = reactive<Record<string, string>>({ app_private_key: '' })
/** 掩码规则:仅保留末 4 位 */
function maskSecret(value: string) {
const s = String(value || '')
if (!s) return ''
if (s.length <= 4) return '******'
return `******${s.slice(-4)}`
}
function toggleSecret(key: 'app_private_key') {
if (editing[key]) {
form[key] = secretBackup[key]
editing[key] = false
return
}
secretBackup[key] = form[key]
form[key] = ''
editing[key] = true
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
appid: '2021003123456789',
app_private_key: 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9f2c7b1a4d6e8f0a',
alipay_public_key:
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt9f2c7b1a4d6e8f0a3c5b7d9e1f4a6c8',
sign_type: 'RSA2',
enabled: true
})
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(app_private_key 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('支付宝配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:APPID 与 RSA2 签名校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查 APPID、应用私钥与支付宝公钥')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}支付宝吗?${enable ? '启用后租户收银台将展示支付宝。' : '停用后租户将无法使用支付宝。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`支付宝已${enable ? '启用' : '停用'}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.secret-row,
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,296 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>云闪付配置</h2>
<p class="sub-title">云闪付走银联通道收款,复用银联商户参数,本页只控制收银台展示与开关。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>云闪付<b>不单独申请商户号</b>,直接复用「银联配置」中的商户号(mer_id)与收单机构代码;
收银台展示的云闪付标识由银联/收单机构下发的物料决定。
</li>
<li>
<b>回调怎么配:</b>云闪付交易通知由<b>银联通道统一下发</b>,默认复用银联回调地址;
若收单机构要求独立地址,需在入网时报备本页展示的地址(后台通知返回 ok)。
</li>
<li>
<b>怎么验证:</b>确认「银联配置」已保存并通过测试连接后,开启下方「在收银台展示云闪付标识」并启用渠道,
用银联卡在云闪付 App 内扫码/被扫各验证一笔。
</li>
</ol>
<el-alert
type="info"
:closable="false"
show-icon
:title="`银联参数继承状态:${unionpayReady ? '已配置' : '未配置,请先完成银联配置'}`"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="180px" class="config-form">
<el-form-item label="继承商户号">
<el-input :model-value="form.mer_id" readonly placeholder="读取自银联配置" />
<div class="field-tip">只读字段,实际取「银联配置」中的商户号,如需修改请前往银联配置页。</div>
</el-form-item>
<el-form-item label="继承收单机构">
<el-input :model-value="form.acq_ins_code" readonly placeholder="读取自银联配置" />
</el-form-item>
<el-form-item label="在收银台展示云闪付标识">
<el-switch v-model="form.show_logo" active-text="展示" inactive-text="隐藏" />
<span class="field-tip inline">关闭后租户收银台不展示云闪付入口,但已发起的订单仍可正常回调。</span>
</el-form-item>
<el-form-item label="展示位置">
<el-checkbox-group v-model="form.logo_positions">
<el-checkbox value="pc">PC 收银台</el-checkbox>
<el-checkbox value="h5">手机 H5 收银台</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/cloudpay,默认与银联共用通道通知。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">需先启用「银联」渠道,云闪付才会在收银台生效。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
const CHANNEL = 'cloudpay'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
/** 银联参数是否已配置(继承判断) */
const unionpayReady = ref(false)
const form = reactive({
mer_id: '',
acq_ins_code: '',
show_logo: true,
logo_positions: ['pc', 'h5'],
enabled: false
})
const rules: FormRules = {
logo_positions: [{ required: true, message: '请至少选择一个展示位置', trigger: 'change' }]
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求(商户号/收单机构建议由后端从银联配置继承返回)
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
// const unionpay = await getChannelConfig('unionpay')
// unionpayReady.value = !!unionpay?.data?.mer_id
Object.assign(form, {
mer_id: '898110158000000',
acq_ins_code: '0402 - 中国建设银行',
show_logo: true,
logo_positions: ['pc', 'h5'],
enabled: false
})
unionpayReady.value = false
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('云闪付配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求(后端会校验银联参数是否可用)
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:云闪付通道可用(继承银联参数)')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请先完成银联配置')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
if (enable && !unionpayReady.value) {
ElMessage.warning('请先完成并启用「银联配置」,云闪付复用银联商户参数')
return
}
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}云闪付吗?`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`云闪付已${enable ? '启用' : '停用'}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 180px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,334 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>PayPal 配置</h2>
<p class="sub-title">面向港澳台及海外租户的收款渠道,当前业务不跨境,默认保持停用。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>登录 PayPal 开发者后台
<a href="https://developer.paypal.com" target="_blank" rel="noopener">developer.paypal.com</a>
→ 「Apps &amp; Credentials」创建 REST App,分别取<b>沙箱</b>与<b>正式</b>环境的 Client ID / Client Secret;
<b>Webhook ID</b> 在 App 详情的 Webhooks 列表中获取。
</li>
<li>
<b>回调怎么配:</b>在 App 详情 → Webhooks 中新增本页<b>回调地址</b>,勾选事件
<code>PAYMENT.CAPTURE.COMPLETED</code>、<code>PAYMENT.CAPTURE.DENIED</code>、<code>PAYMENT.CAPTURE.REFUNDED</code>,
保存后回到列表复制 Webhook ID 填入本页;后端按 PayPal 签名头校验通知来源。
</li>
<li>
<b>怎么验证:</b>保存后点「测试连接」,系统会调用 OAuth2 Token 接口校验 Client ID / Secret;
通过后用沙箱买家账号下单,确认 Webhook 能更新订单状态,再切换正式凭证并启用渠道。
</li>
</ol>
<el-alert
type="warning"
:closable="false"
show-icon
title="Client Secret 只在创建时完整展示一次,请在此处保存;沙箱与正式凭证不可混用。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="Client ID" prop="client_id">
<el-input v-model="form.client_id" placeholder="PayPal REST App 的 Client ID" clearable />
</el-form-item>
<el-form-item label="Client Secret" prop="client_secret">
<div class="secret-row">
<el-input
v-if="editing.client_secret"
v-model="form.client_secret"
type="password"
show-password
placeholder="PayPal REST App 的 Client Secret"
clearable
/>
<el-input v-else :model-value="maskSecret(form.client_secret)" disabled placeholder="未配置" />
<el-button link type="primary" @click="toggleSecret('client_secret')">
{{ editing.client_secret ? '取消' : '修改' }}
</el-button>
</div>
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原 Secret 不变。</div>
</el-form-item>
<el-form-item label="Webhook ID" prop="webhook_id">
<el-input v-model="form.webhook_id" placeholder="PayPal Webhook 列表中的 ID" clearable />
</el-form-item>
<el-form-item label="运行环境">
<el-radio-group v-model="form.env">
<el-radio value="sandbox">沙箱</el-radio>
<el-radio value="live">正式</el-radio>
</el-radio-group>
<div class="field-tip">切到正式环境前,请确认 Client ID / Secret / Webhook ID 均为正式凭证。</div>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/paypal,需公网 HTTPS 且可被 PayPal 访问。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">仅当有海外租户时才建议启用。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
const CHANNEL = 'paypal'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
client_id: '',
client_secret: '',
webhook_id: '',
env: 'sandbox',
enabled: false
})
const rules: FormRules = {
client_id: [{ required: true, message: '请输入 Client ID', trigger: 'blur' }],
client_secret: [{ required: true, message: '请输入 Client Secret', trigger: 'blur' }],
webhook_id: [{ required: true, message: '请输入 Webhook ID', trigger: 'blur' }]
}
/* ---------------- 敏感字段掩码编辑 ---------------- */
const editing = reactive<Record<string, boolean>>({ client_secret: false })
const secretBackup = reactive<Record<string, string>>({ client_secret: '' })
/** 掩码规则:仅保留末 4 位 */
function maskSecret(value: string) {
const s = String(value || '')
if (!s) return ''
if (s.length <= 4) return '******'
return `******${s.slice(-4)}`
}
function toggleSecret(key: 'client_secret') {
if (editing[key]) {
form[key] = secretBackup[key]
editing[key] = false
return
}
secretBackup[key] = form[key]
form[key] = ''
editing[key] = true
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
client_id: '',
client_secret: '',
webhook_id: '',
env: 'sandbox',
enabled: false
})
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(client_secret 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('PayPal 配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:OAuth2 凭证校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查 Client ID / Secret 与运行环境')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}PayPal 吗?${enable ? '请确认已使用正式环境凭证。' : '停用后海外租户将无法使用 PayPal 付款。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`PayPal 已${enable ? '启用' : '停用'}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.secret-row,
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,341 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>银联配置</h2>
<p class="sub-title">银联全渠道收单(PC 网关支付 / 手机控件支付),参数由收单机构下发。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>完成银联入网(
<a href="https://open.unionpay.com" target="_blank" rel="noopener">open.unionpay.com</a>
或对应收单机构渠道)后,由<b>收单机构下发</b>商户号(mer_id)与收单机构代码(acq_ins_code,如 0402 建设银行、0104 中国银行),
并获取<b>商户证书</b>(.pfx / .cer,含证书密码,由客户经理提供)。
</li>
<li>
<b>回调怎么配:</b>银联的<b>前台/后台通知地址需在入网时报备</b>给收单机构,报备通过后把本页回调地址提供给客户经理;
未报备的地址会被银联侧直接拒绝。回调处理成功返回 <code>ok</code>。
</li>
<li>
<b>怎么验证:</b>保存后点「测试连接」,系统会调用银联交易状态查询接口校验证书与商户号配置;
通过后启用渠道,建议用 1 分钱真实下单验证前台跳转与后台通知。
</li>
</ol>
<el-alert
type="warning"
:closable="false"
show-icon
title="银联证书文件与证书密码属高危凭证,仅在平台端保存;证书有有效期,到期前需重新上传,否则会收单失败。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="商户号 mer_id" prop="mer_id">
<el-input v-model="form.mer_id" placeholder="银联下发的商户号,如 898110158000000" clearable />
</el-form-item>
<el-form-item label="收单机构" prop="acq_ins_code">
<el-select v-model="form.acq_ins_code" placeholder="请选择收单机构" style="width: 100%">
<el-option
v-for="item in acqOptions"
:key="item.code"
:label="`${item.code} - ${item.name}`"
:value="item.code"
/>
</el-select>
<div class="field-tip">收单机构代码必须与入网时下发的一致,填错会导致交易被拒。</div>
</el-form-item>
<el-form-item label="商户证书" prop="cert_file">
<div class="upload-col">
<div class="upload-row">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".pfx,.p12,.cer"
:on-change="(file) => handleCertChange(file)"
>
<el-button>选择银联商户证书</el-button>
</el-upload>
<span class="file-name">{{ certFile || '未上传' }}</span>
</div>
<div class="field-tip">支持 .pfx / .p12 / .cer,证书仅在保存时上传后端,由后端加密保存。</div>
</div>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">
后端路由约定:POST /api/payment/callback/unionpay,需在银联入网时报备该地址。
</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后收银台不再展示银联。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
const CHANNEL = 'unionpay'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
/** 常用收单机构代码(可按实际入网情况补充) */
const acqOptions = [
{ code: '0402', name: '中国建设银行' },
{ code: '0104', name: '中国银行' },
{ code: '0102', name: '中国工商银行' },
{ code: '0103', name: '中国农业银行' },
{ code: '0308', name: '招商银行' },
{ code: '0305', name: '中国民生银行' }
]
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const certFile = ref('')
const form = reactive({
mer_id: '',
acq_ins_code: '',
cert_file: '',
enabled: false
})
const rules: FormRules = {
mer_id: [{ required: true, message: '请输入银联商户号', trigger: 'blur' }],
acq_ins_code: [{ required: true, message: '请选择收单机构', trigger: 'change' }]
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
mer_id: '',
acq_ins_code: '',
cert_file: '',
enabled: false
})
certFile.value = ''
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('银联配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:商户号、收单机构与证书校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查商户号、收单机构与证书')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}银联吗?${enable ? '启用前请确认回调地址已向收单机构报备。' : '停用后租户将无法使用银联支付。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`银联已${enable ? '启用' : '停用'}`)
}
function handleCertChange(file: any) {
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, 'cert')
certFile.value = file?.name || ''
form.cert_file = certFile.value
ElMessage.success(`已选择证书:${certFile.value}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.upload-col {
width: 100%;
}
.upload-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.file-name {
font-size: 12px;
color: #909399;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,378 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>微信支付配置</h2>
<p class="sub-title">平台使用费收款主渠道,支持 JSAPI(公众号内)、Native(PC 扫码)支付。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>登录微信商户平台
<a href="https://pay.weixin.qq.com" target="_blank" rel="noopener">pay.weixin.qq.com</a>
→ 「账户中心 → 商户信息」取<b>商户号 mch_id</b>;「产品中心 → 开发配置」取并绑定<b>APPID</b>;
「账户中心 → API 安全」申请<b>APIv3 密钥</b>并下载<b>API 证书</b>(apiclient_cert.pem / apiclient_key.pem)。
</li>
<li>
<b>回调怎么配:</b>微信支付无需在商户平台登记回调域名,只需保证本页<b>回调地址</b>公网可访问、且处理成功时返回
<code>SUCCESS</code>。若使用 JSAPI 支付,还需在「产品中心 → 开发配置 → JSAPI 支付授权目录」中登记收银台域名。
</li>
<li>
<b>怎么验证:</b>填完参数先点「保存配置」,再点「测试连接」,系统会调用订单查询接口校验证书与 APIv3 密钥签名;
通过后可点「启用渠道」开始收款。建议先用 1 分钱真实下单走通全链路。
</li>
</ol>
<el-alert
type="warning"
:closable="false"
show-icon
title="APIv3 密钥与商户私钥属于高危凭证,仅在平台端配置,切勿下发到租户端或写入前端代码。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="商户号 mch_id" prop="mch_id">
<el-input v-model="form.mch_id" placeholder="请输入微信支付商户号,如 1620888999" clearable />
</el-form-item>
<el-form-item label="APPID" prop="appid">
<el-input v-model="form.appid" placeholder="公众号 / 小程序 / APP 的 APPID" clearable />
<div class="field-tip">需与当前商户号完成绑定,否则拉起支付会报「商户号与 AppID 不匹配」。</div>
</el-form-item>
<el-form-item label="APIv3 密钥" prop="api_v3_key">
<div class="secret-row">
<el-input
v-if="editing.api_v3_key"
v-model="form.api_v3_key"
type="password"
show-password
placeholder="请输入 32 位 APIv3 密钥"
clearable
/>
<el-input v-else :model-value="maskSecret(form.api_v3_key)" disabled placeholder="未配置" />
<el-button link type="primary" @click="toggleSecret('api_v3_key')">
{{ editing.api_v3_key ? '取消' : '修改' }}
</el-button>
</div>
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原密钥不变。</div>
</el-form-item>
<el-form-item label="商户证书">
<div class="upload-col">
<div class="upload-row">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".pem,.p12"
:on-change="(file) => handleCertChange(file, 'cert')"
>
<el-button>选择 apiclient_cert.pem</el-button>
</el-upload>
<span class="file-name">{{ certFiles.cert || '未上传' }}</span>
</div>
<div class="upload-row">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".pem,.p12"
:on-change="(file) => handleCertChange(file, 'key')"
>
<el-button>选择 apiclient_key.pem</el-button>
</el-upload>
<span class="file-name">{{ certFiles.key || '未上传' }}</span>
</div>
<div class="field-tip">证书仅在保存时上传到后端,由后端解密保存,前端不做本地持久化。</div>
</div>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/wechat,需公网 HTTPS 且直接返回 SUCCESS。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后收银台不再展示微信支付。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
const CHANNEL = 'wechat'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
mch_id: '',
appid: '',
api_v3_key: '',
enabled: true
})
const certFiles = reactive<{ cert: string; key: string }>({ cert: '', key: '' })
const rules: FormRules = {
mch_id: [{ required: true, message: '请输入商户号', trigger: 'blur' }],
appid: [{ required: true, message: '请输入 APPID', trigger: 'blur' }],
api_v3_key: [{ required: true, message: '请输入 APIv3 密钥', trigger: 'blur' }]
}
/* ---------------- 敏感字段掩码编辑 ---------------- */
const editing = reactive<Record<string, boolean>>({ api_v3_key: false })
const secretBackup = reactive<Record<string, string>>({ api_v3_key: '' })
/** 掩码规则:仅保留末 4 位 */
function maskSecret(value: string) {
const s = String(value || '')
if (!s) return ''
if (s.length <= 4) return '******'
return `******${s.slice(-4)}`
}
function toggleSecret(key: 'api_v3_key') {
if (editing[key]) {
form[key] = secretBackup[key]
editing[key] = false
return
}
secretBackup[key] = form[key]
form[key] = ''
editing[key] = true
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
mch_id: '1620888999',
appid: 'wx9a2c7b1a4d6e8f0a',
api_v3_key: 'WXv3Key9f2c7b1a4d6e8f0a3c5b7d9e',
enabled: true
})
certFiles.cert = 'apiclient_cert.pem'
certFiles.key = 'apiclient_key.pem'
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(api_v3_key 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form, cert_files: certFiles })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('微信支付配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:商户证书与 APIv3 密钥校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查商户号、密钥与证书')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}微信支付吗?${enable ? '启用后租户收银台将展示微信支付。' : '停用后租户将无法使用微信支付。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`微信支付已${enable ? '启用' : '停用'}`)
}
function handleCertChange(file: any, certType: 'cert' | 'key') {
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, certType)
certFiles[certType] = file?.name || ''
ElMessage.success(`已选择证书:${file?.name || ''}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.secret-row,
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.upload-col {
width: 100%;
}
.upload-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.file-name {
font-size: 12px;
color: #909399;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,421 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>推广佣金台账</h2>
<p class="sub-title">
推广佣金为平台自有资金支出,与租户收款解耦;佣金按规则在业务订单支付成功后生成,独立发放。
</p>
</div>
<div class="header-actions">
<el-button @click="goRules">
<el-icon><Setting /></el-icon>
佣金规则
</el-button>
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 统计卡片 -->
<div class="stat-grid">
<el-card shadow="never" class="stat-card">
<div class="stat-label">应付总额</div>
<div class="stat-value warning">¥{{ fenToYuan(stats.payable_amount) }}</div>
<div class="stat-tip">已生成佣金、尚未发放的金额</div>
</el-card>
<el-card shadow="never" class="stat-card">
<div class="stat-label">已付总额</div>
<div class="stat-value success">¥{{ fenToYuan(stats.paid_amount) }}</div>
<div class="stat-tip">已完成发放登记的金额</div>
</el-card>
<el-card shadow="never" class="stat-card">
<div class="stat-label">待处理笔数</div>
<div class="stat-value danger">{{ stats.pending_count }} 笔</div>
<div class="stat-tip">含应付与税务处理中的佣金单</div>
</el-card>
</div>
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="推广方">
<el-input
v-model="filters.promoter"
placeholder="推广方名称 / 编号"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width: 160px">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="下单时间">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 台账列表 -->
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="commission_no" label="佣金单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="out_trade_no" label="关联订单" min-width="180">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goOrder(row)">{{ row.out_trade_no }}</el-button>
</template>
</el-table-column>
<el-table-column prop="promoter_name" label="推广方" min-width="180" show-overflow-tooltip />
<el-table-column label="佣金金额" width="130" align="right">
<template #default="{ row }">
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="130" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="commission" />
</template>
</el-table-column>
<el-table-column label="发放方式" width="130" align="center">
<template #default="{ row }">{{ payMethodLabel(row.pay_method) }}</template>
</el-table-column>
<el-table-column prop="create_time" label="生成时间" width="180" align="center" />
<el-table-column prop="paid_at" label="发放时间" width="180" align="center">
<template #default="{ row }">{{ row.paid_at || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 'payable'"
size="small"
type="primary"
link
@click="handleMarkPaid(row)"
>
标记已付
</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getCommissionList, getCommissionStats, markCommissionPaid } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const RULES_PATH = '/system/payment/commission/rules'
const ORDER_DETAIL_PATH = '/system/payment/orders/detail'
const filters = reactive<{
promoter: string
status: string
dateRange: [string, string] | []
}>({
promoter: '',
status: '',
dateRange: []
})
const page = reactive({ current: 1, size: 10, total: 0 })
const statusOptions = [
{ label: '应付', value: 'payable' },
{ label: '已付', value: 'settled' },
{ label: '税务处理中', value: 'tax_processing' },
{ label: '已作废', value: 'canceled' }
]
const payMethodMap: Record<string, string> = {
balance: '余额抵扣',
bank: '银行转账',
manual: '人工发放',
offset: '下期抵扣'
}
const payMethodLabel = (method: string) => payMethodMap[method] || method || '-'
/**
* 统计卡片数据:TODO 换成 getCommissionStats()
* 后端聚合自 yz_platform_commission_order:
* 应付总额 = sum(amount) where status='payable'
* 已付总额 = sum(amount) where status='settled'
* 待处理笔数 = count(*) where status in ('payable','tax_processing')
*/
const stats = reactive({
payable_amount: 1288000,
paid_amount: 4560500,
pending_count: 23
})
/* ---------------- Mock 数据(本地筛选 + 分页) ---------------- */
interface CommissionRow {
id: number
commission_no: string
out_trade_no: string
promoter_name: string
amount: number
status: string
pay_method: string
create_time: string
paid_at: string
}
const ALL_COMMISSIONS: CommissionRow[] = Array.from({ length: 24 }).map((_, index) => {
const promoters = ['云泽渠道-张明', '渠道伙伴-李雷', '服务商-杭州微光', '渠道伙伴-王芳', '服务商-深圳智联']
const statuses = ['payable', 'payable', 'settled', 'settled', 'tax_processing']
const methods = ['bank', 'balance', 'manual', 'offset']
const day = String(15 - Math.floor(index / 8)).padStart(2, '0')
const status = statuses[index % statuses.length]
return {
id: index + 1,
commission_no: `CM202609${day}${String(3000 + index)}`,
out_trade_no: `ORD202609${day}${String(2000 + index)}`,
promoter_name: promoters[index % promoters.length],
amount: [9990, 19990, 29990, 49990][index % 4],
status,
pay_method: methods[index % methods.length],
create_time: `2026-09-${day} 11:${String((index * 5) % 60).padStart(2, '0')}:00`,
paid_at: status === 'settled' ? `2026-09-${day} 16:20:00` : ''
}
})
const tableData = ref<CommissionRow[]>([])
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getCommissionList({
// page: page.current,
// pageSize: page.size,
// promoter: filters.promoter,
// status: filters.status,
// start_time: filters.dateRange?.[0],
// end_time: filters.dateRange?.[1]
// })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const [start, end] = filters.dateRange || []
const filtered = ALL_COMMISSIONS.filter((item) => {
if (filters.promoter && !item.promoter_name.includes(filters.promoter)) return false
if (filters.status && item.status !== filters.status) return false
const date = item.create_time.slice(0, 10)
if (start && date < start) return false
if (end && date > end) return false
return true
})
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取佣金台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.promoter = ''
filters.status = ''
filters.dateRange = []
page.current = 1
fetchList()
}
async function refresh() {
await fetchList()
ElMessage.success('刷新成功')
}
async function handleMarkPaid(row: CommissionRow) {
try {
const { value } = await ElMessageBox.prompt(
`确认将佣金单 ${row.commission_no}(¥${fenToYuan(row.amount)})标记为已发放?`,
'标记已付',
{
type: 'warning',
inputPlaceholder: '请输入发放方式说明 / 流水号',
inputValidator: (val: string) => (val && val.trim() ? true : '请填写发放说明'),
confirmButtonText: '确认发放'
}
)
// TODO: 接口联通后替换为 markCommissionPaid(row.id, { pay_method: row.pay_method, remark: value })
row.status = 'settled'
row.paid_at = new Date().toLocaleString('zh-CN')
ElMessage.success('已标记为已付')
await fetchList()
} catch {
// 取消
}
}
function goRules() {
router.push(RULES_PATH)
}
function goOrder(row: CommissionRow) {
router.push({ path: ORDER_DETAIL_PATH, query: { outTradeNo: row.out_trade_no } })
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
.stat-label {
font-size: 13px;
color: #909399;
}
.stat-value {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
&.warning {
color: #e6a23c;
}
&.success {
color: #67c23a;
}
&.danger {
color: #f56c6c;
}
}
.stat-tip {
margin-top: 6px;
font-size: 12px;
color: #909399;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.text-muted {
color: #909399;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
@@ -0,0 +1,633 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>佣金规则</h2>
<p class="sub-title">
规则决定业务订单支付成功后生成多少佣金;佣金由平台自有资金支出,与租户收款金额解耦。
</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增规则
</el-button>
<el-button @click="goLedger">
<el-icon><Tickets /></el-icon>
佣金台账
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="规则名称">
<el-input
v-model="filters.keyword"
placeholder="规则名称关键词"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="rule_name" label="规则名称" min-width="200" show-overflow-tooltip />
<el-table-column label="计算方式" width="120" align="center">
<template #default="{ row }">
<el-tag size="small" :type="calcTypeTag(row.calc_type)">{{ calcTypeLabel(row.calc_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="值" min-width="240">
<template #default="{ row }">{{ valueText(row) }}</template>
</el-table-column>
<el-table-column label="适用订单类型" width="160" align="center">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.status === 1"
@change="(val) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
<!-- 新增 / 编辑规则 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑佣金规则' : '新增佣金规则'"
width="720px"
:close-on-click-modal="false"
@closed="resetForm"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
<el-form-item label="规则名称" prop="rule_name">
<el-input v-model="form.rule_name" placeholder="如:渠道推广-标准版年度 5%" clearable />
</el-form-item>
<el-form-item label="计算方式" prop="calc_type">
<el-radio-group v-model="form.calc_type">
<el-radio value="percent">按比例</el-radio>
<el-radio value="fixed">固定金额</el-radio>
<el-radio value="tiered">阶梯</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.calc_type === 'percent'"
label="比例值"
prop="value"
>
<el-input v-model="form.value" placeholder="如 5" style="width: 200px">
<template #append>%</template>
</el-input>
<span class="field-tip">按业务订单实付金额的百分比计算佣金。</span>
</el-form-item>
<el-form-item v-else-if="form.calc_type === 'fixed'" label="固定金额" prop="value">
<el-input v-model="form.value" placeholder="如 50.00" style="width: 200px">
<template #append>元</template>
</el-input>
<span class="field-tip">每笔符合条件的业务订单固定发放该金额(保存时按分存储)。</span>
</el-form-item>
<el-form-item v-else label="阶梯配置">
<div class="tier-block">
<el-table :data="form.steps" size="small" border style="width: 100%">
<el-table-column label="起始金额(元,含)" min-width="150">
<template #default="{ row }">
<el-input v-model="row.min" placeholder="0" />
</template>
</el-table-column>
<el-table-column label="结束金额(元,不含)" min-width="150">
<template #default="{ row }">
<el-input v-model="row.max" placeholder="不限可留空" />
</template>
</el-table-column>
<el-table-column label="比例(%)" min-width="120">
<template #default="{ row }">
<el-input v-model="row.rate" placeholder="如 5" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button size="small" type="danger" link @click="removeStep($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button class="add-step" size="small" @click="addStep">
<el-icon><Plus /></el-icon>
添加阶梯
</el-button>
<div class="field-tip">阶梯按订单金额落在区间内取对应比例,区间需连续且不重叠。</div>
</div>
</el-form-item>
<el-form-item label="适用订单类型" prop="order_type">
<el-select v-model="form.order_type" placeholder="请选择" style="width: 260px">
<el-option label="全部订单" value="all" />
<el-option label="平台使用费" value="platform_usage" />
<el-option label="模块购买" value="module_shop" />
<el-option label="服务费" value="service_fee" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后新生成的佣金单不再应用该规则,历史佣金单不受影响。</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选,如适用渠道、生效时间说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Plus, Edit, Delete, Tickets } from '@element-plus/icons-vue'
// TODO: 接口联调时打开
// import {
// getCommissionRules,
// saveCommissionRule,
// updateCommissionRule,
// deleteCommissionRule,
// toggleCommissionRule
// } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const LEDGER_PATH = '/system/payment/commission'
const filters = reactive({ keyword: '' })
const page = reactive({ current: 1, size: 10, total: 0 })
interface TierStep {
min: string
max: string
rate: string
}
interface RuleRow {
id: number
rule_name: string
calc_type: 'percent' | 'fixed' | 'tiered'
/** percent: 百分比数值;fixed: 金额(分);tiered: 见 steps */
value: number
steps?: { min: number; max: number | null; rate: number }[]
order_type: string
status: number
remark: string
}
const ALL_RULES: RuleRow[] = [
{
id: 1,
rule_name: '渠道推广-平台使用费 5%',
calc_type: 'percent',
value: 5,
order_type: 'platform_usage',
status: 1,
remark: '默认规则,适用于全部签约渠道伙伴'
},
{
id: 2,
rule_name: '服务商-模块购买固定 50 元',
calc_type: 'fixed',
value: 5000,
order_type: 'module_shop',
status: 1,
remark: '每笔模块购买订单固定发放'
},
{
id: 3,
rule_name: '阶梯激励-年度大单',
calc_type: 'tiered',
// 阶梯配置入库为 yz_platform_commission_rule.tier_json,接口读出时反序列化为 steps 数组
value: 0,
steps: [
{ min: 0, max: 1000000, rate: 3 },
{ min: 1000000, max: 5000000, rate: 5 },
{ min: 5000000, max: null, rate: 8 }
],
order_type: 'all',
status: 1,
remark: '按订单金额区间取比例'
},
{
id: 4,
rule_name: '老规则-暂停使用',
calc_type: 'percent',
value: 3,
order_type: 'service_fee',
status: 0,
remark: '2025 年规则,已停用'
}
]
const tableData = ref<RuleRow[]>([])
const calcTypeLabel = (type: string) =>
({ percent: '按比例', fixed: '固定金额', tiered: '阶梯' } as Record<string, string>)[type] || type
const calcTypeTag = (type: string) =>
({ percent: 'primary', fixed: 'success', tiered: 'warning' } as Record<string, any>)[type] || 'info'
const orderTypeMap: Record<string, string> = {
all: '全部订单',
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/** 规则值展示 */
function valueText(row: RuleRow) {
if (row.calc_type === 'percent') return `${row.value}%(按订单实付金额)`
if (row.calc_type === 'fixed') return `¥${fenToYuan(row.value)} / 笔`
const steps = row.steps || []
if (!steps.length) return '-'
return steps
.map((item) => {
const min = fenToYuan(item.min)
const max = item.max === null || item.max === undefined ? '不限' : fenToYuan(item.max)
return `${min} ~ ${max} 元:${item.rate}%`
})
.join(';')
}
/* ---------------- 列表 ---------------- */
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getCommissionRules({ page: page.current, pageSize: page.size, keyword: filters.keyword })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const filtered = ALL_RULES.filter((item) =>
filters.keyword ? item.rule_name.includes(filters.keyword) : true
)
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取佣金规则失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.keyword = ''
page.current = 1
fetchList()
}
/* ---------------- 新增 / 编辑 ---------------- */
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{
id: number | null
rule_name: string
calc_type: 'percent' | 'fixed' | 'tiered'
value: string
steps: TierStep[]
order_type: string
status: number
remark: string
}>({
id: null,
rule_name: '',
calc_type: 'percent',
value: '',
steps: [{ min: '', max: '', rate: '' }],
order_type: 'platform_usage',
status: 1,
remark: ''
})
const rules: FormRules = {
rule_name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
calc_type: [{ required: true, message: '请选择计算方式', trigger: 'change' }],
order_type: [{ required: true, message: '请选择适用订单类型', trigger: 'change' }],
value: [
{
validator: (_rule, value, callback) => {
if (form.calc_type === 'tiered') {
callback()
return
}
if (value === '' || value === null || value === undefined) {
callback(new Error('请输入规则值'))
return
}
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('请输入大于 0 的数值'))
return
}
callback()
},
trigger: 'blur'
}
]
}
function handleAdd() {
isEdit.value = false
dialogVisible.value = true
}
function handleEdit(row: RuleRow) {
isEdit.value = true
form.id = row.id
form.rule_name = row.rule_name
form.calc_type = row.calc_type
form.order_type = row.order_type
form.status = row.status
form.remark = row.remark
if (row.calc_type === 'fixed') {
// 固定金额在库中以分存储,编辑时转元展示
form.value = fenToYuan(row.value)
} else if (row.calc_type === 'percent') {
form.value = String(row.value)
} else {
form.value = ''
}
form.steps = (row.steps || []).map((item) => ({
min: fenToYuan(item.min),
max: item.max === null || item.max === undefined ? '' : fenToYuan(item.max),
rate: String(item.rate)
}))
if (!form.steps.length) form.steps = [{ min: '', max: '', rate: '' }]
dialogVisible.value = true
}
function resetForm() {
form.id = null
form.rule_name = ''
form.calc_type = 'percent'
form.value = ''
form.steps = [{ min: '', max: '', rate: '' }]
form.order_type = 'platform_usage'
form.status = 1
form.remark = ''
formRef.value?.clearValidate()
}
function addStep() {
form.steps.push({ min: '', max: '', rate: '' })
}
function removeStep(index: number) {
if (form.steps.length <= 1) {
ElMessage.warning('至少保留一个阶梯')
return
}
form.steps.splice(index, 1)
}
/** 校验阶梯配置:区间必填、比例必填且大于 0 */
function validateSteps(): string | null {
for (let i = 0; i < form.steps.length; i += 1) {
const step = form.steps[i]
if (step.min === '' || step.min === null || step.min === undefined) {
return `第 ${i + 1} 档起始金额不能为空`
}
if (step.rate === '' || !(Number(step.rate) > 0)) {
return `第 ${i + 1} 档比例需大于 0`
}
if (step.max !== '' && Number(step.max) <= Number(step.min)) {
return `第 ${i + 1} 档结束金额需大于起始金额`
}
}
return null
}
async function handleSubmit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
if (form.calc_type === 'tiered') {
const error = validateSteps()
if (error) {
ElMessage.warning(error)
return
}
}
saving.value = true
try {
const payload: Record<string, any> = {
rule_name: form.rule_name,
calc_type: form.calc_type,
order_type: form.order_type,
status: form.status,
remark: form.remark
}
if (form.calc_type === 'percent') {
payload.value = Number(form.value)
} else if (form.calc_type === 'fixed') {
// 元 -> 分
payload.value = Math.round(Number(form.value) * 100)
} else {
payload.value = 0
payload.steps = form.steps.map((item) => ({
min: Math.round(Number(item.min) * 100),
max: item.max === '' ? null : Math.round(Number(item.max) * 100),
rate: Number(item.rate)
}))
}
// TODO: 接口联通后替换为真实请求
// if (isEdit.value && form.id) await updateCommissionRule(form.id, payload)
// else await saveCommissionRule(payload)
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success(isEdit.value ? '规则已更新' : '规则已新增')
dialogVisible.value = false
await fetchList()
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleStatusChange(row: RuleRow, val: boolean | string | number) {
const next = val ? 1 : 0
// TODO: 接口联通后替换为 toggleCommissionRule(row.id, next)
row.status = next
ElMessage.success(`规则已${next ? '启用' : '停用'}`)
}
async function handleDelete(row: RuleRow) {
try {
await ElMessageBox.confirm(`确定删除规则「${row.rule_name}」吗?已生成的佣金单不受影响。`, '警告', {
type: 'warning'
})
} catch {
return
}
try {
// TODO: 接口联通后替换为 deleteCommissionRule(row.id)
ElMessage.success('删除成功')
await fetchList()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function goLedger() {
router.push(LEDGER_PATH)
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.text-muted {
color: #909399;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.tier-block {
width: 100%;
.add-step {
margin-top: 8px;
}
}
</style>
@@ -0,0 +1,161 @@
<template>
<el-card class="channel-card" shadow="hover" :body-style="{ padding: '18px 20px' }">
<div class="card-head">
<div class="head-left">
<div class="icon-wrap" :class="statusClass">
<el-icon :size="22">
<component :is="iconComp" />
</el-icon>
</div>
<div class="title-wrap">
<div class="channel-name">{{ name }}</div>
<div class="merchant-no">商户号:{{ merchantNo || '未配置' }}</div>
</div>
</div>
<StatusTag :status="status" dict="channel" />
</div>
<div v-if="desc" class="card-desc">{{ desc }}</div>
<div class="card-foot">
<el-button size="small" type="primary" plain @click="emit('config')">配置</el-button>
<el-button v-if="status === 'enabled'" size="small" type="danger" plain @click="emit('disable')">
停用
</el-button>
<el-button v-else size="small" type="success" plain @click="emit('enable')">启用</el-button>
</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money,
PriceTag
} from '@element-plus/icons-vue'
import StatusTag from './StatusTag.vue'
/** 常用渠道图标映射:微信 / 支付宝 / 银联 / 云闪付 / PayPal / 兜底 */
const ICONS: Record<string, any> = {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money,
PriceTag
}
const props = withDefaults(
defineProps<{
/** 渠道名称,如「微信支付」 */
name: string
/** 渠道状态:enabled 已启用 / disabled 已停用 / unconfigured 未配置 */
status?: string
/** 商户号,未配置时显示「未配置」 */
merchantNo?: string
/** 图标名:ChatDotRound / Wallet / CreditCard / Lightning / Money */
icon?: string
/** 卡片补充说明 */
desc?: string
}>(),
{
status: 'unconfigured',
merchantNo: '',
icon: 'PriceTag',
desc: ''
}
)
const emit = defineEmits<{
(e: 'config'): void
(e: 'enable'): void
(e: 'disable'): void
}>()
const iconComp = computed(() => ICONS[props.icon] || PriceTag)
const statusClass = computed(() => {
if (props.status === 'enabled') return 'is-enabled'
if (props.status === 'unconfigured') return 'is-unconfigured'
return 'is-disabled'
})
</script>
<style scoped lang="less">
.channel-card {
margin-bottom: 0;
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.head-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.icon-wrap {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
&.is-enabled {
background: linear-gradient(135deg, #52c41a, #95de64);
}
&.is-disabled {
background: linear-gradient(135deg, #909399, #b1b3b8);
}
&.is-unconfigured {
background: linear-gradient(135deg, #e6a23c, #f3d19e);
}
}
.title-wrap {
min-width: 0;
}
.channel-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.merchant-no {
margin-top: 4px;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-desc {
margin-top: 12px;
font-size: 12px;
line-height: 1.6;
color: #909399;
}
.card-foot {
margin-top: 16px;
display: flex;
gap: 8px;
}
}
</style>
@@ -0,0 +1,113 @@
<template>
<el-tag :type="current.type" :size="size" :effect="effect">
{{ text || current.text }}
</el-tag>
</template>
<script setup lang="ts">
import { computed } from 'vue'
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger'
type DictName = 'common' | 'channel' | 'order' | 'commission' | 'reconcile' | 'diff'
interface StatusItem {
text: string
type: TagType
}
const props = withDefaults(
defineProps<{
/** 状态值,支持字符串或数字(如 1 / 0、'enabled'、'paid') */
status?: string | number
/** 自定义展示文案,不传则取字典里的文案 */
text?: string
/** 字典分组,不同业务模块状态含义不同 */
dict?: DictName
size?: 'large' | 'default' | 'small'
effect?: 'dark' | 'light' | 'plain'
}>(),
{
status: '',
text: '',
dict: 'common',
size: 'small',
effect: 'light'
}
)
/** 通用状态(渠道 / 规则 / 单据都常见的枚举) */
const COMMON: Record<string, StatusItem> = {
'1': { text: '启用', type: 'success' },
'0': { text: '停用', type: 'info' },
enabled: { text: '已启用', type: 'success' },
disabled: { text: '已停用', type: 'info' },
unconfigured: { text: '未配置', type: 'warning' },
yes: { text: '是', type: 'success' },
no: { text: '否', type: 'info' }
}
/** 渠道状态 */
const CHANNEL: Record<string, StatusItem> = {
enabled: { text: '已启用', type: 'success' },
disabled: { text: '已停用', type: 'info' },
unconfigured: { text: '未配置', type: 'warning' }
}
/** 支付单状态 */
const ORDER: Record<string, StatusItem> = {
created: { text: '已创建', type: 'info' },
pending: { text: '待支付', type: 'warning' },
paying: { text: '支付中', type: 'warning' },
paid: { text: '支付成功', type: 'success' },
success: { text: '支付成功', type: 'success' },
failed: { text: '支付失败', type: 'danger' },
closed: { text: '已关闭', type: 'info' },
timeout: { text: '已超时', type: 'info' },
refunding: { text: '退款中', type: 'warning' },
refunded: { text: '已退款', type: 'info' },
partial_refunded: { text: '部分退款', type: 'warning' }
}
/** 佣金状态 */
const COMMISSION: Record<string, StatusItem> = {
payable: { text: '应付', type: 'warning' },
settled: { text: '已付', type: 'success' },
tax_processing: { text: '税务处理中', type: 'primary' },
canceled: { text: '已作废', type: 'info' }
}
/** 对账处理状态 */
const RECONCILE: Record<string, StatusItem> = {
unhandled: { text: '待处理', type: 'warning' },
handling: { text: '处理中', type: 'primary' },
handled: { text: '已处理', type: 'success' },
ignored: { text: '已忽略', type: 'info' }
}
/** 对账差异类型 */
const DIFF: Record<string, StatusItem> = {
long: { text: '长款', type: 'warning' },
short: { text: '短款', type: 'danger' },
status_mismatch: { text: '状态不一致', type: 'warning' }
}
const DICTS: Record<DictName, Record<string, StatusItem>> = {
common: COMMON,
channel: CHANNEL,
order: ORDER,
commission: COMMISSION,
reconcile: RECONCILE,
diff: DIFF
}
const current = computed<StatusItem>(() => {
const key = props.status === undefined || props.status === null ? '' : String(props.status)
return (
DICTS[props.dict]?.[key] ||
COMMON[key] || {
text: key || '-',
type: 'info'
}
)
})
</script>
@@ -0,0 +1,370 @@
<template>
<div class="doc-wrap">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道配置通用约定</span>
</template>
<ul class="dot-list">
<li>所有渠道配置只在<b>平台端</b>维护,租户端不接触任何渠道密钥。</li>
<li>敏感字段(密钥 / 私钥 / Secret)保存后端只返回掩码;页面提交掩码表示<b>不修改原值</b>。</li>
<li>回调地址统一形如 <code>https://你的域名/api/payment/callback/{渠道}</code>,必须<b>公网 HTTPS 可访问</b>,不能带鉴权、不能重定向。</li>
<li>回调处理需<b>幂等</b>:同一笔通知可能重复推送,业务上按渠道流水号唯一键去重。</li>
<li>配置完成后先「测试连接」,再用 1 分钱真实下单走通全链路,最后才启用渠道。</li>
</ul>
</el-card>
<el-collapse v-model="activeNames">
<el-collapse-item v-for="doc in channels" :key="doc.key" :name="doc.key">
<template #title>
<div class="panel-title">
<span class="name">{{ doc.name }}</span>
<StatusTag :status="doc.status" dict="channel" />
</div>
</template>
<div class="section">
<h4>1. 去哪拿参数</h4>
<ul class="dot-list">
<li v-for="(line, index) in doc.where" :key="index">{{ line }}</li>
</ul>
<div v-if="doc.links && doc.links.length" class="links">
<span>相关地址:</span>
<a v-for="link in doc.links" :key="link.url" :href="link.url" target="_blank" rel="noopener">
{{ link.text }}
</a>
</div>
</div>
<div class="section">
<h4>2. 填什么</h4>
<el-table :data="doc.fields" size="small" border style="width: 100%">
<el-table-column prop="label" label="字段" width="180" />
<el-table-column label="必填" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.required ? 'danger' : 'info'" effect="plain">
{{ row.required ? '必填' : '选填' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="敏感" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.secret ? 'warning' : 'info'" effect="plain">
{{ row.secret ? '掩码' : '明文' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="tip" label="说明" min-width="320" />
</el-table>
</div>
<div class="section">
<h4>3. 回调怎么配</h4>
<div class="callback-row">
<el-input :model-value="callbackUrl(doc.key)" readonly />
<el-button @click="copyText(callbackUrl(doc.key))">复制</el-button>
</div>
<p class="para">{{ doc.callback }}</p>
</div>
<div class="section">
<h4>4. 怎么验证</h4>
<ol class="step-list">
<li v-for="(step, index) in doc.verify" :key="index">{{ step }}</li>
</ol>
</div>
<el-alert
v-if="doc.alert"
:type="doc.alert.type"
:closable="false"
show-icon
:title="doc.alert.text"
/>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
interface FieldDoc {
label: string
required: boolean
secret?: boolean
tip: string
}
interface ChannelDoc {
key: string
name: string
status: string
where: string[]
links?: { text: string; url: string }[]
fields: FieldDoc[]
callback: string
verify: string[]
alert?: { type: 'info' | 'warning' | 'error' | 'success'; text: string }
}
const activeNames = ref<string[]>(['wechat'])
const callbackUrl = (channel: string) => `${window.location.origin}/api/payment/callback/${channel}`
const channels: ChannelDoc[] = [
{
key: 'wechat',
name: '微信支付',
status: 'enabled',
where: [
'登录微信商户平台,进入「账户中心 → 商户信息」,记录商户号 mch_id。',
'进入「产品中心 → 开发配置」,绑定需要收款的 APPID(公众号 / 小程序 / APP)。',
'进入「账户中心 → API 安全」,申请 APIv3 密钥(32 位,只在申请时展示)。',
'同页「API 证书」下载证书文件,取 apiclient_cert.pem 与 apiclient_key.pem。'
],
links: [{ text: '微信商户平台', url: 'https://pay.weixin.qq.com' }],
fields: [
{ label: '商户号 mch_id', required: true, tip: '微信支付商户号,纯数字,注意不要填成 APPID' },
{ label: 'APPID', required: true, tip: '必须与该商户号完成绑定,否则拉起支付会报商户号与 AppID 不匹配' },
{ label: 'APIv3 密钥', required: true, secret: true, tip: '32 位字符串,用于解密回调与接口签名,丢失只能重新申请' },
{ label: '商户证书', required: true, secret: true, tip: 'apiclient_cert.pem + apiclient_key.pem,上传后由后端加密保存' },
{ label: '启用状态', required: false, tip: '停用后收银台不再展示微信支付' }
],
callback:
'微信支付无需在商户平台登记回调域名,只要地址公网可访问、处理成功返回字符串 SUCCESS 即可。若使用 JSAPI 支付,需在「产品中心 → 开发配置 → JSAPI 支付授权目录」登记收银台域名(含 https 且以 / 结尾)。',
verify: [
'保存配置后点击「测试连接」,系统调用订单查询接口校验商户号、证书与 APIv3 密钥。',
'用 1 分钱在租户端真实下单,确认能拉起微信收银台并完成支付。',
'确认支付单状态在 10 秒内变为「支付成功」,业务订单同步为已支付。',
'在商户平台查看该笔订单,核对金额与商户单号是否与本地一致。'
],
alert: {
type: 'warning',
text: 'APIv3 密钥与商户私钥只在平台端保存,切勿放进前端代码或下发给租户。'
}
},
{
key: 'alipay',
name: '支付宝',
status: 'enabled',
where: [
'登录支付宝开放平台,进入「控制台 → 网页/移动应用」,记录应用 APPID。',
'进入「开发设置 → 接口加签方式」,选择公钥模式,用签名工具生成应用私钥,上传应用公钥。',
'保存后页面会回显支付宝公钥,复制备用(用于校验渠道回调签名)。',
'进入「产品绑定」,开通电脑网站支付与手机网站支付。'
],
links: [{ text: '支付宝开放平台', url: 'https://open.alipay.com' }],
fields: [
{ label: 'APPID', required: true, tip: '开放平台应用 ID,注意区分「应用 APPID」与「商户号 PID」' },
{ label: '应用私钥', required: true, secret: true, tip: 'RSA2 应用私钥(PKCS8),仅平台端保存' },
{ label: '支付宝公钥', required: true, tip: '开放平台回显的公钥,属公开信息,用于回调验签' },
{ label: '签名类型', required: true, tip: '默认 RSA2,与开放平台加签方式保持一致' },
{ label: '启用状态', required: false, tip: '停用后收银台不再展示支付宝' }
],
callback:
'支付宝异步通知地址(notify_url)随下单请求携带,无需在开放平台登记;处理成功必须返回字符串 success(全小写),否则支付宝会按 4m/10h/24h 等策略重复通知。同步跳转地址由下单时的 return_url 决定。',
verify: [
'保存后点击「测试连接」,系统调用 alipay.trade.query 校验 APPID 与签名配置。',
'用 1 分钱真实下单,确认能跳转支付宝收银台并完成支付。',
'在开放平台「交易 → 交易明细」核对订单,确认异步通知已被后端接收。',
'确认支付单与业务订单状态均更新为成功。'
],
alert: {
type: 'info',
text: '常见失败原因:应用私钥与上传到开放平台的应用公钥不配对,或使用了支付宝公钥去签名(两者角色不能互换)。'
}
},
{
key: 'unionpay',
name: '银联',
status: 'unconfigured',
where: [
'完成银联入网(或通过第三方收单机构入网)后,由收单机构下发商户号 mer_id。',
'向客户经理获取收单机构代码 acq_ins_code(如 0402 建设银行、0104 中国银行)。',
'获取商户证书文件(.pfx / .p12 / .cer)与证书密码,证书有有效期,到期需重新下发。',
'索取测试环境商户号用于联调(测试与生产商户号不通用)。'
],
links: [{ text: '银联开放平台', url: 'https://open.unionpay.com' }],
fields: [
{ label: '商户号 mer_id', required: true, tip: '收单机构下发,测试与生产不同' },
{ label: '收单机构', required: true, tip: '收单机构代码,必须与入网时一致,填错交易会被拒' },
{ label: '商户证书', required: true, secret: true, tip: '.pfx / .p12 / .cer 证书文件,由后端加密保存' },
{ label: '启用状态', required: false, tip: '停用后租户无法使用银联支付' }
],
callback:
'银联的前台通知与后台通知地址需要在入网时报备给收单机构,报备通过后使用本页地址;未报备的地址会被银联侧拒绝。后台通知处理成功返回 ok。',
verify: [
'保存后点击「测试连接」,系统调用银联交易状态查询接口校验证书与商户号。',
'用测试商户号在测试环境跑通 PC 网关支付。',
'生产启用前,务必确认回调地址已完成报备,并与客户经理确认可收款。',
'用 1 分钱真实支付验证前台跳转与后台通知均正常。'
],
alert: {
type: 'warning',
text: '证书与证书密码属高危凭证;证书过期会导致全部银联交易失败,建议记录到期日并提前 1 个月更换。'
}
},
{
key: 'cloudpay',
name: '云闪付',
status: 'disabled',
where: [
'云闪付走银联通道,不单独申请商户号,直接复用「银联配置」中的商户号与收单机构代码。',
'收银台展示所需云闪付标识物料由银联 / 收单机构提供,或使用平台内置标识。',
'如需独立商户号,需单独与收单机构签约后再在本页维护。'
],
fields: [
{ label: '继承商户号', required: false, tip: '只读,取银联配置的商户号,如需修改请前往银联配置页' },
{ label: '继承收单机构', required: false, tip: '只读,取银联配置的收单机构代码' },
{ label: '在收银台展示云闪付标识', required: false, tip: '关闭后不展示入口,但已发起订单仍可正常回调' },
{ label: '展示位置', required: false, tip: '可分别控制在 PC 收银台与手机 H5 收银台展示' },
{ label: '启用状态', required: false, tip: '需先启用银联渠道,云闪付才会在收银台生效' }
],
callback:
'云闪付交易通知默认由银联通道统一下发,复用银联回调地址;若收单机构要求独立地址,需在入网时报备本页地址,后台通知处理成功返回 ok。',
verify: [
'先在「银联配置」完成保存并通过测试连接。',
'回到本页开启展示标识并启用渠道。',
'用云闪付 App 在收银台完成一笔扫码支付,确认回调正常。',
'核对支付单渠道字段为「云闪付」,对账时注意与银联账单的渠道标识差异。'
],
alert: {
type: 'info',
text: '云闪付与银联共用商户参数与证书,因此银联侧报备、证书更换会同时影响云闪付。'
}
},
{
key: 'paypal',
name: 'PayPal',
status: 'disabled',
where: [
'登录 PayPal 开发者后台,进入「Apps & Credentials」,分别查看沙箱与正式环境的应用列表。',
'创建或打开 REST App,复制 Client ID 与 Client Secret(Secret 只在创建时完整展示)。',
'在 App 详情 → Webhooks 中新增回调地址并勾选事件,保存后复制 Webhook ID。',
'如无海外收款资质,可仅保留沙箱配置用于功能联调。'
],
links: [{ text: 'PayPal 开发者后台', url: 'https://developer.paypal.com' }],
fields: [
{ label: 'Client ID', required: true, tip: 'REST App 的 Client ID,沙箱与正式不通用' },
{ label: 'Client Secret', required: true, secret: true, tip: 'REST App 的 Secret,仅平台端保存' },
{ label: 'Webhook ID', required: true, tip: '在 App 的 Webhooks 列表中复制,用于校验通知来源' },
{ label: '运行环境', required: true, tip: '沙箱 / 正式;切换前确认三处凭证都来自同一环境' },
{ label: '启用状态', required: false, tip: '仅在有海外租户时启用' }
],
callback:
'需在 PayPal App 的 Webhooks 中登记本页地址,并勾选 PAYMENT.CAPTURE.COMPLETED、PAYMENT.CAPTURE.DENIED、PAYMENT.CAPTURE.REFUNDED 等事件;后端按 PayPal 签名头校验通知来源,处理成功返回 HTTP 200。',
verify: [
'保存后点击「测试连接」,系统调用 OAuth2 Token 接口校验 Client ID / Secret。',
'用沙箱买家账号完成一笔支付,确认 Webhook 被接收且订单状态更新。',
'在 PayPal 后台 Webhooks 的 Event log 中确认事件已成功推送(返回 2xx)。',
'确认无误后切换到正式环境凭证并启用渠道。'
],
alert: {
type: 'warning',
text: '当前业务不跨境,PayPal 仅为预留渠道,默认停用;启用前请确认已完成外汇与合规评估。'
}
}
]
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
</script>
<style scoped lang="less">
.doc-wrap {
.block-card {
margin-bottom: 20px;
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.panel-title {
display: flex;
align-items: center;
gap: 10px;
.name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.section {
margin-bottom: 18px;
h4 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
padding-left: 8px;
border-left: 3px solid #409eff;
}
}
.dot-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
}
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.para {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.links {
margin-top: 6px;
font-size: 13px;
color: #909399;
a {
margin-right: 12px;
color: #409eff;
}
}
.callback-row {
display: flex;
align-items: center;
gap: 8px;
max-width: 720px;
}
}
</style>
@@ -0,0 +1,246 @@
<template>
<div class="doc-wrap">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">常见问题</span>
</template>
<el-collapse v-model="activeNames">
<el-collapse-item v-for="item in faqs" :key="item.key" :name="item.key">
<template #title>
<div class="q-title">
<el-tag size="small" :type="item.tagType" effect="plain">{{ item.tag }}</el-tag>
<span>{{ item.question }}</span>
</div>
</template>
<div class="answer">
<p v-for="(para, index) in item.answer" :key="index" class="para">{{ para }}</p>
<template v-if="item.steps && item.steps.length">
<div class="steps-title">处理步骤</div>
<ol class="step-list">
<li v-for="(step, index) in item.steps" :key="index">{{ step }}</li>
</ol>
</template>
<el-alert
v-if="item.tip"
class="tip"
:type="item.tagType"
:closable="false"
show-icon
:title="item.tip"
/>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface FaqItem {
key: string
tag: string
tagType: 'primary' | 'success' | 'info' | 'warning' | 'danger'
question: string
answer: string[]
steps?: string[]
tip?: string
}
const activeNames = ref<string[]>(['faq-order'])
const faqs: FaqItem[] = [
{
key: 'faq-order',
tag: '订单',
tagType: 'warning',
question: '支付成功了,但订单状态没更新,怎么处理?',
answer: [
'绝大多数情况是渠道异步回调没有到达后端,或到达了但处理报错。前端页面上显示的成功提示只代表浏览器侧动作完成,不代表服务端已入账。',
'系统已提供两层兜底:一是支付单详情页的「手动查询渠道状态」,会主动向渠道查询并同步;二是后端定时补偿任务会扫描长时间处于 paying 的支付单。'
],
steps: [
'先到「支付订单」中按租户或业务订单号找到该笔支付单,确认状态是否仍为「待支付 / 支付中」。',
'进入支付单详情,点「手动查询渠道状态」,若渠道侧已成功,本地会立即同步为「支付成功」并把业务订单置为已支付。',
'如渠道返回未支付,请让用户重新支付:旧支付单会保持未支付状态,不会重复扣款。',
'若查询成功但业务订单仍未更新,检查回调处理代码中业务订单状态同步逻辑,以及是否存在事务失败日志。',
'最后检查回调地址是否可达:外网能否访问 https://域名/api/payment/callback/{渠道},网关/防火墙是否放行。'
],
tip: '排查顺序建议:支付单状态 → 渠道查询 → 后端回调日志 → 回调地址连通性。'
},
{
key: 'faq-callback-repeat',
tag: '回调',
tagType: 'warning',
question: '渠道回调会重复推送吗?会不会重复记账?',
answer: [
'会。微信、支付宝、银联都会在收到非成功响应或网络异常时按策略重推同一条通知(支付宝最长 24 小时,微信为 15s/15s/30s… 逐步拉长)。',
'因此回调处理必须是幂等的:以「渠道 + 渠道流水号」做唯一键,重复通知直接返回成功响应,不再重复更新订单或重复生成佣金。'
],
steps: [
'确认回调表/支付单表在 (channel, channel_trade_no) 上有唯一索引。',
'回调处理第一步做去重判定:已处理过则直接返回渠道要求成功响应(微信 SUCCESS / 支付宝 success / 银联 ok)。',
'状态更新使用条件更新(如 WHERE status <> paid),避免并发下重复触发业务动作。',
'佣金生成挂在支付成功事件上,同样按业务订单号去重,保证一笔订单只生成一张佣金单。'
],
tip: '自测方法:在回调处理里临时打印日志,用渠道后台的「重发通知」按钮推同一条通知 3 次,确认只产生一次状态变更与一张佣金单。'
},
{
key: 'faq-commission',
tag: '佣金',
tagType: 'primary',
question: '推广佣金怎么发?和租户付款是什么关系?',
answer: [
'佣金是平台自有资金支出,与租户付款资金流完全解耦:租户的钱进平台对公账户,佣金由平台另行向推广方支付,两者不共用一笔资金,也不做分账。',
'佣金的产生由「佣金规则」决定:业务订单支付成功后,匹配启用中的规则(按比例 / 固定金额 / 阶梯)计算金额并生成佣金单,状态为「应付」。'
],
steps: [
'在「佣金规则」中配置规则:按比例填百分比,固定金额填元(系统按分存储),阶梯配置各金额区间的比例,并选择适用订单类型。',
'业务订单支付成功后自动生成佣金单,出现在「佣金台账」中,状态为「应付」。',
'财务实际打款后,在台账中对佣金单点「标记已付」,填写发放方式与流水号(税务处理中的先标记为对应状态)。',
'顶部统计卡片可查看应付总额、已付总额与待处理笔数,作为付款计划依据。'
],
tip: '修改规则只影响之后生成的佣金单,历史佣金单不会被追溯调整;如需调整历史数据,请单独走人工核对。'
},
{
key: 'faq-new-channel',
tag: '扩展',
tagType: 'info',
question: '要新增一个支付渠道(比如新增数字人民币)怎么做?',
answer: [
'渠道是插件式接入:后端实现一个适配器接口,前端在渠道配置目录下新增一个配置页,其余页面(订单列表、对账、首页概览)只需在渠道枚举里补一项即可自动适配。'
],
steps: [
'后端 internal/payment/channel/ 下新增 <channel>.go,实现统一下单、查询、回调验签、退款(如需)四个方法。',
'在支付服务的适配器工厂里注册该渠道,并在渠道枚举与配置表里补一条记录。',
'前端 src/views/system/payment/channels/ 下新增配置页(可复制现有渠道页改造),字段按渠道要求定义,敏感字段使用掩码回显。',
'在 channels 目录对应的渠道枚举(首页概览、订单列表、对账、使用说明)中补充渠道名称与图标。',
'补充回调路由 POST /api/payment/callback/<channel>,并确认渠道后台已登记该地址。',
'联调通过后先用 1 分钱真实下单验证,再启用渠道。'
],
tip: '不要在各业务页面里写渠道 if-else:渠道差异必须收敛在适配器与配置页内,否则后续维护成本会指数上升。'
},
{
key: 'faq-reconcile',
tag: '对账',
tagType: 'danger',
question: '对账出现差异怎么处理?',
answer: [
'对账差异分三类:长款(渠道有、本地无,疑似漏单)、短款(本地有、渠道无,疑似用户未真正付款成功)、状态不一致(金额一致但状态不同,通常是回调丢失或延迟)。',
'所有差异都需要人工核对后在对账差异页标记处理,并填写处理说明,系统不会自动改账。'
],
steps: [
'按差异类型筛选,先处理「长款」:到渠道后台用渠道流水号核对,确认租户确实付款后,按业务订单号补记支付单并置为已支付。',
'再处理「短款」:确认渠道侧确实无此笔成功记录,通常是用户未完成付款或支付超时,将本地支付单置为已关闭即可。',
'处理「状态不一致」:直接用支付单详情页的「手动查询渠道状态」同步,多数情况会立即一致。',
'点击「标记处理」,填写处理说明(如:已补单 / 已联系渠道核实 / 账单重复记录已忽略),便于日后追溯。',
'同一差异反复出现时,检查回调地址连通性与定时补偿任务是否正常执行。'
],
tip: '对账日期为渠道账单的自然日,跨日退款的差异会在下一个对账日体现,属于正常现象,不要手工改账。'
},
{
key: 'faq-domain',
tag: '回调',
tagType: 'info',
question: '域名更换或回调地址调整后要注意什么?',
answer: [
'回调地址是渠道侧登记的固定地址,更换域名后必须同步更新各渠道的登记信息,否则通知会全部丢失,只能依赖手动查询兜底。'
],
steps: [
'微信:异步通知地址随请求携带,无需登记,但要确认 JSAPI 支付授权目录已更新为新域名。',
'支付宝:异步通知地址随请求携带,同样无需登记,确认新域名可公网访问即可。',
'银联 / 云闪付:必须向收单机构重新报备回调地址,报备通过后才会推送通知。',
'PayPal:在 App 的 Webhooks 中更新地址,并重新复制 Webhook ID 核对。',
'切换后立即用 1 分钱下单验证回调可达,并观察回调日志。'
],
tip: '建议保留旧域名的回调路由至少一个版本周期,避免切换期间在途订单通知丢失。'
},
{
key: 'faq-cert',
tag: '凭证',
tagType: 'warning',
question: '银联证书或微信证书过期会有什么表现?',
answer: [
'证书过期后渠道侧会直接拒绝请求,表现为:下单报错、回调验签失败、主动查询接口报证书不合法。支付单会卡在「待支付 / 支付中」。'
],
steps: [
'记录各渠道证书到期日,提前 1 个月申请新证书。',
'在对应渠道配置页重新上传证书文件并保存,然后点「测试连接」确认可用。',
'证书更新后重启一次支付相关服务,确保后端重新加载证书。',
'用 1 分钱真实下单验证下单与回调链路。'
],
tip: 'APIv3 密钥与证书是两套东西:换了证书不用换 APIv3 密钥,别混着一起改。'
},
{
key: 'faq-test-ok',
tag: '排查',
tagType: 'warning',
question: '「测试连接」成功,但真实支付失败,为什么?',
answer: [
'测试连接只校验凭证与接口鉴权,不覆盖下列与实际收款相关的配置,因此测试通过不代表能真实收款。'
],
steps: [
'检查渠道是否已启用(渠道配置页的启用状态与首页概览状态)。',
'检查 APPID / 商户号绑定关系、支付授权目录(微信 JSAPI)是否与收银台域名一致。',
'检查证书是否已上传且未过期,私钥与上传到渠道侧的公钥是否配对。',
'检查回调地址是否公网可达、是否被网关的鉴权拦截导致渠道收到非成功响应。',
'查看后端日志中渠道返回的原始错误码,按渠道文档对照处理。'
],
tip: '联调阶段建议固定使用 1 分钱的最小金额,避免误产生大额测试交易。'
}
]
</script>
<style scoped lang="less">
.doc-wrap {
.block-card {
.card-title {
font-size: 15px;
font-weight: 600;
}
}
}
.q-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.answer {
padding-left: 4px;
.para {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.steps-title {
margin: 12px 0 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.step-list {
margin: 0 0 10px;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.tip {
margin-top: 8px;
}
}
</style>
@@ -0,0 +1,339 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>支付模块使用说明</h2>
<p class="sub-title">
模块定位、渠道配置、代码引用位置与调用示例,新同学按左侧顺序读一遍即可完成一次接入。
</p>
</div>
<el-divider></el-divider>
<div class="guide-layout">
<!-- 左侧导航 -->
<el-menu :default-active="active" class="guide-menu" @select="handleSelect">
<el-menu-item v-for="item in navItems" :key="item.key" :index="item.key">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
<!-- 右侧内容 -->
<div class="guide-content">
<!-- 1. 模块总览(内联) -->
<div v-if="active === 'overview'">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">模块定位</span>
</template>
<p class="para">
支付模块负责<b>平台向租户收取平台使用费</b>的收款链路(下单、拉起支付、渠道回调、订单状态同步、日终对账),
以及<b>推广佣金的台账与发放登记</b>。模块只服务平台端收款,不参与租户与他方之间的交易资金流转。
</p>
<ul class="dot-list">
<li><b>收款方:</b>平台主体,款项直接进入平台对公账户,<b>不涉及分账、不涉及资金池</b>。</li>
<li><b>退款:</b>业务上不做退款入口,仅在后台保留「手动原路退回」兜底能力,需人工确认后出账。</li>
<li><b>佣金:</b>推广佣金由平台自有资金支出,与租户收款解耦,单独台账、单独发放。</li>
<li><b>跨境:</b>当前业务不跨境,PayPal 仅作为预留渠道,默认停用。</li>
</ul>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">资金流向</span>
</template>
<div class="flow">
<div class="flow-node">租户下单<br /><span>(平台使用费)</span></div>
<div class="flow-arrow">→</div>
<div class="flow-node">支付渠道<br /><span>微信 / 支付宝 / 银联 / 云闪付</span></div>
<div class="flow-arrow">→</div>
<div class="flow-node highlight">平台对公账户<br /><span>直接到账,不经过平台其他账户</span></div>
<div class="flow-arrow">→</div>
<div class="flow-node">系统记账<br /><span>支付单 + 业务订单状态</span></div>
</div>
<el-alert
class="flow-tip"
type="info"
:closable="false"
show-icon
title="分账说明:平台收款即终结,不存在「收款后再分给租户」或「平台代收代付」场景,因此支付模块不实现分账能力。"
/>
<p class="para">
推广佣金是<b>反向资金流</b>:由平台从自有资金中向推广方支付,与租户付款、渠道结算完全解耦,
因此在菜单上佣金台账与支付订单是并列的两个功能,而不是支付单的下级。
</p>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道清单</span>
</template>
<el-table :data="channels" stripe style="width: 100%">
<el-table-column prop="name" label="渠道" width="140" />
<el-table-column prop="scope" label="覆盖场景" min-width="220" />
<el-table-column prop="status" label="当前状态" width="140" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="channel" />
</template>
</el-table-column>
<el-table-column prop="remark" label="说明" min-width="260" show-overflow-tooltip />
</el-table>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">核心概念:业务订单 vs 支付单</span>
</template>
<el-table :data="concepts" border style="width: 100%">
<el-table-column prop="dimension" label="对比维度" width="160" />
<el-table-column prop="biz" label="业务订单(out_trade_no)" min-width="280" />
<el-table-column prop="pay" label="支付单(pay_no)" min-width="280" />
</el-table>
<el-alert
class="flow-tip"
type="warning"
:closable="false"
show-icon
title="幂等要点:同一业务订单可存在多笔支付单(超时关闭后重新下单),只有状态为 paid 的支付单才算收款成功;佣金按业务订单支付成功事件生成。"
/>
</el-card>
</div>
<!-- 2. 渠道配置说明 -->
<ConfigDoc v-else-if="active === 'config'" />
<!-- 3/4/5. 哪里引用了支付 / 后端接口调用 / 前端调用示例 -->
<UsageDoc v-else-if="isUsage" :scroll-to="anchor" />
<!-- 6. 常见问题 -->
<FaqDoc v-else-if="active === 'faq'" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Document, Setting, Link, Connection, Monitor, QuestionFilled } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
import ConfigDoc from './config.vue'
import UsageDoc from './usage.vue'
import FaqDoc from './faq.vue'
const active = ref('overview')
const navItems = [
{ key: 'overview', title: '支付模块总览', icon: Document },
{ key: 'config', title: '渠道配置说明', icon: Setting },
{ key: 'refs', title: '哪里引用了支付', icon: Link },
{ key: 'api', title: '后端接口调用', icon: Connection },
{ key: 'frontend', title: '前端调用示例', icon: Monitor },
{ key: 'faq', title: '常见问题', icon: QuestionFilled }
]
const isUsage = computed(() => ['refs', 'api', 'frontend'].includes(active.value))
/** 三个引用/示例导航共用 usage.vue,通过锚点滚动到对应区块 */
const anchor = computed(() => {
if (active.value === 'refs') return 'usage-ref'
if (active.value === 'api') return 'usage-chain'
return 'usage-frontend'
})
function handleSelect(key: string) {
active.value = key
}
const channels = [
{
name: '微信支付',
scope: '公众号 JSAPI、PC 扫码 Native',
status: 'enabled',
remark: '平台使用费主渠道,回调地址 /api/payment/callback/wechat'
},
{
name: '支付宝',
scope: '电脑网站支付、手机网站支付',
status: 'enabled',
remark: 'RSA2 签名,异步通知无需在开放平台登记'
},
{
name: '银联',
scope: 'PC 网关支付、手机控件支付',
status: 'unconfigured',
remark: '需收单机构下发商户号与证书,回调地址需报备'
},
{
name: '云闪付',
scope: '云闪付 App 扫码 / 被扫',
status: 'disabled',
remark: '复用银联商户参数,仅控制收银台标识展示'
},
{
name: 'PayPal',
scope: '海外租户信用卡 / 余额支付',
status: 'disabled',
remark: '跨境预留渠道,当前业务不跨境,默认关闭'
}
]
const concepts = [
{
dimension: '定义',
biz: '租户在平台产生的一笔应付费用(如年度平台使用费)',
pay: '针对某个业务订单发起的一次收款尝试'
},
{
dimension: '主键',
biz: 'out_trade_no(业务系统生成,全局唯一)',
pay: 'pay_no(支付模块生成,形如 P202609150009)'
},
{
dimension: '状态机',
biz: '待支付 → 已支付 / 已取消 / 已关闭',
pay: 'created → pending → paying → paid / failed / closed(+ refunded)'
},
{
dimension: '金额',
biz: '订单金额(分),与套餐价格一致',
pay: '实付金额(分),正常情况下与业务订单金额相等'
},
{
dimension: '谁维护',
biz: '业务模块(租户/订单)维护,支付成功后由回调驱动变更',
pay: '支付模块维护,渠道回调与主动查询双写'
},
{
dimension: '与佣金关系',
biz: '支付成功后按佣金规则生成佣金单',
pay: '本身不产生佣金,佣金与收款资金解耦'
}
]
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.guide-layout {
display: flex;
align-items: flex-start;
gap: 20px;
}
.guide-menu {
width: 220px;
flex-shrink: 0;
border-right: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
}
.guide-content {
flex: 1;
min-width: 0;
}
.block-card {
margin-bottom: 20px;
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.para {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.9;
color: #606266;
b {
color: #303133;
}
}
.dot-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
b {
color: #303133;
}
}
.flow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
.flow-node {
min-width: 150px;
padding: 12px 16px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
text-align: center;
line-height: 1.6;
span {
font-size: 12px;
font-weight: 400;
color: #909399;
}
&.highlight {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
}
}
.flow-arrow {
color: #c0c4cc;
font-size: 16px;
}
}
.flow-tip {
margin-top: 12px;
}
@media (max-width: 1200px) {
.guide-layout {
flex-direction: column;
}
.guide-menu {
width: 100%;
border-right: none;
border-bottom: 1px solid #ebeef5;
}
}
</style>
@@ -0,0 +1,495 @@
<template>
<div class="doc-wrap">
<!-- A + B:引用位置 -->
<section :ref="setSectionRef('usage-ref')" class="section-block">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">A. 后端 Go 引用位置</span>
</template>
<el-table :data="goRefs" size="small" border style="width: 100%">
<el-table-column prop="path" label="文件 / 目录" width="290">
<template #default="{ row }">
<code class="path">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="desc" label="职责说明" min-width="360" />
</el-table>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">B. 前端 Vite 引用位置</span>
</template>
<el-table :data="feRefs" size="small" border style="width: 100%">
<el-table-column prop="path" label="文件 / 目录" width="290">
<template #default="{ row }">
<code class="path">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="desc" label="职责说明" min-width="360" />
</el-table>
</el-card>
</section>
<!-- C:调用链 + 接口约定 -->
<section :ref="setSectionRef('usage-chain')" class="section-block">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-1. 收款调用链</span>
</template>
<div class="chain">
<div v-for="(step, index) in chain" :key="index" class="chain-item">
<div class="chain-index">{{ index + 1 }}</div>
<div class="chain-body">
<div class="chain-text">{{ step.text }}</div>
<div class="chain-note">{{ step.note }}</div>
</div>
<div v-if="index < chain.length - 1" class="chain-line"></div>
</div>
</div>
<el-alert
class="tip"
type="info"
:closable="false"
show-icon
title="状态以服务端为准:前端拉起的支付结果、轮询结果都只是展示,最终以渠道回调(或后端主动查询)写入的支付单状态为准。"
/>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-2. 后端接口约定</span>
</template>
<el-table :data="apiList" size="small" border style="width: 100%">
<el-table-column prop="method" label="方法" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="methodTag(row.method)" effect="plain">{{ row.method }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" width="270">
<template #default="{ row }">
<code class="path">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="params" label="关键参数" min-width="220" />
<el-table-column prop="desc" label="说明" min-width="260" />
</el-table>
<el-alert
class="tip"
type="warning"
:closable="false"
show-icon
title="前缀说明:约定里写作 /api/payment/*,本项目平台端接口统一走 /platform 前缀(dev 代理只转发 /platform、/backend),前端封装里用常量 PAY 统一控制,后端路由确定后只改一处。"
/>
</el-card>
</section>
<!-- C-3:前端调用示例 -->
<section :ref="setSectionRef('usage-frontend')" class="section-block">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-3. 前端代码示例:接口封装</span>
</template>
<pre class="code-block">{{ apiCode }}</pre>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-4. 前端代码示例:租户端下单页调用(Vue 3 script setup)</span>
</template>
<pre class="code-block">{{ pageCode }}</pre>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-5. 平台端主动查询 / 退回示例</span>
</template>
<pre class="code-block">{{ adminCode }}</pre>
</el-card>
</section>
</div>
</template>
<script setup lang="ts">
import { nextTick, watch } from 'vue'
const props = defineProps<{
/** 需要滚动到的区块 id:usage-ref / usage-chain / usage-frontend */
scrollTo?: string
}>()
const sectionEls: Record<string, HTMLElement | null> = {}
function setSectionRef(key: string) {
return (el: any) => {
sectionEls[key] = (el as HTMLElement) || null
}
}
function scrollToSection(key: string) {
nextTick(() => {
sectionEls[key]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
watch(
() => props.scrollTo,
(key) => {
if (key) scrollToSection(key)
},
{ immediate: true }
)
/* ---------------- A. 后端引用位置 ---------------- */
const goRefs = [
{
path: 'internal/payment/service.go',
desc: '支付服务核心:创建支付单、查询支付单、状态机流转;根据 channel 选择对应渠道适配器,统一对外暴露 Pay / Query 方法。'
},
{
path: 'internal/payment/channel/wechat.go',
desc: '微信支付适配器:统一下单参数组装、APIv3 签名、证书加载、回调报文解密与验签,返回支付参数(code_url / JSAPI 参数)。'
},
{
path: 'internal/payment/channel/alipay.go',
desc: '支付宝适配器:RSA2 签名与验签、电脑/手机网站支付参数组装、异步通知解析。'
},
{
path: 'internal/payment/callback.go',
desc: '回调统一入口:按渠道验签 → 以渠道流水号做幂等去重 → 更新支付单状态 → 同步业务订单状态 → 触发佣金规则,处理成功按渠道约定返回(SUCCESS / success / ok)。'
},
{
path: 'internal/payment/reconcile.go',
desc: '日终对账:拉取渠道账单文件、与本地支付流水按流水号比对,产出长款 / 短款 / 状态不一致三类差异并落库。'
},
{
path: 'internal/commission/',
desc: '佣金域:规则匹配(按比例 / 固定金额 / 阶梯)、佣金单生成、发放登记与状态维护;资金来自平台自有,与收款链路解耦。'
}
]
/* ---------------- B. 前端引用位置 ---------------- */
const feRefs = [
{
path: 'src/api/payment.js',
desc: '支付模块接口封装:createPayment / queryPayment / refundPayment / getOrders / getOrderDetail / 渠道配置 / 佣金 / 对账,统一走 @/utils/request。'
},
{
path: 'src/views/system/payment/',
desc: '平台端支付管理页面(本模块):首页概览、渠道配置、支付订单、佣金台账与规则、对账差异、使用说明。'
},
{
path: 'src/views/tenant/order/',
desc: '租户端下单页:选择套餐与支付渠道,调用 createPayment 拿到渠道参数后拉起支付,并用 queryPayment 做结果兜底轮询。'
}
]
/* ---------------- C-1. 调用链 ---------------- */
const chain = [
{
text: '租户端下单页 → src/api/payment.js 的 createPayment()',
note: '前端只提交业务订单号、订单类型、金额(分)与渠道,不接触任何渠道密钥'
},
{
text: '后端 POST /api/payment/create',
note: '支付服务校验业务订单、金额与渠道开关,选择渠道适配器'
},
{
text: '渠道适配器向渠道下单,返回支付参数',
note: '微信返回 code_url / JSAPI 参数,支付宝返回跳转表单,银联返回网关地址'
},
{
text: '前端拉起支付(扫码 / 跳转 / JSAPI)',
note: '用户完成付款,此时前端只展示「支付中」,不落最终状态'
},
{
text: '渠道异步回调 POST /api/payment/callback/:channel',
note: '验签 → 幂等去重 → 支付单置为 paid → 业务订单同步为已支付'
},
{
text: '触发佣金规则,生成佣金单',
note: '佣金为平台自有资金支出,独立台账,与收款资金流解耦'
},
{
text: '日终对账,产出差异',
note: '渠道账单与本地流水比对,差异进入对账差异页人工处理'
}
]
/* ---------------- C-2. 接口约定 ---------------- */
const apiList = [
{
method: 'POST',
path: '/api/payment/create',
params: 'out_trade_no、order_type、amount(分)、channel、subject',
desc: '创建支付单并返回渠道支付参数(pay_no + pay_params)'
},
{
method: 'GET',
path: '/api/payment/query/:payNo',
params: 'payNo、sync(可选)',
desc: '查询支付单;sync=true 时后端主动向渠道查询并同步最新状态'
},
{
method: 'POST',
path: '/api/payment/callback/:channel',
params: '渠道原始报文(渠道服务器调用,前端不调用)',
desc: '渠道异步通知入口:验签、幂等、更新支付单与业务订单'
},
{
method: 'POST',
path: '/api/payment/refund',
params: 'pay_no、amount(分)、reason',
desc: '手动原路退回(兜底能力,需人工确认,不做业务退款流程)'
},
{
method: 'GET',
path: '/api/payment/orders',
params: 'page、pageSize、tenant_id、channel、status、start_time、end_time',
desc: '支付单列表(平台端订单管理、首页最近交易)'
},
{
method: 'GET',
path: '/api/commission/list',
params: 'page、pageSize、promoter、status、start_time、end_time',
desc: '佣金台账列表;统计接口 /api/commission/stats 返回应付 / 已付 / 待处理'
}
]
const methodTag = (method: string) => (method === 'GET' ? 'success' : 'primary')
/* ---------------- C-3/C-4/C-5. 代码示例 ---------------- */
const apiCode = `// src/api/payment.js(节选)
import request from "@/utils/request";
// 接口前缀:约定文档为 /api/payment,本项目平台端统一 /platform 前缀,改这一处即可
const PAY = "/platform/payment";
/**
* 创建支付单:返回渠道支付参数,前端据此拉起支付
* @param {Object} data { out_trade_no, order_type, amount(分), channel, subject }
*/
export function createPayment(data) {
return request({ url: \`\${PAY}/create\`, method: "post", data });
}
/**
* 查询支付单:sync=true 时后端会主动向渠道同步最新状态
* @param {string} payNo 支付单号
*/
export function queryPayment(payNo, params = {}) {
return request({ url: \`\${PAY}/query/\${payNo}\`, method: "get", params });
}
/** 手动原路退回(兜底) */
export function refundPayment(data) {
return request({ url: \`\${PAY}/refund\`, method: "post", data });
}`
const pageCode = `<!-- src/views/tenant/order/index.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { createPayment, queryPayment } from "@/api/payment";
const channels = [
{ label: "微信支付", value: "wechat" },
{ label: "支付宝", value: "alipay" },
{ label: "银联", value: "unionpay" }
];
const loading = ref(false);
const payNo = ref("");
async function handlePay(channel: string) {
loading.value = true;
try {
// 1. 创建支付单:金额单位统一为「分」
const res = await createPayment({
out_trade_no: "ORD202609150001",
order_type: "platform_usage",
amount: 299900, // 2999.00 元
channel,
subject: "平台使用费 - 2026 年度"
});
if (!res?.success) {
ElMessage.error(res?.message || "下单失败");
return;
}
payNo.value = res.data.pay_no;
// 2. 用渠道返回的参数拉起支付
await launchChannelPay(res.data.channel, res.data.pay_params);
// 3. 前端轮询仅用于展示,最终状态以服务端回调结果为准
await pollStatus();
} catch (error: any) {
ElMessage.error(error?.message || "支付发起失败");
} finally {
loading.value = false;
}
}
/** 渠道差异封装在各自适配逻辑里,页面只调用统一入口 */
async function launchChannelPay(channel: string, payParams: Record<string, any>) {
if (channel === "wechat" && payParams.code_url) {
// 展示二维码,用户扫码支付(Native)
renderQrCode(payParams.code_url);
return;
}
if (channel === "alipay" && payParams.form) {
// 支付宝返回自动提交表单:插入容器后触发表单提交即可跳转
const container = document.createElement("div");
container.innerHTML = payParams.form;
document.body.appendChild(container);
(container.querySelector("form") as HTMLFormElement)?.submit();
}
}
/** 兜底轮询:每 3 秒查一次,最多 20 次 */
async function pollStatus() {
for (let i = 0; i < 20; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const res = await queryPayment(payNo.value, { sync: true });
const status = res?.data?.status;
if (status === "paid") {
ElMessage.success("支付成功");
return;
}
if (status === "failed" || status === "closed") {
ElMessage.error("支付未完成,请重新下单");
return;
}
}
ElMessage.warning("支付结果确认中,可稍后在订单列表查看");
}
function renderQrCode(_url: string) {
// 使用项目内二维码组件渲染
}
<\/script>`
const adminCode = `// src/views/system/payment/orders/detail.vue(节选)
import { ElMessage, ElMessageBox } from "element-plus";
import { queryPayment, refundPayment } from "@/api/payment";
/** 手动查询渠道状态:回调丢失时的兜底手段 */
async function handleQueryChannel() {
const res = await queryPayment(payNo.value, { sync: true });
if (res?.data?.status === "paid") {
ElMessage.success("渠道状态已同步为支付成功");
}
await loadDetail();
}
/** 手动原路退回:仅平台管理员使用,需填写原因 */
async function handleManualRefund() {
const { value: reason } = await ElMessageBox.prompt("请输入退回原因", "手动原路退回", {
inputValidator: (val: string) => (val?.trim() ? true : "退回原因不能为空")
});
await refundPayment({ pay_no: detail.pay_no, amount: detail.amount, reason });
ElMessage.success("退回申请已提交,请以渠道退回结果为准");
}`
</script>
<style scoped lang="less">
.doc-wrap {
.section-block {
scroll-margin-top: 20px;
}
.block-card {
margin-bottom: 20px;
.card-title {
font-size: 15px;
font-weight: 600;
}
}
}
.path {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
font-family: Consolas, monospace;
font-size: 12px;
color: #f56c6c;
}
.chain {
.chain-item {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 18px;
}
.chain-index {
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
background: #409eff;
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.chain-body {
padding-top: 1px;
}
.chain-text {
font-size: 13px;
font-weight: 600;
color: #303133;
line-height: 1.7;
}
.chain-note {
margin-top: 2px;
font-size: 12px;
color: #909399;
line-height: 1.7;
}
.chain-line {
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 1px;
background: #dcdfe6;
}
}
.tip {
margin-top: 12px;
}
.code-block {
margin: 0;
padding: 16px 18px;
background: #282c34;
border-radius: 6px;
color: #abb2bf;
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
line-height: 1.75;
max-height: 520px;
overflow: auto;
white-space: pre;
tab-size: 2;
}
</style>
+549
View File
@@ -0,0 +1,549 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>支付管理</h2>
<p class="sub-title">
平台向租户收取平台使用费,款项直接进入平台对公账户;不涉及分账与业务退款(仅保留后台手动原路退回兜底),推广佣金为平台自有资金支出。
</p>
</div>
<div class="header-actions">
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 一、渠道状态概览 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">渠道状态概览</span>
<div class="card-header-right">
<span class="card-tip">共 {{ channelList.length }} 个渠道,已启用 {{ enabledCount }} 个</span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="table">表格</el-radio-button>
<el-radio-button value="card">卡片</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<el-table
v-if="viewMode === 'table'"
:data="channelList"
stripe
v-loading="loading"
style="width: 100%"
>
<el-table-column label="渠道名称" min-width="180">
<template #default="{ row }">
<div class="channel-cell">
<el-icon :size="16" class="channel-icon">
<component :is="channelIcon(row.icon)" />
</el-icon>
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="channel" />
</template>
</el-table-column>
<el-table-column label="商户号" min-width="200">
<template #default="{ row }">
<span v-if="row.merchant_no">{{ row.merchant_no }}</span>
<span v-else class="text-muted">未配置</span>
</template>
</el-table-column>
<el-table-column label="说明" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goChannelConfig(row)">
<el-icon><Setting /></el-icon>
配置
</el-button>
<el-button
v-if="row.status === 'enabled'"
size="small"
type="danger"
link
@click="toggleChannelStatus(row, false)"
>
停用
</el-button>
<el-button
v-else
size="small"
type="success"
link
:disabled="row.status === 'unconfigured'"
@click="toggleChannelStatus(row, true)"
>
启用
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="channel-card-grid" v-loading="loading">
<ChannelCard
v-for="row in channelList"
:key="row.channel"
:name="row.name"
:status="row.status"
:merchant-no="row.merchant_no"
:icon="row.icon"
:desc="row.remark"
@config="goChannelConfig(row)"
@enable="toggleChannelStatus(row, true)"
@disable="toggleChannelStatus(row, false)"
/>
</div>
</el-card>
<!-- 二、快捷入口 -->
<div class="entry-grid">
<el-card
v-for="entry in entries"
:key="entry.key"
shadow="hover"
class="entry-card"
@click="goEntry(entry.path)"
>
<div class="entry-body">
<div class="entry-icon" :style="{ background: entry.color }">
<el-icon :size="22"><component :is="entry.icon" /></el-icon>
</div>
<div class="entry-text">
<div class="entry-title">{{ entry.title }}</div>
<div class="entry-desc">{{ entry.desc }}</div>
</div>
</div>
</el-card>
</div>
<!-- 三、最近交易 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">最近交易(最近 10 条)</span>
<el-button size="small" type="primary" link @click="goEntry(ROUTES.orders)">
查看全部
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</template>
<el-table :data="recentOrders" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="tenant_name" label="租户" min-width="200" show-overflow-tooltip />
<el-table-column label="金额" width="140" align="right">
<template #default="{ row }">
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="渠道" width="140" align="center">
<template #default="{ row }">{{ channelName(row.channel) }}</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="order" />
</template>
</el-table-column>
<el-table-column label="时间" width="180" align="center">
<template #default="{ row }">{{ row.create_time || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goOrderDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Refresh,
Setting,
ArrowRight,
Tickets,
Coin,
DataAnalysis,
Document,
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money
} from '@element-plus/icons-vue'
import StatusTag from './components/StatusTag.vue'
import ChannelCard from './components/ChannelCard.vue'
// TODO: 接口联调时打开
// import { getChannelList, getOrders, toggleChannel } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
/** 渠道概览视图:table 表格 / card 卡片 */
const viewMode = ref<'table' | 'card'>('table')
/** TODO: 路由 path 需与你配置的菜单 path 保持一致,例如实际为 /payment/orders 时请同步修改 */
const ROUTES = {
orders: '/system/payment/orders',
orderDetail: '/system/payment/orders/detail',
commission: '/system/payment/commission',
reconcile: '/system/payment/reconcile',
guide: '/system/payment/guide',
channels: {
wechat: '/system/payment/channels/wechat',
alipay: '/system/payment/channels/alipay',
unionpay: '/system/payment/channels/unionpay',
cloudpay: '/system/payment/channels/cloudpay',
paypal: '/system/payment/channels/paypal'
} as Record<string, string>
}
/** 渠道图标映射(图标名与 ChannelCard 组件保持一致) */
const CHANNEL_ICONS: Record<string, any> = {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money
}
const channelIcon = (icon: string) => CHANNEL_ICONS[icon] || Money
/* ---------------- 渠道概览 ---------------- */
interface ChannelRow {
channel: string
name: string
status: 'enabled' | 'disabled' | 'unconfigured'
merchant_no: string
icon: string
remark: string
}
const channelList = ref<ChannelRow[]>([
{
channel: 'wechat',
name: '微信支付',
status: 'enabled',
merchant_no: '1620888999',
icon: 'ChatDotRound',
remark: 'JSAPI / Native 扫码收款,平台使用费主渠道'
},
{
channel: 'alipay',
name: '支付宝',
status: 'enabled',
merchant_no: '2088123456789012',
icon: 'Wallet',
remark: '电脑网站支付 / 手机网站支付'
},
{
channel: 'unionpay',
name: '银联',
status: 'unconfigured',
merchant_no: '',
icon: 'CreditCard',
remark: '银联全渠道收单,需上传商户证书'
},
{
channel: 'cloudpay',
name: '云闪付',
status: 'disabled',
merchant_no: '898110158000000',
icon: 'Lightning',
remark: '复用银联商户参数,仅控制收银台是否展示云闪付标识'
},
{
channel: 'paypal',
name: 'PayPal',
status: 'disabled',
merchant_no: '',
icon: 'Money',
remark: '面向港澳台及海外租户,当前无跨境需求,默认关闭'
}
])
const enabledCount = computed(() => channelList.value.filter((i) => i.status === 'enabled').length)
const channelName = (channel: string) =>
channelList.value.find((i) => i.channel === channel)?.name || channel
/* ---------------- 快捷入口 ---------------- */
const entries = [
{
key: 'orders',
title: '支付订单',
desc: '支付单、业务订单与状态流转',
icon: Tickets,
color: 'linear-gradient(135deg, #409eff, #79bbff)',
path: ROUTES.orders
},
{
key: 'commission',
title: '推广佣金',
desc: '佣金台账、规则与发放登记',
icon: Coin,
color: 'linear-gradient(135deg, #e6a23c, #f3d19e)',
path: ROUTES.commission
},
{
key: 'reconcile',
title: '对账管理',
desc: '渠道账单与本地的差异核对',
icon: DataAnalysis,
color: 'linear-gradient(135deg, #67c23a, #95de64)',
path: ROUTES.reconcile
},
{
key: 'guide',
title: '使用说明',
desc: '渠道配置、调用链与常见问题',
icon: Document,
color: 'linear-gradient(135deg, #909399, #b1b3b8)',
path: ROUTES.guide
}
]
/* ---------------- 最近交易 ---------------- */
interface RecentOrder {
pay_no: string
tenant_name: string
amount: number
channel: string
status: string
create_time: string
}
const recentOrders = ref<RecentOrder[]>([])
/** 最近 10 条:TODO 换成 getOrders({ page: 1, pageSize: 10 }) */
function loadRecentOrders() {
recentOrders.value = [
{ pay_no: 'P202609150009', tenant_name: '杭州云泽科技有限公司', amount: 299900, channel: 'wechat', status: 'paid', create_time: '2026-09-15 10:24:31' },
{ pay_no: 'P202609150008', tenant_name: '宁波海曙贸易有限公司', amount: 99900, channel: 'alipay', status: 'paid', create_time: '2026-09-15 09:58:02' },
{ pay_no: 'P202609150007', tenant_name: '上海弘远信息技术有限公司', amount: 199900, channel: 'wechat', status: 'pending', create_time: '2026-09-15 09:31:47' },
{ pay_no: 'P202609150006', tenant_name: '深圳市博通电子商务有限公司', amount: 499900, channel: 'alipay', status: 'paid', create_time: '2026-09-15 09:12:15' },
{ pay_no: 'P202609150005', tenant_name: '北京中科智联科技有限公司', amount: 99900, channel: 'wechat', status: 'failed', create_time: '2026-09-15 08:56:39' },
{ pay_no: 'P202609140021', tenant_name: '成都天府软件有限公司', amount: 299900, channel: 'wechat', status: 'paid', create_time: '2026-09-14 18:42:10' },
{ pay_no: 'P202609140020', tenant_name: '武汉光谷数字科技有限公司', amount: 199900, channel: 'alipay', status: 'closed', create_time: '2026-09-14 17:20:55' },
{ pay_no: 'P202609140019', tenant_name: '西安丝路网络科技有限公司', amount: 99900, channel: 'wechat', status: 'paid', create_time: '2026-09-14 16:05:23' },
{ pay_no: 'P202609140018', tenant_name: '青岛海洋装备有限公司', amount: 599900, channel: 'alipay', status: 'paid', create_time: '2026-09-14 14:48:07' },
{ pay_no: 'P202609140017', tenant_name: '长沙湘江云计算有限公司', amount: 299900, channel: 'wechat', status: 'refunded', create_time: '2026-09-14 11:33:41' }
]
}
/* ---------------- 数据加载与操作 ---------------- */
async function fetchChannels() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelList()
// if (res.success) channelList.value = res.data.list || res.data || []
}
async function fetchData() {
loading.value = true
try {
await Promise.all([fetchChannels(), Promise.resolve(loadRecentOrders())])
} catch (error: any) {
ElMessage.error(error?.message || '加载支付概览失败')
} finally {
loading.value = false
}
}
async function refresh() {
await fetchData()
ElMessage.success('刷新成功')
}
function goChannelConfig(row: ChannelRow) {
const path = ROUTES.channels[row.channel]
if (!path) {
ElMessage.warning('该渠道配置页路由未配置')
return
}
router.push(path)
}
function goEntry(path?: string) {
if (!path) return
router.push(path)
}
function goOrderDetail(row: RecentOrder) {
router.push({ path: ROUTES.orderDetail, query: { payNo: row.pay_no } })
}
async function toggleChannelStatus(row: ChannelRow, enable: boolean) {
// TODO: 接口联通后替换为 toggleChannel(row.channel, enable)
row.status = enable ? 'enabled' : 'disabled'
ElMessage.success(`${row.name}已${enable ? '启用' : '停用'}`)
}
/* ---------------- 工具函数 ---------------- */
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
}
.block-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.card-tip {
font-size: 12px;
color: #909399;
}
}
.channel-card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.channel-cell {
display: flex;
align-items: center;
gap: 8px;
.channel-icon {
color: #409eff;
}
}
.text-muted {
color: #909399;
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.entry-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.entry-card {
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
}
.entry-body {
display: flex;
align-items: center;
gap: 12px;
}
.entry-icon {
width: 44px;
height: 44px;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.entry-text {
min-width: 0;
}
.entry-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.entry-desc {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
}
@media (max-width: 1200px) {
.entry-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,416 @@
<template>
<div class="container-box" v-loading="loading">
<el-page-header :content="payNo || '支付订单详情'" @back="goBack">
<template #extra>
<div class="header-actions">
<el-button :loading="querying" @click="handleQueryChannel">
<el-icon><Refresh /></el-icon>
手动查询渠道状态
</el-button>
<el-button type="danger" plain :disabled="!canRefund" @click="handleManualRefund">
手动原路退回
</el-button>
<el-button @click="goBack">返回列表</el-button>
</div>
</template>
</el-page-header>
<el-divider></el-divider>
<!-- 支付单基本信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">支付单信息</span>
<StatusTag :status="detail.status" dict="order" />
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="支付单号">{{ detail.pay_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付状态">
<StatusTag :status="detail.status" dict="order" />
</el-descriptions-item>
<el-descriptions-item label="支付金额">
<span class="amount">¥{{ fenToYuan(detail.amount) }}</span>
<span class="text-muted">({{ detail.amount }} 分)</span>
</el-descriptions-item>
<el-descriptions-item label="币种">{{ detail.currency || 'CNY' }}</el-descriptions-item>
<el-descriptions-item label="支付渠道">{{ channelLabel(detail.channel) }}</el-descriptions-item>
<el-descriptions-item label="渠道流水号">{{ detail.channel_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.create_time || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付完成时间">{{ detail.paid_at || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 业务订单信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">业务订单信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="业务订单号">{{ detail.out_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单类型">{{ orderTypeLabel(detail.order_type) }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ detail.tenant_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户编号">{{ detail.tenant_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单金额">
<span class="amount">¥{{ fenToYuan(detail.order_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="业务订单状态">
<StatusTag :status="detail.order_status" dict="order" />
</el-descriptions-item>
<el-descriptions-item label="订单摘要" :span="2">
{{ detail.subject || '-' }}
</el-descriptions-item>
</el-descriptions>
<el-alert
class="order-tip"
type="info"
:closable="false"
show-icon
title="业务订单与支付单是两张表:一个业务订单可能对应多笔支付单(如超时重下),以状态为 paid 的支付单为准。"
/>
</el-card>
<!-- 渠道信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="渠道">{{ channelLabel(detail.channel) }}</el-descriptions-item>
<el-descriptions-item label="商户号">{{ detail.merchant_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道流水号">{{ detail.channel_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="回调时间">{{ detail.notify_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="回调地址" :span="2">
{{ detail.notify_url || '-' }}
</el-descriptions-item>
<el-descriptions-item label="最近查询时间" :span="2">
{{ detail.last_query_at || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="raw-block">
<div class="raw-title">渠道回调原文(脱敏后摘要)</div>
<pre class="raw-json">{{ detail.callback_raw || '暂无回调数据' }}</pre>
</div>
</el-card>
<!-- 状态流转记录 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">状态流转记录</span>
</template>
<el-timeline v-if="detail.records && detail.records.length">
<el-timeline-item
v-for="(item, index) in detail.records"
:key="index"
:timestamp="item.create_time"
placement="top"
>
<div class="record-row">
<StatusTag :status="item.to_status" dict="order" />
<span class="record-remark">{{ item.remark }}</span>
</div>
<div class="record-operator">操作来源:{{ item.operator }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无流转记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getOrderDetail, queryPayment, refundPayment } from '@/api/payment'
const route = useRoute()
const router = useRouter()
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const LIST_PATH = '/system/payment/orders'
const payNo = computed(() => String(route.query.payNo || ''))
/** 从佣金台账等入口跳转时可能只带业务订单号 */
const outTradeNoQuery = computed(() => String(route.query.outTradeNo || ''))
const loading = ref(false)
const querying = ref(false)
/** 状态流转记录:对应 yz_platform_payment_order_log(to_status / operator / remark / create_time) */
interface RecordItem {
to_status: string
create_time: string
operator: string
remark: string
}
const detail = reactive<Record<string, any>>({
pay_no: '',
status: '',
amount: 0,
currency: 'CNY',
channel: '',
channel_trade_no: '',
create_time: '',
paid_at: '',
out_trade_no: '',
order_type: 'platform_usage',
tenant_name: '',
tenant_id: '',
order_amount: 0,
order_status: '',
subject: '',
merchant_no: '',
notify_at: '',
notify_url: '',
last_query_at: '',
callback_raw: '',
records: [] as RecordItem[]
})
/** 仅支付成功且未退款时允许手动原路退回(兜底能力) */
const canRefund = computed(() => detail.status === 'paid')
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
const orderTypeMap: Record<string, string> = {
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/* ---------------- 数据加载 ---------------- */
async function loadDetail() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求(后端支持按 payNo 或 outTradeNo 查询)
// const res = await getOrderDetail(payNo.value || outTradeNoQuery.value)
// if (res.success) Object.assign(detail, res.data)
Object.assign(detail, {
pay_no: payNo.value || 'P202609150009',
out_trade_no: outTradeNoQuery.value || 'ORD202609150001',
status: 'paid',
amount: 299900,
currency: 'CNY',
channel: 'wechat',
channel_trade_no: '4200001234202609150987654321',
create_time: '2026-09-15 10:23:12',
paid_at: '2026-09-15 10:24:31',
out_trade_no: 'ORD202609150001',
order_type: 'platform_usage',
tenant_name: '杭州云泽科技有限公司',
tenant_id: 'T10001',
order_amount: 299900,
order_status: 'paid',
subject: '平台使用费 - 2026 年度(标准版)',
merchant_no: '1620888999',
notify_at: '2026-09-15 10:24:33',
notify_url: `${window.location.origin}/api/payment/callback/wechat`,
last_query_at: '2026-09-15 10:25:02',
callback_raw: JSON.stringify(
{
id: 'evt_20260915102431001',
event_type: 'TRANSACTION.SUCCESS',
resource: {
out_trade_no: 'ORD202609150001',
transaction_id: '4200001234202609150987654321',
trade_state: 'SUCCESS',
amount: { total: 299900, payer_total: 299900, currency: 'CNY' }
}
},
null,
2
),
records: [
{
to_status: 'created',
create_time: '2026-09-15 10:23:12',
operator: '租户端下单页',
remark: '创建支付单,渠道:微信支付(Native 扫码)'
},
{
to_status: 'paying',
create_time: '2026-09-15 10:23:35',
operator: '渠道回调',
remark: '用户扫码完成付款,渠道返回支付中'
},
{
to_status: 'paid',
create_time: '2026-09-15 10:24:31',
operator: '渠道回调',
remark: '支付成功,订单金额 299900 分,已同步业务订单状态为已支付'
}
] as RecordItem[]
})
} catch (error: any) {
ElMessage.error(error?.message || '获取支付单详情失败')
} finally {
loading.value = false
}
}
/* ---------------- 操作 ---------------- */
/** 手动查询渠道状态:主动调用渠道查询接口并回写本地状态(兜底回调丢失) */
async function handleQueryChannel() {
querying.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await queryPayment(payNo.value, { sync: true })
await new Promise((resolve) => setTimeout(resolve, 600))
detail.last_query_at = new Date().toLocaleString('zh-CN')
ElMessage.success('渠道状态已查询:SUCCESS(本地状态已同步)')
await loadDetail()
} catch (error: any) {
ElMessage.error(error?.message || '查询渠道状态失败')
} finally {
querying.value = false
}
}
/** 手动原路退回(兜底,不做业务层退款流程) */
async function handleManualRefund() {
let reason = ''
try {
const { value } = await ElMessageBox.prompt(
`确定对支付单 ${detail.pay_no} 发起原路退回吗?金额 ¥${fenToYuan(detail.amount)},此操作会真实出账且不可撤销。`,
'手动原路退回',
{
type: 'warning',
inputPlaceholder: '请输入退回原因(必填)',
inputValidator: (val: string) => (val && val.trim() ? true : '退回原因不能为空'),
confirmButtonText: '确认退回'
}
)
reason = value
} catch {
return
}
try {
// TODO: 接口联通后替换为真实请求
// await refundPayment({ pay_no: detail.pay_no, amount: detail.amount, reason })
detail.status = 'refunding'
detail.records.unshift({
to_status: 'refunding',
create_time: new Date().toLocaleString('zh-CN'),
operator: '平台管理员',
remark: `手动原路退回:${reason}`
})
ElMessage.success('退回申请已提交,请以渠道退回结果为准')
} catch (error: any) {
ElMessage.error(error?.message || '退回失败')
}
}
function goBack() {
router.push(LIST_PATH)
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
loadDetail()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
.block-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.text-muted {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.order-tip {
margin-top: 12px;
}
.raw-block {
margin-top: 16px;
.raw-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.raw-json {
margin: 0;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 6px;
font-size: 12px;
line-height: 1.7;
color: #606266;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
}
.record-row {
display: flex;
align-items: center;
gap: 8px;
}
.record-remark {
font-size: 13px;
color: #303133;
}
.record-operator {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
</style>
@@ -0,0 +1,352 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>支付订单</h2>
<div class="header-actions">
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="租户">
<el-select
v-model="filters.tenant_id"
placeholder="全部租户"
clearable
filterable
remote
:remote-method="searchTenant"
:loading="tenantLoading"
style="width: 220px"
>
<el-option
v-for="item in tenantOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="filters.channel" placeholder="全部渠道" clearable style="width: 160px">
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width: 160px">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表 -->
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="pay_no" label="支付单号" min-width="180">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goDetail(row)">
{{ row.pay_no }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="out_trade_no" label="业务订单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="tenant_name" label="租户" min-width="200" show-overflow-tooltip />
<el-table-column label="金额" width="130" align="right">
<template #default="{ row }">
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="渠道" width="120" align="center">
<template #default="{ row }">{{ channelName(row.channel) }}</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="order" />
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getOrders } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
const tenantLoading = ref(false)
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const DETAIL_PATH = '/system/payment/orders/detail'
const filters = reactive<{
tenant_id: string | number | ''
channel: string
status: string
dateRange: [string, string] | []
}>({
tenant_id: '',
channel: '',
status: '',
dateRange: []
})
const page = reactive({ current: 1, size: 10, total: 0 })
const channelOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '云闪付', value: 'cloudpay' },
{ label: 'PayPal', value: 'paypal' }
]
const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '支付中', value: 'paying' },
{ label: '支付成功', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已关闭', value: 'closed' },
{ label: '已退款', value: 'refunded' }
]
const tenantOptions = ref([
{ id: 'T10001', name: '杭州云泽科技有限公司' },
{ id: 'T10002', name: '宁波海曙贸易有限公司' },
{ id: 'T10003', name: '上海弘远信息技术有限公司' },
{ id: 'T10004', name: '深圳市博通电子商务有限公司' }
])
const channelName = (channel: string) =>
channelOptions.find((i) => i.value === channel)?.label || channel
/** 租户远程搜索:TODO 替换为 getTenantList({ keyword }) */
async function searchTenant(keyword: string) {
tenantLoading.value = true
try {
const all = [
{ id: 'T10001', name: '杭州云泽科技有限公司' },
{ id: 'T10002', name: '宁波海曙贸易有限公司' },
{ id: 'T10003', name: '上海弘远信息技术有限公司' },
{ id: 'T10004', name: '深圳市博通电子商务有限公司' },
{ id: 'T10005', name: '北京中科智联科技有限公司' },
{ id: 'T10006', name: '成都天府软件有限公司' }
]
tenantOptions.value = keyword ? all.filter((i) => i.name.includes(keyword)) : all
} finally {
tenantLoading.value = false
}
}
/* ---------------- Mock 数据(本地筛选 + 分页) ---------------- */
interface OrderRow {
pay_no: string
out_trade_no: string
tenant_id: string
tenant_name: string
amount: number
channel: string
status: string
create_time: string
}
const ALL_ORDERS: OrderRow[] = Array.from({ length: 26 }).map((_, index) => {
const channels = ['wechat', 'alipay', 'unionpay', 'cloudpay']
const statuses = ['paid', 'paid', 'paid', 'pending', 'failed', 'closed', 'refunded']
const tenants = [
{ id: 'T10001', name: '杭州云泽科技有限公司' },
{ id: 'T10002', name: '宁波海曙贸易有限公司' },
{ id: 'T10003', name: '上海弘远信息技术有限公司' },
{ id: 'T10004', name: '深圳市博通电子商务有限公司' },
{ id: 'T10005', name: '北京中科智联科技有限公司' },
{ id: 'T10006', name: '成都天府软件有限公司' }
]
const amounts = [99900, 199900, 299900, 499900, 599900]
const tenant = tenants[index % tenants.length]
const day = String(15 - Math.floor(index / 6)).padStart(2, '0')
const hour = String(20 - (index % 12)).padStart(2, '0')
const minute = String((index * 7) % 60).padStart(2, '0')
return {
pay_no: `P202609${day}${String(1000 + index)}`,
out_trade_no: `ORD202609${day}${String(2000 + index)}`,
tenant_id: tenant.id,
tenant_name: tenant.name,
amount: amounts[index % amounts.length],
channel: channels[index % channels.length],
status: statuses[index % statuses.length],
create_time: `2026-09-${day} ${hour}:${minute}:00`
}
})
const tableData = ref<OrderRow[]>([])
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getOrders({
// page: page.current,
// pageSize: page.size,
// tenant_id: filters.tenant_id,
// channel: filters.channel,
// status: filters.status,
// start_time: filters.dateRange?.[0],
// end_time: filters.dateRange?.[1]
// })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const [start, end] = filters.dateRange || []
const filtered = ALL_ORDERS.filter((item) => {
if (filters.tenant_id && item.tenant_id !== filters.tenant_id) return false
if (filters.channel && item.channel !== filters.channel) return false
if (filters.status && item.status !== filters.status) return false
const date = item.create_time.slice(0, 10)
if (start && date < start) return false
if (end && date > end) return false
return true
})
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取支付订单失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.tenant_id = ''
filters.channel = ''
filters.status = ''
filters.dateRange = []
page.current = 1
fetchList()
}
async function refresh() {
await fetchList()
ElMessage.success('刷新成功')
}
function goDetail(row: OrderRow) {
router.push({ path: DETAIL_PATH, query: { payNo: row.pay_no } })
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
@@ -0,0 +1,461 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>对账差异</h2>
<p class="sub-title">
按日拉取渠道账单与本地支付流水比对,差异需人工核对后标记处理,避免漏单与重复入账。
</p>
</div>
<div class="header-actions">
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-alert
class="reconcile-tip"
type="warning"
:closable="false"
show-icon
title="差异类型说明:长款=渠道有、本地无(疑似漏单);短款=本地有、渠道无(疑似未成功);状态不一致=金额一致但状态不同。"
/>
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="渠道">
<el-select v-model="filters.channel" placeholder="全部渠道" clearable style="width: 160px">
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="对账日期">
<el-date-picker
v-model="filters.bill_date"
type="date"
placeholder="选择对账日期"
value-format="YYYY-MM-DD"
style="width: 180px"
/>
</el-form-item>
<el-form-item label="差异类型">
<el-select v-model="filters.diff_type" placeholder="全部类型" clearable style="width: 160px">
<el-option label="长款" value="long" />
<el-option label="短款" value="short" />
<el-option label="状态不一致" value="status_mismatch" />
</el-select>
</el-form-item>
<el-form-item label="处理状态">
<el-select v-model="filters.handle_status" placeholder="全部状态" clearable style="width: 160px">
<el-option label="待处理" value="unhandled" />
<el-option label="处理中" value="handling" />
<el-option label="已处理" value="handled" />
<el-option label="已忽略" value="ignored" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 差异列表 -->
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column label="渠道" width="120" align="center">
<template #default="{ row }">{{ channelLabel(row.channel) }}</template>
</el-table-column>
<el-table-column prop="bill_date" label="对账日期" width="130" align="center" />
<el-table-column prop="local_trade_no" label="本地流水号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.local_trade_no">{{ row.local_trade_no }}</span>
<span v-else class="text-muted">无本地记录</span>
</template>
</el-table-column>
<el-table-column prop="channel_trade_no" label="渠道流水号" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.channel_trade_no">{{ row.channel_trade_no }}</span>
<span v-else class="text-muted">无渠道记录</span>
</template>
</el-table-column>
<el-table-column label="本地金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.local_amount !== null">¥{{ fenToYuan(row.local_amount) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="渠道金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.channel_amount !== null">¥{{ fenToYuan(row.channel_amount) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="差异类型" width="180">
<template #default="{ row }">
<StatusTag :status="row.diff_type" dict="diff" />
<span class="diff-amount">{{ diffAmountText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="处理状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.handle_status" dict="reconcile" />
</template>
</el-table-column>
<el-table-column label="处理备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.handle_status === 'unhandled' || row.handle_status === 'handling'"
size="small"
type="primary"
link
@click="handleMarkHandled(row)"
>
标记处理
</el-button>
<span v-else class="text-muted">已处理</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getReconcileList, markReconcileHandled } from '@/api/payment'
const loading = ref(false)
const filters = reactive({
channel: '',
bill_date: '',
diff_type: '',
handle_status: ''
})
const page = reactive({ current: 1, size: 10, total: 0 })
const channelOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '云闪付', value: 'cloudpay' },
{ label: 'PayPal', value: 'paypal' }
]
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
/* ---------------- Mock 数据(本地筛选 + 分页) ---------------- */
interface ReconcileRow {
id: number
channel: string
bill_date: string
local_trade_no: string
channel_trade_no: string
local_amount: number | null
channel_amount: number | null
diff_type: 'long' | 'short' | 'status_mismatch'
handle_status: 'unhandled' | 'handling' | 'handled' | 'ignored'
remark: string
}
const ALL_DIFFS: ReconcileRow[] = [
{
id: 1,
channel: 'wechat',
bill_date: '2026-09-14',
local_trade_no: '',
channel_trade_no: '4200001234202609140011223344',
local_amount: null,
channel_amount: 299900,
diff_type: 'long',
handle_status: 'unhandled',
remark: ''
},
{
id: 2,
channel: 'alipay',
bill_date: '2026-09-14',
local_trade_no: 'P202609140012',
channel_trade_no: '',
local_amount: 99900,
channel_amount: null,
diff_type: 'short',
handle_status: 'unhandled',
remark: ''
},
{
id: 3,
channel: 'wechat',
bill_date: '2026-09-13',
local_trade_no: 'P202609130031',
channel_trade_no: '4200001234202609130055667788',
local_amount: 199900,
channel_amount: 199900,
diff_type: 'status_mismatch',
handle_status: 'handled',
remark: '渠道已成功,本地回调丢失,已手动查询同步为已支付'
},
{
id: 4,
channel: 'unionpay',
bill_date: '2026-09-13',
local_trade_no: 'P202609130028',
channel_trade_no: 'UP2026091300286547',
local_amount: 499900,
channel_amount: 499900,
diff_type: 'status_mismatch',
handle_status: 'handling',
remark: '已联系收单机构核实,等待回复'
},
{
id: 5,
channel: 'alipay',
bill_date: '2026-09-12',
local_trade_no: '',
channel_trade_no: '2026091222001445123456789012',
local_amount: null,
channel_amount: 99900,
diff_type: 'long',
handle_status: 'ignored',
remark: '渠道账单重复记录(已确认同一笔订单),无需处理'
},
{
id: 6,
channel: 'wechat',
bill_date: '2026-09-12',
local_trade_no: 'P202609120009',
channel_trade_no: '',
local_amount: 299900,
channel_amount: null,
diff_type: 'short',
handle_status: 'handled',
remark: '用户超时未支付,本地已置为已关闭'
},
{
id: 7,
channel: 'cloudpay',
bill_date: '2026-09-11',
local_trade_no: 'P202609110004',
channel_trade_no: 'UP2026091100043321',
local_amount: 599900,
channel_amount: 599900,
diff_type: 'status_mismatch',
handle_status: 'handled',
remark: '银联通道时报状态延迟,次日后台查询已一致'
},
{
id: 8,
channel: 'wechat',
bill_date: '2026-09-11',
local_trade_no: 'P202609110002',
channel_trade_no: '4200001234202609110099001122',
local_amount: 199900,
channel_amount: 199800,
diff_type: 'short',
handle_status: 'unhandled',
remark: ''
}
]
const tableData = ref<ReconcileRow[]>([])
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getReconcileList({
// page: page.current,
// pageSize: page.size,
// channel: filters.channel,
// bill_date: filters.bill_date,
// diff_type: filters.diff_type,
// handle_status: filters.handle_status
// })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const filtered = ALL_DIFFS.filter((item) => {
if (filters.channel && item.channel !== filters.channel) return false
if (filters.bill_date && item.bill_date !== filters.bill_date) return false
if (filters.diff_type && item.diff_type !== filters.diff_type) return false
if (filters.handle_status && item.handle_status !== filters.handle_status) return false
return true
})
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取对账差异失败')
} finally {
loading.value = false
}
}
/* ---------------- 操作 ---------------- */
async function handleMarkHandled(row: ReconcileRow) {
let remark = ''
try {
const { value } = await ElMessageBox.prompt('请填写处理说明(会记录到备注,便于日后追溯)', '标记处理', {
type: 'warning',
inputPlaceholder: '如:已手动补单/已联系渠道核实/已忽略',
inputValidator: (val: string) => (val && val.trim() ? true : '请填写处理说明'),
confirmButtonText: '确认'
})
remark = value
} catch {
return
}
try {
// TODO: 接口联通后替换为 markReconcileHandled(row.id, { handle_type: 'manual', remark })
row.handle_status = 'handled'
row.remark = remark
ElMessage.success('已标记为已处理')
await fetchList()
} catch (error: any) {
ElMessage.error(error?.message || '标记失败')
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.channel = ''
filters.bill_date = ''
filters.diff_type = ''
filters.handle_status = ''
page.current = 1
fetchList()
}
async function refresh() {
await fetchList()
ElMessage.success('刷新成功')
}
/** 差异金额文案:本地与渠道金额都有的才显示差额 */
function diffAmountText(row: ReconcileRow) {
if (row.local_amount === null || row.channel_amount === null) return ''
const diff = row.channel_amount - row.local_amount
if (diff === 0) return ''
const sign = diff > 0 ? '+' : '-'
return `(差额 ${sign}¥${fenToYuan(Math.abs(diff))})`
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.reconcile-tip {
margin-bottom: 20px;
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.text-muted {
color: #909399;
}
.diff-amount {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>