Files
past-go/past-go/internal/httpapi/router.go
T
CodeBuddy bf99e52937 feat: 存储空间管理、虚拟支付接入与容量守卫
- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询
- 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order)
- 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数
- 前端上传前容量预检,超容引导升级无限空间
- 我的页会员卡片展示「无限空间」与已用容量/到期时间
- 后台「存储空间管理」新增支付记录标签页
2026-10-30 23:45:39 +08:00

244 lines
7.5 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 httpapi
import (
"bytes"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// HandlerFunc 一个 action 的处理函数。返回 errResponded(由 c.JSON 产生)表示正常结束;
// 返回其它 error 对应 PHP 顶层 catch → 500 {"error": message}。
type HandlerFunc func(c *Ctx) error
type Router struct {
app *App
handlers map[string]HandlerFunc
adminFS fs.FS // React 后台构建产物(embed)
}
func NewRouter(app *App, adminFS fs.FS) *Router {
return &Router{app: app, handlers: map[string]HandlerFunc{}, adminFS: adminFS}
}
// Handle 注册 action
func (r *Router) Handle(action string, h HandlerFunc) {
if _, dup := r.handlers[action]; dup {
panic("duplicate action: " + action)
}
r.handlers[action] = h
}
// Actions 已注册的 action 列表(用于自检)
func (r *Router) Actions() []string {
out := make([]string, 0, len(r.handlers))
for k := range r.handlers {
out = append(out, k)
}
return out
}
// 与 index.php 中 $publicActions / $adminActions 完全一致
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
"check_invite_code", "register_by_invite", "check_auth", "storage_pay_notify")
var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders")
func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items))
for _, s := range items {
m[s] = true
}
return m
}
var actionInURIRe = regexp.MustCompile(`[?&]action=([^&]+)`)
// Engine 构建 gin 引擎:API 入口、上传文件静态服务、后台 SPA。
func (r *Router) Engine(debugMode bool) *gin.Engine {
if debugMode {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
e := gin.New()
e.Use(gin.Recovery(), accessLog())
e.RedirectTrailingSlash = false
e.MaxMultipartMemory = 32 << 20
// 简洁 API 路径;旧入口保留给已发布客户端和管理后台。
e.Any("/api", r.serveAPI)
e.Any("/api/", r.serveAPI)
e.Any("/api/:action", r.serveAPI)
e.Any("/backend/api/index.php", r.serveAPI)
e.Any("/backend/api/", r.serveAPI)
e.Any("/backend/api", r.serveAPI)
// 上传文件:DB 中路径 uploads/... → URL /backend/uploads/...(gin.Dir 关闭目录列表;缓存 1 个月,对应 .htaccess)
uploadDir := r.app.Cfg.UploadDir()
uploads := e.Group("/backend/uploads", func(c *gin.Context) {
// 只对真实存在的文件加长缓存;404 不能被浏览器缓存(否则文件补上后仍显示不出来)
p := filepath.Join(uploadDir, filepath.FromSlash(strings.TrimPrefix(c.Param("filepath"), "/")))
if st, err := os.Stat(p); err == nil && !st.IsDir() && !strings.Contains(c.Param("filepath"), "..") {
c.Header("Cache-Control", "public, max-age=2592000")
} else {
c.Header("Cache-Control", "no-store")
}
})
uploads.StaticFS("/", gin.Dir(uploadDir, false))
// 管理后台 SPA
e.GET("/backend/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/backend/admin/") })
e.GET("/backend/admin/*filepath", r.serveAdmin)
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
return e
}
func (r *Router) serveAPI(g *gin.Context) {
w, req := g.Writer, g.Request
// CORS(与 PHP 一致)
h := w.Header()
// API 响应按用户身份变化,且权限可能随时修改,不能复用旧的 GET 结果。
h.Set("Cache-Control", "no-store")
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if req.Method == http.MethodOptions {
w.WriteHeader(200)
return
}
c := &Ctx{G: g, W: w, R: req, App: r.app, DB: r.app.DB, Method: req.Method}
g.Set("action", "")
// 路径中的 action 优先,兼容旧客户端的查询和表单参数。
action := g.Param("action")
if action == "" {
action = req.URL.Query().Get("action")
}
if action == "" {
action = c.Form("action")
}
if action == "" {
if m := actionInURIRe.FindStringSubmatch(req.RequestURI); m != nil {
if v, err := url.QueryUnescape(m[1]); err == nil {
action = v
}
}
}
// A camelCase path opts into camelCase request/response fields. Snake paths retain legacy payloads.
publicAction := action
c.CamelCase = g.Param("action") != "" && !strings.Contains(action, "_")
action = snakeName(action)
c.Action = action
g.Set("action", publicAction)
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic", "action", action, "err", rec, "stack", string(debug.Stack()))
if !c.responded {
c.Err(500, fmt.Sprint(rec))
}
}
g.Set("uid", c.uid)
}()
if action == "" {
c.JSON(200, M{
"name": "我们past API",
"version": "2.0.0",
"publicEndpoints": []string{"register", "login", "wechatLogin", "siteInfo", "siteStats", "adminLogin", "checkInviteCode", "registerByInvite"},
})
return
}
// 权限检查(与 PHP 的 if/elseif 顺序一致)
var err error
if adminActions[action] && action != "admin_login" {
err = c.requireAdminLogin()
} else if !publicActions[action] && !adminActions[action] {
err = c.requireUserLogin()
}
if err != nil {
if !IsResponded(err) {
c.Err(500, err.Error())
}
return
}
hf, ok := r.handlers[action]
if !ok {
c.Err(400, "未知操作")
return
}
if err := hf(c); err != nil && !IsResponded(err) {
slog.Error("handler error", "action", action, "err", err)
c.Err(500, err.Error())
}
if !c.responded {
// PHP 中每个分支都以 jsonResponse 结束;兜底返回空对象,避免挂起
c.JSON(200, M{})
}
}
// accessLog gin 中间件:结构化访问日志(slog)
func accessLog() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
attrs := []any{"method", c.Request.Method, "path", c.Request.URL.Path, "status", c.Writer.Status(), "ms", time.Since(start).Milliseconds()}
if a, ok := c.Get("action"); ok {
attrs = append(attrs, "action", a)
}
if uid, ok := c.Get("uid"); ok {
attrs = append(attrs, "uid", uid)
}
slog.Info("http", attrs...)
}
}
// serveAdmin 提供 embed 的后台 SPA;未命中的路径回退到 index.html
func (r *Router) serveAdmin(g *gin.Context) {
p := strings.TrimPrefix(path.Clean("/"+g.Param("filepath")), "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(r.adminFS, p); err != nil {
p = "index.html"
}
if strings.HasSuffix(p, ".html") {
g.Header("Cache-Control", "no-cache")
}
// 不用 http.FileServer:它会把 /index.html 301 到 ./
f, err := r.adminFS.Open(p)
if err != nil {
g.Status(404)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
g.Status(404)
return
}
rs, ok := f.(io.ReadSeeker)
if !ok {
b, _ := io.ReadAll(f)
rs = bytes.NewReader(b)
}
http.ServeContent(g.Writer, g.Request, p, st.ModTime(), rs)
}