feat(i18n): T15 full-stack language audit with coverage assertions

- viewer/admin 错误码→文案映射按契约 ErrorCodes 枚举全集登记(机器守护
  spec 遍历枚举断言无缺漏 + 映射键在 zh/en 词条均存在 + locale 键集
  递归相等);mobile 镜像补齐 DOWNLOAD_DISABLED(P4a 增补漏登)
- 漏译修复:viewer.photoRemoved en 侧缺失(审计测试捕获);
  DOWNLOAD_DISABLED 观众侧专属文案 + useDownload 专属提示接线;
  admin apiMsg 增 RATE_LIMITED/ALBUM_NOT_PUBLIC/DOWNLOAD_DISABLED/
  SYNC_AUTH_FAILED 四码词条
- 运行时抽查:admin 主旅程 en 版 e2e(登录→相册管理全英文 + 偏好
  持久);embed iframe lang=en 渲染与深链跟随 e2e(zh 侧既有用例覆盖)
- contracts 新增 ./errors 浏览器安全子路径(errors.ts 纯 TS);
  admin 增 @photo/contracts devDep(仅测试消费)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 23:44:27 +08:00
parent 0238f505c4
commit 7385785504
17 changed files with 310 additions and 13 deletions
@@ -2,6 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
getPhotoDownload,
getPublicPhotos,
type PublicAlbum,
@@ -250,6 +251,20 @@ describe('photo download (P4a Task 4)', () => {
expect(openSpy).not.toHaveBeenCalled();
});
it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => {
// 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。
mockedGetPhotoDownload.mockReset().mockRejectedValue(
new ApiError('DOWNLOAD_DISABLED', 'downloads disabled', 403, null),
);
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载');
expect(wrapper.find('.download-failed').text()).not.toContain('下载失败');
});
it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => {
stubUserAgent(WECHAT_IOS_UA);
const wrapper = mountLightbox(makeAlbum());
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { ErrorCodes } from '@photo/contracts/errors';
import { errorCodeI18nKeys } from '../api/errors';
import en from '../i18n/locales/en';
import zhCN from '../i18n/locales/zh-CN';
// T15 全端语言验收(P4b Task 6):错误码→文案映射的机器守护。
// 1. 契约 ErrorCodes 枚举全集在本表无缺漏(A12「主要提示无漏译」);
// 2. 表内每个文案键在 zh-CN / en 两份词条都真实存在(防键注册了但一侧
// 没写文案的真漏译;点路径解析);
// 3. zh-CN / en 两份词条键集合递归相等(模板级 zh/en 一致性,镜像 admin
// 侧 locale 守护与 P3 mobile arb 完整性测试)。
// 服务端不做文案本地化(错误体 message 仅英文诊断兜底,客户端按稳定 code
// 映射——见 openapi.yaml info.description),故本表即观众侧错误文案全集。
type Messages = Record<string, unknown>;
/** 递归收集词条文件的叶子键点路径集合(errors.albumNotFound 等)。 */
function leafPaths(messages: Messages, prefix = ''): Set<string> {
const paths = new Set<string>();
for (const [key, value] of Object.entries(messages)) {
const path = prefix === '' ? key : `${prefix}.${key}`;
if (value !== null && typeof value === 'object') {
for (const child of leafPaths(value as Messages, path)) paths.add(child);
} else {
paths.add(path);
}
}
return paths;
}
/** 按点路径取词条值(不存在返回 undefined)。 */
function resolvePath(messages: Messages, path: string): unknown {
let current: unknown = messages;
for (const segment of path.split('.')) {
if (current === null || typeof current !== 'object') return undefined;
current = (current as Messages)[segment];
}
return current;
}
describe('错误码文案覆盖(T15/A12)', () => {
const zhPaths = leafPaths(zhCN as Messages);
const enPaths = leafPaths(en as Messages);
it('契约 ErrorCodes 枚举全集在 errorCodeI18nKeys 无缺漏', () => {
const codes = Object.values(ErrorCodes);
expect(codes.length).toBeGreaterThanOrEqual(18); // 契约现存 18 码,只增不减
for (const code of codes) {
expect(errorCodeI18nKeys[code], `缺少错误码映射: ${code}`).toBeDefined();
}
});
it('映射表内每个文案键在 zh-CN 与 en 词条均存在且非空', () => {
for (const [code, key] of Object.entries(errorCodeI18nKeys)) {
for (const [locale, paths] of [
['zh-CN', zhPaths],
['en', enPaths],
] as const) {
expect(paths.has(key), `${code} 的文案键 ${key} 未登记于 ${locale} 词条`).toBe(true);
const value = resolvePath((locale === 'zh-CN' ? zhCN : en) as Messages, key);
expect(typeof value === 'string' && value.length > 0, `${key} 在 ${locale} 非非空字符串`).toBe(true);
}
}
});
it('zh-CN 与 en 词条键集合递归相等(无单侧漏译)', () => {
expect([...zhPaths].filter((path) => !enPaths.has(path))).toEqual([]);
expect([...enPaths].filter((path) => !zhPaths.has(path))).toEqual([]);
});
});
+20 -1
View File
@@ -2,19 +2,38 @@ import { NETWORK_ERROR_CODE } from './client';
// 错误码 → i18n 文案键映射表:401/404 等观众可认知的码给出确定性文案,
// 未登记码统一走 errors.unknown(原始 message 仅用于日志/排查)。
//
// T15 全端语言验收(P4b Task 6):本表按契约枚举全集登记(机器守护见
// __tests__/error-coverage.spec.ts,遍历 @photo/contracts ErrorCodes 断言
// 无缺漏)。viewer 可达码给专属文案;管理/上传/内部域码(登录凭据、成员
// 冲突、上传校验、服务间鉴权——viewer 无对应界面)按移动端先例防御性
// 注册到通用文案,杜绝未来路由扩展时静默漏译。
export const errorCodeI18nKeys: Readonly<Record<string, string>> = {
// 客户端合成码
[NETWORK_ERROR_CODE]: 'errors.network',
// 401 域
AUTH_TOKEN_EXPIRED: 'errors.authExpired',
AUTH_FORBIDDEN: 'errors.forbidden',
// 登录凭据错误:viewer 无登录面(App/admin 域),防御性注册。
AUTH_INVALID_CREDENTIALS: 'errors.unknown',
AUTH_RATE_LIMITED: 'errors.rateLimited',
// P2 公开互动(likes/visits)匿名限速 429——viewer 直面。
RATE_LIMITED: 'errors.rateLimited',
AUTH_FORBIDDEN: 'errors.forbidden',
// 404 域
NOT_FOUND: 'errors.notFound',
ALBUM_NOT_FOUND: 'errors.albumNotFound',
ALBUM_NOT_PUBLIC: 'errors.albumNotAvailable',
PHOTO_NOT_FOUND: 'errors.photoNotFound',
PHOTO_NOT_PUBLIC: 'errors.photoNotFound',
// P4a 下载两级开关(A18):相册可见但禁止下载——下载入口与文案直达。
DOWNLOAD_DISABLED: 'errors.downloadDisabled',
// 上传/成员域(admin 与 App 上传链路):viewer 不可达,防御性注册。
ALBUM_UPLOAD_DISABLED: 'errors.unknown',
MEMBER_CONFLICT: 'errors.unknown',
UPLOAD_SESSION_CONFLICT: 'errors.unknown',
UPLOAD_VERIFY_FAILED: 'errors.unknown',
// 服务间内部同步鉴权(/internal/**):外部客户端不可达,防御性注册。
SYNC_AUTH_FAILED: 'errors.unknown',
// 其余
VALIDATION_FAILED: 'errors.validationFailed',
INTERNAL_ERROR: 'errors.internal',
+2 -2
View File
@@ -439,9 +439,9 @@ onBeforeUnmount(() => {
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 下载失败轻提示(端点失败/网络;可重点击重试)。 -->
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案;可重点击重试)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
{{ t('download.failed') }}
{{ t(download.failed) }}
</p>
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
+11 -4
View File
@@ -17,10 +17,12 @@
// 打开后可正常下载)。clipboard 不可用时降级展示可选中文本供手动长按复制。
import { computed, onBeforeUnmount, reactive, ref, type Ref } from 'vue';
import {
ApiError,
getPhotoDownload,
type DownloadVariant,
type PublicAlbum,
} from '../api/client';
import { ErrorCodes } from '@photo/contracts/errors';
export interface DownloadInputs {
token: Ref<string>;
@@ -63,7 +65,9 @@ export const COPY_NOTICE_MS = 2500;
export function useDownload(inputs: DownloadInputs) {
const busy = ref(false);
const failed = ref(false);
/** 失败文案键(''=无失败):默认通用失败;DOWNLOAD_DISABLED 给专属文案
* (T15 漏译修复——相册中途关闭下载开关时观众可理解的确定性提示)。 */
const failed = ref<'' | 'download.failed' | 'errors.downloadDisabled'>('');
/** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */
const wechatGuide = ref(false);
const copyNotice = ref<'' | 'copied' | 'failed'>('');
@@ -90,7 +94,7 @@ export function useDownload(inputs: DownloadInputs) {
const photoId = inputs.photoId();
if (photoId === null || busy.value) return;
busy.value = true;
failed.value = false;
failed.value = '';
try {
let credential = await getPhotoDownload(inputs.token.value, photoId, variant);
if (isExpired(credential.expiresAt)) {
@@ -99,8 +103,11 @@ export function useDownload(inputs: DownloadInputs) {
credential = await getPhotoDownload(inputs.token.value, photoId, variant);
}
openDownloadUrl(credential.url);
} catch {
failed.value = true;
} catch (error) {
failed.value =
error instanceof ApiError && error.code === ErrorCodes.DOWNLOAD_DISABLED
? 'errors.downloadDisabled'
: 'download.failed';
} finally {
busy.value = false;
}
+4
View File
@@ -51,6 +51,8 @@ export default {
loadFailed: 'Failed to load image',
// PHOTO_NOT_PUBLIC 专用:not found / unpublished / hidden share one code (anti-enumeration).
photoUnavailable: 'Photo not found or unavailable',
// 轮播中被移除(ws photo.hidden)的轻提示。
photoRemoved: 'This photo has been removed',
backToAlbum: 'Back to album',
},
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
@@ -67,5 +69,7 @@ export default {
internal: 'Service temporarily unavailable, please try again later',
unknown: 'Something went wrong, please try again later',
network: 'Network error, please check your connection',
// DOWNLOAD_DISABLED (P4a two-tier download switches): visible but not downloadable.
downloadDisabled: 'Downloads are not enabled for this album',
},
};
+2
View File
@@ -69,5 +69,7 @@ export default {
internal: '服务暂时不可用,请稍后再试',
unknown: '出错了,请稍后再试',
network: '网络连接失败,请检查网络',
// DOWNLOAD_DISABLED(P4a 下载两级开关):相册可见但未开放下载。
downloadDisabled: '该相册未开放下载',
},
};