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// 下(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)) }