From 8842a74d68fe2d0dc3f9d1e99969ae6500c8fd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=B7=A5=E7=A8=8B=E5=B8=88?= Date: Mon, 31 Aug 2026 09:27:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(account-service):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E6=8E=A5=E5=8F=A3=E8=B5=B0=E9=83=A8=E7=BD=B2=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E5=B9=B6=E6=94=B9=E4=B8=AD=E6=96=87=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公网 /account/ 下绝对路径 /api/admin 会被 OpenIM 接走,无条件 res.json() 把非 JSON 响应打成英文异常。按页面路径拼接前缀,并先看状态再解析。 Co-authored-by: Cursor Co-authored-by: multica-agent --- account-service/README.md | 8 +- account-service/admin-client.js | 79 +++++++ account-service/admin.html | 109 +++++++-- account-service/test-admin-page.js | 365 +++++++++++++++++++++++++++++ docs/任务清单.md | 7 +- docs/最新进度.md | 3 +- 6 files changed, 550 insertions(+), 21 deletions(-) create mode 100644 account-service/admin-client.js create mode 100644 account-service/test-admin-page.js diff --git a/account-service/README.md b/account-service/README.md index e07e18f..775ec2e 100644 --- a/account-service/README.md +++ b/account-service/README.md @@ -41,7 +41,7 @@ POST /api/login | POST | `/api/admin/reset_password` | 重置密码 `{staffNo, password}` | | POST | `/api/admin/delete` | 删除 `{staffNo}`,立即无法登录并被踢下线(OpenIM 侧同名用户保留但本服务不再为其签发令牌,等于不可用) | -管理页:浏览器打开 `http://<服务器IP>:10010/`,输入管理口令即可操作,无需写接口。 +管理页:浏览器打开 `http://<服务器IP>:10010/`(公网反代场景为 `https://<域名>/account/`),输入管理口令即可操作,无需写接口。页面会按当前地址自动带上部署前缀请求 `/api/admin/...`,不要改成以 `/` 开头的绝对路径,否则公网会被 OpenIM 根路由接走。 ## 部署 @@ -57,3 +57,9 @@ docker compose up -d --build account-server - `ACCOUNT_ADMIN_TOKEN`:管理口令,**部署时务必改掉默认值** 重启/升级:改代码后 `docker compose up -d --build account-server`;数据在 `./data/account/employees.json`,重建容器不丢。 + +管理页热修自测(不连现网、不读真实员工数据): + +```bash +node account-service/test-admin-page.js +``` diff --git a/account-service/admin-client.js b/account-service/admin-client.js new file mode 100644 index 0000000..f671362 --- /dev/null +++ b/account-service/admin-client.js @@ -0,0 +1,79 @@ +/** + * 员工管理页前端请求辅助(与 admin.html 内联函数保持一致,供自动化检查引用)。 + * 零 npm 依赖。 + */ +'use strict'; + +function adminApiUrl(apiPath, pagePath) { + const pathname = pagePath != null + ? pagePath + : (typeof location !== 'undefined' ? location.pathname : '/'); + let prefix = String(pathname || '/'); + if (prefix.length > 1 && prefix.endsWith('/')) prefix = prefix.slice(0, -1); + if (prefix === '/admin' || prefix.endsWith('/admin')) { + prefix = prefix.slice(0, -6); + } + if (prefix === '/') prefix = ''; + const route = apiPath.charAt(0) === '/' ? apiPath : '/' + apiPath; + return prefix + route; +} + +function httpStatusHint(status) { + const n = Number(status); + if (n === 401) return '管理口令错误或未提供'; + if (n === 403) return '没有权限执行此操作'; + if (n === 404) return '接口地址不正确,请从管理页重新打开'; + if (n === 502 || n === 503 || n === 504) return '服务暂时不可用,请稍后重试'; + if (n >= 500) return '服务暂时出错,请稍后重试'; + if (n >= 400) return '请求失败,请稍后重试'; + return ''; +} + +function toUserMessage(err) { + const raw = err && typeof err === 'object' && err.message != null + ? String(err.message) + : String(err || ''); + const s = raw.trim(); + if (!s) return '请求失败,请稍后重试'; + if (/unexpected end of json|failed to execute ['"]json['"]|json parse|not valid json|unexpected token/i.test(s)) { + return '服务器返回了无法识别的内容'; + } + if (/failed to fetch|networkerror|load failed|network request failed/i.test(s)) { + return '网络异常,请检查连接后重试'; + } + return s; +} + +async function readAdminJson(res) { + const status = res.status; + const ctype = String((res.headers && res.headers.get && res.headers.get('content-type')) || '').toLowerCase(); + let text = ''; + try { + text = await res.text(); + } catch { + throw new Error(httpStatusHint(status) || '无法读取服务器响应'); + } + const trimmed = (text || '').trim(); + if (!trimmed) { + throw new Error(httpStatusHint(status) || '服务器没有返回内容'); + } + const looksJson = ctype.indexOf('json') !== -1 || trimmed.charAt(0) === '{' || trimmed.charAt(0) === '['; + if (!looksJson) { + throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容'); + } + let j; + try { + j = JSON.parse(trimmed); + } catch { + throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容'); + } + if (!j || typeof j !== 'object') { + throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容'); + } + if (j.code !== 0) { + throw new Error(j.msg || httpStatusHint(status) || '请求失败'); + } + return j.data; +} + +module.exports = { adminApiUrl, httpStatusHint, toUserMessage, readAdminJson }; diff --git a/account-service/admin.html b/account-service/admin.html index dda615c..ac6cbdc 100644 --- a/account-service/admin.html +++ b/account-service/admin.html @@ -87,17 +87,96 @@ $('token').value = localStorage.getItem('adminToken') || ''; function saveToken() { localStorage.setItem('adminToken', $('token').value.trim()); show('口令已保存', true); loadList(); } function show(text, ok) { const m = $('msg'); m.textContent = text; m.className = 'msg ' + (ok ? 'okm' : 'err'); } -async function api(path, body) { - const res = await fetch(path, { - method: body ? 'POST' : 'GET', - headers: { 'Content-Type': 'application/json', 'admin-token': $('token').value.trim() }, - body: body ? JSON.stringify(body) : undefined, - }); - const j = await res.json(); - if (j.code !== 0) throw new Error(j.msg || '请求失败'); +function adminApiUrl(apiPath, pagePath) { + const pathname = pagePath != null + ? pagePath + : (typeof location !== 'undefined' ? location.pathname : '/'); + let prefix = String(pathname || '/'); + if (prefix.length > 1 && prefix.endsWith('/')) prefix = prefix.slice(0, -1); + if (prefix === '/admin' || prefix.endsWith('/admin')) { + prefix = prefix.slice(0, -6); + } + if (prefix === '/') prefix = ''; + const route = apiPath.charAt(0) === '/' ? apiPath : '/' + apiPath; + return prefix + route; +} + +function httpStatusHint(status) { + const n = Number(status); + if (n === 401) return '管理口令错误或未提供'; + if (n === 403) return '没有权限执行此操作'; + if (n === 404) return '接口地址不正确,请从管理页重新打开'; + if (n === 502 || n === 503 || n === 504) return '服务暂时不可用,请稍后重试'; + if (n >= 500) return '服务暂时出错,请稍后重试'; + if (n >= 400) return '请求失败,请稍后重试'; + return ''; +} + +function toUserMessage(err) { + const raw = err && typeof err === 'object' && err.message != null + ? String(err.message) + : String(err || ''); + const s = raw.trim(); + if (!s) return '请求失败,请稍后重试'; + if (/unexpected end of json|failed to execute ['"]json['"]|json parse|not valid json|unexpected token/i.test(s)) { + return '服务器返回了无法识别的内容'; + } + if (/failed to fetch|networkerror|load failed|network request failed/i.test(s)) { + return '网络异常,请检查连接后重试'; + } + return s; +} + +async function readAdminJson(res) { + const status = res.status; + const ctype = String((res.headers && res.headers.get && res.headers.get('content-type')) || '').toLowerCase(); + let text = ''; + try { + text = await res.text(); + } catch { + throw new Error(httpStatusHint(status) || '无法读取服务器响应'); + } + const trimmed = (text || '').trim(); + if (!trimmed) { + throw new Error(httpStatusHint(status) || '服务器没有返回内容'); + } + const looksJson = ctype.indexOf('json') !== -1 || trimmed.charAt(0) === '{' || trimmed.charAt(0) === '['; + if (!looksJson) { + throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容'); + } + let j; + try { + j = JSON.parse(trimmed); + } catch { + throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容'); + } + if (!j || typeof j !== 'object') { + throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容'); + } + if (j.code !== 0) { + throw new Error(j.msg || httpStatusHint(status) || '请求失败'); + } return j.data; } +async function api(path, body) { + let res; + try { + res = await fetch(adminApiUrl(path), { + method: body ? 'POST' : 'GET', + headers: { 'Content-Type': 'application/json', 'admin-token': $('token').value.trim() }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (e) { + throw new Error(toUserMessage(e)); + } + try { + return await readAdminJson(res); + } catch (e) { + throw new Error(toUserMessage(e)); + } +} + async function loadList() { try { const d = await api('/api/admin/employees'); @@ -115,7 +194,7 @@ async function loadList() { `).join('') || '暂无员工,请先导入'; - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } async function toggle(staffNo, enable) { @@ -123,7 +202,7 @@ async function toggle(staffNo, enable) { await api('/api/admin/' + (enable ? 'enable' : 'disable'), { staffNo }); show((enable ? '已启用 ' : '已停用 ') + staffNo, true); loadList(); - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } async function resetPwd(staffNo) { @@ -133,7 +212,7 @@ async function resetPwd(staffNo) { await api('/api/admin/reset_password', { staffNo, password: pwd }); show('已重置 ' + staffNo + ' 的密码', true); loadList(); - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } async function addEmp() { @@ -142,7 +221,7 @@ async function addEmp() { show('已添加 ' + $('addNo').value.trim(), true); $('addNo').value = $('addName').value = $('addPwd').value = ''; loadList(); - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } async function doImport() { @@ -151,7 +230,7 @@ async function doImport() { const bad = d.results.filter(r => !r.ok); show('导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0); loadList(); - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } // 直接选择模板 CSV 文件导入,不用复制粘贴 @@ -166,7 +245,7 @@ async function importFile(input) { const bad = d.results.filter(r => !r.ok); show('文件「' + file.name + '」导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0); loadList(); - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } async function deleteEmp(staffNo) { @@ -175,7 +254,7 @@ async function deleteEmp(staffNo) { await api('/api/admin/delete', { staffNo }); show('已删除 ' + staffNo, true); loadList(); - } catch (e) { show(e.message, false); } + } catch (e) { show(toUserMessage(e), false); } } // 下载 CSV 导入模板(加 BOM 让 Excel 正确识别中文) diff --git a/account-service/test-admin-page.js b/account-service/test-admin-page.js new file mode 100644 index 0000000..3074e86 --- /dev/null +++ b/account-service/test-admin-page.js @@ -0,0 +1,365 @@ +/** + * 员工管理页热修检查(零 npm 依赖) + * + * 覆盖: + * - /account/ 与直连根路径下的接口地址 + * - 页面所有管理操作都走同一套请求封装 + * - 正确口令能加载列表;错误口令显示中文 + * - 添加合法员工返回 JSON + * - 上游非 JSON / 空响应时页面有中文兜底 + * + * 运行:node test-admin-page.js + * 不打印口令或员工密码。 + */ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { adminApiUrl, httpStatusHint, toUserMessage, readAdminJson } = require('./admin-client'); + +const DIR = __dirname; +let passed = 0; +const failures = []; + +function ok(name) { + passed += 1; + console.log(' pass ' + name); +} + +function fail(name, err) { + failures.push(name + ': ' + (err && err.message ? err.message : String(err))); + console.log(' FAIL ' + name); +} + +async function check(name, fn) { + try { + await fn(); + ok(name); + } catch (e) { + fail(name, e); + } +} + +function sliceHelpers(src) { + const start = src.indexOf('function adminApiUrl'); + if (start < 0) throw new Error('找不到 adminApiUrl'); + let end = src.indexOf('async function api('); + if (end < 0) end = src.indexOf('module.exports'); + if (end < 0) throw new Error('找不到辅助函数结束位置'); + return src.slice(start, end).replace(/\s+/g, ' ').trim(); +} + +function hasCjk(s) { + return /[\u4e00-\u9fff]/.test(String(s || '')); +} + +function hasEnglishTechError(s) { + return /unexpected end of json|failed to execute ['"]json['"]|unexpected token/i.test(String(s || '')); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + server.on('error', reject); + }); +} + +function waitHealth(url, timeoutMs) { + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + fetch(url).then((r) => { + if (r.ok) return resolve(); + throw new Error(String(r.status)); + }).catch((e) => { + if (Date.now() - start > timeoutMs) return reject(new Error('服务未就绪')); + setTimeout(tick, 80); + }); + }; + tick(); + }); +} + +async function main() { + console.log('account-service admin page checks'); + + const html = fs.readFileSync(path.join(DIR, 'admin.html'), 'utf8'); + const clientSrc = fs.readFileSync(path.join(DIR, 'admin-client.js'), 'utf8'); + + await check('页面不再无条件 res.json()', () => { + assert.ok(!/\.json\s*\(/.test(html), 'admin.html 仍调用 res.json()'); + }); + + await check('所有管理操作走 api() 封装', () => { + const ops = [ + '/api/admin/employees', + '/api/admin/import', + '/api/admin/enable', + '/api/admin/disable', + '/api/admin/reset_password', + '/api/admin/delete', + ]; + for (const op of ops) { + assert.ok(html.includes("api('" + op) || html.includes('api(\'' + op) || html.includes('/api/admin/\' +'), '缺少 ' + op); + } + assert.ok(html.includes("api('/api/admin/' + (enable ? 'enable' : 'disable')")); + assert.ok((html.match(/await api\(/g) || []).length >= 7); + }); + + await check('html 与 admin-client.js 辅助函数一致', () => { + const a = sliceHelpers(html); + const b = sliceHelpers(clientSrc); + assert.ok(a.length > 80 && b.length > 80); + assert.strictEqual(a, b, 'admin.html 内联辅助函数与 admin-client.js 不一致'); + }); + + await check('直连根路径与 /account/ 前缀地址', () => { + const route = '/api/admin/employees'; + assert.strictEqual(adminApiUrl(route, '/'), '/api/admin/employees'); + assert.strictEqual(adminApiUrl(route, '/admin'), '/api/admin/employees'); + assert.strictEqual(adminApiUrl(route, '/account/'), '/account/api/admin/employees'); + assert.strictEqual(adminApiUrl(route, '/account'), '/account/api/admin/employees'); + assert.strictEqual(adminApiUrl(route, '/account/admin'), '/account/api/admin/employees'); + assert.strictEqual(adminApiUrl(route, '/account/admin/'), '/account/api/admin/employees'); + assert.strictEqual(adminApiUrl('/api/admin/import', '/account/'), '/account/api/admin/import'); + assert.strictEqual(adminApiUrl('/api/admin/enable', '/account/'), '/account/api/admin/enable'); + assert.strictEqual(adminApiUrl('/api/admin/disable', '/account/'), '/account/api/admin/disable'); + assert.strictEqual(adminApiUrl('/api/admin/reset_password', '/account/'), '/account/api/admin/reset_password'); + assert.strictEqual(adminApiUrl('/api/admin/delete', '/account/'), '/account/api/admin/delete'); + }); + + await check('错误口令 / 404 / 空响应 / 非 JSON 均为中文兜底', async () => { + const cases = [ + { + name: '401 json', + res: new Response(JSON.stringify({ code: 1, msg: '管理口令错误或未提供' }), { + status: 401, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }), + expect: '管理口令错误或未提供', + }, + { + name: '404 text', + res: new Response('404 page not found', { + status: 404, + headers: { 'content-type': 'text/plain' }, + }), + expect: httpStatusHint(404), + }, + { + name: 'empty 404', + res: new Response('', { status: 404, headers: { 'content-type': 'text/plain' } }), + expect: httpStatusHint(404), + }, + { + name: '500 html', + res: new Response('oops', { + status: 500, + headers: { 'content-type': 'text/html' }, + }), + expect: httpStatusHint(500), + }, + { + name: 'broken json', + res: new Response('{', { status: 200, headers: { 'content-type': 'application/json' } }), + expect: '服务器返回了无法识别的内容', + }, + ]; + for (const c of cases) { + let msg = ''; + try { + await readAdminJson(c.res); + throw new Error(c.name + ' 应当失败'); + } catch (e) { + msg = toUserMessage(e); + } + assert.ok(hasCjk(msg), c.name + ' 不是中文: ' + msg); + assert.ok(!hasEnglishTechError(msg), c.name + ' 泄漏英文: ' + msg); + if (c.expect) assert.strictEqual(msg, c.expect, c.name); + } + const english = toUserMessage(new Error("Failed to execute 'json' on 'Response': Unexpected end of JSON input")); + assert.strictEqual(english, '服务器返回了无法识别的内容'); + assert.ok(hasCjk(english)); + }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acct-admin-')); + const dataFile = path.join(tmpDir, 'employees.json'); + fs.writeFileSync(dataFile, '{}'); + + const mockIm = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + let buf = ''; + req.on('data', (c) => { buf += c; }); + req.on('end', () => { + if ((req.url || '').includes('/auth/get_admin_token')) { + return res.end(JSON.stringify({ errCode: 0, data: { token: 'mock', expireTimeSeconds: 3600 } })); + } + if ((req.url || '').includes('/user/user_register')) { + return res.end(JSON.stringify({ errCode: 0 })); + } + if ((req.url || '').includes('/user/get_users_info')) { + return res.end(JSON.stringify({ errCode: 0, data: { usersInfo: [{ userID: 't10001' }] } })); + } + res.statusCode = 404; + res.end(JSON.stringify({ errCode: 1 })); + }); + }); + + const imPort = await listen(mockIm); + const acctPort = 18000 + Math.floor(Math.random() * 2000); + const adminToken = 'unit-test-admin'; + const child = spawn(process.execPath, ['server.js'], { + cwd: DIR, + env: { + ...process.env, + PORT: String(acctPort), + DATA_FILE: dataFile, + ADMIN_TOKEN: adminToken, + OPENIM_API_URL: 'http://127.0.0.1:' + imPort, + OPENIM_SECRET: 'mock', + LIVEKIT_API_KEY: '', + LIVEKIT_API_SECRET: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const secretRe = new RegExp(adminToken + '|initpass123', 'g'); + const scrub = (s) => String(s || '').replace(secretRe, '[redacted]'); + child.stdout.on('data', (d) => { + const t = scrub(d); + if (t.trim()) console.log(' [server] ' + t.trim()); + }); + child.stderr.on('data', (d) => { + const t = scrub(d); + if (t.trim()) console.log(' [server-err] ' + t.trim()); + }); + + const stop = () => { + try { child.kill('SIGTERM'); } catch { /* ignore */ } + try { mockIm.close(); } catch { /* ignore */ } + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }; + + try { + await waitHealth('http://127.0.0.1:' + acctPort + '/api/health', 8000); + + await check('正确口令能加载列表', async () => { + const res = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/employees', { + headers: { 'admin-token': adminToken }, + }); + const data = await readAdminJson(res); + assert.ok(data && typeof data.total === 'number'); + assert.ok(Array.isArray(data.employees)); + }); + + await check('错误口令显示中文', async () => { + const res = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/employees', { + headers: { 'admin-token': 'wrong-token' }, + }); + let msg = ''; + try { + await readAdminJson(res); + } catch (e) { + msg = toUserMessage(e); + } + assert.ok(msg); + assert.ok(hasCjk(msg), '不是中文: ' + msg); + assert.ok(!hasEnglishTechError(msg)); + assert.ok(/口令|未提供|没有权限/.test(msg), msg); + }); + + await check('添加合法员工返回 JSON', async () => { + const res = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'admin-token': adminToken }, + body: JSON.stringify({ staffNo: 't10001', name: '测试员', password: 'initpass123' }), + }); + const ctype = String(res.headers.get('content-type') || ''); + assert.ok(ctype.indexOf('json') !== -1, 'content-type=' + ctype); + const data = await readAdminJson(res); + assert.strictEqual(data.staffNo, 't10001'); + assert.strictEqual(data.name, '测试员'); + assert.strictEqual(data.status, 'active'); + assert.ok(!JSON.stringify(data).includes('initpass123')); + assert.ok(!('hash' in data) && !('salt' in data) && !('password' in data)); + }); + + await check('启停/重置/删除走同一解析且成功返回 JSON', async () => { + const tokenHeader = { 'Content-Type': 'application/json', 'admin-token': adminToken }; + const disableRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/disable', { + method: 'POST', + headers: tokenHeader, + body: JSON.stringify({ staffNo: 't10001' }), + }); + const disabled = await readAdminJson(disableRes); + assert.strictEqual(disabled.status, 'disabled'); + + const enableRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/enable', { + method: 'POST', + headers: tokenHeader, + body: JSON.stringify({ staffNo: 't10001' }), + }); + const enabled = await readAdminJson(enableRes); + assert.strictEqual(enabled.status, 'active'); + + const resetRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/reset_password', { + method: 'POST', + headers: tokenHeader, + body: JSON.stringify({ staffNo: 't10001', password: 'initpass123' }), + }); + const reset = await readAdminJson(resetRes); + assert.strictEqual(reset.staffNo, 't10001'); + + const delRes = await fetch('http://127.0.0.1:' + acctPort + '/api/admin/delete', { + method: 'POST', + headers: tokenHeader, + body: JSON.stringify({ staffNo: 't10001' }), + }); + const del = await readAdminJson(delRes); + assert.strictEqual(del.deleted, true); + }); + + await check('上游非 JSON 时页面中文兜底(模拟公网误入 OpenIM)', async () => { + const upstream = http.createServer((req, res) => { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 page not found'); + }); + const upPort = await listen(upstream); + try { + const res = await fetch('http://127.0.0.1:' + upPort + '/api/admin/employees'); + let msg = ''; + try { + await readAdminJson(res); + } catch (e) { + msg = toUserMessage(e); + } + assert.ok(hasCjk(msg)); + assert.ok(!hasEnglishTechError(msg)); + assert.strictEqual(msg, '接口地址不正确,请从管理页重新打开'); + } finally { + upstream.close(); + } + }); + } finally { + stop(); + await new Promise((r) => setTimeout(r, 150)); + } + + console.log(''); + if (failures.length) { + console.log(failures.length + ' failed, ' + passed + ' passed'); + for (const f of failures) console.log(' - ' + f); + process.exit(1); + } + console.log('all ' + passed + ' passed'); +} + +main().catch((e) => { + console.error('test crashed (no secrets printed)'); + console.error(e && e.message ? e.message : 'unknown error'); + process.exit(1); +}); diff --git a/docs/任务清单.md b/docs/任务清单.md index 8d2a7ee..36dd47d 100644 --- a/docs/任务清单.md +++ b/docs/任务清单.md @@ -1,12 +1,10 @@ # 任务清单 -更新时间:2026-08-23。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。 +更新时间:2026-08-31。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。 ## 正在做 -目前没有已确认正在施工的业务代码任务。 - -本次已完成仓库交接手册整理,后续所有任务都应按 `docs/README.md` 的更新规矩同步维护本清单。 +HEL-294 员工管理页接口路径与英文报错热修:代码和本地自测已完成,等待部署到香港测试环境后由管理员在公网 `/account/` 页面验收。 ## 已做完 @@ -16,6 +14,7 @@ - 完成一轮手机与电脑的跨端回归记录;其中已通过的项目见旧档 `phase4-regression-2026-08-16.md`。 - 将 LiveKit 的信令/媒体端口统一为 `17880`、`17881`、`17882`,并提供可重复执行的修复脚本。 - 建立本目录四份交接手册,并为三份过时旧说明加上明确的留档提示。 +- HEL-294:管理页按部署前缀请求账号接口,并给 401/404/500/空响应/非 JSON 提供中文兜底(代码已合入分支,公网部署前现网仍会报英文错)。 ## 还没安排 diff --git a/docs/最新进度.md b/docs/最新进度.md index 0848a72..9426ed2 100644 --- a/docs/最新进度.md +++ b/docs/最新进度.md @@ -1,6 +1,6 @@ # 最新进度 -更新时间:2026-08-23。以下内容依据当前仓库代码和最新提交 `fa8584a` 整理。 +更新时间:2026-08-31。以下内容依据当前仓库代码整理。 ## 现在做到哪里 @@ -9,6 +9,7 @@ - 手机端在 `mobile/`,使用 OpenIM Flutter SDK `3.8.3+hotfix.12`,版本 `1.0.6+7`。 - 电脑端在 `pc-client/`,使用 Electron,显示版本 `v1.0.2`。 - 员工用工号和密码通过 `account-service/` 登录;管理员导入和管理账号的页面、接口已在仓库中。 +- HEL-294:管理页接口改为适配 `/account/` 部署前缀;非 JSON/空响应改为中文提示,不再把浏览器英文异常直接展示。热修代码已提交,尚未部署到公网。 - 手机和电脑端已接入聊天、通讯录、同事申请、语音消息、文件/图片消息和一对一语音通话相关代码。 - 语音服务 LiveKit 的信令和媒体端口已统一为 `17880`、`17881`、`17882`;仓库提供 `scripts/fix-livekit-ports.sh` 用于服务器已部署环境的端口修复。