454 lines
14 KiB
Go
Executable File
454 lines
14 KiB
Go
Executable File
// Package admin 管理后台:上游对接方管理 API
|
|
//
|
|
// 安全设计:
|
|
// - 本文件是唯一访问 upstreams.db 的组件
|
|
// - 敏感字段(易支付密钥 key)AES-256-GCM 加密后落库
|
|
// - 解密后的上游数据推送到 UpstreamPool(内存),供 epay 验签/回调签名使用
|
|
// - 常规 API 响应不返回明文密钥(仅 "****" + 后4位)
|
|
// - 唯一例外:GET /upstreams/:id/secret(需登录,供管理后台「查看/复制完整密钥」),返回解密后的明文 key
|
|
package admin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"paygateway/internal/account"
|
|
"paygateway/internal/logger"
|
|
"paygateway/internal/store"
|
|
"paygateway/internal/upstream"
|
|
)
|
|
|
|
// UpstreamHandler 上游管理处理器
|
|
type UpstreamHandler struct {
|
|
upstreamDB *store.UpstreamDB // 唯一访问 upstreams.db 的组件
|
|
pool *upstream.UpstreamPool
|
|
initCrypto func(string) error // 初始化加密模块(与账户共用主密钥)
|
|
encFn func([]byte) (string, error)
|
|
decFn func(string) ([]byte, error)
|
|
}
|
|
|
|
// NewUpstreamHandler 创建上游管理处理器
|
|
func NewUpstreamHandler(db *store.UpstreamDB, pool *upstream.UpstreamPool, masterKey string) (*UpstreamHandler, error) {
|
|
// 复用 account 包的 AES-256-GCM 加密能力(主密钥与账户池一致)
|
|
if err := account.InitCrypto(masterKey); err != nil {
|
|
return nil, fmt.Errorf("上游加密初始化失败: %w", err)
|
|
}
|
|
return &UpstreamHandler{
|
|
upstreamDB: db,
|
|
pool: pool,
|
|
initCrypto: account.InitCrypto,
|
|
encFn: account.Encrypt,
|
|
decFn: account.Decrypt,
|
|
}, nil
|
|
}
|
|
|
|
// InitFromDB 启动时从 upstreams.db 加载所有上游,解密后初始化 UpstreamPool
|
|
func (h *UpstreamHandler) InitFromDB(ctx context.Context) error {
|
|
encrypted, err := h.upstreamDB.ListUpstreams(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("加载上游列表失败: %w", err)
|
|
}
|
|
var decryptErrs []string
|
|
for _, e := range encrypted {
|
|
up, err := h.decryptUpstream(e)
|
|
if err != nil {
|
|
logger.Error("upstream", "解密上游失败(主密钥不一致或数据损坏)",
|
|
"id", e.ID, "name", e.Name, "err", err)
|
|
decryptErrs = append(decryptErrs, fmt.Sprintf("id=%d name=%s: %v", e.ID, e.Name, err))
|
|
continue
|
|
}
|
|
h.pool.Add(up)
|
|
}
|
|
// P1 守卫:DB 非空但所有/部分记录解密失败 = 主密钥变更或数据损坏,禁止静默启动(否则网关下单链路全断)
|
|
if len(decryptErrs) > 0 {
|
|
return fmt.Errorf("upstreams.db 解密失败 %d/%d 条(疑似 ACCOUNT_MASTER_KEY 变更),已拒绝启动;请恢复 .env 中的 ACCOUNT_MASTER_KEY 或清空 upstreams.db 后重新对接。失败明细:\n%s",
|
|
len(decryptErrs), len(encrypted), strings.Join(decryptErrs, "\n"))
|
|
}
|
|
logger.Info("upstream", "上游池初始化完成", "count", h.pool.Count())
|
|
return nil
|
|
}
|
|
|
|
// RegisterRoutes 注册上游管理路由(需在 authMiddleware 组内)
|
|
func (h *UpstreamHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
|
rg.GET("/upstreams", h.handleListUpstreams)
|
|
rg.GET("/upstreams/:id", h.handleGetUpstream)
|
|
rg.POST("/upstreams", h.handleCreateUpstream)
|
|
rg.PUT("/upstreams/:id", h.handleUpdateUpstream)
|
|
rg.DELETE("/upstreams/:id", h.handleDeleteUpstream)
|
|
rg.POST("/upstreams/:id/enable", h.handleEnableUpstream)
|
|
rg.POST("/upstreams/:id/disable", h.handleDisableUpstream)
|
|
rg.GET("/upstreams/:id/logs", h.handleUpstreamLogs)
|
|
rg.GET("/upstreams/:id/secret", h.handleUpstreamSecret)
|
|
}
|
|
|
|
// ========== API Handlers ==========
|
|
|
|
// UpstreamResponse 前端展示的上游信息(密钥脱敏)
|
|
type UpstreamResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Pid string `json:"pid"`
|
|
Key string `json:"key"` // 脱敏显示
|
|
BaseURL string `json:"base_url"`
|
|
NotifyDomains string `json:"notify_domains"`
|
|
Status string `json:"status"`
|
|
Remark string `json:"remark,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// upstreamToResponse 将 Upstream 转为前端响应(脱敏密钥)
|
|
func upstreamToResponse(up *upstream.Upstream) *UpstreamResponse {
|
|
return &UpstreamResponse{
|
|
ID: up.ID,
|
|
Name: up.Name,
|
|
Pid: up.Pid,
|
|
Key: maskKey(up.Key),
|
|
BaseURL: up.BaseURL,
|
|
NotifyDomains: up.NotifyDomains,
|
|
Status: string(up.Status),
|
|
Remark: up.Remark,
|
|
CreatedAt: up.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: up.UpdatedAt.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
// encryptUpstream 加密上游的敏感字段,返回 UpstreamEncrypted(用于落库)
|
|
func (h *UpstreamHandler) encryptUpstream(up *upstream.Upstream) (*upstream.UpstreamEncrypted, error) {
|
|
e := &upstream.UpstreamEncrypted{
|
|
Name: up.Name,
|
|
Pid: up.Pid,
|
|
BaseURL: up.BaseURL,
|
|
NotifyDomains: up.NotifyDomains,
|
|
Status: string(up.Status),
|
|
Remark: up.Remark,
|
|
CreatedAt: up.CreatedAt,
|
|
UpdatedAt: up.UpdatedAt,
|
|
}
|
|
var err error
|
|
if up.Key != "" {
|
|
e.KeyEnc, err = h.encFn([]byte(up.Key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("加密易支付密钥失败: %w", err)
|
|
}
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// decryptUpstream 解密上游的敏感字段,返回 Upstream(用于内存)
|
|
func (h *UpstreamHandler) decryptUpstream(e *upstream.UpstreamEncrypted) (*upstream.Upstream, error) {
|
|
up := &upstream.Upstream{
|
|
ID: e.ID,
|
|
Name: e.Name,
|
|
Pid: e.Pid,
|
|
BaseURL: e.BaseURL,
|
|
NotifyDomains: e.NotifyDomains,
|
|
Status: upstream.UpstreamStatus(e.Status),
|
|
Remark: e.Remark,
|
|
CreatedAt: e.CreatedAt,
|
|
UpdatedAt: e.UpdatedAt,
|
|
}
|
|
if e.KeyEnc != "" {
|
|
data, err := h.decFn(e.KeyEnc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解密易支付密钥失败: %w", err)
|
|
}
|
|
up.Key = string(data)
|
|
}
|
|
return up, nil
|
|
}
|
|
|
|
// ========== List ==========
|
|
|
|
func (h *UpstreamHandler) handleListUpstreams(c *gin.Context) {
|
|
ups := h.pool.All()
|
|
resp := make([]*UpstreamResponse, 0, len(ups))
|
|
for _, up := range ups {
|
|
resp = append(resp, upstreamToResponse(up))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": resp})
|
|
}
|
|
|
|
// ========== Get ==========
|
|
|
|
func (h *UpstreamHandler) handleGetUpstream(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
up := h.pool.ByID(id)
|
|
if up == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "上游不存在"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": upstreamToResponse(up)})
|
|
}
|
|
|
|
// handleUpstreamSecret 返回上游完整密钥(明文,需登录)。
|
|
// 用途:管理后台「查看/复制完整密钥」与「对接指引」复制真实可用的密钥。
|
|
// 安全提示:仅登录用户可调用;前端展示时应二次确认并提示勿泄露。
|
|
func (h *UpstreamHandler) handleUpstreamSecret(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
up := h.pool.ByID(id)
|
|
if up == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "上游不存在"})
|
|
return
|
|
}
|
|
logger.Info("upstream", "上游密钥被查看(明文)", "id", id, "name", up.Name, "operator", c.GetString("username"))
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
|
"id": up.ID,
|
|
"name": up.Name,
|
|
"pid": up.Pid,
|
|
"key": up.Key,
|
|
}})
|
|
}
|
|
|
|
// ========== Create ==========
|
|
|
|
type createUpstreamReq struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Pid string `json:"pid" binding:"required"`
|
|
Key string `json:"key"`
|
|
BaseURL string `json:"base_url"`
|
|
NotifyDomains string `json:"notify_domains"`
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
func (h *UpstreamHandler) handleCreateUpstream(c *gin.Context) {
|
|
var req createUpstreamReq
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
|
return
|
|
}
|
|
if req.Key == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "易支付密钥不能为空"})
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
up := &upstream.Upstream{
|
|
Name: req.Name,
|
|
Pid: req.Pid,
|
|
Key: req.Key,
|
|
BaseURL: req.BaseURL,
|
|
NotifyDomains: req.NotifyDomains,
|
|
Status: upstream.StatusEnabled,
|
|
Remark: req.Remark,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
// 加密敏感字段
|
|
enc, err := h.encryptUpstream(up)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 写入 upstreams.db(仅 Admin API 可访问)
|
|
id, err := h.upstreamDB.CreateUpstream(c.Request.Context(), enc)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上游失败(pid 可能已存在): " + err.Error()})
|
|
return
|
|
}
|
|
up.ID = id
|
|
|
|
// 推送到内存池(其他模块立即可用)
|
|
h.pool.Add(up)
|
|
|
|
// 审计日志
|
|
_ = h.upstreamDB.InsertUpstreamLog(c.Request.Context(), &upstream.UpstreamLog{
|
|
UpstreamID: id,
|
|
Action: "create",
|
|
Detail: fmt.Sprintf("创建上游: %s (pid=%s)", req.Name, req.Pid),
|
|
Operator: c.GetString("username"),
|
|
})
|
|
|
|
logger.Info("upstream", "上游创建成功", "id", id, "name", req.Name, "pid", req.Pid)
|
|
c.JSON(http.StatusOK, gin.H{"data": upstreamToResponse(up)})
|
|
}
|
|
|
|
// ========== Update ==========
|
|
|
|
type updateUpstreamReq struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Pid string `json:"pid" binding:"required"`
|
|
Key string `json:"key"`
|
|
BaseURL string `json:"base_url"`
|
|
NotifyDomains string `json:"notify_domains"`
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
func (h *UpstreamHandler) handleUpdateUpstream(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
|
|
var req updateUpstreamReq
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// 保留原有状态与密钥(密钥字段前端不传(空)时保留原值,避免误清空)
|
|
existing := h.pool.ByID(id)
|
|
if existing == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "上游不存在"})
|
|
return
|
|
}
|
|
|
|
up := &upstream.Upstream{
|
|
ID: id,
|
|
Name: req.Name,
|
|
Pid: req.Pid,
|
|
Key: existing.Key,
|
|
BaseURL: req.BaseURL,
|
|
NotifyDomains: req.NotifyDomains,
|
|
Status: existing.Status,
|
|
Remark: req.Remark,
|
|
CreatedAt: existing.CreatedAt,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if req.Key != "" {
|
|
up.Key = req.Key
|
|
}
|
|
|
|
// 加密敏感字段
|
|
enc, err := h.encryptUpstream(up)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
enc.ID = id
|
|
|
|
// 更新 upstreams.db
|
|
if err := h.upstreamDB.UpdateUpstream(c.Request.Context(), enc); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新上游失败(pid 可能已存在): " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// 更新内存池
|
|
h.pool.Update(up)
|
|
|
|
// 审计日志
|
|
_ = h.upstreamDB.InsertUpstreamLog(c.Request.Context(), &upstream.UpstreamLog{
|
|
UpstreamID: id,
|
|
Action: "update",
|
|
Detail: fmt.Sprintf("更新上游: %s (pid=%s)", req.Name, req.Pid),
|
|
Operator: c.GetString("username"),
|
|
})
|
|
|
|
logger.Info("upstream", "上游更新成功", "id", id)
|
|
c.JSON(http.StatusOK, gin.H{"data": upstreamToResponse(up)})
|
|
}
|
|
|
|
// ========== Delete ==========
|
|
|
|
func (h *UpstreamHandler) handleDeleteUpstream(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// 删除数据库
|
|
if err := h.upstreamDB.DeleteUpstream(ctx, id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除上游失败: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// 移除内存池
|
|
h.pool.Remove(id)
|
|
|
|
// 审计日志
|
|
_ = h.upstreamDB.InsertUpstreamLog(ctx, &upstream.UpstreamLog{
|
|
UpstreamID: id,
|
|
Action: "delete",
|
|
Detail: fmt.Sprintf("删除上游 ID: %d", id),
|
|
Operator: c.GetString("username"),
|
|
})
|
|
|
|
logger.Info("upstream", "上游已删除", "id", id)
|
|
c.JSON(http.StatusOK, gin.H{"data": "ok"})
|
|
}
|
|
|
|
// ========== Enable / Disable ==========
|
|
|
|
func (h *UpstreamHandler) handleEnableUpstream(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
|
|
if err := h.upstreamDB.SetUpstreamStatus(ctx, id, string(upstream.StatusEnabled)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
h.pool.SetStatus(id, upstream.StatusEnabled)
|
|
|
|
_ = h.upstreamDB.InsertUpstreamLog(ctx, &upstream.UpstreamLog{
|
|
UpstreamID: id, Action: "enable", Detail: "手动启用", Operator: c.GetString("username"),
|
|
})
|
|
logger.Info("upstream", "上游已启用", "id", id)
|
|
c.JSON(http.StatusOK, gin.H{"data": "ok"})
|
|
}
|
|
|
|
func (h *UpstreamHandler) handleDisableUpstream(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
|
|
if err := h.upstreamDB.SetUpstreamStatus(ctx, id, string(upstream.StatusDisabled)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
h.pool.SetStatus(id, upstream.StatusDisabled)
|
|
|
|
_ = h.upstreamDB.InsertUpstreamLog(ctx, &upstream.UpstreamLog{
|
|
UpstreamID: id, Action: "disable", Detail: "手动禁用", Operator: c.GetString("username"),
|
|
})
|
|
logger.Info("upstream", "上游已禁用", "id", id)
|
|
c.JSON(http.StatusOK, gin.H{"data": "ok"})
|
|
}
|
|
|
|
// ========== Upstream Logs ==========
|
|
|
|
func (h *UpstreamHandler) handleUpstreamLogs(c *gin.Context) {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if id <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效上游ID"})
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 30
|
|
}
|
|
|
|
logs, err := h.upstreamDB.ListUpstreamLogs(c.Request.Context(), id, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": logs})
|
|
}
|