Files
past-go/past-go/internal/httpapi/ctx.go
T
2026-10-26 17:32:47 +08:00

327 lines
8.2 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 提供与 PHP 版 index.php 等价的请求上下文、路由分发、鉴权和响应封装。
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"past/internal/config"
"past/internal/geo"
"past/internal/store"
"past/internal/wechat"
)
// App 全局依赖
type App struct {
Cfg *config.Config
DB *store.DB
Wechat *wechat.Client
Geo *geo.Client
Admin *AdminSessions
}
// Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。
type Ctx struct {
G *gin.Context
W http.ResponseWriter
R *http.Request
App *App
DB *store.DB
Action string
CamelCase bool
Method string
input map[string]any
inputParsed bool
body []byte
bodyRead bool
uid int64
uidResolved bool
coupleID int64
coupleResolved bool
responded bool
}
// errResponded 表示响应已写出(对应 PHP 的 exit)。
var errResponded = errors.New("responded")
// IsResponded 判断错误是否为“已响应”哨兵
func IsResponded(err error) bool { return errors.Is(err, errResponded) }
// ---------- 响应 ----------
// JSON 对应 jsonResponse($data, $code)。不用 gin 自带的 c.JSON(它会把 <>& 转义成 \u003c),
// 而是自行编码后通过 c.Data 输出,保持与 PHP json_encode(JSON_UNESCAPED_UNICODE) 一致。
func (c *Ctx) JSON(code int, v any) error {
if c.responded {
return errResponded
}
c.responded = true
if c.uidResolved && c.uid != 0 && c.coupleResolved {
c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID))
c.G.Header("X-User-ID", fmt.Sprint(c.uid))
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
c.G.Data(500, "application/json; charset=utf-8", []byte(`{"error":"json encode failed"}`))
return errResponded
}
if c.CamelCase {
var payload any
dec := json.NewDecoder(bytes.NewReader(buf.Bytes()))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return err
}
if c.CamelCase {
payload = renameKeys(payload, camelName)
}
buf.Reset()
if err := enc.Encode(payload); err != nil {
return err
}
}
c.G.Data(code, "application/json; charset=utf-8", bytes.TrimRight(buf.Bytes(), "\n"))
return errResponded
}
// OK 返回 200
func (c *Ctx) OK(v any) error { return c.JSON(200, v) }
// Err 对应 jsonResponse(['error' => $msg], $code)
func (c *Ctx) Err(code int, msg string) error {
return c.JSON(code, M{"error": msg})
}
// ErrCode 带错误码的上传错误:['error' => msg, 'code' => n]
func (c *Ctx) ErrCode(code int, msg string, errCode int) error {
return c.JSON(code, M{"error": msg, "code": errCode})
}
// M 便捷的 JSON 对象类型
type M = map[string]any
// RequirePOST 对应 if ($method !== 'POST') jsonResponse(['error' => '方法不允许'], 405);
func (c *Ctx) RequirePOST() error {
if c.Method != "POST" {
return c.Err(405, "方法不允许")
}
return nil
}
func (c *Ctx) RequireGET() error {
if c.Method != "GET" {
return c.Err(405, "方法不允许")
}
return nil
}
// ---------- 请求体 / 参数 ----------
// Body 原始请求体(php://input)
func (c *Ctx) Body() []byte {
if !c.bodyRead {
c.bodyRead = true
if c.R.Body != nil {
c.body, _ = io.ReadAll(c.R.Body)
}
}
return c.body
}
// Input 对应 $input = json_decode(file_get_contents('php://input'), true);解析失败返回空 map。
func (c *Ctx) Input() map[string]any {
if !c.inputParsed {
c.inputParsed = true
c.input = map[string]any{}
// Multipart fields belong to Form; reading them as JSON would consume the upload stream.
if c.isForm() {
return c.input
}
b := c.Body()
if len(bytes.TrimSpace(b)) > 0 {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var v any
if dec.Decode(&v) == nil {
if m, ok := v.(map[string]any); ok {
if c.CamelCase {
m = renameKeys(m, snakeName).(map[string]any)
}
c.input = normalizeNumbers(m).(map[string]any)
}
}
}
}
return c.input
}
// normalizeNumbers 把 json.Number 转成 int64 或 float64(整数保持整数,对齐 PHP json_decode)
func normalizeNumbers(v any) any {
switch x := v.(type) {
case json.Number:
if i, err := x.Int64(); err == nil {
return i
}
f, _ := x.Float64()
return f
case map[string]any:
for k, val := range x {
x[k] = normalizeNumbers(val)
}
return x
case []any:
for i, val := range x {
x[i] = normalizeNumbers(val)
}
return x
}
return v
}
// In 对应 $input['key'] ?? nil
func (c *Ctx) In(key string) any { return normalizeMediaInput(c.Input()[key]) }
// InHas 对应 isset($input['key'])
func (c *Ctx) InHas(key string) bool {
v, ok := c.Input()[key]
return ok && v != nil
}
// InStr 对应 (string)($input['key'] ?? ”)
func (c *Ctx) InStr(key string) string { return store.ToStr(c.In(key)) }
// InTrim 对应 trim($input['key'] ?? ”)
func (c *Ctx) InTrim(key string) string { return strings.TrimSpace(c.InStr(key)) }
// InInt 对应 intval($input['key'] ?? 0)
func (c *Ctx) InInt(key string) int64 { return store.ToInt(c.In(key)) }
// InFloat 对应 floatval($input['key'] ?? 0)
func (c *Ctx) InFloat(key string) float64 { return store.ToFloat(c.In(key)) }
// InBool 对应 PHP 真值判断
func (c *Ctx) InBool(key string) bool { return store.Truthy(c.In(key)) }
// InList 对应 is_array($input['key']) ? $input['key'] : []
func (c *Ctx) InList(key string) []any {
if l, ok := c.In(key).([]any); ok {
return l
}
return []any{}
}
// InMap 对应关联数组
func (c *Ctx) InMap(key string) map[string]any {
if m, ok := c.In(key).(map[string]any); ok {
return m
}
return nil
}
// Q 对应 $_GET['key'] ?? ”
func (c *Ctx) Q(key string) string {
if c.CamelCase {
if value, ok := c.G.GetQuery(camelName(key)); ok {
return value
}
}
return c.G.Query(key)
}
// QHas 对应 isset($_GET['key'])
func (c *Ctx) QHas(key string) bool {
if c.CamelCase {
if _, ok := c.G.GetQuery(camelName(key)); ok {
return true
}
}
_, ok := c.G.GetQuery(key)
return ok
}
// QInt 对应 intval($_GET['key'] ?? 0)
func (c *Ctx) QInt(key string) int64 { return store.ToInt(c.Q(key)) }
// Form 对应 $_POST['key'] ?? ”(表单/multipart 字段)
func (c *Ctx) Form(key string) string {
if !c.isForm() {
return ""
}
if c.CamelCase {
if value, ok := c.G.GetPostForm(camelName(key)); ok {
return value
}
}
return c.G.PostForm(key)
}
// FormHas 对应 isset($_POST['key'])
func (c *Ctx) FormHas(key string) bool {
if !c.isForm() {
return false
}
if c.CamelCase {
if _, ok := c.G.GetPostForm(camelName(key)); ok {
return true
}
}
_, ok := c.G.GetPostForm(key)
return ok
}
// isForm 只有表单/multipart 请求才解析 $_POST(其它类型的 body 留给 Input() 读取 JSON)
func (c *Ctx) isForm() bool {
ct := c.R.Header.Get("Content-Type")
return strings.HasPrefix(ct, "multipart/form-data") || strings.HasPrefix(ct, "application/x-www-form-urlencoded")
}
// File 对应 $_FILES['name'](单文件);不存在返回 nil。
func (c *Ctx) File(name string) *multipart.FileHeader {
fs := c.Files(name)
if len(fs) == 0 {
return nil
}
return fs[0]
}
// Files 对应 $_FILES['name']:与 PHP 一致,同名字段 name 重复时只保留最后一个;name[] 才是多文件数组
func (c *Ctx) Files(name string) []*multipart.FileHeader {
if !strings.HasPrefix(c.R.Header.Get("Content-Type"), "multipart/form-data") {
return nil
}
form, err := c.G.MultipartForm()
if err != nil || form == nil {
return nil
}
if fs := form.File[name]; len(fs) > 0 {
return fs[len(fs)-1:]
}
return form.File[name+"[]"]
}
// ClientIP 对应 getClientIp():X-Forwarded-For → X-Real-IP → RemoteAddr
// (gin 自带的 ClientIP 只信任 TrustedProxies,这里保持 PHP 的宽松取法,Nginx/frp 链路上由 Nginx 设置头)
func (c *Ctx) ClientIP() string {
if xff := c.G.GetHeader("X-Forwarded-For"); xff != "" {
ip := strings.TrimSpace(strings.Split(xff, ",")[0])
if ip != "" && ip != "unknown" {
return ip
}
}
if xr := strings.TrimSpace(c.G.GetHeader("X-Real-Ip")); xr != "" && xr != "unknown" {
return xr
}
return c.G.RemoteIP()
}