317 lines
7.9 KiB
Go
317 lines
7.9 KiB
Go
// Package httpapi 提供与 PHP 版 index.php 等价的请求上下文、路由分发、鉴权和响应封装。
|
||
package httpapi
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"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
|
||
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
|
||
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
|
||
}
|
||
buf.Reset()
|
||
if err := enc.Encode(renameKeys(payload, camelName)); 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 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()
|
||
}
|