1214 lines
40 KiB
Go
Executable File
1214 lines
40 KiB
Go
Executable File
// Package admin 管理后台:静态页面 + JSON API
|
||
//
|
||
// 前端为 Vue3 + Element Plus 构建产物,embed 进二进制(internal/admin/static)。
|
||
// 部署时只需一个 gateway 二进制,无需单独部署前端。
|
||
package admin
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"embed"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io/fs"
|
||
"mime"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"paygateway/internal/channel"
|
||
"paygateway/internal/logger"
|
||
"paygateway/internal/model"
|
||
"paygateway/internal/scrcpy"
|
||
"paygateway/internal/store"
|
||
)
|
||
|
||
// mimeTypeByPath 根据文件扩展名返回 Content-Type
|
||
func mimeTypeByPath(p string) string {
|
||
switch ext := filepath.Ext(p); ext {
|
||
case ".js":
|
||
return "text/javascript; charset=utf-8"
|
||
case ".css":
|
||
return "text/css; charset=utf-8"
|
||
case ".html":
|
||
return "text/html; charset=utf-8"
|
||
case ".json":
|
||
return "application/json; charset=utf-8"
|
||
case ".svg":
|
||
return "image/svg+xml"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".gif":
|
||
return "image/gif"
|
||
case ".webp":
|
||
return "image/webp"
|
||
case ".woff", ".woff2":
|
||
return "font/woff2"
|
||
case ".ttf":
|
||
return "font/ttf"
|
||
case ".ico":
|
||
return "image/x-icon"
|
||
default:
|
||
if mt := mime.TypeByExtension(ext); mt != "" {
|
||
return mt
|
||
}
|
||
return "application/octet-stream"
|
||
}
|
||
}
|
||
|
||
//go:embed all:static
|
||
var staticFS embed.FS
|
||
|
||
// TunnelStatus 隧道状态提供者(由 main 注入,避免循环依赖)
|
||
type TunnelStatus struct {
|
||
URL string `json:"url"` // 隧道地址(空 = 未启动)
|
||
Callback string `json:"callback"` // 完整回调地址
|
||
Healthy bool `json:"healthy"` // 健康状态
|
||
Running bool `json:"running"` // 是否已启动
|
||
Error string `json:"error,omitempty"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
// EpayInfo 易支付对接信息(其他项目配置 paygateway 时使用)
|
||
type EpayInfo struct {
|
||
BaseURL string `json:"base_url"` // 服务基础地址
|
||
SubmitURL string `json:"submit_url"` // 下单接口(New API 的"易支付网关地址"填这个)
|
||
QueryURL string `json:"query_url"` // 订单查询接口
|
||
Pid string `json:"pid"` // 商户ID
|
||
Key string `json:"key"` // 商户密钥
|
||
SignType string `json:"sign_type"` // 签名方式
|
||
}
|
||
|
||
// ChannelInfo 支付通道信息(由 main 组装,admin 不依赖具体通道包)
|
||
type ChannelInfo struct {
|
||
Name string `json:"name"`
|
||
Enabled bool `json:"enabled"`
|
||
Kind string `json:"kind"` // callback / listener
|
||
Modes []string `json:"modes"` // 支持模式
|
||
ModesEnabled map[string]bool `json:"modes_enabled,omitempty"` // 各模式启停状态
|
||
AppID string `json:"app_id,omitempty"`
|
||
MchID string `json:"mch_id,omitempty"`
|
||
IsProd bool `json:"is_prod"`
|
||
NotifyURL string `json:"notify_url,omitempty"`
|
||
// 账户配置状态(引导用):是否已配置账户 + 账户数量
|
||
Configured bool `json:"configured"`
|
||
AccountCount int `json:"account_count"`
|
||
// 统计(由 admin 填充)
|
||
TodayOrders int64 `json:"today_orders"`
|
||
TodayAmount int64 `json:"today_amount"`
|
||
TotalOrders int64 `json:"total_orders"`
|
||
TotalAmount int64 `json:"total_amount"`
|
||
}
|
||
|
||
// LogStore 独立日志库接口(查询管理界面日志用,与订单主库分离)
|
||
type LogStore interface {
|
||
QueryLogs(ctx context.Context, level, scope string, limit, offset int) ([]*store.LogRecord, error)
|
||
CountLogs(ctx context.Context, level, scope string) (int64, error)
|
||
}
|
||
|
||
// Server 管理后台服务
|
||
type Server struct {
|
||
st store.Store
|
||
logStore LogStore // 独立日志数据库(长期保存,查询日志用)
|
||
ledgerStore store.LedgerStore // 独立账本数据库
|
||
callbackPath string // /alipay/notify
|
||
tunnelURL func() string // 获取隧道 URL
|
||
tunnelHealth func() bool // 隧道健康
|
||
tunnelErr func() string // 隧道错误信息(可选)
|
||
channels func() []ChannelInfo // 通道信息提供者
|
||
epayInfo func() *EpayInfo // 易支付对接信息提供者
|
||
onSettingsChange func(key, value string) // 设置变更回调(通知 main 更新通道等)
|
||
onLedgerVerify func() *VerifyLedgerResult // 账本校验回调
|
||
onLedgerSnapshot func() error // 创建快照回调
|
||
onLedgerPublicKey func() string // 账本公钥回调
|
||
|
||
// 账户池管理(唯一访问 accounts.db 的组件)
|
||
accountHandler *AccountHandler
|
||
// 上游管理(唯一访问 upstreams.db 的组件)
|
||
upstreamHandler *UpstreamHandler
|
||
reconcilerHandler *ReconcilerHandler
|
||
// 个人免签通道管理(设备/推送流水/无订单收款)
|
||
personalHandler *PersonalHandler
|
||
// 实时设备流服务(云手机式操作;nil = 未启用)
|
||
streamSrv *scrcpy.StreamServer
|
||
// 账本页服务端监听状态提供者(main.go 注入;nil = 未启用)。返回 JSON 序列化数据。
|
||
ledgerStatus func() any
|
||
// ledgerWarmup 设备手动预热函数(main.go 注入;nil = 未启用)。DeviceActor 模型中
|
||
// 手动预热是队列中的一个命令,与自动预热/轮询天然互斥,无需窗口独占。
|
||
ledgerWarmup func(ctx context.Context, deviceID string, providers map[string]bool) error
|
||
|
||
// 鉴权:凭据来源 DB(settings);无凭据时 adminSetupNeeded=true 引导注册
|
||
adminUser string
|
||
adminPass []byte // bcrypt 哈希,禁止明文比对
|
||
adminSetupNeeded bool // 首次启动,尚无管理员,需引导注册
|
||
setupMu sync.Mutex // 保护注册的并发竞态(防多个请求同时通过检查)
|
||
jwtSecret []byte
|
||
|
||
startTime time.Time
|
||
|
||
loginLimiter *loginLimiter
|
||
}
|
||
|
||
// VerifyLedgerResult 账本校验结果(admin 展示用)
|
||
type VerifyLedgerResult struct {
|
||
Valid bool `json:"valid"`
|
||
TotalEntries int64 `json:"total_entries"`
|
||
Errors []string `json:"errors,omitempty"`
|
||
SnapshotOK bool `json:"snapshot_ok"`
|
||
Duration string `json:"duration"`
|
||
CheckedAt string `json:"checked_at"`
|
||
}
|
||
|
||
// tokenExpiry token 过期时间
|
||
const tokenExpiry = 24 * time.Hour
|
||
|
||
// New 创建管理后台服务。
|
||
// 管理员凭据完全由数据库驱动(settings 表 admin_username / admin_password_hash,bcrypt)。
|
||
// 无凭据时 adminSetupNeeded=true,前端引导首次注册;.env 不再承载管理员凭据。
|
||
func New(st store.Store, ledgerStore store.LedgerStore, callbackPath string, tunURL func() string, tunHealth func() bool, channels func() []ChannelInfo, epayInfo func() *EpayInfo, onSettingsChange func(key, value string), onLedgerVerify func() *VerifyLedgerResult, onLedgerSnapshot func() error, onLedgerPublicKey func() string, accountHandler *AccountHandler, upstreamHandler *UpstreamHandler, reconcilerHandler *ReconcilerHandler, personalHandler *PersonalHandler, jwtSecret string) *Server {
|
||
secret := jwtSecret
|
||
if secret == "" {
|
||
// 安全:未配置 JWT_SECRET 时生成随机密钥(禁止可预测的日期密钥,防止伪造 token)
|
||
// 注意:随机密钥仅内存持有,服务重启后已发 token 失效(重新登录即可)
|
||
buf := make([]byte, 32)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
// crypto/rand 失败几乎不可能;兜底用时间戳+pid 混合,仍不可预测
|
||
buf = []byte(fmt.Sprintf("pgw-%d-%d", time.Now().UnixNano(), os.Getpid()))
|
||
}
|
||
secret = hex.EncodeToString(buf)
|
||
logger.Warn("admin", "JWT_SECRET 未配置,已生成随机密钥(重启后需重新登录)")
|
||
}
|
||
s := &Server{
|
||
st: st,
|
||
ledgerStore: ledgerStore,
|
||
callbackPath: callbackPath,
|
||
tunnelURL: tunURL,
|
||
tunnelHealth: tunHealth,
|
||
channels: channels,
|
||
epayInfo: epayInfo,
|
||
onSettingsChange: onSettingsChange,
|
||
onLedgerVerify: onLedgerVerify,
|
||
onLedgerSnapshot: onLedgerSnapshot,
|
||
onLedgerPublicKey: onLedgerPublicKey,
|
||
accountHandler: accountHandler,
|
||
upstreamHandler: upstreamHandler,
|
||
reconcilerHandler: reconcilerHandler,
|
||
personalHandler: personalHandler,
|
||
jwtSecret: []byte(secret),
|
||
startTime: time.Now(),
|
||
loginLimiter: newLoginLimiter(),
|
||
}
|
||
s.loadAdminFromDB()
|
||
return s
|
||
}
|
||
|
||
// SetLogStore 绑定独立日志数据库(New 之后调用;日志查询/统计均走该库)
|
||
func (s *Server) SetLogStore(ls LogStore) { s.logStore = ls }
|
||
|
||
// loadAdminFromDB 从 settings 表加载管理员凭据(admin_username / admin_password_hash,bcrypt)
|
||
func (s *Server) loadAdminFromDB() {
|
||
ctx := context.Background()
|
||
user, errU := s.st.GetSetting(ctx, "admin_username")
|
||
pass, errP := s.st.GetSetting(ctx, "admin_password_hash")
|
||
if errU == nil && errP == nil && user != "" && strings.HasPrefix(pass, "$2") {
|
||
s.adminUser = user
|
||
s.adminPass = []byte(pass)
|
||
logger.Info("admin", "管理员凭据已从数据库加载", "username", user)
|
||
return
|
||
}
|
||
// 无管理员凭据 → 首次启动,引导注册
|
||
s.adminSetupNeeded = true
|
||
logger.Info("admin", "首次启动:无管理员凭据,等待注册(POST /api/auth/register)")
|
||
}
|
||
|
||
// SetStreamServer 注入实时设备流服务(web 控制台云手机式操作)
|
||
// 不通过 New 注入以保持签名稳定;未设置时设备控制台回退到截图模式
|
||
func (s *Server) SetStreamServer(ss *scrcpy.StreamServer) {
|
||
s.streamSrv = ss
|
||
}
|
||
|
||
// SetLedgerStatus 注入账本页监听状态提供者(docs/PLAN-ADB-LEDGER-IMPL.md §7 只读接口)
|
||
func (s *Server) SetLedgerStatus(fn func() any) { s.ledgerStatus = fn }
|
||
|
||
// SetLedgerWarmup 注入设备手动预热函数(DeviceActor 模型:手动预热是队列中的一个命令,天然互斥)。
|
||
// fn 接收 (ctx, deviceID, providers),provider 指定通道({"alipay":true,"wechat":true},空=全部)。
|
||
func (s *Server) SetLedgerWarmup(fn func(ctx context.Context, deviceID string, providers map[string]bool) error) {
|
||
s.ledgerWarmup = fn
|
||
}
|
||
|
||
// Run 启动管理后台(阻塞)
|
||
func (s *Server) Run(addr string) {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
r := gin.New()
|
||
r.Use(gin.Recovery())
|
||
|
||
// ---- 登录(无需鉴权)----
|
||
r.POST("/api/auth/login", s.handleLogin)
|
||
// ---- 首次启动注册(无需鉴权;已配置管理员后自动拒绝)----
|
||
r.POST("/api/auth/register", s.handleRegister)
|
||
r.GET("/api/auth/setup-status", s.handleSetupStatus)
|
||
|
||
// ---- 健康检查(无需鉴权,供 LB/监控/heartbeat 用)----
|
||
// 返回 200 = 服务进程活着 + 业务 DB 可读 + 账本表可读;隧道状态作为子项返回不影响 200。
|
||
// 失败的子项以 503 + JSON 暴露给运维 / 监控告警。
|
||
r.GET("/api/health", s.handleHealth)
|
||
|
||
// ---- JSON API(需鉴权)----
|
||
api := r.Group("/api", s.authMiddleware())
|
||
{
|
||
api.GET("/dashboard", s.handleDashboard)
|
||
api.GET("/epay-info", s.handleEpayInfo)
|
||
api.GET("/channels", s.handleChannels)
|
||
api.GET("/orders", s.handleListOrders)
|
||
api.GET("/orders/:id/events", s.handleOrderEvents)
|
||
api.GET("/notify-tasks", s.handleNotifyTasks)
|
||
api.GET("/notify-stats", s.handleNotifyStats)
|
||
api.GET("/logs", s.handleLogs)
|
||
api.GET("/ledger", s.handleLedger)
|
||
api.POST("/ledger/verify", s.handleLedgerVerify)
|
||
api.GET("/ledger/snapshots", s.handleLedgerSnapshots)
|
||
api.POST("/ledger/snapshot", s.handleCreateSnapshot)
|
||
api.GET("/ledger/public-key", s.handleLedgerPublicKey)
|
||
api.GET("/ledger/export", s.handleLedgerExport)
|
||
api.GET("/tunnel", s.handleTunnel)
|
||
api.GET("/settings", s.handleSettings)
|
||
api.POST("/settings", s.handleUpdateSettings)
|
||
api.POST("/auth/reset", s.handleResetAdmin)
|
||
// 安全密钥查看(确认后返回完整值,含账本私钥/QR token 等)
|
||
api.GET("/settings/keys", s.handleSettingsKeys)
|
||
api.POST("/settings/channel/:name/toggle", s.handleToggleChannel)
|
||
api.POST("/settings/channel/:name/mode/:mode/toggle", s.handleToggleChannelMode)
|
||
|
||
// 账户池管理路由(仅 Admin API 可访问 accounts.db)
|
||
if s.accountHandler != nil {
|
||
s.accountHandler.RegisterRoutes(api)
|
||
}
|
||
// 上游管理路由(仅 Admin API 可访问 upstreams.db)
|
||
if s.upstreamHandler != nil {
|
||
s.upstreamHandler.RegisterRoutes(api)
|
||
}
|
||
if s.reconcilerHandler != nil {
|
||
s.reconcilerHandler.RegisterRoutes(api)
|
||
}
|
||
// 个人免签通道管理路由(设备/推送流水/无订单收款)
|
||
if s.personalHandler != nil {
|
||
if s.ledgerWarmup != nil {
|
||
s.personalHandler.SetLedgerWarmup(s.ledgerWarmup)
|
||
}
|
||
s.personalHandler.RegisterRoutes(api)
|
||
}
|
||
// 账本页服务端监听状态(只读,docs/PLAN-ADB-LEDGER-IMPL.md §7)
|
||
if s.ledgerStatus != nil {
|
||
api.GET("/ledger-monitors", s.handleLedgerMonitors)
|
||
}
|
||
}
|
||
// 实时设备流(云手机式操作)—— WebSocket 升级无法携带 Authorization header,
|
||
// 必须注册在根路由(无 authMiddleware),token 走 query 参数在 handler 内校验
|
||
if s.streamSrv != nil {
|
||
r.GET("/api/personal/devices/stream", s.handleDeviceStream)
|
||
}
|
||
|
||
// ---- 静态页面(SPA,NoRoute 统一处理,避免与 /api 路由冲突)----
|
||
sub, err := fs.Sub(staticFS, "static")
|
||
if err != nil {
|
||
logger.Error("admin", "静态资源加载失败", "err", err)
|
||
sub = nil
|
||
}
|
||
r.NoRoute(func(c *gin.Context) {
|
||
if sub != nil && c.Request.Method == http.MethodGet {
|
||
// 1) 先尝试从静态目录读真实文件(/assets/*.js 等)
|
||
p := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||
if p == "" {
|
||
p = "index.html"
|
||
}
|
||
if data, err := fs.ReadFile(sub, p); err == nil {
|
||
// index.html 不缓存(保证 SPA 入口始终引用最新 bundle;JS/CSS 带 hash 名天然不冲突)
|
||
if p == "index.html" {
|
||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||
}
|
||
c.Data(http.StatusOK, mimeTypeByPath(p), data)
|
||
return
|
||
}
|
||
// 2) 读不到 → SPA 回退到 index.html(前端路由)
|
||
if data, err := fs.ReadFile(sub, "index.html"); err == nil {
|
||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||
return
|
||
}
|
||
}
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
})
|
||
|
||
logger.Info("admin", "管理后台启动", "addr", addr)
|
||
if err := r.Run(addr); err != nil {
|
||
logger.Error("admin", "管理后台退出", "err", err)
|
||
}
|
||
}
|
||
|
||
// ========== Dashboard ==========
|
||
|
||
func (s *Server) handleDashboard(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
d := gin.H{}
|
||
var err error
|
||
|
||
if d["total_orders"], err = s.st.CountOrders(ctx, ""); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["success_orders"], err = s.st.CountOrders(ctx, model.StatusSuccess); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["wait_orders"], err = s.st.CountOrders(ctx, model.StatusWaitPay); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["closed_orders"], err = s.st.CountOrders(ctx, model.StatusClosed); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["total_amount_minor"], err = s.st.SumAmount(ctx, model.StatusSuccess); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
|
||
// 通知任务统计
|
||
if d["notify_pending"], err = s.st.CountNotifyTasks(ctx, "pending"); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["notify_success"], err = s.st.CountNotifyTasks(ctx, "success"); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["notify_dead"], err = s.st.CountNotifyTasks(ctx, "dead"); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
|
||
// 日志统计(独立日志库;未配置时跳过)
|
||
if s.logStore != nil {
|
||
if d["log_errors"], err = s.logStore.CountLogs(ctx, "ERROR", ""); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["log_warns"], err = s.logStore.CountLogs(ctx, "WARN", ""); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
} else {
|
||
d["log_errors"] = int64(0)
|
||
d["log_warns"] = int64(0)
|
||
}
|
||
|
||
// 账本(独立数据库)
|
||
if d["ledger_count"], err = s.ledgerStore.CountLedger(ctx); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
last, err := s.ledgerStore.GetLastLedgerEntry(ctx)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if last != nil {
|
||
d["ledger_last_hash"] = last.EntryHash
|
||
d["ledger_last_time"] = last.CreatedAt.Format(time.RFC3339)
|
||
}
|
||
|
||
// 最近订单
|
||
recent, err := s.st.ListOrders(ctx, "", "", 8, 0)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
d["recent_orders"] = recent
|
||
|
||
// 订单/金额趋势:支持 start/end(日历范围);无则用旧的 days 快捷参数
|
||
start := c.Query("start")
|
||
end := c.Query("end")
|
||
if start == "" || end == "" {
|
||
days, _ := strconv.Atoi(c.DefaultQuery("days", "14"))
|
||
if days <= 0 {
|
||
days = 14
|
||
}
|
||
end = time.Now().Format("2006-01-02")
|
||
start = time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||
}
|
||
trend, err := s.st.OrdersTrend(ctx, start, end)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
d["trend"] = trend
|
||
|
||
// 隧道状态
|
||
d["tunnel"] = s.tunnelStatus()
|
||
|
||
c.JSON(http.StatusOK, gin.H{"data": d})
|
||
}
|
||
|
||
// ========== Epay Info ==========
|
||
|
||
func (s *Server) handleEpayInfo(c *gin.Context) {
|
||
if s.epayInfo == nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": nil})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": s.epayInfo()})
|
||
}
|
||
|
||
// ========== Channels ==========
|
||
|
||
func (s *Server) handleChannels(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
var out []ChannelInfo
|
||
|
||
if s.channels != nil {
|
||
out = s.channels()
|
||
} else {
|
||
out = []ChannelInfo{}
|
||
}
|
||
|
||
// 填充统计:每个通道的今日/累计订单与金额
|
||
today := time.Now().Format("2006-01-02")
|
||
for i := range out {
|
||
// 累计
|
||
if n, err := s.st.CountOrdersByChannel(ctx, "", out[i].Name); err == nil {
|
||
out[i].TotalOrders = n
|
||
}
|
||
if a, err := s.st.SumAmountByChannel(ctx, model.StatusSuccess, out[i].Name); err == nil {
|
||
out[i].TotalAmount = a
|
||
}
|
||
// 今日(通过 trend 或直接查:简单方式查今日创建的全部订单)
|
||
if n, err := s.st.CountOrdersByChannelToday(ctx, out[i].Name, today); err == nil {
|
||
out[i].TodayOrders = n
|
||
}
|
||
if a, err := s.st.SumAmountByChannelToday(ctx, out[i].Name, today); err == nil {
|
||
out[i].TodayAmount = a
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||
}
|
||
|
||
// ========== Notify Stats ==========
|
||
|
||
func (s *Server) handleNotifyStats(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
d := gin.H{}
|
||
var err error
|
||
if d["pending"], err = s.st.CountNotifyTasks(ctx, "pending"); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["success"], err = s.st.CountNotifyTasks(ctx, "success"); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
if d["dead"], err = s.st.CountNotifyTasks(ctx, "dead"); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
// 待重试(即将到期的 pending)
|
||
if d["total"], err = s.st.CountNotifyTasks(ctx, ""); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": d})
|
||
}
|
||
|
||
// ========== Orders ==========
|
||
|
||
func (s *Server) handleListOrders(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
status := c.Query("status")
|
||
channel := c.Query("channel")
|
||
keyword := c.Query("keyword")
|
||
from := c.Query("from")
|
||
to := c.Query("to")
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 20
|
||
}
|
||
|
||
total, err := s.st.CountOrdersFiltered(ctx, status, channel, keyword, from, to)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
list, err := s.st.ListOrdersFiltered(ctx, status, channel, keyword, from, to, limit, offset)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
// 附加账户名称(从账户池内存查询)
|
||
if s.accountHandler != nil {
|
||
for _, o := range list {
|
||
if o.AccountID > 0 {
|
||
o.AccountName = s.accountHandler.AccountName(o.AccountID)
|
||
}
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"total": total, "list": list}})
|
||
}
|
||
|
||
func (s *Server) handleOrderEvents(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if id <= 0 {
|
||
s.err(c, fmt.Errorf("无效订单ID"))
|
||
return
|
||
}
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 20
|
||
}
|
||
events, err := s.st.ListEvents(ctx, id, limit)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": events})
|
||
}
|
||
|
||
// ========== Notify Tasks ==========
|
||
|
||
func (s *Server) handleNotifyTasks(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
status := c.Query("status")
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
list, err := s.st.ListNotifyTasks(ctx, status, limit)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
// 关联订单号
|
||
type taskWithOrder struct {
|
||
*store.NotifyTask
|
||
OutTradeNo string `json:"out_trade_no"`
|
||
Subject string `json:"subject"`
|
||
AmountMinor int64 `json:"amount_minor"`
|
||
}
|
||
var out []taskWithOrder
|
||
for _, t := range list {
|
||
item := taskWithOrder{NotifyTask: t}
|
||
if o, err := s.st.GetOrderByID(ctx, t.OrderID); err == nil && o != nil {
|
||
item.OutTradeNo = o.OutTradeNo
|
||
item.Subject = o.Subject
|
||
item.AmountMinor = o.AmountMinor
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||
}
|
||
|
||
// ========== Logs ==========
|
||
|
||
func (s *Server) handleLogs(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
if s.logStore == nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"total": 0, "list": []*store.LogRecord{}}})
|
||
return
|
||
}
|
||
level := c.Query("level")
|
||
scope := c.Query("scope")
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
|
||
total, err := s.logStore.CountLogs(ctx, level, scope)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
list, err := s.logStore.QueryLogs(ctx, level, scope, limit, offset)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"total": total, "list": list}})
|
||
}
|
||
|
||
// ========== Ledger ==========
|
||
|
||
func (s *Server) handleLedger(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 30
|
||
}
|
||
list, err := s.ledgerStore.ListLedger(ctx, limit)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
count, err := s.ledgerStore.CountLedger(ctx)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"total": count, "list": list}})
|
||
}
|
||
|
||
// handleLedgerVerify 手动触发账本校验
|
||
func (s *Server) handleLedgerVerify(c *gin.Context) {
|
||
if s.onLedgerVerify == nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"valid": false, "error": "未配置账本校验回调"}})
|
||
return
|
||
}
|
||
logger.Info("admin", "手动触发账本校验")
|
||
res := s.onLedgerVerify()
|
||
c.JSON(http.StatusOK, gin.H{"data": res})
|
||
}
|
||
|
||
// handleLedgerSnapshots 快照列表
|
||
func (s *Server) handleLedgerSnapshots(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 30
|
||
}
|
||
list, err := s.ledgerStore.ListSnapshots(ctx, limit)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": list})
|
||
}
|
||
|
||
// handleCreateSnapshot 手动创建快照
|
||
func (s *Server) handleCreateSnapshot(c *gin.Context) {
|
||
if s.onLedgerSnapshot == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置账本"})
|
||
return
|
||
}
|
||
if err := s.onLedgerSnapshot(); err != nil {
|
||
logger.Error("admin", "创建快照失败", "err", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
logger.Info("admin", "手动创建快照成功")
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "快照创建成功"}})
|
||
}
|
||
|
||
// handleLedgerPublicKey 返回账本公钥(供外部验证签名)
|
||
func (s *Server) handleLedgerPublicKey(c *gin.Context) {
|
||
if s.onLedgerPublicKey == nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"public_key": "未配置账本"}})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"public_key": s.onLedgerPublicKey()}})
|
||
}
|
||
|
||
// handleLedgerExport 导出账本为 CSV(审计报告)
|
||
func (s *Server) handleLedgerExport(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
|
||
// 账本条目 CSV
|
||
entries, err := s.ledgerStore.ListLedger(ctx, 100000)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
|
||
// 快照 CSV
|
||
snapshots, err := s.ledgerStore.ListSnapshots(ctx, 1000)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
|
||
var b strings.Builder
|
||
// 写入 UTF-8 BOM(Excel 中文兼容)
|
||
b.WriteString("\xEF\xBB\xBF")
|
||
b.WriteString("# PayGateway 审计账本导出\n")
|
||
b.WriteString("# 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n")
|
||
b.WriteString("# 账本条目数: " + strconv.Itoa(len(entries)) + ", 快照数: " + strconv.Itoa(len(snapshots)) + "\n\n")
|
||
|
||
b.WriteString("== 账本条目 ==\n")
|
||
b.WriteString("id,event_id,prev_hash,payload_hash,entry_hash,signature,created_at\n")
|
||
for _, e := range entries {
|
||
b.WriteString(fmt.Sprintf("%d,%d,%s,%s,%s,%s,%s\n",
|
||
e.ID, e.EventID, e.PrevHash, e.PayloadHash, e.EntryHash, e.Signature,
|
||
e.CreatedAt.Format(time.RFC3339)))
|
||
}
|
||
|
||
b.WriteString("\n== 每日快照 ==\n")
|
||
b.WriteString("id,snapshot_date,last_hash,signature,created_at\n")
|
||
for _, sn := range snapshots {
|
||
b.WriteString(fmt.Sprintf("%d,%s,%s,%s,%s\n",
|
||
sn.ID, sn.SnapshotDate, sn.LastHash, sn.Signature,
|
||
sn.CreatedAt.Format(time.RFC3339)))
|
||
}
|
||
|
||
// 设置下载响应头
|
||
filename := fmt.Sprintf("ledger_audit_%s.csv", time.Now().Format("20060102"))
|
||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||
c.Data(http.StatusOK, "text/csv; charset=utf-8", []byte(b.String()))
|
||
}
|
||
|
||
// ========== Tunnel ==========
|
||
|
||
func (s *Server) handleTunnel(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"data": s.tunnelStatus()})
|
||
}
|
||
|
||
// ========== 账本页服务端监听状态(只读) ==========
|
||
|
||
func (s *Server) handleLedgerMonitors(c *gin.Context) {
|
||
if s.ledgerStatus == nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": []any{}, "enabled": false})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": s.ledgerStatus(), "enabled": true})
|
||
}
|
||
|
||
func (s *Server) tunnelStatus() TunnelStatus {
|
||
ts := TunnelStatus{UpdatedAt: time.Now().Format(time.RFC3339)}
|
||
if s.tunnelURL != nil {
|
||
ts.URL = s.tunnelURL()
|
||
}
|
||
if s.tunnelHealth != nil {
|
||
ts.Healthy = s.tunnelHealth()
|
||
}
|
||
if s.tunnelErr != nil {
|
||
ts.Error = s.tunnelErr()
|
||
}
|
||
if ts.URL != "" {
|
||
ts.Running = true
|
||
ts.Callback = ts.URL + s.callbackPath
|
||
}
|
||
return ts
|
||
}
|
||
|
||
// handleHealth 深度健康检查(无鉴权,挂载在 r.GET("/api/health"),供 LB / 监控 / 告警调用)。
|
||
//
|
||
// HTTP 状态码契约(运维 / 监控据此配 alert):
|
||
// - 200 OK : 服务进程活着 + 业务 DB 可读 + 账本表可读。
|
||
// - 503 + JSON : 任一关键子项(db / ledger)失败;tunnel 失败不影响整体 200(隧道仅是支付回调通道,本地支付能力不受影响)。
|
||
func (s *Server) handleHealth(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
|
||
resp := gin.H{"status": "ok", "checked_at": time.Now().Format(time.RFC3339)}
|
||
if !s.startTime.IsZero() {
|
||
resp["uptime_sec"] = int64(time.Since(s.startTime).Seconds())
|
||
}
|
||
|
||
criticalFail := false
|
||
|
||
disk := gin.H{}
|
||
if n, err := s.st.CountOrders(ctx, ""); err != nil {
|
||
disk["ok"] = false
|
||
disk["error"] = err.Error()
|
||
criticalFail = true
|
||
} else {
|
||
disk["ok"] = true
|
||
disk["orders"] = n
|
||
}
|
||
resp["db"] = disk
|
||
|
||
led := gin.H{}
|
||
if s.ledgerStore != nil {
|
||
if n, err := s.ledgerStore.CountLedger(ctx); err != nil {
|
||
led["ok"] = false
|
||
led["error"] = err.Error()
|
||
criticalFail = true
|
||
} else {
|
||
led["ok"] = true
|
||
led["count"] = n
|
||
}
|
||
} else {
|
||
led["ok"] = false
|
||
led["error"] = "ledgerStore 未注入"
|
||
criticalFail = true
|
||
}
|
||
resp["ledger"] = led
|
||
|
||
tun := gin.H{}
|
||
if s.tunnelHealth != nil {
|
||
tun["healthy"] = s.tunnelHealth()
|
||
}
|
||
if s.tunnelURL != nil {
|
||
tun["url"] = s.tunnelURL()
|
||
}
|
||
resp["tunnel"] = tun
|
||
|
||
if criticalFail {
|
||
resp["status"] = "error"
|
||
c.JSON(http.StatusServiceUnavailable, resp)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, resp)
|
||
}
|
||
|
||
// ========== 鉴权 ==========
|
||
|
||
type loginReq struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// authMiddleware 鉴权中间件,验证 JWT token
|
||
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := c.GetHeader("Authorization")
|
||
if token == "" {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||
return
|
||
}
|
||
// 去掉 "Bearer " 前缀
|
||
if len(token) > 7 && token[:7] == "Bearer " {
|
||
token = token[7:]
|
||
}
|
||
claims := &jwtClaims{}
|
||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||
return s.jwtSecret, nil
|
||
})
|
||
if err != nil || !parsed.Valid {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token 无效或已过期"})
|
||
return
|
||
}
|
||
c.Set("username", claims.Username)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
type jwtClaims struct {
|
||
Username string `json:"username"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
func (s *Server) handleLogin(c *gin.Context) {
|
||
var req loginReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
|
||
// 失败退避限速:IP + username 双维度校验,已锁定直接 429
|
||
if s.enforceLoginLimit(c, req.Username) {
|
||
return
|
||
}
|
||
|
||
if s.adminSetupNeeded {
|
||
// 首次启动尚未注册管理员,引导注册(与"登录未启用"区分,避免管理员困惑)
|
||
c.JSON(http.StatusConflict, gin.H{"error": "管理员未注册,请先完成初始化"})
|
||
return
|
||
}
|
||
if s.adminUser == "" || len(s.adminPass) == 0 || !strings.HasPrefix(string(s.adminPass), "$2") {
|
||
// 后台登录被禁用(配置不全或非 bcrypt 哈希),统一返回 403 避免泄露配置状态
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "管理后台登录未启用"})
|
||
return
|
||
}
|
||
if req.Username != s.adminUser {
|
||
// 用户名错误也走 bcrypt 路径以保持固定时间,避免用户名枚举
|
||
_ = bcrypt.CompareHashAndPassword(s.adminPass, []byte("00"+req.Password))
|
||
s.recordLoginOutcome(c, req.Username, true)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||
return
|
||
}
|
||
if err := bcrypt.CompareHashAndPassword(s.adminPass, []byte(req.Password)); err != nil {
|
||
s.recordLoginOutcome(c, req.Username, true)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||
return
|
||
}
|
||
|
||
s.recordLoginOutcome(c, req.Username, false)
|
||
|
||
now := time.Now()
|
||
claims := &jwtClaims{
|
||
Username: req.Username,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(now.Add(tokenExpiry)),
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
Issuer: "paygateway",
|
||
},
|
||
}
|
||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||
if err != nil {
|
||
logger.Error("admin", "token 生成失败", "err", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 token 失败"})
|
||
return
|
||
}
|
||
logger.Info("admin", "登录成功", "username", req.Username)
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||
"token": token,
|
||
"expires": now.Add(tokenExpiry).Unix(),
|
||
}})
|
||
}
|
||
|
||
// ========== 首次启动注册 ==========
|
||
|
||
type registerReq struct {
|
||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||
Password string `json:"password" binding:"required,min=8,max=72"`
|
||
}
|
||
|
||
// handleSetupStatus 查询是否需要首次注册(前端据此决定展示注册页 or 登录页)
|
||
func (s *Server) handleSetupStatus(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"setup_needed": s.adminSetupNeeded}})
|
||
}
|
||
|
||
// handleRegister 首次启动注册管理员(bcrypt 哈希存入 settings 表)
|
||
// 安全:仅在尚无管理员凭据时可调用;注册成功后立即禁用
|
||
func (s *Server) handleRegister(c *gin.Context) {
|
||
s.setupMu.Lock()
|
||
defer s.setupMu.Unlock()
|
||
// 双重校验:内存标志 + DB 实况(防内存竞态绕过导致重复注册覆盖凭据)
|
||
if !s.adminSetupNeeded {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "管理员已配置,注册已关闭"})
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
if user, err := s.st.GetSetting(ctx, "admin_username"); err == nil && user != "" {
|
||
s.adminSetupNeeded = false
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "管理员已配置,注册已关闭"})
|
||
return
|
||
}
|
||
var req registerReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误:用户名 3-32 位,密码至少 8 位"})
|
||
return
|
||
}
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||
return
|
||
}
|
||
// 先写哈希再写用户名:任一失败都不留半配置(用户名无值 = 注册未完成)
|
||
if err := s.st.SetSetting(ctx, "admin_password_hash", string(hash)); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存管理员密码失败: " + err.Error()})
|
||
return
|
||
}
|
||
if err := s.st.SetSetting(ctx, "admin_username", req.Username); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存管理员失败: " + err.Error()})
|
||
return
|
||
}
|
||
// 立即生效:内存凭据切换为 DB 值,关闭注册入口
|
||
s.adminUser = req.Username
|
||
s.adminPass = hash
|
||
s.adminSetupNeeded = false
|
||
logger.Info("admin", "管理员注册成功", "username", req.Username)
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||
}
|
||
|
||
// ========== 重置管理员(设置页入口) ==========
|
||
|
||
type resetAdminReq struct {
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
|
||
// handleResetAdmin 重置管理员凭据:验证当前密码后清空 DB 与内存凭据,系统回到首次注册态。
|
||
// 安全:需登录 + 提交当前密码(防误操作/CSRF);重置后所有已发 token 仍有效但登录校验立即切换到注册态,
|
||
// 前端收到 ok 后清除本地 token 并跳转注册页。重置动作记审计日志。
|
||
func (s *Server) handleResetAdmin(c *gin.Context) {
|
||
if s.adminSetupNeeded {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "当前已是未注册状态,无需重置"})
|
||
return
|
||
}
|
||
var req resetAdminReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
// 验证当前管理员密码(防未授权操作)
|
||
if err := bcrypt.CompareHashAndPassword(s.adminPass, []byte(req.Password)); err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "当前密码错误"})
|
||
return
|
||
}
|
||
s.setupMu.Lock()
|
||
defer s.setupMu.Unlock()
|
||
ctx := c.Request.Context()
|
||
// 删除 DB 凭据
|
||
_ = s.st.DeleteSetting(ctx, "admin_username")
|
||
_ = s.st.DeleteSetting(ctx, "admin_password_hash")
|
||
// 清空内存凭据,回到注册态
|
||
s.adminUser = ""
|
||
s.adminPass = nil
|
||
s.adminSetupNeeded = true
|
||
logger.Warn("admin", "管理员凭据已重置,系统回到首次注册态", "operator", c.GetString("username"))
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||
}
|
||
|
||
// ========== Settings ==========
|
||
|
||
func (s *Server) handleSettings(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
dbSettings, err := s.st.GetAllSettings(ctx)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
// 多商户模式:易支付商户凭据已迁移到「上游对接」页(upstreams.db),
|
||
// 系统设置不再返回/管理 epay_pid/epay_key/epay_public_base_url
|
||
delete(dbSettings, "epay_pid")
|
||
delete(dbSettings, "epay_key")
|
||
delete(dbSettings, "epay_public_base_url")
|
||
// 管理员凭据哈希不得暴露给前端(仅 /api/auth/* 使用)
|
||
delete(dbSettings, "admin_username")
|
||
delete(dbSettings, "admin_password_hash")
|
||
// 账本私钥 / QR token 等完整密钥不随常规设置下发,仅由 /api/settings/keys 确认后返回
|
||
delete(dbSettings, "ledger_private_key")
|
||
delete(dbSettings, "qr_token_secret")
|
||
// 合并通道启用状态
|
||
channels := channel.AllNames()
|
||
channelEnabled := make(map[string]bool)
|
||
for _, name := range channels {
|
||
channelEnabled[name] = channel.IsEnabled(name)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||
"settings": dbSettings,
|
||
"channel_enabled": channelEnabled,
|
||
"channels": channels,
|
||
}})
|
||
}
|
||
|
||
// handleSettingsKeys 返回安全密钥状态与完整值(需登录鉴权)。
|
||
// 仅返回有实际意义的密钥:账本私钥 / QR token 密钥;调用即记审计日志。
|
||
func (s *Server) handleSettingsKeys(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
dbSettings, err := s.st.GetAllSettings(ctx)
|
||
if err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
keys := gin.H{}
|
||
if v, ok := dbSettings["ledger_private_key"]; ok && v != "" {
|
||
keys["ledger_private_key"] = v
|
||
}
|
||
if v, ok := dbSettings["qr_token_secret"]; ok && v != "" {
|
||
keys["qr_token_secret"] = v
|
||
}
|
||
logger.Info("admin", "安全密钥被查看(明文)", "operator", c.GetString("username"))
|
||
c.JSON(http.StatusOK, gin.H{"data": keys})
|
||
}
|
||
|
||
type settingsUpdate struct {
|
||
Key string `json:"key"`
|
||
Value string `json:"value"`
|
||
}
|
||
|
||
func (s *Server) handleUpdateSettings(c *gin.Context) {
|
||
var req struct {
|
||
Settings []settingsUpdate `json:"settings"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
for _, item := range req.Settings {
|
||
// 多商户模式:易支付商户凭据一律禁止通过系统设置保存(只认「上游对接」页)
|
||
switch item.Key {
|
||
case "epay_pid", "epay_key", "epay_public_base_url":
|
||
logger.Warn("admin", "拒绝保存已废弃的易支付系统设置", "key", item.Key)
|
||
continue
|
||
}
|
||
if err := s.st.SetSetting(ctx, item.Key, item.Value); err != nil {
|
||
s.err(c, err)
|
||
return
|
||
}
|
||
// 通知主进程更新运行时配置(如通道环境切换)
|
||
if s.onSettingsChange != nil {
|
||
s.onSettingsChange(item.Key, item.Value)
|
||
}
|
||
}
|
||
logger.Info("admin", "设置已更新", "count", len(req.Settings))
|
||
c.JSON(http.StatusOK, gin.H{"data": "ok"})
|
||
}
|
||
|
||
func (s *Server) handleToggleChannel(c *gin.Context) {
|
||
name := c.Param("name")
|
||
on := !channel.IsEnabled(name)
|
||
channel.SetEnabled(name, on)
|
||
// 持久化到 settings 表
|
||
ctx := c.Request.Context()
|
||
val := "false"
|
||
if on {
|
||
val = "true"
|
||
}
|
||
_ = s.st.SetSetting(ctx, "channel_enabled_"+name, val)
|
||
logger.Info("admin", "通道启停", "channel", name, "enabled", on)
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"name": name, "enabled": on}})
|
||
}
|
||
|
||
// handleToggleChannelMode 开启/关闭通道的某个支付模式
|
||
func (s *Server) handleToggleChannelMode(c *gin.Context) {
|
||
name := c.Param("name")
|
||
mode := c.Param("mode")
|
||
ch, ok := channel.Lookup(name)
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "通道不存在"})
|
||
return
|
||
}
|
||
// 通过类型断言判断是否支持模式切换
|
||
type modeToggler interface {
|
||
IsModeEnabled(mode string) bool
|
||
SetModeEnabled(mode string, enabled bool)
|
||
EnabledModes() map[string]bool
|
||
}
|
||
toggler, ok := ch.(modeToggler)
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "该通道不支持模式切换"})
|
||
return
|
||
}
|
||
on := !toggler.IsModeEnabled(mode)
|
||
toggler.SetModeEnabled(mode, on)
|
||
// 持久化到 settings 表
|
||
ctx := c.Request.Context()
|
||
val := "false"
|
||
if on {
|
||
val = "true"
|
||
}
|
||
_ = s.st.SetSetting(ctx, "alipay_mode_"+mode, val)
|
||
logger.Info("admin", "通道支付模式启停", "channel", name, "mode", mode, "enabled", on)
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"channel": name, "mode": mode, "enabled": on}})
|
||
}
|
||
|
||
// handleDeviceStream 实时设备流 WebSocket(云手机式操作)
|
||
// GET /api/personal/devices/stream?serial=127.0.0.1:5556&token=<jwt>
|
||
// token 走 query(浏览器 WebSocket 无法自定义 header)
|
||
func (s *Server) handleDeviceStream(c *gin.Context) {
|
||
token := c.Query("token")
|
||
if token == "" {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||
return
|
||
}
|
||
claims := &jwtClaims{}
|
||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||
return s.jwtSecret, nil
|
||
})
|
||
if err != nil || !parsed.Valid {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token 无效或已过期"})
|
||
return
|
||
}
|
||
if s.streamSrv == nil {
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "实时流未启用"})
|
||
return
|
||
}
|
||
s.streamSrv.HandleStream(c.Writer, c.Request)
|
||
}
|
||
|
||
func (s *Server) err(c *gin.Context, err error) {
|
||
logger.Error("admin", "API错误", "err", err, "path", c.Request.URL.Path)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
}
|