httpx.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. // Package httpx 提供跨领域共用的 HTTP 辅助。
  2. //
  3. // 依据 docs/architecture.md 5.4:统一错误响应构造函数放在这里,
  4. // 所有 handler 调用同一套;错误细节不返回给客户端。
  5. package httpx
  6. import (
  7. "errors"
  8. "log/slog"
  9. "net/http"
  10. "github.com/gin-gonic/gin"
  11. "github.com/kairo/haven/internal/auth"
  12. )
  13. // ErrorBody 是 api.md 1.4 定义的错误响应。
  14. // fields 仅在 422 validation_failed 时出现。
  15. type ErrorBody struct {
  16. Error string `json:"error"`
  17. Message string `json:"message"`
  18. Fields map[string]string `json:"fields,omitempty"`
  19. }
  20. // 错误码常量,取值见 docs/api.md 1.4。
  21. const (
  22. CodeBadRequest = "bad_request"
  23. CodeUnauthorized = "unauthorized"
  24. CodeForbidden = "forbidden"
  25. CodeNotFound = "not_found"
  26. CodeConflict = "conflict"
  27. CodeValidationFailed = "validation_failed"
  28. CodeInternal = "internal_error"
  29. )
  30. // FailInternal 返回 500,细节只进日志(api.md 1.4、architecture.md 5.4)。
  31. func FailInternal(c *gin.Context, err error) {
  32. if err != nil {
  33. slog.Error("请求处理失败", "method", c.Request.Method, "path", c.Request.URL.Path, "err", err)
  34. }
  35. c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorBody{
  36. Error: CodeInternal,
  37. Message: "服务端错误",
  38. })
  39. }
  40. // FailUnauthorized 返回 401。
  41. func FailUnauthorized(c *gin.Context) {
  42. FailUnauthorizedWith(c, "未登录或凭证无效")
  43. }
  44. // FailUnauthorizedWith 返回 401,并带自定义 message(如“动态码错误”)。
  45. func FailUnauthorizedWith(c *gin.Context, message string) {
  46. c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
  47. Error: CodeUnauthorized,
  48. Message: message,
  49. })
  50. }
  51. // FailBadRequest 返回 400(JSON 解析失败等),解析细节只进日志。
  52. func FailBadRequest(c *gin.Context, err error) {
  53. if err != nil {
  54. slog.Debug("请求体解析失败", "method", c.Request.Method, "path", c.Request.URL.Path, "err", err)
  55. }
  56. FailBadRequestWith(c, "请求体格式错误")
  57. }
  58. // FailBadRequestWith 返回 400,并带自定义 message。
  59. func FailBadRequestWith(c *gin.Context, message string) {
  60. c.AbortWithStatusJSON(http.StatusBadRequest, ErrorBody{
  61. Error: CodeBadRequest,
  62. Message: message,
  63. })
  64. }
  65. // FailNotFound 返回 404。
  66. func FailNotFound(c *gin.Context) {
  67. c.AbortWithStatusJSON(http.StatusNotFound, ErrorBody{
  68. Error: CodeNotFound,
  69. Message: "资源不存在",
  70. })
  71. }
  72. // FailConflict 返回 409。
  73. func FailConflict(c *gin.Context, message string) {
  74. c.AbortWithStatusJSON(http.StatusConflict, ErrorBody{
  75. Error: CodeConflict,
  76. Message: message,
  77. })
  78. }
  79. // FailUnprocessable 返回 422,但不带 fields(语义化校验失败)。
  80. func FailUnprocessable(c *gin.Context, message string) {
  81. c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
  82. Error: CodeValidationFailed,
  83. Message: message,
  84. })
  85. }
  86. // FailForbidden 返回 403。
  87. func FailForbidden(c *gin.Context) {
  88. c.AbortWithStatusJSON(http.StatusForbidden, ErrorBody{
  89. Error: CodeForbidden,
  90. Message: "权限不足",
  91. })
  92. }
  93. // FailValidation 返回 422,并带 fields。
  94. func FailValidation(c *gin.Context, fields map[string]string) {
  95. c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
  96. Error: CodeValidationFailed,
  97. Message: "字段校验失败",
  98. Fields: fields,
  99. })
  100. }
  101. // Fail 按领域错误映射成 api.md 1.4 的响应。
  102. //
  103. // 只映射认证包已定义的业务错误;未识别的错误一律 500。
  104. func Fail(c *gin.Context, err error) {
  105. switch {
  106. case errors.Is(err, auth.ErrInvalidCredentials):
  107. c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
  108. Error: CodeUnauthorized,
  109. Message: auth.ErrInvalidCredentials.Error(),
  110. })
  111. case errors.Is(err, auth.ErrPendingExpired):
  112. c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
  113. Error: CodeUnauthorized,
  114. Message: auth.ErrPendingExpired.Error(),
  115. })
  116. case errors.Is(err, auth.ErrBadTotp):
  117. c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
  118. Error: CodeUnauthorized,
  119. Message: auth.ErrBadTotp.Error(),
  120. })
  121. case errors.Is(err, auth.ErrTotpAlreadyEnabled):
  122. c.AbortWithStatusJSON(http.StatusConflict, ErrorBody{
  123. Error: CodeConflict,
  124. Message: auth.ErrTotpAlreadyEnabled.Error(),
  125. })
  126. case errors.Is(err, auth.ErrTotpNotEnabled):
  127. FailValidation(c, map[string]string{"code": "尚未启用 TOTP"})
  128. case errors.Is(err, auth.ErrNotFound):
  129. c.AbortWithStatusJSON(http.StatusNotFound, ErrorBody{
  130. Error: CodeNotFound,
  131. Message: "资源不存在",
  132. })
  133. default:
  134. FailInternal(c, err)
  135. }
  136. }
  137. // InstallAuthErrorHandlers 把统一错误响应注入 auth 包,
  138. // 使鉴权中间件不必依赖 httpx(避免包循环)。
  139. func InstallAuthErrorHandlers() {
  140. auth.SetErrorHandlers(
  141. func(c *gin.Context, _ error) { FailUnauthorized(c) },
  142. func(c *gin.Context, _ error) { FailForbidden(c) },
  143. FailInternal,
  144. )
  145. }
苏ICP备2026082406号
苏公网安备 待审核