Files
past-go/past-go/internal/httpapi/router.go
T
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
2026-11-02 23:40:49 +08:00

268 lines
8.4 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"
"go.uber.org/zap"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
"past/internal/upload"
)
// 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
}
func (r *Router) SetFileStorage(files *upload.Uploader) { r.app.Files = files }
// 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", "wechat_pay_notify")
var adminActions = set("admin_files", "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()
gin.DefaultWriter = zap.NewStdLog(zap.L()).Writer()
gin.DefaultErrorWriter = zap.NewStdLog(zap.L()).Writer()
e.Use(gin.CustomRecovery(func(c *gin.Context, recovered any) {
zap.S().Errorw("http panic", "err", recovered, "stack", string(debug.Stack()))
c.AbortWithStatus(http.StatusInternalServerError)
}), accessLog())
e.RedirectTrailingSlash = false
e.MaxMultipartMemory = 32 << 20
// 简洁 API 路径;旧入口保留给已发布客户端和管理后台。
e.GET("/api/files/:fileId", func(c *gin.Context) {
if r.app.Files == nil {
c.Status(503)
return
}
signed, err := r.app.Files.PresignedURL(c.Request.Context(), c.Param("fileId"))
if err != nil {
c.JSON(404, gin.H{"error": "文件不存在"})
return
}
c.Header("Cache-Control", "no-store")
c.Redirect(http.StatusTemporaryRedirect, signed)
})
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 {
zap.S().Errorw("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
}
if c.DB != nil {
c.DB = c.DB.WithActor(c.uid, adminActions[action] && action != "admin_login")
}
hf, ok := r.handlers[action]
if !ok {
c.Err(400, "未知操作")
return
}
if err := hf(c); err != nil && !IsResponded(err) {
zap.S().Errorw("handler error", "action", action, "err", err)
c.Err(500, err.Error())
}
if !c.responded {
// PHP 中每个分支都以 jsonResponse 结束;兜底返回空对象,避免挂起
c.JSON(200, M{})
}
}
// accessLog gin 中间件:结构化访问日志(zap)
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)
}
zap.S().Infow("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)
}