diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7fa8f04..9abc7da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,7 @@ jobs: ninja-build \ rsync \ python3 \ + gettext \ libboost-context-dev \ libtool \ gcc \ diff --git a/.gitignore b/.gitignore index 60688a8..a9206f1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ bin/conf/*.yml *.db .venv *.pyc +locale/C/LC_MESSAGES/*.mo dataset models protocol/include diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e5cc9f..cc0fcff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,5 +16,7 @@ create_server_module(blog dbproxy_data ) +add_dependencies(blog i18n) + set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin/module) diff --git a/README.md b/README.md index 74ac54d..186196f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 自研 ORM(XML 配置,自动生成 C++ 数据访问层) - chen-sdk 基础库 - 多线程/协程支持 +- GNU gettext 国际化 - Docker 部署支持 ## 主要功能 @@ -77,7 +78,7 @@ cp bin/conf/prod.env.example bin/conf/prod.env ## 部署打包 -`collect.sh` 会收集部署所需文件并打包为 tar.gz(结构与生产目录一致:`server` / `module` / `lib` / `conf` / `dict` / `errors.json`): +`collect.sh` 会收集部署所需文件并打包为 tar.gz(结构与生产目录一致:`server` / `module` / `lib` / `conf` / `dict` / `errors.json` / `locale`): ```sh # 打包生产配置(配置值含部署路径 WORK_PATH 全部取自 bin/conf/prod.env) @@ -159,18 +160,25 @@ CI 生产配置由 **GitHub Secrets/Variables** 注入(见 workflow 的 `Prepa ```json { "chen-sdk": { "repo": "Chen-Christins/chen", "version": "1.5.4", "arch": "arm64" }, - "thirdparty": { "repo": "Chen-Christins/chen", "tag": "v1.0.0", "asset": "deps-{arch}.zip" } + "thirdparty": { "repo": "Chen-Christins/chen", "tag": "v1.0.0", "asset": "deps-{arch}.zip" }, + "cppjieba": { + "repo": "yanyiwu/cppjieba", + "tag": "v5.1.0", + "limonp_repo": "yanyiwu/limonp", + "limonp_ref": "a269e34dc4948d5a9209e21a7887b52daa0d3e78" + } } ``` - `chen-sdk`:框架 SDK(`chen-sdk--.zip`),解压为根目录 `chen-sdk-/`。 - `thirdparty`:三方依赖包(protobuf / yaml-cpp / tinyxml2 / jsoncpp / hiredis-vip 的头文件 + 静态库),解压到 `deps/`。 +- `cppjieba`:中文分词头文件(`deps/include/cppjieba`)及其 `limonp` 依赖(`deps/include/limonp`)。两者均为 header-only,编译时直接进入 `libblog.so`。 | 命令 | 说明 | |------|------| -| `./deps.py sync` | 下载 chen-sdk + thirdparty(默认全部) | +| `./deps.py sync` | 下载 chen-sdk + thirdparty + cppjieba(默认全部) | | `./deps.py sync --sdk-only` | 只下载 chen-sdk | -| `./deps.py thirdparty` | 只下载三方依赖到 `deps/` | +| `./deps.py thirdparty` | 只下载三方依赖与 cppjieba 到 `deps/` | | `./deps.py use 1.5.4 --arch amd64` | 切换 SDK 版本/架构 | | `./deps.py use --archive /tmp/chen-sdk.zip` | 使用本地临时 SDK 包 | | `./deps.py current` | 查看已安装 SDK 与 `deps/` 状态 | @@ -179,10 +187,41 @@ CI 生产配置由 **GitHub Secrets/Variables** 注入(见 workflow 的 `Prepa 说明: -- 资产缓存在 `.deps-cache/`,用 GitHub Release 的 `digest` 做 sha256 校验(离线时仅告警)。 -- 三方依赖只用于**编译**(`deps/include`)。模块**不**静态链接三方库,运行时符号由预编译 `bin/server` 提供,避免与 server 内的静态副本重复;部署机也无需安装这些库。 +- 资产缓存在 `.deps-cache/`,用 GitHub Release 的 `digest` 做 sha256 校验(离线时仅告警);cppjieba/limonp 的源码包按 tag/commit 缓存。 +- 三方依赖与 cppjieba 头文件只用于**编译**(`deps/include`)。模块**不**静态链接三方库,运行时符号由预编译 `bin/server` 提供,避免与 server 内的静态副本重复;部署机也无需安装这些库。cppjieba 为 header-only,不存在需要额外链接的 `.a`。 - 架构按主机自动识别(`dependencies.json` 的 `arch=auto`):mac(arm64) / Linux(amd64) 各自 `./deps.py sync` 即可,无需软链;如需固定,用 `./deps.py use --arch ` 改配置。 +## 国际化(i18n) + +- 语言解析顺序:`?lang=` → `Accept-Language` → 系统设置 `language` → `zh_CN`。 +- 当前支持 `zh_CN`、`en`。 +- 错误码消息:`errors.json` 提供 errno/key 与兜底,`locale/po/*.po` 提供 gettext 翻译,运行时加载 `locale/C/LC_MESSAGES/techhaven_.mo`。 +- 自定义消息:`result->setErrno(ec, "literal")`;带参数使用 `result->setErrnoFmt(ec, "param %s is required", param)`。 +- 新增或修改消息后: + +```sh +ninja i18n-sync # 重新提取源码消息并合并 PO +# 编辑 locale/po/en.po、locale/po/zh_CN.po +ninja i18n-check # 校验 POT/PO 完整性及占位符 +``` + +- `.mo` 由构建自动生成到 `locale/C/LC_MESSAGES/`(已 gitignore)。 +- 构建依赖:Linux 安装 `gettext`(提供 `xgettext` / `msgfmt` / `msgmerge` / `msginit`);运行时使用 glibc gettext,无需额外库。 +- 部署包会包含 `locale/C/LC_MESSAGES/*.mo`;热部署会同步 `locale/` 目录。 + +服务启动后可验证: + +```sh +curl -H 'Accept-Language: en-US' http://127.0.0.1:8090/api/v1/user/info +# msg: Not logged in + +curl 'http://127.0.0.1:8090/api/v1/user/info?lang=zh-CN' +# msg: 未登录 + +curl 'http://127.0.0.1:8090/api/v1/error-codes?lang=en' +# data: 英文错误码表 +``` + ## 相关文档 - [API_DOCS.md](API_DOCS.md) 详细接口说明 - `orm_config/` 下 XML 文件定义所有表结构 diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake index 764a4a4..c4f070b 100644 --- a/cmake/dependencies.cmake +++ b/cmake/dependencies.cmake @@ -28,16 +28,58 @@ else() message(WARNING "deps/ 未就绪,运行 ./deps.py sync 下载三方依赖(头文件/静态库)") endif() +set(CMAKE_FIND_PACKAGE_PREFER_CONFIG TRUE) add_subdirectory(${CHEN_SDK_DIR}) +unset(CMAKE_FIND_PACKAGE_PREFER_CONFIG) + add_subdirectory(${PROJECT_SOURCE_DIR}/dbproxy/data) -# cppjieba — 中文分词(全文搜索) -include(FetchContent) -FetchContent_Declare(cppjieba - GIT_REPOSITORY https://github.com/yanyiwu/cppjieba.git - GIT_TAG v5.1.0 +# i18n: GNU gettext catalogs +find_package(Python3 COMPONENTS Interpreter REQUIRED) +find_program(MSGFMT_EXECUTABLE msgfmt) +if(NOT MSGFMT_EXECUTABLE) + message(WARNING "msgfmt not found; install gettext to compile locale catalogs") +endif() + +set(I18N_MSGFMT_ARG "") +if(MSGFMT_EXECUTABLE) + set(I18N_MSGFMT_ARG --msgfmt "${MSGFMT_EXECUTABLE}") +endif() + +set(I18N_SCRIPT ${PROJECT_SOURCE_DIR}/scripts/i18n.py) +set(I18N_PO_DIR ${PROJECT_SOURCE_DIR}/locale/po) +set(I18N_MO_DIR ${PROJECT_SOURCE_DIR}/locale/C/LC_MESSAGES) +set(I18N_PO_FILES ${I18N_PO_DIR}/en.po ${I18N_PO_DIR}/zh_CN.po) +set(I18N_MO_FILES ${I18N_MO_DIR}/techhaven_en.mo ${I18N_MO_DIR}/techhaven_zh_CN.mo) + +add_custom_command( + OUTPUT ${I18N_MO_FILES} + COMMAND ${Python3_EXECUTABLE} ${I18N_SCRIPT} + --project-dir ${PROJECT_SOURCE_DIR} ${I18N_MSGFMT_ARG} check + COMMAND ${Python3_EXECUTABLE} ${I18N_SCRIPT} + --project-dir ${PROJECT_SOURCE_DIR} ${I18N_MSGFMT_ARG} build + DEPENDS ${I18N_PO_FILES} ${I18N_SCRIPT} + ${PROJECT_SOURCE_DIR}/errors.json + ${PROJECT_SOURCE_DIR}/locale/techhaven.pot + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + COMMENT "Checking and building gettext catalogs" + VERBATIM +) + +add_custom_target(i18n DEPENDS ${I18N_MO_FILES}) + +add_custom_target(i18n-check + COMMAND ${Python3_EXECUTABLE} ${I18N_SCRIPT} + --project-dir ${PROJECT_SOURCE_DIR} ${I18N_MSGFMT_ARG} check + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + COMMENT "Checking gettext catalogs" + VERBATIM +) + +add_custom_target(i18n-sync + COMMAND ${Python3_EXECUTABLE} ${I18N_SCRIPT} + --project-dir ${PROJECT_SOURCE_DIR} sync + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + COMMENT "Syncing gettext catalogs" + VERBATIM ) -FetchContent_MakeAvailable(cppjieba) -include_directories(${cppjieba_SOURCE_DIR}/include) -include_directories(${cppjieba_SOURCE_DIR}/deps/limonp/include) -add_compile_definitions(CPPJIEBA_DICT_PATH="${cppjieba_SOURCE_DIR}/dict") diff --git a/cmake/utils.cmake b/cmake/utils.cmake index 4624710..e3c3f17 100644 --- a/cmake/utils.cmake +++ b/cmake/utils.cmake @@ -135,7 +135,7 @@ function(create_server_module module_name source_dir module_output_dir server_ou ${CHEN_LIBRARY} ${CHEN_SDK_DEP_LIBS} ) - message(STATUS "[DEBUG] create_server_module libs=${libs}") +# message(STATUS "[DEBUG] create_server_module libs=${libs}") set(${MODULE_UPPER}_LIBS ${libs} PARENT_SCOPE) force_redefine_file_macro_for_sources(${module_name}) diff --git a/collect.sh b/collect.sh index dea18cb..af2cd5e 100755 --- a/collect.sh +++ b/collect.sh @@ -45,6 +45,8 @@ if [ -z "$CHEN_SDK_LIB" ]; then fi check_file "$ROOT_DIR/errors.json" check_file "$ROOT_DIR/dict" +check_file "$ROOT_DIR/locale/C/LC_MESSAGES/techhaven_en.mo" +check_file "$ROOT_DIR/locale/C/LC_MESSAGES/techhaven_zh_CN.mo" shopt -s nullglob module_sos=("$ROOT_DIR"/bin/module/*.so) @@ -92,9 +94,12 @@ for so in "${module_sos[@]}"; do done cp "$CHEN_SDK_LIB" "$STAGE_DIR/lib/" -# 4. 拷贝字典与错误码 +# 4. 拷贝字典、错误码与国际化目录 #cp -R "$ROOT_DIR/dict" "$STAGE_DIR/dict" cp "$ROOT_DIR/errors.json" "$STAGE_DIR/errors.json" +mkdir -p "$STAGE_DIR/locale/C/LC_MESSAGES" +cp "$ROOT_DIR/locale/C/LC_MESSAGES/"*.mo "$STAGE_DIR/locale/C/LC_MESSAGES/" +echo " 复制国际化目录 locale/" # 5. 打包 tar czf "$PKG_NAME" -C "$STAGE_DIR" . diff --git a/dependencies.json b/dependencies.json index 44a63c3..0278daf 100644 --- a/dependencies.json +++ b/dependencies.json @@ -8,5 +8,11 @@ "repo": "Chen-Christins/chen", "tag": "v1.0.0", "asset": "deps-{arch}.zip" + }, + "cppjieba": { + "repo": "yanyiwu/cppjieba", + "tag": "v5.1.0", + "limonp_repo": "yanyiwu/limonp", + "limonp_ref": "a269e34dc4948d5a9209e21a7887b52daa0d3e78" } } diff --git a/deps.py b/deps.py index d69b7f7..24cf780 100755 --- a/deps.py +++ b/deps.py @@ -4,7 +4,8 @@ Reads dependencies.json (optionally overridden by dependencies.local.json) and downloads the matching chen-sdk release asset to the repository root, replacing any previously installed SDK. CMake and collect.sh keep working through their -existing `chen-sdk-*` glob. +existing `chen-sdk-*` glob. Third-party headers and cppjieba/limonp sources are +installed under deps/ for compile-time use. """ import argparse @@ -28,9 +29,12 @@ SDK_KEY = "chen-sdk" THIRDPARTY_KEY = "thirdparty" +CPPJIEBA_KEY = "cppjieba" DEFAULT_REPO = "Chen-Christins/chen" SDK_DIR_PREFIX = "chen-sdk-" DEPS_DIR = ROOT / "deps" +CPPJIEBA_INCLUDE = DEPS_DIR / "include" / "cppjieba" +LIMONP_INCLUDE = DEPS_DIR / "include" / "limonp" VERSION_RE = re.compile(r"^chen-sdk-(\d[\w.\-]*?)(?:-(arm64|amd64))?$") ASSET_RE = re.compile(r"^chen-sdk-(\d[\w.\-]*?)-(arm64|amd64)\.zip$") @@ -398,6 +402,24 @@ def resolve_tp_arch(tp, sdk_cfg=None): return arch +def cppjieba_config(manifest=None): + data = manifest if manifest is not None else load_json(MANIFEST) + cfg = data.get(CPPJIEBA_KEY) + if not cfg: + return None + override = load_json(LOCAL_OVERRIDE).get(CPPJIEBA_KEY, {}) + cfg = dict(cfg) + cfg.update(override) + cfg.setdefault("repo", "yanyiwu/cppjieba") + cfg.setdefault("tag", "v5.1.0") + cfg.setdefault("limonp_repo", "yanyiwu/limonp") + return cfg + + +def cppjieba_ready(): + return (CPPJIEBA_INCLUDE / "Jieba.hpp").is_file() and (LIMONP_INCLUDE / "StringUtil.hpp").is_file() + + def ensure_asset(repo, tag, asset, ui): cache = CACHE_DIR / asset if cache.is_file() and cache.stat().st_size > 0: @@ -410,6 +432,23 @@ def ensure_asset(repo, tag, asset, ui): return cache +def ensure_source_archive(repo, ref, asset, ui): + cache = CACHE_DIR / asset + if cache.is_file() and cache.stat().st_size > 0: + ui.ok(f"📦 命中缓存 {ui.path(cache.name)}") + return cache + url = f"https://github.com/{repo}/archive/{ref}.zip" + ui.step("📦", f"下载 {asset}") + ui.info(url) + download(url, cache, ui) + return cache + + +def source_asset_name(prefix, ref): + value = re.sub(r"[^A-Za-z0-9._-]+", "-", ref).strip("-") + return f"{prefix}-{value or 'source'}.zip" + + def fetch_digest_tag(repo, tag, asset, ui): url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" headers = {"Accept": "application/vnd.github+json", "User-Agent": "techhaven-deps"} @@ -495,6 +534,61 @@ def install_thirdparty(zip_path, ui): ensure_bin_executable(DEPS_DIR) +def install_cppjieba_headers(cpp_zip, limonp_zip, ui): + tmp = Path(tempfile.mkdtemp(prefix=".cppjieba-tmp-", dir=ROOT)) + try: + cpp_dir = tmp / "cppjieba" + limonp_dir = tmp / "limonp" + cpp_dir.mkdir() + limonp_dir.mkdir() + with zipfile.ZipFile(cpp_zip) as zf: + extract_zip_restore(zf, cpp_dir) + with zipfile.ZipFile(limonp_zip) as zf: + extract_zip_restore(zf, limonp_dir) + + cpp_header = next(cpp_dir.rglob("include/cppjieba/Jieba.hpp"), None) + limonp_header = next(limonp_dir.rglob("include/limonp/StringUtil.hpp"), None) + if cpp_header is None: + raise DepsError(f"{Path(cpp_zip).name} 内未找到 include/cppjieba/Jieba.hpp") + if limonp_header is None: + raise DepsError(f"{Path(limonp_zip).name} 内未找到 include/limonp/StringUtil.hpp") + + include_dir = DEPS_DIR / "include" + include_dir.mkdir(parents=True, exist_ok=True) + for dest in (CPPJIEBA_INCLUDE, LIMONP_INCLUDE): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(cpp_header.parent, CPPJIEBA_INCLUDE) + shutil.copytree(limonp_header.parent, LIMONP_INCLUDE) + finally: + shutil.rmtree(tmp, ignore_errors=True) + if not cppjieba_ready(): + raise DepsError("cppjieba 头文件安装后校验失败") + + +def sync_cppjieba(ui): + cfg = cppjieba_config() + if not cfg: + return 0 + repo = cfg["repo"] + tag = cfg["tag"] + limonp_repo = cfg["limonp_repo"] + limonp_ref = cfg.get("limonp_ref") + if not limonp_ref: + raise DepsError("dependencies.json 中 cppjieba 缺少 limonp_ref") + + cpp_zip = ensure_source_archive(repo, tag, source_asset_name("cppjieba", tag), ui) + limonp_zip = ensure_source_archive( + limonp_repo, limonp_ref, source_asset_name("limonp", limonp_ref), ui + ) + install_cppjieba_headers(cpp_zip, limonp_zip, ui) + ui.ok( + f"✅ cppjieba 头文件已就绪 {ui.path('deps/include/cppjieba')} " + f"(tag={tag}, limonp={limonp_ref[:7]})" + ) + return 0 + + def sync_thirdparty(args, ui, sdk_cfg=None): tp = thirdparty_config() if not tp: @@ -531,6 +625,13 @@ def sync_thirdparty(args, ui, sdk_cfg=None): return 0 +def sync_deps(args, ui, sdk_cfg=None): + rc = sync_thirdparty(args, ui, sdk_cfg) + if rc: + return rc + return sync_cppjieba(ui) + + def cmd_thirdparty(args, ui): sdk_cfg = None if MANIFEST.exists(): @@ -538,7 +639,7 @@ def cmd_thirdparty(args, ui): sdk_cfg = resolve_config() except DepsError: sdk_cfg = None - return sync_thirdparty(args, ui, sdk_cfg) + return sync_deps(args, ui, sdk_cfg) def cmd_sync(args, ui): @@ -553,7 +654,7 @@ def cmd_sync(args, ui): sdk_cfg = resolve_config() except DepsError: sdk_cfg = None - rc = sync_thirdparty(args, ui, sdk_cfg) + rc = sync_deps(args, ui, sdk_cfg) if rc: return rc return 0 @@ -678,7 +779,12 @@ def cmd_current(args, ui): ui.info(f"三方依赖 deps/ 就绪({len(libs)} 个静态库, include 齐全)tag={tp.get('tag')}") else: ui.warn("三方依赖 deps/ 未就绪(运行 ./deps.py sync 或 ./deps.py thirdparty)") - return 0 if (dirs or valid_deps_dir(DEPS_DIR)) else 1 + if cppjieba_ready(): + cfg_cpp = cppjieba_config() or {} + ui.info(f"cppjieba 头文件就绪(tag={cfg_cpp.get('tag')})") + else: + ui.warn("cppjieba 头文件未就绪(运行 ./deps.py sync 或 ./deps.py thirdparty)") + return 0 if (dirs or valid_deps_dir(DEPS_DIR) or cppjieba_ready()) else 1 def cmd_list(args, ui): @@ -751,13 +857,13 @@ def build_parser(): parser.add_argument("--keep-old", action="store_true", help="切换后保留旧版本,不清理") sub = parser.add_subparsers(dest="command") - p_sync = sub.add_parser("sync", aliases=["fetch", "install"], help="下载 chen-sdk 与三方依赖(thirdparty)") + p_sync = sub.add_parser("sync", aliases=["fetch", "install"], help="下载 chen-sdk、三方依赖与 cppjieba") p_sync.add_argument("--force", action="store_true", help="即使已安装也重新下载") p_sync.add_argument("--sdk-only", action="store_true", help="只同步 chen-sdk") - p_sync.add_argument("--thirdparty-only", action="store_true", help="只同步三方依赖") + p_sync.add_argument("--thirdparty-only", action="store_true", help="只同步三方依赖与 cppjieba") p_sync.set_defaults(func=cmd_sync) - p_tp = sub.add_parser("thirdparty", aliases=["deps"], help="只下载三方依赖到 deps/(头文件+静态库)") + p_tp = sub.add_parser("thirdparty", aliases=["deps"], help="只下载三方依赖与 cppjieba 到 deps/") p_tp.set_defaults(func=cmd_thirdparty) p_use = sub.add_parser("use", help="切换版本,或用本地包安装") diff --git a/locale/po/en.po b/locale/po/en.po new file mode 100644 index 0000000..8aa0703 --- /dev/null +++ b/locale/po/en.po @@ -0,0 +1,912 @@ +msgid "" +msgstr "" +"Project-Id-Version: TechHaven-Server 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-18 07:05+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: scripts/i18n.py\n" + +#. errno: 0 +msgid "SUCCESS" +msgstr "Success" + +#. errno: 1001 +msgid "PARAM_MISSING" +msgstr "Required parameter missing" + +#. errno: 1002 +msgid "PARAM_INVALID" +msgstr "Invalid parameter format" + +#. errno: 1003 +msgid "INVALID_METHOD" +msgstr "Unsupported request method" + +#. errno: 1101 +msgid "NOT_LOGIN" +msgstr "Not logged in" + +#. errno: 1102 +msgid "ACCESS_DENIED" +msgstr "Access denied" + +#. errno: 1103 +msgid "ACCOUNT_INVALID" +msgstr "Account status abnormal" + +#. errno: 1104 +msgid "AUTH_CODE_INVALID" +msgstr "Invalid verification code" + +#. errno: 2001 +msgid "USER_NOT_FOUND" +msgstr "User not found" + +#. errno: 2002 +msgid "USER_ACCOUNT_EXISTS" +msgstr "Account already exists" + +#. errno: 2003 +msgid "USER_EMAIL_EXISTS" +msgstr "Email already registered" + +#. errno: 2004 +msgid "USER_INVALID_ACCOUNT" +msgstr "Invalid account format" + +#. errno: 2005 +msgid "USER_INVALID_EMAIL" +msgstr "Invalid email format" + +#. errno: 2006 +msgid "USER_INVALID_PASSWORD" +msgstr "Invalid password format" + +#. errno: 2007 +msgid "USER_PASSWORD_WRONG" +msgstr "Incorrect password" + +#. errno: 2008 +msgid "USER_OLD_PASSWORD_WRONG" +msgstr "Incorrect old password" + +#. errno: 2009 +msgid "USER_ALREADY_DELETED" +msgstr "User already deleted" + +#. errno: 2010 +msgid "USER_ALREADY_LOGIN" +msgstr "Already logged in" + +#. errno: 2011 +msgid "USER_EMAIL_NOT_REGISTER" +msgstr "Email not registered" + +#. errno: 2012 +msgid "USER_NO_PARAM" +msgstr "No parameter provided" + +#. errno: 2013 +msgid "USER_PASSWORDS_DIFFER" +msgstr "Passwords do not match" + +#. errno: 2014 +msgid "USER_CANNOT_FOLLOW_SELF" +msgstr "Cannot follow yourself" + +#. errno: 2015 +msgid "USER_NOT_FOLLOWING" +msgstr "Not following this user" + +#. errno: 2016 +msgid "USER_FOLLOW_FAILED" +msgstr "Follow operation failed" + +#. errno: 2017 +msgid "SEND_CODE_FREQUENT" +msgstr "Verification code sent too frequently, please try again later" + +#. errno: 2018 +msgid "DEVICE_NOT_FOUND" +msgstr "Login device not found" + +#. errno: 2019 +msgid "DEVICE_KICK_FAILED" +msgstr "Failed to kick device" + +#. errno: 2020 +msgid "TOTP_INVALID_CODE" +msgstr "Invalid TOTP code" + +#. errno: 2021 +msgid "TOTP_NOT_ENABLED" +msgstr "Two-factor authentication not enabled" + +#. errno: 2022 +msgid "TOTP_ALREADY_ENABLED" +msgstr "Two-factor authentication already enabled" + +#. errno: 2023 +msgid "TOTP_ENABLE_FAILED" +msgstr "Failed to enable two-factor authentication" + +#. errno: 2024 +msgid "TOTP_CONFIRM_FAILED" +msgstr "Failed to confirm two-factor authentication" + +#. errno: 2025 +msgid "RECOVERY_CODE_INVALID" +msgstr "Recovery code invalid or already used" + +#. errno: 2026 +msgid "TEMP_TOKEN_INVALID" +msgstr "Temporary token invalid or expired" + +#. errno: 2027 +msgid "GITHUB_OAUTH_FAILED" +msgstr "GitHub OAuth login failed" + +#. errno: 2028 +msgid "GITHUB_STATE_INVALID" +msgstr "OAuth state invalid or expired" + +#. errno: 3001 +msgid "ARTICLE_NOT_FOUND" +msgstr "Article not found" + +#. errno: 3002 +msgid "ARTICLE_INVALID_TYPE" +msgstr "Invalid article type" + +#. errno: 3003 +msgid "ARTICLE_INVALID_STATE" +msgstr "Invalid article state" + +#. errno: 3004 +msgid "ARTICLE_INVALID_ID" +msgstr "Invalid article ID" + +#. errno: 3005 +msgid "ARTICLE_INSERT_FAILED" +msgstr "Failed to create article" + +#. errno: 3006 +msgid "ARTICLE_UPDATE_FAILED" +msgstr "Failed to update article" + +#. errno: 3007 +msgid "ARTICLE_DELETE_FAILED" +msgstr "Failed to delete article" + +#. errno: 3008 +msgid "ARTICLE_PUBLISH_FAILED" +msgstr "Failed to publish article" + +#. errno: 3009 +msgid "ARTICLE_CATEGORY_EMPTY" +msgstr "Category parameters empty" + +#. errno: 4001 +msgid "FILE_NOT_FOUND" +msgstr "File not found" + +#. errno: 4002 +msgid "FILE_TOO_LARGE" +msgstr "File too large" + +#. errno: 4003 +msgid "FILE_INVALID_NAME" +msgstr "Invalid file name" + +#. errno: 4004 +msgid "FILE_INVALID_PATH" +msgstr "Invalid file path" + +#. errno: 4005 +msgid "FILE_READ_ERROR" +msgstr "File read error" + +#. errno: 4006 +msgid "FILE_UPLOAD_FAILED" +msgstr "File upload failed" + +#. errno: 4007 +msgid "FILE_SAVE_FAILED" +msgstr "File save failed" + +#. errno: 4008 +msgid "FILE_PROTOCOL_ERROR" +msgstr "Upload protocol error" + +#. errno: 4009 +msgid "FILE_DUMP_FAILED" +msgstr "File dump failed" + +#. errno: 4010 +msgid "UPLOAD_SESSION_NOT_FOUND" +msgstr "Upload session not found" + +#. errno: 4011 +msgid "UPLOAD_INVALID_TYPE" +msgstr "Invalid upload type" + +#. errno: 4012 +msgid "UPLOAD_INVALID_PARAMS" +msgstr "Invalid upload parameters" + +#. errno: 4013 +msgid "UPLOAD_CHUNK_INVALID" +msgstr "Invalid chunk parameters" + +#. errno: 4014 +msgid "UPLOAD_CHUNK_ALREADY_DONE" +msgstr "Chunk already received" + +#. errno: 4015 +msgid "UPLOAD_CHUNK_INCOMPLETE" +msgstr "Not all chunks uploaded" + +#. errno: 4016 +msgid "UPLOAD_FILE_CREATE_FAILED" +msgstr "Failed to create file" + +#. errno: 4017 +msgid "UPLOAD_FILE_ASSEMBLE_FAILED" +msgstr "Failed to assemble file" + +#. errno: 4018 +msgid "UPLOAD_FILE_HASH_FAILED" +msgstr "Failed to calculate file hash" + +#. errno: 4019 +msgid "UPLOAD_TEMP_DIR_FAILED" +msgstr "Failed to create temp directory" + +#. errno: 4020 +msgid "UPLOAD_TEMP_FILE_FAILED" +msgstr "Failed to create temp file" + +#. errno: 4021 +msgid "UPLOAD_CHUNK_SIZE_EXCEED" +msgstr "Chunk size exceeds declared size" + +#. errno: 4022 +msgid "UPLOAD_TYPE_INVALID_TYPE" +msgstr "Invalid file type for upload" + +#. errno: 4023 +msgid "UPLOAD_FILE_EMPTY" +msgstr "Upload file is empty" + +#. errno: 5001 +msgid "ORG_NOT_FOUND" +msgstr "Organization not found" + +#. errno: 5002 +msgid "ORG_DISABLED" +msgstr "Organization is disabled" + +#. errno: 5003 +msgid "ORG_ALREADY_JOINED" +msgstr "Already joined this organization" + +#. errno: 5004 +msgid "ORG_ALREADY_APPLIED" +msgstr "Already applied to join" + +#. errno: 5005 +msgid "ORG_APPLY_NOT_FOUND" +msgstr "Application not found" + +#. errno: 5006 +msgid "ORG_APPLY_ALREADY_REVIEWED" +msgstr "Application already reviewed" + +#. errno: 5007 +msgid "ORG_INVALID_ROLE" +msgstr "Invalid organization role" + +#. errno: 5008 +msgid "ORG_INVALID_STATE" +msgstr "Invalid organization state" + +#. errno: 5009 +msgid "ORG_NOT_MEMBER" +msgstr "Not a member of this organization" + +#. errno: 5010 +msgid "ORG_INVALID_ID" +msgstr "Invalid organization ID" + +#. errno: 5011 +msgid "ORG_INSERT_FAILED" +msgstr "Failed to create organization" + +#. errno: 5012 +msgid "ORG_UPDATE_FAILED" +msgstr "Failed to update organization" + +#. errno: 5013 +msgid "ORG_DELETE_FAILED" +msgstr "Failed to delete organization" + +#. errno: 5014 +msgid "ORG_NO_VALID_ORGS" +msgstr "No valid organizations found" + +#. errno: 5015 +msgid "ORG_APPLY_INSERT_FAILED" +msgstr "Failed to submit application" + +#. errno: 5016 +msgid "ORG_APPLY_UPDATE_FAILED" +msgstr "Failed to update application" + +#. errno: 5017 +msgid "ORG_USER_REL_FAILED" +msgstr "Organization member operation failed" + +#. errno: 5018 +msgid "ORG_REPO_NOT_FOUND" +msgstr "Repository not found" + +#. errno: 5019 +msgid "ORG_REPO_NAME_EXISTS" +msgstr "Repository name already exists in this organization" + +#. errno: 5020 +msgid "ORG_PR_NOT_FOUND" +msgstr "PR record not found" + +#. errno: 5021 +msgid "ORG_REPO_TOKEN_MISSING" +msgstr "Repository GitHub token not configured" + +#. errno: 5022 +msgid "GITHUB_PR_NOT_FOUND" +msgstr "PR not found on GitHub" + +#. errno: 5023 +msgid "GITHUB_RATE_LIMITED" +msgstr "GitHub API rate limit exceeded, please try again later" + +#. errno: 5024 +msgid "GITHUB_API_FAILED" +msgstr "GitHub API request failed" + +#. errno: 5025 +msgid "ORG_PR_SYNC_IN_PROGRESS" +msgstr "PR sync already in progress, please wait" + +#. errno: 6001 +msgid "ASSIGNMENT_NOT_FOUND" +msgstr "Assignment not found" + +#. errno: 6002 +msgid "ASSIGNMENT_INVALID_ID" +msgstr "Invalid assignment ID" + +#. errno: 6003 +msgid "ASSIGNMENT_SIZE_EXCEED" +msgstr "Assignment file size exceeds limit" + +#. errno: 6004 +msgid "ASSIGNMENT_MAX_SIZE_EXCEED" +msgstr "Submission count exceeds limit" + +#. errno: 6005 +msgid "ASSIGNMENT_INSERT_FAILED" +msgstr "Failed to create assignment" + +#. errno: 6006 +msgid "ASSIGNMENT_UPDATE_FAILED" +msgstr "Failed to update assignment" + +#. errno: 6007 +msgid "ASSIGNMENT_DELETE_FAILED" +msgstr "Failed to delete assignment" + +#. errno: 7001 +msgid "COMMENT_NOT_FOUND" +msgstr "Comment not found" + +#. errno: 7002 +msgid "COMMENT_PERMISSION_DENIED" +msgstr "Permission denied for this comment" + +#. errno: 7003 +msgid "COMMENT_CREATE_FAILED" +msgstr "Failed to create comment" + +#. errno: 7004 +msgid "COMMENT_UPDATE_FAILED" +msgstr "Failed to update comment" + +#. errno: 7005 +msgid "COMMENT_DELETE_FAILED" +msgstr "Failed to delete comment" + +#. errno: 7006 +msgid "COMMENT_PRAISE_FAILED" +msgstr "Failed to praise comment" + +#. errno: 7007 +msgid "COMMENT_UNPRAISE_FAILED" +msgstr "Failed to unpraise comment" + +#. errno: 7008 +msgid "COMMENT_PARENT_NOT_FOUND" +msgstr "Parent comment not found" + +#. errno: 8001 +msgid "BUG_NOT_FOUND" +msgstr "Bug not found" + +#. errno: 8002 +msgid "REQUIREMENT_NOT_FOUND" +msgstr "Requirement not found" + +#. errno: 8003 +msgid "TASK_NOT_FOUND" +msgstr "Task not found" + +#. errno: 8004 +msgid "RD_INVALID_TYPE" +msgstr "Invalid ticket type" + +#. errno: 8005 +msgid "RD_NOT_ORG_MEMBER" +msgstr "Not a member of this organization" + +#. errno: 8006 +msgid "RD_INSERT_FAILED" +msgstr "Failed to create ticket" + +#. errno: 8007 +msgid "RD_UPDATE_FAILED" +msgstr "Failed to update ticket" + +#. errno: 8008 +msgid "RD_DELETE_FAILED" +msgstr "Failed to delete ticket" + +#. errno: 8009 +msgid "RD_INVALID_USER" +msgstr "Invalid user" + +#. errno: 9001 +msgid "DB_CONNECTION_FAILED" +msgstr "Database connection failed" + +#. errno: 9002 +msgid "DB_OPERATION_FAILED" +msgstr "Database operation failed" + +#. errno: 9003 +msgid "DB_TRANSACTION_FAILED" +msgstr "Database transaction failed" + +#. errno: 9004 +msgid "DB_COMMIT_FAILED" +msgstr "Database commit failed" + +#. errno: 9005 +msgid "INTERNAL_ERROR" +msgstr "Internal server error" + +#. errno: 9006 +msgid "SETTINGS_NOT_LOADED" +msgstr "System settings not loaded" + +#. errno: 9007 +msgid "SETTINGS_SAVE_FAILED" +msgstr "Failed to save system settings" + +#. errno: 9008 +msgid "SMTP_NOT_CONFIGURED" +msgstr "SMTP server not configured" + +#. errno: 9009 +msgid "REDIS_OPERATION_FAILED" +msgstr "Redis operation failed" + +#. errno: 9010 +msgid "METHOD_NOT_ALLOWED" +msgstr "Method not allowed" + +#. errno: 9011 +msgid "NOTIFICATION_SEND_FAILED" +msgstr "Failed to send notification" + +#. errno: 9012 +msgid "AI_CONFIG_SAVE_FAILED" +msgstr "Failed to save AI config" + +#. errno: 9013 +msgid "ARTICLE_PRAISE_FAILED" +msgstr "Failed to praise article" + +#. errno: 9014 +msgid "ARTICLE_UNPRAISE_FAILED" +msgstr "Failed to unpraise article" + +#. errno: 9015 +msgid "NOTIFICATION_TITLE_TOO_LONG" +msgstr "Notification title too long" + +#. errno: 9016 +msgid "NOTIFICATION_CONTENT_TOO_LONG" +msgstr "Notification content too long" + +#. errno: 9017 +msgid "NOTIFICATION_USERS_REQUIRED" +msgstr "Target users required" + +#. errno: 9018 +msgid "NOTIFICATION_INVALID_TARGET" +msgstr "Invalid notification target type" + +#. errno: 12001 +msgid "MESSAGE_CONVERSATION_NOT_FOUND" +msgstr "Conversation not found" + +#. errno: 12002 +msgid "MESSAGE_SEND_FAILED" +msgstr "Failed to send message" + +#. errno: 12003 +msgid "MESSAGE_TEXT_TOO_LONG" +msgstr "Message content too long" + +#. errno: 12004 +msgid "MESSAGE_PEER_NOT_FOUND" +msgstr "Peer user not found" + +#. errno: 12005 +msgid "MESSAGE_CANNOT_SELF" +msgstr "Cannot message yourself" + +#: src/servlets/article/article_calendar_servlet.cc:36 +msgid "invalid year or month" +msgstr "invalid year or month" + +#: src/servlets/category/category_create_servlet.cc:44 +msgid "invalid parent_id" +msgstr "invalid parent_id" + +#: src/servlets/category/category_create_servlet.cc:54 +msgid "invalid cid" +msgstr "invalid cid" + +#: src/servlets/category/category_create_servlet.cc:85 +msgid "category operation failed" +msgstr "category operation failed" + +#: src/servlets/category/category_delete_servlet.cc:61 +msgid "no categories" +msgstr "no categories" + +#: src/servlets/database/backup_create_servlet.cc:55 +#: src/servlets/database/export_create_servlet.cc:38 +msgid "param type is required" +msgstr "param type is required" + +#: src/servlets/database/backup_create_servlet.cc:59 +msgid "type must be full or incremental" +msgstr "type must be full or incremental" + +#: src/servlets/database/backup_create_servlet.cc:68 +msgid "create backup failed" +msgstr "create backup failed" + +#: src/servlets/database/backup_delete_servlet.cc:38 +#: src/servlets/database/backup_download_servlet.cc:48 +#: src/servlets/database/export_delete_servlet.cc:38 +#: src/servlets/database/export_download_servlet.cc:48 +#: src/servlets/message/conversation_delete_servlet.cc:32 +#: src/servlets/message/conversation_message_servlet.cc:35 +#: src/servlets/message/conversation_read_servlet.cc:32 +msgid "param id is required" +msgstr "param id is required" + +#: src/servlets/database/backup_delete_servlet.cc:43 +#: src/servlets/database/backup_download_servlet.cc:53 +#: src/servlets/database/export_delete_servlet.cc:43 +#: src/servlets/database/export_download_servlet.cc:53 +#: src/servlets/help/admin_faq_delete_servlet.cc:56 +#: src/servlets/help/admin_faq_edit_servlet.cc:56 +#: src/servlets/help/admin_feedback_convert_servlet.cc:57 +#: src/servlets/help/admin_feedback_delete_servlet.cc:49 +#: src/servlets/message/conversation_delete_servlet.cc:37 +#: src/servlets/message/conversation_message_servlet.cc:40 +#: src/servlets/message/conversation_read_servlet.cc:37 +msgid "invalid id" +msgstr "invalid id" + +#: src/servlets/database/backup_delete_servlet.cc:49 +#: src/servlets/database/backup_download_servlet.cc:59 +msgid "backup record not found" +msgstr "backup record not found" + +#: src/servlets/database/backup_delete_servlet.cc:54 +msgid "delete backup failed" +msgstr "delete backup failed" + +#: src/servlets/database/export_create_servlet.cc:42 +msgid "type must be articles/users/comments/full" +msgstr "type must be articles/users/comments/full" + +#: src/servlets/database/export_create_servlet.cc:51 +msgid "create export failed" +msgstr "create export failed" + +#: src/servlets/database/export_delete_servlet.cc:49 +#: src/servlets/database/export_download_servlet.cc:59 +msgid "export record not found" +msgstr "export record not found" + +#: src/servlets/database/export_delete_servlet.cc:54 +msgid "delete export failed" +msgstr "delete export failed" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:49 +msgid "target must be faq, requirement or bug" +msgstr "target must be faq, requirement or bug" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:96 +msgid "insert faq failed" +msgstr "insert faq failed" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:110 +#: src/servlets/help/admin_feedback_convert_servlet.cc:142 +msgid "invalid orgId" +msgstr "invalid orgId" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:128 +msgid "insert requirement failed" +msgstr "insert requirement failed" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:161 +msgid "insert bug failed" +msgstr "insert bug failed" + +#: src/servlets/help/admin_feedback_list_servlet.cc:50 +msgid "query feedbacks failed" +msgstr "query feedbacks failed" + +#: src/servlets/help/feedback_servlet.cc:46 +msgid "feedback insert failed" +msgstr "feedback insert failed" + +#: src/servlets/label/label_create_servlet.cc:41 +msgid "invalid lid" +msgstr "invalid lid" + +#: src/servlets/label/label_create_servlet.cc:73 +msgid "label operation failed" +msgstr "label operation failed" + +#: src/servlets/message/conversation_delete_servlet.cc:52 +msgid "delete conversation failed" +msgstr "delete conversation failed" + +#: src/servlets/message/conversation_message_servlet.cc:79 +msgid "param text is required" +msgstr "param text is required" + +#: src/servlets/message/conversation_read_servlet.cc:52 +msgid "mark read failed" +msgstr "mark read failed" + +#: src/servlets/message/conversation_servlet.cc:54 +msgid "param peer_id is required" +msgstr "param peer_id is required" + +#: src/servlets/message/conversation_servlet.cc:59 +msgid "invalid peer_id" +msgstr "invalid peer_id" + +#: src/servlets/message/message_json.cc:52 +msgid "私信仅对内部成员开放" +msgstr "Direct messages are only available to internal members" + +#: src/servlets/notify/notification_read_servlet.cc:41 +msgid "id or ids is required" +msgstr "id or ids is required" + +#: src/servlets/organization/organization_apply_review_servlet.cc:37 +msgid "invalid apply_id" +msgstr "invalid apply_id" + +#: src/servlets/organization/organization_apply_review_servlet.cc:42 +msgid "action must be approve or reject" +msgstr "action must be approve or reject" + +#: src/servlets/organization/organization_join_check_servlet.cc:35 +#: src/servlets/user/user_admin_create_servlet.cc:77 +#: src/servlets/user/user_admin_update_servlet.cc:103 +msgid "invalid state" +msgstr "invalid state" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:46 +msgid "PR 记录不存在" +msgstr "PR record not found" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:72 +msgid "仅研发主管及以上角色可删除审查记录" +msgstr "Only R&D leads and above can delete review records" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:84 +msgid "删除审查记录失败" +msgstr "Failed to delete review record" + +#: src/servlets/organization/organization_repo_prs_list_servlet.cc:67 +#: src/servlets/organization/organization_repo_prs_list_servlet.cc:80 +#: src/servlets/organization/organization_repos_add_servlet.cc:81 +#: src/servlets/organization/organization_repos_delete_servlet.cc:56 +#: src/servlets/organization/organization_repos_list_servlet.cc:53 +msgid "无权访问该组织" +msgstr "No access to this organization" + +#: src/servlets/organization/organization_repo_prs_sync_servlet.cc:74 +msgid "param id or repo_id is required" +msgstr "param id or repo_id is required" + +#: src/servlets/organization/organization_repo_prs_sync_servlet.cc:119 +msgid "仅研发主管及以上角色可同步 PR" +msgstr "Only R&D leads and above can sync PRs" + +#: src/servlets/organization/organization_repos_add_servlet.cc:37 +msgid "仓库名称最长128字符" +msgstr "Repository name must be at most 128 characters" + +#: src/servlets/organization/organization_repos_add_servlet.cc:42 +msgid "仓库地址最长512字符" +msgstr "Repository URL must be at most 512 characters" + +#: src/servlets/organization/organization_repos_add_servlet.cc:47 +msgid "仅支持 GitHub 仓库" +msgstr "Only GitHub repositories are supported" + +#: src/servlets/organization/organization_repos_add_servlet.cc:52 +msgid "编程语言最长64字符" +msgstr "Programming language must be at most 64 characters" + +#: src/servlets/organization/organization_repos_add_servlet.cc:86 +msgid "仅研发主管及以上角色可添加仓库" +msgstr "Only R&D leads and above can add repositories" + +#: src/servlets/organization/organization_repos_add_servlet.cc:94 +msgid "该组织下已存在同名仓库" +msgstr "A repository with this name already exists in the organization" + +#: src/servlets/organization/organization_repos_add_servlet.cc:143 +msgid "添加仓库失败" +msgstr "Failed to add repository" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:40 +msgid "仓库不存在" +msgstr "Repository not found" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:61 +msgid "仅研发主管及以上角色可删除仓库" +msgstr "Only R&D leads and above can delete repositories" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:73 +msgid "删除仓库失败" +msgstr "Failed to delete repository" + +#: src/servlets/organization/organization_repos_sync_servlet.cc:62 +msgid "仅研发主管及以上角色可触发仓库同步" +msgstr "Only R&D leads and above can trigger repository sync" + +#: src/servlets/organization/organization_repos_sync_servlet.cc:77 +msgid "同步失败" +msgstr "Sync failed" + +#: src/servlets/organization/organization_repos_token_servlet.cc:60 +msgid "仅研发主管及以上角色可管理仓库 Token" +msgstr "Only R&D leads and above can manage repository tokens" + +#: src/servlets/organization/organization_repos_token_servlet.cc:74 +msgid "保存 Token 失败" +msgstr "Failed to save token" + +#: src/servlets/organization/organization_user_switch_role_servlet.cc:70 +#: src/servlets/user/user_admin_create_servlet.cc:73 +#: src/servlets/user/user_admin_update_servlet.cc:96 +msgid "invalid role" +msgstr "invalid role" + +#: src/servlets/rd/rd_bug_detail_servlet.cc:26 +#: src/servlets/rd/rd_requirement_detail_servlet.cc:26 +#: src/servlets/rd/rd_task_detail_servlet.cc:26 +msgid "id is required" +msgstr "id is required" + +#: src/servlets/rd/rd_my_tickets_servlet.cc:26 +msgid "type is required" +msgstr "type is required" + +#: src/servlets/rd/rd_trend_servlet.cc:181 +msgid "日期范围不合法" +msgstr "Invalid date range" + +#: src/servlets/rd/rd_trend_servlet.cc:195 +msgid "无权访问任何组织" +msgstr "No access to any organization" + +#: src/servlets/user/user_2fa_confirm_servlet.cc:54 +msgid "please call /api/v1/user/2fa/enable first" +msgstr "please call /api/v1/user/2fa/enable first" + +#: src/servlets/user/user_2fa_disable_servlet.cc:63 +msgid "disable 2fa failed" +msgstr "disable 2fa failed" + +#: src/servlets/user/user_2fa_reset_servlet.cc:81 +msgid "reset 2fa failed" +msgstr "reset 2fa failed" + +#: src/servlets/user/user_admin_create_servlet.cc:49 +#: src/servlets/user/user_create_servlet.cc:36 +msgid "account and password required" +msgstr "account and password required" + +#: src/servlets/user/user_admin_create_servlet.cc:96 +#: src/servlets/user/user_admin_reset_passwd_servlet.cc:72 +#: src/servlets/user/user_create_servlet.cc:86 +#: src/servlets/user/user_reset_passwd_servlet.cc:63 +msgid "insert user failed" +msgstr "insert user failed" + +#: src/servlets/user/user_admin_delete_servlet.cc:67 +msgid "delete user failed" +msgstr "delete user failed" + +#: src/servlets/user/user_admin_reset_passwd_servlet.cc:48 +#: src/servlets/user/user_reset_passwd_servlet.cc:34 +msgid "password required" +msgstr "password required" + +#: src/servlets/user/user_admin_update_servlet.cc:117 +#: src/servlets/user/user_update_servlet.cc:94 +msgid "update user failed" +msgstr "update user failed" + +#: src/servlets/user/user_ai_config_servlet.cc:60 +msgid "type must be openai, claude or glm" +msgstr "type must be openai, claude or glm" + +#: src/servlets/user/user_ai_config_servlet.cc:67 +msgid "max_tokens is required for claude" +msgstr "max_tokens is required for claude" + +#: src/servlets/user/user_oauth_github_callback_servlet.cc:308 +#: src/servlets/user/user_oauth_github_servlet.cc:59 +msgid "GitHub OAuth 未配置" +msgstr "GitHub OAuth is not configured" + +#: src/servlets/user/user_refresh_token_servlet.cc:52 +msgid "token is required" +msgstr "token is required" + +#: src/servlets/user/user_refresh_token_servlet.cc:58 +msgid "token mismatch, please re-login" +msgstr "token mismatch, please re-login" + +#: src/servlets/user/user_update_servlet.cc:59 +msgid "old password required" +msgstr "old password required" + +#: src/util.h:128 src/util.h:137 +#, c-format +msgid "param %s is required" +msgstr "param %s is required" diff --git a/locale/po/zh_CN.po b/locale/po/zh_CN.po new file mode 100644 index 0000000..add1c0a --- /dev/null +++ b/locale/po/zh_CN.po @@ -0,0 +1,912 @@ +msgid "" +msgstr "" +"Project-Id-Version: TechHaven-Server 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-18 07:05+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: scripts/i18n.py\n" + +#. errno: 0 +msgid "SUCCESS" +msgstr "成功" + +#. errno: 1001 +msgid "PARAM_MISSING" +msgstr "缺少必填参数" + +#. errno: 1002 +msgid "PARAM_INVALID" +msgstr "参数格式错误" + +#. errno: 1003 +msgid "INVALID_METHOD" +msgstr "不支持的请求方法" + +#. errno: 1101 +msgid "NOT_LOGIN" +msgstr "未登录" + +#. errno: 1102 +msgid "ACCESS_DENIED" +msgstr "权限不足" + +#. errno: 1103 +msgid "ACCOUNT_INVALID" +msgstr "账号状态异常" + +#. errno: 1104 +msgid "AUTH_CODE_INVALID" +msgstr "验证码错误" + +#. errno: 2001 +msgid "USER_NOT_FOUND" +msgstr "用户不存在" + +#. errno: 2002 +msgid "USER_ACCOUNT_EXISTS" +msgstr "账号已存在" + +#. errno: 2003 +msgid "USER_EMAIL_EXISTS" +msgstr "邮箱已注册" + +#. errno: 2004 +msgid "USER_INVALID_ACCOUNT" +msgstr "账号格式错误" + +#. errno: 2005 +msgid "USER_INVALID_EMAIL" +msgstr "邮箱格式错误" + +#. errno: 2006 +msgid "USER_INVALID_PASSWORD" +msgstr "密码格式错误" + +#. errno: 2007 +msgid "USER_PASSWORD_WRONG" +msgstr "密码错误" + +#. errno: 2008 +msgid "USER_OLD_PASSWORD_WRONG" +msgstr "旧密码错误" + +#. errno: 2009 +msgid "USER_ALREADY_DELETED" +msgstr "用户已删除" + +#. errno: 2010 +msgid "USER_ALREADY_LOGIN" +msgstr "已登录" + +#. errno: 2011 +msgid "USER_EMAIL_NOT_REGISTER" +msgstr "邮箱未注册" + +#. errno: 2012 +msgid "USER_NO_PARAM" +msgstr "缺少更新参数" + +#. errno: 2013 +msgid "USER_PASSWORDS_DIFFER" +msgstr "两次密码不一致" + +#. errno: 2014 +msgid "USER_CANNOT_FOLLOW_SELF" +msgstr "不能关注自己" + +#. errno: 2015 +msgid "USER_NOT_FOLLOWING" +msgstr "未关注该用户" + +#. errno: 2016 +msgid "USER_FOLLOW_FAILED" +msgstr "关注操作失败" + +#. errno: 2017 +msgid "SEND_CODE_FREQUENT" +msgstr "验证码发送过于频繁,请稍后再试" + +#. errno: 2018 +msgid "DEVICE_NOT_FOUND" +msgstr "登录设备不存在" + +#. errno: 2019 +msgid "DEVICE_KICK_FAILED" +msgstr "设备下线失败" + +#. errno: 2020 +msgid "TOTP_INVALID_CODE" +msgstr "验证码错误" + +#. errno: 2021 +msgid "TOTP_NOT_ENABLED" +msgstr "双因素认证未启用" + +#. errno: 2022 +msgid "TOTP_ALREADY_ENABLED" +msgstr "双因素认证已启用" + +#. errno: 2023 +msgid "TOTP_ENABLE_FAILED" +msgstr "启用双因素认证失败" + +#. errno: 2024 +msgid "TOTP_CONFIRM_FAILED" +msgstr "确认双因素认证失败" + +#. errno: 2025 +msgid "RECOVERY_CODE_INVALID" +msgstr "恢复码无效或已使用" + +#. errno: 2026 +msgid "TEMP_TOKEN_INVALID" +msgstr "临时凭证无效或已过期" + +#. errno: 2027 +msgid "GITHUB_OAUTH_FAILED" +msgstr "GitHub 授权登录失败" + +#. errno: 2028 +msgid "GITHUB_STATE_INVALID" +msgstr "登录状态校验失败,请重试" + +#. errno: 3001 +msgid "ARTICLE_NOT_FOUND" +msgstr "文章不存在" + +#. errno: 3002 +msgid "ARTICLE_INVALID_TYPE" +msgstr "文章类型错误" + +#. errno: 3003 +msgid "ARTICLE_INVALID_STATE" +msgstr "文章状态错误" + +#. errno: 3004 +msgid "ARTICLE_INVALID_ID" +msgstr "文章ID无效" + +#. errno: 3005 +msgid "ARTICLE_INSERT_FAILED" +msgstr "创建文章失败" + +#. errno: 3006 +msgid "ARTICLE_UPDATE_FAILED" +msgstr "更新文章失败" + +#. errno: 3007 +msgid "ARTICLE_DELETE_FAILED" +msgstr "删除文章失败" + +#. errno: 3008 +msgid "ARTICLE_PUBLISH_FAILED" +msgstr "发布文章失败" + +#. errno: 3009 +msgid "ARTICLE_CATEGORY_EMPTY" +msgstr "分类参数为空" + +#. errno: 4001 +msgid "FILE_NOT_FOUND" +msgstr "文件不存在" + +#. errno: 4002 +msgid "FILE_TOO_LARGE" +msgstr "文件过大" + +#. errno: 4003 +msgid "FILE_INVALID_NAME" +msgstr "文件名无效" + +#. errno: 4004 +msgid "FILE_INVALID_PATH" +msgstr "文件路径无效" + +#. errno: 4005 +msgid "FILE_READ_ERROR" +msgstr "文件读取失败" + +#. errno: 4006 +msgid "FILE_UPLOAD_FAILED" +msgstr "文件上传失败" + +#. errno: 4007 +msgid "FILE_SAVE_FAILED" +msgstr "文件保存失败" + +#. errno: 4008 +msgid "FILE_PROTOCOL_ERROR" +msgstr "上传协议错误" + +#. errno: 4009 +msgid "FILE_DUMP_FAILED" +msgstr "文件转储失败" + +#. errno: 4010 +msgid "UPLOAD_SESSION_NOT_FOUND" +msgstr "上传会话不存在" + +#. errno: 4011 +msgid "UPLOAD_INVALID_TYPE" +msgstr "上传类型无效" + +#. errno: 4012 +msgid "UPLOAD_INVALID_PARAMS" +msgstr "上传参数无效" + +#. errno: 4013 +msgid "UPLOAD_CHUNK_INVALID" +msgstr "分片参数无效" + +#. errno: 4014 +msgid "UPLOAD_CHUNK_ALREADY_DONE" +msgstr "分片已接收" + +#. errno: 4015 +msgid "UPLOAD_CHUNK_INCOMPLETE" +msgstr "分片未全部上传" + +#. errno: 4016 +msgid "UPLOAD_FILE_CREATE_FAILED" +msgstr "创建文件失败" + +#. errno: 4017 +msgid "UPLOAD_FILE_ASSEMBLE_FAILED" +msgstr "文件合并失败" + +#. errno: 4018 +msgid "UPLOAD_FILE_HASH_FAILED" +msgstr "文件哈希计算失败" + +#. errno: 4019 +msgid "UPLOAD_TEMP_DIR_FAILED" +msgstr "创建临时目录失败" + +#. errno: 4020 +msgid "UPLOAD_TEMP_FILE_FAILED" +msgstr "创建临时文件失败" + +#. errno: 4021 +msgid "UPLOAD_CHUNK_SIZE_EXCEED" +msgstr "分片大小超出声明值" + +#. errno: 4022 +msgid "UPLOAD_TYPE_INVALID_TYPE" +msgstr "上传文件类型无效" + +#. errno: 4023 +msgid "UPLOAD_FILE_EMPTY" +msgstr "上传文件为空" + +#. errno: 5001 +msgid "ORG_NOT_FOUND" +msgstr "组织不存在" + +#. errno: 5002 +msgid "ORG_DISABLED" +msgstr "组织已禁用" + +#. errno: 5003 +msgid "ORG_ALREADY_JOINED" +msgstr "已加入该组织" + +#. errno: 5004 +msgid "ORG_ALREADY_APPLIED" +msgstr "已申请加入该组织" + +#. errno: 5005 +msgid "ORG_APPLY_NOT_FOUND" +msgstr "申请不存在" + +#. errno: 5006 +msgid "ORG_APPLY_ALREADY_REVIEWED" +msgstr "申请已审核" + +#. errno: 5007 +msgid "ORG_INVALID_ROLE" +msgstr "无效的组织角色" + +#. errno: 5008 +msgid "ORG_INVALID_STATE" +msgstr "无效的组织状态" + +#. errno: 5009 +msgid "ORG_NOT_MEMBER" +msgstr "不是组织成员" + +#. errno: 5010 +msgid "ORG_INVALID_ID" +msgstr "组织ID无效" + +#. errno: 5011 +msgid "ORG_INSERT_FAILED" +msgstr "创建组织失败" + +#. errno: 5012 +msgid "ORG_UPDATE_FAILED" +msgstr "更新组织失败" + +#. errno: 5013 +msgid "ORG_DELETE_FAILED" +msgstr "删除组织失败" + +#. errno: 5014 +msgid "ORG_NO_VALID_ORGS" +msgstr "没有有效的组织" + +#. errno: 5015 +msgid "ORG_APPLY_INSERT_FAILED" +msgstr "提交申请失败" + +#. errno: 5016 +msgid "ORG_APPLY_UPDATE_FAILED" +msgstr "更新申请失败" + +#. errno: 5017 +msgid "ORG_USER_REL_FAILED" +msgstr "组织成员操作失败" + +#. errno: 5018 +msgid "ORG_REPO_NOT_FOUND" +msgstr "仓库不存在" + +#. errno: 5019 +msgid "ORG_REPO_NAME_EXISTS" +msgstr "该组织下已存在同名仓库" + +#. errno: 5020 +msgid "ORG_PR_NOT_FOUND" +msgstr "PR 记录不存在" + +#. errno: 5021 +msgid "ORG_REPO_TOKEN_MISSING" +msgstr "仓库未配置 GitHub Token" + +#. errno: 5022 +msgid "GITHUB_PR_NOT_FOUND" +msgstr "GitHub 上未找到该 PR" + +#. errno: 5023 +msgid "GITHUB_RATE_LIMITED" +msgstr "GitHub API 触发限流,请稍后再试" + +#. errno: 5024 +msgid "GITHUB_API_FAILED" +msgstr "GitHub API 请求失败" + +#. errno: 5025 +msgid "ORG_PR_SYNC_IN_PROGRESS" +msgstr "该 PR 正在同步中,请稍后" + +#. errno: 6001 +msgid "ASSIGNMENT_NOT_FOUND" +msgstr "作业不存在" + +#. errno: 6002 +msgid "ASSIGNMENT_INVALID_ID" +msgstr "作业ID无效" + +#. errno: 6003 +msgid "ASSIGNMENT_SIZE_EXCEED" +msgstr "作业文件大小超限" + +#. errno: 6004 +msgid "ASSIGNMENT_MAX_SIZE_EXCEED" +msgstr "提交数量超限" + +#. errno: 6005 +msgid "ASSIGNMENT_INSERT_FAILED" +msgstr "创建作业失败" + +#. errno: 6006 +msgid "ASSIGNMENT_UPDATE_FAILED" +msgstr "更新作业失败" + +#. errno: 6007 +msgid "ASSIGNMENT_DELETE_FAILED" +msgstr "删除作业失败" + +#. errno: 7001 +msgid "COMMENT_NOT_FOUND" +msgstr "评论不存在" + +#. errno: 7002 +msgid "COMMENT_PERMISSION_DENIED" +msgstr "无权操作此评论" + +#. errno: 7003 +msgid "COMMENT_CREATE_FAILED" +msgstr "创建评论失败" + +#. errno: 7004 +msgid "COMMENT_UPDATE_FAILED" +msgstr "更新评论失败" + +#. errno: 7005 +msgid "COMMENT_DELETE_FAILED" +msgstr "删除评论失败" + +#. errno: 7006 +msgid "COMMENT_PRAISE_FAILED" +msgstr "点赞评论失败" + +#. errno: 7007 +msgid "COMMENT_UNPRAISE_FAILED" +msgstr "取消点赞评论失败" + +#. errno: 7008 +msgid "COMMENT_PARENT_NOT_FOUND" +msgstr "父评论不存在" + +#. errno: 8001 +msgid "BUG_NOT_FOUND" +msgstr "Bug不存在" + +#. errno: 8002 +msgid "REQUIREMENT_NOT_FOUND" +msgstr "需求不存在" + +#. errno: 8003 +msgid "TASK_NOT_FOUND" +msgstr "任务不存在" + +#. errno: 8004 +msgid "RD_INVALID_TYPE" +msgstr "无效的工单类型" + +#. errno: 8005 +msgid "RD_NOT_ORG_MEMBER" +msgstr "不是该组织成员" + +#. errno: 8006 +msgid "RD_INSERT_FAILED" +msgstr "创建工单失败" + +#. errno: 8007 +msgid "RD_UPDATE_FAILED" +msgstr "更新工单失败" + +#. errno: 8008 +msgid "RD_DELETE_FAILED" +msgstr "删除工单失败" + +#. errno: 8009 +msgid "RD_INVALID_USER" +msgstr "用户无效" + +#. errno: 9001 +msgid "DB_CONNECTION_FAILED" +msgstr "数据库连接失败" + +#. errno: 9002 +msgid "DB_OPERATION_FAILED" +msgstr "数据库操作失败" + +#. errno: 9003 +msgid "DB_TRANSACTION_FAILED" +msgstr "数据库事务失败" + +#. errno: 9004 +msgid "DB_COMMIT_FAILED" +msgstr "数据库提交失败" + +#. errno: 9005 +msgid "INTERNAL_ERROR" +msgstr "服务器内部错误" + +#. errno: 9006 +msgid "SETTINGS_NOT_LOADED" +msgstr "系统设置未加载" + +#. errno: 9007 +msgid "SETTINGS_SAVE_FAILED" +msgstr "系统设置保存失败" + +#. errno: 9008 +msgid "SMTP_NOT_CONFIGURED" +msgstr "邮件服务未配置" + +#. errno: 9009 +msgid "REDIS_OPERATION_FAILED" +msgstr "缓存服务操作失败" + +#. errno: 9010 +msgid "METHOD_NOT_ALLOWED" +msgstr "不支持的请求方法" + +#. errno: 9011 +msgid "NOTIFICATION_SEND_FAILED" +msgstr "通知发送失败" + +#. errno: 9012 +msgid "AI_CONFIG_SAVE_FAILED" +msgstr "AI配置保存失败" + +#. errno: 9013 +msgid "ARTICLE_PRAISE_FAILED" +msgstr "点赞文章失败" + +#. errno: 9014 +msgid "ARTICLE_UNPRAISE_FAILED" +msgstr "取消点赞文章失败" + +#. errno: 9015 +msgid "NOTIFICATION_TITLE_TOO_LONG" +msgstr "通知标题过长" + +#. errno: 9016 +msgid "NOTIFICATION_CONTENT_TOO_LONG" +msgstr "通知内容过长" + +#. errno: 9017 +msgid "NOTIFICATION_USERS_REQUIRED" +msgstr "缺少目标用户" + +#. errno: 9018 +msgid "NOTIFICATION_INVALID_TARGET" +msgstr "无效的通知目标类型" + +#. errno: 12001 +msgid "MESSAGE_CONVERSATION_NOT_FOUND" +msgstr "会话不存在" + +#. errno: 12002 +msgid "MESSAGE_SEND_FAILED" +msgstr "消息发送失败" + +#. errno: 12003 +msgid "MESSAGE_TEXT_TOO_LONG" +msgstr "消息内容过长" + +#. errno: 12004 +msgid "MESSAGE_PEER_NOT_FOUND" +msgstr "对端用户不存在" + +#. errno: 12005 +msgid "MESSAGE_CANNOT_SELF" +msgstr "不能与自己私信" + +#: src/servlets/article/article_calendar_servlet.cc:36 +msgid "invalid year or month" +msgstr "无效的年份或月份" + +#: src/servlets/category/category_create_servlet.cc:44 +msgid "invalid parent_id" +msgstr "无效的 parent_id" + +#: src/servlets/category/category_create_servlet.cc:54 +msgid "invalid cid" +msgstr "无效的 cid" + +#: src/servlets/category/category_create_servlet.cc:85 +msgid "category operation failed" +msgstr "分类操作失败" + +#: src/servlets/category/category_delete_servlet.cc:61 +msgid "no categories" +msgstr "没有可用的分类" + +#: src/servlets/database/backup_create_servlet.cc:55 +#: src/servlets/database/export_create_servlet.cc:38 +msgid "param type is required" +msgstr "缺少必填参数 type" + +#: src/servlets/database/backup_create_servlet.cc:59 +msgid "type must be full or incremental" +msgstr "type 必须是 full 或 incremental" + +#: src/servlets/database/backup_create_servlet.cc:68 +msgid "create backup failed" +msgstr "创建备份失败" + +#: src/servlets/database/backup_delete_servlet.cc:38 +#: src/servlets/database/backup_download_servlet.cc:48 +#: src/servlets/database/export_delete_servlet.cc:38 +#: src/servlets/database/export_download_servlet.cc:48 +#: src/servlets/message/conversation_delete_servlet.cc:32 +#: src/servlets/message/conversation_message_servlet.cc:35 +#: src/servlets/message/conversation_read_servlet.cc:32 +msgid "param id is required" +msgstr "缺少必填参数 id" + +#: src/servlets/database/backup_delete_servlet.cc:43 +#: src/servlets/database/backup_download_servlet.cc:53 +#: src/servlets/database/export_delete_servlet.cc:43 +#: src/servlets/database/export_download_servlet.cc:53 +#: src/servlets/help/admin_faq_delete_servlet.cc:56 +#: src/servlets/help/admin_faq_edit_servlet.cc:56 +#: src/servlets/help/admin_feedback_convert_servlet.cc:57 +#: src/servlets/help/admin_feedback_delete_servlet.cc:49 +#: src/servlets/message/conversation_delete_servlet.cc:37 +#: src/servlets/message/conversation_message_servlet.cc:40 +#: src/servlets/message/conversation_read_servlet.cc:37 +msgid "invalid id" +msgstr "无效的 ID" + +#: src/servlets/database/backup_delete_servlet.cc:49 +#: src/servlets/database/backup_download_servlet.cc:59 +msgid "backup record not found" +msgstr "备份记录不存在" + +#: src/servlets/database/backup_delete_servlet.cc:54 +msgid "delete backup failed" +msgstr "删除备份失败" + +#: src/servlets/database/export_create_servlet.cc:42 +msgid "type must be articles/users/comments/full" +msgstr "type 必须是 articles/users/comments/full" + +#: src/servlets/database/export_create_servlet.cc:51 +msgid "create export failed" +msgstr "创建导出任务失败" + +#: src/servlets/database/export_delete_servlet.cc:49 +#: src/servlets/database/export_download_servlet.cc:59 +msgid "export record not found" +msgstr "导出记录不存在" + +#: src/servlets/database/export_delete_servlet.cc:54 +msgid "delete export failed" +msgstr "删除导出记录失败" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:49 +msgid "target must be faq, requirement or bug" +msgstr "target 必须是 faq、requirement 或 bug" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:96 +msgid "insert faq failed" +msgstr "创建 FAQ 失败" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:110 +#: src/servlets/help/admin_feedback_convert_servlet.cc:142 +msgid "invalid orgId" +msgstr "无效的 orgId" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:128 +msgid "insert requirement failed" +msgstr "创建需求失败" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:161 +msgid "insert bug failed" +msgstr "创建 Bug 失败" + +#: src/servlets/help/admin_feedback_list_servlet.cc:50 +msgid "query feedbacks failed" +msgstr "查询反馈失败" + +#: src/servlets/help/feedback_servlet.cc:46 +msgid "feedback insert failed" +msgstr "提交反馈失败" + +#: src/servlets/label/label_create_servlet.cc:41 +msgid "invalid lid" +msgstr "无效的 lid" + +#: src/servlets/label/label_create_servlet.cc:73 +msgid "label operation failed" +msgstr "标签操作失败" + +#: src/servlets/message/conversation_delete_servlet.cc:52 +msgid "delete conversation failed" +msgstr "删除会话失败" + +#: src/servlets/message/conversation_message_servlet.cc:79 +msgid "param text is required" +msgstr "缺少必填参数 text" + +#: src/servlets/message/conversation_read_servlet.cc:52 +msgid "mark read failed" +msgstr "标记已读失败" + +#: src/servlets/message/conversation_servlet.cc:54 +msgid "param peer_id is required" +msgstr "缺少必填参数 peer_id" + +#: src/servlets/message/conversation_servlet.cc:59 +msgid "invalid peer_id" +msgstr "无效的 peer_id" + +#: src/servlets/message/message_json.cc:52 +msgid "私信仅对内部成员开放" +msgstr "私信仅对内部成员开放" + +#: src/servlets/notify/notification_read_servlet.cc:41 +msgid "id or ids is required" +msgstr "缺少必填参数 id 或 ids" + +#: src/servlets/organization/organization_apply_review_servlet.cc:37 +msgid "invalid apply_id" +msgstr "无效的 apply_id" + +#: src/servlets/organization/organization_apply_review_servlet.cc:42 +msgid "action must be approve or reject" +msgstr "action 必须是 approve 或 reject" + +#: src/servlets/organization/organization_join_check_servlet.cc:35 +#: src/servlets/user/user_admin_create_servlet.cc:77 +#: src/servlets/user/user_admin_update_servlet.cc:103 +msgid "invalid state" +msgstr "无效的状态" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:46 +msgid "PR 记录不存在" +msgstr "PR 记录不存在" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:72 +msgid "仅研发主管及以上角色可删除审查记录" +msgstr "仅研发主管及以上角色可删除审查记录" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:84 +msgid "删除审查记录失败" +msgstr "删除审查记录失败" + +#: src/servlets/organization/organization_repo_prs_list_servlet.cc:67 +#: src/servlets/organization/organization_repo_prs_list_servlet.cc:80 +#: src/servlets/organization/organization_repos_add_servlet.cc:81 +#: src/servlets/organization/organization_repos_delete_servlet.cc:56 +#: src/servlets/organization/organization_repos_list_servlet.cc:53 +msgid "无权访问该组织" +msgstr "无权访问该组织" + +#: src/servlets/organization/organization_repo_prs_sync_servlet.cc:74 +msgid "param id or repo_id is required" +msgstr "缺少必填参数 id 或 repo_id" + +#: src/servlets/organization/organization_repo_prs_sync_servlet.cc:119 +msgid "仅研发主管及以上角色可同步 PR" +msgstr "仅研发主管及以上角色可同步 PR" + +#: src/servlets/organization/organization_repos_add_servlet.cc:37 +msgid "仓库名称最长128字符" +msgstr "仓库名称最长128字符" + +#: src/servlets/organization/organization_repos_add_servlet.cc:42 +msgid "仓库地址最长512字符" +msgstr "仓库地址最长512字符" + +#: src/servlets/organization/organization_repos_add_servlet.cc:47 +msgid "仅支持 GitHub 仓库" +msgstr "仅支持 GitHub 仓库" + +#: src/servlets/organization/organization_repos_add_servlet.cc:52 +msgid "编程语言最长64字符" +msgstr "编程语言最长64字符" + +#: src/servlets/organization/organization_repos_add_servlet.cc:86 +msgid "仅研发主管及以上角色可添加仓库" +msgstr "仅研发主管及以上角色可添加仓库" + +#: src/servlets/organization/organization_repos_add_servlet.cc:94 +msgid "该组织下已存在同名仓库" +msgstr "该组织下已存在同名仓库" + +#: src/servlets/organization/organization_repos_add_servlet.cc:143 +msgid "添加仓库失败" +msgstr "添加仓库失败" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:40 +msgid "仓库不存在" +msgstr "仓库不存在" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:61 +msgid "仅研发主管及以上角色可删除仓库" +msgstr "仅研发主管及以上角色可删除仓库" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:73 +msgid "删除仓库失败" +msgstr "删除仓库失败" + +#: src/servlets/organization/organization_repos_sync_servlet.cc:62 +msgid "仅研发主管及以上角色可触发仓库同步" +msgstr "仅研发主管及以上角色可触发仓库同步" + +#: src/servlets/organization/organization_repos_sync_servlet.cc:77 +msgid "同步失败" +msgstr "同步失败" + +#: src/servlets/organization/organization_repos_token_servlet.cc:60 +msgid "仅研发主管及以上角色可管理仓库 Token" +msgstr "仅研发主管及以上角色可管理仓库 Token" + +#: src/servlets/organization/organization_repos_token_servlet.cc:74 +msgid "保存 Token 失败" +msgstr "保存 Token 失败" + +#: src/servlets/organization/organization_user_switch_role_servlet.cc:70 +#: src/servlets/user/user_admin_create_servlet.cc:73 +#: src/servlets/user/user_admin_update_servlet.cc:96 +msgid "invalid role" +msgstr "无效的角色" + +#: src/servlets/rd/rd_bug_detail_servlet.cc:26 +#: src/servlets/rd/rd_requirement_detail_servlet.cc:26 +#: src/servlets/rd/rd_task_detail_servlet.cc:26 +msgid "id is required" +msgstr "缺少必填参数 id" + +#: src/servlets/rd/rd_my_tickets_servlet.cc:26 +msgid "type is required" +msgstr "缺少必填参数 type" + +#: src/servlets/rd/rd_trend_servlet.cc:181 +msgid "日期范围不合法" +msgstr "日期范围不合法" + +#: src/servlets/rd/rd_trend_servlet.cc:195 +msgid "无权访问任何组织" +msgstr "无权访问任何组织" + +#: src/servlets/user/user_2fa_confirm_servlet.cc:54 +msgid "please call /api/v1/user/2fa/enable first" +msgstr "请先调用 /api/v1/user/2fa/enable" + +#: src/servlets/user/user_2fa_disable_servlet.cc:63 +msgid "disable 2fa failed" +msgstr "关闭双因素认证失败" + +#: src/servlets/user/user_2fa_reset_servlet.cc:81 +msgid "reset 2fa failed" +msgstr "重置双因素认证失败" + +#: src/servlets/user/user_admin_create_servlet.cc:49 +#: src/servlets/user/user_create_servlet.cc:36 +msgid "account and password required" +msgstr "缺少账号或密码" + +#: src/servlets/user/user_admin_create_servlet.cc:96 +#: src/servlets/user/user_admin_reset_passwd_servlet.cc:72 +#: src/servlets/user/user_create_servlet.cc:86 +#: src/servlets/user/user_reset_passwd_servlet.cc:63 +msgid "insert user failed" +msgstr "创建用户失败" + +#: src/servlets/user/user_admin_delete_servlet.cc:67 +msgid "delete user failed" +msgstr "删除用户失败" + +#: src/servlets/user/user_admin_reset_passwd_servlet.cc:48 +#: src/servlets/user/user_reset_passwd_servlet.cc:34 +msgid "password required" +msgstr "缺少密码" + +#: src/servlets/user/user_admin_update_servlet.cc:117 +#: src/servlets/user/user_update_servlet.cc:94 +msgid "update user failed" +msgstr "更新用户失败" + +#: src/servlets/user/user_ai_config_servlet.cc:60 +msgid "type must be openai, claude or glm" +msgstr "type 必须是 openai、claude 或 glm" + +#: src/servlets/user/user_ai_config_servlet.cc:67 +msgid "max_tokens is required for claude" +msgstr "claude 必须提供 max_tokens" + +#: src/servlets/user/user_oauth_github_callback_servlet.cc:308 +#: src/servlets/user/user_oauth_github_servlet.cc:59 +msgid "GitHub OAuth 未配置" +msgstr "GitHub OAuth 未配置" + +#: src/servlets/user/user_refresh_token_servlet.cc:52 +msgid "token is required" +msgstr "缺少 token" + +#: src/servlets/user/user_refresh_token_servlet.cc:58 +msgid "token mismatch, please re-login" +msgstr "token 不匹配,请重新登录" + +#: src/servlets/user/user_update_servlet.cc:59 +msgid "old password required" +msgstr "缺少旧密码" + +#: src/util.h:128 src/util.h:137 +#, c-format +msgid "param %s is required" +msgstr "缺少必填参数 %s" diff --git a/locale/techhaven.pot b/locale/techhaven.pot new file mode 100644 index 0000000..a382366 --- /dev/null +++ b/locale/techhaven.pot @@ -0,0 +1,912 @@ +msgid "" +msgstr "" +"Project-Id-Version: TechHaven-Server 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-18 07:06+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: scripts/i18n.py\n" + +#. errno: 0 +msgid "SUCCESS" +msgstr "" + +#. errno: 1001 +msgid "PARAM_MISSING" +msgstr "" + +#. errno: 1002 +msgid "PARAM_INVALID" +msgstr "" + +#. errno: 1003 +msgid "INVALID_METHOD" +msgstr "" + +#. errno: 1101 +msgid "NOT_LOGIN" +msgstr "" + +#. errno: 1102 +msgid "ACCESS_DENIED" +msgstr "" + +#. errno: 1103 +msgid "ACCOUNT_INVALID" +msgstr "" + +#. errno: 1104 +msgid "AUTH_CODE_INVALID" +msgstr "" + +#. errno: 2001 +msgid "USER_NOT_FOUND" +msgstr "" + +#. errno: 2002 +msgid "USER_ACCOUNT_EXISTS" +msgstr "" + +#. errno: 2003 +msgid "USER_EMAIL_EXISTS" +msgstr "" + +#. errno: 2004 +msgid "USER_INVALID_ACCOUNT" +msgstr "" + +#. errno: 2005 +msgid "USER_INVALID_EMAIL" +msgstr "" + +#. errno: 2006 +msgid "USER_INVALID_PASSWORD" +msgstr "" + +#. errno: 2007 +msgid "USER_PASSWORD_WRONG" +msgstr "" + +#. errno: 2008 +msgid "USER_OLD_PASSWORD_WRONG" +msgstr "" + +#. errno: 2009 +msgid "USER_ALREADY_DELETED" +msgstr "" + +#. errno: 2010 +msgid "USER_ALREADY_LOGIN" +msgstr "" + +#. errno: 2011 +msgid "USER_EMAIL_NOT_REGISTER" +msgstr "" + +#. errno: 2012 +msgid "USER_NO_PARAM" +msgstr "" + +#. errno: 2013 +msgid "USER_PASSWORDS_DIFFER" +msgstr "" + +#. errno: 2014 +msgid "USER_CANNOT_FOLLOW_SELF" +msgstr "" + +#. errno: 2015 +msgid "USER_NOT_FOLLOWING" +msgstr "" + +#. errno: 2016 +msgid "USER_FOLLOW_FAILED" +msgstr "" + +#. errno: 2017 +msgid "SEND_CODE_FREQUENT" +msgstr "" + +#. errno: 2018 +msgid "DEVICE_NOT_FOUND" +msgstr "" + +#. errno: 2019 +msgid "DEVICE_KICK_FAILED" +msgstr "" + +#. errno: 2020 +msgid "TOTP_INVALID_CODE" +msgstr "" + +#. errno: 2021 +msgid "TOTP_NOT_ENABLED" +msgstr "" + +#. errno: 2022 +msgid "TOTP_ALREADY_ENABLED" +msgstr "" + +#. errno: 2023 +msgid "TOTP_ENABLE_FAILED" +msgstr "" + +#. errno: 2024 +msgid "TOTP_CONFIRM_FAILED" +msgstr "" + +#. errno: 2025 +msgid "RECOVERY_CODE_INVALID" +msgstr "" + +#. errno: 2026 +msgid "TEMP_TOKEN_INVALID" +msgstr "" + +#. errno: 2027 +msgid "GITHUB_OAUTH_FAILED" +msgstr "" + +#. errno: 2028 +msgid "GITHUB_STATE_INVALID" +msgstr "" + +#. errno: 3001 +msgid "ARTICLE_NOT_FOUND" +msgstr "" + +#. errno: 3002 +msgid "ARTICLE_INVALID_TYPE" +msgstr "" + +#. errno: 3003 +msgid "ARTICLE_INVALID_STATE" +msgstr "" + +#. errno: 3004 +msgid "ARTICLE_INVALID_ID" +msgstr "" + +#. errno: 3005 +msgid "ARTICLE_INSERT_FAILED" +msgstr "" + +#. errno: 3006 +msgid "ARTICLE_UPDATE_FAILED" +msgstr "" + +#. errno: 3007 +msgid "ARTICLE_DELETE_FAILED" +msgstr "" + +#. errno: 3008 +msgid "ARTICLE_PUBLISH_FAILED" +msgstr "" + +#. errno: 3009 +msgid "ARTICLE_CATEGORY_EMPTY" +msgstr "" + +#. errno: 4001 +msgid "FILE_NOT_FOUND" +msgstr "" + +#. errno: 4002 +msgid "FILE_TOO_LARGE" +msgstr "" + +#. errno: 4003 +msgid "FILE_INVALID_NAME" +msgstr "" + +#. errno: 4004 +msgid "FILE_INVALID_PATH" +msgstr "" + +#. errno: 4005 +msgid "FILE_READ_ERROR" +msgstr "" + +#. errno: 4006 +msgid "FILE_UPLOAD_FAILED" +msgstr "" + +#. errno: 4007 +msgid "FILE_SAVE_FAILED" +msgstr "" + +#. errno: 4008 +msgid "FILE_PROTOCOL_ERROR" +msgstr "" + +#. errno: 4009 +msgid "FILE_DUMP_FAILED" +msgstr "" + +#. errno: 4010 +msgid "UPLOAD_SESSION_NOT_FOUND" +msgstr "" + +#. errno: 4011 +msgid "UPLOAD_INVALID_TYPE" +msgstr "" + +#. errno: 4012 +msgid "UPLOAD_INVALID_PARAMS" +msgstr "" + +#. errno: 4013 +msgid "UPLOAD_CHUNK_INVALID" +msgstr "" + +#. errno: 4014 +msgid "UPLOAD_CHUNK_ALREADY_DONE" +msgstr "" + +#. errno: 4015 +msgid "UPLOAD_CHUNK_INCOMPLETE" +msgstr "" + +#. errno: 4016 +msgid "UPLOAD_FILE_CREATE_FAILED" +msgstr "" + +#. errno: 4017 +msgid "UPLOAD_FILE_ASSEMBLE_FAILED" +msgstr "" + +#. errno: 4018 +msgid "UPLOAD_FILE_HASH_FAILED" +msgstr "" + +#. errno: 4019 +msgid "UPLOAD_TEMP_DIR_FAILED" +msgstr "" + +#. errno: 4020 +msgid "UPLOAD_TEMP_FILE_FAILED" +msgstr "" + +#. errno: 4021 +msgid "UPLOAD_CHUNK_SIZE_EXCEED" +msgstr "" + +#. errno: 4022 +msgid "UPLOAD_TYPE_INVALID_TYPE" +msgstr "" + +#. errno: 4023 +msgid "UPLOAD_FILE_EMPTY" +msgstr "" + +#. errno: 5001 +msgid "ORG_NOT_FOUND" +msgstr "" + +#. errno: 5002 +msgid "ORG_DISABLED" +msgstr "" + +#. errno: 5003 +msgid "ORG_ALREADY_JOINED" +msgstr "" + +#. errno: 5004 +msgid "ORG_ALREADY_APPLIED" +msgstr "" + +#. errno: 5005 +msgid "ORG_APPLY_NOT_FOUND" +msgstr "" + +#. errno: 5006 +msgid "ORG_APPLY_ALREADY_REVIEWED" +msgstr "" + +#. errno: 5007 +msgid "ORG_INVALID_ROLE" +msgstr "" + +#. errno: 5008 +msgid "ORG_INVALID_STATE" +msgstr "" + +#. errno: 5009 +msgid "ORG_NOT_MEMBER" +msgstr "" + +#. errno: 5010 +msgid "ORG_INVALID_ID" +msgstr "" + +#. errno: 5011 +msgid "ORG_INSERT_FAILED" +msgstr "" + +#. errno: 5012 +msgid "ORG_UPDATE_FAILED" +msgstr "" + +#. errno: 5013 +msgid "ORG_DELETE_FAILED" +msgstr "" + +#. errno: 5014 +msgid "ORG_NO_VALID_ORGS" +msgstr "" + +#. errno: 5015 +msgid "ORG_APPLY_INSERT_FAILED" +msgstr "" + +#. errno: 5016 +msgid "ORG_APPLY_UPDATE_FAILED" +msgstr "" + +#. errno: 5017 +msgid "ORG_USER_REL_FAILED" +msgstr "" + +#. errno: 5018 +msgid "ORG_REPO_NOT_FOUND" +msgstr "" + +#. errno: 5019 +msgid "ORG_REPO_NAME_EXISTS" +msgstr "" + +#. errno: 5020 +msgid "ORG_PR_NOT_FOUND" +msgstr "" + +#. errno: 5021 +msgid "ORG_REPO_TOKEN_MISSING" +msgstr "" + +#. errno: 5022 +msgid "GITHUB_PR_NOT_FOUND" +msgstr "" + +#. errno: 5023 +msgid "GITHUB_RATE_LIMITED" +msgstr "" + +#. errno: 5024 +msgid "GITHUB_API_FAILED" +msgstr "" + +#. errno: 5025 +msgid "ORG_PR_SYNC_IN_PROGRESS" +msgstr "" + +#. errno: 6001 +msgid "ASSIGNMENT_NOT_FOUND" +msgstr "" + +#. errno: 6002 +msgid "ASSIGNMENT_INVALID_ID" +msgstr "" + +#. errno: 6003 +msgid "ASSIGNMENT_SIZE_EXCEED" +msgstr "" + +#. errno: 6004 +msgid "ASSIGNMENT_MAX_SIZE_EXCEED" +msgstr "" + +#. errno: 6005 +msgid "ASSIGNMENT_INSERT_FAILED" +msgstr "" + +#. errno: 6006 +msgid "ASSIGNMENT_UPDATE_FAILED" +msgstr "" + +#. errno: 6007 +msgid "ASSIGNMENT_DELETE_FAILED" +msgstr "" + +#. errno: 7001 +msgid "COMMENT_NOT_FOUND" +msgstr "" + +#. errno: 7002 +msgid "COMMENT_PERMISSION_DENIED" +msgstr "" + +#. errno: 7003 +msgid "COMMENT_CREATE_FAILED" +msgstr "" + +#. errno: 7004 +msgid "COMMENT_UPDATE_FAILED" +msgstr "" + +#. errno: 7005 +msgid "COMMENT_DELETE_FAILED" +msgstr "" + +#. errno: 7006 +msgid "COMMENT_PRAISE_FAILED" +msgstr "" + +#. errno: 7007 +msgid "COMMENT_UNPRAISE_FAILED" +msgstr "" + +#. errno: 7008 +msgid "COMMENT_PARENT_NOT_FOUND" +msgstr "" + +#. errno: 8001 +msgid "BUG_NOT_FOUND" +msgstr "" + +#. errno: 8002 +msgid "REQUIREMENT_NOT_FOUND" +msgstr "" + +#. errno: 8003 +msgid "TASK_NOT_FOUND" +msgstr "" + +#. errno: 8004 +msgid "RD_INVALID_TYPE" +msgstr "" + +#. errno: 8005 +msgid "RD_NOT_ORG_MEMBER" +msgstr "" + +#. errno: 8006 +msgid "RD_INSERT_FAILED" +msgstr "" + +#. errno: 8007 +msgid "RD_UPDATE_FAILED" +msgstr "" + +#. errno: 8008 +msgid "RD_DELETE_FAILED" +msgstr "" + +#. errno: 8009 +msgid "RD_INVALID_USER" +msgstr "" + +#. errno: 9001 +msgid "DB_CONNECTION_FAILED" +msgstr "" + +#. errno: 9002 +msgid "DB_OPERATION_FAILED" +msgstr "" + +#. errno: 9003 +msgid "DB_TRANSACTION_FAILED" +msgstr "" + +#. errno: 9004 +msgid "DB_COMMIT_FAILED" +msgstr "" + +#. errno: 9005 +msgid "INTERNAL_ERROR" +msgstr "" + +#. errno: 9006 +msgid "SETTINGS_NOT_LOADED" +msgstr "" + +#. errno: 9007 +msgid "SETTINGS_SAVE_FAILED" +msgstr "" + +#. errno: 9008 +msgid "SMTP_NOT_CONFIGURED" +msgstr "" + +#. errno: 9009 +msgid "REDIS_OPERATION_FAILED" +msgstr "" + +#. errno: 9010 +msgid "METHOD_NOT_ALLOWED" +msgstr "" + +#. errno: 9011 +msgid "NOTIFICATION_SEND_FAILED" +msgstr "" + +#. errno: 9012 +msgid "AI_CONFIG_SAVE_FAILED" +msgstr "" + +#. errno: 9013 +msgid "ARTICLE_PRAISE_FAILED" +msgstr "" + +#. errno: 9014 +msgid "ARTICLE_UNPRAISE_FAILED" +msgstr "" + +#. errno: 9015 +msgid "NOTIFICATION_TITLE_TOO_LONG" +msgstr "" + +#. errno: 9016 +msgid "NOTIFICATION_CONTENT_TOO_LONG" +msgstr "" + +#. errno: 9017 +msgid "NOTIFICATION_USERS_REQUIRED" +msgstr "" + +#. errno: 9018 +msgid "NOTIFICATION_INVALID_TARGET" +msgstr "" + +#. errno: 12001 +msgid "MESSAGE_CONVERSATION_NOT_FOUND" +msgstr "" + +#. errno: 12002 +msgid "MESSAGE_SEND_FAILED" +msgstr "" + +#. errno: 12003 +msgid "MESSAGE_TEXT_TOO_LONG" +msgstr "" + +#. errno: 12004 +msgid "MESSAGE_PEER_NOT_FOUND" +msgstr "" + +#. errno: 12005 +msgid "MESSAGE_CANNOT_SELF" +msgstr "" + +#: src/servlets/article/article_calendar_servlet.cc:36 +msgid "invalid year or month" +msgstr "" + +#: src/servlets/category/category_create_servlet.cc:44 +msgid "invalid parent_id" +msgstr "" + +#: src/servlets/category/category_create_servlet.cc:54 +msgid "invalid cid" +msgstr "" + +#: src/servlets/category/category_create_servlet.cc:85 +msgid "category operation failed" +msgstr "" + +#: src/servlets/category/category_delete_servlet.cc:61 +msgid "no categories" +msgstr "" + +#: src/servlets/database/backup_create_servlet.cc:55 +#: src/servlets/database/export_create_servlet.cc:38 +msgid "param type is required" +msgstr "" + +#: src/servlets/database/backup_create_servlet.cc:59 +msgid "type must be full or incremental" +msgstr "" + +#: src/servlets/database/backup_create_servlet.cc:68 +msgid "create backup failed" +msgstr "" + +#: src/servlets/database/backup_delete_servlet.cc:38 +#: src/servlets/database/backup_download_servlet.cc:48 +#: src/servlets/database/export_delete_servlet.cc:38 +#: src/servlets/database/export_download_servlet.cc:48 +#: src/servlets/message/conversation_delete_servlet.cc:32 +#: src/servlets/message/conversation_message_servlet.cc:35 +#: src/servlets/message/conversation_read_servlet.cc:32 +msgid "param id is required" +msgstr "" + +#: src/servlets/database/backup_delete_servlet.cc:43 +#: src/servlets/database/backup_download_servlet.cc:53 +#: src/servlets/database/export_delete_servlet.cc:43 +#: src/servlets/database/export_download_servlet.cc:53 +#: src/servlets/help/admin_faq_delete_servlet.cc:56 +#: src/servlets/help/admin_faq_edit_servlet.cc:56 +#: src/servlets/help/admin_feedback_convert_servlet.cc:57 +#: src/servlets/help/admin_feedback_delete_servlet.cc:49 +#: src/servlets/message/conversation_delete_servlet.cc:37 +#: src/servlets/message/conversation_message_servlet.cc:40 +#: src/servlets/message/conversation_read_servlet.cc:37 +msgid "invalid id" +msgstr "" + +#: src/servlets/database/backup_delete_servlet.cc:49 +#: src/servlets/database/backup_download_servlet.cc:59 +msgid "backup record not found" +msgstr "" + +#: src/servlets/database/backup_delete_servlet.cc:54 +msgid "delete backup failed" +msgstr "" + +#: src/servlets/database/export_create_servlet.cc:42 +msgid "type must be articles/users/comments/full" +msgstr "" + +#: src/servlets/database/export_create_servlet.cc:51 +msgid "create export failed" +msgstr "" + +#: src/servlets/database/export_delete_servlet.cc:49 +#: src/servlets/database/export_download_servlet.cc:59 +msgid "export record not found" +msgstr "" + +#: src/servlets/database/export_delete_servlet.cc:54 +msgid "delete export failed" +msgstr "" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:49 +msgid "target must be faq, requirement or bug" +msgstr "" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:96 +msgid "insert faq failed" +msgstr "" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:110 +#: src/servlets/help/admin_feedback_convert_servlet.cc:142 +msgid "invalid orgId" +msgstr "" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:128 +msgid "insert requirement failed" +msgstr "" + +#: src/servlets/help/admin_feedback_convert_servlet.cc:161 +msgid "insert bug failed" +msgstr "" + +#: src/servlets/help/admin_feedback_list_servlet.cc:50 +msgid "query feedbacks failed" +msgstr "" + +#: src/servlets/help/feedback_servlet.cc:46 +msgid "feedback insert failed" +msgstr "" + +#: src/servlets/label/label_create_servlet.cc:41 +msgid "invalid lid" +msgstr "" + +#: src/servlets/label/label_create_servlet.cc:73 +msgid "label operation failed" +msgstr "" + +#: src/servlets/message/conversation_delete_servlet.cc:52 +msgid "delete conversation failed" +msgstr "" + +#: src/servlets/message/conversation_message_servlet.cc:79 +msgid "param text is required" +msgstr "" + +#: src/servlets/message/conversation_read_servlet.cc:52 +msgid "mark read failed" +msgstr "" + +#: src/servlets/message/conversation_servlet.cc:54 +msgid "param peer_id is required" +msgstr "" + +#: src/servlets/message/conversation_servlet.cc:59 +msgid "invalid peer_id" +msgstr "" + +#: src/servlets/message/message_json.cc:52 +msgid "私信仅对内部成员开放" +msgstr "" + +#: src/servlets/notify/notification_read_servlet.cc:41 +msgid "id or ids is required" +msgstr "" + +#: src/servlets/organization/organization_apply_review_servlet.cc:37 +msgid "invalid apply_id" +msgstr "" + +#: src/servlets/organization/organization_apply_review_servlet.cc:42 +msgid "action must be approve or reject" +msgstr "" + +#: src/servlets/organization/organization_join_check_servlet.cc:35 +#: src/servlets/user/user_admin_create_servlet.cc:77 +#: src/servlets/user/user_admin_update_servlet.cc:103 +msgid "invalid state" +msgstr "" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:46 +msgid "PR 记录不存在" +msgstr "" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:72 +msgid "仅研发主管及以上角色可删除审查记录" +msgstr "" + +#: src/servlets/organization/organization_repo_prs_delete_servlet.cc:84 +msgid "删除审查记录失败" +msgstr "" + +#: src/servlets/organization/organization_repo_prs_list_servlet.cc:67 +#: src/servlets/organization/organization_repo_prs_list_servlet.cc:80 +#: src/servlets/organization/organization_repos_add_servlet.cc:81 +#: src/servlets/organization/organization_repos_delete_servlet.cc:56 +#: src/servlets/organization/organization_repos_list_servlet.cc:53 +msgid "无权访问该组织" +msgstr "" + +#: src/servlets/organization/organization_repo_prs_sync_servlet.cc:74 +msgid "param id or repo_id is required" +msgstr "" + +#: src/servlets/organization/organization_repo_prs_sync_servlet.cc:119 +msgid "仅研发主管及以上角色可同步 PR" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:37 +msgid "仓库名称最长128字符" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:42 +msgid "仓库地址最长512字符" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:47 +msgid "仅支持 GitHub 仓库" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:52 +msgid "编程语言最长64字符" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:86 +msgid "仅研发主管及以上角色可添加仓库" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:94 +msgid "该组织下已存在同名仓库" +msgstr "" + +#: src/servlets/organization/organization_repos_add_servlet.cc:143 +msgid "添加仓库失败" +msgstr "" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:40 +msgid "仓库不存在" +msgstr "" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:61 +msgid "仅研发主管及以上角色可删除仓库" +msgstr "" + +#: src/servlets/organization/organization_repos_delete_servlet.cc:73 +msgid "删除仓库失败" +msgstr "" + +#: src/servlets/organization/organization_repos_sync_servlet.cc:62 +msgid "仅研发主管及以上角色可触发仓库同步" +msgstr "" + +#: src/servlets/organization/organization_repos_sync_servlet.cc:77 +msgid "同步失败" +msgstr "" + +#: src/servlets/organization/organization_repos_token_servlet.cc:60 +msgid "仅研发主管及以上角色可管理仓库 Token" +msgstr "" + +#: src/servlets/organization/organization_repos_token_servlet.cc:74 +msgid "保存 Token 失败" +msgstr "" + +#: src/servlets/organization/organization_user_switch_role_servlet.cc:70 +#: src/servlets/user/user_admin_create_servlet.cc:73 +#: src/servlets/user/user_admin_update_servlet.cc:96 +msgid "invalid role" +msgstr "" + +#: src/servlets/rd/rd_bug_detail_servlet.cc:26 +#: src/servlets/rd/rd_requirement_detail_servlet.cc:26 +#: src/servlets/rd/rd_task_detail_servlet.cc:26 +msgid "id is required" +msgstr "" + +#: src/servlets/rd/rd_my_tickets_servlet.cc:26 +msgid "type is required" +msgstr "" + +#: src/servlets/rd/rd_trend_servlet.cc:181 +msgid "日期范围不合法" +msgstr "" + +#: src/servlets/rd/rd_trend_servlet.cc:195 +msgid "无权访问任何组织" +msgstr "" + +#: src/servlets/user/user_2fa_confirm_servlet.cc:54 +msgid "please call /api/v1/user/2fa/enable first" +msgstr "" + +#: src/servlets/user/user_2fa_disable_servlet.cc:63 +msgid "disable 2fa failed" +msgstr "" + +#: src/servlets/user/user_2fa_reset_servlet.cc:81 +msgid "reset 2fa failed" +msgstr "" + +#: src/servlets/user/user_admin_create_servlet.cc:49 +#: src/servlets/user/user_create_servlet.cc:36 +msgid "account and password required" +msgstr "" + +#: src/servlets/user/user_admin_create_servlet.cc:96 +#: src/servlets/user/user_admin_reset_passwd_servlet.cc:72 +#: src/servlets/user/user_create_servlet.cc:86 +#: src/servlets/user/user_reset_passwd_servlet.cc:63 +msgid "insert user failed" +msgstr "" + +#: src/servlets/user/user_admin_delete_servlet.cc:67 +msgid "delete user failed" +msgstr "" + +#: src/servlets/user/user_admin_reset_passwd_servlet.cc:48 +#: src/servlets/user/user_reset_passwd_servlet.cc:34 +msgid "password required" +msgstr "" + +#: src/servlets/user/user_admin_update_servlet.cc:117 +#: src/servlets/user/user_update_servlet.cc:94 +msgid "update user failed" +msgstr "" + +#: src/servlets/user/user_ai_config_servlet.cc:60 +msgid "type must be openai, claude or glm" +msgstr "" + +#: src/servlets/user/user_ai_config_servlet.cc:67 +msgid "max_tokens is required for claude" +msgstr "" + +#: src/servlets/user/user_oauth_github_callback_servlet.cc:308 +#: src/servlets/user/user_oauth_github_servlet.cc:59 +msgid "GitHub OAuth 未配置" +msgstr "" + +#: src/servlets/user/user_refresh_token_servlet.cc:52 +msgid "token is required" +msgstr "" + +#: src/servlets/user/user_refresh_token_servlet.cc:58 +msgid "token mismatch, please re-login" +msgstr "" + +#: src/servlets/user/user_update_servlet.cc:59 +msgid "old password required" +msgstr "" + +#: src/util.h:128 src/util.h:137 +#, c-format +msgid "param %s is required" +msgstr "" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 8f440e6..f6f4f54 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -198,6 +198,11 @@ case "$MODE" in mkdir -p "$DEPLOY_PATH/module" mv "$tmp"/module/*.so "$DEPLOY_PATH/module/" mv "$tmp/errors.json" "$DEPLOY_PATH/errors.json" + if [ -d "$tmp/locale" ]; then + log "热更新国际化目录: $tmp/locale -> $DEPLOY_PATH/locale" + rm -rf "$DEPLOY_PATH/locale" + mv "$tmp/locale" "$DEPLOY_PATH/locale" + fi # 优先向 master 发 SIGHUP(触发 master fork 新 worker,不依赖 pid 文件/work_path) master="$(server_master_pid || true)" diff --git a/scripts/i18n.py b/scripts/i18n.py new file mode 100755 index 0000000..a298bd7 --- /dev/null +++ b/scripts/i18n.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Generate and compile gettext catalogs for TechHaven-Server messages.""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +PROJECT_NAME = "TechHaven-Server" +DOMAIN_PREFIX = "techhaven" +LANGUAGES = {"en": "en", "zh_CN": "zh"} +KEY_RE = re.compile(r"^[A-Z0-9_]+$") +PRINTF_RE = re.compile(r"%(?:[-+ #0]*\d*(?:\.\d+)?[diouxXeEfFgGaAcspn%])") +SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".h"} +SOURCE_KEYWORDS = ("setErrno:2", "setErrnoFmt:2") + + +class CatalogError(Exception): + pass + + +def po_escape(value): + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def po_unescape(value): + result = [] + index = 0 + escapes = { + "n": "\n", + "r": "\r", + "t": "\t", + '"': '"', + "\\": "\\", + } + while index < len(value): + char = value[index] + if char == "\\" and index + 1 < len(value): + nxt = value[index + 1] + result.append(escapes.get(nxt, nxt)) + index += 2 + continue + result.append(char) + index += 1 + return "".join(result) + + +def load_errors(project_dir): + path = project_dir / "errors.json" + if not path.is_file(): + raise CatalogError(f"errors.json not found: {path}") + + try: + with path.open("r", encoding="utf-8") as stream: + root = json.load(stream) + except json.JSONDecodeError as exc: + raise CatalogError(f"failed to parse {path}: {exc}") from exc + + errors = root.get("errors") + if not isinstance(errors, list): + raise CatalogError("errors.json: 'errors' must be an array") + + seen_errno = {} + seen_key = {} + normalized = [] + for index, item in enumerate(errors): + if not isinstance(item, dict): + raise CatalogError(f"errors[{index}] must be an object") + + errno = item.get("errno") + key = item.get("key") + zh = item.get("zh") + en = item.get("en") + + if not isinstance(errno, int): + raise CatalogError(f"errors[{index}].errno must be an integer") + if not isinstance(key, str) or not KEY_RE.fullmatch(key): + raise CatalogError(f"errors[{index}].key is invalid: {key!r}") + if not isinstance(zh, str) or not zh: + raise CatalogError(f"errors[{index}].zh must be a non-empty string") + if not isinstance(en, str) or not en: + raise CatalogError(f"errors[{index}].en must be a non-empty string") + + if errno in seen_errno: + raise CatalogError(f"duplicate errno {errno}: {key} and {seen_errno[errno]}") + if key in seen_key: + raise CatalogError(f"duplicate key {key}: errno {errno} and {seen_key[key]}") + seen_errno[errno] = key + seen_key[key] = errno + + normalized.append({"errno": errno, "key": key, "zh": zh, "en": en}) + + if not normalized: + raise CatalogError("errors.json contains no error codes") + + return root.get("version", "unknown"), normalized + + +def header(language): + version = os.environ.get("SOURCE_DATE_EPOCH") + try: + timestamp = ( + datetime.fromtimestamp(int(version), tz=timezone.utc) + if version is not None + else datetime.now(tz=timezone.utc) + ) + except (TypeError, ValueError, OSError): + timestamp = datetime.now(tz=timezone.utc) + date = timestamp.strftime("%Y-%m-%d %H:%M%z") + plural = ( + "nplurals=1; plural=0;" + if language.startswith("zh") + else "nplurals=2; plural=(n != 1);" + ) + return [ + 'msgid ""', + 'msgstr ""', + f'"Project-Id-Version: {PROJECT_NAME}\\n"', + '"Report-Msgid-Bugs-To: \\n"', + f'"POT-Creation-Date: {date}\\n"', + '"PO-Revision-Date: \\n"', + '"Last-Translator: \\n"', + '"Language-Team: \\n"', + f'"Language: {language}\\n"', + '"MIME-Version: 1.0\\n"', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Content-Transfer-Encoding: 8bit\\n"', + f'"Plural-Forms: {plural}\\n"', + '"X-Generator: scripts/i18n.py\\n"', + ] + + +def render_pot(version, entries): + lines = header("") + lines[2] = f'"Project-Id-Version: {PROJECT_NAME} {version}\\n"' + for entry in entries: + lines.append("") + lines.append(f"#. errno: {entry['errno']}") + lines.append(f'msgid "{po_escape(entry["key"])}"') + lines.append('msgstr ""') + return "\n".join(lines) + "\n" + + +def parse_po(path): + if not path.is_file(): + raise CatalogError(f"catalog not found: {path}") + + entries = {} + current = {} + field = None + + def store(): + msgid = current.get("msgid") + if msgid is None: + return + if msgid in entries: + raise CatalogError(f"duplicate msgid in {path}: {msgid}") + entries[msgid] = current.get("msgstr", "") + + with path.open("r", encoding="utf-8") as stream: + for raw_line in stream: + line = raw_line.rstrip("\n") + if line.startswith("#"): + continue + if line.startswith("msgid "): + store() + current = {"msgid": po_unescape(line[6:].strip()[1:-1]), "msgstr": ""} + field = "msgid" + continue + if line.startswith("msgstr "): + current["msgstr"] = po_unescape(line[7:].strip()[1:-1]) + field = "msgstr" + continue + if line.startswith('"') and field: + value = po_unescape(line.strip()[1:-1]) + current[field] += value + continue + + store() + entries.pop("", None) + return entries + + +def placeholder_types(value): + return sorted( + item[-1] + for item in PRINTF_RE.findall(value) + if item != "%%" + ) + + +def find_tool(name): + path = shutil.which(name) + if not path: + raise CatalogError(f"{name} not found; install gettext") + return path + + +def run_tool(command, label, cwd=None): + result = subprocess.run(command, capture_output=True, text=True, cwd=cwd) + if result.returncode != 0: + message = (result.stderr or result.stdout).strip() + raise CatalogError(f"{label} failed: {message}") + return result + + +def collect_sources(project_dir): + root = project_dir / "src" + if not root.is_dir(): + raise CatalogError(f"source directory not found: {root}") + return sorted( + path.relative_to(project_dir) + for path in root.rglob("*") + if path.is_file() and path.suffix in SOURCE_SUFFIXES + ) + + +def ensure_po(po_path, pot_path, language, msginit): + if po_path.is_file(): + return + run_tool( + [ + msginit, + "--no-translator", + "--locale", + language, + "--input", + str(pot_path), + "--output-file", + str(po_path), + ], + f"msginit {language}", + ) + + +def fill_error_translations(path, entries, language): + translations = {entry["key"]: entry[LANGUAGES[language]] for entry in entries} + text = path.read_text(encoding="utf-8") + blocks = text.split("\n\n") + output = [] + for block in blocks: + lines = block.splitlines() + msgid = None + msgstr_index = None + for index, line in enumerate(lines): + if line.startswith("msgid "): + msgid = po_unescape(line[6:].strip()[1:-1]) + elif line.startswith("msgstr "): + msgstr_index = index + break + if msgid in translations and msgstr_index is not None: + if lines[msgstr_index].strip() == 'msgstr ""': + lines[msgstr_index] = f'msgstr "{po_escape(translations[msgid])}"' + output.append("\n".join(lines)) + path.write_text("\n\n".join(output) + "\n", encoding="utf-8", newline="\n") + + +def preview(values, limit=8): + values = sorted(values) + if len(values) <= limit: + return ", ".join(values) + return ", ".join(values[:limit]) + f", ... ({len(values)} total)" + + +def sync(project_dir): + version, entries = load_errors(project_dir) + locale_dir = project_dir / "locale" + po_dir = locale_dir / "po" + po_dir.mkdir(parents=True, exist_ok=True) + pot_path = locale_dir / f"{DOMAIN_PREFIX}.pot" + xgettext = find_tool("xgettext") + msgcat = find_tool("msgcat") + msgmerge = find_tool("msgmerge") + msginit = find_tool("msginit") + + with tempfile.TemporaryDirectory(prefix="techhaven-i18n-") as temp: + temp_dir = Path(temp) + errors_pot = temp_dir / "errors.pot" + source_pot = temp_dir / "source.pot" + errors_pot.write_text( + render_pot(version, entries), encoding="utf-8", newline="\n" + ) + + command = [ + xgettext, + "--language=C++", + "--from-code=UTF-8", + "--add-comments=TRANSLATORS:", + ] + for keyword in SOURCE_KEYWORDS: + command.append(f"--keyword={keyword}") + command.extend(["-o", str(source_pot), *map(str, collect_sources(project_dir))]) + run_tool(command, "xgettext", cwd=project_dir) + run_tool( + [msgcat, "--use-first", str(errors_pot), str(source_pot), "-o", str(pot_path)], + "msgcat", + ) + + pot_entries = parse_po(pot_path) + for language in LANGUAGES: + path = po_dir / f"{language}.po" + ensure_po(path, pot_path, language, msginit) + run_tool( + [ + msgmerge, + "--quiet", + "--update", + "--backup=none", + "--no-fuzzy-matching", + str(path), + str(pot_path), + ], + f"msgmerge {language}", + ) + fill_error_translations(path, entries, language) + catalog = parse_po(path) + missing = [msgid for msgid in pot_entries if not catalog.get(msgid)] + if missing: + print( + f"warning: {path.name} has {len(missing)} untranslated entries: " + f"{preview(missing)}" + ) + print(f"updated {path.relative_to(project_dir)}") + + print(f"generated {pot_path.relative_to(project_dir)}") + print( + f"{len(entries)} error codes, " + f"{len(pot_entries) - len(entries)} custom messages" + ) + + +def check(project_dir): + version, entries = load_errors(project_dir) + locale_dir = project_dir / "locale" + po_dir = locale_dir / "po" + pot_path = locale_dir / f"{DOMAIN_PREFIX}.pot" + + if not pot_path.is_file(): + raise CatalogError(f"catalog not found: {pot_path}") + + pot_entries = parse_po(pot_path) + expected_keys = {entry["key"] for entry in entries} + missing_pot = expected_keys - set(pot_entries) + if missing_pot: + raise CatalogError(f"POT is missing keys: {preview(missing_pot)}") + custom = set(pot_entries) - expected_keys + if custom: + print(f"POT custom messages: {len(custom)}") + + for language in LANGUAGES: + path = po_dir / f"{language}.po" + catalog = parse_po(path) + missing = [msgid for msgid in pot_entries if msgid not in catalog] + if missing: + raise CatalogError( + f"{path.name} is missing {len(missing)} entries: {preview(missing)}" + ) + empty = [msgid for msgid in pot_entries if not catalog.get(msgid)] + if empty: + raise CatalogError( + f"{path.name} has {len(empty)} empty translations: {preview(empty)}" + ) + + for msgid in pot_entries: + translated = catalog[msgid] + if placeholder_types(msgid) != placeholder_types(translated): + raise CatalogError( + f"{path.name}: placeholder mismatch for {msgid!r}" + ) + + print(f"checked {path.relative_to(project_dir)} ({len(catalog)} entries)") + + print(f"checked {pot_path.relative_to(project_dir)} ({len(pot_entries)} entries)") + print(f"version {version}") + + +def build(project_dir, msgfmt): + if not msgfmt: + raise CatalogError( + "msgfmt not found; install gettext or pass --msgfmt " + ) + + po_dir = project_dir / "locale" / "po" + output_dir = project_dir / "locale" / "C" / "LC_MESSAGES" + output_dir.mkdir(parents=True, exist_ok=True) + + for language in LANGUAGES: + po_path = po_dir / f"{language}.po" + if not po_path.is_file(): + raise CatalogError(f"catalog not found: {po_path}") + mo_path = output_dir / f"{DOMAIN_PREFIX}_{language}.mo" + command = [ + msgfmt, + "--check", + "--check-format", + "--statistics", + "-o", + str(mo_path), + str(po_path), + ] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + message = (result.stderr or result.stdout).strip() + raise CatalogError(f"msgfmt failed for {po_path.name}: {message}") + print(f"compiled {mo_path.relative_to(project_dir)}") + + +def main(): + default_project = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project-dir", type=Path, default=default_project) + parser.add_argument("--msgfmt", default=None) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("sync", help="regenerate locale/techhaven.pot and merge PO files") + subparsers.add_parser("check", help="validate errors.json, POT and PO catalogs") + subparsers.add_parser("build", help="compile PO files into MO files") + args = parser.parse_args() + + project_dir = args.project_dir.resolve() + msgfmt = args.msgfmt or shutil.which("msgfmt") + + try: + if args.command == "sync": + sync(project_dir) + elif args.command == "check": + check(project_dir) + else: + build(project_dir, msgfmt) + except CatalogError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/blog_module.cc b/src/blog_module.cc index 1abc44d..bc1576c 100644 --- a/src/blog_module.cc +++ b/src/blog_module.cc @@ -17,6 +17,7 @@ #include "./chunk_upload.h" #include "./event/events.h" +#include "./i18n/i18n.h" #include "./include/managers.h" #include "./include/servlets.h" #include "./include/tables.h" @@ -46,6 +47,10 @@ bool BlogModule::onLoad() return false; } + if (!I18n::Init(g_work_path->getValue() + "/locale")) { + WARN(logger) << "i18n unavailable, error messages will fall back to errors.json"; + } + chen::FoxThreadMgr::GetInstance()->init(); chen::FoxThreadMgr::GetInstance()->start(); diff --git a/src/i18n/i18n.cc b/src/i18n/i18n.cc new file mode 100644 index 0000000..6738296 --- /dev/null +++ b/src/i18n/i18n.cc @@ -0,0 +1,229 @@ +#include "i18n.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include "../manager/system_settings_manager.h" + +namespace blog { + +static chen::Logger::ptr logger = LOG_ROOT(); + +namespace { + +constexpr const char* kDomainPrefix = "techhaven_"; +constexpr const char* kDefaultLanguage = "zh_CN"; +const std::string kTrimChars = " \t\r\n\v\f"; + +std::string DomainFor(const std::string& language) +{ + return std::string(kDomainPrefix) + language; +} + +double ParseQuality(const std::string& value) +{ + const std::string text = chen::StringUtil::Trim(value, kTrimChars); + if (text.size() < 3) { + return -1.0; + } + if ((text[0] != 'q' && text[0] != 'Q') || text[1] != '=') { + return -1.0; + } + const std::string number = chen::StringUtil::Trim(text.substr(2), kTrimChars); + if (number.empty()) { + return -1.0; + } + char* end = nullptr; + const double quality = std::strtod(number.c_str(), &end); + if (end == number.c_str() || *end != '\0' || quality < 0.0 || quality > 1.0) { + return -1.0; + } + return quality; +} + +std::string BestFromAcceptLanguage(const std::string& header) +{ + if (header.empty()) { + return ""; + } + + struct Candidate { + double quality; + std::string language; + }; + + std::vector candidates; + size_t start = 0; + while (start <= header.size()) { + const size_t comma = header.find(',', start); + + std::string str = header.substr(start, comma == std::string::npos ? std::string::npos : comma - start); + const std::string item = chen::StringUtil::Trim(str, kTrimChars); + + if (!item.empty()) { + std::string tag; + double quality = 1.0; + bool first = true; + bool valid = true; + size_t pos = 0; + while (pos <= item.size()) { + const size_t semicolon = item.find(';', pos); + + std::string pt = item.substr(pos, semicolon == std::string::npos ? std::string::npos : semicolon - pos); + const std::string part = chen::StringUtil::Trim(pt, kTrimChars); + + if (first) { + tag = part; + first = false; + } else if (part.size() > 1 && (part[0] == 'q' || part[0] == 'Q') && part[1] == '=') { + const double parsed = ParseQuality(part); + if (parsed < 0.0) { + valid = false; + break; + } + quality = parsed; + } + if (semicolon == std::string::npos) { + break; + } + pos = semicolon + 1; + } + + if (valid && quality > 0.0 && !tag.empty() && tag != "*") { + std::string language = I18n::NormalizeLanguage(tag); + if (!language.empty()) { + candidates.push_back({quality, std::move(language)}); + } + } + } + if (comma == std::string::npos) { + break; + } + start = comma + 1; + } + + std::stable_sort(candidates.begin(), candidates.end(), [](const Candidate& lhs, const Candidate& rhs) { + return lhs.quality > rhs.quality; + }); + + return candidates.empty() ? "" : candidates.front().language; +} + +} // namespace + +const std::vector& I18n::SupportedLanguages() +{ + static const std::vector s_languages = {"en", "zh_CN"}; + return s_languages; +} + +std::string I18n::DefaultLanguage() +{ + return kDefaultLanguage; +} + +std::string I18n::NormalizeLanguage(std::string_view language) +{ + std::string value = chen::StringUtil::Trim(std::string(language), kTrimChars); + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + std::replace(value.begin(), value.end(), '_', '-'); + + if (value == "zh" || value.rfind("zh-", 0) == 0) { + return "zh_CN"; + } + if (value == "en" || value.rfind("en-", 0) == 0) { + return "en"; + } + return ""; +} + +bool I18n::Init(const std::string& localeDir) +{ + std::error_code ec; + const bool exists = std::filesystem::is_directory(localeDir, ec); + if (!exists) { + WARN(logger) << "i18n locale directory not found: " << localeDir; + } + + const char* currentLocale = setlocale(LC_MESSAGES, nullptr); + if (!currentLocale || std::string(currentLocale) != "C") { + if (!setlocale(LC_MESSAGES, "C")) { + WARN(logger) << "setlocale(LC_MESSAGES, C) failed"; + } + } + + bool ok = exists; + for (const auto& language : SupportedLanguages()) { + const std::string domain = DomainFor(language); + if (!bindtextdomain(domain.c_str(), localeDir.c_str())) { + WARN(logger) << "bindtextdomain failed: " << domain << " -> " << localeDir; + ok = false; + } + if (!bind_textdomain_codeset(domain.c_str(), "UTF-8")) { + WARN(logger) << "bind_textdomain_codeset failed: " << domain; + ok = false; + } + } + + INFO(logger) << "i18n initialized, locale_dir=" << localeDir; + return ok; +} + +std::string I18n::Translate(std::string_view msgid, std::string_view language) +{ + if (msgid.empty()) { + return ""; + } + + std::string normalized = NormalizeLanguage(language); + if (normalized.empty()) { + normalized = DefaultLanguage(); + } + + const std::string domain = DomainFor(normalized); + const std::string key(msgid); + const char* translated = dgettext(domain.c_str(), key.c_str()); + if (!translated) { + return key; + } + return std::string(translated); +} + +std::string I18n::ResolveLanguage(chen::http::HttpRequest::ptr request) +{ + if (request) { + const std::string query = request->getParam("lang"); + std::string normalized = NormalizeLanguage(query); + if (!normalized.empty()) { + return normalized; + } + + normalized = BestFromAcceptLanguage(request->getHeader("Accept-Language")); + if (!normalized.empty()) { + return normalized; + } + } + + auto settings = SystemSettingsMgr::GetInstance()->get(); + if (settings) { + std::string normalized = NormalizeLanguage(settings->getLanguage()); + if (!normalized.empty()) { + return normalized; + } + } + + return DefaultLanguage(); +} + +} // namespace blog diff --git a/src/i18n/i18n.h b/src/i18n/i18n.h new file mode 100644 index 0000000..a75de83 --- /dev/null +++ b/src/i18n/i18n.h @@ -0,0 +1,33 @@ +/** + * @file i18n.h + * @brief 国际化运行时:语言解析与 gettext 查表 + * @author Christins + * @date 2026-10-18 + * @copyright GPL-3.0 + */ +#pragma once + +#include +#include +#include + +#include + +namespace blog { + +class I18n { +public: + static bool Init(const std::string& localeDir); + + static std::string NormalizeLanguage(std::string_view language); + + static std::string ResolveLanguage(chen::http::HttpRequest::ptr request); + + static std::string Translate(std::string_view msgid, std::string_view language); + + static const std::vector& SupportedLanguages(); + + static std::string DefaultLanguage(); +}; + +} // namespace blog diff --git a/src/manager/article_manager.cc b/src/manager/article_manager.cc index a32fb75..3305edd 100644 --- a/src/manager/article_manager.cc +++ b/src/manager/article_manager.cc @@ -408,6 +408,8 @@ ArticleManager::ArticleStats ArticleManager::getStats(int32_t category, int32_t case REJECTED: stats.rejected += cnt; break; + default: + break; } } diff --git a/src/manager/error_code_manager.cc b/src/manager/error_code_manager.cc index 48baa8d..d8be399 100644 --- a/src/manager/error_code_manager.cc +++ b/src/manager/error_code_manager.cc @@ -6,6 +6,8 @@ #include #include +#include "../i18n/i18n.h" + namespace blog { static chen::Logger::ptr logger = LOG_ROOT(); @@ -35,6 +37,7 @@ bool ErrorCodeManager::load(const std::string& configPath) m_version = root.get("version", "unknown").asString(); + m_keys.clear(); m_zhMessages.clear(); m_enMessages.clear(); @@ -46,9 +49,13 @@ bool ErrorCodeManager::load(const std::string& configPath) for (const auto& item : errors) { int32_t code = item["errno"].asInt(); + std::string key = item.get("key", "").asString(); std::string zh = item.get("zh", "").asString(); std::string en = item.get("en", "").asString(); + if (!key.empty()) { + m_keys[code] = key; + } m_zhMessages[code] = zh; m_enMessages[code] = en; } @@ -74,17 +81,32 @@ std::string ErrorCodeManager::getMessage(int32_t errCode) const std::string ErrorCodeManager::getMessage(int32_t errCode, const std::string& lang) const { - const auto& messages = (lang == "en" || lang == "en-US" || lang == "en_US") ? m_enMessages : m_zhMessages; + const std::string normalized = I18n::NormalizeLanguage(lang); + const bool english = normalized == "en"; + + auto keyIt = m_keys.find(errCode); + if (keyIt != m_keys.end() && !keyIt->second.empty()) { + const std::string translated = I18n::Translate(keyIt->second, normalized.empty() ? I18n::DefaultLanguage() : normalized); + if (!translated.empty() && translated != keyIt->second) { + return translated; + } + } + + const auto& messages = english ? m_enMessages : m_zhMessages; auto it = messages.find(errCode); if (it != messages.end()) { return it->second; } - return lang == "en" ? "Unknown error" : "未知错误"; + return english ? "Unknown error" : "未知错误"; } std::map ErrorCodeManager::getAllMessages(const std::string& lang) const { - return (lang == "en" || lang == "en-US" || lang == "en_US") ? m_enMessages : m_zhMessages; + std::map result; + for (const auto& entry : m_zhMessages) { + result[entry.first] = getMessage(entry.first, lang); + } + return result; } std::string ErrorCodeManager::toJson(const std::string& lang) const diff --git a/src/manager/error_code_manager.h b/src/manager/error_code_manager.h index 3756c0e..59be5ff 100644 --- a/src/manager/error_code_manager.h +++ b/src/manager/error_code_manager.h @@ -75,6 +75,7 @@ class ErrorCodeManager { private: std::string m_configPath; std::string m_version; + std::map m_keys; // errno → 翻译 key std::map m_zhMessages; // errno → 中文消息 std::map m_enMessages; // errno → 英文消息 }; diff --git a/src/servlets/article/comment_list_servlet.cc b/src/servlets/article/comment_list_servlet.cc index 76f8306..ce9b1cf 100644 --- a/src/servlets/article/comment_list_servlet.cc +++ b/src/servlets/article/comment_list_servlet.cc @@ -54,7 +54,7 @@ static Json::Value buildCommentItem( } } item["replies"] = repliesArr; - item["reply_count"] = it != children.end() ? (Json::Int64)it->second.size() : (Json::Int64)0; + item["reply_count"] = it != children.end() ? it->second.size() : 0; return item; } @@ -103,7 +103,7 @@ int32_t CommentListServlet::handle( } } - result->set("total", it != children.end() ? (Json::Int64)it->second.size() : (Json::Int64)0); + result->set("total", it != children.end() ? it->second.size() : 0); result->set("list", list); result->setErrno(errcode::SUCCESS); } while (0); diff --git a/src/servlets/category/category_query_servlet.cc b/src/servlets/category/category_query_servlet.cc index 02f2416..d773464 100644 --- a/src/servlets/category/category_query_servlet.cc +++ b/src/servlets/category/category_query_servlet.cc @@ -44,8 +44,8 @@ int32_t CategoryQueryServlet::handle( item["description"] = cat->getDescription(); item["parent_id"] = cat->getParentId(); item["status"] = cat->getStatus(); - item["create_time"] = (Json::Int64)cat->getCreateTime(); - item["update_time"] = (Json::Int64)cat->getUpdateTime(); + item["create_time"] = cat->getCreateTime(); + item["update_time"] = cat->getUpdateTime(); int64_t article_count = 0; int64_t view_count = 0; @@ -58,8 +58,8 @@ int32_t CategoryQueryServlet::handle( view_count += article->getViews(); } } - item["article_count"] = (Json::Int64)article_count; - item["view_count"] = (Json::Int64)view_count; + item["article_count"] = article_count; + item["view_count"] = view_count; list.append(item); } diff --git a/src/servlets/dashboard/dashboard_activities_servlet.cc b/src/servlets/dashboard/dashboard_activities_servlet.cc index f5dccb9..b079820 100644 --- a/src/servlets/dashboard/dashboard_activities_servlet.cc +++ b/src/servlets/dashboard/dashboard_activities_servlet.cc @@ -142,7 +142,7 @@ int32_t DashboardActivitiesServlet::handle( Json::Value item; item["type"] = act.type; item["title"] = act.title; - item["timestamp"] = (Json::Int64)act.create_time; + item["timestamp"] = act.create_time; item["time"] = TimeAgo(now, act.create_time); list.append(item); } diff --git a/src/servlets/dashboard/dashboard_trend_servlet.cc b/src/servlets/dashboard/dashboard_trend_servlet.cc index 2ea599e..9081ba7 100644 --- a/src/servlets/dashboard/dashboard_trend_servlet.cc +++ b/src/servlets/dashboard/dashboard_trend_servlet.cc @@ -147,7 +147,7 @@ int32_t DashboardTrendServlet::handle( item["label"] = "第" + std::to_string(i + 1) + "周"; item["date"] = FormatDate(bucket_ts) + " ~ " + FormatDate(week_end); } - item["visits"] = (Json::Int64)visits; + item["visits"] = visits; list.append(item); } diff --git a/src/servlets/database/backup_create_servlet.cc b/src/servlets/database/backup_create_servlet.cc index ac54d4e..8b951c5 100644 --- a/src/servlets/database/backup_create_servlet.cc +++ b/src/servlets/database/backup_create_servlet.cc @@ -1,9 +1,7 @@ #include "backup_create_servlet.h" #include -#include #include -#include #include "../../event/event_define.h" #include "../../manager/backup_record_manager.h" diff --git a/src/servlets/database/backup_download_servlet.cc b/src/servlets/database/backup_download_servlet.cc index 14fd308..fabc1de 100644 --- a/src/servlets/database/backup_download_servlet.cc +++ b/src/servlets/database/backup_download_servlet.cc @@ -13,7 +13,7 @@ namespace blog { namespace servlet { static chen::Logger::ptr logger = LOG_ROOT(); -static chen::ConfigVar::ptr server_work_path = chen::Config::Lookup("server.work_path"); +static chen::ConfigVar::ptr server_data_path = chen::Config::Lookup("server.data_path"); BackupDownloadServlet::BackupDownloadServlet() : BlogLoginedServlet("BackupDownloadServlet") @@ -67,7 +67,7 @@ int32_t BackupDownloadServlet::handle( } if (filepath[0] != '/') { - filepath = server_work_path->getValue() + "/" + filepath; + filepath = server_data_path->getValue() + "/" + filepath; } std::ifstream ifs(filepath, std::ios::binary); @@ -82,7 +82,6 @@ int32_t BackupDownloadServlet::handle( response->setHeader("Content-Disposition", "attachment; filename=\"" + info->getName() + ".tar.gz\""); response->setHeader("Content-Type", "application/octet-stream"); - response->setHeader("Content-Length", std::to_string(fileSize)); response->setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); response->setHeader("Pragma", "no-cache"); response->setHeader("Expires", "0"); @@ -104,8 +103,6 @@ int32_t BackupDownloadServlet::handle( } while (0); if (!success) { response->delHeader("Content-Disposition"); - response->delHeader("Content-Length"); - response->setHeader("Content-Type", "application/json"); response->setBody(result->toJsonString()); } return 0; diff --git a/src/servlets/database/export_download_servlet.cc b/src/servlets/database/export_download_servlet.cc index 817e522..229a549 100644 --- a/src/servlets/database/export_download_servlet.cc +++ b/src/servlets/database/export_download_servlet.cc @@ -13,7 +13,7 @@ namespace blog { namespace servlet { static chen::Logger::ptr logger = LOG_ROOT(); -static chen::ConfigVar::ptr server_work_path = chen::Config::Lookup("server.work_path"); +static chen::ConfigVar::ptr server_data_path = chen::Config::Lookup("server.data_path"); ExportDownloadServlet::ExportDownloadServlet() : BlogLoginedServlet("ExportDownloadServlet") @@ -67,7 +67,7 @@ int32_t ExportDownloadServlet::handle( } if (filepath[0] != '/') { - filepath = server_work_path->getValue() + "/" + filepath; + filepath = server_data_path->getValue() + "/" + filepath; } std::ifstream ifs(filepath, std::ios::binary); @@ -85,7 +85,6 @@ int32_t ExportDownloadServlet::handle( format = "json"; response->setHeader("Content-Disposition", "attachment; filename=\"" + info->getName() + "." + format + "\""); response->setHeader("Content-Type", "application/octet-stream"); - response->setHeader("Content-Length", std::to_string(fileSize)); response->setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); response->setHeader("Pragma", "no-cache"); response->setHeader("Expires", "0"); @@ -107,8 +106,6 @@ int32_t ExportDownloadServlet::handle( } while (0); if (!success) { response->delHeader("Content-Disposition"); - response->delHeader("Content-Length"); - response->setHeader("Content-Type", "application/json"); response->setBody(result->toJsonString()); } return 0; diff --git a/src/servlets/message/conversation_message_servlet.cc b/src/servlets/message/conversation_message_servlet.cc index 3e52a9a..55c892c 100644 --- a/src/servlets/message/conversation_message_servlet.cc +++ b/src/servlets/message/conversation_message_servlet.cc @@ -96,7 +96,7 @@ int32_t ConversationMessageServlet::handle( // HTTP 兜底发送也走聊天 WS 推送给接收方 Json::Value frame; frame["type"] = "message"; - frame["conversation_id"] = (Json::Int64)conversation_id; + frame["conversation_id"] = conversation_id; Json::Value msg_json; BuildMessageJson(msg_json, msg, peer_id); frame["message"] = msg_json; diff --git a/src/servlets/message/message_json.cc b/src/servlets/message/message_json.cc index 6b30890..5ebf94b 100644 --- a/src/servlets/message/message_json.cc +++ b/src/servlets/message/message_json.cc @@ -8,10 +8,10 @@ namespace servlet { void BuildMessageJson(Json::Value& json, data::ConversationMessageInfo::ptr msg, int64_t uid) { - json["id"] = (Json::Int64)msg->getId(); + json["id"] = msg->getId(); json["fromMe"] = (msg->getSenderId() == uid); json["text"] = msg->getContent(); - json["create_time"] = (Json::Int64)msg->getCreateTime(); + json["create_time"] = msg->getCreateTime(); } void BuildConversationJson(Json::Value& json, data::ConversationInfo::ptr conv, int64_t uid) @@ -19,23 +19,23 @@ void BuildConversationJson(Json::Value& json, data::ConversationInfo::ptr conv, int64_t peer_id = (conv->getUserAId() == uid) ? conv->getUserBId() : conv->getUserAId(); int32_t unread = (conv->getUserAId() == uid) ? conv->getUnreadA() : conv->getUnreadB(); - json["id"] = (Json::Int64)conv->getId(); - json["peer_id"] = (Json::Int64)peer_id; + json["id"] = conv->getId(); + json["peer_id"] = peer_id; json["online"] = MessageMgr::GetInstance()->isChatConnected(peer_id); auto user = UserMgr::GetInstance()->get(peer_id); json["name"] = user ? user->getName() : ""; - json["last_time"] = (Json::Int64)conv->getLastMessageTime(); + json["last_time"] = conv->getLastMessageTime(); json["unread"] = unread; Json::Value arr(Json::arrayValue); if (conv->getLastMessageId() > 0) { Json::Value last; - last["id"] = (Json::Int64)conv->getLastMessageId(); + last["id"] = conv->getLastMessageId(); last["fromMe"] = (conv->getLastSenderId() == uid); last["text"] = conv->getLastMessage(); - last["create_time"] = (Json::Int64)conv->getLastMessageTime(); + last["create_time"] = conv->getLastMessageTime(); arr.append(last); } json["messages"] = arr; diff --git a/src/servlets/message/message_ws_servlet.cc b/src/servlets/message/message_ws_servlet.cc index 4ade044..30d49ef 100644 --- a/src/servlets/message/message_ws_servlet.cc +++ b/src/servlets/message/message_ws_servlet.cc @@ -127,7 +127,7 @@ int32_t MessageWSServlet::handle(chen::http::HttpRequest::ptr header, chen::http if (type == "send") { std::string client_id = root.get("client_id", "").asString(); - int64_t conversation_id = root.get("conversation_id", (Json::Int64)0).asInt64(); + int64_t conversation_id = root.get("conversation_id", 0).asInt64(); std::string text = root.get("text", "").asString(); auto sendSendError = [&](int32_t ec, const std::string& errstr) { @@ -172,7 +172,7 @@ int32_t MessageWSServlet::handle(chen::http::HttpRequest::ptr header, chen::http Json::Value ack; ack["type"] = "message_ack"; ack["client_id"] = client_id; - ack["conversation_id"] = (Json::Int64)conversation_id; + ack["conversation_id"] = conversation_id; Json::Value mj; BuildMessageJson(mj, msg_info, uid); ack["message"] = mj; @@ -184,7 +184,7 @@ int32_t MessageWSServlet::handle(chen::http::HttpRequest::ptr header, chen::http if (MessageMgr::GetInstance()->isChatConnected(peer_id)) { Json::Value push; push["type"] = "message"; - push["conversation_id"] = (Json::Int64)conversation_id; + push["conversation_id"] = conversation_id; Json::Value mj; BuildMessageJson(mj, msg_info, peer_id); push["message"] = mj; @@ -192,7 +192,7 @@ int32_t MessageWSServlet::handle(chen::http::HttpRequest::ptr header, chen::http } INFO(logger) << "[ChatWS] ack->" << uid << " push->" << peer_id << " peer_online=" << MessageMgr::GetInstance()->isChatConnected(peer_id); } else if (type == "read") { - int64_t conversation_id = root.get("conversation_id", (Json::Int64)0).asInt64(); + int64_t conversation_id = root.get("conversation_id", 0).asInt64(); if (conversation_id <= 0) { sendError(session, errcode::PARAM_MISSING, "conversation_id required"); break; @@ -215,7 +215,7 @@ int32_t MessageWSServlet::handle(chen::http::HttpRequest::ptr header, chen::http if (MessageMgr::GetInstance()->isChatConnected(peer_id)) { Json::Value rcpt; rcpt["type"] = "read"; - rcpt["conversation_id"] = (Json::Int64)conversation_id; + rcpt["conversation_id"] = conversation_id; rcpt["unread"] = 0; MessageMgr::GetInstance()->sendToUser(peer_id, chen::JsonUtil::ToString(rcpt)); } diff --git a/src/servlets/organization/organization_repo_prs_list_servlet.cc b/src/servlets/organization/organization_repo_prs_list_servlet.cc index cd5222e..7596812 100644 --- a/src/servlets/organization/organization_repo_prs_list_servlet.cc +++ b/src/servlets/organization/organization_repo_prs_list_servlet.cc @@ -92,8 +92,8 @@ int32_t OrganizationRepoPrsListServlet::handle( auto& list = result->jsondata["list"]; for (const auto& pr : prs) { Json::Value item; - item["id"] = (Json::Int64)pr->getId(); - item["repo_id"] = (Json::Int64)pr->getRepoId(); + item["id"] = pr->getId(); + item["repo_id"] = pr->getRepoId(); item["github_pr_id"] = pr->getGithubPrId(); item["title"] = pr->getTitle(); item["description"] = pr->getDescription(); @@ -108,10 +108,10 @@ int32_t OrganizationRepoPrsListServlet::handle( item["deletions"] = pr->getDeletions(); item["reviewers"] = pr->getReviewers(); item["review_status"] = pr->getReviewStatus(); - item["closed_at"] = (Json::Int64)pr->getClosedAt(); - item["merged_at"] = (Json::Int64)pr->getMergedAt(); - item["created_at"] = (Json::Int64)pr->getCreateTime(); - item["updated_at"] = (Json::Int64)pr->getUpdateTime(); + item["closed_at"] = pr->getClosedAt(); + item["merged_at"] = pr->getMergedAt(); + item["created_at"] = pr->getCreateTime(); + item["updated_at"] = pr->getUpdateTime(); list.append(item); } } while (0); diff --git a/src/servlets/organization/organization_repo_prs_sync_servlet.cc b/src/servlets/organization/organization_repo_prs_sync_servlet.cc index 346c906..831b49a 100644 --- a/src/servlets/organization/organization_repo_prs_sync_servlet.cc +++ b/src/servlets/organization/organization_repo_prs_sync_servlet.cc @@ -136,8 +136,8 @@ int32_t OrganizationRepoPrsSyncServlet::handle( } Json::Value item; - item["id"] = (Json::Int64)updated->getId(); - item["repo_id"] = (Json::Int64)updated->getRepoId(); + item["id"] = updated->getId(); + item["repo_id"] = updated->getRepoId(); item["github_pr_id"] = updated->getGithubPrId(); item["title"] = updated->getTitle(); item["description"] = updated->getDescription(); @@ -152,10 +152,10 @@ int32_t OrganizationRepoPrsSyncServlet::handle( item["deletions"] = updated->getDeletions(); item["reviewers"] = updated->getReviewers(); item["review_status"] = updated->getReviewStatus(); - item["closed_at"] = (Json::Int64)updated->getClosedAt(); - item["merged_at"] = (Json::Int64)updated->getMergedAt(); - item["created_at"] = (Json::Int64)updated->getCreateTime(); - item["updated_at"] = (Json::Int64)updated->getUpdateTime(); + item["closed_at"] = updated->getClosedAt(); + item["merged_at"] = updated->getMergedAt(); + item["created_at"] = updated->getCreateTime(); + item["updated_at"] = updated->getUpdateTime(); result->jsondata = item; break; } diff --git a/src/servlets/organization/organization_repos_add_servlet.cc b/src/servlets/organization/organization_repos_add_servlet.cc index 0a479cb..3fbcde2 100644 --- a/src/servlets/organization/organization_repos_add_servlet.cc +++ b/src/servlets/organization/organization_repos_add_servlet.cc @@ -147,7 +147,7 @@ int32_t OrganizationReposAddServlet::handle( OrganizationRepoMgr::GetInstance()->add(info); - result->set("id", (Json::Int64)info->getId()); + result->set("id", info->getId()); } while (0); response->setBody(result->toJsonString()); return 0; diff --git a/src/servlets/organization/organization_repos_list_servlet.cc b/src/servlets/organization/organization_repos_list_servlet.cc index 547bdc8..659f7f8 100644 --- a/src/servlets/organization/organization_repos_list_servlet.cc +++ b/src/servlets/organization/organization_repos_list_servlet.cc @@ -64,20 +64,20 @@ int32_t OrganizationReposListServlet::handle( auto& list = result->jsondata["list"]; for (const auto& repo : repos) { Json::Value item; - item["id"] = (Json::Int64)repo->getId(); - item["org_id"] = (Json::Int64)repo->getOrgId(); + item["id"] = repo->getId(); + item["org_id"] = repo->getOrgId(); item["name"] = repo->getName(); item["description"] = repo->getDescription(); item["url"] = repo->getUrl(); item["language"] = repo->getLanguage(); item["stars_count"] = repo->getStarsCount(); item["sort_order"] = repo->getSortOrder(); - item["created_at"] = (Json::Int64)repo->getCreateTime(); - item["updated_at"] = (Json::Int64)repo->getUpdateTime(); + item["created_at"] = repo->getCreateTime(); + item["updated_at"] = repo->getUpdateTime(); item["has_token"] = !repo->getToken().empty(); item["sync_status"] = repo->getSyncStatus(); item["pr_sync_status"] = repo->getPrSyncStatus(); - item["pr_synced_at"] = (Json::Int64)repo->getPrSyncedAt(); + item["pr_synced_at"] = repo->getPrSyncedAt(); list.append(item); } } while (0); diff --git a/src/servlets/settings/error_codes_servlet.cc b/src/servlets/settings/error_codes_servlet.cc index 4e8d51e..b3ebb2d 100644 --- a/src/servlets/settings/error_codes_servlet.cc +++ b/src/servlets/settings/error_codes_servlet.cc @@ -1,13 +1,10 @@ #include "error_codes_servlet.h" +#include "../../i18n/i18n.h" #include "../../manager/error_code_manager.h" -#include - namespace blog::servlet { -static chen::Logger::ptr logger = LOG_ROOT(); - ErrorCodesServlet::ErrorCodesServlet() : BlogServlet("ErrorCodesServlet") { @@ -19,30 +16,10 @@ int32_t ErrorCodesServlet::handle( chen::http::HttpSession::ptr session, Result::ptr result) { + response->setHeader("Vary", "Accept-Language"); + do { - // 解析语言参数: ?lang=zh-CN 或 ?lang=en-US - std::string lang = request->getParam("lang"); - if (lang.empty()) { - // 尝试从 Accept-Language 请求头解析 - std::string acceptLang = request->getHeader("Accept-Language"); - if (!acceptLang.empty()) { - size_t comma = acceptLang.find(','); - std::string first = (comma != std::string::npos) ? acceptLang.substr(0, comma) : acceptLang; - size_t dash = first.find('-'); - lang = (dash != std::string::npos) ? first.substr(0, dash) : first; - if (lang != "zh" && lang != "en") { - lang = "zh"; - } - } else { - lang = "zh"; - } - } else { - if (lang.find("en") != std::string::npos) { - lang = "en"; - } else { - lang = "zh"; - } - } + std::string lang = I18n::ResolveLanguage(request); auto manager = ErrorCodeMgr::GetInstance(); if (manager->getCount() == 0) { diff --git a/src/servlets/settings/system_settings_servlet.cc b/src/servlets/settings/system_settings_servlet.cc index f8cdc03..4a12d3a 100644 --- a/src/servlets/settings/system_settings_servlet.cc +++ b/src/servlets/settings/system_settings_servlet.cc @@ -16,7 +16,7 @@ SystemSettingsServlet::SystemSettingsServlet() static void BuildSettingsJson(Json::Value& json, data::SystemSettingsInfo::ptr info) { - json["id"] = (Json::Int64)info->getId(); + json["id"] = info->getId(); json["siteName"] = info->getSiteName(); json["siteDescription"] = info->getSiteDescription(); json["siteKeywords"] = info->getSiteKeywords(); @@ -49,8 +49,8 @@ static void BuildSettingsJson(Json::Value& json, data::SystemSettingsInfo::ptr i json["sessionTimeout"] = info->getSessionTimeout(); json["maintenanceMode"] = info->getMaintenanceMode() ? true : false; json["backupSchedule"] = info->getBackupSchedule(); - json["createdAt"] = (Json::Int64)info->getCreatedAt(); - json["updatedAt"] = (Json::Int64)info->getUpdatedAt(); + json["createdAt"] = info->getCreatedAt(); + json["updatedAt"] = info->getUpdatedAt(); } int32_t SystemSettingsServlet::handle( @@ -81,7 +81,6 @@ int32_t SystemSettingsServlet::handle( } BuildSettingsJson(result->jsondata, settings); } else if (method == chen::http::HttpMethod::PUT) { - INFO(logger) << "req: \n" << request->toString(); auto info = SystemSettingsMgr::GetInstance()->get(); if (!info) { result->setErrno(errcode::SETTINGS_NOT_LOADED); diff --git a/src/struct.cc b/src/struct.cc index 10319dc..512ef7b 100644 --- a/src/struct.cc +++ b/src/struct.cc @@ -1,9 +1,12 @@ #include "struct.h" +#include + #include #include "blog/data/user_info.h" #include "error_codes.h" +#include "i18n/i18n.h" #include "manager/error_code_manager.h" #include "manager/session_manager.h" #include "manager/user_login_device_manager.h" @@ -36,19 +39,35 @@ Result::Result(int32_t ec, const std::string& m) : errno_(ec) , used(chen::TimeUtil::GetCurrentUs()) , msg(m) + , lang(I18n::DefaultLanguage()) { } void Result::setErrno(int32_t ec) { errno_ = ec; - msg = ErrorCodeMgr::GetInstance()->getMessage(ec); + msg = ErrorCodeMgr::GetInstance()->getMessage(ec, lang); } void Result::setErrno(int32_t ec, const std::string& customMsg) { errno_ = ec; - msg = customMsg; + msg = I18n::Translate(customMsg, lang); +} + +void Result::setErrnoFmt(int32_t ec, const char* fmt, ...) +{ + errno_ = ec; + if (!fmt) { + msg.clear(); + return; + } + + const std::string localized = I18n::Translate(fmt, lang); + va_list ap; + va_start(ap, fmt); + msg = chen::StringUtil::Formatv(localized.c_str(), ap); + va_end(ap); } void Result::setDataJson(const std::string& jsonStr) @@ -80,6 +99,7 @@ int32_t BlogServlet::handle(chen::http::HttpRequest::ptr request, chen::http::Ht { uint64_t ts = chen::TimeUtil::GetCurrentUs(); Result::ptr result = std::make_shared(); + result->lang = I18n::ResolveLanguage(request); // response->setHeader("Access-Control-Allow-Origin", "*"); // response->setHeader("Access-Control-Allow-Credentials", "true"); @@ -105,10 +125,6 @@ bool BlogServlet::handlePre( if (request->getPath() != "/user/login" && request->getPath() != "/user/logout") { initLogin(request, response, session); } - if (request->getMethod() != chen::http::HttpMethod::GET && request->getMethod() != chen::http::HttpMethod::POST) { - result->setErrno(errcode::INVALID_METHOD); - return false; - } return true; } @@ -118,7 +134,10 @@ bool BlogServlet::handlePost( chen::http::HttpSession::ptr session, Result::ptr result) { - response->setHeader("Content-Type", "application/json"); + std::string val; + if (!response->checkGetHeaderAs("Content-Type", val, "application/json")) { + response->setHeader("Content-Type", val); + } INFO(logger) << GetRemoteIP(request, session) << "\t" << request->getCookie(CookieKey::SESSION_KEY, "-") << "\t" << getUserId(request) << "\t" << result->errno_ << "\t" << result->msg << "\t" << request->getPath() << "\t" << (!request->getQuery().empty() ? request->getQuery() : "-"); diff --git a/src/struct.h b/src/struct.h index 7a1ef43..5c2e43e 100644 --- a/src/struct.h +++ b/src/struct.h @@ -29,6 +29,7 @@ struct Result { int32_t errno_; // 统一业务错误码(默认 0 = 成功) int64_t used; std::string msg; + std::string lang; Json::Value jsondata; template @@ -58,6 +59,8 @@ struct Result { */ void setErrno(int32_t ec, const std::string& customMsg); + void setErrnoFmt(int32_t ec, const char* fmt, ...); + /** * @brief 设置 data 为原始 JSON 字符串(用于下发配置等场景) */ diff --git a/src/util.h b/src/util.h index aa20a86..8718695 100644 --- a/src/util.h +++ b/src/util.h @@ -122,20 +122,20 @@ inline void SendWX(const std::string& group, const std::string& msg) // TODO: ... } -#define DEFINE_AND_CHECK_STRING(result, var, param) \ - std::string var = request->getParam(param); \ - if (var.empty()) { \ - result->setErrno(errcode::PARAM_MISSING, "param " param " is required"); \ - break; \ +#define DEFINE_AND_CHECK_STRING(result, var, param) \ + std::string var = request->getParam(param); \ + if (var.empty()) { \ + result->setErrnoFmt(errcode::PARAM_MISSING, "param %s is required", param); \ + break; \ } #define DEFINE_AND_CHECK_STRING_WITH_DEFAULT(result, var, param, default_val) std::string var = request->getParam(param, default_val); -#define DEFINE_AND_CHECK_TYPE(result, type, var, param) \ - type var; \ - if (!request->checkGetParamAs(param, var)) { \ - result->setErrno(errcode::PARAM_MISSING, "param " param " is required"); \ - break; \ +#define DEFINE_AND_CHECK_TYPE(result, type, var, param) \ + type var; \ + if (!request->checkGetParamAs(param, var)) { \ + result->setErrnoFmt(errcode::PARAM_MISSING, "param %s is required", param); \ + break; \ } inline void SendSSEJson(chen::http::SSESession::ptr session, const std::string& type, const std::string& key, const std::string& value)