Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8842a74d68 | ||
|
|
2f8a76da3f | ||
|
|
6cd369f548 | ||
|
|
fa8584a73f | ||
|
|
5cc2685f03 |
+6
-4
@@ -65,10 +65,12 @@ LOG_LEVEL=3
|
|||||||
LIVEKIT_API_KEY=openimLKkey
|
LIVEKIT_API_KEY=openimLKkey
|
||||||
# secret 必须 ≥32 字符,否则 LiveKit 启动报 secret is too short
|
# secret 必须 ≥32 字符,否则 LiveKit 启动报 secret is too short
|
||||||
LIVEKIT_API_SECRET=lk-secret-9f2c7a41d5e8b063f1a4c795e2d8b3a6
|
LIVEKIT_API_SECRET=lk-secret-9f2c7a41d5e8b063f1a4c795e2d8b3a6
|
||||||
# 三个都是宿主机映射端口;7880/7881/7882 被占用时改成 17880/17881/17882 之类即可
|
# 三个端口必须与 config/livekit.yaml 的 port/tcp_port/udp_port 完全一致,
|
||||||
LIVEKIT_PORT=7880
|
# 且与 docker-compose.yaml 的“宿主侧=容器侧”保持一致(默认 17880/17881/17882)。
|
||||||
LIVEKIT_RTC_TCP_PORT=7881
|
# 改端口时三个文件要一起改,否则语音通话会出现“能接听但双方网络错误”。
|
||||||
LIVEKIT_RTC_UDP_PORT=7882
|
LIVEKIT_PORT=17880
|
||||||
|
LIVEKIT_RTC_TCP_PORT=17881
|
||||||
|
LIVEKIT_RTC_UDP_PORT=17882
|
||||||
|
|
||||||
# ===== 公司账号服务(工号+密码登录,自研)=====
|
# ===== 公司账号服务(工号+密码登录,自研)=====
|
||||||
# 宿主端口;管理页 http://<SERVER_IP>:10010/
|
# 宿主端口;管理页 http://<SERVER_IP>:10010/
|
||||||
|
|||||||
@@ -33,9 +33,9 @@
|
|||||||
| 10002 | TCP/HTTP | OpenIM REST API |
|
| 10002 | TCP/HTTP | OpenIM REST API |
|
||||||
| 10005 | TCP/HTTP | MinIO(图片/语音/文件下载) |
|
| 10005 | TCP/HTTP | MinIO(图片/语音/文件下载) |
|
||||||
| 10010 | TCP/HTTP | 公司账号服务(员工登录接口 + 管理页) |
|
| 10010 | TCP/HTTP | 公司账号服务(员工登录接口 + 管理页) |
|
||||||
| 7880 | TCP/WS | LiveKit 信令(`.env` 的 `LIVEKIT_PORT` 可改) |
|
| 17880 | TCP/WS | LiveKit 信令(`.env` 的 `LIVEKIT_PORT` 可改,但必须与 `config/livekit.yaml` 一致) |
|
||||||
| 7882 | UDP | LiveKit 语音媒体(`LIVEKIT_RTC_UDP_PORT` 可改) |
|
| 17882 | UDP | LiveKit 语音媒体(`LIVEKIT_RTC_UDP_PORT` 可改,同上) |
|
||||||
| 7881 | TCP | LiveKit 媒体备用通道(`LIVEKIT_RTC_TCP_PORT` 可改) |
|
| 17881 | TCP | LiveKit 媒体备用通道(`LIVEKIT_RTC_TCP_PORT` 可改,同上) |
|
||||||
| 10004 | TCP/HTTP | MinIO 控制台(运维用,可不开放) |
|
| 10004 | TCP/HTTP | MinIO 控制台(运维用,可不开放) |
|
||||||
| 12379/12380 | TCP | Etcd(仅容器间用,建议不对外) |
|
| 12379/12380 | TCP | Etcd(仅容器间用,建议不对外) |
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ docker compose up -d # 再启动
|
|||||||
|
|
||||||
- OpenIM API: `http://<服务器IP>:10002`
|
- OpenIM API: `http://<服务器IP>:10002`
|
||||||
- OpenIM WebSocket: `ws://<服务器IP>:10001`
|
- OpenIM WebSocket: `ws://<服务器IP>:10001`
|
||||||
- LiveKit: `ws://<服务器IP>:7880`(7880 为默认信令端口,可用 `.env` 的 `LIVEKIT_PORT` 改),API Key/Secret 见 `.env` 的 `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`
|
- LiveKit: `ws://<服务器IP>:17880`(17880 为默认信令端口;改端口时必须 `.env` 的 `LIVEKIT_PORT`、`config/livekit.yaml` 的 `port`、`docker-compose.yaml` 的容器侧端口三者一起改),API Key/Secret 见 `.env` 的 `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`
|
||||||
- 测试账号:`test001` / `test002`(由自测脚本注册);语音通话时两名用户以各自 identity 进同一房间即可
|
- 测试账号:`test001` / `test002`(由自测脚本注册);语音通话时两名用户以各自 identity 进同一房间即可
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
@@ -87,6 +87,6 @@ docker compose up -d # 再启动
|
|||||||
- **文件/语音能发但打不开**:`MINIO_EXTERNAL_ADDRESS` 没写成客户端能访问的 IP。改 `.env` 后 `docker compose up -d` 重建 openim-server。
|
- **文件/语音能发但打不开**:`MINIO_EXTERNAL_ADDRESS` 没写成客户端能访问的 IP。改 `.env` 后 `docker compose up -d` 重建 openim-server。
|
||||||
- **启动报缺 `ETCD_USERNAME` / `KAFKA_USERNAME` 等警告**:未启用对应组件认证,官方说明可忽略。
|
- **启动报缺 `ETCD_USERNAME` / `KAFKA_USERNAME` 等警告**:未启用对应组件认证,官方说明可忽略。
|
||||||
- **openim-server 一直 unhealthy**:`docker exec -it openim-server mage check` 看哪项依赖没通;首次启动需等 30-60 秒。
|
- **openim-server 一直 unhealthy**:`docker exec -it openim-server mage check` 看哪项依赖没通;首次启动需等 30-60 秒。
|
||||||
- **livekit 启动报 `bind ... 7880/7881: address already in use`**:服务器上这些端口被别的程序占了(`ss -tlnp | grep 788` 查占用者)。不要动别人的服务,把 `.env` 里 `LIVEKIT_PORT` / `LIVEKIT_RTC_TCP_PORT` / `LIVEKIT_RTC_UDP_PORT` 改成空闲端口(如 17880/17881/17882),再 `docker compose up -d --force-recreate livekit`。之后客户端和自测脚本都用新端口(自测脚本会自动读 `.env`)。
|
- **livekit 启动报 `bind ... 7880/7881: address already in use`**:服务器上这些端口被别的程序占了(`ss -tlnp | grep 788` 查占用者)。不要动别人的服务,把端口统一改成空闲端口(如 17880/17881/17882)——注意**必须三处一起改**:`.env` 的 `LIVEKIT_PORT` / `LIVEKIT_RTC_TCP_PORT` / `LIVEKIT_RTC_UDP_PORT`、`config/livekit.yaml` 的 `port` / `tcp_port` / `udp_port`、`docker-compose.yaml` 里 livekit 的容器侧端口(保持“宿主侧=容器侧”),然后 `docker compose up -d --force-recreate livekit`。只改其中一两处会导致“能邀请、能接听,但接听后双方网络错误”。
|
||||||
- **livekit 日志报 `secret is too short`**:`LIVEKIT_API_SECRET` 必须 ≥32 字符,换个长密钥后重建 livekit 容器。
|
- **livekit 日志报 `secret is too short`**:`LIVEKIT_API_SECRET` 必须 ≥32 字符,换个长密钥后重建 livekit 容器。
|
||||||
- **语音通话连不上**:确认服务器 UDP 7882 放行(内网防火墙/安全组);UDP 不通时会走 TCP 7881 兜底。
|
- **语音通话连不上(接听后双方网络错误)**:先确认 `.env` / `config/livekit.yaml` / `docker-compose.yaml` 三处端口一致;再确认服务器防火墙放通 `LIVEKIT_PORT`(TCP)、`LIVEKIT_RTC_TCP_PORT`(TCP)、`LIVEKIT_RTC_UDP_PORT`(UDP)(默认 17880/17881/17882)。UDP 不通时会走 TCP 兜底。
|
||||||
|
|||||||
@@ -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/`,输入管理口令即可操作,无需写接口。
|
管理页:浏览器打开 `http://<服务器IP>:10010/`(公网反代场景为 `https://<域名>/account/`),输入管理口令即可操作,无需写接口。页面会按当前地址自动带上部署前缀请求 `/api/admin/...`,不要改成以 `/` 开头的绝对路径,否则公网会被 OpenIM 根路由接走。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -57,3 +57,9 @@ 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 };
|
||||||
+90
-11
@@ -87,15 +87,94 @@ $('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) {
|
||||||
const res = await fetch(path, {
|
let res;
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
const j = await res.json();
|
} catch (e) {
|
||||||
if (j.code !== 0) throw new Error(j.msg || '请求失败');
|
throw new Error(toUserMessage(e));
|
||||||
return j.data;
|
}
|
||||||
|
try {
|
||||||
|
return await readAdminJson(res);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(toUserMessage(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
@@ -115,7 +194,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(staffNo, enable) {
|
async function toggle(staffNo, enable) {
|
||||||
@@ -123,7 +202,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPwd(staffNo) {
|
async function resetPwd(staffNo) {
|
||||||
@@ -133,7 +212,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEmp() {
|
async function addEmp() {
|
||||||
@@ -142,7 +221,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport() {
|
async function doImport() {
|
||||||
@@ -151,7 +230,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接选择模板 CSV 文件导入,不用复制粘贴
|
// 直接选择模板 CSV 文件导入,不用复制粘贴
|
||||||
@@ -166,7 +245,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEmp(staffNo) {
|
async function deleteEmp(staffNo) {
|
||||||
@@ -175,7 +254,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(e.message, false); }
|
} catch (e) { show(toUserMessage(e), false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载 CSV 导入模板(加 BOM 让 Excel 正确识别中文)
|
// 下载 CSV 导入模板(加 BOM 让 Excel 正确识别中文)
|
||||||
|
|||||||
@@ -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('<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);
|
||||||
|
});
|
||||||
+10
-3
@@ -1,10 +1,17 @@
|
|||||||
# LiveKit 服务端配置(内网部署)
|
# LiveKit 服务端配置(内网部署)
|
||||||
# API Key/Secret 通过环境变量 LIVEKIT_KEYS 注入(见 docker-compose.yaml)
|
# API Key/Secret 通过环境变量 LIVEKIT_KEYS 注入(见 docker-compose.yaml)
|
||||||
# 服务器对内 IP 通过启动参数 --node-ip 注入(deploy.sh 写入 .env 的 SERVER_IP)
|
# 服务器对内 IP 通过启动参数 --node-ip 注入(deploy.sh 写入 .env 的 SERVER_IP)
|
||||||
port: 7880
|
#
|
||||||
|
# 重要:这里的 port / tcp_port / udp_port 必须与 docker-compose.yaml 里
|
||||||
|
# 容器侧端口、以及 .env 的 LIVEKIT_PORT / LIVEKIT_RTC_TCP_PORT /
|
||||||
|
# LIVEKIT_RTC_UDP_PORT 三者完全一致(默认 17880/17881/17882)。
|
||||||
|
# LiveKit 用 --node-ip + use_external_ip:false 向客户端宣告媒体地址时,
|
||||||
|
# 只会宣告这里的端口,不会感知 Docker 宿主侧的重映射;三者不一致会导致
|
||||||
|
# “能邀请、能接听,但接听后双方网络错误”(信令通、媒体不通)。
|
||||||
|
port: 17880
|
||||||
rtc:
|
rtc:
|
||||||
tcp_port: 7881
|
tcp_port: 17881
|
||||||
udp_port: 7882
|
udp_port: 17882
|
||||||
# 内网部署:不做公网 IP 探测,candidate 直接使用 --node-ip
|
# 内网部署:不做公网 IP 探测,candidate 直接使用 --node-ip
|
||||||
use_external_ip: false
|
use_external_ip: false
|
||||||
room:
|
room:
|
||||||
|
|||||||
+6
-4
@@ -370,7 +370,9 @@ services:
|
|||||||
|
|
||||||
# LiveKit:语音/视频通话的 SFU,Apache 2.0,自部署。
|
# LiveKit:语音/视频通话的 SFU,Apache 2.0,自部署。
|
||||||
# 客户端通过 ws://<SERVER_IP>:<LIVEKIT_PORT> 连接,凭 LIVEKIT_API_KEY/SECRET 签发的 token 进房。
|
# 客户端通过 ws://<SERVER_IP>:<LIVEKIT_PORT> 连接,凭 LIVEKIT_API_KEY/SECRET 签发的 token 进房。
|
||||||
# 三个宿主端口都可在 .env 调整(7880/7881/7882 被其他服务占用时改映射即可,容器内端口不变)。
|
# 三个端口必须与 config/livekit.yaml 里容器监听的端口完全一致(默认 17880/17881/17882),
|
||||||
|
# 即“宿主侧 = 容器侧”,不要在宿主侧做重映射;LiveKit 向客户端宣告媒体地址时只认
|
||||||
|
# config/livekit.yaml 里的端口,重映射会导致信令通、媒体不通。
|
||||||
livekit:
|
livekit:
|
||||||
image: ${LIVEKIT_IMAGE}
|
image: ${LIVEKIT_IMAGE}
|
||||||
container_name: livekit
|
container_name: livekit
|
||||||
@@ -379,9 +381,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||||
ports:
|
ports:
|
||||||
- "${LIVEKIT_PORT:-7880}:7880" # HTTP / WebSocket 信令
|
- "${LIVEKIT_PORT:-17880}:17880" # HTTP / WebSocket 信令
|
||||||
- "${LIVEKIT_RTC_TCP_PORT:-7881}:7881" # RTC over TCP(UDP 不通时的备用通道)
|
- "${LIVEKIT_RTC_TCP_PORT:-17881}:17881" # RTC over TCP(UDP 不通时的备用通道)
|
||||||
- "${LIVEKIT_RTC_UDP_PORT:-7882}:7882/udp" # RTC 媒体(UDP 复用端口)
|
- "${LIVEKIT_RTC_UDP_PORT:-17882}:17882/udp" # RTC 媒体(UDP 复用端口)
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/livekit.yaml:/etc/livekit.yaml
|
- ./config/livekit.yaml:/etc/livekit.yaml
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 畅联项目手册
|
||||||
|
|
||||||
|
这是项目的交接首页。新接手的人先按下面顺序阅读,不要只凭旧文档或聊天记录判断项目状态。
|
||||||
|
|
||||||
|
1. 先读 [项目需求](项目需求.md):弄清楚项目要做什么、不能碰什么。
|
||||||
|
2. 再读 [最新进度](最新进度.md):确认代码已经做到哪里、哪些结论已经被验证。
|
||||||
|
3. 最后读 [任务清单](任务清单.md):从“正在做”和“还没安排”里选择下一件事。
|
||||||
|
|
||||||
|
## 仓库里有什么
|
||||||
|
|
||||||
|
- `mobile/`:手机端,使用 Flutter,含 Android 和 iPhone 的工程文件。
|
||||||
|
- `pc-client/`:电脑端,使用 Electron,Windows 是当前优先支持的平台。
|
||||||
|
- `account-service/`:公司账号服务,负责工号密码登录、管理员导入和管理名单。
|
||||||
|
- `docker-compose.yaml`、`config/`、`scripts/`:OpenIM、语音服务和内网环境的配置及维护脚本。
|
||||||
|
- `docs/`:本手册和以前留下的验收、回归、使用说明。
|
||||||
|
|
||||||
|
## 更新规矩
|
||||||
|
|
||||||
|
以后每完成、新增、取消或发现一个任务,负责这件事的智能体必须在同一次提交中顺手更新本目录:
|
||||||
|
|
||||||
|
- 改需求或边界时,更新 `项目需求.md`。
|
||||||
|
- 做完功能、修复问题、完成构建或验证时,更新 `最新进度.md`。
|
||||||
|
- 任务状态变化时,更新 `任务清单.md` 的三栏,并写明依据。
|
||||||
|
- 旧文档不能删除;如果它和当前事实不一致,在旧文档开头加“已过时,仅留档备查”提示,再在本目录写新版。
|
||||||
|
|
||||||
|
手册只记录已经确认的事实。拿不准的内容要写成“待确认”,不要当成已完成。
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> ⚠️ 本文档已过时,仅留档备查,请勿删除。当前请先看 `docs/README.md`、`docs/项目需求.md`、`docs/最新进度.md` 和 `docs/任务清单.md`。
|
||||||
|
|
||||||
# 畅联使用说明(一页纸)
|
# 畅联使用说明(一页纸)
|
||||||
|
|
||||||
## 这是什么
|
## 这是什么
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> ⚠️ 本文档已过时,仅留档备查,请勿删除。当前请先看 `docs/README.md`、`docs/项目需求.md`、`docs/最新进度.md` 和 `docs/任务清单.md`。
|
||||||
|
|
||||||
# 第4步 · 试用前部署与验收清单(B-61)
|
# 第4步 · 试用前部署与验收清单(B-61)
|
||||||
|
|
||||||
> 本文件记录"50 人试用"前必须在服务器上手动完成的事项、验收清单与手机正式签名流程。
|
> 本文件记录"50 人试用"前必须在服务器上手动完成的事项、验收清单与手机正式签名流程。
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> ⚠️ 本文档已过时,仅留档备查,请勿删除。当前请先看 `docs/README.md`、`docs/项目需求.md`、`docs/最新进度.md` 和 `docs/任务清单.md`。
|
||||||
|
|
||||||
# 阶段 4 跨端回归记录(2026-08-16)
|
# 阶段 4 跨端回归记录(2026-08-16)
|
||||||
|
|
||||||
> 基线:`main` @ `a007e8f`(含 B-52 三项修复:发消息根因 `d6ef6eb`、同事页/五态 `b50ee24`+`c73e19c`、PC 通讯录入口 `032882d`,以及本轮版本号提升 `7eab7d5`、AUTH_HOST 联调开关 `a007e8f`)。
|
> 基线:`main` @ `a007e8f`(含 B-52 三项修复:发消息根因 `d6ef6eb`、同事页/五态 `b50ee24`+`c73e19c`、PC 通讯录入口 `032882d`,以及本轮版本号提升 `7eab7d5`、AUTH_HOST 联调开关 `a007e8f`)。
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 任务清单
|
||||||
|
|
||||||
|
更新时间:2026-08-31。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。
|
||||||
|
|
||||||
|
## 正在做
|
||||||
|
|
||||||
|
HEL-294 员工管理页接口路径与英文报错热修:代码和本地自测已完成,等待部署到香港测试环境后由管理员在公网 `/account/` 页面验收。
|
||||||
|
|
||||||
|
## 已做完
|
||||||
|
|
||||||
|
- 建立手机端 Flutter 工程和 Windows 优先的 Electron 电脑端工程。
|
||||||
|
- 接入公司工号加密码登录,以及管理员导入、管理员工账号的账号服务。
|
||||||
|
- 完成聊天、通讯录、同事申请、语音消息、文件/图片消息和一对一语音通话的相关代码接入。
|
||||||
|
- 完成一轮手机与电脑的跨端回归记录;其中已通过的项目见旧档 `phase4-regression-2026-08-16.md`。
|
||||||
|
- 将 LiveKit 的信令/媒体端口统一为 `17880`、`17881`、`17882`,并提供可重复执行的修复脚本。
|
||||||
|
- 建立本目录四份交接手册,并为三份过时旧说明加上明确的留档提示。
|
||||||
|
- HEL-294:管理页按部署前缀请求账号接口,并给 401/404/500/空响应/非 JSON 提供中文兜底(代码已合入分支,公网部署前现网仍会报英文错)。
|
||||||
|
|
||||||
|
## 还没安排
|
||||||
|
|
||||||
|
- 组织真实手机和 Windows 电脑的回归:重点确认文件传输和一对一语音通话。
|
||||||
|
- 明确手机正式签名的负责人和安全保管方式;不能把签名密钥提交进仓库。
|
||||||
|
- 获得负责人同意后,再导入真实员工名单并验证账号管理流程。
|
||||||
|
- 等负责人对三个公网问题拍板后,才安排公网 IP/域名、备案、反向代理和 HTTPS 的具体施工;当前禁止擅自开始。
|
||||||
|
- 公网方案获批后,重新评估电脑端 Electron、登录失败限制、令牌本地保存和跨域设置的安全风险。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 最新进度
|
||||||
|
|
||||||
|
更新时间:2026-08-31。以下内容依据当前仓库代码整理。
|
||||||
|
|
||||||
|
## 现在做到哪里
|
||||||
|
|
||||||
|
项目已经具备可继续测试的手机端、Windows 电脑端、账号服务和内网部署配置:
|
||||||
|
|
||||||
|
- 手机端在 `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` 用于服务器已部署环境的端口修复。
|
||||||
|
|
||||||
|
## 已有验证记录
|
||||||
|
|
||||||
|
- `docs/phase4-regression-2026-08-16.md` 记录过手机与电脑之间的文字、同事申请、图片、语音消息等跨端回归结果。
|
||||||
|
- 该记录中的文件传输和一对一语音通话,曾因模拟器环境无法闭环,仍需要用真实设备再次确认。
|
||||||
|
- 旧记录使用的是更早的提交、安装包版本和端口状态;因此已保留为档案并在开头标明过时,不能当作当前验收结论。
|
||||||
|
|
||||||
|
## 当前停在哪
|
||||||
|
|
||||||
|
公网接入施工暂停,等待负责人对三个公网相关问题拍板后再恢复。现阶段没有把内网服务发布到公网的授权。
|
||||||
|
|
||||||
|
这意味着:代码可继续维护和内网验证,但公网 IP/域名、备案、反向代理和 HTTPS 等工作不能擅自启动。
|
||||||
|
|
||||||
|
## 已知风险和待验证事实
|
||||||
|
|
||||||
|
- 真机上的文件传输和一对一语音通话仍需实际设备验证,不能只根据模拟器结果宣称完成。
|
||||||
|
- 手机正式签名、真实员工名单导入和试用前的管理员安全设置,都需要负责人明确确认并由有权限的人执行。
|
||||||
|
- 电脑端使用较旧的 Electron,且历史记录提到内网场景的安全取舍;若未来获准公网接入,必须先重新评估 HTTPS、浏览器安全设置、登录限流和跨域策略。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 项目需求
|
||||||
|
|
||||||
|
## 项目是什么
|
||||||
|
|
||||||
|
“畅联”是集团内部使用的通讯 App,也就是给员工聊天、找同事和语音通话的工具。项目以开源 OpenIM 为基础改造,保留 OpenIM 原有标识,不换品牌、不另起一套产品标识。
|
||||||
|
|
||||||
|
## 要解决什么
|
||||||
|
|
||||||
|
员工需要在公司内部使用同一个账号,在手机和电脑上沟通,不依赖个人社交软件。管理员可以把员工名单一次导入,员工用工号和密码登录。
|
||||||
|
|
||||||
|
## 必须具备的功能
|
||||||
|
|
||||||
|
1. 聊天:能收发工作消息;当前代码还包含图片、文件和语音消息的相关能力。
|
||||||
|
2. 通讯录:能查找同事、添加同事和管理联系人。
|
||||||
|
3. 一对一语音通话:通过 LiveKit(负责实时语音连接的服务)建立通话。
|
||||||
|
4. 账号登录:员工用“工号 + 密码”登录,不能用个人注册替代。
|
||||||
|
5. 管理员导入:管理员能导入员工名单并管理账号。
|
||||||
|
|
||||||
|
## 支持范围
|
||||||
|
|
||||||
|
- 手机端:Android 和 iPhone 工程都在仓库中;当前手机端版本号为 `1.0.6+7`。
|
||||||
|
- 电脑端:Windows 优先;当前电脑端显示版本为 `v1.0.2`。
|
||||||
|
- 服务端:以 Docker Compose(把一组服务一起启动的工具)编排 OpenIM、账号服务和语音服务。
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
- 只做集团内部通讯,不改造成公开社交产品。
|
||||||
|
- 保留 OpenIM 的现有标识,禁止替换品牌。
|
||||||
|
- 本项目当前只记录和维护内网环境;公网接入尚未获准施工。
|
||||||
|
- 不把员工名单、密码、管理员口令、密钥或服务器私密配置写入仓库和文档。
|
||||||
|
- 本仓库的日常任务如无明确授权,不做发布、部署或改动生产环境。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 修复语音通话“门号不一致”(信令通、媒体不通)问题。
|
||||||
|
# 根因:LiveKit 的 config/livekit.yaml 里媒体端口(7881/7882)与宿主实际映射(17881/17882)不一致,
|
||||||
|
# 导致 LiveKit 向客户端宣告的媒体地址连不上。
|
||||||
|
# 修复:统一 .env / config/livekit.yaml / docker-compose.yaml 三处端口为 17880/17881/17882,
|
||||||
|
# 只重启 livekit 容器。
|
||||||
|
#
|
||||||
|
# 用法(在服务器仓库目录里执行,且已 git pull 到含本修复的版本):
|
||||||
|
# ./scripts/fix-livekit-ports.sh
|
||||||
|
#
|
||||||
|
# 该脚本幂等,可重复执行;会先备份再改动,失败可回退。
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BK="backup-livekit-ports-$TS"
|
||||||
|
mkdir -p "$BK"
|
||||||
|
echo "==> 备份当前配置到 $BK/"
|
||||||
|
for f in .env config/livekit.yaml docker-compose.yaml; do
|
||||||
|
[ -f "$f" ] && cp -p "$f" "$BK/$(basename "$f")"
|
||||||
|
done
|
||||||
|
[ -f "$BK/.env" ] || echo "(未找到 .env,跳过备份)"
|
||||||
|
|
||||||
|
# 1. 校验仓库文件已含修复(config/livekit.yaml 端口应为 17880/17881/17882)
|
||||||
|
grep -q '^port: 17880' config/livekit.yaml || { echo "✗ config/livekit.yaml 未包含 port: 17880,请先 git pull 更新仓库后再执行本脚本"; exit 1; }
|
||||||
|
grep -q 'tcp_port: 17881' config/livekit.yaml || { echo "✗ config/livekit.yaml 未包含 tcp_port: 17881"; exit 1; }
|
||||||
|
grep -q 'udp_port: 17882' config/livekit.yaml || { echo "✗ config/livekit.yaml 未包含 udp_port: 17882"; exit 1; }
|
||||||
|
|
||||||
|
# 2. 对齐 .env 三个端口
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "==> 未找到 .env,已从 .env.example 生成"
|
||||||
|
fi
|
||||||
|
sed -i 's|^LIVEKIT_PORT=.*|LIVEKIT_PORT=17880|' .env
|
||||||
|
sed -i 's|^LIVEKIT_RTC_TCP_PORT=.*|LIVEKIT_RTC_TCP_PORT=17881|' .env
|
||||||
|
sed -i 's|^LIVEKIT_RTC_UDP_PORT=.*|LIVEKIT_RTC_UDP_PORT=17882|' .env
|
||||||
|
# 若这三行原本不存在,则追加
|
||||||
|
grep -q '^LIVEKIT_PORT=' .env || echo 'LIVEKIT_PORT=17880' >> .env
|
||||||
|
grep -q '^LIVEKIT_RTC_TCP_PORT=' .env || echo 'LIVEKIT_RTC_TCP_PORT=17881' >> .env
|
||||||
|
grep -q '^LIVEKIT_RTC_UDP_PORT=' .env || echo 'LIVEKIT_RTC_UDP_PORT=17882' >> .env
|
||||||
|
echo "==> .env 三个端口已设为 17880/17881/17882"
|
||||||
|
|
||||||
|
# 3. 只重建 livekit 容器(不动其它服务)
|
||||||
|
docker compose up -d --force-recreate livekit
|
||||||
|
|
||||||
|
# 4. 校验
|
||||||
|
echo "==> 等待 livekit 就绪..."
|
||||||
|
sleep 3
|
||||||
|
docker compose ps livekit
|
||||||
|
echo
|
||||||
|
echo "==> 请确认防火墙放通 17880/TCP、17881/TCP、17882/UDP(默认已在本机监听):"
|
||||||
|
ss -tlnp 2>/dev/null | grep -E ':(17880|17881)' || true
|
||||||
|
ss -ulnp 2>/dev/null | grep ':17882' || true
|
||||||
|
echo
|
||||||
|
echo "==> 完成。回退方法(用刚才的备份恢复旧配置):"
|
||||||
|
echo " cp $BK/livekit.yaml config/livekit.yaml 2>/dev/null"
|
||||||
|
echo " cp $BK/docker-compose.yaml docker-compose.yaml 2>/dev/null"
|
||||||
|
echo " cp $BK/.env .env 2>/dev/null"
|
||||||
|
echo " docker compose up -d --force-recreate livekit"
|
||||||
+1
-1
@@ -18,7 +18,7 @@ MINIO_SK="$(env_get MINIO_SECRET_ACCESS_KEY)"; MINIO_SK="${MINIO_SK:-openIM123}"
|
|||||||
MINIO_PORT="$(env_get MINIO_PORT)"; MINIO_PORT="${MINIO_PORT:-10005}"
|
MINIO_PORT="$(env_get MINIO_PORT)"; MINIO_PORT="${MINIO_PORT:-10005}"
|
||||||
LK_KEY="$(env_get LIVEKIT_API_KEY)"
|
LK_KEY="$(env_get LIVEKIT_API_KEY)"
|
||||||
LK_SECRET="$(env_get LIVEKIT_API_SECRET)"
|
LK_SECRET="$(env_get LIVEKIT_API_SECRET)"
|
||||||
LK_PORT="$(env_get LIVEKIT_PORT)"; LK_PORT="${LK_PORT:-7880}"
|
LK_PORT="$(env_get LIVEKIT_PORT)"; LK_PORT="${LK_PORT:-17880}"
|
||||||
LK_HTTP="http://${IP}:${LK_PORT}"
|
LK_HTTP="http://${IP}:${LK_PORT}"
|
||||||
LK_WS="ws://${IP}:${LK_PORT}"
|
LK_WS="ws://${IP}:${LK_PORT}"
|
||||||
LK_CLI_IMAGE="livekit/livekit-cli:v2.18.2"
|
LK_CLI_IMAGE="livekit/livekit-cli:v2.18.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user