Files
sundynix-agentix/sundynix-gateway/internal/handler/payment_admin.go
T
Blizzard d1e1e0fc4a feat(billing): 微信支付配置进 DB —— admin 控制面保存即热生效
用户定的形态:配置存数据库、密钥文件放服务器磁盘(库里只存路径)。
env 降级为兜底(DB 优先 → env → 隐藏,与 tokens_per_credit 同一约定)。

- payment 包重构:Config(6 字段)+ Manager(RWMutex 热重载,学 prompt 控制面
  改完即生效不重启);未启用原因人话化(未配置/缺哪些字段/初始化失败具体错)。
- APIv3 密钥入库前 AES-GCM 加密(shared/secrets,与模型 API Key 同一把
  SUNDYNIX_SECRET_KEY);GET 只回 has_apiv3_key 不回显;PUT 留空=沿用旧密钥
  (只写不回显语义,同模型 Key)。
- admin GET/PUT /admin/payment/wechat;业务路径全部改经 Manager.Current()
  取快照(BillingPacks/下单/查单/回调)。
- admin 计费页「微信支付配置」卡片:状态徽章(已启用/未启用+原因)+六字段
  +保存并热生效。
live:无配置→「未配置」;存假配置→热重载报「私钥加载失败:decode err」;
去掉 appid→「配置不全,缺: appid」;密钥留空沿用(has_apiv3_key 保持 true);
psql 复核库内密文 enc:1: 前缀、不含明文子串。go/tsc/41 vitest 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:55:30 +08:00

109 lines
3.7 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 handler
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/payment"
"github.com/sundynix/sundynix-shared/secrets"
)
// 微信支付配置控制面:配置进 DBsundynix_setting)、改完热生效不重启;
// APIv3 密钥入库前 AES-GCM 加密(与模型 API Key 同一把 SUNDYNIX_SECRET_KEY),
// 私钥文件留在服务器磁盘,库里只存路径。env 仍作兜底(DB 优先 → env → 隐藏)。
// SettingWechatPay 是 settings KV 里的键,值为 payment.Config 的 JSONapiv3_key 为密文)。
const SettingWechatPay = "payment_wechat"
// loadWechatConfig 读支付配置:DB 优先(解密 apiv3),空则回退 env。
func (h *Handler) loadWechatConfig(ctx context.Context) payment.Config {
raw := h.db.GetSetting(ctx, SettingWechatPay)
if raw == "" {
return payment.ConfigFromEnv()
}
var c payment.Config
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return payment.ConfigFromEnv()
}
if c.APIv3Key != "" {
if plain, err := secrets.Decrypt(c.APIv3Key); err == nil {
c.APIv3Key = plain
}
}
return c
}
// InitWechat 启动时装配微信渠道(DB 优先 → env)。失败只降级隐藏,不阻断启动。
func (h *Handler) InitWechat(ctx context.Context) {
_ = h.pay.Reload(ctx, h.loadWechatConfig(ctx))
}
// AdminGetWechatPay: GET /api/v1/admin/payment/wechat —— 当前配置(密钥不回显)+ 渠道状态。
func (h *Handler) AdminGetWechatPay(c *gin.Context) {
cfg := h.loadWechatConfig(c.Request.Context())
enabled, reason := h.pay.Status()
c.JSON(http.StatusOK, gin.H{
"config": gin.H{
"mchid": cfg.MchID,
"cert_serial": cfg.CertSerial,
"private_key_path": cfg.PrivateKeyPath,
"appid": cfg.AppID,
"notify_url": cfg.NotifyURL,
"has_apiv3_key": cfg.APIv3Key != "", // 密钥只报有无,明文永不回显
},
"enabled": enabled,
"reason": reason,
})
}
// AdminSaveWechatPay: PUT /api/v1/admin/payment/wechat —— 保存配置并热重载渠道。
// apiv3_key 留空 = 沿用已存的(只写不回显的编辑语义,同模型 Key)。
func (h *Handler) AdminSaveWechatPay(c *gin.Context) {
var b struct {
MchID string `json:"mchid"`
CertSerial string `json:"cert_serial"`
PrivateKeyPath string `json:"private_key_path"`
APIv3Key string `json:"apiv3_key"`
AppID string `json:"appid"`
NotifyURL string `json:"notify_url"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ctx := c.Request.Context()
apiv3 := strings.TrimSpace(b.APIv3Key)
if apiv3 == "" {
apiv3 = h.loadWechatConfig(ctx).APIv3Key // 留空沿用旧密钥(此处为明文,稍后统一加密入库)
}
cfg := payment.Config{
MchID: strings.TrimSpace(b.MchID),
CertSerial: strings.TrimSpace(b.CertSerial),
PrivateKeyPath: strings.TrimSpace(b.PrivateKeyPath),
APIv3Key: apiv3,
AppID: strings.TrimSpace(b.AppID),
NotifyURL: strings.TrimSpace(b.NotifyURL),
}
stored := cfg
if stored.APIv3Key != "" {
enc, err := secrets.Encrypt(stored.APIv3Key)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
return
}
stored.APIv3Key = enc
}
raw, _ := json.Marshal(stored)
if err := h.db.SetSetting(ctx, SettingWechatPay, string(raw)); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 热重载:配置有毛病只会让渠道隐藏并给出原因,不影响其它功能。
_ = h.pay.Reload(ctx, cfg)
enabled, reason := h.pay.Status()
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": enabled, "reason": reason})
}