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.
268 lines
8.4 KiB
Go
268 lines
8.4 KiB
Go
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)
|
||
}
|