171 lines
5.0 KiB
Go
171 lines
5.0 KiB
Go
package response
|
||
|
||
import (
|
||
"bytes"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// Response 统一响应结构
|
||
//
|
||
// M-38/M-39 修复(breaking):Data/RequestID 去掉 omitempty,让 data 与 request_id
|
||
// 字段在所有响应中恒存在(失败时 data=null、未装 RequestID 中间件时 request_id="")。
|
||
// 原实现用 omitempty 导致失败响应无 data 字段、空切片被 omit、未装中间件时无 request_id,
|
||
// 破坏"统一响应结构"契约,下游严格按 schema 解析会出错。
|
||
type Response struct {
|
||
Code int `json:"code"`
|
||
Msg string `json:"msg"`
|
||
Data any `json:"data"`
|
||
RequestID string `json:"request_id"` // 请求追踪ID
|
||
}
|
||
|
||
// PageData 分页数据结构
|
||
type PageData struct {
|
||
Items any `json:"items"`
|
||
Total int64 `json:"total"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"page_size"`
|
||
}
|
||
|
||
// getRequestID 从上下文获取请求ID
|
||
func getRequestID(c *gin.Context) string {
|
||
return c.GetString("request_id")
|
||
}
|
||
|
||
// Success 成功响应
|
||
func Success(c *gin.Context, data any) {
|
||
c.JSON(http.StatusOK, Response{
|
||
Code: CodeSuccess,
|
||
Msg: "操作成功",
|
||
Data: data,
|
||
RequestID: getRequestID(c),
|
||
})
|
||
}
|
||
|
||
// SuccessWithMsg 成功响应(自定义消息)
|
||
func SuccessWithMsg(c *gin.Context, msg string, data any) {
|
||
c.JSON(http.StatusOK, Response{
|
||
Code: CodeSuccess,
|
||
Msg: msg,
|
||
Data: data,
|
||
RequestID: getRequestID(c),
|
||
})
|
||
}
|
||
|
||
// Fail 失败响应
|
||
func Fail(c *gin.Context, msg string) {
|
||
writeResp(c, CodeFail, msg, nil)
|
||
}
|
||
|
||
// FailWithCode 失败响应(自定义错误码)
|
||
func FailWithCode(c *gin.Context, code int, msg string) {
|
||
writeResp(c, code, msg, nil)
|
||
}
|
||
|
||
// Unauthorized 未授权响应
|
||
func Unauthorized(c *gin.Context, msg string) {
|
||
writeResp(c, CodeUnauthorized, msg, nil)
|
||
}
|
||
|
||
// NotFound 资源不存在响应
|
||
func NotFound(c *gin.Context, msg string) {
|
||
writeResp(c, CodeNotFound, msg, nil)
|
||
}
|
||
|
||
// ServerError 服务器错误响应
|
||
func ServerError(c *gin.Context, msg string) {
|
||
writeResp(c, CodeServerError, msg, nil)
|
||
}
|
||
|
||
// RateLimit 请求过于频繁响应
|
||
func RateLimit(c *gin.Context) {
|
||
writeResp(c, CodeRateLimit, "请求过于频繁,请稍后再试", nil)
|
||
}
|
||
|
||
// Page 分页响应
|
||
func Page(c *gin.Context, items any, total int64, page, pageSize int) {
|
||
Success(c, PageData{
|
||
Items: items,
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
})
|
||
}
|
||
|
||
// contentDisposition 生成 RFC 5987 兼容的 Content-Disposition 值(M6 修复)。
|
||
// 同时给出 ASCII filename(向后兼容旧客户端)与 filename*(UTF-8 百分号编码,支持中文等非 ASCII),
|
||
// 避免直接拼接导致中文文件名乱码。
|
||
func contentDisposition(filename string) string {
|
||
ascii := asciiFallbackName(filename)
|
||
enc := url.PathEscape(filename)
|
||
// PathEscape 把空格编码为 %20(符合 RFC 5987),无需额外处理。
|
||
return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + enc
|
||
}
|
||
|
||
// asciiFallbackName 生成仅含 ASCII 的回退文件名:非 ASCII 字符替换为下划线,
|
||
// 空文件名回退为 "download"。
|
||
func asciiFallbackName(filename string) string {
|
||
if filename == "" {
|
||
return "download"
|
||
}
|
||
b := make([]byte, 0, len(filename))
|
||
for i := 0; i < len(filename); i++ {
|
||
c := filename[i]
|
||
if c >= 0x20 && c < 0x7f && c != '"' && c != '\\' {
|
||
b = append(b, c)
|
||
} else {
|
||
b = append(b, '_')
|
||
}
|
||
}
|
||
if len(b) == 0 {
|
||
return "download"
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// Download 文件下载响应
|
||
// Compatibility note: for large files or object-storage streams, prefer
|
||
// DownloadReader so the whole object is not buffered in []byte first.
|
||
func Download(c *gin.Context, filename string, data []byte) {
|
||
DownloadReader(c, filename, "application/octet-stream", int64(len(data)), bytesReader(data))
|
||
}
|
||
|
||
// DownloadWithContentType 文件下载(自定义Content-Type)
|
||
func DownloadWithContentType(c *gin.Context, filename string, contentType string, data []byte) {
|
||
DownloadReader(c, filename, contentType, int64(len(data)), bytesReader(data))
|
||
}
|
||
|
||
// DownloadReader 流式下载响应。
|
||
//
|
||
// 对大文件/对象存储下载优先使用此函数,避免先把完整内容读入 []byte 驻留内存。
|
||
// contentLength 传 -1 表示未知长度;Gin 会省略 Content-Length。
|
||
func DownloadReader(c *gin.Context, filename, contentType string, contentLength int64, r io.Reader) {
|
||
if contentType == "" {
|
||
contentType = "application/octet-stream"
|
||
}
|
||
headers := map[string]string{
|
||
"Content-Disposition": contentDisposition(filename),
|
||
}
|
||
c.DataFromReader(http.StatusOK, contentLength, contentType, r, headers)
|
||
}
|
||
|
||
// bytesReader 避免在公开 API 中暴露 bytes 包细节,同时让旧 []byte API 复用流式写路径。
|
||
func bytesReader(data []byte) io.Reader {
|
||
return bytes.NewReader(data)
|
||
}
|
||
|
||
// HTML HTML内容响应
|
||
// Security note: HTML writes raw markup and does not escape untrusted input.
|
||
func HTML(c *gin.Context, data string) {
|
||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||
c.String(http.StatusOK, data)
|
||
}
|
||
|
||
// Redirect 页面跳转
|
||
func Redirect(c *gin.Context, code int, url string) {
|
||
c.Redirect(code, url)
|
||
}
|