diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go index 6056e36..bafcdc5 100644 --- a/internal/httpapi/response.go +++ b/internal/httpapi/response.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "time" + "unicode" ) const ( @@ -23,6 +24,85 @@ type apiError struct { ServerTime string `json:"server_time"` } +var apiErrorMessages = map[string]string{ + "MALFORMED_REQUEST": "请求参数格式错误", + "METHOD_NOT_ALLOWED": "当前请求方式不支持", + "AUTH_REQUIRED": "登录已失效,请重新登录", + "FORBIDDEN": "当前账户无权执行此操作", + "INTERNAL": "服务异常,请稍后重试", + "SERVICE_UNAVAILABLE": "服务暂不可用,请稍后重试", + "USERNAME_INVALID_FORMAT": "用户名格式不正确", + "PHONE_INVALID_FORMAT": "手机号码格式不正确", + "PASSWORD_INVALID_FORMAT": "密码格式不正确", + "REFERRAL_CODE_INVALID": "邀请码无效,请检查后重新输入", + "PRIVACY_POLICY_NOT_ACCEPTED": "请先阅读并同意用户协议和隐私政策", + "ACCOUNT_ALREADY_REGISTERED": "该账户或手机号已经注册", + "INVALID_CREDENTIALS": "手机号或密码错误", + "RATE_LIMITED": "操作过于频繁,请稍后再试", + "AVATAR_ATTACHMENT_INVALID": "头像图片无效,请重新上传", + "CURRENT_PASSWORD_INVALID": "当前登录密码不正确", + "LOGIN_PASSWORD_INVALID": "登录密码不正确", + "NEW_PASSWORD_INVALID": "新密码格式不正确", + "OLD_TRADE_PASSWORD_REQUIRED": "请输入原支付密码", + "OLD_TRADE_PASSWORD_INVALID": "原支付密码不正确", + "TRADE_PASSWORD_INVALID": "支付密码不正确", + "TRADE_PASSWORD_NOT_SET": "请先设置支付密码", + "ACCOUNT_NOT_FOUND": "账户不存在", + "ACCOUNT_CHANGED": "账户信息已发生变化,请重试", + "CATEGORY_ID_INVALID": "商品分类参数不正确", + "PRODUCT_ID_INVALID": "商品参数不正确", + "PRODUCT_NOT_FOUND": "商品不存在或已下架", + "PAGE_INVALID": "页码参数不正确", + "PAGE_SIZE_INVALID": "每页数量参数不正确", + "IMAGE_INVALID": "图片格式或大小不符合要求", + "HALL_ID_INVALID": "任务大厅参数不正确", + "ORDER_ID_INVALID": "订单参数不正确", + "ORDER_STATUS_INVALID": "订单状态参数不正确", + "EVIDENCE_INVALID": "任务凭证不符合要求", + "TASK_DISABLED": "领取功能暂未开放", + "OUTSIDE_BUSINESS_HOURS": "当前不在营业时间内", + "INSUFFICIENT_BALANCE": "可用余额不足", + "DAILY_LIMIT_REACHED": "今日领取次数已用完", + "LEVEL_NOT_ELIGIBLE": "当前会员等级不满足领取条件", + "ACCOUNT_RESTRICTED": "账户状态受限,请联系客服", + "PENDING_ORDER_EXISTS": "存在未完成的任务,请先处理", + "NO_TASK_AVAILABLE": "当前暂无可领取任务", + "ORDER_NOT_FOUND": "任务订单不存在", + "ORDER_STATE_INVALID": "当前任务状态不允许此操作", + "ORDER_EXPIRED": "该任务已超时", + "RESOURCE_NOT_FOUND": "请求的内容不存在", + "CUSTOMER_SERVICE_NOT_FOUND": "客服已下线或不存在", + "CUSTOMER_SERVICE_NOT_BOUND": "客服账号尚未配置", + "IM_RELATION_PENDING": "好友申请已发送,请等待客服确认", + "IM_RELATION_NOT_ALLOWED": "当前无法与该客服建立会话", + "IM_SERVICE_UNAVAILABLE": "客服系统暂不可用,请稍后重试", + "ACCOUNT_TYPE_INVALID": "收款账户类型不正确", + "PAYMENT_ACCOUNT_FIELDS_INVALID": "收款账户信息填写不完整或格式不正确", + "CURRENCY_INVALID": "币种参数不正确", + "WITHDRAWAL_AMOUNT_INVALID": "取出金额不符合当前规则", + "RECHARGE_AMOUNT_INVALID": "储入金额不符合当前规则", + "RECHARGE_REMARK_INVALID": "储入备注格式不正确", + "RECHARGE_VOUCHER_INVALID": "储入凭证无效,请重新上传", + "IDEMPOTENCY_KEY_INVALID": "请求标识无效,请刷新后重试", + "LEDGER_FILTER_INVALID": "账变记录筛选条件不正确", + "WITHDRAWAL_STATUS_INVALID": "取出状态参数不正确", + "RECHARGE_STATUS_INVALID": "储入状态参数不正确", + "RECHARGE_ORDER_ID_INVALID": "储入订单参数不正确", + "WITHDRAWAL_ORDER_ID_INVALID": "取出订单参数不正确", + "PAYMENT_ACCOUNT_EXISTS": "该收款账户已经存在", + "PAYMENT_ACCOUNT_INVALID": "收款账户未核验或不可用", + "WITHDRAWAL_DISABLED": "取出功能暂未开放", + "WITHDRAWAL_CHANNEL_UNAVAILABLE": "当前没有可用的取出通道", + "RECHARGE_CHANNEL_UNAVAILABLE": "当前没有可用的储入通道", + "WITHDRAWAL_DAILY_LIMIT_REACHED": "今日取出次数已用完", + "WITHDRAWAL_ORDER_NOT_FOUND": "取出订单不存在", + "RECHARGE_ORDER_NOT_FOUND": "储入订单不存在", + "IDEMPOTENCY_CONFLICT": "请勿重复提交不同的请求", + "QR_ATTACHMENT_INVALID": "二维码图片无效,请重新上传", + "BANNER_POSITION_INVALID": "轮播图位置参数不正确", + "CONTENT_KEY_INVALID": "内容页面参数不正确", +} + func writeSuccess(w http.ResponseWriter, r *http.Request, data any) { writeJSON(w, r, http.StatusOK, map[string]any{ "code": 0, "data": data, "request_id": requestID(r), @@ -31,10 +111,20 @@ func writeSuccess(w http.ResponseWriter, r *http.Request, data any) { } func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) { - writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: message, Details: details, + writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: localizedAPIErrorMessage(reason, message), Details: details, RequestID: requestID(r), ServerTime: time.Now().UTC().Format(time.RFC3339Nano)}) } +func localizedAPIErrorMessage(reason, message string) string { + if localized := apiErrorMessages[reason]; localized != "" { + return localized + } + if strings.IndexFunc(message, func(r rune) bool { return unicode.Is(unicode.Han, r) }) >= 0 { + return message + } + return "请求失败,请稍后重试" +} + func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") if requestID := requestID(r); requestID != "" { diff --git a/internal/httpapi/response_test.go b/internal/httpapi/response_test.go new file mode 100644 index 0000000..f09053d --- /dev/null +++ b/internal/httpapi/response_test.go @@ -0,0 +1,24 @@ +package httpapi + +import "testing" + +func TestLocalizedAPIErrorMessage(t *testing.T) { + tests := []struct { + name string + reason string + message string + want string + }{ + {name: "login password", reason: "LOGIN_PASSWORD_INVALID", message: "login password is invalid", want: "登录密码不正确"}, + {name: "known validation", reason: "PHONE_INVALID_FORMAT", message: "validation failed", want: "手机号码格式不正确"}, + {name: "preserve chinese", reason: "CUSTOM_REASON", message: "自定义中文提示", want: "自定义中文提示"}, + {name: "hide unknown english", reason: "CUSTOM_REASON", message: "unexpected dependency error", want: "请求失败,请稍后重试"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := localizedAPIErrorMessage(test.reason, test.message); got != test.want { + t.Fatalf("localizedAPIErrorMessage() = %q, want %q", got, test.want) + } + }) + } +} diff --git a/web/src/mobile/api.test.ts b/web/src/mobile/api.test.ts index 92c9ec4..74c449a 100644 --- a/web/src/mobile/api.test.ts +++ b/web/src/mobile/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dateTime, dateTimeSeconds } from './api'; +import { dateTime, dateTimeSeconds, errorText } from './api'; describe('Beijing business time display', () => { it.each(['UTC', 'America/New_York', 'Asia/Shanghai'])( @@ -28,3 +28,13 @@ describe('Beijing business time display', () => { expect(dateTimeSeconds('invalid-time')).toBe('--'); }); }); + +describe('Chinese error display', () => { + it('keeps Chinese business messages', () => { + expect(errorText(new Error('登录密码不正确'))).toBe('登录密码不正确'); + }); + + it('does not expose raw English errors to users', () => { + expect(errorText(new Error('login password is invalid'))).toBe('操作失败,请稍后重试'); + }); +}); diff --git a/web/src/mobile/api.ts b/web/src/mobile/api.ts index 1dd0c1f..bc6c851 100644 --- a/web/src/mobile/api.ts +++ b/web/src/mobile/api.ts @@ -5,33 +5,75 @@ const DEVICE_KEY = 'olons_web_device'; const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending'; const reasonMessages: Record = { + MALFORMED_REQUEST: '请求参数格式错误', + METHOD_NOT_ALLOWED: '当前请求方式不支持', AUTH_REQUIRED: '登录已失效,请重新登录', FORBIDDEN: '当前账户无权执行此操作', + USERNAME_INVALID_FORMAT: '用户名格式不正确', + PHONE_INVALID_FORMAT: '手机号码格式不正确', + PASSWORD_INVALID_FORMAT: '密码格式不正确', INVALID_CREDENTIALS: '手机号或密码错误', ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册', REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入', + PRIVACY_POLICY_NOT_ACCEPTED: '请先阅读并同意用户协议和隐私政策', RATE_LIMITED: '操作过于频繁,请稍后再试', + AVATAR_ATTACHMENT_INVALID: '头像图片无效,请重新上传', + CURRENT_PASSWORD_INVALID: '当前登录密码不正确', + LOGIN_PASSWORD_INVALID: '登录密码不正确', + NEW_PASSWORD_INVALID: '新密码格式不正确', + OLD_TRADE_PASSWORD_REQUIRED: '请输入原支付密码', + OLD_TRADE_PASSWORD_INVALID: '原支付密码不正确', + ACCOUNT_NOT_FOUND: '账户不存在', + ACCOUNT_CHANGED: '账户信息已发生变化,请重试', + CATEGORY_ID_INVALID: '商品分类参数不正确', + PRODUCT_ID_INVALID: '商品参数不正确', + PRODUCT_NOT_FOUND: '商品不存在或已下架', + PAGE_INVALID: '页码参数不正确', + PAGE_SIZE_INVALID: '每页数量参数不正确', + HALL_ID_INVALID: '任务大厅参数不正确', + ORDER_ID_INVALID: '订单参数不正确', + ORDER_STATUS_INVALID: '订单状态参数不正确', + EVIDENCE_INVALID: '任务凭证不符合要求', TASK_DISABLED: '领取功能暂未开放', OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内', INSUFFICIENT_BALANCE: '可用余额不足299元,暂时无法领取', DAILY_LIMIT_REACHED: '今日领取次数已用完', + LEVEL_NOT_ELIGIBLE: '当前会员等级不满足领取条件', + ACCOUNT_RESTRICTED: '账户状态受限,请联系客服', PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理', NO_TASK_AVAILABLE: '当前暂无可领取任务', + ORDER_NOT_FOUND: '任务订单不存在', ORDER_EXPIRED: '该任务已超时', ORDER_STATE_INVALID: '当前任务状态不允许此操作', TRADE_PASSWORD_NOT_SET: '请先设置支付密码', TRADE_PASSWORD_INVALID: '支付密码不正确', + ACCOUNT_TYPE_INVALID: '收款账户类型不正确', PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确', + CURRENCY_INVALID: '币种参数不正确', QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传', IMAGE_INVALID: '图片格式或大小不符合要求', + RECHARGE_REMARK_INVALID: '储入备注格式不正确', PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用', + PAYMENT_ACCOUNT_EXISTS: '该收款账户已经存在', WITHDRAWAL_DISABLED: '取出功能暂未开放', WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则', WITHDRAWAL_DAILY_LIMIT_REACHED: '今日取出次数已用完', WITHDRAWAL_CHANNEL_UNAVAILABLE: '当前没有可用的取出通道', + WITHDRAWAL_STATUS_INVALID: '取出状态参数不正确', + WITHDRAWAL_ORDER_ID_INVALID: '取出订单参数不正确', + WITHDRAWAL_ORDER_NOT_FOUND: '取出订单不存在', RECHARGE_AMOUNT_INVALID: '储入金额不符合当前通道规则', RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道', RECHARGE_VOUCHER_INVALID: '储入凭证无效', + RECHARGE_STATUS_INVALID: '储入状态参数不正确', + RECHARGE_ORDER_ID_INVALID: '储入订单参数不正确', + RECHARGE_ORDER_NOT_FOUND: '储入订单不存在', + IDEMPOTENCY_KEY_INVALID: '请求标识无效,请刷新后重试', + IDEMPOTENCY_CONFLICT: '请勿重复提交不同的请求', + LEDGER_FILTER_INVALID: '账变记录筛选条件不正确', + RESOURCE_NOT_FOUND: '请求的内容不存在', + BANNER_POSITION_INVALID: '轮播图位置参数不正确', + CONTENT_KEY_INVALID: '内容页面参数不正确', SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试', CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在', CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置', @@ -90,9 +132,7 @@ client.interceptors.response.use( const reason = payload?.reason || ''; return Promise.reject( new MobileApiError( - reasonMessages[reason] || - payload?.message || - (status ? '请求失败,请稍后重试' : '网络连接失败'), + localizedErrorMessage(reason, payload?.message, status), reason, status ) @@ -110,7 +150,7 @@ export async function request(config: AxiosRequestConfig): Promise { if (response.data.code !== 0) { const reason = response.data.reason || ''; throw new MobileApiError( - reasonMessages[reason] || response.data.message || '请求失败', + localizedErrorMessage(reason, response.data.message, response.status), reason, response.status ); @@ -218,5 +258,16 @@ export function safeImage(url?: string) { } export function errorText(error: unknown) { - return error instanceof Error ? error.message : '操作失败,请稍后重试'; + if (!(error instanceof Error)) return '操作失败,请稍后重试'; + return containsChinese(error.message) ? error.message : '操作失败,请稍后重试'; +} + +function localizedErrorMessage(reason: string, message: string | undefined, status: number) { + if (reasonMessages[reason]) return reasonMessages[reason]; + if (message && containsChinese(message)) return message; + return status ? '请求失败,请稍后重试' : '网络连接失败,请检查网络后重试'; +} + +function containsChinese(value: string) { + return /[\u3400-\u9fff]/.test(value); }