Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d393f4f1e | ||
|
|
a71f6a0ce1 | ||
|
|
f1fe4a3928 | ||
|
|
f35e1f03d5 |
@@ -84,3 +84,14 @@ ALERTMANAGER_PORT=19093
|
|||||||
GRAFANA_PORT=13000
|
GRAFANA_PORT=13000
|
||||||
NODE_EXPORTER_PORT=19100
|
NODE_EXPORTER_PORT=19100
|
||||||
GRAFANA_URL="http://127.0.0.1:13000/"
|
GRAFANA_URL="http://127.0.0.1:13000/"
|
||||||
|
|
||||||
|
# ===== 极光离线推送(OpenIM push)=====
|
||||||
|
# AppKey 可以进配置;Master Secret 禁止写在本文件、Git、日志或客户端。
|
||||||
|
# 公网部署时由 scripts/load-jpush-secret.sh 从
|
||||||
|
# /root/.config/jinxunda/jpush_master_secret 注入 JPUSH_MASTER_SECRET。
|
||||||
|
OPENIM_PUSH_ENABLE=
|
||||||
|
JPUSH_APPKEY=95fc2352648a7d4568ac8d72
|
||||||
|
JPUSH_PUSH_URL=https://api.jpush.cn/v3/push
|
||||||
|
# 必须加引号:值含分号。compose 读 .env 无妨;不要用 shell source/. 加载未加引号的副本。
|
||||||
|
JPUSH_PUSH_INTENT="intent:#Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;component=cn.solsum.jinxunda/cn.solsum.jinxunda.MainActivity;end"
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
/components/
|
/components/
|
||||||
.selftest/
|
.selftest/
|
||||||
data/
|
data/
|
||||||
|
**/agconnect-services.json
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,13 @@ docker compose up -d # 再启动
|
|||||||
|
|
||||||
## 客户端接入参数(自测通过后)
|
## 客户端接入参数(自测通过后)
|
||||||
|
|
||||||
- OpenIM API: `http://<服务器IP>:10002`
|
- OpenIM API: `http://<服务器IP>:10002`(公网测试见 `deploy/public/README.md`:`https://jxd.jinniu.ink`)
|
||||||
- OpenIM WebSocket: `ws://<服务器IP>:10001`
|
- OpenIM WebSocket: `ws://<服务器IP>:10001`(公网:`wss://jxd.jinniu.ink/msg_gateway`)
|
||||||
- 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`
|
- 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 进同一房间即可
|
||||||
|
|
||||||
|
公网测试部署(不改本机内网步骤):见 `deploy/public/README.md`。施工员不执行该部署。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
- **文件/语音能发但打不开**:`MINIO_EXTERNAL_ADDRESS` 没写成客户端能访问的 IP。改 `.env` 后 `docker compose up -d` 重建 openim-server。
|
- **文件/语音能发但打不开**:`MINIO_EXTERNAL_ADDRESS` 没写成客户端能访问的 IP。改 `.env` 后 `docker compose up -d` 重建 openim-server。
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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
@@ -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 正确识别中文)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# LiveKit 公网测试配置(jxd.jinniu.ink)
|
||||||
|
# 仅由 deploy/public/docker-compose.public.yaml 挂载;内网仍用 livekit.yaml。
|
||||||
|
#
|
||||||
|
# 媒体候选地址走公网探测;信令经 Nginx 443 反代,UDP/TCP 媒体端口仍需对公网开放。
|
||||||
|
port: 17880
|
||||||
|
rtc:
|
||||||
|
tcp_port: 17881
|
||||||
|
udp_port: 17882
|
||||||
|
use_external_ip: true
|
||||||
|
room:
|
||||||
|
departure_timeout: 300
|
||||||
|
empty_timeout: 300
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 复制到仓库根目录的 .env 后按机填写。禁止把 Master Secret 写进本文件或 Git。
|
||||||
|
# 公网测试域名已解析到测试机;客户端只认域名,不认 IP。
|
||||||
|
|
||||||
|
# 本机公网 IP(LiveKit 媒体候选用)。在服务器上用 `curl -4 -s ifconfig.me` 查看,不要写进客户端。
|
||||||
|
SERVER_IP=
|
||||||
|
|
||||||
|
MINIO_EXTERNAL_ADDRESS="https://jxd.jinniu.ink/files"
|
||||||
|
|
||||||
|
OPENIM_PUSH_ENABLE=jpush
|
||||||
|
JPUSH_APPKEY=95fc2352648a7d4568ac8d72
|
||||||
|
JPUSH_PUSH_URL=https://api.jpush.cn/v3/push
|
||||||
|
# 必须加引号:值含分号。compose 读 .env 无妨;不要用 shell source/. 加载未加引号的副本。
|
||||||
|
JPUSH_PUSH_INTENT="intent:#Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;component=cn.solsum.jinxunda/cn.solsum.jinxunda.MainActivity;end"
|
||||||
|
|
||||||
|
# Master Secret 由 scripts/load-jpush-secret.sh 从
|
||||||
|
# /root/.config/jinxunda/jpush_master_secret 注入为环境变量 JPUSH_MASTER_SECRET。
|
||||||
|
# JPUSH_MASTER_SECRET_FILE=/root/.config/jinxunda/jpush_master_secret
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 公网测试部署说明(jxd.jinniu.ink)
|
||||||
|
|
||||||
|
施工员本卡**不执行**本节命令、不 SSH 改测试机。由总工审核通过后再部署。
|
||||||
|
|
||||||
|
目标机:已解析到 `jxd.jinniu.ink` 的香港测试服务器。数据目录仍是仓库下的 `components/` 与 `data/`,回滚不删数据。
|
||||||
|
|
||||||
|
## 对外暴露与对内绑定
|
||||||
|
|
||||||
|
| 入口 | 协议 | 后端 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `https://jxd.jinniu.ink/` | HTTPS | OpenIM API `:10002` | 客户端 `apiAddr` |
|
||||||
|
| `wss://jxd.jinniu.ink/msg_gateway` | WSS | OpenIM 网关 `:10001` | 客户端 `wsAddr` |
|
||||||
|
| `https://jxd.jinniu.ink/account/` | HTTPS | 账号服务 `:10010` | 客户端 `authApiBase` |
|
||||||
|
| `https://jxd.jinniu.ink/files/` | HTTPS | MinIO `:10005` | `MINIO_EXTERNAL_ADDRESS` |
|
||||||
|
| `wss://jxd.jinniu.ink/livekit/` | WSS | LiveKit 信令 `:17880` | 客户端 `livekitUrl` |
|
||||||
|
| `:17881/tcp` `:17882/udp` | 媒体 | LiveKit RTC | 必须对公网开放,不能只走 443 |
|
||||||
|
|
||||||
|
内部端口(10001/10002/10005/10010/17880/etcd)在 overlay 里绑 `127.0.0.1`。防火墙建议只放行 80/tcp、443/tcp、17881/tcp、17882/udp。
|
||||||
|
|
||||||
|
## 证书
|
||||||
|
|
||||||
|
1. 先把 `deploy/public/nginx.bootstrap.conf` 挂到 edge(只听 80)。
|
||||||
|
2. `certbot certonly --webroot -w deploy/public/acme -d jxd.jinniu.ink`
|
||||||
|
3. 确认 `/etc/letsencrypt/live/jxd.jinniu.ink/` 有 `fullchain.pem` 与 `privkey.pem`。
|
||||||
|
4. 改回 `nginx.conf` 后重载 edge。
|
||||||
|
|
||||||
|
私钥不得复制进 Git。
|
||||||
|
|
||||||
|
## 极光 Master Secret
|
||||||
|
|
||||||
|
文件在测试机 `/root/.config/jinxunda/jpush_master_secret`(权限 600)。部署前:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./scripts/load-jpush-secret.sh # 只导出环境变量,不打印内容
|
||||||
|
```
|
||||||
|
|
||||||
|
不要 `cat` 该文件,不要写进 `.env` 或 compose 明文。
|
||||||
|
|
||||||
|
## 启动(总工)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/tongxunruanjian
|
||||||
|
cp .env.example .env # 若还没有
|
||||||
|
# 按 deploy/public/.env.public.example 写入 SERVER_IP、MINIO_EXTERNAL_ADDRESS、JPUSH_*(不含 Master Secret)
|
||||||
|
source ./scripts/load-jpush-secret.sh
|
||||||
|
./scripts/deploy-public.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`SERVER_IP` 填本机公网 IP(LiveKit 媒体候选),不要写进手机/电脑客户端代码。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/rollback-public.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
效果:停掉 nginx edge,按原来的 `docker-compose.yaml` 拉起;`components/` 与 `data/account` 不动。
|
||||||
|
|
||||||
|
## 客户端地址(已写进代码默认值)
|
||||||
|
|
||||||
|
- 手机:`mobile/lib/config.dart`,默认 `jxd.jinniu.ink` + TLS。内网回切:`--dart-define=SERVER_HOST=192.168.200.11 --dart-define=USE_TLS=false`
|
||||||
|
- 电脑:改 `pc-client/.env` 的 `VITE_*` 为 `https://` / `wss://` 路径,见该目录 `.env.example`
|
||||||
|
|
||||||
|
## 华为通道缺口
|
||||||
|
|
||||||
|
见 `mobile/android/app/README-agconnect.md`。缺 `agconnect-services.json` 时华为真机厂商通道不通,其余施工已完成。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 公网测试 overlay。用法(在仓库根目录,由总工执行,本卡不跑):
|
||||||
|
# ./scripts/load-jpush-secret.sh
|
||||||
|
# docker compose -f docker-compose.yaml -f deploy/public/docker-compose.public.yaml up -d
|
||||||
|
# 不要用 shell source/. 加载 .env:JPUSH_PUSH_INTENT 含分号,未加引号时会被拆成命令。
|
||||||
|
# compose 会自行读取仓库根目录 .env 做变量替换;Master Secret 只经 load-jpush-secret.sh 注入。
|
||||||
|
#
|
||||||
|
# 原则:内部端口只绑 127.0.0.1;对外只开 80/443 + LiveKit 媒体 17881/tcp、17882/udp。
|
||||||
|
# ports 使用 !override,避免与基础 compose 的 0.0.0.0 绑定叠加。
|
||||||
|
# 回滚:去掉本 overlay,改回 docker compose -f docker-compose.yaml up -d,数据目录 components/ 与 data/ 不动。
|
||||||
|
|
||||||
|
services:
|
||||||
|
openim-server:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${OPENIM_MSG_GATEWAY_PORT}:10001"
|
||||||
|
- "127.0.0.1:${OPENIM_API_PORT}:10002"
|
||||||
|
environment:
|
||||||
|
- IMENV_OPENIM_PUSH_ENABLE=${OPENIM_PUSH_ENABLE:-jpush}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_APPKEY=${JPUSH_APPKEY}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_MASTERSECRET=${JPUSH_MASTER_SECRET}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_PUSHURL=${JPUSH_PUSH_URL:-https://api.jpush.cn/v3/push}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_PUSHINTENT=${JPUSH_PUSH_INTENT}
|
||||||
|
|
||||||
|
minio:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${MINIO_PORT}:9000"
|
||||||
|
- "127.0.0.1:${MINIO_CONSOLE_PORT}:9090"
|
||||||
|
|
||||||
|
etcd:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:12379:2379"
|
||||||
|
- "127.0.0.1:12380:2380"
|
||||||
|
|
||||||
|
livekit:
|
||||||
|
command: --config /etc/livekit.yaml --node-ip ${SERVER_IP}
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${LIVEKIT_PORT:-17880}:17880"
|
||||||
|
- "${LIVEKIT_RTC_TCP_PORT:-17881}:17881"
|
||||||
|
- "${LIVEKIT_RTC_UDP_PORT:-17882}:17882/udp"
|
||||||
|
volumes:
|
||||||
|
- ./config/livekit.public.yaml:/etc/livekit.yaml
|
||||||
|
|
||||||
|
account-server:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${ACCOUNT_PORT:-10010}:10010"
|
||||||
|
|
||||||
|
edge:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: jxd-edge
|
||||||
|
restart: always
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./deploy/public/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./deploy/public/acme:/var/www/acme:ro
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
depends_on:
|
||||||
|
- openim-server
|
||||||
|
- account-server
|
||||||
|
- minio
|
||||||
|
- livekit
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 仅 80 端口:申请 Let's Encrypt 证书用。证书拿到后换成 nginx.conf。
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name jxd.jinniu.ink;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/acme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 200 'jxd.jinniu.ink bootstrap\n';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 公网测试:jxd.jinniu.ink 终止 TLS,反代到本机回环上的 OpenIM / 账号 / 对象存储 / LiveKit。
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream openim_api {
|
||||||
|
server 127.0.0.1:10002;
|
||||||
|
}
|
||||||
|
upstream openim_ws {
|
||||||
|
server 127.0.0.1:10001;
|
||||||
|
}
|
||||||
|
upstream account_api {
|
||||||
|
server 127.0.0.1:10010;
|
||||||
|
}
|
||||||
|
upstream minio_s3 {
|
||||||
|
server 127.0.0.1:10005;
|
||||||
|
}
|
||||||
|
upstream livekit_signal {
|
||||||
|
server 127.0.0.1:17880;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name jxd.jinniu.ink;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/acme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name jxd.jinniu.ink;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/jxd.jinniu.ink/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/jxd.jinniu.ink/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# OpenIM 消息长连接
|
||||||
|
location /msg_gateway {
|
||||||
|
proxy_pass http://openim_ws/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 公司账号服务(登录、管理页、rtc_token)
|
||||||
|
location /account/ {
|
||||||
|
proxy_pass http://account_api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# MinIO 对象(MINIO_EXTERNAL_ADDRESS=https://jxd.jinniu.ink/files)
|
||||||
|
location /files/ {
|
||||||
|
proxy_pass http://minio_s3/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# LiveKit 信令(客户端 wss://jxd.jinniu.ink/livekit → 容器 /rtc 等)
|
||||||
|
location /livekit/ {
|
||||||
|
proxy_pass http://livekit_signal/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OpenIM REST API(catch-all,须放最后)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://openim_api;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -358,6 +358,13 @@ services:
|
|||||||
- IMENV_LOG_ISSTDOUT=${LOG_IS_STDOUT}
|
- IMENV_LOG_ISSTDOUT=${LOG_IS_STDOUT}
|
||||||
- IMENV_LOG_REMAINLOGLEVEL=${LOG_LEVEL}
|
- IMENV_LOG_REMAINLOGLEVEL=${LOG_LEVEL}
|
||||||
- IMENV_OPENIM_API_PROMETHEUS_GRAFANAURL=${GRAFANA_URL}
|
- IMENV_OPENIM_API_PROMETHEUS_GRAFANAURL=${GRAFANA_URL}
|
||||||
|
# 离线推送(极光)。未启用时保持空,OpenIM 走 dummy pusher。
|
||||||
|
# Master Secret 只从环境变量注入,禁止写进仓库。
|
||||||
|
- IMENV_OPENIM_PUSH_ENABLE=${OPENIM_PUSH_ENABLE:-}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_APPKEY=${JPUSH_APPKEY:-}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_MASTERSECRET=${JPUSH_MASTER_SECRET:-}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_PUSHURL=${JPUSH_PUSH_URL:-https://api.jpush.cn/v3/push}
|
||||||
|
- IMENV_OPENIM_PUSH_JPUSH_PUSHINTENT=${JPUSH_PUSH_INTENT:-}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongo
|
- mongo
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- `pc-client/`:电脑端,使用 Electron,Windows 是当前优先支持的平台。
|
- `pc-client/`:电脑端,使用 Electron,Windows 是当前优先支持的平台。
|
||||||
- `account-service/`:公司账号服务,负责工号密码登录、管理员导入和管理名单。
|
- `account-service/`:公司账号服务,负责工号密码登录、管理员导入和管理名单。
|
||||||
- `docker-compose.yaml`、`config/`、`scripts/`:OpenIM、语音服务和内网环境的配置及维护脚本。
|
- `docker-compose.yaml`、`config/`、`scripts/`:OpenIM、语音服务和内网环境的配置及维护脚本。
|
||||||
|
- `deploy/public/`:香港测试机 `jxd.jinniu.ink` 的公网反代、证书和回滚说明(由总工部署)。
|
||||||
- `docs/`:本手册和以前留下的验收、回归、使用说明。
|
- `docs/`:本手册和以前留下的验收、回归、使用说明。
|
||||||
|
|
||||||
## 更新规矩
|
## 更新规矩
|
||||||
|
|||||||
+10
-6
@@ -1,10 +1,10 @@
|
|||||||
# 任务清单
|
# 任务清单
|
||||||
|
|
||||||
更新时间:2026-08-31。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。
|
更新时间:2026-08-30。任务状态以当前仓库和项目约束为准;没有证据的事项不写成“已完成”。
|
||||||
|
|
||||||
## 正在做
|
## 正在做
|
||||||
|
|
||||||
HEL-294 员工管理页接口路径与英文报错热修:代码和本地自测已完成,等待部署到香港测试环境后由管理员在公网 `/account/` 页面验收。
|
无。华为配置注入与 Gradle 识别已提交,等待审核。
|
||||||
|
|
||||||
## 已做完
|
## 已做完
|
||||||
|
|
||||||
@@ -14,12 +14,16 @@ 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 提供中文兜底(代码已合入分支,公网部署前现网仍会报英文错)。
|
- 手机端应用标识统一为 `cn.solsum.jinxunda`。
|
||||||
|
- 安卓极光 SDK 初始化、通知权限、alias 回传、点击处理,以及 OpenIM 离线推送所需的服务端配置入口(Master Secret 从服务器文件注入)。
|
||||||
|
- 公网反向代理 / HTTPS / WSS / 防火墙与回滚说明(配置进仓库;香港测试环境已由总工部署)。
|
||||||
|
- 公网 overlay 用 `!override` 覆盖内部端口绑定;部署脚本校验 `SERVER_IP` 非空。
|
||||||
|
- 构建机本地放入华为 `agconnect-services.json`(不入库);AGConnect 插件映射/版本已按 AGP 9 补齐,Gradle 能识别包名 `cn.solsum.jinxunda`。
|
||||||
|
|
||||||
## 还没安排
|
## 还没安排
|
||||||
|
|
||||||
- 组织真实手机和 Windows 电脑的回归:重点确认文件传输和一对一语音通话。
|
- 在可编译 LiveKit 的环境打出安卓包,并用真机验证华为厂商通道到达。
|
||||||
|
- 组织真实手机和 Windows 电脑的回归:重点确认文件传输、一对一语音通话和杀进程后的离线通知。
|
||||||
- 明确手机正式签名的负责人和安全保管方式;不能把签名密钥提交进仓库。
|
- 明确手机正式签名的负责人和安全保管方式;不能把签名密钥提交进仓库。
|
||||||
- 获得负责人同意后,再导入真实员工名单并验证账号管理流程。
|
- 获得负责人同意后,再导入真实员工名单并验证账号管理流程。
|
||||||
- 等负责人对三个公网问题拍板后,才安排公网 IP/域名、备案、反向代理和 HTTPS 的具体施工;当前禁止擅自开始。
|
- 不接入小米、OPPO、vivo、荣耀、魅族、FCM 或苹果推送(本阶段明确不做)。
|
||||||
- 公网方案获批后,重新评估电脑端 Electron、登录失败限制、令牌本地保存和跨域设置的安全风险。
|
|
||||||
|
|||||||
+17
-14
@@ -1,32 +1,35 @@
|
|||||||
# 最新进度
|
# 最新进度
|
||||||
|
|
||||||
更新时间:2026-08-31。以下内容依据当前仓库代码整理。
|
更新时间:2026-08-30。以下内容依据当前仓库代码整理。
|
||||||
|
|
||||||
## 现在做到哪里
|
## 现在做到哪里
|
||||||
|
|
||||||
项目已经具备可继续测试的手机端、Windows 电脑端、账号服务和内网部署配置:
|
项目已经具备可继续测试的手机端、Windows 电脑端、账号服务,香港测试环境已由总工部署;本轮补齐了安卓华为厂商通道的客户端构建配置:
|
||||||
|
|
||||||
- 手机端在 `mobile/`,使用 OpenIM Flutter SDK `3.8.3+hotfix.12`,版本 `1.0.6+7`。
|
- 手机端在 `mobile/`,使用 OpenIM Flutter SDK `3.8.3+hotfix.12`,版本 `1.0.7+8`。
|
||||||
- 电脑端在 `pc-client/`,使用 Electron,显示版本 `v1.0.2`。
|
- 安卓/苹果应用标识为 `cn.solsum.jinxunda`。
|
||||||
- 员工用工号和密码通过 `account-service/` 登录;管理员导入和管理账号的页面、接口已在仓库中。
|
- 手机端已接入极光官方 Flutter 插件(AppKey 在客户端配置),登录后把 OpenIM userID 设为极光 alias。
|
||||||
- HEL-294:管理页接口改为适配 `/account/` 部署前缀;非 JSON/空响应改为中文提示,不再把浏览器英文异常直接展示。热修代码已提交,尚未部署到公网。
|
- 华为 AGConnect 插件在放入本地 `agconnect-services.json` 后启用(agcp `1.9.5.301`,适配 AGP 9);该文件含密钥,仓库继续忽略,不入库。
|
||||||
- 手机和电脑端已接入聊天、通讯录、同事申请、语音消息、文件/图片消息和一对一语音通话相关代码。
|
- 手机默认服务地址为 `jxd.jinniu.ink` 的 HTTPS/WSS;内网可用 `--dart-define=SERVER_HOST=... --dart-define=USE_TLS=false` 覆盖。
|
||||||
- 语音服务 LiveKit 的信令和媒体端口已统一为 `17880`、`17881`、`17882`;仓库提供 `scripts/fix-livekit-ports.sh` 用于服务器已部署环境的端口修复。
|
- 公网部署 overlay、Nginx、证书步骤、Master Secret 注入脚本和回滚脚本在 `deploy/public/` 与 `scripts/deploy-public.sh`;由总工部署,施工员不改测试机。
|
||||||
|
- overlay 的内部端口用 Compose `!override` 覆盖基础 `0.0.0.0` 绑定,只留 `127.0.0.1`;LiveKit `17881/tcp`、`17882/udp` 仍对公网开放。部署脚本会拒绝空 `SERVER_IP`。
|
||||||
|
|
||||||
## 已有验证记录
|
## 已有验证记录
|
||||||
|
|
||||||
- `docs/phase4-regression-2026-08-16.md` 记录过手机与电脑之间的文字、同事申请、图片、语音消息等跨端回归结果。
|
- `docs/phase4-regression-2026-08-16.md` 记录过手机与电脑之间的文字、同事申请、图片、语音消息等跨端回归结果。
|
||||||
- 该记录中的文件传输和一对一语音通话,曾因模拟器环境无法闭环,仍需要用真实设备再次确认。
|
- 该记录中的文件传输和一对一语音通话,曾因模拟器环境无法闭环,仍需要用真实设备再次确认。
|
||||||
- 旧记录使用的是更早的提交、安装包版本和端口状态;因此已保留为档案并在开头标明过时,不能当作当前验收结论。
|
- 旧记录使用的是更早的提交、安装包版本和端口状态;因此已保留为档案并在开头标明过时,不能当作当前验收结论。
|
||||||
|
- 放入与 `cn.solsum.jinxunda` 匹配的真实华为配置后,Gradle `:app:processDebugAGCPlugin` 与 `:app:processReleaseAGCPlugin` 通过;debug/release/profile 均识别该配置文件。未因配置缺失或包名不一致失败。
|
||||||
|
- `flutter pub get` 已写入 `jpush_flutter 3.5.6` / `jpush_flutter_android 1.0.2` 到 `mobile/pubspec.lock`。
|
||||||
|
- `flutter analyze` 仅有 2 条既有 OpenIM `group` 弃用 info;`flutter test` 中 `config_test` 通过。
|
||||||
|
- 整包 `flutter build apk` 未出包:锁定的 `livekit_client 2.5.0+hotfix.3` 与 Flutter 3.47 / Dart 3.13 的空安全检查不兼容。此项不在华为配置本卡范围,未改 LiveKit 版本。
|
||||||
|
|
||||||
## 当前停在哪
|
## 当前停在哪
|
||||||
|
|
||||||
公网接入施工暂停,等待负责人对三个公网相关问题拍板后再恢复。现阶段没有把内网服务发布到公网的授权。
|
华为配置已在构建机本地注入并被 Gradle 识别。整包 APK 与真机华为通道到达,仍待后续在可编译 LiveKit 的环境或真机上完成。本轮未改测试机、未回退公网端口与部署安全修复。
|
||||||
|
|
||||||
这意味着:代码可继续维护和内网验证,但公网 IP/域名、备案、反向代理和 HTTPS 等工作不能擅自启动。
|
|
||||||
|
|
||||||
## 已知风险和待验证事实
|
## 已知风险和待验证事实
|
||||||
|
|
||||||
- 真机上的文件传输和一对一语音通话仍需实际设备验证,不能只根据模拟器结果宣称完成。
|
- 真机上的文件传输、一对一语音通话、杀进程后的离线推送(尤其华为通道)仍需实际设备验证。
|
||||||
- 手机正式签名、真实员工名单导入和试用前的管理员安全设置,都需要负责人明确确认并由有权限的人执行。
|
- 整包 APK 受 `livekit_client 2.5.0+hotfix.3` 与当前 Flutter 空安全检查限制,需另卡决定是否升级 LiveKit。
|
||||||
- 电脑端使用较旧的 Electron,且历史记录提到内网场景的安全取舍;若未来获准公网接入,必须先重新评估 HTTPS、浏览器安全设置、登录限流和跨域策略。
|
- 电脑端使用较旧的 Electron;公网接入后仍需评估 HTTPS、登录限流和跨域策略。
|
||||||
|
|||||||
+4
-4
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
## 支持范围
|
## 支持范围
|
||||||
|
|
||||||
- 手机端:Android 和 iPhone 工程都在仓库中;当前手机端版本号为 `1.0.6+7`。
|
- 手机端:Android 和 iPhone 工程都在仓库中;当前手机端版本号为 `1.0.7+8`。应用标识 `cn.solsum.jinxunda`。
|
||||||
- 电脑端:Windows 优先;当前电脑端显示版本为 `v1.0.2`。
|
- 电脑端:Windows 优先;当前电脑端显示版本为 `v1.0.2`。
|
||||||
- 服务端:以 Docker Compose(把一组服务一起启动的工具)编排 OpenIM、账号服务和语音服务。
|
- 服务端:以 Docker Compose(把一组服务一起启动的工具)编排 OpenIM、账号服务和语音服务。公网测试域名为 `jxd.jinniu.ink`(HTTPS/WSS),部署配置在 `deploy/public/`,实际部署由总工执行。
|
||||||
|
|
||||||
## 当前边界
|
## 当前边界
|
||||||
|
|
||||||
- 只做集团内部通讯,不改造成公开社交产品。
|
- 只做集团内部通讯,不改造成公开社交产品。
|
||||||
- 保留 OpenIM 的现有标识,禁止替换品牌。
|
- 保留 OpenIM 的现有标识,禁止替换品牌。
|
||||||
- 本项目当前只记录和维护内网环境;公网接入尚未获准施工。
|
- 公网第一阶段:安卓极光 + 华为厂商通道(`agconnect-services.json` 只放构建机、不入库,见 `mobile/android/app/README-agconnect.md`);不接小米/OPPO/vivo/荣耀/魅族/FCM/苹果推送。
|
||||||
- 不把员工名单、密码、管理员口令、密钥或服务器私密配置写入仓库和文档。
|
- 不把员工名单、密码、管理员口令、极光 Master Secret、华为 Secret、私钥或服务器私密配置写入仓库和文档。
|
||||||
- 本仓库的日常任务如无明确授权,不做发布、部署或改动生产环境。
|
- 本仓库的日常任务如无明确授权,不做发布、部署或改动生产环境。
|
||||||
@@ -44,6 +44,9 @@ app.*.map.json
|
|||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# 华为 AppGallery Connect 客户端配置(含 client_secret,禁止入库)
|
||||||
|
/android/app/agconnect-services.json
|
||||||
|
|
||||||
# 本机路径与构建缓存(不入库)
|
# 本机路径与构建缓存(不入库)
|
||||||
**/android/local.properties
|
**/android/local.properties
|
||||||
**/android/.gradle/
|
**/android/.gradle/
|
||||||
|
|||||||
+20
-8
@@ -16,20 +16,32 @@ iOS 需要一台 Mac 搭配 Xcode 与开发者证书,另按苹果流程打包
|
|||||||
|
|
||||||
已声明的平台权限(勿删):
|
已声明的平台权限(勿删):
|
||||||
|
|
||||||
- `android/app/src/main/AndroidManifest.xml`:`INTERNET`、`RECORD_AUDIO`(语音消息/通话)、`MODIFY_AUDIO_SETTINGS`、存储/相册读取(发文件、发图片);并开启了 `usesCleartextTraffic`(内网服务器暂用 http/ws 明文,开通外网改 HTTPS 后应移除)。
|
- `android/app/src/main/AndroidManifest.xml`:`INTERNET`、`RECORD_AUDIO`(语音消息/通话)、`MODIFY_AUDIO_SETTINGS`、存储/相册读取(发文件、发图片)、`POST_NOTIFICATIONS`(极光离线通知)。公网默认 HTTPS;内网联调 `--dart-define=USE_TLS=false` 时仍允许明文,故暂保留 `usesCleartextTraffic`。
|
||||||
- `ios/Runner/Info.plist`:`NSMicrophoneUsageDescription`(语音消息与通话)、`NSPhotoLibraryUsageDescription`(发图片)、`NSAppTransportSecurity/NSAllowsArbitraryLoads`(同上,内网明文)。
|
- `ios/Runner/Info.plist`:`NSMicrophoneUsageDescription`(语音消息与通话)、`NSPhotoLibraryUsageDescription`(发图片)、`NSAppTransportSecurity/NSAllowsArbitraryLoads`(同上,内网明文)。
|
||||||
- `android/build.gradle.kts` / `settings.gradle.kts` 把阿里云 Maven 镜像放在最前(本网络访问 maven.google.com 不稳定)。
|
- `android/build.gradle.kts` / `settings.gradle.kts` 把阿里云 Maven 镜像放在最前(本网络访问 maven.google.com 不稳定),并增加华为 Maven(厂商通道)。
|
||||||
|
|
||||||
## 修改服务器地址
|
## 修改服务器地址
|
||||||
|
|
||||||
所有服务器地址集中在 `lib/config.dart`,改 `serverHost`(内网 IP/域名)和对应端口常量即可:
|
所有服务器地址集中在 `lib/config.dart`。默认公网:`jxd.jinniu.ink` + HTTPS/WSS。
|
||||||
|
|
||||||
| 常量 | 用途 | 默认值 |
|
内网联调不要改文件,打包时覆盖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build apk --release \
|
||||||
|
--dart-define=SERVER_HOST=192.168.200.11 \
|
||||||
|
--dart-define=USE_TLS=false
|
||||||
|
```
|
||||||
|
|
||||||
|
仅覆盖登录服务:`--dart-define=AUTH_HOST=127.0.0.1`
|
||||||
|
|
||||||
|
| 常量 | 用途 | 公网默认 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apiAddr` | OpenIM API | `http://192.168.200.11:10002` |
|
| `apiAddr` | OpenIM API | `https://jxd.jinniu.ink` |
|
||||||
| `wsAddr` | OpenIM WebSocket | `ws://192.168.200.11:10001` |
|
| `wsAddr` | OpenIM WebSocket | `wss://jxd.jinniu.ink/msg_gateway` |
|
||||||
| `livekitUrl` | LiveKit 语音通话 | `ws://192.168.200.11:17880`(注意已重映射,非默认 7880) |
|
| `livekitUrl` | LiveKit 语音通话 | `wss://jxd.jinniu.ink/livekit` |
|
||||||
| `authApiBase` | 公司账号登录后端 | `http://192.168.200.11:10010` |
|
| `authApiBase` | 公司账号登录后端 | `https://jxd.jinniu.ink/account` |
|
||||||
|
|
||||||
|
华为厂商通道需要本地放置 `android/app/agconnect-services.json`(与包名 `cn.solsum.jinxunda` 对应),取得方式见 `android/app/README-agconnect.md`。不要把该文件提交进 Git。
|
||||||
|
|
||||||
## 登录接口契约(后端:任务 B-60 account-service,已上线)
|
## 登录接口契约(后端:任务 B-60 account-service,已上线)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 华为厂商通道配置文件
|
||||||
|
|
||||||
|
华为通道客户端依赖已接入。构建机需要一份本地文件:
|
||||||
|
|
||||||
|
`mobile/android/app/agconnect-services.json`
|
||||||
|
|
||||||
|
## 从哪里取得
|
||||||
|
|
||||||
|
1. 打开华为 [AppGallery Connect](https://developer.huawei.com/consumer/cn/service/josp/agc/index.html)
|
||||||
|
2. 选择与包名 `cn.solsum.jinxunda` 对应的应用
|
||||||
|
3. 项目设置 → 应用 → 下载 `agconnect-services.json`
|
||||||
|
4. 把文件放到本目录(与 `build.gradle.kts` 同级)
|
||||||
|
5. **不要提交进 Git**(文件含华为 client_secret)。仓库已忽略该路径。
|
||||||
|
|
||||||
|
没有这份文件时:
|
||||||
|
|
||||||
|
- AGConnect 插件不会启用,安卓包仍可走极光自有通道构建
|
||||||
|
- **华为真机的厂商通道无法接通**,杀进程后的到达率会明显下降
|
||||||
|
|
||||||
|
放入真实配置后,`:app` 会启用 AGConnect(agcp `1.9.5.301`,适配 AGP 9)。Gradle 会按包名 `cn.solsum.jinxunda` 校验该文件;校验失败则构建失败。
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
maven("https://maven.aliyun.com/repository/google")
|
||||||
|
maven("https://maven.aliyun.com/repository/central")
|
||||||
|
maven("https://developer.huawei.com/repo/")
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
// agcp 1.9.x 用 apply() 时会检查 buildscript classpath 里有没有 AGP;
|
||||||
|
// 仅靠 plugins DSL 会报「com.android.tools.build:gradle is no set」。
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle:9.0.1")
|
||||||
|
classpath("com.huawei.agconnect:agcp:1.9.5.301")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 华为 agconnect-services.json 到位后才启用 AGConnect 插件,避免缺文件时整包编不过。
|
||||||
|
// 文件从华为 AppGallery Connect 下载,放到本目录,不要提交进 Git。
|
||||||
|
// jpush_flutter_android 只在自己的 library 模块 apply 该插件,不会处理本 app 的配置文件,
|
||||||
|
// 所以这里仍要在 :app 上 apply,并不是重复。
|
||||||
|
if (file("agconnect-services.json").exists()) {
|
||||||
|
apply(plugin = "com.huawei.agconnect")
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "cn.shengshentech.changlian"
|
namespace = "cn.solsum.jinxunda"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
@@ -15,14 +39,15 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
applicationId = "cn.solsum.jinxunda"
|
||||||
applicationId = "cn.shengshentech.changlian"
|
// 极光厂商通道插件要求 minSdk 24
|
||||||
// You can update the following values to match your application needs.
|
minSdk = 24
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
manifestPlaceholders["JPUSH_PKGNAME"] = "cn.solsum.jinxunda"
|
||||||
|
manifestPlaceholders["JPUSH_APPKEY"] = "95fc2352648a7d4568ac8d72"
|
||||||
|
manifestPlaceholders["JPUSH_CHANNEL"] = "developer-default"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 压缩打包 .so(默认未压缩存储,OpenIM/WebRTC 的 native 库让包多占近一倍体积);
|
// 压缩打包 .so(默认未压缩存储,OpenIM/WebRTC 的 native 库让包多占近一倍体积);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<!-- OpenIM 插件注册网络状态监听必须要这个权限;插件自身 manifest 漏了声明,
|
<!-- OpenIM 插件注册网络状态监听必须要这个权限;插件自身 manifest 漏了声明,
|
||||||
缺它 release 包会 SecurityException 导致插件注册失败、SDK 全部调用无响应 -->
|
缺它 release 包会 SecurityException 导致插件注册失败、SDK 全部调用无响应 -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<!-- Android 13+ 通知栏权限(极光离线推送) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<!-- 语音消息录音与语音通话 -->
|
<!-- 语音消息录音与语音通话 -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
@@ -33,6 +36,10 @@
|
|||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
|
<!-- 关闭 Flutter 自动深链接,避免厂商通道点击拉起白屏 -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutter_deeplinking_enabled"
|
||||||
|
android:value="false" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package cn.shengshentech.changlian
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package cn.solsum.jinxunda
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
// Flutter 3.29+ 默认把启动 Intent 当深链接解析。华为等厂商通道点击拉起时
|
||||||
|
// intent 带 n_extra,路由表没有对应页会白屏。本应用不用官方深链接。
|
||||||
|
override fun shouldHandleDeeplinking(): Boolean = false
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ allprojects {
|
|||||||
maven("https://maven.aliyun.com/repository/google")
|
maven("https://maven.aliyun.com/repository/google")
|
||||||
maven("https://maven.aliyun.com/repository/central")
|
maven("https://maven.aliyun.com/repository/central")
|
||||||
maven("https://maven.aliyun.com/repository/public")
|
maven("https://maven.aliyun.com/repository/public")
|
||||||
|
maven("https://developer.huawei.com/repo/")
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.0.1"
|
||||||
|
agcp = "1.9.5.301"
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
huawei-agconnect = { id = "com.huawei.agconnect", version.ref = "agcp" }
|
||||||
@@ -15,16 +15,29 @@ pluginManagement {
|
|||||||
maven("https://maven.aliyun.com/repository/google")
|
maven("https://maven.aliyun.com/repository/google")
|
||||||
maven("https://maven.aliyun.com/repository/central")
|
maven("https://maven.aliyun.com/repository/central")
|
||||||
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||||
|
maven("https://developer.huawei.com/repo/")
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 华为插件发布在 Maven 的 agcp 构件上,没有 Plugin Portal 的
|
||||||
|
// com.huawei.agconnect.gradle.plugin 坐标。plugins DSL 必须映射过去,
|
||||||
|
// 否则放入 agconnect-services.json 后启用插件会解析失败。
|
||||||
|
resolutionStrategy {
|
||||||
|
eachPlugin {
|
||||||
|
if (requested.id.id == "com.huawei.agconnect") {
|
||||||
|
useModule("com.huawei.agconnect:agcp:1.9.5.301")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "9.0.1" apply false
|
id("com.android.application") version "9.0.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
|
id("com.huawei.agconnect") version "1.9.5.301" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+37
-14
@@ -1,14 +1,24 @@
|
|||||||
/// 服务器地址集中配置。
|
/// 服务器地址集中配置。
|
||||||
///
|
///
|
||||||
/// 服务器换了地址只需要改这一个文件:
|
/// 默认指向公网测试域名 `jxd.jinniu.ink`(HTTPS / WSS)。
|
||||||
/// 1. 改下面的 [serverHost] 为新的服务器内网 IP(或域名);
|
/// 内网联调不要改本文件,打包时覆盖:
|
||||||
/// 2. 如果端口也被重新映射,改对应端口常量即可。
|
/// `--dart-define=SERVER_HOST=192.168.200.11 --dart-define=USE_TLS=false`
|
||||||
|
/// 仅覆盖登录服务:`--dart-define=AUTH_HOST=127.0.0.1`
|
||||||
library;
|
library;
|
||||||
|
|
||||||
/// 服务器内网地址(不带协议、不带端口)
|
/// 公网测试域名(不带协议、不带端口)
|
||||||
const String serverHost = '192.168.200.11';
|
const String defaultPublicHost = 'jxd.jinniu.ink';
|
||||||
|
|
||||||
/// OpenIM API 端口(服务端 docker 映射,默认 10002)
|
/// 服务器地址(不带协议、不带端口)
|
||||||
|
const String serverHost = String.fromEnvironment(
|
||||||
|
'SERVER_HOST',
|
||||||
|
defaultValue: defaultPublicHost,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 是否走 HTTPS/WSS(公网默认 true;内网联调传 false)
|
||||||
|
const bool useTls = bool.fromEnvironment('USE_TLS', defaultValue: true);
|
||||||
|
|
||||||
|
/// OpenIM API 端口(服务端 docker 映射,默认 10002;TLS 时由反向代理走 443)
|
||||||
const int apiPort = 10002;
|
const int apiPort = 10002;
|
||||||
|
|
||||||
/// OpenIM WebSocket 端口(默认 10001)
|
/// OpenIM WebSocket 端口(默认 10001)
|
||||||
@@ -20,17 +30,30 @@ const int livekitPort = 17880;
|
|||||||
/// 公司账号登录后端端口(B-60 account-server,docker 默认映射 10010)
|
/// 公司账号登录后端端口(B-60 account-server,docker 默认映射 10010)
|
||||||
const int authApiPort = 10010;
|
const int authApiPort = 10010;
|
||||||
|
|
||||||
/// OpenIM API 地址,例:http://192.168.200.11:10002
|
const String _authHostOverride = String.fromEnvironment('AUTH_HOST', defaultValue: '');
|
||||||
const String apiAddr = 'http://$serverHost:$apiPort';
|
|
||||||
|
|
||||||
/// OpenIM 消息长连接地址,例:ws://192.168.200.11:10001
|
/// OpenIM API 地址
|
||||||
const String wsAddr = 'ws://$serverHost:$wsPort';
|
String get apiAddr => useTls ? 'https://$serverHost' : 'http://$serverHost:$apiPort';
|
||||||
|
|
||||||
/// LiveKit 连接地址,例:ws://192.168.200.11:17880
|
/// OpenIM 消息长连接地址
|
||||||
const String livekitUrl = 'ws://$serverHost:$livekitPort';
|
String get wsAddr => useTls ? 'wss://$serverHost/msg_gateway' : 'ws://$serverHost:$wsPort';
|
||||||
|
|
||||||
/// 公司账号登录后端地址,例:http://192.168.200.11:10010
|
/// LiveKit 连接地址
|
||||||
|
String get livekitUrl => useTls ? 'wss://$serverHost/livekit' : 'ws://$serverHost:$livekitPort';
|
||||||
|
|
||||||
|
/// 公司账号登录后端地址
|
||||||
///
|
///
|
||||||
/// 仅本地联调用:`--dart-define=AUTH_HOST=127.0.0.1` 可覆盖登录服务地址(OpenIM/LiveKit
|
/// 仅本地联调用:`--dart-define=AUTH_HOST=127.0.0.1` 可覆盖登录服务地址(OpenIM/LiveKit
|
||||||
/// 仍走 [serverHost]),正式打包不带该参数则与 [serverHost] 一致。
|
/// 仍走 [serverHost]),正式打包不带该参数则与 [serverHost] 一致。
|
||||||
const String authApiBase = 'http://${String.fromEnvironment('AUTH_HOST', defaultValue: serverHost)}:$authApiPort';
|
String get authApiBase {
|
||||||
|
if (_authHostOverride.isNotEmpty) {
|
||||||
|
return 'http://$_authHostOverride:$authApiPort';
|
||||||
|
}
|
||||||
|
return useTls ? 'https://$serverHost/account' : 'http://$serverHost:$authApiPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 极光 AppKey(可进客户端;Master Secret 禁止出现在客户端或仓库)
|
||||||
|
const String jpushAppKey = '95fc2352648a7d4568ac8d72';
|
||||||
|
|
||||||
|
/// 登录页自检:探测消息长连接入口(TLS 走反向代理路径,明文走宿主端口)
|
||||||
|
String get wsProbeUrl => useTls ? 'https://$serverHost/msg_gateway' : 'http://$serverHost:$wsPort/';
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import 'screens/home_screen.dart';
|
|||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'services/call_service.dart';
|
import 'services/call_service.dart';
|
||||||
import 'services/im_service.dart';
|
import 'services/im_service.dart';
|
||||||
|
import 'services/push_service.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
PushService.instance.init();
|
||||||
runApp(const ChanglianApp());
|
runApp(const ChanglianApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ class _ChanglianAppState extends State<ChanglianApp> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
// 被踢下线 / token 失效:清凭证、回登录页
|
// 被踢下线 / token 失效:清凭证、回登录页
|
||||||
IMService.instance.onForceLogout = () async {
|
IMService.instance.onForceLogout = () async {
|
||||||
|
await PushService.instance.unbindUser();
|
||||||
await LoginScreen.clearCredential();
|
await LoginScreen.clearCredential();
|
||||||
navigatorKey.currentState?.pushAndRemoveUntil(
|
navigatorKey.currentState?.pushAndRemoveUntil(
|
||||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
@@ -90,6 +93,7 @@ class _SplashGateState extends State<SplashGate> {
|
|||||||
await im.init().timeout(const Duration(seconds: 20));
|
await im.init().timeout(const Duration(seconds: 20));
|
||||||
await im.login(userID: credential.$1, token: credential.$2).timeout(const Duration(seconds: 20));
|
await im.login(userID: credential.$1, token: credential.$2).timeout(const Duration(seconds: 20));
|
||||||
CallService.instance.start();
|
CallService.instance.start();
|
||||||
|
await PushService.instance.bindUser(credential.$1);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
|
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../config.dart';
|
|||||||
import '../services/auth_api.dart';
|
import '../services/auth_api.dart';
|
||||||
import '../services/call_service.dart';
|
import '../services/call_service.dart';
|
||||||
import '../services/im_service.dart';
|
import '../services/im_service.dart';
|
||||||
|
import '../services/push_service.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
|
||||||
@@ -92,7 +93,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
// 3. 启动通话信令监听
|
// 3. 启动通话信令监听
|
||||||
CallService.instance.start();
|
CallService.instance.start();
|
||||||
// 4. 存凭证用于下次自动登录
|
// 4. 极光 alias = OpenIM userID,供服务端离线推送
|
||||||
|
await PushService.instance.bindUser(result.userID);
|
||||||
|
// 5. 存凭证用于下次自动登录
|
||||||
await LoginScreen.saveCredential(result.userID, result.token, result.nickname);
|
await LoginScreen.saveCredential(result.userID, result.token, result.nickname);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
@@ -150,7 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
probe('账号服务', '$authApiBase/api/health'),
|
probe('账号服务', '$authApiBase/api/health'),
|
||||||
probe('消息接口', '$apiAddr/'),
|
probe('消息接口', '$apiAddr/'),
|
||||||
probe('消息长连接', 'http://$serverHost:$wsPort/'),
|
probe('消息长连接', wsProbeUrl),
|
||||||
]);
|
]);
|
||||||
return '自检:${results.join(',')}';
|
return '自检:${results.join(',')}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||||
|
|
||||||
import '../services/im_service.dart';
|
import '../services/im_service.dart';
|
||||||
|
import '../services/push_service.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets/avatar.dart';
|
import '../widgets/avatar.dart';
|
||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
@@ -166,6 +167,7 @@ class _MineScreenState extends State<MineScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
await LoginScreen.clearCredential();
|
await LoginScreen.clearCredential();
|
||||||
|
await PushService.instance.unbindUser();
|
||||||
await IMService.instance.logout();
|
await IMService.instance.logout();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ class MessageSendService {
|
|||||||
message: message,
|
message: message,
|
||||||
userID: isGroup ? null : peer,
|
userID: isGroup ? null : peer,
|
||||||
groupID: isGroup ? groupID : null,
|
groupID: isGroup ? groupID : null,
|
||||||
offlinePushInfo: OfflinePushInfo(),
|
offlinePushInfo: OfflinePushInfo(
|
||||||
|
title: '畅联',
|
||||||
|
desc: '你收到一条新消息',
|
||||||
|
iOSBadgeCount: true,
|
||||||
|
iOSPushSound: '+1',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return sent;
|
return sent;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:jpush_flutter/jpush_flutter.dart';
|
||||||
|
import 'package:jpush_flutter/jpush_interface.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
import '../config.dart';
|
||||||
|
import '../nav.dart';
|
||||||
|
import '../screens/home_screen.dart';
|
||||||
|
import 'im_service.dart';
|
||||||
|
|
||||||
|
/// 极光推送封装:初始化、通知权限、Registration ID、把 OpenIM userID 设为 alias。
|
||||||
|
///
|
||||||
|
/// OpenIM 服务端 JPUSH 离线推送按 alias=userID 下发,因此登录后必须 setAlias。
|
||||||
|
/// 本阶段只接安卓;iOS 不调用 setup。
|
||||||
|
class PushService {
|
||||||
|
PushService._();
|
||||||
|
|
||||||
|
static final PushService instance = PushService._();
|
||||||
|
|
||||||
|
final JPushFlutterInterface _jpush = JPush.newJPush();
|
||||||
|
bool _ready = false;
|
||||||
|
String? _boundAlias;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_ready) return;
|
||||||
|
if (kIsWeb || !Platform.isAndroid) return;
|
||||||
|
try {
|
||||||
|
_jpush.addEventHandler(
|
||||||
|
onReceiveNotification: (message) async {},
|
||||||
|
onOpenNotification: (message) async {
|
||||||
|
_onNotificationClick();
|
||||||
|
},
|
||||||
|
onReceiveMessage: (message) async {},
|
||||||
|
onReceiveNotificationAuthorization: (message) async {},
|
||||||
|
onConnected: (message) async {},
|
||||||
|
onCommandResult: (message) async {},
|
||||||
|
);
|
||||||
|
_jpush.setAuth(enable: true);
|
||||||
|
_jpush.setup(
|
||||||
|
appKey: jpushAppKey,
|
||||||
|
channel: 'developer-default',
|
||||||
|
production: true,
|
||||||
|
debug: false,
|
||||||
|
);
|
||||||
|
_jpush.applyPushAuthority(
|
||||||
|
const NotificationSettingsIOS(sound: true, alert: true, badge: true),
|
||||||
|
);
|
||||||
|
await _requestAndroidNotificationPermission();
|
||||||
|
_ready = true;
|
||||||
|
await _jpush.getRegistrationID();
|
||||||
|
} catch (_) {
|
||||||
|
// 推送失败不能挡住登录和聊天
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> bindUser(String userID) async {
|
||||||
|
if (userID.isEmpty) return;
|
||||||
|
await init();
|
||||||
|
if (!_ready) return;
|
||||||
|
try {
|
||||||
|
await _jpush.setAlias(userID);
|
||||||
|
_boundAlias = userID;
|
||||||
|
} catch (_) {
|
||||||
|
// alias 失败时下次登录再试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unbindUser() async {
|
||||||
|
if (!_ready) return;
|
||||||
|
try {
|
||||||
|
await _jpush.deleteAlias();
|
||||||
|
} catch (_) {
|
||||||
|
// 本地照常清理
|
||||||
|
}
|
||||||
|
_boundAlias = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get boundAlias => _boundAlias;
|
||||||
|
|
||||||
|
Future<void> _requestAndroidNotificationPermission() async {
|
||||||
|
try {
|
||||||
|
final status = await Permission.notification.status;
|
||||||
|
if (!status.isGranted) {
|
||||||
|
await Permission.notification.request();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 权限申请失败不阻断
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNotificationClick() {
|
||||||
|
final nav = navigatorKey.currentState;
|
||||||
|
if (nav == null) return;
|
||||||
|
if (!IMService.instance.loggedIn) return;
|
||||||
|
nav.pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-10
@@ -369,10 +369,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
version: "0.20.3"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -397,6 +397,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
jpush_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: jpush_flutter
|
||||||
|
sha256: "5dcfcd553787db0c92a9b2f0898a6dbc053530c6636ed488416cd9edf12f08d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.5.6"
|
||||||
|
jpush_flutter_android:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: jpush_flutter_android
|
||||||
|
sha256: d2bae547aefb2ff5acf28badaf115bb358e9244b6acef84625d8db0228bbc0b2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -489,10 +505,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -505,10 +521,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -902,10 +918,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -926,10 +942,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.2"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+11
-1
@@ -1,7 +1,7 @@
|
|||||||
name: changlian
|
name: changlian
|
||||||
description: 畅联 —— 公司内部通讯 App(手机端)
|
description: 畅联 —— 公司内部通讯 App(手机端)
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.0.6+7
|
version: 1.0.7+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.6.0 <4.0.0"
|
sdk: ">=3.6.0 <4.0.0"
|
||||||
@@ -37,6 +37,16 @@ dependencies:
|
|||||||
open_filex: 4.7.0
|
open_filex: 4.7.0
|
||||||
# 通话房间号
|
# 通话房间号
|
||||||
uuid: 4.5.2
|
uuid: 4.5.2
|
||||||
|
# 极光推送官方 Flutter 插件(MIT);厂商通道另见 jpush_flutter_android
|
||||||
|
jpush_flutter: 3.5.6
|
||||||
|
jpush_flutter_android: 1.0.2
|
||||||
|
|
||||||
|
# 极光安卓厂商通道:本阶段只开华为。AppKey 可进客户端;其余厂商密钥不要填。
|
||||||
|
jpush_android:
|
||||||
|
app_key: 95fc2352648a7d4568ac8d72
|
||||||
|
channel: developer-default
|
||||||
|
huawei:
|
||||||
|
enable: true
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:changlian/config.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('默认公网地址走 HTTPS/WSS 且不含测试 IP', () {
|
||||||
|
expect(serverHost, 'jxd.jinniu.ink');
|
||||||
|
expect(useTls, isTrue);
|
||||||
|
expect(apiAddr, 'https://jxd.jinniu.ink');
|
||||||
|
expect(wsAddr, 'wss://jxd.jinniu.ink/msg_gateway');
|
||||||
|
expect(livekitUrl, 'wss://jxd.jinniu.ink/livekit');
|
||||||
|
expect(authApiBase, 'https://jxd.jinniu.ink/account');
|
||||||
|
expect(apiAddr, isNot(contains('154.37')));
|
||||||
|
expect(wsAddr, isNot(contains('154.37')));
|
||||||
|
expect(jpushAppKey, '95fc2352648a7d4568ac8d72');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 内网测试环境(192.168.200.11,见仓库根目录 README 端口表)
|
# 公网测试环境(jxd.jinniu.ink)。内网联调请建 .env.local 覆盖为 http/ws + 内网 IP。
|
||||||
VITE_WS_URL=ws://192.168.200.11:10001
|
VITE_WS_URL=wss://jxd.jinniu.ink/msg_gateway
|
||||||
VITE_API_URL=http://192.168.200.11:10002
|
VITE_API_URL=https://jxd.jinniu.ink
|
||||||
# 自研公司账号服务(登录 + 通话令牌)。本地联调可建 .env.local 覆盖为 mock:http://127.0.0.1:11010
|
# 自研公司账号服务(登录 + 通话令牌)。本地联调可覆盖为 mock:http://127.0.0.1:11010
|
||||||
VITE_ACCOUNT_URL=http://192.168.200.11:10010
|
VITE_ACCOUNT_URL=https://jxd.jinniu.ink/account
|
||||||
# LiveKit 语音通话(宿主端口已改映射为 17880)
|
# LiveKit 语音通话信令(媒体走服务器 17881/17882,不经 443)
|
||||||
VITE_LIVEKIT_URL=ws://192.168.200.11:17880
|
VITE_LIVEKIT_URL=wss://jxd.jinniu.ink/livekit
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 香港测试机公网部署入口(jxd.jinniu.ink)。由总工执行;施工员本卡不跑本脚本,也不 SSH 改现网。
|
||||||
|
# 用法: ./scripts/deploy-public.sh
|
||||||
|
# 回滚: ./scripts/rollback-public.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "没有 .env。先从 .env.example 复制,再按 deploy/public/.env.public.example 补公网项。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# LiveKit --node-ip 依赖 SERVER_IP;空值时信令通、媒体宣告错误。
|
||||||
|
# 不 source .env(JPUSH_PUSH_INTENT 含分号);只抽取这一项。
|
||||||
|
if [ -z "${SERVER_IP:-}" ]; then
|
||||||
|
SERVER_IP="$(awk -F= '/^SERVER_IP=/{v=$2} END{print v}' .env | tr -d '[:space:]"')"
|
||||||
|
fi
|
||||||
|
if [ -z "${SERVER_IP}" ]; then
|
||||||
|
echo "SERVER_IP 为空,拒绝启动。请在 .env 填写本机公网 IP(LiveKit 媒体候选用)。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 注入 Master Secret(失败则停,避免空密钥启动)
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source ./scripts/load-jpush-secret.sh
|
||||||
|
if [ -z "${JPUSH_MASTER_SECRET:-}" ]; then
|
||||||
|
echo "Master Secret 未注入,拒绝启动" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p deploy/public/acme components/redis/config components/mnt/data components/mongodb components/etcd components/kafka
|
||||||
|
touch components/redis/config/redis.conf
|
||||||
|
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yaml \
|
||||||
|
-f deploy/public/docker-compose.public.yaml \
|
||||||
|
pull
|
||||||
|
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yaml \
|
||||||
|
-f deploy/public/docker-compose.public.yaml \
|
||||||
|
up -d
|
||||||
|
|
||||||
|
echo "已提交 compose 启动。检查: docker compose -f docker-compose.yaml -f deploy/public/docker-compose.public.yaml ps"
|
||||||
|
echo "证书未就绪时先用 nginx.bootstrap.conf 申请证书,见 deploy/public/README.md"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 从服务器安全文件注入极光 Master Secret 到环境变量。
|
||||||
|
# 不打印文件内容、不写入仓库。供总工部署时 source,本卡不执行。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SECRET_FILE="${JPUSH_MASTER_SECRET_FILE:-/root/.config/jinxunda/jpush_master_secret}"
|
||||||
|
|
||||||
|
if [ ! -f "$SECRET_FILE" ]; then
|
||||||
|
echo "缺少极光 Master Secret 文件(已配置路径,不打印内容)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
perm=$(stat -c '%a' "$SECRET_FILE" 2>/dev/null || stat -f '%OLp' "$SECRET_FILE")
|
||||||
|
if [ "$perm" != "600" ] && [ "$perm" != "400" ]; then
|
||||||
|
echo "Master Secret 文件权限应为 600 或 400(当前 $perm)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
len=$(wc -c < "$SECRET_FILE" | tr -d ' ')
|
||||||
|
if [ "$len" -lt 16 ] || [ "$len" -gt 64 ]; then
|
||||||
|
echo "Master Secret 文件长度异常(不打印内容)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
JPUSH_MASTER_SECRET="$(tr -d '\n\r' < "$SECRET_FILE")"
|
||||||
|
export JPUSH_MASTER_SECRET
|
||||||
|
# 有意不 echo 该变量
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 公网 overlay 回滚到内网 compose:停掉 edge,按原 docker-compose.yaml 拉起。
|
||||||
|
# 不删除 components/ 与 data/,不改现网数据文件。
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yaml -f deploy/public/docker-compose.public.yaml stop edge || true
|
||||||
|
docker compose -f docker-compose.yaml -f deploy/public/docker-compose.public.yaml rm -f edge || true
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yaml up -d
|
||||||
|
|
||||||
|
echo "已回到内网 compose。数据目录未动。检查: docker compose ps"
|
||||||
Reference in New Issue
Block a user