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 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()}
|
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 限流/無容量:建議改 BYOK;message = 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 }
|