Compare commits

..
Author SHA1 Message Date
a149fe3973 chore(mobile): 将安卓/苹果应用标识统一为 cn.solsum.jinxunda
后续极光与厂商推送将使用该包名;旧测试版需卸载重装。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 18:30:17 +08:00
9 changed files with 30 additions and 559 deletions
+1 -7
View File
@@ -41,7 +41,7 @@ POST /api/login
| POST | `/api/admin/reset_password` | 重置密码 `{staffNo, password}` | | POST | `/api/admin/reset_password` | 重置密码 `{staffNo, password}` |
| POST | `/api/admin/delete` | 删除 `{staffNo}`,立即无法登录并被踢下线(OpenIM 侧同名用户保留但本服务不再为其签发令牌,等于不可用) | | POST | `/api/admin/delete` | 删除 `{staffNo}`,立即无法登录并被踢下线(OpenIM 侧同名用户保留但本服务不再为其签发令牌,等于不可用) |
管理页:浏览器打开 `http://<服务器IP>:10010/`(公网反代场景为 `https://<域名>/account/`),输入管理口令即可操作,无需写接口。页面会按当前地址自动带上部署前缀请求 `/api/admin/...`,不要改成以 `/` 开头的绝对路径,否则公网会被 OpenIM 根路由接走 管理页:浏览器打开 `http://<服务器IP>:10010/`,输入管理口令即可操作,无需写接口
## 部署 ## 部署
@@ -57,9 +57,3 @@ docker compose up -d --build account-server
- `ACCOUNT_ADMIN_TOKEN`:管理口令,**部署时务必改掉默认值** - `ACCOUNT_ADMIN_TOKEN`:管理口令,**部署时务必改掉默认值**
重启/升级:改代码后 `docker compose up -d --build account-server`;数据在 `./data/account/employees.json`,重建容器不丢。 重启/升级:改代码后 `docker compose up -d --build account-server`;数据在 `./data/account/employees.json`,重建容器不丢。
管理页热修自测(不连现网、不读真实员工数据):
```bash
node account-service/test-admin-page.js
```
-79
View File
@@ -1,79 +0,0 @@
/**
* 员工管理页前端请求辅助(与 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 };
+11 -90
View File
@@ -87,94 +87,15 @@ $('token').value = localStorage.getItem('adminToken') || '';
function saveToken() { localStorage.setItem('adminToken', $('token').value.trim()); show('口令已保存', true); loadList(); } 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'); } function show(text, ok) { const m = $('msg'); m.textContent = text; m.className = 'msg ' + (ok ? 'okm' : 'err'); }
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) { async function api(path, body) {
let res; const res = await fetch(path, {
try {
res = await fetch(adminApiUrl(path), {
method: body ? 'POST' : 'GET', method: body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json', 'admin-token': $('token').value.trim() }, headers: { 'Content-Type': 'application/json', 'admin-token': $('token').value.trim() },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
} catch (e) { const j = await res.json();
throw new Error(toUserMessage(e)); if (j.code !== 0) throw new Error(j.msg || '请求失败');
} return j.data;
try {
return await readAdminJson(res);
} catch (e) {
throw new Error(toUserMessage(e));
}
} }
async function loadList() { async function loadList() {
@@ -194,7 +115,7 @@ async function loadList() {
<button class="danger" onclick="deleteEmp('${e.staffNo}')">删除</button> <button class="danger" onclick="deleteEmp('${e.staffNo}')">删除</button>
</td> </td>
</tr>`).join('') || '<tr><td colspan="5" style="color:#98a2b3">暂无员工,请先导入</td></tr>'; </tr>`).join('') || '<tr><td colspan="5" style="color:#98a2b3">暂无员工,请先导入</td></tr>';
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
async function toggle(staffNo, enable) { async function toggle(staffNo, enable) {
@@ -202,7 +123,7 @@ async function toggle(staffNo, enable) {
await api('/api/admin/' + (enable ? 'enable' : 'disable'), { staffNo }); await api('/api/admin/' + (enable ? 'enable' : 'disable'), { staffNo });
show((enable ? '已启用 ' : '已停用 ') + staffNo, true); show((enable ? '已启用 ' : '已停用 ') + staffNo, true);
loadList(); loadList();
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
async function resetPwd(staffNo) { async function resetPwd(staffNo) {
@@ -212,7 +133,7 @@ async function resetPwd(staffNo) {
await api('/api/admin/reset_password', { staffNo, password: pwd }); await api('/api/admin/reset_password', { staffNo, password: pwd });
show('已重置 ' + staffNo + ' 的密码', true); show('已重置 ' + staffNo + ' 的密码', true);
loadList(); loadList();
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
async function addEmp() { async function addEmp() {
@@ -221,7 +142,7 @@ async function addEmp() {
show('已添加 ' + $('addNo').value.trim(), true); show('已添加 ' + $('addNo').value.trim(), true);
$('addNo').value = $('addName').value = $('addPwd').value = ''; $('addNo').value = $('addName').value = $('addPwd').value = '';
loadList(); loadList();
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
async function doImport() { async function doImport() {
@@ -230,7 +151,7 @@ async function doImport() {
const bad = d.results.filter(r => !r.ok); 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); show('导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0);
loadList(); loadList();
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
// 直接选择模板 CSV 文件导入,不用复制粘贴 // 直接选择模板 CSV 文件导入,不用复制粘贴
@@ -245,7 +166,7 @@ async function importFile(input) {
const bad = d.results.filter(r => !r.ok); 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); show('文件「' + file.name + '」导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0);
loadList(); loadList();
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
async function deleteEmp(staffNo) { async function deleteEmp(staffNo) {
@@ -254,7 +175,7 @@ async function deleteEmp(staffNo) {
await api('/api/admin/delete', { staffNo }); await api('/api/admin/delete', { staffNo });
show('已删除 ' + staffNo, true); show('已删除 ' + staffNo, true);
loadList(); loadList();
} catch (e) { show(toUserMessage(e), false); } } catch (e) { show(e.message, false); }
} }
// 下载 CSV 导入模板(加 BOM 让 Excel 正确识别中文) // 下载 CSV 导入模板(加 BOM 让 Excel 正确识别中文)
-365
View File
@@ -1,365 +0,0 @@
/**
* 员工管理页热修检查(零 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('<html>oops</html>', {
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);
});
+4 -3
View File
@@ -1,10 +1,12 @@
# 任务清单 # 任务清单
更新时间:2026-08-31。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。 更新时间:2026-08-23。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。
## 正在做 ## 正在做
HEL-294 员工管理页接口路径与英文报错热修:代码和本地自测已完成,等待部署到香港测试环境后由管理员在公网 `/account/` 页面验收 目前没有已确认正在施工的业务代码任务
本次已完成仓库交接手册整理,后续所有任务都应按 `docs/README.md` 的更新规矩同步维护本清单。
## 已做完 ## 已做完
@@ -14,7 +16,6 @@ HEL-294 员工管理页接口路径与英文报错热修:代码和本地自测
- 完成一轮手机与电脑的跨端回归记录;其中已通过的项目见旧档 `phase4-regression-2026-08-16.md` - 完成一轮手机与电脑的跨端回归记录;其中已通过的项目见旧档 `phase4-regression-2026-08-16.md`
- 将 LiveKit 的信令/媒体端口统一为 `17880``17881``17882`,并提供可重复执行的修复脚本。 - 将 LiveKit 的信令/媒体端口统一为 `17880``17881``17882`,并提供可重复执行的修复脚本。
- 建立本目录四份交接手册,并为三份过时旧说明加上明确的留档提示。 - 建立本目录四份交接手册,并为三份过时旧说明加上明确的留档提示。
- HEL-294:管理页按部署前缀请求账号接口,并给 401/404/500/空响应/非 JSON 提供中文兜底(代码已合入分支,公网部署前现网仍会报英文错)。
## 还没安排 ## 还没安排
+1 -2
View File
@@ -1,6 +1,6 @@
# 最新进度 # 最新进度
更新时间:2026-08-31。以下内容依据当前仓库代码整理。 更新时间:2026-08-23。以下内容依据当前仓库代码和最新提交 `fa8584a` 整理。
## 现在做到哪里 ## 现在做到哪里
@@ -9,7 +9,6 @@
- 手机端在 `mobile/`,使用 OpenIM Flutter SDK `3.8.3+hotfix.12`,版本 `1.0.6+7` - 手机端在 `mobile/`,使用 OpenIM Flutter SDK `3.8.3+hotfix.12`,版本 `1.0.6+7`
- 电脑端在 `pc-client/`,使用 Electron,显示版本 `v1.0.2` - 电脑端在 `pc-client/`,使用 Electron,显示版本 `v1.0.2`
- 员工用工号和密码通过 `account-service/` 登录;管理员导入和管理账号的页面、接口已在仓库中。 - 员工用工号和密码通过 `account-service/` 登录;管理员导入和管理账号的页面、接口已在仓库中。
- HEL-294:管理页接口改为适配 `/account/` 部署前缀;非 JSON/空响应改为中文提示,不再把浏览器英文异常直接展示。热修代码已提交,尚未部署到公网。
- 手机和电脑端已接入聊天、通讯录、同事申请、语音消息、文件/图片消息和一对一语音通话相关代码。 - 手机和电脑端已接入聊天、通讯录、同事申请、语音消息、文件/图片消息和一对一语音通话相关代码。
- 语音服务 LiveKit 的信令和媒体端口已统一为 `17880``17881``17882`;仓库提供 `scripts/fix-livekit-ports.sh` 用于服务器已部署环境的端口修复。 - 语音服务 LiveKit 的信令和媒体端口已统一为 `17880``17881``17882`;仓库提供 `scripts/fix-livekit-ports.sh` 用于服务器已部署环境的端口修复。
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
} }
android { android {
namespace = "cn.shengshentech.changlian" namespace = "cn.solsum.jinxunda"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -16,7 +16,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "cn.shengshentech.changlian" applicationId = "cn.solsum.jinxunda"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@@ -1,4 +1,4 @@
package cn.shengshentech.changlian package cn.solsum.jinxunda
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
+6 -6
View File
@@ -382,7 +382,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = cn.shengshentech.changlian; PRODUCT_BUNDLE_IDENTIFIER = cn.solsum.jinxunda;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -398,7 +398,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shengshentech.changlian.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = cn.solsum.jinxunda.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -415,7 +415,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shengshentech.changlian.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = cn.solsum.jinxunda.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -430,7 +430,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shengshentech.changlian.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = cn.solsum.jinxunda.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -561,7 +561,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = cn.shengshentech.changlian; PRODUCT_BUNDLE_IDENTIFIER = cn.solsum.jinxunda;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -583,7 +583,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = cn.shengshentech.changlian; PRODUCT_BUNDLE_IDENTIFIER = cn.solsum.jinxunda;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;