Files

1214 lines
40 KiB
Go
Executable File
Raw Permalink 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 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()})
}