thread-master/apps/backend/internal/response/response.go

217 lines
10 KiB
Go
Raw Permalink Normal View History

2026-07-10 12:54:45 +00:00
package response
import (
"context"
"errors"
"net/http"
2026-07-13 01:15:30 +00:00
"strings"
2026-07-10 12:54:45 +00:00
"apps/backend/internal/domain"
2026-07-13 01:15:30 +00:00
appnotifDomain "apps/backend/internal/module/appnotif/domain"
2026-07-15 15:23:59 +00:00
billingDomain "apps/backend/internal/module/billing"
inspireDomain "apps/backend/internal/module/inspire/domain"
2026-07-13 01:15:30 +00:00
jobDomain "apps/backend/internal/module/job/domain"
2026-07-10 12:54:45 +00:00
memberDomain "apps/backend/internal/module/member/domain"
2026-07-13 01:15:30 +00:00
scoutDomain "apps/backend/internal/module/scout/domain"
studioDomain "apps/backend/internal/module/studio/domain"
threadsDomain "apps/backend/internal/module/threads/domain"
2026-07-10 12:54:45 +00:00
tokenDomain "apps/backend/internal/module/token/domain"
2026-07-13 01:15:30 +00:00
usageDomain "apps/backend/internal/module/usage/domain"
2026-07-10 12:54:45 +00:00
"github.com/zeromicro/go-zero/rest/httpx"
)
2026-07-13 01:15:30 +00:00
// cleanBizMessage strips domain error prefixes so UI can show Chinese hints directly.
func cleanBizMessage(msg string) string {
msg = strings.TrimSpace(msg)
for _, p := range []string{
"studio validation: ",
"inspire validation: ",
"scout validation: ",
"validation: ",
} {
if strings.HasPrefix(msg, p) {
return strings.TrimSpace(strings.TrimPrefix(msg, p))
}
// also case where errors.Is wraps as "prefix: detail"
if i := strings.Index(msg, ": "); i > 0 {
head := msg[:i]
if strings.Contains(head, "validation") || strings.Contains(head, "ErrValidation") {
return strings.TrimSpace(msg[i+2:])
}
}
}
return msg
}
2026-07-10 12:54:45 +00:00
// Envelope is the standard JSON body (AGENTS.md).
type Envelope struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
Error interface{} `json:"error"`
}
// Write is used by goctl-generated handlers.
func Write(ctx context.Context, w http.ResponseWriter, data interface{}, err error) {
if err != nil {
status, env := mapError(err)
httpx.WriteJsonCtx(ctx, w, status, env)
return
}
httpx.WriteJsonCtx(ctx, w, http.StatusOK, Envelope{
Code: domain.SuccessCode,
Message: domain.SuccessMessage,
Data: data,
Error: nil,
})
}
func WrapRequestError(err error) error {
if err == nil {
return nil
}
return &bizError{http: http.StatusBadRequest, code: 400001, msg: err.Error()}
}
type bizError struct {
http int
code int64
msg string
}
func (e *bizError) Error() string { return e.msg }
func Biz(httpStatus int, code int64, msg string) error {
return &bizError{http: httpStatus, code: code, msg: msg}
}
func mapError(err error) (int, Envelope) {
var be *bizError
if errors.As(err, &be) {
return be.http, Envelope{Code: be.code, Message: be.msg}
}
switch {
2026-07-15 15:23:59 +00:00
case errors.Is(err, billingDomain.ErrDisabled):
return http.StatusServiceUnavailable, Envelope{Code: 503010, Message: err.Error()}
case errors.Is(err, billingDomain.ErrInvalidPlan), errors.Is(err, billingDomain.ErrInvalidRequest):
return http.StatusBadRequest, Envelope{Code: 400080, Message: err.Error()}
case errors.Is(err, billingDomain.ErrInvalidSignature):
return http.StatusBadRequest, Envelope{Code: 400081, Message: "invalid webhook signature"}
case errors.Is(err, billingDomain.ErrNotFound):
return http.StatusNotFound, Envelope{Code: 404080, Message: err.Error()}
case errors.Is(err, billingDomain.ErrConflict):
return http.StatusConflict, Envelope{Code: 409080, Message: err.Error()}
case errors.Is(err, billingDomain.ErrCustomerRequired):
return http.StatusConflict, Envelope{Code: 409081, Message: err.Error()}
2026-07-10 12:54:45 +00:00
case errors.Is(err, memberDomain.ErrBadPassword):
return http.StatusUnauthorized, Envelope{Code: 401010, Message: "invalid email or password"}
case errors.Is(err, memberDomain.ErrSuspended):
return http.StatusForbidden, Envelope{Code: 403001, Message: "account suspended"}
case errors.Is(err, memberDomain.ErrEmailTaken):
return http.StatusConflict, Envelope{Code: 409001, Message: "email already registered"}
case errors.Is(err, memberDomain.ErrWeakPassword):
// 與 domain.PasswordPolicyEN、FE api.err.400003 / password.policy.hint 同一句
return http.StatusBadRequest, Envelope{Code: 400003, Message: memberDomain.PasswordPolicyEN}
case errors.Is(err, memberDomain.ErrInvalidCode):
return http.StatusBadRequest, Envelope{Code: 400004, Message: "invalid or expired code"}
2026-07-15 15:23:59 +00:00
case errors.Is(err, memberDomain.ErrCodeRateLimited):
return http.StatusTooManyRequests, Envelope{Code: 429001, Message: "verification code requested too frequently"}
2026-07-10 12:54:45 +00:00
case errors.Is(err, memberDomain.ErrEmailNotRegistered):
return http.StatusNotFound, Envelope{Code: 404002, Message: "email not registered"}
case errors.Is(err, memberDomain.ErrNotFound):
return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"}
case errors.Is(err, memberDomain.ErrLastAdmin):
return http.StatusBadRequest, Envelope{Code: 400021, Message: err.Error()}
case errors.Is(err, memberDomain.ErrSelfSuspend):
return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()}
2026-07-13 03:18:08 +00:00
// 邀請message 為 FE i18n keyinvite.err.*
case errors.Is(err, memberDomain.ErrInviteAlreadyBound),
errors.Is(err, memberDomain.ErrInviteCodeRequired),
errors.Is(err, memberDomain.ErrInviteCodeNotFound),
errors.Is(err, memberDomain.ErrInviteCodeSelf),
errors.Is(err, memberDomain.ErrInviteCycle),
errors.Is(err, memberDomain.ErrInviteSelfParent),
errors.Is(err, memberDomain.ErrInviteParentMiss):
return http.StatusBadRequest, Envelope{Code: 400070, Message: err.Error()}
case errors.Is(err, memberDomain.ErrInviteNeedAdmin):
return http.StatusForbidden, Envelope{Code: 403002, Message: err.Error()}
2026-07-10 12:54:45 +00:00
case errors.Is(err, tokenDomain.ErrInvalidToken):
return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"}
2026-07-13 01:15:30 +00:00
case errors.Is(err, threadsDomain.ErrNotFound), errors.Is(err, jobDomain.ErrNotFound),
errors.Is(err, appnotifDomain.ErrNotFound):
return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"}
case errors.Is(err, threadsDomain.ErrForbidden), errors.Is(err, jobDomain.ErrForbidden),
errors.Is(err, appnotifDomain.ErrForbidden):
return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
case errors.Is(err, threadsDomain.ErrOAuthDenied):
return http.StatusBadRequest, Envelope{Code: 400031, Message: "oauth denied"}
case errors.Is(err, threadsDomain.ErrOAuthExchange), errors.Is(err, threadsDomain.ErrInvalidState):
return http.StatusBadRequest, Envelope{Code: 400032, Message: err.Error()}
case errors.Is(err, threadsDomain.ErrRefreshFailed):
return http.StatusBadRequest, Envelope{Code: 400033, Message: "session refresh failed"}
case errors.Is(err, jobDomain.ErrIllegalStatus):
return http.StatusConflict, Envelope{Code: 409010, Message: "illegal job status transition"}
case errors.Is(err, usageDomain.ErrNoKey):
return http.StatusBadRequest, Envelope{Code: 400040, Message: "no api key configured (platform or byok)"}
case errors.Is(err, usageDomain.ErrQuotaExceeded):
return http.StatusPaymentRequired, Envelope{Code: 402001, Message: "platform credits insufficient"}
2026-07-13 03:18:08 +00:00
case errors.Is(err, usageDomain.ErrMeterCapExceeded):
// message = FE i18n key
return http.StatusPaymentRequired, Envelope{Code: 402002, Message: err.Error()}
case errors.Is(err, usageDomain.ErrPlatformCapacity):
// 平台 key 限流/無容量:建議改 BYOKmessage = FE i18n key
return http.StatusServiceUnavailable, Envelope{Code: 503002, Message: err.Error()}
2026-07-13 01:15:30 +00:00
case errors.Is(err, usageDomain.ErrForbidden):
return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
case errors.Is(err, usageDomain.ErrInvalidPlan):
return http.StatusBadRequest, Envelope{Code: 400041, Message: "invalid plan"}
2026-07-15 15:23:59 +00:00
case errors.Is(err, usageDomain.ErrPurchaseDisabled):
return http.StatusGone, Envelope{Code: 410003, Message: err.Error()}
2026-07-13 01:15:30 +00:00
case errors.Is(err, studioDomain.ErrNotFound):
return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"}
case errors.Is(err, studioDomain.ErrForbidden):
return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
case errors.Is(err, studioDomain.ErrValidation), errors.Is(err, studioDomain.ErrBadURL):
return http.StatusBadRequest, Envelope{Code: 400050, Message: cleanBizMessage(err.Error())}
case errors.Is(err, studioDomain.ErrNoAccount):
return http.StatusBadRequest, Envelope{Code: 400051, Message: "no usable threads account"}
case errors.Is(err, studioDomain.ErrIllegalStatus), errors.Is(err, studioDomain.ErrRootBlocked):
return http.StatusConflict, Envelope{Code: 409020, Message: err.Error()}
case errors.Is(err, studioDomain.ErrFormulaRemove):
return http.StatusGone, Envelope{Code: 410001, Message: "generateFromFormula removed"}
case errors.Is(err, inspireDomain.ErrNotFound), errors.Is(err, scoutDomain.ErrNotFound):
return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"}
case errors.Is(err, inspireDomain.ErrForbidden), errors.Is(err, scoutDomain.ErrForbidden):
return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
case errors.Is(err, inspireDomain.ErrValidation), errors.Is(err, scoutDomain.ErrValidation):
return http.StatusBadRequest, Envelope{Code: 400060, Message: cleanBizMessage(err.Error())}
case errors.Is(err, inspireDomain.ErrRemoved), errors.Is(err, scoutDomain.ErrTopicRemoved):
return http.StatusGone, Envelope{Code: 410002, Message: err.Error()}
case errors.Is(err, scoutDomain.ErrNoCrawlerSession):
return http.StatusBadRequest, Envelope{Code: 400061, Message: "crawler session required when dev_mode enabled"}
case errors.Is(err, scoutDomain.ErrHasProducts):
return http.StatusConflict, Envelope{Code: 409030, Message: "brand has products; remove products first"}
2026-07-10 12:54:45 +00:00
default:
2026-07-15 15:23:59 +00:00
return http.StatusInternalServerError, Envelope{Code: 500000, Message: "internal server error"}
2026-07-10 12:54:45 +00:00
}
}
func OK(w http.ResponseWriter, data interface{}) {
Write(context.Background(), w, data, nil)
}
func Fail(w http.ResponseWriter, httpStatus int, code int64, message string, errDetail interface{}) {
if code == domain.SuccessCode {
code = 400000
}
httpx.WriteJson(w, httpStatus, Envelope{Code: code, Message: message, Data: nil, Error: errDetail})
}
func NotImplemented(w http.ResponseWriter, feature string) {
Fail(w, http.StatusNotImplemented, 501000, "not implemented: "+feature, map[string]string{"feature": feature})
}
func IsSuccessCode(code int64) bool { return code == domain.SuccessCode }