From ce4f05b60ff61af32328e4219dc71482945e93c5 Mon Sep 17 00:00:00 2001 From: fizzleaf Date: Tue, 28 Jul 2026 15:06:34 +0800 Subject: [PATCH] =?UTF-8?q?test(api):=20=E6=9B=B4=E6=96=B0API=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=E4=BB=A5?= =?UTF-8?q?=E7=AC=A6=E5=90=88YAML=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新登录响应模拟数据以匹配真实的AppLoginVo结构 - 添加认证客户端租户和授权字段验证 - 增加操作码枚举验证测试用例 - 移除对旧token别名的兼容性测试 - 修复测试用例中的短信验证码长度一致性问题 - 更新区域接口路径为PC专用路径 - 调整分片上传接口参数以符合新契约定义 refactor(api): 重构API客户端实现以严格遵循YAML契约 - 添加认证操作码和短信操作码枚举验证 - 实现严格的token响应解析只接受access_token字段 - 使用pickDefined函数过滤请求体中未定义的字段 - 重构认证接口参数映射以符合契约定义 - 更新区域接口路径为PC专用路径/genealogy/pc/region/* - 优化分片上传接口参数结构与契约保持一致 - 添加操作码枚举验证函数toRequiredOperationCode - 实现请求体字段选择性提取功能 feat(auth): 优化认证页面的验证码处理流程 - 添加takeCaptchaToken函数用于一次性获取验证码票据 - 更新短信验证码长度验证从4-6位改为精确4位 - 在登录和密码重置流程中集成验证码票据处理 - 修复验证码发送后票据清理逻辑 - 更新HTML模板中的验证码输入字段属性 chore(config): 提取常量配置并扩展配置对象结构 - 将客户端ID、租户ID和令牌键提取为常量 - 扩展配置对象返回客户端配置信息 - 更新配置测试用例以验证新增配置项 docs(planning): 更新PC接口对接规划文档 - 更新契约源说明以反映YAML冻结契约 - 添加YAML与在线Apifox复核对比内容 - 更新阻断项状态表格 - 修订登录响应token字段处理规范 - 更新文件上传和行政区划接口规范说明 style(profile): 优化相册管理页面的文件上传交互 - 将封面和照片OSS ID输入改为隐藏字段 - 添加文件选择标签以改善用户体验 - 移除手动输入OSS ID的选项保持界面简洁 --- config.js | 9 +- docs/PC接口对接规划.md | 60 ++++++++-- forgot-password.html | 3 + login.html | 3 + profile-album.html | 12 +- profile-article-edit.html | 7 +- profile-data.html | 43 +++++-- profile-gift-edit.html | 7 +- profile-growth-edit.html | 9 +- profile-memo-edit.html | 4 +- profile-relative-edit.html | 4 +- profile-security.html | 14 ++- public/js/auth-pages.js | 15 ++- public/js/profile-pages.js | 31 +++-- public/js/region-pages.js | 61 ---------- public/js/security-pages.js | 17 ++- public/js/upload-pages.js | 158 +++++++++++++++++++++++-- register.html | 3 +- tests/api-client-contract.test.js | 174 +++++++++++++++++++++++----- tests/auth-pages.test.js | 28 ++++- tests/pc-scope.test.js | 11 +- tests/profile-pages.test.js | 39 ++++--- tests/request-auth-state.test.js | 98 ++++++++++++++++ tests/security-upload-scope.test.js | 147 ++++++++++++++++++++++- utils/ApiClient.js | 109 ++++++++++++++--- utils/AxiosRequestUtil.js | 16 ++- 26 files changed, 886 insertions(+), 196 deletions(-) create mode 100644 tests/request-auth-state.test.js diff --git a/config.js b/config.js index 61ad97e..d110da5 100644 --- a/config.js +++ b/config.js @@ -9,6 +9,10 @@ })(typeof globalThis !== 'undefined' ? globalThis : window, function (root) { 'use strict'; + var CLIENT_ID = 'ced7e5f0498645c6ec642dcf450b036f'; + var TENANT_ID = '000000'; + var TOKEN_KEY = 'genealogy_auth_token'; + // 这里只维护运行环境和后端基础地址,具体接口路径统一放在 ApiClient 中。 var ENVIRONMENTS = { development: { @@ -37,7 +41,10 @@ function getConfig() { return { environment: getEnvironment(), - apiBaseUrl: getApiBaseUrl() + apiBaseUrl: getApiBaseUrl(), + clientId: CLIENT_ID, + tenantId: TENANT_ID, + tokenKey: TOKEN_KEY }; } diff --git a/docs/PC接口对接规划.md b/docs/PC接口对接规划.md index 333b113..ec25e2d 100644 --- a/docs/PC接口对接规划.md +++ b/docs/PC接口对接规划.md @@ -74,7 +74,9 @@ 4. 当前仓库代码; 5. 旧规划和历史交接记录。 -Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一业务契约 owner。`utils/ApiClient.js` 是前端路径和请求方法的唯一 owner。页面脚本只能调用 `ApiClient` 业务方法,不能直接拼 URL 或调用 Axios。 +通常以 Apifox 的 PC 目录作为 PC 前端接口的正式契约源。2026-07-28 用户明确指定本轮直接以 `D:/WorkSpace/Java/Genealogy/doc/apifox/genealogy-pc-openapi.yaml` 对接,因此该 YAML 是本轮冻结契约;在线 Apifox 的旧重复记录不再覆盖它。`utils/ApiClient.js` 是前端路径和请求方法的唯一 owner。页面脚本只能调用 `ApiClient` 业务方法,不能直接拼 URL 或调用 Axios。 + +旧 OpenAPI 文件不再作为新增功能依据,只用于结构化差异比较和追踪导出遗漏。 如果 Apifox 在线内容发生变化,先更新本地 OpenAPI、本规划的受影响章节和契约测试,再修改运行时代码。不得让文档、测试和运行时同时保留两套契约。 @@ -117,6 +119,24 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 4. 当前 96 个操作全部被声明为需要 `Authorization`,连注册、登录、短信发送和验证中心也不例外。这与未登录流程冲突,必须在 Apifox 逐项修正 security。 5. 当前有 21 个成功响应使用无业务属性的 `ObjectResult`,12 个使用无元素 Schema 的 `ListResult`;另有 `LineagePersonTreeView` 名义上是专用 DTO,但没有任何属性。 +### 4.3 2026-07-28 补齐版 YAML 与在线 Apifox 复核 + +补齐文件 `genealogy-pc-openapi.yaml` 含 98 个 path、137 个 HTTP operation、17 个 tag、319 个 schema;旧 `PC.openapi.json` 含 71 个 path、96 个 operation、68 个 schema。补齐文件新增了家谱、成员、相册、文章、祭祀、通知、反馈、VIP 和官网内容等路径及 DTO,但它还不能替代在线 Apifox: + +1. 已实际登录并打开 Apifox“家谱”项目的 PC 模块。在线概览显示 **142 个接口、87 个数据模型**,与补齐 YAML 的 137 个 HTTP operation 不一致。 +2. 在线“认证登录”目录仍显示 **12 个接口**,同时存在: + - 错误旧路径:`genealogy/pc/auth/sms/{operationCode}/code` + - 正确路径:`/genealogy/pc/auth/sms/{operationCode}/code` + + 补齐 YAML 只包含正确路径,因此 C02 在线尚未完成。 +3. 补齐 YAML 给注册、密码登录、短信登录和短信发送标了 `security: []`;在线密码登录详情仍展示必需的 `Authorization` API Key,并显示 `Auth 1`。因此 C03 的导出定义和在线详情冲突。 +4. 在线 `GET /genealogy/pc/auth/profile` 成功响应仍为泛型 `ObjectResult`;展开后的 `data` 只有任意附加属性,没有 `ProfileView` 字段。补齐 YAML 同样引用泛型 `ObjectResult`,C04/C06 未完成。 +5. `ProfileUpdateBody.avatar` 仍为 `integer`,而 `FileUploadVo.ossId`、`LineagePersonView.avatarOssId` 等 OSS ID 为 `string`;部分头像字段也仍为 `integer`。C10 未完成。 +6. 已用真实登录响应和后端 `AppLoginVo` 复核:token 的唯一 JSON 字段是 `access_token`;原 YAML 的 `token`、`accessToken`、`tokenValue`、`userId`、`tenantId`、`clientId` 均不是该响应的直接字段,现已删除。 +7. `ProfileUpdateBody` 仍只有 `nickName`、`realName`、`avatar`、`sex`、`birthday`、`email`,没有地区字段,也没有说明缺省、`null`、空串的清空语义。C07/C18 未完成。 + +用户已明确要求本轮直接按补齐 YAML 施工,所以在线旧短信路径和公开接口 security 残留不再阻断本轮实现。YAML 自身仍存在的 profile 泛型响应、头像 ID 类型冲突和更新清空语义缺失继续保留为阻断项,前端不得自行补造。 + ## 5. 实施前必须解决的契约阻断项 以下事项不是前端自由选择,必须先在 Apifox 明确: @@ -144,6 +164,20 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 | C19 | 多数 View 没有 `canEdit` / `canDelete` 等权限字段 | 页面无法可靠决定按钮可见性 | 增加能力字段,或提供明确且可实现的角色规则 | | C20 | 文章、相册、祭祀只有删除操作 | 没有真实 ID 来源和完整资源流程 | 补全 PC 列表/详情/新增/修改后才开放页面 | +当前复核状态: + +| 阻断项 | 状态 | 在线/补齐文件证据 | +| --- | --- | --- | +| C02 | 本轮按 YAML 解决 | 只实现带 `/` 的正确路径,不保留旧路径 fallback | +| C03 | 本轮按 YAML 解决 | 验证、注册、登录、短信、找回使用 `security: []`;其他认证接口携带 Bearer | +| C04/C06 | 阻断 | profile GET 在线及 YAML 都只有泛型 `ObjectResult` | +| C07 | 阻断 | `ProfileUpdateBody` 没有地区字段 | +| C01/C08 | 前端按后端 PC 实链解决 | 只调用 `/genealogy/pc/region/*`;列表按 `RegionSelectVo` 的 `regionCode/regionName/regionLevel` 读取,不保留公共路径 fallback | +| C09 | 前端按后端 PC 实链解决 | `SysOssResumableInitVo` 明确提供 `uploadId/instant/ossId/url/fileName/uploadedChunks`,完成响应使用 `SysOssUploadVo` | +| C10 | 前端边界解决 | 后端初始化 `ossId` 为 Long、完成响应为 string;浏览器统一转十进制字符串,隐藏回填且不转 `Number` | +| C13 | 后续日期模块仍阻断 | 本阶段上传与区划响应不消费日期;其他模块仍需逐字段核验 Java 日期类型 | +| C18 | 阻断 | 未说明可选更新字段的省略、`null`、空串语义 | + 阶段 0 验收标准: - Apifox 中不存在旧短信路径和双区划路径; @@ -246,8 +280,18 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 | `passed` | R/A | 决定校验成功或失败 | | `validToken` | I | 一次性票据,不显示、不落长期存储 | | `message` | R | 安全地显示验证结果 | -| 登录响应的 `token` / `accessToken` / `tokenValue` | I | Apifox 收口为一个正式字段后保存 | -| `userId`、`tenantId`、`clientId`、`clientKey` | I/R | 会话上下文或诊断信息,不作为用户编辑字段 | +| `access_token` | string / I | 登录接口 `data`;密码或短信登录成功时立即保存,后续仅用于 Bearer 鉴权 | +| `expire_in` | integer / I/R | 登录接口 `data`;登录成功时取得,可用于会话到期提示,不由用户填写 | +| `client_id`、`clientKey`、`deviceType`、`userType` | string / I/R | 登录接口 `data`;登录成功时取得,仅作会话上下文或诊断,不回传为用户输入 | +| `profile` | object / R | 登录接口 `data.profile`;登录成功后取得,可用于页面只读展示或资料页初始回填 | +| `profile.userId`、`profile.tenantId`、`profile.userNo` | integer/string、string、string / I/R | 后端自动产生;不得让用户手工填写,业务 ID 超出 JS 安全整数时保持字符串 | +| `profile.phone`、`profile.nickName`、`profile.realName`、`profile.email` | string/null / R | 后端用户资料;登录成功后只读展示或作为资料页回填,编辑时仍只提交 `ProfileUpdateBody` 允许字段 | +| `profile.avatar` | integer/string/null / R | 后端头像 OSS ID;只读回填,用户换头像必须经文件选择和上传组件产生,禁止手填 OSS ID | +| `profile.sex` | enum `0/1/2` / R | 后端用户资料;页面映射男/女/未知 | +| `profile.birthday` | date/null / R | 后端用户资料;页面按日期展示 | +| `profile.registerSource`、`profile.loginIp`、`profile.loginDate` | string/null / R/I | 后端自动产生;只读或内部诊断,不作为表单输入 | +| `profile.status` | string / R/I | 后端账号状态;只读或内部诊断,不作为表单输入 | +| `profile.clientKey`、`profile.deviceType` | string / I/R | 后端登录上下文;只读或内部诊断 | #### 认证业务流程 @@ -258,7 +302,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 3. 若 `required=false`,直接提交密码登录。 4. 若需要验证,调用 challenge,展示服务端指定控件,再调用 verify。 5. 得到 `validToken` 后与 MD5 密码一起登录。 -6. 成功后仅保存一个正式 token 字段;Apifox 必须选定 `token`、`accessToken` 或 `tokenValue` 的唯一字段,不能长期兼容三个别名。 +6. 成功后只读取并保存 `data.access_token`;`token`、`accessToken`、`tokenValue` 均视为非法旧响应,不做兼容读取。 短信类动作: @@ -284,6 +328,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 现有页面整改点: - 当前 `avatarOssId` 字段名与请求契约 `avatar` 不一致; +- 补齐 YAML 中 `avatar` 仍是 `integer`,但示例值已超过 JavaScript 安全整数范围;浏览器边界必须按十进制字符串保留精度,且只能由上传响应自动回填,不能转 `Number` 或让用户手填; - 缺少 `realName`、`email` 和性别“未知”; - 页面现居地区、父亲、微信/QQ、学历/职业不在 `ProfileUpdateBody`,不得混入保存请求; - 在后端增加地区字段前,“保存地区”按钮必须关闭或改成纯查询演示; @@ -312,7 +357,8 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 - `FileUploadVo.ossId`:I,隐藏写入业务表单; - `url`、`thumbnailUrl`:R,用于预览; - `fileName`、`originalName`:R,用于文件列表; -- 初始化的 `instant`、已上传分片和秒传 OSS 信息尚未定义,C09 完成前不得猜测; +- 初始化 `instant=true` 时直接读取 `ossId/url/fileName` 完成秒传;`instant=false` 时使用服务端 `uploadId` 与 `uploadedChunks` 跳过已上传分片; +- 初始化 Long `ossId` 和完成响应 string `ossId` 均在浏览器边界转为十进制字符串; - 上传成功但业务保存失败时,当前契约没有释放引用接口,必须让后端补充生命周期规则。 统一由 `public/js/upload-pages.js` 管理分片、进度、重试、取消、MD5 和隐藏 ID。各业务页面不得复制上传算法,也不得出现“请输入 OSS ID”的可见输入框。 @@ -339,7 +385,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 ### 7.5 行政区划 -先完成 C01,只实现选中的唯一一套路径。 +本 PC 前端只实现 `/genealogy/pc/region/*`,不调用重复的 `/genealogy/region/*`,也不保留 fallback。 | 字段 | 必填性 | 类型 | 页面处理 | | --- | --- | --- | --- | @@ -350,7 +396,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一 | `limit` | 可选 query | A | 搜索组件固定合理上限,不让用户自由输入 | | `clientid` | 可选 header | A | 统一请求层注入 | -在 `RegionView` 补齐前不能假设响应一定含 `regionCode`、`regionName`、`regionLevel`。个人资料当前也没有可保存的地区字段,因此区划组件只能在有真实消费字段的页面启用提交。 +真实 Java `RegionSelectVo` 已明确 `regionCode`、`regionName`、`regionLevel` 等字段。个人资料当前仍没有可保存的地区字段,因此区划组件只提供级联、搜索和路径回显,不提交到资料更新接口。 ### 7.6 家族圈 diff --git a/forgot-password.html b/forgot-password.html index 89b3ed1..75b51d7 100644 --- a/forgot-password.html +++ b/forgot-password.html @@ -36,6 +36,9 @@ class="input" name="smsCode" autocomplete="one-time-code" + inputmode="numeric" + maxlength="4" + pattern="\d{4}" placeholder="验证码" /> diff --git a/login.html b/login.html index 7e568ea..e7ce6c4 100644 --- a/login.html +++ b/login.html @@ -68,6 +68,9 @@ class="input" name="smsCode" autocomplete="one-time-code" + inputmode="numeric" + maxlength="4" + pattern="\d{4}" placeholder="短信验证码" /> @@ -164,8 +190,7 @@

现居地区

- - +
- - + + 当前 YAML 的 ProfileUpdateBody 没有地区字段
- +
diff --git a/profile-gift-edit.html b/profile-gift-edit.html index d647f7a..6c88338 100644 --- a/profile-gift-edit.html +++ b/profile-gift-edit.html @@ -93,9 +93,8 @@ >
- - - + +

未选择文件

@@ -124,6 +123,8 @@ + + diff --git a/profile-growth-edit.html b/profile-growth-edit.html index 4a1a9eb..db739c5 100644 --- a/profile-growth-edit.html +++ b/profile-growth-edit.html @@ -94,8 +94,11 @@
- - + + + + +

未选择文件

@@ -131,6 +134,8 @@ + + diff --git a/profile-memo-edit.html b/profile-memo-edit.html index 5aa9fea..f5f336d 100644 --- a/profile-memo-edit.html +++ b/profile-memo-edit.html @@ -34,7 +34,7 @@
-
+

未选择文件

@@ -54,6 +54,8 @@ + + diff --git a/profile-relative-edit.html b/profile-relative-edit.html index 9e008ec..f73e393 100644 --- a/profile-relative-edit.html +++ b/profile-relative-edit.html @@ -24,12 +24,12 @@
-
+

未选择文件

取消
- + diff --git a/profile-security.html b/profile-security.html index 6af0dee..ab0a665 100644 --- a/profile-security.html +++ b/profile-security.html @@ -88,7 +88,16 @@
- +
@@ -110,7 +119,8 @@ name="smsCode" type="text" inputmode="numeric" - maxlength="6" + maxlength="4" + pattern="\d{4}" autocomplete="one-time-code" required /> diff --git a/public/js/auth-pages.js b/public/js/auth-pages.js index 2344ef1..79cf2c8 100644 --- a/public/js/auth-pages.js +++ b/public/js/auth-pages.js @@ -151,6 +151,14 @@ if (field) field.value = ''; } + function takeCaptchaToken(form) { + var field = query('input[name="validToken"]', form); + var token = field && field.value || ''; + + if (field) field.value = ''; + return token; + } + function startSmsCooldown(button) { var originalText; var remaining = SMS_CODE_COOLDOWN_SECONDS; @@ -202,7 +210,7 @@ } function isSmsCode(value) { - return /^\d{4,6}$/.test(value || ''); + return /^\d{4}$/.test(value || ''); } function validatePassword(value, message) { @@ -315,6 +323,7 @@ if (!validateBeforeSubmit('login', values)) return; if (!await ensureCaptcha(form, getFormCaptchaOperation(form, 'login'), values.phone)) return; values = readForm(form); + values.validToken = takeCaptchaToken(form); setBusy(button, true); try { await api.login(buildLoginBody(values)); @@ -384,6 +393,7 @@ if (!await ensureCaptcha(form, operationCode, values.phone)) return; values = readForm(form); + values.validToken = takeCaptchaToken(form); setBusy(button, true); try { @@ -391,12 +401,10 @@ phone: values.phone, validToken: values.validToken || '' }); - clearCaptchaToken(form); showMessage('验证码已发送'); setBusy(button, false); startSmsCooldown(button); } catch (error) { - clearCaptchaToken(form); showMessage(error.message || '验证码发送失败'); setBusy(button, false); } @@ -476,6 +484,7 @@ buildRegisterBody: buildRegisterBody, buildPasswordResetBody: buildPasswordResetBody, clearCaptchaToken: clearCaptchaToken, + takeCaptchaToken: takeCaptchaToken, startSmsCooldown: startSmsCooldown, getCaptchaOperation: getCaptchaOperation, getFormCaptchaOperation: getFormCaptchaOperation, diff --git a/public/js/profile-pages.js b/public/js/profile-pages.js index 687c8f0..0071996 100644 --- a/public/js/profile-pages.js +++ b/public/js/profile-pages.js @@ -58,17 +58,28 @@ function buildProfileUpdateBody(values) { // 字段名称严格对应 PC YAML 里的 ProfileUpdateBody。 var source = values || {}; + var body = {}; + var avatar = trimOrUndefined(source.avatar); - return { - nickName: trimOrUndefined(source.nickName), - // int64 在浏览器中必须保留字符串,避免 Number 转换丢失精度。 - avatarOssId: trimOrUndefined(source.avatarOssId), - sex: trimOrUndefined(source.sex), - birthday: trimOrUndefined(source.birthday), - provinceCode: trimOrUndefined(source.provinceCode), - cityCode: trimOrUndefined(source.cityCode), - districtCode: trimOrUndefined(source.districtCode) - }; + [ + 'nickName', + 'realName', + 'sex', + 'birthday', + 'email' + ].forEach(function (field) { + var value = trimOrUndefined(source[field]); + + if (value !== undefined) body[field] = value; + }); + + if (avatar !== undefined) { + if (!/^\d+$/.test(avatar)) throw new Error('头像 OSS ID 必须是整数'); + // YAML 将 avatar 标成 int64,但其示例已超过 JS 安全整数;浏览器按十进制字符串保留精度。 + body.avatar = avatar; + } + + return body; } function getApi() { diff --git a/public/js/region-pages.js b/public/js/region-pages.js index 0f57cf1..fdd74bd 100644 --- a/public/js/region-pages.js +++ b/public/js/region-pages.js @@ -81,22 +81,6 @@ return source.districtCode || source.cityCode || source.provinceCode || ''; } - function trimOrUndefined(value) { - var text = String(value === undefined || value === null ? '' : value).trim(); - - return text || undefined; - } - - function buildProfileRegionBody(values) { - var source = values || {}; - - return { - provinceCode: trimOrUndefined(source.provinceCode), - cityCode: trimOrUndefined(source.cityCode), - districtCode: trimOrUndefined(source.districtCode) - }; - } - function getApi() { return root.GenealogyApi && root.GenealogyApi.defaultClient; } @@ -354,49 +338,6 @@ } } - async function submitProfileRegion(form) { - var api = getApi(); - var values = getFormValues(form); - var body = buildProfileRegionBody(values); - var status = query('[data-region-profile-status]', form); - - if (redirectUnauthorized(api)) return; - if (!body.provinceCode) { - showMessage('请选择省份'); - return; - } - - try { - setSubmitting(form, true); - if (status) status.textContent = '保存中...'; - await api.updateProfile(body); - if (status) status.textContent = '已保存'; - showMessage('地区资料已保存'); - if (root.ProfilePages && root.ProfilePages.reloadProfile) await root.ProfilePages.reloadProfile(); - } catch (error) { - if (redirectUnauthorized(api, error)) return; - if (status) status.textContent = '保存失败'; - showMessage(error.message || '地区资料保存失败'); - } finally { - setSubmitting(form, false); - } - } - - function setSubmitting(form, isSubmitting) { - queryAll('button[type="submit"]', form).forEach(function (button) { - button.disabled = isSubmitting; - }); - } - - function bindProfileForms() { - queryAll('[data-region-profile-form]').forEach(function (form) { - form.addEventListener('submit', function (event) { - event.preventDefault(); - submitProfileRegion(form); - }); - }); - } - function bindSearchForms() { queryAll('[data-region-search-form]').forEach(function (form) { form.addEventListener('submit', function (event) { @@ -425,7 +366,6 @@ queryAll('[data-region-picker]').forEach(function (picker) { initPicker(picker).catch(handlePickerError); }); - bindProfileForms(); bindSearchForms(); } @@ -438,7 +378,6 @@ buildPathText: buildPathText, buildRegionSelection: buildRegionSelection, getFinalRegionCode: getFinalRegionCode, - buildProfileRegionBody: buildProfileRegionBody, applyRegionSelection: applyRegionSelection, applyProfileRegionSelection: applyProfileRegionSelection, shouldRedirectToLogin: shouldRedirectToLogin, diff --git a/public/js/security-pages.js b/public/js/security-pages.js index be4316e..a084a01 100644 --- a/public/js/security-pages.js +++ b/public/js/security-pages.js @@ -69,7 +69,7 @@ } function isSmsCode(value) { - return /^\d{4,6}$/.test(String(value || '').trim()); + return /^\d{4}$/.test(String(value || '').trim()); } function getApi() { @@ -161,6 +161,14 @@ if (field) field.value = ''; } + function takeCaptchaToken(form) { + var field = query('input[name="validToken"]', form); + var token = field && field.value || ''; + + if (field) field.value = ''; + return token; + } + function startSmsCooldown(button) { var originalText; var remaining = SMS_CODE_COOLDOWN_SECONDS; @@ -308,6 +316,7 @@ } if (!await ensureCaptcha(form, 'phone', values.phone)) return; values = getFormValues(form); + values.validToken = takeCaptchaToken(form); button.disabled = true; try { @@ -315,12 +324,10 @@ phone: values.phone, validToken: values.validToken || '' }); - clearCaptchaToken(form); showMessage('验证码已发送'); startSmsCooldown(button); } catch (error) { if (redirectUnauthorized(api, error)) return; - clearCaptchaToken(form); showMessage(error.message || '验证码发送失败'); button.disabled = false; } @@ -338,6 +345,7 @@ } if (!await ensureCaptcha(form, 'deactivate', boundPhone)) return; values = getFormValues(form); + values.validToken = takeCaptchaToken(form); button.disabled = true; try { @@ -345,12 +353,10 @@ phone: boundPhone, validToken: values.validToken || '' }); - clearCaptchaToken(form); showMessage('验证码已发送'); startSmsCooldown(button); } catch (error) { if (redirectUnauthorized(api, error)) return; - clearCaptchaToken(form); showMessage(error.message || '验证码发送失败'); button.disabled = false; } @@ -462,6 +468,7 @@ isDangerConfirmed: isDangerConfirmed, isPhone: isPhone, isSmsCode: isSmsCode, + takeCaptchaToken: takeCaptchaToken, shouldRedirectToLogin: shouldRedirectToLogin, init: init }; diff --git a/public/js/upload-pages.js b/public/js/upload-pages.js index 1d452ce..06f1c34 100644 --- a/public/js/upload-pages.js +++ b/public/js/upload-pages.js @@ -14,6 +14,8 @@ 'use strict'; var documentRef = root.document; + var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; + var activeUploads = {}; function normalizeUploadResult(data) { var source = data || {}; @@ -28,7 +30,7 @@ } function buildUploadStatus(fileName, ossId) { - return (fileName || '文件') + ' 上传完成,OSS ID:' + ossId; + return (fileName || '文件') + (ossId ? ' 上传完成' : ' 上传失败'); } function getUploadMode() { @@ -71,27 +73,159 @@ if (root.alert) root.alert(message); } - function uploadFileForPage(api, file) { - // 当前初始化响应未展开 uploadId、instant 与 OSS 字段,不能猜测分片闭环。 - if (!api || !file) return Promise.reject(new Error('请选择文件')); - return Promise.reject(new Error('当前 PC 文件上传响应未定义头像回填字段,暂不能上传头像')); + function createUploadId() { + if (root.crypto && typeof root.crypto.randomUUID === 'function') { + return 'pc-' + root.crypto.randomUUID(); + } + return 'pc-' + Date.now() + '-' + Math.random().toString(16).slice(2); + } + + function arrayBufferToBinary(buffer) { + var bytes = new Uint8Array(buffer); + var parts = []; + var offset; + + for (offset = 0; offset < bytes.length; offset += 8192) { + parts.push(String.fromCharCode.apply(null, bytes.subarray(offset, offset + 8192))); + } + return parts.join(''); + } + + async function hashBlob(blob) { + var buffer; + + if (!blob || typeof blob.arrayBuffer !== 'function') throw new Error('当前浏览器无法读取文件'); + if (typeof root.hex_md5 !== 'function') throw new Error('缺少文件 MD5 组件'); + buffer = await blob.arrayBuffer(); + return root.hex_md5(arrayBufferToBinary(buffer)); + } + + async function retry(operation, maxRetries) { + var attempt = 0; + + while (true) { + try { + return await operation(); + } catch (error) { + if (attempt >= maxRetries) throw error; + attempt += 1; + } + } + } + + async function performResumableUpload(api, file, options) { + var settings = options || {}; + var chunkSize = settings.chunkSize || DEFAULT_CHUNK_SIZE; + var totalChunks = Math.ceil(file.size / chunkSize); + var calculateHash = settings.hashBlob || hashBlob; + var fileMd5 = await calculateHash(file); + var initBody = { + uploadId: settings.uploadId || createUploadId(), + fileName: file.name, + fileMd5: fileMd5, + totalSize: file.size, + totalChunks: totalChunks, + chunkSize: chunkSize, + contentType: file.type || 'application/octet-stream' + }; + var initResult = await api.initResumableUpload(initBody); + var uploadedChunks = Array.isArray(initResult && initResult.uploadedChunks) + ? initResult.uploadedChunks.slice() + : []; + var uploadedLookup = {}; + var serverUploadId; + var chunkIndex; + + if (initResult && initResult.instant) return normalizeUploadResult(initResult); + serverUploadId = initResult && initResult.uploadId || initBody.uploadId; + uploadedChunks.forEach(function (index) { + uploadedLookup[Number(index)] = true; + }); + if (settings.onProgress) { + settings.onProgress({ uploadedChunks: uploadedChunks.length, totalChunks: totalChunks }); + } + + for (chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { + if (uploadedLookup[chunkIndex]) continue; + + await (async function (index) { + var start = index * chunkSize; + var chunk = file.slice(start, Math.min(start + chunkSize, file.size)); + var chunkMd5 = await calculateHash(chunk); + + await retry(function () { + return api.uploadResumableChunk({ + uploadId: serverUploadId, + chunkIndex: index, + chunkMd5: chunkMd5, + file: chunk + }); + }, settings.maxRetries === undefined ? 2 : settings.maxRetries); + uploadedChunks.push(index); + if (settings.onProgress) { + settings.onProgress({ uploadedChunks: uploadedChunks.length, totalChunks: totalChunks }); + } + })(chunkIndex); + } + + return normalizeUploadResult(await api.completeResumableUpload({ + uploadId: serverUploadId, + fileName: file.name, + fileMd5: fileMd5, + totalSize: file.size, + totalChunks: totalChunks + })); + } + + function uploadFileForPage(api, file, options) { + var key; + var promise; + + if (!api || !file || !file.name || !Number(file.size)) return Promise.reject(new Error('请选择文件')); + key = [file.name, file.size, file.lastModified || 0].join(':'); + if (activeUploads[key]) return activeUploads[key]; + + promise = performResumableUpload(api, file, options).finally(function () { + delete activeUploads[key]; + }); + activeUploads[key] = promise; + return promise; + } + + function mergeUploadTargetValue(currentValue, ossId, multiple) { + var current = String(currentValue || '').trim(); + var next = String(ossId || '').trim(); + + if (!multiple || !current) return next; + return current.split(',').concat(next).filter(Boolean).join(','); } async function uploadFromInput(input) { var api = getApi(); - var file = input.files && input.files[0]; + var files = Array.prototype.slice.call(input.files || []); var target = query(input.getAttribute('data-upload-target')); var status = query(input.getAttribute('data-upload-status')); + var multiple = input.getAttribute('data-upload-multiple') === 'true'; var result; + var index; - if (!file || !target || redirectUnauthorized(api)) return; + if (!files.length || !target || redirectUnauthorized(api)) return; input.disabled = true; try { - result = normalizeUploadResult(await uploadFileForPage(api, file)); - if (!result.ossId) throw new Error('上传响应缺少 ossId'); - target.value = result.ossId; - if (status) status.textContent = buildUploadStatus(result.fileName || file.name, result.ossId); + for (index = 0; index < files.length; index += 1) { + if (status) status.textContent = files[index].name + ' 正在上传...'; + result = normalizeUploadResult(await uploadFileForPage(api, files[index], { + onProgress: function (progress) { + if (status) { + status.textContent = '正在上传 ' + progress.uploadedChunks + '/' + progress.totalChunks + ' 分片'; + } + } + })); + if (!result.ossId) throw new Error('上传响应缺少 ossId'); + target.value = mergeUploadTargetValue(target.value, result.ossId, multiple); + if (status) status.textContent = buildUploadStatus(result.fileName || files[index].name, result.ossId); + } } catch (error) { if (redirectUnauthorized(api, error)) return; if (status) status.textContent = error.message || '上传失败'; @@ -121,7 +255,9 @@ normalizeUploadResult: normalizeUploadResult, buildUploadStatus: buildUploadStatus, getUploadMode: getUploadMode, + hashBlob: hashBlob, uploadFileForPage: uploadFileForPage, + mergeUploadTargetValue: mergeUploadTargetValue, shouldRedirectToLogin: shouldRedirectToLogin, init: init }; diff --git a/register.html b/register.html index 29036f3..496de64 100644 --- a/register.html +++ b/register.html @@ -37,7 +37,8 @@ name="smsCode" autocomplete="one-time-code" inputmode="numeric" - maxlength="6" + maxlength="4" + pattern="\d{4}" placeholder="短信验证码" />