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