217 lines
10 KiB
Go
217 lines
10 KiB
Go
package response
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"apps/backend/internal/domain"
|
||
appnotifDomain "apps/backend/internal/module/appnotif/domain"
|
||
billingDomain "apps/backend/internal/module/billing"
|
||
inspireDomain "apps/backend/internal/module/inspire/domain"
|
||
jobDomain "apps/backend/internal/module/job/domain"
|
||
memberDomain "apps/backend/internal/module/member/domain"
|
||
scoutDomain "apps/backend/internal/module/scout/domain"
|
||
studioDomain "apps/backend/internal/module/studio/domain"
|
||
threadsDomain "apps/backend/internal/module/threads/domain"
|
||
tokenDomain "apps/backend/internal/module/token/domain"
|
||
usageDomain "apps/backend/internal/module/usage/domain"
|
||
|
||
"github.com/zeromicro/go-zero/rest/httpx"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 {
|
||
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()}
|
||
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"}
|
||
case errors.Is(err, memberDomain.ErrCodeRateLimited):
|
||
return http.StatusTooManyRequests, Envelope{Code: 429001, Message: "verification code requested too frequently"}
|
||
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()}
|
||
// 邀請:message 為 FE i18n key(invite.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()}
|
||
case errors.Is(err, tokenDomain.ErrInvalidToken):
|
||
return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"}
|
||
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"}
|
||
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 限流/無容量:建議改 BYOK;message = FE i18n key
|
||
return http.StatusServiceUnavailable, Envelope{Code: 503002, Message: err.Error()}
|
||
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"}
|
||
case errors.Is(err, usageDomain.ErrPurchaseDisabled):
|
||
return http.StatusGone, Envelope{Code: 410003, Message: err.Error()}
|
||
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"}
|
||
default:
|
||
return http.StatusInternalServerError, Envelope{Code: 500000, Message: "internal server error"}
|
||
}
|
||
}
|
||
|
||
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 }
|