增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+615
View File
@@ -0,0 +1,615 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services/payment"
"github.com/beego/beego/v2/client/orm"
beelog "github.com/beego/beego/v2/core/logs"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformPaymentController 平台端支付管理(渠道配置 / 支付订单 / 兜底退回 / 渠道回调)
type PlatformPaymentController struct {
beego.Controller
}
func (c *PlatformPaymentController) platformClaims() (*jwtutil.Claims, error) {
return paymentPlatformClaims(&c.Controller)
}
// paymentPlatformClaims 平台端鉴权(支付相关控制器共用),传各控制器内嵌的 &c.Controller
func paymentPlatformClaims(c *beego.Controller) (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *PlatformPaymentController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformPaymentController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// channelSecretKeys 各渠道的敏感参数键(接口回显时只回掩码;提交掩码/空串表示不修改)
var channelSecretKeys = map[string][]string{
payment.ChannelWechat: {"api_v3_key"},
payment.ChannelAlipay: {"app_private_key"},
payment.ChannelUnionPay: {"cert_password"},
payment.ChannelCloudPay: {"cert_password"},
payment.ChannelPayPal: {"client_secret"},
}
func channelRowDTO(row *models.PlatformPaymentChannel) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "channel": row.Channel, "name": row.Name,
"merchant_no": row.MerchantNo, "callback_url": row.CallbackURL,
"enabled": row.Enabled == 1,
"remark": row.Remark,
"last_test_time": row.LastTestTime, "last_test_result": row.LastTestResult,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// maskedConfigView 解密配置并掩码敏感字段,供前端回显
func maskedConfigView(cfg *payment.ChannelConfig, row *models.PlatformPaymentChannel) map[string]interface{} {
view := map[string]interface{}{}
for k, v := range cfg.Params {
view[k] = v
}
for _, key := range channelSecretKeys[row.Channel] {
if v, ok := view[key]; ok {
if s, ok2 := v.(string); ok2 && s != "" {
view[key] = payment.MaskSecret(s)
}
}
}
return view
}
func findChannelRow(channel string) (*models.PlatformPaymentChannel, error) {
row := &models.PlatformPaymentChannel{}
err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Filter("delete_time__isnull", true).
One(row)
return row, err
}
type paymentChannelPayload struct {
Name string `json:"name"`
MerchantNo string `json:"merchant_no"`
CallbackURL string `json:"callback_url"`
Remark string `json:"remark"`
Config map[string]string `json:"config"`
Extra map[string]any `json:"extra"`
}
// loadMergedConfig 加载已存配置并把「新填写且非掩码」的值合并进去(用于保存与测试连接共用)
func loadMergedConfig(channel string, p *paymentChannelPayload) (*payment.ChannelConfig, error) {
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
return nil, err
}
if p == nil {
return cfg, nil
}
for k, v := range p.Config {
v = strings.TrimSpace(v)
if v == "" || payment.IsMasked(v) {
continue // 空串 / 掩码 = 保持原值
}
cfg.Params[k] = v
}
if p.Extra != nil {
cfg.Extra = p.Extra
}
return cfg, nil
}
// ListChannels GET /platform/payment/channels 渠道概览
func (c *PlatformPaymentController) ListChannels() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.PlatformPaymentChannel
_, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询渠道失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformPaymentChannel{}
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, channelRowDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": len(list)})
}
// GetChannel GET /platform/payment/channels/:channel 渠道配置详情(敏感字段掩码)
func (c *PlatformPaymentController) GetChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
row, err := findChannelRow(channel)
if err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
dto := channelRowDTO(row)
dto["config"] = maskedConfigView(cfg, row)
dto["extra"] = cfg.Extra
dto["cert_files"] = cfg.CertPaths
c.ok(dto)
}
// SaveChannel POST /platform/payment/channels/:channel 保存渠道配置(敏感字段留空/掩码表示不修改)
func (c *PlatformPaymentController) SaveChannel() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
if _, err := findChannelRow(channel); err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p paymentChannelPayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
merged, err := loadMergedConfig(channel, &p)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
plain, _ := json.Marshal(merged.Params)
enc, err := payment.EncryptConfig(string(plain))
if err != nil {
c.jsonErr(500, 500, "渠道参数加密失败: "+err.Error())
return
}
extraJSON := ""
if p.Extra != nil {
b, _ := json.Marshal(p.Extra)
extraJSON = string(b)
}
now := time.Now()
updates := orm.Params{
"config_json": enc,
"merchant_no": strings.TrimSpace(p.MerchantNo),
"remark": strings.TrimSpace(p.Remark),
"update_time": now,
}
if p.Name != "" {
updates["name"] = strings.TrimSpace(p.Name)
}
if p.CallbackURL != "" {
updates["callback_url"] = strings.TrimSpace(p.CallbackURL)
}
if extraJSON != "" {
updates["extra_json"] = extraJSON
}
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(updates); err != nil {
c.jsonErr(500, 500, "保存失败: "+err.Error())
return
}
_ = claims
row, _ := findChannelRow(channel)
c.ok(channelRowDTO(row))
}
// TestChannel POST /platform/payment/channels/:channel/test 测试连接(支持未保存参数)
func (c *PlatformPaymentController) TestChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p paymentChannelPayload
_ = json.Unmarshal(body, &p) // 允许空 body:只测已保存配置
merged, err := loadMergedConfig(channel, &p)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
adapter, err := payment.GetChannelAdapter(channel)
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
msg, err := adapter.TestConnect(ctx, merged)
now := time.Now()
result := msg
if err != nil {
result = err.Error()
}
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"last_test_time": now, "last_test_result": result, "update_time": now})
if err != nil {
c.jsonErr(400, 400, result)
return
}
c.ok(map[string]interface{}{"message": result})
}
// ToggleChannel POST /platform/payment/channels/:channel/status 启用/停用
func (c *PlatformPaymentController) ToggleChannel() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Enabled *bool `json:"enabled"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Enabled == nil {
c.jsonErr(400, 400, "参数错误")
return
}
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
if *p.Enabled && len(cfg.Params) == 0 {
c.jsonErr(400, 400, "渠道参数未配置,无法启用")
return
}
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"enabled": b2i(*p.Enabled), "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "操作失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"enabled": *p.Enabled})
}
func b2i(v bool) int8 {
if v {
return 1
}
return 0
}
// UploadCertificate POST /platform/payment/channels/:channel/certificate 上传渠道证书
// 证书文件保存在服务端 certs/payment/<channel>/ 下(0600 权限,不对外提供访问),
// 文件路径写入 cert_json,密钥内容绝不入库、不回显。
func (c *PlatformPaymentController) UploadCertificate() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
channel := c.Ctx.Input.Param(":channel")
if _, err := findChannelRow(channel); err != nil {
c.jsonErr(404, 404, "渠道不存在")
return
}
certType := strings.TrimSpace(c.GetString("cert_type"))
if certType == "" {
certType = "cert"
}
file, header, err := c.GetFile("file")
if err != nil || header == nil {
c.jsonErr(400, 400, "请选择证书文件")
return
}
defer func() { _ = file.Close() }()
dir := filepath.Join("certs", "payment", channel)
if err := os.MkdirAll(dir, 0o700); err != nil {
c.jsonErr(500, 500, "创建证书目录失败: "+err.Error())
return
}
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" {
ext = ".pem"
}
name := fmt.Sprintf("%s_%d%s", certType, time.Now().UnixNano(), ext)
savePath := filepath.Join(dir, name)
out, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
c.jsonErr(500, 500, "保存证书失败: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
_ = out.Close()
c.jsonErr(500, 500, "保存证书失败: "+err.Error())
return
}
_ = out.Close()
// 更新 cert_json
cfg, err := payment.LoadChannelConfig(channel)
if err != nil {
c.jsonErr(500, 500, err.Error())
return
}
if cfg.CertPaths == nil {
cfg.CertPaths = map[string]string{}
}
certKey := certType + "_path"
cfg.CertPaths[certKey] = savePath
if _, err := os.Stat(savePath); err == nil {
// 同步补充对应关系:cert -> cert_path,key -> key_path
if certType == "cert" {
cfg.CertPaths["cert_path"] = savePath
} else {
cfg.CertPaths["key_path"] = savePath
}
}
certJSON, _ := json.Marshal(cfg.CertPaths)
if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)).
Filter("channel", channel).
Update(orm.Params{"cert_json": string(certJSON), "update_time": time.Now()}); err != nil {
c.jsonErr(500, 500, "更新证书信息失败: "+err.Error())
return
}
c.ok(map[string]interface{}{"file": header.Filename, "path": savePath, "cert_type": certType})
}
/* ---------------- 支付订单 ---------------- */
func paymentOrderDTO(row *models.PlatformPaymentOrder) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "pay_no": row.PayNo, "out_trade_no": row.OutTradeNo,
"order_type": row.OrderType, "subject": row.Subject,
"tenant_id": row.TenantID, "tenant_name": row.TenantName,
"amount": row.Amount, "order_amount": row.OrderAmount, "currency": row.Currency,
"channel": row.Channel, "merchant_no": row.MerchantNo, "channel_trade_no": row.ChannelTradeNo,
"status": row.Status, "refund_amount": row.RefundAmount,
"return_url": row.ReturnURL, "notify_url": row.NotifyURL,
"expire_at": row.ExpireAt, "paid_at": row.PaidAt, "closed_at": row.ClosedAt,
"notify_at": row.NotifyAt, "last_query_at": row.LastQueryAt,
"order_synced": row.OrderSynced,
"promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
// ListOrders GET /platform/payment/orders 支付单列表
func (c *PlatformPaymentController) ListOrders() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := strconv.Atoi(c.GetString("page", "1"))
pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 10
}
qs := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).Filter("delete_time__isnull", true)
if v := strings.TrimSpace(c.GetString("tenant_id")); v != "" {
qs = qs.Filter("tenant_id", v)
}
if v := strings.TrimSpace(c.GetString("out_trade_no")); v != "" {
qs = qs.Filter("out_trade_no__icontains", v)
}
if v := strings.TrimSpace(c.GetString("pay_no")); v != "" {
qs = qs.Filter("pay_no__icontains", v)
}
if v := strings.TrimSpace(c.GetString("channel")); v != "" {
qs = qs.Filter("channel", v)
}
if v := strings.TrimSpace(c.GetString("status")); v != "" {
qs = qs.Filter("status", v)
}
if v := strings.TrimSpace(c.GetString("start_time")); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
qs = qs.Filter("create_time__gte", t)
}
}
if v := strings.TrimSpace(c.GetString("end_time")); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1))
}
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
var rows []models.PlatformPaymentOrder
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, paymentOrderDTO(&rows[i]))
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// OrderDetail GET /platform/payment/orders/:payNo 支付单详情(含状态流转 + 最近回调原文)
func (c *PlatformPaymentController) OrderDetail() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
row, err := payment.GetOrderByPayNo(payNo)
if err != nil {
c.jsonErr(404, 404, "支付单不存在")
return
}
dto := paymentOrderDTO(row)
// 状态流转记录
var logs []models.PlatformPaymentOrderLog
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrderLog)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&logs)
records := make([]map[string]interface{}, 0, len(logs))
for i := range logs {
records = append(records, map[string]interface{}{
"to_status": logs[i].ToStatus, "operator": logs[i].Operator,
"remark": logs[i].Remark, "create_time": logs[i].CreateTime,
})
}
dto["records"] = records
// 最近一次成功回调的原文(来自 yz_platform_payment_callback_log.raw_body)
var cb models.PlatformPaymentCallbackLog
err = models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)).
Filter("pay_no", payNo).
Filter("delete_time__isnull", true).
OrderBy("-id").
One(&cb)
if err == nil && cb.RawBody != nil {
dto["callback_raw"] = *cb.RawBody
dto["notify_at"] = cb.CreateTime
}
c.ok(dto)
}
// QueryOrder POST /platform/payment/orders/:payNo/query 手动查询渠道状态(兜底回调丢失)
func (c *PlatformPaymentController) QueryOrder() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
payNo := c.Ctx.Input.Param(":payNo")
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Sync *bool `json:"sync"`
}
_ = json.Unmarshal(body, &p)
sync := true
if p.Sync != nil {
sync = *p.Sync
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
row, err := payment.QueryPayment(ctx, payNo, sync)
if err != nil {
c.jsonErr(404, 404, err.Error())
return
}
c.ok(paymentOrderDTO(row))
}
// Refund POST /platform/payment/refund 手动原路退回(兜底能力)
func (c *PlatformPaymentController) Refund() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
PayNo string `json:"pay_no"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(body, &p); err != nil || strings.TrimSpace(p.PayNo) == "" {
c.jsonErr(400, 400, "参数错误")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
refund, err := payment.ManualRefund(ctx, payment.RefundInput{
PayNo: strings.TrimSpace(p.PayNo),
Amount: p.Amount,
Reason: strings.TrimSpace(p.Reason),
OperatorID: fmt.Sprintf("%d", claims.UserID),
OperatorName: claims.Username,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"refund_no": refund.RefundNo, "status": refund.Status,
"amount": refund.Amount, "channel_refund_no": refund.ChannelRefundNo,
"fail_reason": refund.FailReason,
})
}
// Notify POST /platform/payment/callback/:channel 渠道异步通知入口(渠道服务器调用,无平台鉴权)
// 处理成功时按渠道约定返回:微信 {"code":"SUCCESS"} / 支付宝 success / 银联与 PayPal ok / 200 空体。
func (c *PlatformPaymentController) Notify() {
channel := c.Ctx.Input.Param(":channel")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ack, err := payment.HandleNotify(ctx, channel, c.Ctx.Request, c.Ctx.Input.IP())
if err != nil {
beelog.Warn("支付回调处理失败: channel=%s err=%v", channel, err)
c.Ctx.Output.SetStatus(http.StatusInternalServerError)
_, _ = c.Ctx.ResponseWriter.Write([]byte("FAIL"))
return
}
if ack == "" {
ack = "ok"
}
c.Ctx.Output.SetStatus(http.StatusOK)
_, _ = c.Ctx.ResponseWriter.Write([]byte(ack))
}