- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询 - 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order) - 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数 - 前端上传前容量预检,超容引导升级无限空间 - 我的页会员卡片展示「无限空间」与已用容量/到期时间 - 后台「存储空间管理」新增支付记录标签页
244 lines
7.5 KiB
Go
244 lines
7.5 KiB
Go
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)
|
||
}
|