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 }