Files
yunzerwebsiteallinone/go/controllers/platform_payment.go
T

645 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{} {
hasConfig := (row.ConfigJSON != nil && *row.ConfigJSON != "" && *row.ConfigJSON != "{}") || row.MerchantNo != ""
if row.Channel == payment.ChannelCloudPay {
hasConfig = true
}
status := "unconfigured"
if row.Enabled == 1 {
status = "enabled"
} else if hasConfig {
status = "disabled"
}
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,
"status": status,
"has_config": hasConfig,
"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)
}
merchantNo := strings.TrimSpace(p.MerchantNo)
if merchantNo == "" && merged != nil {
switch channel {
case payment.ChannelPayPal:
merchantNo = merged.Get("client_id")
case payment.ChannelWechat:
merchantNo = merged.Get("mch_id")
case payment.ChannelAlipay:
merchantNo = merged.Get("app_id")
case payment.ChannelUnionPay, payment.ChannelCloudPay:
merchantNo = merged.Get("mer_id")
}
}
now := time.Now()
updates := orm.Params{
"config_json": enc,
"merchant_no": merchantNo,
"update_time": now,
}
if p.Remark != "" {
updates["remark"] = strings.TrimSpace(p.Remark)
}
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))
}