Compare commits

..
Author SHA1 Message Date
设计开发队长andmultica-agent 092039bc1a fix(mobile-next): 收口我的信息只读字段显示
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 00:52:22 +08:00
7bda2f280a docs: 记录 B-261 /user/find/full 404 定位与构建证据
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 23:58:42 +08:00
9001688c9e fix(mobile-next): 我的信息改走 OpenIM SDK,避免账号服务 404
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 23:58:37 +08:00
1895830907 docs: 记录 B-225 联网修复对照、候选包哈希与回退
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 16:21:58 +08:00
bbc4dce0b6 fix: 验收包写入公司内网地址,避免 Windows 空 VITE 与 Android 假域名
Windows 补 .env.production 与 endpoints 兜底;Android 默认 host/端口改为内网,启动后重写 HttpUtil/ApiService,Urls 改为 getter。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 16:21:40 +08:00
18 changed files with 362 additions and 186 deletions
+99
View File
@@ -0,0 +1,99 @@
# B-225 双端验收包无法联网
分支:`agent/agent/a4ad7eed`(基于问题基线 `18d890ae7fdf66fa4df67360469f0f143641666a`
施工依据:B-148 第13阶段返工;对照旧包 `v1.0.6-internal-test`、故障预发布 `v3.8.3-internal-acceptance`、本卡新源码与新产物。
本卡只修客户端/构建配置并重打候选包,不改现网服务、不覆盖 Gitea 预发布 tag、不把构建成功当成真机联网通过。
**结论:Windows 根因是 Vite 未注入 `VITE_*`,登录打到 `undefined`Android 根因是默认 host 为官方占位域名,且回退端口 10008/10009 已关闭。候选包产物级已写入公司内网地址。真机登录未测。**
## 根因(产物对照,不是猜)
公司内网现况(构建机只读探测 `192.168.200.11`):10001 / 10002 / 10010 / 17880 开;官方 chat 默认口 10008 / 10009 关。账号服务 `POST /api/login` 有业务响应。
| 对照项 | 旧可用包 `v1.0.6-internal-test` | 故障预发布 `v3.8.3-internal-acceptance` | 本卡源码 + 候选包 |
|---|---|---|---|
| Windows 登录 | asar 字面量 `http://192.168.200.11:10010/api/login` | asar 为 `` `${{}.VITE_ACCOUNT_URL}/api/login` ``Vite 空对象) | asar 字面量 `http://192.168.200.11:10010` 等四条地址 |
| Windows IM / LiveKit | `:10002` / `:10001` / `:17880` 字面量 | 同样未注入 | 同上,四条均字面量 |
| Android `libapp.so` | 仅公司地址 | 公司地址 + `your-server-ip or your-domain` + `:10008` | 公司四条地址;`your-server-ip` / `:10008` / `:10009` 为 0 |
Windows 直接原因:仓库根 `.gitignore` 忽略 `.env`B-213 打包时没有 `.env` / `.env.production``vite build` 把 `import.meta.env.VITE_*` 编成空对象属性。
Android 直接原因:`Config._host` 不是 IP,启动默认走 HTTPS 假域名;`HttpUtil.init()` 在 `CompanyBootstrap` 之前拍死 `dio.baseUrl``ApiService.setBaseUrl` 曾只写 host 不带 `http://` 和端口;`Urls` 的 `static final` 第一次读取会冻结错误 URL;官方 chat 口已关,公司账号口是 10010。
## 最小修复
| 端 | 改动 | 作用 |
|---|---|---|
| PC | 新增 `pc-client/.env.production`(可入库) | `vite build` 默认 production 能注入内网地址 |
| PC | 新增 `pc-client/src/config/endpoints.ts`,登录/目录/RTC/IM 改走兜底常量 | 即使漏加载 env,也不会变成 `undefined` |
| Android | `Config._host = 192.168.200.11`auth/chatToken 回退 10008/10009 → 10010 | 默认不再走假域名和已关闭端口 |
| Android | `HttpUtil.setBaseUrl`bootstrap 写入完整 `AppEndpoints.apiAddr` | 启动后 Dio / ApiService 指向 `http://192.168.200.11:10002` |
| Android | `Urls` 改为 getter | DataSp 写入后 URL 会跟着更新 |
未改:`account-service/`、docker、端口、现网数据、`mobile/` 旧工程。
## 提交与回退
从新到旧回退:先本验收记录,再联网修复。
| 主题 | SHA | 文件 |
|---|---|---|
| 本验收记录 | 本文件当次提交 | `docs/acceptance/b225-network-fix.md` |
| 联网修复 | `bbc4dce0b61b44447f84732d841b8a66e3a2f4be` | `pc-client/.env.production`、`pc-client/src/config/endpoints.ts`、PC 调用点、Android Config/Urls/HttpUtil/bootstrap |
回退示例(新到旧):
```
git revert <本验收提交>
git revert bbc4dce0b61b44447f84732d841b8a66e3a2f4be
```
用户侧继续用旧包 `v1.0.6-internal-test`。不要回退到故障预发布 `v3.8.3-internal-acceptance`。
代码侧也可把分支重置回 `18d890ae7fdf66fa4df67360469f0f143641666a`。回退不影响现网服务与已发布旧包。
## 环境
- Flutter 3.24.5 / Dart 3.5.4`C:\devtools\flutter_3.24.5`
- Temurin JDK 17.0.20
- Android SDKplatform-tools、platforms;android-34、build-tools;34.0.0
- Node v24.5.0`pc-client` `npm install --ignore-scripts` 后 `npm run build` + `npx electron-builder --win --x64`
- `GRADLE_OPTS=-Xmx4096M`
## 候选包(未发布,未覆盖 `v3.8.3-internal-acceptance`
构建基线 HEAD`18d890ae7fdf66fa4df67360469f0f143641666a` + 本卡两提交。
| 产物 | 版本 | 大小 | SHA256 | 路径 |
|---|---|---|---|---|
| Android release APK | 3.8.3 | 53,288,459 B | `3709E9B24F2F8B12B9BD931D6633DC101C186367B5C3E704DD91D67DCB791B86` | `mobile-next/build/app/outputs/flutter-apk/app-release.apk` |
| Windows NSIS EXE | 1.0.2 | 105,893,401 B | `F16B421857C341016E4375A869C53FF80B1028E49F84A59FE43C93D50FCC866C` | `pc-client/release/Base/1.0.2/畅联_1.0.2.exe` |
故障预发布对照(不要当候选):
| 产物 | SHA256 |
|---|---|
| APK `v3.8.3-internal-acceptance` | `41261A1BEBD00597D16B50A0D01A261AF592129C5F28014B423A1AAE07AE7C73` |
| EXE `v3.8.3-internal-acceptance` | `25491BA9A19E181144602F81991993DBF3350A3981BD1CC82354949A83B0E645` |
产物抽检:
- 新 EXE `app.asar``http://192.168.200.11:10010` / `:10002` / `ws://...:10001` / `:17880` 各 1 次;`VITE_ACCOUNT_URL`、`{}.VITE_`、`undefined/api/login` 为 0。
- 新 APK `lib/arm64-v8a/libapp.so`:上述四条公司地址各 1 次;`your-server-ip`、`:10008`、`:10009` 为 0。
APK 内嵌时间戳,哈希不可位级复现。构建期告警同前:Flutter Gradle apply-script 弃用;部分依赖 Kotlin metadata 1.8.0 vs 工程预期 1.6.0。均属上游,release 通过。
## 已测 / 未测
已测:
- 构建机到 `192.168.200.11` 的 10001/10002/10010/17880 端口开,10008/10009 关
- 账号服务对错误工号返回业务 JSON(服务本身在)
- `npm run build`、`electron-builder --win --x64`、`flutter build apk --release` 成功
- 新旧产物字符串对照如上
未测(不得写成已通过):
- 公司内网真机 / 真 PC 用工号登录
- 登录后会话列表、发消息、音视频
- 本候选包安装覆盖旧包后的升级路径
@@ -0,0 +1,64 @@
# B-261 Android「我的信息」404
分支:本卡施工分支(基于联网修复复测包 `1895830` / `agent/agent/a4ad7eed`
对照用户安装包:Gitea `v3.8.3-network-fix-retest``changlian-android-3.8.3-b225.apk`
本卡只改客户端用户资料读取,不改现网服务、不改账号数据、不覆盖旧 Release。
**结论:404 是客户端把官方 chat 接口打到了账号服务。责任在客户端。已改为走 OpenIM SDK,不再请求 `/user/find/full`。**
## 直接原因
用户截图:`GET http://192.168.200.11:10010/user/find/full` → 404。
| 核对项 | 结果 |
|---|---|
| 账号服务真实路由 | 只有 `/api/login``/api/directory``/api/rtc_token`、管理接口;没有 `/user/find/full` |
| 现网探测 | `GET/POST http://192.168.200.11:10010/user/find/full` 均 404`GET /api/health` 200,员工数 3 |
| 当前发布包调用方 | 官方页「我的信息」走 `Apis.queryMyFullInfo()``HttpUtil.post(Urls.getUsersFullInfo)` |
| URL 来源 | B-225 把 `Config.appAuthUrl` 从已关闭的 :10008 改到 :10010,登录能通,但这条 chat 路径仍打到账号服务 |
| 代码方法 | 源码是 POST;GET/POST 对不存在的路径都是 404,和截图一致 |
`mobile/` 和 PC 早已用 OpenIM SDK 取资料,不打这条路径。新 `mobile-next/` 漏改。
责任边界:客户端请求了账号服务没有的接口。不是工号密码错误,也不是服务器宕机。不需要改现网。
## 最小修复
`Apis.getUserFullInfo` / `queryMyFullInfo` / `searchUserFullInfo` / `updateUserInfo` 改为 OpenIM SDK(与 PC `pc-client/src/api/login.ts` 同一策略)。
- 能同步:工号、昵称、头像、勿扰
- 不再有值:手机号、邮箱、性别、生日等原 chat 业务字段
- 未改:`account-service/`、docker、旧 `mobile/`、PC
## 回退
从新到旧:`git revert` 本验收记录提交,再 `git revert` 代码提交。用户侧可继续用 `v3.8.3-network-fix-retest` 或旧包 `v1.0.6-internal-test`
## 构建
- Flutter 3.24.5 / Dart 3.5.4 / Temurin 17.0.20
- `flutter build apk --release` 成功
- 上游告警同 B-225Flutter Gradle apply-script 弃用;部分依赖 Kotlin metadata 1.8.0 vs 工程预期 1.6.0。release 通过。
| 产物 | 版本 | 包名 | 大小 | SHA256 |
|---|---|---|---|---|
| Android release APK | 3.8.3+180 | `io.openim.flutter.demo` | 53,286,915 B | `42D74E85AEC52A3889ED79CE7345C3BA5F69B4B3FA994032C2D38DF6A9CF5987` |
路径:`mobile-next/build/app/outputs/flutter-apk/app-release.apk`(APK 内嵌时间戳,哈希不可位级复现)
## 已测 / 未测
已测:
- 现网 `:10010/user/find/full` GET/POST 404`/api/health` 200
- `UserFullInfoMapper` 单测 3 项通过
- 改动文件 `flutter analyze` 无新增 error(剩余 8 条均为 `apis.dart` 原有 warning/info
- Android release 构建通过,校验值见上表
- `Apis` 不再引用 `Urls.getUsersFullInfo` / `updateUserInfo` / `searchUserFullInfo`
未测(不得写成已通过):
- 公司内网真机登录后打开「我的信息」
- 登录、联系人、发消息完整回归
残余:官方 `searchFriendInfo`、改密、验证码仍指向已下线 chat 路径;工号登录主路径和「我的信息」不再走这些接口。
@@ -10,6 +10,7 @@ class CompanyBootstrap {
static Future<void> install() async {
applyOfficialStylesFromTokens();
await DataSp.putServerConfig(AppEndpoints.toServerConfig());
ApiService().setBaseUrl(AppEndpoints.host);
HttpUtil.setBaseUrl(AppEndpoints.apiAddr);
ApiService().setBaseUrl(AppEndpoints.apiAddr);
}
}
@@ -1,4 +1,3 @@
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
import 'package:openim/pages/login/login_logic.dart';
@@ -26,8 +25,6 @@ class MyInfoLogic extends GetxController {
attr: EditAttr.mobile,
);
void editEmail() => AppNavigator.startEditMyInfo(attr: EditAttr.email, maxLength: 30);
void openPhotoSheet() {
IMViews.openPhotoSheet(
onData: (path, url) async {
@@ -43,62 +40,6 @@ class MyInfoLogic extends GetxController {
quality: 15);
}
void openDatePicker() {
var appLocale = Get.locale;
var isZh = appLocale!.languageCode.toLowerCase().contains("zh");
DatePicker.showDatePicker(
Get.context!,
locale: isZh ? LocaleType.zh : LocaleType.en,
maxTime: DateTime.now(),
currentTime: DateTime.fromMillisecondsSinceEpoch(imLogic.userInfo.value.birth ?? 0),
theme: DatePickerTheme(
cancelStyle: Styles.ts_0C1C33_17sp,
doneStyle: Styles.ts_0089FF_17sp,
itemStyle: Styles.ts_0C1C33_17sp,
),
onConfirm: (dateTime) {
_updateBirthday(dateTime.millisecondsSinceEpoch ~/ 1000);
},
);
}
void selectGender() {
Get.bottomSheet(
BottomSheetView(
items: [
SheetItem(
label: StrRes.man,
onTap: () => _updateGender(1),
),
SheetItem(
label: StrRes.woman,
onTap: () => _updateGender(2),
),
],
),
);
}
void _updateGender(int gender) {
LoadingView.singleton.wrap(
asyncFunction: () => Apis.updateUserInfo(userID: OpenIM.iMManager.userID, gender: gender)
.then((value) => imLogic.userInfo.update((val) {
val?.gender = gender;
})),
);
}
void _updateBirthday(int birthday) {
LoadingView.singleton.wrap(
asyncFunction: () => Apis.updateUserInfo(
userID: OpenIM.iMManager.userID,
birth: birthday * 1000,
).then((value) => imLogic.userInfo.update((val) {
val?.birth = birthday * 1000;
})),
);
}
@override
void onReady() {
_queryMyFullIno();
@@ -42,16 +42,18 @@ class MyInfoPage extends StatelessWidget {
),
_buildItemView(
label: StrRes.gender,
value: imLogic.userInfo.value.isMale ? StrRes.man : StrRes.woman,
onTap: logic.selectGender,
value: _genderText(imLogic.userInfo.value.gender),
showRightArrow: false,
),
_buildItemView(
label: StrRes.birthDay,
value: DateUtil.formatDateMs(
imLogic.userInfo.value.birth ?? 0,
format: IMUtils.getTimeFormat1(),
),
onTap: logic.openDatePicker,
value: imLogic.userInfo.value.birth == null
? ''
: DateUtil.formatDateMs(
imLogic.userInfo.value.birth!,
format: IMUtils.getTimeFormat1(),
),
showRightArrow: false,
),
],
),
@@ -68,7 +70,7 @@ class MyInfoPage extends StatelessWidget {
_buildItemView(
label: StrRes.email,
value: imLogic.userInfo.value.email,
onTap: logic.editEmail,
showRightArrow: false,
),
],
),
@@ -93,6 +95,12 @@ class MyInfoPage extends StatelessWidget {
child: Column(children: children),
);
String _genderText(int? gender) {
if (gender == 1) return StrRes.man;
if (gender == 2) return StrRes.woman;
return '';
}
Widget _buildItemView({
required String label,
String? value,
@@ -33,6 +33,7 @@ export 'src/utils/sp_util.dart';
export 'src/utils/utils.dart';
export 'src/utils/voice_record.dart';
export 'src/utils/api_service.dart';
export 'src/utils/user_full_info_mapper.dart';
export 'src/widgets/avatar_view.dart';
export 'src/widgets/azlist_view.dart';
export 'src/widgets/bottom_bar.dart';
+43 -84
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
@@ -11,14 +10,19 @@ import 'package:sprintf/sprintf.dart';
import 'utils/api_service.dart';
class Apis {
static Options get imTokenOptions => Options(headers: {'token': DataSp.imToken});
static Options get imTokenOptions =>
Options(headers: {'token': DataSp.imToken});
static Options get chatTokenOptions => Options(headers: {'token': DataSp.chatToken});
static Options get chatTokenOptions =>
Options(headers: {'token': DataSp.chatToken});
static StreamController kickoffController = StreamController<int>.broadcast();
static void _kickoff(int? errCode) {
if (errCode == 1501 || errCode == 1503 || errCode == 1504 || errCode == 1505) {
if (errCode == 1501 ||
errCode == 1503 ||
errCode == 1504 ||
errCode == 1505) {
kickoffController.sink.add(errCode);
}
}
@@ -197,43 +201,16 @@ class Apis {
int? allowBeep,
int? allowVibration,
}) async {
try {
Map<String, dynamic> param = {'userID': userID};
void put(String key, dynamic value) {
if (null != value) {
param[key] = value;
}
}
put('account', account);
put('phoneNumber', phoneNumber);
put('areaCode', areaCode);
put('email', email);
put('nickname', nickname);
put('faceURL', faceURL);
put('gender', gender);
put('gender', gender);
put('level', level);
put('birth', birth);
put('allowAddFriend', allowAddFriend);
put('allowBeep', allowBeep);
put('allowVibration', allowVibration);
return HttpUtil.post(
Urls.updateUserInfo,
data: {
...param,
'platform': IMUtils.getPlatform(),
},
options: chatTokenOptions,
// 原 chat `/user/update` 已随 :10008 下线。OpenIM 只同步昵称/头像;
// 邮箱、性别、生日等业务字段由调用方更新本地状态,不打账号服务。
if (userID.isEmpty) return {};
if (nickname != null || faceURL != null) {
await OpenIM.iMManager.userManager.setSelfInfo(
nickname: nickname,
faceURL: faceURL,
);
} catch (e, s) {
final t = e as (int, String?);
final errCode = t.$1;
final errMsg = t.$2;
_kickoff(errCode);
Logger.print('e:$errCode s:$errMsg');
}
return {};
}
static Future<List<FriendInfo>> searchFriendInfo(
@@ -251,7 +228,9 @@ class Apis {
options: chatTokenOptions,
);
if (data['users'] is List) {
return (data['users'] as List).map((e) => FriendInfo.fromJson(e)).toList();
return (data['users'] as List)
.map((e) => FriendInfo.fromJson(e))
.toList();
}
return [];
} catch (e, s) {
@@ -269,26 +248,16 @@ class Apis {
int showNumber = 10,
required List<String> userIDList,
}) async {
if (userIDList.isEmpty) return [];
try {
final data = await HttpUtil.post(
Urls.getUsersFullInfo,
data: {
'pagination': {'pageNumber': pageNumber, 'showNumber': showNumber},
'userIDs': userIDList,
'platform': IMUtils.getPlatform(),
},
options: chatTokenOptions,
final users = await OpenIM.iMManager.userManager.getUsersInfo(
userIDList: userIDList,
);
if (data['users'] is List) {
return (data['users'] as List).map((e) => UserFullInfo.fromJson(e)).toList();
}
return null;
return users
.map((e) => UserFullInfoMapper.fromImJson(e.toJson()))
.toList();
} catch (e, s) {
final t = e as (int, String?);
final errCode = t.$1;
final errMsg = t.$2;
_kickoff(errCode);
Logger.print('e:$errCode s:$errMsg');
Logger.print('getUserFullInfo e:$e s:$s');
return [];
}
}
@@ -298,34 +267,20 @@ class Apis {
int pageNumber = 1,
int showNumber = 10,
}) async {
try {
final data = await HttpUtil.post(
Urls.searchUserFullInfo,
data: {
'pagination': {'pageNumber': pageNumber, 'showNumber': showNumber},
'keyword': content,
},
options: chatTokenOptions,
);
if (data['users'] is List) {
return (data['users'] as List).map((e) => UserFullInfo.fromJson(e)).toList();
}
return null;
} catch (e, s) {
final t = e as (int, String?);
final errCode = t.$1;
final errMsg = t.$2;
_kickoff(errCode);
Logger.print('e:$errCode s:$errMsg');
return [];
}
final keyword = content.trim();
if (keyword.isEmpty) return [];
if (pageNumber > 1) return [];
return getUserFullInfo(userIDList: [keyword], showNumber: showNumber);
}
static Future<UserFullInfo?> queryMyFullInfo() async {
final list = await Apis.getUserFullInfo(
userIDList: [OpenIM.iMManager.userID],
);
return list?.firstOrNull;
try {
final user = await OpenIM.iMManager.userManager.getSelfUserInfo();
return UserFullInfoMapper.fromImJson(user.toJson());
} catch (e, s) {
Logger.print('queryMyFullInfo e:$e s:$s');
return null;
}
}
static Future<bool> requestVerificationCode({
@@ -353,7 +308,8 @@ class Apis {
});
}
static Future<SignalingCertificate> getTokenForRTC(String roomID, String userID) async {
static Future<SignalingCertificate> getTokenForRTC(
String roomID, String userID) async {
return HttpUtil.post(
Urls.getTokenForRTC,
data: {
@@ -410,7 +366,10 @@ class Apis {
}
static Future<Map<String, dynamic>> getClientConfig() async {
return {'discoverPageURL': Config.discoverPageURL, 'allowSendMsgNotFriend': Config.allowSendMsgNotFriend};
return {
'discoverPageURL': Config.discoverPageURL,
'allowSendMsgNotFriend': Config.allowSendMsgNotFriend
};
}
static void _catchError(Object e, StackTrace s, {bool forceBack = true}) {
@@ -20,7 +20,7 @@ class Config {
await Hive.initFlutter(path);
MediaKit.ensureInitialized();
HttpUtil.init();
ApiService().setBaseUrl(serverIp);
ApiService().setBaseUrl(imApiUrl);
} catch (_) {}
runApp();
@@ -65,9 +65,10 @@ class Config {
static const friendScheme = "io.openim.app/addFriend/";
static const groupScheme = "io.openim.app/joinGroup/";
static const _host = "your-server-ip or your-domain";
static const _host = "192.168.200.11";
static const _ipRegex = '((2[0-4]\\d|25[0-5]|[01]?\\d\\d?)\\.){3}(2[0-4]\\d|25[0-5]|[01]?\\d\\d?)';
static const _ipRegex =
'((2[0-4]\\d|25[0-5]|[01]?\\d\\d?)\\.){3}(2[0-4]\\d|25[0-5]|[01]?\\d\\d?)';
static bool get _isIP => RegExp(_ipRegex).hasMatch(_host);
@@ -86,7 +87,7 @@ class Config {
if (null != server) {
url = server['chatTokenUrl'];
}
return url ?? (_isIP ? "http://$_host:10009" : "https://$_host/chat");
return url ?? (_isIP ? "http://$_host:10010" : "https://$_host/chat");
}
static String get appAuthUrl {
@@ -95,7 +96,7 @@ class Config {
if (null != server) {
url = server['authUrl'];
}
return url ?? (_isIP ? "http://$_host:10008" : "https://$_host/chat");
return url ?? (_isIP ? "http://$_host:10010" : "https://$_host/chat");
}
static String get imApiUrl {
+22 -15
View File
@@ -1,22 +1,29 @@
import 'config.dart';
class Urls {
static final onlineStatus = "${Config.imApiUrl}/manager/get_users_online_status";
static final queryAllUsers = "${Config.imApiUrl}/manager/get_all_users_uid";
static final updateUserInfo = "${Config.appAuthUrl}/user/update";
static final searchFriendInfo = "${Config.appAuthUrl}/friend/search";
static final getUsersFullInfo = "${Config.appAuthUrl}/user/find/full";
static final searchUserFullInfo = "${Config.appAuthUrl}/user/search/full";
static String get onlineStatus =>
"${Config.imApiUrl}/manager/get_users_online_status";
static String get queryAllUsers =>
"${Config.imApiUrl}/manager/get_all_users_uid";
// 以下 chat 路径仅作历史对照。账号服务 :10010 没有这些路由;
// 用户资料读写已改走 OpenIM SDK,见 Apis.getUserFullInfo / updateUserInfo。
static String get updateUserInfo => "${Config.appAuthUrl}/user/update";
static String get searchFriendInfo => "${Config.appAuthUrl}/friend/search";
static String get getUsersFullInfo => "${Config.appAuthUrl}/user/find/full";
static String get searchUserFullInfo =>
"${Config.appAuthUrl}/user/search/full";
static final getVerificationCode = "${Config.appAuthUrl}/account/code/send";
static final checkVerificationCode = "${Config.appAuthUrl}/account/code/verify";
static final register = "${Config.appAuthUrl}/account/register";
static String get getVerificationCode =>
"${Config.appAuthUrl}/account/code/send";
static String get checkVerificationCode =>
"${Config.appAuthUrl}/account/code/verify";
static String get register => "${Config.appAuthUrl}/account/register";
static final resetPwd = "${Config.appAuthUrl}/account/password/reset";
static final changePwd = "${Config.appAuthUrl}/account/password/change";
static final login = "${Config.appAuthUrl}/account/login";
static String get resetPwd => "${Config.appAuthUrl}/account/password/reset";
static String get changePwd => "${Config.appAuthUrl}/account/password/change";
static String get login => "${Config.appAuthUrl}/account/login";
static final upgrade = "${Config.appAuthUrl}/app/check";
static final getClientConfig = '${Config.appAuthUrl}/client_config/get';
static final getTokenForRTC = "${Config.appAuthUrl}/user/rtc/get_token";
static String get upgrade => "${Config.appAuthUrl}/app/check";
static String get getClientConfig => '${Config.appAuthUrl}/client_config/get';
static String get getTokenForRTC => "${Config.appAuthUrl}/user/rtc/get_token";
}
@@ -39,7 +39,12 @@ class HttpUtil {
dio.options.receiveTimeout = const Duration(seconds: 30);
}
static String get operationID => DateTime.now().millisecondsSinceEpoch.toString();
static void setBaseUrl(String url) {
dio.options.baseUrl = url;
}
static String get operationID =>
DateTime.now().millisecondsSinceEpoch.toString();
static Future post(
String path, {
@@ -103,8 +108,11 @@ class HttpUtil {
final bytes = await File(compressPath ?? path).readAsBytes();
final mf = MultipartFile.fromBytes(bytes, filename: fileName);
var formData =
FormData.fromMap({'operationID': '${DateTime.now().millisecondsSinceEpoch}', 'fileType': 1, 'file': mf});
var formData = FormData.fromMap({
'operationID': '${DateTime.now().millisecondsSinceEpoch}',
'fileType': 1,
'file': mf
});
var resp = await dio.post<Map<String, dynamic>>(
"${Config.imApiUrl}/third/minio_upload",
@@ -151,12 +159,14 @@ class HttpUtil {
onCompletion?.call();
intervalDo.drop(
fun: () async {
saveFileToGallerySaver(File(cachePath), showTaost: EasyLoading.isShow);
saveFileToGallerySaver(File(cachePath),
showTaost: EasyLoading.isShow);
},
milliseconds: 1500);
}
if (count == total) {
saveFileToGallerySaver(File(cachePath), showTaost: EasyLoading.isShow);
saveFileToGallerySaver(File(cachePath),
showTaost: EasyLoading.isShow);
}
},
);
@@ -166,7 +176,8 @@ class HttpUtil {
var byteData = await image.toByteData(format: ImageByteFormat.png);
if (byteData != null) {
Uint8List uint8list = byteData.buffer.asUint8List();
var result = await ImageGallerySaverPlus.saveImage(Uint8List.fromList(uint8list));
var result =
await ImageGallerySaverPlus.saveImage(Uint8List.fromList(uint8list));
if (result != null) {
var tips = StrRes.saveSuccessfully;
if (Platform.isAndroid) {
@@ -214,13 +225,15 @@ class HttpUtil {
);
}
static Future saveFileToGallerySaver(File file, {String? name, bool showTaost = true}) async {
static Future saveFileToGallerySaver(File file,
{String? name, bool showTaost = true}) async {
Permissions.storage(() async {
var tips = StrRes.saveSuccessfully;
Logger.print('saveFileToGallerySaver: ${file.path}');
final imageBytes = await file.readAsBytes();
final result = await ImageGallerySaverPlus.saveImage(imageBytes, name: name);
final result =
await ImageGallerySaverPlus.saveImage(imageBytes, name: name);
if (result != null && showTaost) {
if (Platform.isAndroid) {
final filePath = result['filePath'].split('//').last;
@@ -0,0 +1,24 @@
import '../models/user_full_info.dart';
/// 把 OpenIM 公开资料收成原 chat 服务 `UserFullInfo` 形状。
///
/// 官方 `open-im-chat`:10008 `/user/find/full`)已下线,账号服务 :10010
/// 没有这条路由。SDK 只保证 userID / nickname / faceURL / ex / 勿扰。
class UserFullInfoMapper {
UserFullInfoMapper._();
static UserFullInfo fromImJson(Map<String, dynamic> json) {
return UserFullInfo.fromJson({
'userID': json['userID'],
'nickname': json['nickname'],
'faceURL': json['faceURL'],
'ex': json['ex'],
'remark': json['remark'],
'globalRecvMsgOpt': json['globalRecvMsgOpt'],
});
}
static List<UserFullInfo> fromImList(Iterable<Map<String, dynamic>> items) {
return items.map(fromImJson).toList();
}
}
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openim_common/openim_common.dart';
void main() {
test('把 OpenIM 公开资料映射成我的信息可用字段', () {
final info = UserFullInfoMapper.fromImJson({
'userID': '10001',
'nickname': '张三',
'faceURL': 'http://example/a.png',
'ex': '',
'globalRecvMsgOpt': 0,
});
expect(info.userID, '10001');
expect(info.nickname, '张三');
expect(info.faceURL, 'http://example/a.png');
expect(info.globalRecvMsgOpt, 0);
});
test('不把 chat 业务字段当成仍可从账号服务拿到', () {
final info = UserFullInfoMapper.fromImJson({
'userID': '10001',
'nickname': '张三',
'phoneNumber': '13800000000',
'email': 'a@b.c',
'gender': 1,
});
expect(info.userID, '10001');
expect(info.nickname, '张三');
expect(info.phoneNumber, isNull);
expect(info.email, isNull);
expect(info.gender, isNull);
});
test('空列表保持为空', () {
expect(UserFullInfoMapper.fromImList(const []), isEmpty);
});
}
+6
View File
@@ -0,0 +1,6 @@
# 生产/验收包写入的公司内网地址。vite build 默认 mode=production 会加载本文件。
# 不要依赖本机未入库的 .env:缺文件时 VITE_* 会变成 undefined,登录会变成网络失败。
VITE_WS_URL=ws://192.168.200.11:10001
VITE_API_URL=http://192.168.200.11:10002
VITE_ACCOUNT_URL=http://192.168.200.11:10010
VITE_LIVEKIT_URL=ws://192.168.200.11:17880
+3 -2
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import { ACCOUNT_URL, LIVEKIT_URL } from "@/config/endpoints";
import { getIMToken } from "@/utils/storage";
// RTC(音视频通话)令牌改由自研账号服务签发(原 OpenIM chat 服务 :10008 已下线)。
@@ -15,7 +16,7 @@ export const getRtcConnectData = async (room: string, identity: string) => {
msg: string;
data: { token: string };
}>(
`${import.meta.env.VITE_ACCOUNT_URL}/api/rtc_token`,
`${ACCOUNT_URL}/api/rtc_token`,
{ room, identity },
{ headers: { Authorization: `Bearer ${imToken}` } },
);
@@ -24,7 +25,7 @@ export const getRtcConnectData = async (room: string, identity: string) => {
}
return {
data: {
serverUrl: import.meta.env.VITE_LIVEKIT_URL as string,
serverUrl: LIVEKIT_URL,
token: res.data.token,
},
};
+11
View File
@@ -0,0 +1,11 @@
/** 公司内网接口。Vite 未注入 VITE_* 时不得变成 undefined。 */
const COMPANY_HOST = "192.168.200.11";
export const API_URL =
import.meta.env.VITE_API_URL || `http://${COMPANY_HOST}:10002`;
export const WS_URL =
import.meta.env.VITE_WS_URL || `ws://${COMPANY_HOST}:10001`;
export const ACCOUNT_URL =
import.meta.env.VITE_ACCOUNT_URL || `http://${COMPANY_HOST}:10010`;
export const LIVEKIT_URL =
import.meta.env.VITE_LIVEKIT_URL || `ws://${COMPANY_HOST}:17880`;
+3 -2
View File
@@ -19,6 +19,7 @@ import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { CustomType } from "@/constants";
import { API_URL, WS_URL } from "@/config/endpoints";
import {
pushNewMessage,
updateOneMessage,
@@ -123,8 +124,8 @@ export function useGlobalEvent() {
const IMToken = (await getIMToken()) as string;
const IMUserID = (await getIMUserID()) as string;
try {
const apiAddr = import.meta.env.VITE_API_URL;
const wsAddr = import.meta.env.VITE_WS_URL;
const apiAddr = API_URL;
const wsAddr = WS_URL;
if (window.electronAPI) {
await IMSDK.initSDK({
platformID: window.electronAPI?.getPlatform() ?? 5,
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GroupedVirtuoso } from "react-virtuoso";
import { ACCOUNT_URL } from "@/config/endpoints";
import { useContactStore } from "@/store";
import { emit } from "@/utils/events";
import { getIMToken } from "@/utils/storage";
@@ -29,7 +30,7 @@ export const MyFriends = () => {
return;
}
const { data } = await axios.get(
`${import.meta.env.VITE_ACCOUNT_URL}/api/directory`,
`${ACCOUNT_URL}/api/directory`,
{
params: { keyword: "", limit: 100 },
headers: { Authorization: `Bearer ${token}` },
+2 -1
View File
@@ -4,6 +4,7 @@ import { t } from "i18next";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ACCOUNT_URL } from "@/config/endpoints";
import { setIMProfile } from "@/utils/storage";
interface LoginResult {
@@ -29,7 +30,7 @@ const LoginForm = ({ onBanner }: LoginFormProps) => {
setLoading(true);
try {
const { data: res } = await axios.post<LoginResult>(
`${import.meta.env.VITE_ACCOUNT_URL}/api/login`,
`${ACCOUNT_URL}/api/login`,
{
staffNo: values.staffNo.trim(),
password: values.password,