Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42bd6a225 | ||
|
|
b492a5ff76 | ||
|
|
e6d5dfbfc1 | ||
|
|
0d3d410bd2 | ||
|
|
21e1ae23c1 | ||
|
|
3694051a95 | ||
|
|
6f18980a64 | ||
|
|
051f60aaf0 | ||
|
|
664839d6d6 | ||
|
|
ee18bf5eb1 | ||
|
|
8b2061ef57 | ||
|
|
b97c23a241 | ||
|
|
4d9ff68220 | ||
|
|
8f54d768d4 | ||
|
|
57af540f09 | ||
|
|
1cbc3c4cf0 |
+8
-9
@@ -1,10 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.deploy/
|
.deploy/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
SESSION_HANDOFF.md
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# JC Web IM 开发记录
|
# JC Web IM 开发记录
|
||||||
|
|
||||||
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
|
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
# 绿地信通 Web IM
|
# 绿地信通 Web IM
|
||||||
|
|
||||||
这是从零重写的 Web IM 前端,UI 和状态管理独立于原 `vue-chat`,底层通过授权的 WildFire Web SDK 连接既有 IM 服务。
|
这是从零重写的 Web IM 前端,UI 和状态管理独立于原 `vue-chat`,底层通过授权的 WildFire Web SDK 连接既有 IM 服务。
|
||||||
|
|
||||||
## 当前工作范围
|
## 当前工作范围
|
||||||
|
|
||||||
- 当前工作是在 `jc-web-im` 目录新写 Web 版 IM 客户端。
|
- 当前工作是在 `jc-web-im` 目录新写 Web 版 IM 客户端。
|
||||||
- 不要默认回到仓库根目录老 `src/` 客户端里继续改,除非明确需要兼容或参考旧实现。
|
- 不要默认回到仓库根目录老 `src/` 客户端里继续改,除非明确需要兼容或参考旧实现。
|
||||||
- 中断恢复时优先阅读 `/var/www/im-web/SESSION_HANDOFF.md`。
|
- 中断恢复时优先阅读 `/var/www/im-web/SESSION_HANDOFF.md`。
|
||||||
|
|
||||||
## 服务地址
|
## 服务地址
|
||||||
|
|
||||||
- App Server: `http://52.76.146.193:8888`
|
- App Server: `http://52.76.146.193:8888`
|
||||||
- IM route: `http://52.76.146.193:80/route`
|
- IM route: `http://52.76.146.193:80/route`
|
||||||
- WebSocket: `ws://52.76.146.193:8083/ws`
|
- WebSocket: `ws://52.76.146.193:8083/ws`
|
||||||
- HTTPS/WSS 当前未启用为默认连接方式。
|
- HTTPS/WSS 当前未启用为默认连接方式。
|
||||||
|
|
||||||
远端部署路径:
|
远端部署路径:
|
||||||
|
|
||||||
- App Server: `/opt/app-server`
|
- App Server: `/opt/app-server`
|
||||||
- IM Server: `/opt/im-server`
|
- IM Server: `/opt/im-server`
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/im-web/jc-web-im
|
cd /var/www/im-web/jc-web-im
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
开发访问地址:
|
开发访问地址:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:8023
|
http://localhost:8023
|
||||||
http://10.0.0.120:8023
|
http://10.0.0.120:8023
|
||||||
```
|
```
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
生产部署请看 [WEB_DEPLOYMENT.md](./WEB_DEPLOYMENT.md)。当前 Web 版需要 Nginx 托管静态文件,并通过 PHP-FPM 运行 `dist/api/*.php` 代理接口。
|
生产部署请看 [WEB_DEPLOYMENT.md](./WEB_DEPLOYMENT.md)。当前 Web 版需要 Nginx 托管静态文件,并通过 PHP-FPM 运行 `dist/api/*.php` 代理接口。
|
||||||
|
|
||||||
## 连接方式
|
## 连接方式
|
||||||
|
|
||||||
`src/services/imClient.js` 是唯一的 IM 适配层。上层 UI 只调用这里的方法:
|
`src/services/imClient.js` 是唯一的 IM 适配层。上层 UI 只调用这里的方法:
|
||||||
|
|
||||||
- `loginWithPassword`
|
- `loginWithPassword`
|
||||||
- `loginWithCode`
|
- `loginWithCode`
|
||||||
- `connect`
|
- `connect`
|
||||||
- `getConversationList`
|
- `getConversationList`
|
||||||
- `getMessages`
|
- `getMessages`
|
||||||
- `sendTextMessage`
|
- `sendTextMessage`
|
||||||
|
|
||||||
后续如果替换 SDK 或自研协议,只改这个适配层,不需要改 UI 组件。
|
后续如果替换 SDK 或自研协议,只改这个适配层,不需要改 UI 组件。
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# JC Web IM Session Handoff
|
||||||
|
|
||||||
|
Updated: 2026-05-02 03:18:54 UTC
|
||||||
|
|
||||||
|
## Current Goal
|
||||||
|
|
||||||
|
Continue the Web IM version under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/www/jc-im/jc-web-im
|
||||||
|
```
|
||||||
|
|
||||||
|
The current focus is the contact list and contact detail flow:
|
||||||
|
|
||||||
|
- Load contacts quickly.
|
||||||
|
- Avoid duplicate contact rows.
|
||||||
|
- Show contact detail fields correctly.
|
||||||
|
- Allow editing the friend alias / remark name.
|
||||||
|
|
||||||
|
Important user instruction:
|
||||||
|
|
||||||
|
- Do not modify `app-server` code.
|
||||||
|
- Local config may be adjusted to fit the local test environment.
|
||||||
|
- Web-side code and PHP proxy code may be changed as needed.
|
||||||
|
|
||||||
|
## Runtime Services
|
||||||
|
|
||||||
|
Current local runtime:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im-server: /opt/im-server
|
||||||
|
app-server: /opt/app-server
|
||||||
|
web source: /var/www/jc-im/jc-web-im
|
||||||
|
web dist: /var/www/jc-im/jc-web-im/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
PM2 process names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
jc-im-server
|
||||||
|
jc-app-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 list
|
||||||
|
curl -s http://127.0.0.1:18000/api/version
|
||||||
|
curl -s http://127.0.0.1:18888/
|
||||||
|
curl -I http://127.0.0.1:8023/
|
||||||
|
```
|
||||||
|
|
||||||
|
Important ports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
18000 im-server HTTP/API
|
||||||
|
18080 im-server admin
|
||||||
|
1883 im-server long connection/MQTT
|
||||||
|
18888 app-server
|
||||||
|
8023 Web IM Nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Server Status
|
||||||
|
|
||||||
|
Remote `app-server` was previously pulled from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[email protected]:/opt/app-server
|
||||||
|
```
|
||||||
|
|
||||||
|
It is a colleague-modified deployment and is no longer started from a jar. It runs from exploded classes/lib, equivalent to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -Dfile.encoding=UTF-8 -Xms256m -Xmx1024m \
|
||||||
|
-classpath classes:lib/* cn.wildfirechat.app.Application
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not change its code unless the user explicitly reverses that instruction.
|
||||||
|
|
||||||
|
Current local config was preserved/adapted under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/app-server/config/application.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
Known important setting:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
AppCode=676d5adc3ee049b4aeb69f440305c953
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis was configured locally to accept the password expected by this app-server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mjfg2U@YZdLx&wmH
|
||||||
|
```
|
||||||
|
|
||||||
|
Be careful: other Redis clients may need auth if they reconnect.
|
||||||
|
|
||||||
|
## Contact List Architecture
|
||||||
|
|
||||||
|
The Web version currently uses PHP proxy endpoints under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/www/jc-im/jc-web-im/public/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Build copies them to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/www/jc-im/jc-web-im/dist/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Current PHP APIs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
contacts.php
|
||||||
|
update_alias.php
|
||||||
|
add_contact.php
|
||||||
|
send_message.php
|
||||||
|
messages.php
|
||||||
|
im_web_bootstrap.php
|
||||||
|
```
|
||||||
|
|
||||||
|
The PHP proxy exists because the browser must not directly carry the `im-server` admin secret.
|
||||||
|
|
||||||
|
Admin config is in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public/api/im_web_bootstrap.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Current values:
|
||||||
|
|
||||||
|
```php
|
||||||
|
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||||
|
const IM_ADMIN_SECRET = '123456';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contact List API
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/contacts.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"userId":"当前登录用户ID"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Current logic:
|
||||||
|
|
||||||
|
1. Calls `im-server` admin `/admin/friend/list`.
|
||||||
|
2. Calls `/admin/user/batch_get_infos`.
|
||||||
|
3. Calls `/admin/friend/get_alias` for each friend.
|
||||||
|
4. Returns unique contacts with `uid`, `displayName`, `mobile`, `portrait`, `address`, `alias`.
|
||||||
|
|
||||||
|
This was verified to be fast locally, around milliseconds rather than the old 8-10 seconds.
|
||||||
|
|
||||||
|
### Remark / Alias Update API
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/update_alias.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"userId":"当前登录用户ID","targetId":"联系人ID","alias":"备注名"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Current logic:
|
||||||
|
|
||||||
|
1. Validates `userId`, `targetId`.
|
||||||
|
2. Allows empty alias to clear remark.
|
||||||
|
3. Limits alias to 32 UTF-8 characters.
|
||||||
|
4. Calls `im-server` admin `/admin/friend/set_alias`.
|
||||||
|
|
||||||
|
Verified manually:
|
||||||
|
|
||||||
|
- Setting alias to `测试备注` succeeded.
|
||||||
|
- `contacts.php` returned the new alias.
|
||||||
|
- Test alias was then cleared back to empty.
|
||||||
|
|
||||||
|
## Frontend Changes Completed
|
||||||
|
|
||||||
|
Files touched for contact details and alias editing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/config.js
|
||||||
|
src/services/httpImClient.js
|
||||||
|
src/stores/contacts.js
|
||||||
|
src/components/contacts/ContactPanel.vue
|
||||||
|
src/styles/base.css
|
||||||
|
public/api/update_alias.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Important behavior now:
|
||||||
|
|
||||||
|
- `contactsApi` defaults to `/api/contacts.php` in production.
|
||||||
|
- `updateAliasApi` defaults to `/api/update_alias.php` in production.
|
||||||
|
- `HttpImClient.getServerContactList()` maps `address` and `alias`.
|
||||||
|
- `HttpImClient.updateContactAlias()` posts to the PHP proxy.
|
||||||
|
- `contacts.updateContactAlias()` updates local contact state immediately after successful save.
|
||||||
|
- Contact detail display is:
|
||||||
|
- avatar / portrait
|
||||||
|
- remark name from `t_friend._alias`; if empty, show `未设置`
|
||||||
|
- username from `_display_name`
|
||||||
|
- mobile from `_mobile`
|
||||||
|
- address from `_address`; if empty, show `未知`
|
||||||
|
- A `✏️` button appears next to the remark name. Clicking it opens a modal to edit the remark.
|
||||||
|
|
||||||
|
## Build / Deploy Status
|
||||||
|
|
||||||
|
Last successful build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/jc-im/jc-web-im
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build output included:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/js/app.28165923.js
|
||||||
|
dist/css/app.08b643a5.css
|
||||||
|
dist/api/update_alias.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Build warnings were only webpack asset-size warnings for `chunk-vendors`, not functional errors.
|
||||||
|
|
||||||
|
Syntax checks passed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -l jc-web-im/public/api/update_alias.php
|
||||||
|
php -l jc-web-im/dist/api/update_alias.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Documentation
|
||||||
|
|
||||||
|
Deployment doc has been added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/www/jc-im/jc-web-im/WEB_DEPLOYMENT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
README links to it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/www/jc-im/jc-web-im/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Key deployment note:
|
||||||
|
|
||||||
|
- Production needs Nginx + PHP-FPM.
|
||||||
|
- PHP minimum recommended version: 7.4.
|
||||||
|
- PHP recommended versions: 8.1 / 8.2 / 8.3.
|
||||||
|
- Required PHP extensions: `curl`, `mbstring`.
|
||||||
|
- Nginx root should point to `/var/www/jc-im/jc-web-im/dist`.
|
||||||
|
- `18080` admin port should not be exposed publicly.
|
||||||
|
|
||||||
|
## Known Current Test User Data
|
||||||
|
|
||||||
|
Recent contact list test used:
|
||||||
|
|
||||||
|
```text
|
||||||
|
current userId: 58f71e6245e145409e69b30216c6ebad
|
||||||
|
```
|
||||||
|
|
||||||
|
Contacts returned:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FireRobot
|
||||||
|
571356e9237c460698329c1653d6fd63 用户5050 183019202118
|
||||||
|
55737875bf7a4d089102cbba03b1a8b8 用户8592 18301902118
|
||||||
|
```
|
||||||
|
|
||||||
|
Both human contacts had empty alias after the manual test was restored.
|
||||||
|
|
||||||
|
## Suggested Next Steps
|
||||||
|
|
||||||
|
Good continuation points:
|
||||||
|
|
||||||
|
1. Open the Web page on port `8023` and manually confirm the alias edit modal in the browser.
|
||||||
|
2. Check whether the contact header should also show the `✏️` edit control or only the profile card should show it.
|
||||||
|
3. Continue message send/sync polish if contact flow is accepted.
|
||||||
|
4. Consider optimizing `contacts.php` alias loading if a user has many friends, because it currently calls `get_alias` once per friend.
|
||||||
|
|
||||||
|
## Do Not Forget
|
||||||
|
|
||||||
|
- Do not modify `/opt/app-server` code.
|
||||||
|
- If changing PHP proxy admin secret or admin URL, update source under `public/api` and rebuild.
|
||||||
|
- If editing runtime config after build, check both `public/config/runtime-config.js` and `dist/config/runtime-config.js` depending on whether the change should survive future builds.
|
||||||
|
- The current workspace is `/var/www/jc-im`, not the old `/var/www/im-web`.
|
||||||
+273
-280
@@ -1,284 +1,277 @@
|
|||||||
# Web 版部署说明
|
# Web 版部署说明
|
||||||
|
|
||||||
本文档说明 `jc-web-im` 的生产部署方式。当前 Web 版由两部分组成:
|
本文档说明 `jc-web-im` 的生产部署方式。当前 Web 版由两部分组成:
|
||||||
|
|
||||||
- Vue 构建后的静态文件,部署到 Web 根目录。
|
- Vue 构建后的静态文件,部署到 Web 根目录。
|
||||||
- `dist/api/*.php` PHP 代理接口,负责安全调用本机 `im-server` / `app-server` 相关接口。
|
- `dist/api/*.php` PHP 代理接口,负责安全调用本机 `im-server` / `app-server` 相关接口。
|
||||||
|
|
||||||
浏览器不能直接携带 `im-server` admin secret 访问 `18080`,所以联系人列表、备注名修改、消息发送等功能通过 PHP 代理完成。
|
浏览器不能直接携带 `im-server` admin secret 访问 `18080`,所以联系人列表、备注名修改、消息发送等功能通过 PHP 代理完成。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```text
|
|
||||||
/var/www/jc-im/jc-web-im
|
|
||||||
├── public/api/ # PHP 代理源码,构建时复制到 dist/api
|
|
||||||
├── public/config/ # 运行时配置
|
|
||||||
├── src/ # Vue 前端源码
|
|
||||||
├── deploy/ # Nginx 示例配置
|
|
||||||
└── dist/ # npm run build 后的生产目录
|
|
||||||
```
|
|
||||||
|
|
||||||
生产环境建议让 Nginx 的 `root` 指向:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/var/www/jc-im/jc-web-im/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
服务器需要安装:
|
|
||||||
|
|
||||||
- Nginx
|
|
||||||
- PHP-FPM
|
|
||||||
- PHP 扩展:`curl`、`mbstring`
|
|
||||||
- Node.js / npm,仅构建时需要
|
|
||||||
|
|
||||||
PHP 版本:
|
|
||||||
|
|
||||||
- 最低建议:PHP 7.4
|
|
||||||
- 推荐:PHP 8.1 / 8.2 / 8.3
|
|
||||||
|
|
||||||
当前 PHP 代理代码使用 `curl_*`、`random_int`、`Throwable`、`mb_strlen` 等能力,部署前请确认 `curl` 和 `mbstring` 扩展可用。
|
|
||||||
|
|
||||||
## 后端服务要求
|
|
||||||
|
|
||||||
Web 版依赖以下服务正常运行:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app-server: http://127.0.0.1:18888
|
|
||||||
im-server HTTP/API: http://127.0.0.1:18000
|
|
||||||
im-server admin: http://127.0.0.1:18080
|
|
||||||
```
|
|
||||||
|
|
||||||
`im-server admin`、IM 数据库和应用数据库配置通过 PHP-FPM 环境变量提供:
|
|
||||||
|
|
||||||
```text
|
|
||||||
public/api/im_web_bootstrap.php
|
|
||||||
```
|
|
||||||
|
|
||||||
需要配置:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IM_ADMIN_URL=http://127.0.0.1:18080
|
/var/www/jc-im/jc-web-im
|
||||||
IM_ADMIN_SECRET=请填写管理端密钥
|
├── public/api/ # PHP 代理源码,构建时复制到 dist/api
|
||||||
IM_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4
|
├── public/config/ # 运行时配置
|
||||||
IM_DB_USER=请填写数据库用户
|
├── src/ # Vue 前端源码
|
||||||
IM_DB_PASSWORD=请填写数据库密码
|
├── deploy/ # Nginx 示例配置
|
||||||
IM_APP_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4
|
└── dist/ # npm run build 后的生产目录
|
||||||
IM_APP_DB_USER=请填写应用数据库用户
|
|
||||||
IM_APP_DB_PASSWORD=请填写应用数据库密码
|
|
||||||
IM_DB_TIMEZONE=Asia/Bangkok
|
|
||||||
```
|
```
|
||||||
|
|
||||||
请在 PHP-FPM pool、容器 secret 或部署平台的环境变量中配置,禁止把真实密钥提交到仓库。
|
生产环境建议让 Nginx 的 `root` 指向:
|
||||||
|
|
||||||
## 构建
|
```text
|
||||||
|
/var/www/jc-im/jc-web-im/dist
|
||||||
在项目目录执行:
|
```
|
||||||
|
|
||||||
```bash
|
## 依赖
|
||||||
cd /var/www/jc-im/jc-web-im
|
|
||||||
npm run build
|
服务器需要安装:
|
||||||
```
|
|
||||||
|
- Nginx
|
||||||
构建完成后,生产文件位于:
|
- PHP-FPM
|
||||||
|
- PHP 扩展:`curl`、`mbstring`
|
||||||
```text
|
- Node.js / npm,仅构建时需要
|
||||||
/var/www/jc-im/jc-web-im/dist
|
|
||||||
```
|
PHP 版本:
|
||||||
|
|
||||||
## 运行时配置
|
- 最低建议:PHP 7.4
|
||||||
|
- 推荐:PHP 8.1 / 8.2 / 8.3
|
||||||
运行时配置文件:
|
|
||||||
|
当前 PHP 代理代码使用 `curl_*`、`random_int`、`Throwable`、`mb_strlen` 等能力,部署前请确认 `curl` 和 `mbstring` 扩展可用。
|
||||||
```text
|
|
||||||
dist/config/runtime-config.js
|
## 后端服务要求
|
||||||
```
|
|
||||||
|
Web 版依赖以下服务正常运行:
|
||||||
示例:
|
|
||||||
|
```text
|
||||||
```js
|
app-server: http://127.0.0.1:18888
|
||||||
window.JC_WEB_IM_CONFIG = {
|
im-server HTTP/API: http://127.0.0.1:18000
|
||||||
appServer: 'http://your-domain:18888',
|
im-server admin: http://127.0.0.1:18080
|
||||||
registerConfigApi: 'http://your-domain:8024/api/register-config',
|
```
|
||||||
imServerHost: 'your-domain',
|
|
||||||
imRoutePort: 18000,
|
`im-server admin` 的地址和密钥在:
|
||||||
useWss: false,
|
|
||||||
wsPath: '/ws',
|
```text
|
||||||
sdkMode: 'custom-ws'
|
public/api/im_web_bootstrap.php
|
||||||
};
|
```
|
||||||
```
|
|
||||||
|
默认配置:
|
||||||
常用字段说明:
|
|
||||||
|
```php
|
||||||
| 字段 | 说明 |
|
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||||
| --- | --- |
|
const IM_ADMIN_SECRET = '123456';
|
||||||
| `appServer` | Web 登录、验证码等 App Server 地址 |
|
```
|
||||||
| `registerConfigApi` | 注册配置接口地址 |
|
|
||||||
| `imServerHost` | IM HTTP/API 和 WebSocket 使用的主机名 |
|
如果生产环境 admin 端口或 secret 不同,需要同步修改该文件后重新构建。
|
||||||
| `imRoutePort` | IM route/API 端口,当前为 `18000` |
|
|
||||||
| `useWss` | 是否使用 WSS |
|
## 构建
|
||||||
| `wsPath` | WebSocket 路径 |
|
|
||||||
| `sdkMode` | 当前使用 `custom-ws` |
|
在项目目录执行:
|
||||||
|
|
||||||
联系人相关 PHP 代理默认使用同源地址:
|
```bash
|
||||||
|
cd /var/www/jc-im/jc-web-im
|
||||||
```text
|
npm run build
|
||||||
/api/contacts.php
|
```
|
||||||
/api/update_alias.php
|
|
||||||
/api/add_contact.php
|
构建完成后,生产文件位于:
|
||||||
/api/send_message.php
|
|
||||||
/api/messages.php
|
```text
|
||||||
```
|
/var/www/jc-im/jc-web-im/dist
|
||||||
|
```
|
||||||
通常不需要在 `runtime-config.js` 里单独配置这些地址。
|
|
||||||
|
## 运行时配置
|
||||||
## Nginx 示例
|
|
||||||
|
运行时配置文件:
|
||||||
根据实际 PHP-FPM socket 调整 `fastcgi_pass`,例如 PHP 8.3:
|
|
||||||
|
```text
|
||||||
```nginx
|
dist/config/runtime-config.js
|
||||||
server {
|
```
|
||||||
listen 8023;
|
|
||||||
listen [::]:8023;
|
示例:
|
||||||
server_name your-domain _;
|
|
||||||
|
```js
|
||||||
root /var/www/jc-im/jc-web-im/dist;
|
window.JC_WEB_IM_CONFIG = {
|
||||||
index index.html;
|
appServer: 'http://your-domain:18888',
|
||||||
client_max_body_size 100m;
|
registerConfigApi: 'http://your-domain:8024/api/register-config',
|
||||||
|
imServerHost: 'your-domain',
|
||||||
location = / {
|
imRoutePort: 18000,
|
||||||
try_files /index.html =404;
|
useWss: false,
|
||||||
expires -1;
|
wsPath: '/ws',
|
||||||
add_header Cache-Control "no-store";
|
sdkMode: 'custom-ws'
|
||||||
}
|
};
|
||||||
|
```
|
||||||
location = /index.html {
|
|
||||||
expires -1;
|
常用字段说明:
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
}
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
location = /config/runtime-config.js {
|
| `appServer` | Web 登录、验证码等 App Server 地址 |
|
||||||
expires -1;
|
| `registerConfigApi` | 注册配置接口地址 |
|
||||||
add_header Cache-Control "no-store";
|
| `imServerHost` | IM HTTP/API 和 WebSocket 使用的主机名 |
|
||||||
}
|
| `imRoutePort` | IM route/API 端口,当前为 `18000` |
|
||||||
|
| `useWss` | 是否使用 WSS |
|
||||||
location ~ ^/api/.+\.php$ {
|
| `wsPath` | WebSocket 路径 |
|
||||||
include snippets/fastcgi-php.conf;
|
| `sdkMode` | 当前使用 `custom-ws` |
|
||||||
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/jc-im/jc-web-im/dist/:/tmp/";
|
|
||||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
联系人相关 PHP 代理默认使用同源地址:
|
||||||
expires -1;
|
|
||||||
add_header Cache-Control "no-store";
|
```text
|
||||||
}
|
/api/contacts.php
|
||||||
|
/api/update_alias.php
|
||||||
location / {
|
/api/add_contact.php
|
||||||
etag on;
|
/api/send_message.php
|
||||||
try_files $uri $uri/ /index.html;
|
/api/messages.php
|
||||||
expires 12h;
|
```
|
||||||
}
|
|
||||||
}
|
通常不需要在 `runtime-config.js` 里单独配置这些地址。
|
||||||
```
|
|
||||||
|
## Nginx 示例
|
||||||
启用配置后检查并重载:
|
|
||||||
|
根据实际 PHP-FPM socket 调整 `fastcgi_pass`,例如 PHP 8.3:
|
||||||
```bash
|
|
||||||
sudo nginx -t
|
```nginx
|
||||||
sudo systemctl reload nginx
|
server {
|
||||||
```
|
listen 8023;
|
||||||
|
listen [::]:8023;
|
||||||
## 防火墙建议
|
server_name your-domain _;
|
||||||
|
|
||||||
生产环境建议:
|
root /var/www/jc-im/jc-web-im/dist;
|
||||||
|
index index.html;
|
||||||
- 对外开放 Web 端口,例如 `80` / `443` / `8023`。
|
client_max_body_size 100m;
|
||||||
- `18888` 如需浏览器直接访问 app-server,需要对外开放。
|
|
||||||
- `18000` 如 WebSocket/route 需要浏览器直连,需要对外开放。
|
location = / {
|
||||||
- `18080` admin 端口不要对公网开放,只允许本机 PHP 代理访问。
|
try_files /index.html =404;
|
||||||
|
expires -1;
|
||||||
## 验证
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
检查 PHP 语法:
|
|
||||||
|
location = /index.html {
|
||||||
```bash
|
expires -1;
|
||||||
php -l /var/www/jc-im/jc-web-im/dist/api/contacts.php
|
add_header Cache-Control "no-store";
|
||||||
php -l /var/www/jc-im/jc-web-im/dist/api/update_alias.php
|
}
|
||||||
```
|
|
||||||
|
location = /config/runtime-config.js {
|
||||||
检查 Web 页面:
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
```bash
|
}
|
||||||
curl -I http://127.0.0.1:8023/
|
|
||||||
```
|
location ~ ^/api/.+\.php$ {
|
||||||
|
include snippets/fastcgi-php.conf;
|
||||||
检查联系人接口:
|
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/jc-im/jc-web-im/dist/:/tmp/";
|
||||||
|
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||||
```bash
|
expires -1;
|
||||||
curl -s -H 'Content-Type: application/json' \
|
add_header Cache-Control "no-store";
|
||||||
--data '{"userId":"你的用户ID"}' \
|
}
|
||||||
http://127.0.0.1:8023/api/contacts.php
|
|
||||||
```
|
location / {
|
||||||
|
etag on;
|
||||||
成功时返回:
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 12h;
|
||||||
```json
|
}
|
||||||
{
|
}
|
||||||
"code": 0,
|
```
|
||||||
"message": "success",
|
|
||||||
"result": {
|
启用配置后检查并重载:
|
||||||
"contacts": []
|
|
||||||
}
|
```bash
|
||||||
}
|
sudo nginx -t
|
||||||
```
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
检查备注名修改接口:
|
|
||||||
|
## 防火墙建议
|
||||||
```bash
|
|
||||||
curl -s -H 'Content-Type: application/json' \
|
生产环境建议:
|
||||||
--data '{"userId":"你的用户ID","targetId":"联系人ID","alias":"测试备注"}' \
|
|
||||||
http://127.0.0.1:8023/api/update_alias.php
|
- 对外开放 Web 端口,例如 `80` / `443` / `8023`。
|
||||||
```
|
- `18888` 如需浏览器直接访问 app-server,需要对外开放。
|
||||||
|
- `18000` 如 WebSocket/route 需要浏览器直连,需要对外开放。
|
||||||
成功时返回:
|
- `18080` admin 端口不要对公网开放,只允许本机 PHP 代理访问。
|
||||||
|
|
||||||
```json
|
## 验证
|
||||||
{
|
|
||||||
"code": 0,
|
检查 PHP 语法:
|
||||||
"message": "success",
|
|
||||||
"result": {
|
```bash
|
||||||
"contact": {
|
php -l /var/www/jc-im/jc-web-im/dist/api/contacts.php
|
||||||
"uid": "联系人ID",
|
php -l /var/www/jc-im/jc-web-im/dist/api/update_alias.php
|
||||||
"alias": "测试备注"
|
```
|
||||||
}
|
|
||||||
}
|
检查 Web 页面:
|
||||||
}
|
|
||||||
```
|
```bash
|
||||||
|
curl -I http://127.0.0.1:8023/
|
||||||
## 常见问题
|
```
|
||||||
|
|
||||||
### 页面能打开,但联系人加载失败
|
检查联系人接口:
|
||||||
|
|
||||||
优先检查:
|
```bash
|
||||||
|
curl -s -H 'Content-Type: application/json' \
|
||||||
- PHP-FPM 是否运行。
|
--data '{"userId":"你的用户ID"}' \
|
||||||
- Nginx 的 PHP location 是否命中 `/api/*.php`。
|
http://127.0.0.1:8023/api/contacts.php
|
||||||
- `curl` 扩展是否启用。
|
```
|
||||||
- `public/api/im_web_bootstrap.php` 中 `IM_ADMIN_URL` 和 `IM_ADMIN_SECRET` 是否匹配当前 `im-server`。
|
|
||||||
- `im-server` 的 `18080` admin 接口是否在本机可访问。
|
成功时返回:
|
||||||
|
|
||||||
### 修改备注名无效
|
```json
|
||||||
|
{
|
||||||
检查:
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
- `dist/api/update_alias.php` 是否存在。
|
"result": {
|
||||||
- Nginx 是否允许执行 `dist/api/*.php`。
|
"contacts": []
|
||||||
- `im-server` 是否提供 `/admin/friend/set_alias`。
|
}
|
||||||
- 当前登录用户和目标联系人是否确实是好友关系。
|
}
|
||||||
|
```
|
||||||
### 部署后仍是旧前端
|
|
||||||
|
检查备注名修改接口:
|
||||||
检查:
|
|
||||||
|
```bash
|
||||||
- Nginx `root` 是否指向新的 `dist` 目录。
|
curl -s -H 'Content-Type: application/json' \
|
||||||
- 是否执行过 `npm run build`。
|
--data '{"userId":"你的用户ID","targetId":"联系人ID","alias":"测试备注"}' \
|
||||||
- 浏览器是否缓存了旧资源。`index.html` 和 `runtime-config.js` 应配置为 `no-store`。
|
http://127.0.0.1:8023/api/update_alias.php
|
||||||
|
```
|
||||||
|
|
||||||
|
成功时返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"result": {
|
||||||
|
"contact": {
|
||||||
|
"uid": "联系人ID",
|
||||||
|
"alias": "测试备注"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 页面能打开,但联系人加载失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
- PHP-FPM 是否运行。
|
||||||
|
- Nginx 的 PHP location 是否命中 `/api/*.php`。
|
||||||
|
- `curl` 扩展是否启用。
|
||||||
|
- `public/api/im_web_bootstrap.php` 中 `IM_ADMIN_URL` 和 `IM_ADMIN_SECRET` 是否匹配当前 `im-server`。
|
||||||
|
- `im-server` 的 `18080` admin 接口是否在本机可访问。
|
||||||
|
|
||||||
|
### 修改备注名无效
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- `dist/api/update_alias.php` 是否存在。
|
||||||
|
- Nginx 是否允许执行 `dist/api/*.php`。
|
||||||
|
- `im-server` 是否提供 `/admin/friend/set_alias`。
|
||||||
|
- 当前登录用户和目标联系人是否确实是好友关系。
|
||||||
|
|
||||||
|
### 部署后仍是旧前端
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- Nginx `root` 是否指向新的 `dist` 目录。
|
||||||
|
- 是否执行过 `npm run build`。
|
||||||
|
- 浏览器是否缓存了旧资源。`index.html` 和 `runtime-config.js` 应配置为 `no-store`。
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['@vue/cli-plugin-babel/preset']
|
presets: ['@vue/cli-plugin-babel/preset']
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
server {
|
server {
|
||||||
listen 8023;
|
listen 8023;
|
||||||
listen [::]:8023;
|
listen [::]:8023;
|
||||||
server_name winchen.ezpay.asia 10.0.0.120 _;
|
server_name winchen.ezpay.asia 10.0.0.120 _;
|
||||||
|
|
||||||
root /var/www/im-web;
|
root /var/www/im-web;
|
||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
|
gzip on;
|
||||||
location = / {
|
gzip_vary on;
|
||||||
try_files /index.html =404;
|
gzip_min_length 1024;
|
||||||
expires -1;
|
gzip_types application/json;
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
}
|
location = / {
|
||||||
|
try_files /index.html =404;
|
||||||
location = /index.html {
|
expires -1;
|
||||||
expires -1;
|
add_header Cache-Control "no-store";
|
||||||
add_header Cache-Control "no-store";
|
}
|
||||||
}
|
|
||||||
|
location = /index.html {
|
||||||
location ~ ^/api/.+\.php$ {
|
expires -1;
|
||||||
include snippets/fastcgi-php.conf;
|
add_header Cache-Control "no-store";
|
||||||
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
}
|
||||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
|
||||||
expires -1;
|
location = /api/media_proxy.php {
|
||||||
}
|
include snippets/fastcgi-php.conf;
|
||||||
|
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
||||||
location / {
|
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||||
etag on;
|
fastcgi_read_timeout 300;
|
||||||
try_files $uri $uri/ /index.html;
|
}
|
||||||
expires 12h;
|
|
||||||
}
|
location ~ ^/api/.+\.php$ {
|
||||||
}
|
include snippets/fastcgi-php.conf;
|
||||||
|
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
||||||
|
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
etag on;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 12h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG_FILE=${JC_WEB_IM_UPDATE_CONFIG:-/etc/jc-web-im-update.conf}
|
||||||
|
STATE_DIR=/var/lib/jc-web-im-update
|
||||||
|
LOCK_FILE=/run/lock/jc-web-im-update.lock
|
||||||
|
|
||||||
|
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||||
|
echo "Missing configuration: $CONFIG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
|
||||||
|
: "${REPO_DIR:?REPO_DIR is required}"
|
||||||
|
: "${WEB_ROOT:?WEB_ROOT is required}"
|
||||||
|
: "${BRANCH:=main}"
|
||||||
|
|
||||||
|
case "$REPO_DIR" in
|
||||||
|
/opt/jc-web-im-release|/opt/jc-web-im-release-*) ;;
|
||||||
|
*) echo "Unsafe REPO_DIR: $REPO_DIR" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$WEB_ROOT" in
|
||||||
|
/var/www/jc-web-im|/var/www/jc-web-im-*) ;;
|
||||||
|
*) echo "Unsafe WEB_ROOT: $WEB_ROOT" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
flock -n 9 || {
|
||||||
|
echo "Another update is already running"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" fetch --quiet origin "$BRANCH"
|
||||||
|
TARGET_COMMIT=$(git -C "$REPO_DIR" rev-parse "origin/$BRANCH")
|
||||||
|
CURRENT_COMMIT=$(cat "$STATE_DIR/last_deployed" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ "$TARGET_COMMIT" == "$CURRENT_COMMIT" ]]; then
|
||||||
|
echo "Already current: $TARGET_COMMIT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" reset --hard --quiet "$TARGET_COMMIT"
|
||||||
|
DIST_DIR="$REPO_DIR/dist"
|
||||||
|
[[ -f "$DIST_DIR/index.html" ]] || {
|
||||||
|
echo "Missing dist/index.html at $TARGET_COMMIT" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS= read -r asset; do
|
||||||
|
[[ -f "$DIST_DIR/$asset" ]] || {
|
||||||
|
echo "Missing referenced asset: $asset" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done < <(grep -oE '(css|js)/[^"'"'"' ]+' "$DIST_DIR/index.html" | sort -u)
|
||||||
|
|
||||||
|
find "$DIST_DIR/api" -type f -name '*.php' -print0 |
|
||||||
|
xargs -0 -r -n1 php -l >/dev/null
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/jc-web-im/$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
[[ -f "$WEB_ROOT/index.html" ]] && cp -a "$WEB_ROOT/index.html" "$BACKUP_DIR/"
|
||||||
|
[[ -f "$WEB_ROOT/config/runtime-config.js" ]] && {
|
||||||
|
mkdir -p "$BACKUP_DIR/config"
|
||||||
|
cp -a "$WEB_ROOT/config/runtime-config.js" "$BACKUP_DIR/config/"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$WEB_ROOT"
|
||||||
|
rsync -a --exclude='/config/runtime-config.js' "$DIST_DIR/" "$WEB_ROOT/"
|
||||||
|
chown -R www-data:www-data "$WEB_ROOT"
|
||||||
|
printf '%s\n' "$TARGET_COMMIT" >"$STATE_DIR/last_deployed"
|
||||||
|
|
||||||
|
echo "Deployed: $TARGET_COMMIT -> $WEB_ROOT"
|
||||||
Vendored
+95
-95
@@ -1,95 +1,95 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$keyword = trim((string)($payload['keyword'] ?? ''));
|
$keyword = trim((string)($payload['keyword'] ?? ''));
|
||||||
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
|
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少当前用户ID');
|
respond(400, '缺少当前用户ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($keyword === '') {
|
if ($keyword === '') {
|
||||||
respond(400, '请输入要添加的联系人');
|
respond(400, '请输入要添加的联系人');
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryImUserInfo(array $payload): ?array
|
function tryImUserInfo(array $payload): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
|
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
|
||||||
return $result ?: null;
|
return $result ?: null;
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUserInfo(array $user): array
|
function normalizeUserInfo(array $user): array
|
||||||
{
|
{
|
||||||
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
|
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
|
||||||
return [
|
return [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'name' => (string)($user['name'] ?? $uid),
|
'name' => (string)($user['name'] ?? $uid),
|
||||||
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
||||||
'mobile' => (string)($user['mobile'] ?? ''),
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
'portrait' => (string)($user['portrait'] ?? ''),
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
'alias' => '',
|
'alias' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$target = tryImUserInfo(['userId' => $keyword]);
|
$target = tryImUserInfo(['userId' => $keyword]);
|
||||||
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
|
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
|
||||||
$target = tryImUserInfo(['mobile' => $keyword]);
|
$target = tryImUserInfo(['mobile' => $keyword]);
|
||||||
}
|
}
|
||||||
if (!$target) {
|
if (!$target) {
|
||||||
$target = tryImUserInfo(['name' => $keyword]);
|
$target = tryImUserInfo(['name' => $keyword]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$target) {
|
if (!$target) {
|
||||||
respond(404, '没有找到该用户');
|
respond(404, '没有找到该用户');
|
||||||
}
|
}
|
||||||
|
|
||||||
$contact = normalizeUserInfo($target);
|
$contact = normalizeUserInfo($target);
|
||||||
if ($contact['uid'] === '') {
|
if ($contact['uid'] === '') {
|
||||||
respond(404, '没有找到该用户');
|
respond(404, '没有找到该用户');
|
||||||
}
|
}
|
||||||
if ($contact['uid'] === $userId) {
|
if ($contact['uid'] === $userId) {
|
||||||
respond(400, '不能添加自己为联系人');
|
respond(400, '不能添加自己为联系人');
|
||||||
}
|
}
|
||||||
|
|
||||||
$friendResult = requireImSuccess(
|
$friendResult = requireImSuccess(
|
||||||
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
||||||
'好友列表检查失败'
|
'好友列表检查失败'
|
||||||
);
|
);
|
||||||
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
|
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
|
||||||
if (in_array($contact['uid'], $friendIds, true)) {
|
if (in_array($contact['uid'], $friendIds, true)) {
|
||||||
respond(409, '该用户已经是联系人', ['contact' => $contact]);
|
respond(409, '该用户已经是联系人', ['contact' => $contact]);
|
||||||
}
|
}
|
||||||
|
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/friend/status', [
|
imAdminPost('/admin/friend/status', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
'friendUid' => $contact['uid'],
|
'friendUid' => $contact['uid'],
|
||||||
'status' => 0,
|
'status' => 0,
|
||||||
]),
|
]),
|
||||||
'添加联系人失败'
|
'添加联系人失败'
|
||||||
);
|
);
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/friend/status', [
|
imAdminPost('/admin/friend/status', [
|
||||||
'userId' => $contact['uid'],
|
'userId' => $contact['uid'],
|
||||||
'friendUid' => $userId,
|
'friendUid' => $userId,
|
||||||
'status' => 0,
|
'status' => 0,
|
||||||
]),
|
]),
|
||||||
'添加联系人失败'
|
'添加联系人失败'
|
||||||
);
|
);
|
||||||
|
|
||||||
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
|
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
|
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+67
-71
@@ -1,71 +1,67 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$friendResult = requireImSuccess(
|
$friendResult = requireImSuccess(
|
||||||
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
||||||
'联系人加载失败'
|
'联系人加载失败'
|
||||||
);
|
);
|
||||||
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
|
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
|
||||||
return trim((string)$uid);
|
return trim((string)$uid);
|
||||||
}, $friendResult['list'] ?? []))));
|
}, $friendResult['list'] ?? []))));
|
||||||
if (!$friendIds) {
|
if (!$friendIds) {
|
||||||
respond(0, 'success', ['contacts' => []]);
|
respond(0, 'success', ['contacts' => []]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$usersResult = requireImSuccess(
|
$usersResult = requireImSuccess(
|
||||||
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
|
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
|
||||||
'联系人资料加载失败'
|
'联系人资料加载失败'
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
|
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$remarkMap = [];
|
$remarkMap = [];
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
$contactsByUid = [];
|
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
|
||||||
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
} catch (Throwable) {
|
||||||
$uid = (string)($user['userId'] ?? '');
|
$friendAliasMap = [];
|
||||||
if ($uid === '' || isset($contactsByUid[$uid])) {
|
}
|
||||||
continue;
|
|
||||||
}
|
$contactsByUid = [];
|
||||||
$alias = '';
|
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
||||||
try {
|
$uid = (string)($user['userId'] ?? '');
|
||||||
$aliasResult = requireImSuccess(
|
if ($uid === '' || isset($contactsByUid[$uid])) {
|
||||||
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
|
continue;
|
||||||
'联系人备注加载失败'
|
}
|
||||||
);
|
$alias = $friendAliasMap[$uid] ?? '';
|
||||||
$alias = trim((string)($aliasResult['alias'] ?? ''));
|
if ($alias === '') {
|
||||||
} catch (Throwable $error) {
|
$alias = $remarkMap[$uid] ?? '';
|
||||||
$alias = '';
|
}
|
||||||
}
|
$contactsByUid[$uid] = [
|
||||||
if ($alias === '') {
|
'uid' => $uid,
|
||||||
$alias = $remarkMap[$uid] ?? '';
|
'name' => (string)($user['name'] ?? $uid),
|
||||||
}
|
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
||||||
$contactsByUid[$uid] = [
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
'uid' => $uid,
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
'name' => (string)($user['name'] ?? $uid),
|
'address' => (string)($user['address'] ?? ''),
|
||||||
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
'alias' => $alias,
|
||||||
'mobile' => (string)($user['mobile'] ?? ''),
|
];
|
||||||
'portrait' => (string)($user['portrait'] ?? ''),
|
}
|
||||||
'address' => (string)($user['address'] ?? ''),
|
|
||||||
'alias' => $alias,
|
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
|
||||||
];
|
} catch (Throwable $error) {
|
||||||
}
|
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
|
||||||
|
}
|
||||||
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
|
|
||||||
} catch (Throwable $error) {
|
|
||||||
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+1173
-990
File diff suppressed because it is too large
Load Diff
Vendored
+443
-348
@@ -1,348 +1,443 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
function webImEnv(string $key, string $fallback = ''): string
|
||||||
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
|
{
|
||||||
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
|
$runtimeValue = getenv($key);
|
||||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
if ($runtimeValue !== false && $runtimeValue !== '') {
|
||||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
return $runtimeValue;
|
||||||
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
|
}
|
||||||
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
|
|
||||||
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
|
static $env = null;
|
||||||
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
|
if ($env === null) {
|
||||||
|
$env = [];
|
||||||
function respond(int $code, string $message, array $result = []): void
|
$paths = array_filter([
|
||||||
{
|
getenv('JC_WEB_IM_ENV_FILE') ?: '',
|
||||||
echo json_encode([
|
dirname(__DIR__, 2) . '/.env',
|
||||||
'code' => $code,
|
'/var/www/im-admin/.env',
|
||||||
'message' => $message,
|
'/var/www/im/.env',
|
||||||
'result' => $result,
|
]);
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
exit;
|
foreach ($paths as $path) {
|
||||||
}
|
if (!is_readable($path)) {
|
||||||
|
continue;
|
||||||
function readJsonPayload(): array
|
}
|
||||||
{
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
$line = trim($line);
|
||||||
return is_array($payload) ? $payload : [];
|
if ($line === '' || strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
function imAdminPost(string $path, array $payload): array
|
[$name, $value] = array_map('trim', explode('=', $line, 2));
|
||||||
{
|
if ($name === '') {
|
||||||
$nonce = (string)random_int(1, 1000000);
|
continue;
|
||||||
$timestamp = (string)((int)floor(microtime(true) * 1000));
|
}
|
||||||
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
|
$value = trim($value);
|
||||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
if (
|
||||||
|
strlen($value) >= 2
|
||||||
$curl = curl_init(IM_ADMIN_URL . $path);
|
&& (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))
|
||||||
if ($curl === false) {
|
) {
|
||||||
throw new RuntimeException('IM admin client init failed');
|
$value = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
|
$env[$name] = $value;
|
||||||
curl_setopt_array($curl, [
|
}
|
||||||
CURLOPT_POST => true,
|
break;
|
||||||
CURLOPT_POSTFIELDS => $body,
|
}
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
}
|
||||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
|
|
||||||
CURLOPT_CONNECTTIMEOUT_MS => 1000,
|
return $env[$key] ?? $fallback;
|
||||||
CURLOPT_TIMEOUT_MS => 4000,
|
}
|
||||||
CURLOPT_FRESH_CONNECT => true,
|
|
||||||
CURLOPT_FORBID_REUSE => true,
|
function webImFirstEnv(array $keys, string $fallback = ''): string
|
||||||
CURLOPT_HTTPHEADER => [
|
{
|
||||||
'Content-Type: application/json; charset=utf-8',
|
foreach ($keys as $key) {
|
||||||
'Connection: close',
|
$value = webImEnv($key);
|
||||||
'nonce: ' . $nonce,
|
if ($value !== '') {
|
||||||
'timestamp: ' . $timestamp,
|
return $value;
|
||||||
'sign: ' . $sign,
|
}
|
||||||
],
|
}
|
||||||
]);
|
return $fallback;
|
||||||
|
}
|
||||||
$responseBody = curl_exec($curl);
|
|
||||||
$curlError = curl_error($curl);
|
function webImMysqlDsn(string $host, string $port, string $database): string
|
||||||
curl_close($curl);
|
{
|
||||||
|
return sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||||
if ($responseBody === false || $responseBody === '') {
|
}
|
||||||
throw new RuntimeException($curlError ?: 'IM admin request failed');
|
|
||||||
}
|
define('IM_ADMIN_URL', webImEnv('JC_WEB_IM_ADMIN_URL', 'http://127.0.0.1:18080'));
|
||||||
|
define('IM_ADMIN_SECRET', webImFirstEnv(['JC_WEB_IM_ADMIN_SECRET', 'SecretKey'], '123456'));
|
||||||
$data = json_decode($responseBody, true);
|
define('IM_DB_DSN', webImMysqlDsn(
|
||||||
if (!is_array($data)) {
|
webImEnv('JC_WEB_IM_DB_HOST', webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
throw new RuntimeException('Invalid IM admin response');
|
webImEnv('JC_WEB_IM_DB_PORT', webImEnv('DB_PORT', '3306')),
|
||||||
}
|
webImEnv('JC_WEB_IM_DB_DATABASE', webImEnv('DB_DATABASE', 'wfchat'))
|
||||||
|
));
|
||||||
return $data;
|
define('IM_DB_USER', webImEnv('JC_WEB_IM_DB_USER', webImEnv('DB_USERNAME', 'winchen')));
|
||||||
}
|
define('IM_DB_PASSWORD', webImEnv('JC_WEB_IM_DB_PASSWORD', webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
|
define('IM_APP_DB_DSN', webImMysqlDsn(
|
||||||
function requireImSuccess(array $response, string $fallbackMessage): array
|
webImFirstEnv(['JC_WEB_IM_APP_DB_HOST', 'DB_HOST_2', 'DB_HOST2_2'], webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
{
|
webImFirstEnv(['JC_WEB_IM_APP_DB_PORT', 'DB_PORT_2', 'DB_PORT2_2'], webImEnv('DB_PORT', '3306')),
|
||||||
if (($response['code'] ?? -1) !== 0) {
|
webImFirstEnv(['JC_WEB_IM_APP_DB_DATABASE', 'DB_DATABASE_2', 'DB_DATABASE2_2'], webImEnv('DB_DATABASE', 'appdata'))
|
||||||
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
|
));
|
||||||
}
|
define('IM_APP_DB_USER', webImFirstEnv(['JC_WEB_IM_APP_DB_USER', 'DB_USERNAME_2', 'DB_USERNAME2_2'], webImEnv('DB_USERNAME', 'winchen')));
|
||||||
|
define('IM_APP_DB_PASSWORD', webImFirstEnv(['JC_WEB_IM_APP_DB_PASSWORD', 'DB_PASSWORD_2', 'DB_PASSWORD2_2'], webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
return is_array($response['result'] ?? null) ? $response['result'] : [];
|
define('IM_DB_TIMEZONE', webImEnv('JC_WEB_IM_DB_TIMEZONE', 'Asia/Bangkok'));
|
||||||
}
|
|
||||||
|
function respond(int $code, string $message, array $result = []): void
|
||||||
function imDb(): PDO
|
{
|
||||||
{
|
echo json_encode([
|
||||||
static $pdo = null;
|
'code' => $code,
|
||||||
if ($pdo instanceof PDO) {
|
'message' => $message,
|
||||||
return $pdo;
|
'result' => $result,
|
||||||
}
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
|
}
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
function readJsonPayload(): array
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
{
|
||||||
]);
|
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
||||||
|
return is_array($payload) ? $payload : [];
|
||||||
return $pdo;
|
}
|
||||||
}
|
|
||||||
|
function imAdminPost(string $path, array $payload): array
|
||||||
function imAppDb(): PDO
|
{
|
||||||
{
|
$nonce = (string)random_int(1, 1000000);
|
||||||
static $pdo = null;
|
$timestamp = (string)((int)floor(microtime(true) * 1000));
|
||||||
if ($pdo instanceof PDO) {
|
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
|
||||||
return $pdo;
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
}
|
|
||||||
|
$curl = curl_init(IM_ADMIN_URL . $path);
|
||||||
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
|
if ($curl === false) {
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
throw new RuntimeException('IM admin client init failed');
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
}
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
|
||||||
]);
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
return $pdo;
|
CURLOPT_POSTFIELDS => $body,
|
||||||
}
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
|
||||||
function ensureWebImAppTables(): void
|
CURLOPT_CONNECTTIMEOUT_MS => 1000,
|
||||||
{
|
CURLOPT_TIMEOUT_MS => 4000,
|
||||||
static $ensured = false;
|
CURLOPT_FRESH_CONNECT => true,
|
||||||
if ($ensured) {
|
CURLOPT_FORBID_REUSE => true,
|
||||||
return;
|
CURLOPT_HTTPHEADER => [
|
||||||
}
|
'Content-Type: application/json; charset=utf-8',
|
||||||
$ensured = true;
|
'Connection: close',
|
||||||
|
'nonce: ' . $nonce,
|
||||||
$db = imAppDb();
|
'timestamp: ' . $timestamp,
|
||||||
$db->exec(
|
'sign: ' . $sign,
|
||||||
"create table if not exists `t_web_groups` (
|
],
|
||||||
`id` bigint unsigned not null auto_increment,
|
]);
|
||||||
`group_id` varchar(64) not null,
|
|
||||||
`category` varchar(20) not null default 'group',
|
$responseBody = curl_exec($curl);
|
||||||
`name` varchar(128) not null,
|
$curlError = curl_error($curl);
|
||||||
`portrait` varchar(1024) not null default '',
|
curl_close($curl);
|
||||||
`owner_uid` varchar(64) not null,
|
|
||||||
`join_mode` varchar(20) not null default 'open',
|
if ($responseBody === false || $responseBody === '') {
|
||||||
`announcement` text null,
|
throw new RuntimeException($curlError ?: 'IM admin request failed');
|
||||||
`native_group_id` varchar(64) not null default '',
|
}
|
||||||
`native_status` varchar(32) not null default 'pending',
|
|
||||||
`native_error` varchar(255) not null default '',
|
$data = json_decode($responseBody, true);
|
||||||
`created_by` varchar(64) not null,
|
if (!is_array($data)) {
|
||||||
`created_at_ms` bigint not null,
|
throw new RuntimeException('Invalid IM admin response');
|
||||||
`updated_at_ms` bigint not null,
|
}
|
||||||
`deleted` tinyint not null default 0,
|
|
||||||
primary key (`id`),
|
return $data;
|
||||||
unique key `uk_t_web_groups_group_id` (`group_id`),
|
}
|
||||||
key `idx_t_web_groups_owner` (`owner_uid`),
|
|
||||||
key `idx_t_web_groups_category` (`category`)
|
function requireImSuccess(array $response, string $fallbackMessage): array
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
{
|
||||||
);
|
if (($response['code'] ?? -1) !== 0) {
|
||||||
try {
|
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
|
||||||
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
|
}
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
return is_array($response['result'] ?? null) ? $response['result'] : [];
|
||||||
$db->exec(
|
}
|
||||||
"create table if not exists `t_web_group_members` (
|
|
||||||
`id` bigint unsigned not null auto_increment,
|
function imDb(): PDO
|
||||||
`group_id` varchar(64) not null,
|
{
|
||||||
`user_id` varchar(64) not null,
|
static $pdo = null;
|
||||||
`role` varchar(20) not null default 'member',
|
if ($pdo instanceof PDO) {
|
||||||
`alias` varchar(128) not null default '',
|
return $pdo;
|
||||||
`joined_at_ms` bigint not null,
|
}
|
||||||
`updated_at_ms` bigint not null,
|
|
||||||
`status` tinyint not null default 0,
|
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
|
||||||
primary key (`id`),
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
key `idx_t_web_group_members_user` (`user_id`)
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
]);
|
||||||
);
|
|
||||||
$db->exec(
|
return $pdo;
|
||||||
"create table if not exists `t_web_group_messages` (
|
}
|
||||||
`id` bigint unsigned not null auto_increment,
|
|
||||||
`message_uid` varchar(64) not null,
|
function imAppDb(): PDO
|
||||||
`group_id` varchar(64) not null,
|
{
|
||||||
`sender_uid` varchar(64) not null,
|
static $pdo = null;
|
||||||
`text` text not null,
|
if ($pdo instanceof PDO) {
|
||||||
`created_at_ms` bigint not null,
|
return $pdo;
|
||||||
`edited_at_ms` bigint not null default 0,
|
}
|
||||||
`recalled` tinyint not null default 0,
|
|
||||||
primary key (`id`),
|
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
|
||||||
unique key `uk_t_web_group_messages_uid` (`message_uid`),
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
key `idx_t_web_group_messages_sender` (`sender_uid`)
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
]);
|
||||||
);
|
|
||||||
$db->exec(
|
return $pdo;
|
||||||
"create table if not exists `t_web_message_attachments` (
|
}
|
||||||
`id` bigint unsigned not null auto_increment,
|
|
||||||
`message_uid` varchar(64) not null,
|
function ensureWebImAppTables(): void
|
||||||
`conversation_type` varchar(16) not null default 'single',
|
{
|
||||||
`target_id` varchar(128) not null default '',
|
static $ensured = false;
|
||||||
`sender_uid` varchar(64) not null default '',
|
if ($ensured) {
|
||||||
`attachment_type` varchar(16) not null,
|
return;
|
||||||
`url` varchar(1024) not null,
|
}
|
||||||
`name` varchar(255) not null default '',
|
$ensured = true;
|
||||||
`mime` varchar(128) not null default '',
|
|
||||||
`size_bytes` bigint not null default 0,
|
$db = imAppDb();
|
||||||
`created_at_ms` bigint not null,
|
$db->exec(
|
||||||
primary key (`id`),
|
"create table if not exists `t_web_groups` (
|
||||||
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
|
`id` bigint unsigned not null auto_increment,
|
||||||
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
|
`group_id` varchar(64) not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`category` varchar(20) not null default 'group',
|
||||||
);
|
`name` varchar(128) not null,
|
||||||
$db->exec(
|
`portrait` varchar(1024) not null default '',
|
||||||
"create table if not exists `t_web_group_reads` (
|
`owner_uid` varchar(64) not null,
|
||||||
`id` bigint unsigned not null auto_increment,
|
`join_mode` varchar(20) not null default 'open',
|
||||||
`group_id` varchar(64) not null,
|
`announcement` text null,
|
||||||
`user_id` varchar(64) not null,
|
`native_group_id` varchar(64) not null default '',
|
||||||
`read_at_ms` bigint not null default 0,
|
`native_status` varchar(32) not null default 'pending',
|
||||||
`updated_at_ms` bigint not null,
|
`native_error` varchar(255) not null default '',
|
||||||
primary key (`id`),
|
`created_by` varchar(64) not null,
|
||||||
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
|
`created_at_ms` bigint not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`updated_at_ms` bigint not null,
|
||||||
);
|
`deleted` tinyint not null default 0,
|
||||||
$db->exec(
|
primary key (`id`),
|
||||||
"create table if not exists `t_web_group_mute_rules` (
|
unique key `uk_t_web_groups_group_id` (`group_id`),
|
||||||
`id` bigint unsigned not null auto_increment,
|
key `idx_t_web_groups_owner` (`owner_uid`),
|
||||||
`group_id` varchar(64) not null,
|
key `idx_t_web_groups_category` (`category`)
|
||||||
`mode` varchar(32) not null default 'off',
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
`schedule_type` varchar(16) not null default 'once',
|
);
|
||||||
`starts_at_ms` bigint not null default 0,
|
try {
|
||||||
`ends_at_ms` bigint not null default 0,
|
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
|
||||||
`daily_start` varchar(8) not null default '',
|
} catch (Throwable) {
|
||||||
`daily_end` varchar(8) not null default '',
|
}
|
||||||
`enabled` tinyint not null default 0,
|
$db->exec(
|
||||||
`created_by` varchar(64) not null default '',
|
"create table if not exists `t_web_group_members` (
|
||||||
`updated_at_ms` bigint not null,
|
`id` bigint unsigned not null auto_increment,
|
||||||
primary key (`id`),
|
`group_id` varchar(64) not null,
|
||||||
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
|
`user_id` varchar(64) not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`role` varchar(20) not null default 'member',
|
||||||
);
|
`alias` varchar(128) not null default '',
|
||||||
$db->exec(
|
`joined_at_ms` bigint not null,
|
||||||
"create table if not exists `t_web_broadcast_logs` (
|
`updated_at_ms` bigint not null,
|
||||||
`id` bigint unsigned not null auto_increment,
|
`status` tinyint not null default 0,
|
||||||
`sender_uid` varchar(64) not null,
|
primary key (`id`),
|
||||||
`target_count` int not null default 0,
|
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
|
||||||
`success_count` int not null default 0,
|
key `idx_t_web_group_members_user` (`user_id`)
|
||||||
`failed_count` int not null default 0,
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
`text` text not null,
|
);
|
||||||
`target_json` text null,
|
$db->exec(
|
||||||
`result_json` text null,
|
"create table if not exists `t_web_group_messages` (
|
||||||
`created_at_ms` bigint not null,
|
`id` bigint unsigned not null auto_increment,
|
||||||
primary key (`id`),
|
`message_uid` varchar(64) not null,
|
||||||
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
|
`group_id` varchar(64) not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`sender_uid` varchar(64) not null,
|
||||||
);
|
`text` text not null,
|
||||||
$db->exec(
|
`created_at_ms` bigint not null,
|
||||||
"create table if not exists `t_web_user_remarks` (
|
`edited_at_ms` bigint not null default 0,
|
||||||
`id` bigint unsigned not null auto_increment,
|
`recalled` tinyint not null default 0,
|
||||||
`owner_uid` varchar(64) not null,
|
primary key (`id`),
|
||||||
`target_uid` varchar(64) not null,
|
unique key `uk_t_web_group_messages_uid` (`message_uid`),
|
||||||
`alias` varchar(128) not null default '',
|
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
|
||||||
`created_at_ms` bigint not null,
|
key `idx_t_web_group_messages_sender` (`sender_uid`)
|
||||||
`updated_at_ms` bigint not null,
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
primary key (`id`),
|
);
|
||||||
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
|
$db->exec(
|
||||||
key `idx_t_web_user_remarks_target` (`target_uid`)
|
"create table if not exists `t_web_message_attachments` (
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`id` bigint unsigned not null auto_increment,
|
||||||
);
|
`message_uid` varchar(64) not null,
|
||||||
}
|
`conversation_type` varchar(16) not null default 'single',
|
||||||
|
`target_id` varchar(128) not null default '',
|
||||||
function webImPlaceholders(array $values): string
|
`sender_uid` varchar(64) not null default '',
|
||||||
{
|
`attachment_type` varchar(16) not null,
|
||||||
return implode(',', array_fill(0, count($values), '?'));
|
`url` varchar(1024) not null,
|
||||||
}
|
`name` varchar(255) not null default '',
|
||||||
|
`mime` varchar(128) not null default '',
|
||||||
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
|
`size_bytes` bigint not null default 0,
|
||||||
{
|
`created_at_ms` bigint not null,
|
||||||
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
|
primary key (`id`),
|
||||||
return trim((string)$targetId);
|
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
|
||||||
}, $targetIds))));
|
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
|
||||||
if ($ownerId === '' || !$targetIds) {
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
return [];
|
);
|
||||||
}
|
$db->exec(
|
||||||
|
"create table if not exists `t_web_group_reads` (
|
||||||
$statement = $db->prepare(
|
`id` bigint unsigned not null auto_increment,
|
||||||
'select `target_uid`, `alias` from `t_web_user_remarks`'
|
`group_id` varchar(64) not null,
|
||||||
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
|
`user_id` varchar(64) not null,
|
||||||
);
|
`read_at_ms` bigint not null default 0,
|
||||||
$statement->execute(array_merge([$ownerId], $targetIds));
|
`updated_at_ms` bigint not null,
|
||||||
|
primary key (`id`),
|
||||||
$remarks = [];
|
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
|
||||||
foreach ($statement->fetchAll() as $row) {
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
|
);
|
||||||
}
|
$db->exec(
|
||||||
|
"create table if not exists `t_web_group_mute_rules` (
|
||||||
return $remarks;
|
`id` bigint unsigned not null auto_increment,
|
||||||
}
|
`group_id` varchar(64) not null,
|
||||||
|
`mode` varchar(32) not null default 'off',
|
||||||
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
`schedule_type` varchar(16) not null default 'once',
|
||||||
{
|
`starts_at_ms` bigint not null default 0,
|
||||||
if ($ownerId === '' || $targetId === '') {
|
`ends_at_ms` bigint not null default 0,
|
||||||
return;
|
`daily_start` varchar(8) not null default '',
|
||||||
}
|
`daily_end` varchar(8) not null default '',
|
||||||
|
`enabled` tinyint not null default 0,
|
||||||
if ($alias === '') {
|
`created_by` varchar(64) not null default '',
|
||||||
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
|
`updated_at_ms` bigint not null,
|
||||||
$delete->execute([$ownerId, $targetId]);
|
primary key (`id`),
|
||||||
return;
|
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
|
||||||
}
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
$now = webImNowMs();
|
$db->exec(
|
||||||
$statement = $db->prepare(
|
"create table if not exists `t_web_broadcast_logs` (
|
||||||
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
|
`id` bigint unsigned not null auto_increment,
|
||||||
. ' values (?, ?, ?, ?, ?)'
|
`sender_uid` varchar(64) not null,
|
||||||
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
|
`target_count` int not null default 0,
|
||||||
);
|
`success_count` int not null default 0,
|
||||||
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
|
`failed_count` int not null default 0,
|
||||||
}
|
`text` text not null,
|
||||||
|
`target_json` text null,
|
||||||
function webImNowMs(): int
|
`result_json` text null,
|
||||||
{
|
`created_at_ms` bigint not null,
|
||||||
return (int)floor(microtime(true) * 1000);
|
primary key (`id`),
|
||||||
}
|
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
function webImMessageUid(): string
|
);
|
||||||
{
|
$db->exec(
|
||||||
$timestampPart = (webImNowMs() - 1514736000000) << 21;
|
"create table if not exists `t_web_user_remarks` (
|
||||||
return (string)($timestampPart | random_int(1, 0x1FFFFF));
|
`id` bigint unsigned not null auto_increment,
|
||||||
}
|
`owner_uid` varchar(64) not null,
|
||||||
|
`target_uid` varchar(64) not null,
|
||||||
function javaStringHashCode(string $value): int
|
`alias` varchar(128) not null default '',
|
||||||
{
|
`created_at_ms` bigint not null,
|
||||||
$hash = 0;
|
`updated_at_ms` bigint not null,
|
||||||
$length = strlen($value);
|
primary key (`id`),
|
||||||
for ($i = 0; $i < $length; $i++) {
|
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
|
||||||
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
|
key `idx_t_web_user_remarks_target` (`target_uid`)
|
||||||
if ($hash >= 0x80000000) {
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
$hash -= 0x100000000;
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
function webImPlaceholders(array $values): string
|
||||||
return $hash;
|
{
|
||||||
}
|
return implode(',', array_fill(0, count($values), '?'));
|
||||||
|
}
|
||||||
function imUserMessagesTable(string $userId): string
|
|
||||||
{
|
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
|
||||||
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
|
{
|
||||||
}
|
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
|
||||||
|
return trim((string)$targetId);
|
||||||
function imMessageTable(string $messageId): string
|
}, $targetIds))));
|
||||||
{
|
if ($ownerId === '' || !$targetIds) {
|
||||||
$timestamp = ((int)$messageId >> 21) + 1514736000000;
|
return [];
|
||||||
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
|
}
|
||||||
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
|
||||||
$year = (int)$date->format('Y');
|
$statement = $db->prepare(
|
||||||
$month = (int)$date->format('n') - 1;
|
'select `target_uid`, `alias` from `t_web_user_remarks`'
|
||||||
|
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
|
||||||
return 't_messages_' . (($year % 3) * 12 + $month);
|
);
|
||||||
}
|
$statement->execute(array_merge([$ownerId], $targetIds));
|
||||||
|
|
||||||
|
$remarks = [];
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
|
||||||
|
{
|
||||||
|
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
|
||||||
|
return trim((string)$friendId);
|
||||||
|
}, $friendIds))));
|
||||||
|
if ($ownerId === '' || !$friendIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliases = [];
|
||||||
|
foreach (array_chunk($friendIds, 500) as $chunk) {
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_friend_uid`, `_alias` from `t_friend`'
|
||||||
|
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$ownerId], $chunk));
|
||||||
|
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
||||||
|
{
|
||||||
|
if ($ownerId === '' || $targetId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($alias === '') {
|
||||||
|
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
|
||||||
|
$delete->execute([$ownerId, $targetId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = webImNowMs();
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
|
||||||
|
. ' values (?, ?, ?, ?, ?)'
|
||||||
|
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
|
);
|
||||||
|
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImNowMs(): int
|
||||||
|
{
|
||||||
|
return (int)floor(microtime(true) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImMessageUid(): string
|
||||||
|
{
|
||||||
|
$timestampPart = (webImNowMs() - 1514736000000) << 21;
|
||||||
|
return (string)($timestampPart | random_int(1, 0x1FFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
function javaStringHashCode(string $value): int
|
||||||
|
{
|
||||||
|
$hash = 0;
|
||||||
|
$length = strlen($value);
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
|
||||||
|
if ($hash >= 0x80000000) {
|
||||||
|
$hash -= 0x100000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imUserMessagesTable(string $userId): string
|
||||||
|
{
|
||||||
|
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imMessageTable(string $messageId): string
|
||||||
|
{
|
||||||
|
$timestamp = ((int)$messageId >> 21) + 1514736000000;
|
||||||
|
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
|
||||||
|
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||||
|
$year = (int)$date->format('Y');
|
||||||
|
$month = (int)$date->format('n') - 1;
|
||||||
|
|
||||||
|
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+58
-58
@@ -1,58 +1,58 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
|
|
||||||
function failDownload(int $status, string $message): never
|
function failDownload(int $status, string $message): never
|
||||||
{
|
{
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
echo $message;
|
echo $message;
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDownloadName(string $fileName, string $fallback): string
|
function safeDownloadName(string $fileName, string $fallback): string
|
||||||
{
|
{
|
||||||
$name = basename(str_replace('\\', '/', $fileName));
|
$name = basename(str_replace('\\', '/', $fileName));
|
||||||
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
|
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
|
||||||
$name = trim($name, " .\t\n\r\0\x0B");
|
$name = trim($name, " .\t\n\r\0\x0B");
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
$name = $fallback;
|
$name = $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mb_substr($name, 0, 180, 'UTF-8');
|
return mb_substr($name, 0, 180, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = trim((string)($_GET['url'] ?? ''));
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
$requestedName = trim((string)($_GET['name'] ?? ''));
|
$requestedName = trim((string)($_GET['name'] ?? ''));
|
||||||
if ($url === '') {
|
if ($url === '') {
|
||||||
failDownload(400, '缺少媒体地址');
|
failDownload(400, '缺少媒体地址');
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
|
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
|
||||||
$path = rawurldecode($path);
|
$path = rawurldecode($path);
|
||||||
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||||
failDownload(400, '媒体地址不合法');
|
failDownload(400, '媒体地址不合法');
|
||||||
}
|
}
|
||||||
|
|
||||||
$root = realpath(WEB_MEDIA_ROOT);
|
$root = realpath(WEB_MEDIA_ROOT);
|
||||||
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
|
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
|
||||||
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||||
failDownload(404, '媒体文件不存在');
|
failDownload(404, '媒体文件不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
$fallbackName = basename($fullPath);
|
$fallbackName = basename($fullPath);
|
||||||
$downloadName = safeDownloadName($requestedName, $fallbackName);
|
$downloadName = safeDownloadName($requestedName, $fallbackName);
|
||||||
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
|
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
|
||||||
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
|
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
|
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
|
||||||
header('Content-Type: ' . $mime);
|
header('Content-Type: ' . $mime);
|
||||||
header('Content-Length: ' . (string)filesize($fullPath));
|
header('Content-Length: ' . (string)filesize($fullPath));
|
||||||
header(
|
header(
|
||||||
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
|
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
|
||||||
. rawurlencode($downloadName)
|
. rawurlencode($downloadName)
|
||||||
);
|
);
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
readfile($fullPath);
|
readfile($fullPath);
|
||||||
|
|||||||
Vendored
+236
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
|
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||||
|
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||||
|
|
||||||
|
function failMediaProxy(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo $message;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyAllowedHosts(): array
|
||||||
|
{
|
||||||
|
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||||
|
$hosts = array_map(
|
||||||
|
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||||
|
preg_split('/[,\s]+/', $configured) ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($hosts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedMediaProxyUrl(string $url): array
|
||||||
|
{
|
||||||
|
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||||
|
failMediaProxy(400, '媒体地址不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($url);
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||||
|
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||||
|
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||||
|
if (
|
||||||
|
$scheme !== 'https'
|
||||||
|
|| $host === ''
|
||||||
|
|| $port !== 443
|
||||||
|
|| isset($parts['user'])
|
||||||
|
|| isset($parts['pass'])
|
||||||
|
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||||
|
) {
|
||||||
|
failMediaProxy(403, '媒体来源不允许代理');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$url, $host];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyCacheRoot(): string
|
||||||
|
{
|
||||||
|
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||||
|
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||||
|
failMediaProxy(503, '媒体缓存目录不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyPreferredIps(): array
|
||||||
|
{
|
||||||
|
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||||
|
$ips = [];
|
||||||
|
foreach ($configured as $ip) {
|
||||||
|
$ip = trim($ip);
|
||||||
|
if (
|
||||||
|
$ip !== ''
|
||||||
|
&& filter_var(
|
||||||
|
$ip,
|
||||||
|
FILTER_VALIDATE_IP,
|
||||||
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||||
|
) !== false
|
||||||
|
) {
|
||||||
|
$ips[$ip] = $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($ips);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyMime(string $path): string
|
||||||
|
{
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||||
|
if ($finfo !== false) {
|
||||||
|
finfo_close($finfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||||
|
$lastError = '媒体源无响应';
|
||||||
|
$preferredIps = mediaProxyPreferredIps();
|
||||||
|
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||||
|
|
||||||
|
foreach ($targets as $index => $preferredIp) {
|
||||||
|
if (microtime(true) >= $deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$attempt = $index + 1;
|
||||||
|
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||||
|
$stream = @fopen($temporaryFile, 'x+b');
|
||||||
|
if ($stream === false) {
|
||||||
|
$lastError = '无法创建媒体缓存文件';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = 0;
|
||||||
|
$tooLarge = false;
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
fclose($stream);
|
||||||
|
@unlink($temporaryFile);
|
||||||
|
$lastError = '无法初始化媒体请求';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||||
|
$curlOptions = [
|
||||||
|
CURLOPT_RETURNTRANSFER => false,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||||
|
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||||
|
CURLOPT_NOSIGNAL => true,
|
||||||
|
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||||
|
CURLOPT_FRESH_CONNECT => true,
|
||||||
|
CURLOPT_FORBID_REUSE => true,
|
||||||
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||||
|
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||||
|
$length = strlen($chunk);
|
||||||
|
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||||
|
$tooLarge = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$written = fwrite($stream, $chunk);
|
||||||
|
if ($written === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$bytes += $written;
|
||||||
|
return $written;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||||
|
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||||
|
}
|
||||||
|
curl_setopt_array($curl, $curlOptions);
|
||||||
|
|
||||||
|
$result = curl_exec($curl);
|
||||||
|
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlError = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
fflush($stream);
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||||
|
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||||
|
if (!@rename($temporaryFile, $cacheFile)) {
|
||||||
|
@unlink($temporaryFile);
|
||||||
|
$lastError = '媒体缓存保存失败';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
@chmod($cacheFile, 0644);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($temporaryFile);
|
||||||
|
$lastError = $tooLarge
|
||||||
|
? '图片超过代理大小限制'
|
||||||
|
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||||
|
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||||
|
usleep(150000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||||
|
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||||
|
{
|
||||||
|
if (!is_file($cacheFile)) {
|
||||||
|
failMediaProxy(404, '媒体缓存不存在');
|
||||||
|
}
|
||||||
|
$mime = mediaProxyMime($cacheFile);
|
||||||
|
if (!str_starts_with($mime, 'image/')) {
|
||||||
|
failMediaProxy(415, '媒体内容不是图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
header('ETag: "' . $etag . '"');
|
||||||
|
header('Cache-Control: public, max-age=86400, immutable');
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||||
|
header('Content-Disposition: inline');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||||
|
http_response_code(304);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
readfile($cacheFile);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||||
|
$cacheRoot = mediaProxyCacheRoot();
|
||||||
|
$cacheKey = hash('sha256', $url);
|
||||||
|
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||||
|
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||||
|
|
||||||
|
if (!is_file($cacheFile)) {
|
||||||
|
$lock = @fopen($lockFile, 'c');
|
||||||
|
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||||
|
if (is_resource($lock)) {
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||||
|
}
|
||||||
|
if (!is_file($cacheFile)) {
|
||||||
|
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||||
|
}
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||||
Vendored
+288
-288
@@ -1,288 +1,288 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
class UserActionException extends RuntimeException
|
class UserActionException extends RuntimeException
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActionUserId(string $value): string
|
function resolveActionUserId(string $value): string
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = imDb();
|
$db = imDb();
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$value, $value, $value]);
|
$statement->execute([$value, $value, $value]);
|
||||||
$uid = $statement->fetchColumn();
|
$uid = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_string($uid) && $uid !== '' ? $uid : $value;
|
return is_string($uid) && $uid !== '' ? $uid : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageTimestampMs(string $messageUid): int
|
function messageTimestampMs(string $messageUid): int
|
||||||
{
|
{
|
||||||
return (((int)$messageUid) >> 21) + 1514736000000;
|
return (((int)$messageUid) >> 21) + 1514736000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowTimestampMs(): int
|
function nowTimestampMs(): int
|
||||||
{
|
{
|
||||||
return (int)floor(microtime(true) * 1000);
|
return (int)floor(microtime(true) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recallLimitMinutes(): float
|
function recallLimitMinutes(): float
|
||||||
{
|
{
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `conf_value` from `system_conf`'
|
'select `conf_value` from `system_conf`'
|
||||||
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
|
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute(['RetractMessage']);
|
$statement->execute(['RetractMessage']);
|
||||||
$value = trim((string)($statement->fetchColumn() ?: '0'));
|
$value = trim((string)($statement->fetchColumn() ?: '0'));
|
||||||
|
|
||||||
if ($value === '' || !is_numeric($value)) {
|
if ($value === '' || !is_numeric($value)) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return max(0.0, (float)$value);
|
return max(0.0, (float)$value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertRecallWithinLimit(string $messageUid): void
|
function assertRecallWithinLimit(string $messageUid): void
|
||||||
{
|
{
|
||||||
$limitMinutes = recallLimitMinutes();
|
$limitMinutes = recallLimitMinutes();
|
||||||
if ($limitMinutes <= 0) {
|
if ($limitMinutes <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
|
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
|
||||||
$limitMs = (int)round($limitMinutes * 60 * 1000);
|
$limitMs = (int)round($limitMinutes * 60 * 1000);
|
||||||
if ($messageAgeMs > $limitMs) {
|
if ($messageAgeMs > $limitMs) {
|
||||||
throw new UserActionException('已超过可撤回时间');
|
throw new UserActionException('已超过可撤回时间');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchActionMessageRow(string $messageUid): array
|
function fetchActionMessageRow(string $messageUid): array
|
||||||
{
|
{
|
||||||
$table = imMessageTable($messageUid);
|
$table = imMessageTable($messageUid);
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
|
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$messageUid]);
|
$statement->execute([$messageUid]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
|
|
||||||
return is_array($row) ? $row : [];
|
return is_array($row) ? $row : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMessageEditsTable(): void
|
function ensureMessageEditsTable(): void
|
||||||
{
|
{
|
||||||
imDb()->exec(
|
imDb()->exec(
|
||||||
'create table if not exists `t_web_message_edits` ('
|
'create table if not exists `t_web_message_edits` ('
|
||||||
. '`message_uid` varchar(32) not null,'
|
. '`message_uid` varchar(32) not null,'
|
||||||
. '`editor_uid` varchar(64) not null,'
|
. '`editor_uid` varchar(64) not null,'
|
||||||
. '`edited_text` text null,'
|
. '`edited_text` text null,'
|
||||||
. '`edited_at_ms` bigint not null,'
|
. '`edited_at_ms` bigint not null,'
|
||||||
. '`created_at` datetime not null default current_timestamp,'
|
. '`created_at` datetime not null default current_timestamp,'
|
||||||
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
|
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
|
||||||
. 'primary key (`message_uid`),'
|
. 'primary key (`message_uid`),'
|
||||||
. 'key `idx_editor_uid` (`editor_uid`),'
|
. 'key `idx_editor_uid` (`editor_uid`),'
|
||||||
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||||
{
|
{
|
||||||
$row = fetchActionMessageRow($messageUid);
|
$row = fetchActionMessageRow($messageUid);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
return editWebGroupMessage($operatorId, $messageUid, $text);
|
return editWebGroupMessage($operatorId, $messageUid, $text);
|
||||||
}
|
}
|
||||||
if ((string)$row['_from'] !== $operatorId) {
|
if ((string)$row['_from'] !== $operatorId) {
|
||||||
throw new UserActionException('只能编辑自己发送的消息');
|
throw new UserActionException('只能编辑自己发送的消息');
|
||||||
}
|
}
|
||||||
if ((int)$row['_content_type'] !== 1) {
|
if ((int)$row['_content_type'] !== 1) {
|
||||||
throw new UserActionException('只能编辑文字消息');
|
throw new UserActionException('只能编辑文字消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$editedAt = nowTimestampMs();
|
$editedAt = nowTimestampMs();
|
||||||
$table = imMessageTable($messageUid);
|
$table = imMessageTable($messageUid);
|
||||||
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
|
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
|
||||||
$update->execute([$text, $messageUid]);
|
$update->execute([$text, $messageUid]);
|
||||||
|
|
||||||
ensureMessageEditsTable();
|
ensureMessageEditsTable();
|
||||||
$upsert = imDb()->prepare(
|
$upsert = imDb()->prepare(
|
||||||
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
|
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
|
||||||
. ' values (?, ?, ?, ?)'
|
. ' values (?, ?, ?, ?)'
|
||||||
. ' on duplicate key update'
|
. ' on duplicate key update'
|
||||||
. ' `editor_uid` = values(`editor_uid`),'
|
. ' `editor_uid` = values(`editor_uid`),'
|
||||||
. ' `edited_text` = values(`edited_text`),'
|
. ' `edited_text` = values(`edited_text`),'
|
||||||
. ' `edited_at_ms` = values(`edited_at_ms`),'
|
. ' `edited_at_ms` = values(`edited_at_ms`),'
|
||||||
. ' `updated_at` = current_timestamp'
|
. ' `updated_at` = current_timestamp'
|
||||||
);
|
);
|
||||||
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
|
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'text' => $text,
|
'text' => $text,
|
||||||
'editedAt' => $editedAt,
|
'editedAt' => $editedAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchWebGroupActionMessage(string $messageUid): array
|
function fetchWebGroupActionMessage(string $messageUid): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select m.*, g.`category` from `t_web_group_messages` m'
|
'select m.*, g.`category` from `t_web_group_messages` m'
|
||||||
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
|
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
|
||||||
. ' where m.`message_uid` = ? limit 1'
|
. ' where m.`message_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$messageUid]);
|
$statement->execute([$messageUid]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
|
|
||||||
return is_array($row) ? $row : [];
|
return is_array($row) ? $row : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
|
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
|
||||||
{
|
{
|
||||||
$row = fetchWebGroupActionMessage($messageUid);
|
$row = fetchWebGroupActionMessage($messageUid);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new UserActionException('消息不存在');
|
throw new UserActionException('消息不存在');
|
||||||
}
|
}
|
||||||
if ((string)$row['sender_uid'] !== $operatorId) {
|
if ((string)$row['sender_uid'] !== $operatorId) {
|
||||||
throw new UserActionException('只能编辑自己发送的消息');
|
throw new UserActionException('只能编辑自己发送的消息');
|
||||||
}
|
}
|
||||||
if ((int)($row['recalled'] ?? 0) !== 0) {
|
if ((int)($row['recalled'] ?? 0) !== 0) {
|
||||||
throw new UserActionException('已撤回的消息请重新编辑发送');
|
throw new UserActionException('已撤回的消息请重新编辑发送');
|
||||||
}
|
}
|
||||||
|
|
||||||
$editedAt = nowTimestampMs();
|
$editedAt = nowTimestampMs();
|
||||||
$update = imAppDb()->prepare(
|
$update = imAppDb()->prepare(
|
||||||
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
|
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
|
||||||
);
|
);
|
||||||
$update->execute([$text, $editedAt, $messageUid]);
|
$update->execute([$text, $editedAt, $messageUid]);
|
||||||
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||||
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
|
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'text' => $text,
|
'text' => $text,
|
||||||
'editedAt' => $editedAt,
|
'editedAt' => $editedAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function webGroupMemberRoleForAction(string $groupId, string $userId): string
|
function webGroupMemberRoleForAction(string $groupId, string $userId): string
|
||||||
{
|
{
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `role` from `t_web_group_members`'
|
'select `role` from `t_web_group_members`'
|
||||||
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$groupId, $userId]);
|
$statement->execute([$groupId, $userId]);
|
||||||
$role = $statement->fetchColumn();
|
$role = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_string($role) ? $role : '';
|
return is_string($role) ? $role : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
|
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
|
||||||
{
|
{
|
||||||
$row = fetchWebGroupActionMessage($messageUid);
|
$row = fetchWebGroupActionMessage($messageUid);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$isSender = (string)$row['sender_uid'] === $operatorId;
|
$isSender = (string)$row['sender_uid'] === $operatorId;
|
||||||
if (!$isSender) {
|
if (!$isSender) {
|
||||||
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
|
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
|
||||||
if (!in_array($role, ['owner', 'admin'], true)) {
|
if (!in_array($role, ['owner', 'admin'], true)) {
|
||||||
throw new UserActionException('只有群主或管理员可以撤回他人消息');
|
throw new UserActionException('只有群主或管理员可以撤回他人消息');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertRecallWithinLimit($messageUid);
|
assertRecallWithinLimit($messageUid);
|
||||||
|
|
||||||
$now = nowTimestampMs();
|
$now = nowTimestampMs();
|
||||||
$update = imAppDb()->prepare(
|
$update = imAppDb()->prepare(
|
||||||
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
|
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
|
||||||
);
|
);
|
||||||
$update->execute([$messageUid]);
|
$update->execute([$messageUid]);
|
||||||
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||||
$groupUpdate->execute([$now, (string)$row['group_id']]);
|
$groupUpdate->execute([$now, (string)$row['group_id']]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$action = trim((string)($payload['action'] ?? ''));
|
$action = trim((string)($payload['action'] ?? ''));
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
|
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
|
||||||
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
|
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
|
||||||
|
|
||||||
if ($action === '' || $messageUid <= 0) {
|
if ($action === '' || $messageUid <= 0) {
|
||||||
respond(400, '缺少操作类型或消息ID');
|
respond(400, '缺少操作类型或消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($action === 'recall') {
|
if ($action === 'recall') {
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少操作者ID');
|
respond(400, '缺少操作者ID');
|
||||||
}
|
}
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
$row = fetchActionMessageRow($messageUidText);
|
$row = fetchActionMessageRow($messageUidText);
|
||||||
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
|
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'messageUid' => (string)$messageUid,
|
'messageUid' => (string)$messageUid,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new UserActionException('消息不存在');
|
throw new UserActionException('消息不存在');
|
||||||
}
|
}
|
||||||
assertRecallWithinLimit($messageUidText);
|
assertRecallWithinLimit($messageUidText);
|
||||||
requireImSuccess(imAdminPost('/admin/message/recall', [
|
requireImSuccess(imAdminPost('/admin/message/recall', [
|
||||||
'operator' => $userId,
|
'operator' => $userId,
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'userRecall' => true,
|
'userRecall' => true,
|
||||||
'isUserRecall' => true,
|
'isUserRecall' => true,
|
||||||
]), '撤回失败');
|
]), '撤回失败');
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'messageUid' => (string)$messageUid,
|
'messageUid' => (string)$messageUid,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'edit') {
|
if ($action === 'edit') {
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少操作者ID');
|
respond(400, '缺少操作者ID');
|
||||||
}
|
}
|
||||||
$text = trim((string)($payload['text'] ?? ''));
|
$text = trim((string)($payload['text'] ?? ''));
|
||||||
if ($text === '') {
|
if ($text === '') {
|
||||||
respond(400, '编辑内容不能为空');
|
respond(400, '编辑内容不能为空');
|
||||||
}
|
}
|
||||||
if (mb_strlen($text, 'UTF-8') > 5000) {
|
if (mb_strlen($text, 'UTF-8') > 5000) {
|
||||||
respond(400, '编辑内容过长');
|
respond(400, '编辑内容过长');
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'message' => editTextMessage($userId, $messageUidText, $text),
|
'message' => editTextMessage($userId, $messageUidText, $text),
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'delete') {
|
if ($action === 'delete') {
|
||||||
requireImSuccess(imAdminPost('/admin/message/delete', [
|
requireImSuccess(imAdminPost('/admin/message/delete', [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
]), '删除失败');
|
]), '删除失败');
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'messageUid' => (string)$messageUid,
|
'messageUid' => (string)$messageUid,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(400, '不支持的消息操作');
|
respond(400, '不支持的消息操作');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
|
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+387
-387
@@ -1,387 +1,387 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
function detailPlaceholders(array $values): string
|
function detailPlaceholders(array $values): string
|
||||||
{
|
{
|
||||||
return implode(',', array_fill(0, count($values), '?'));
|
return implode(',', array_fill(0, count($values), '?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
|
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
|
||||||
{
|
{
|
||||||
$id = (int)$messageId;
|
$id = (int)$messageId;
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
return ($id >> 21) + 1514736000000;
|
return ($id >> 21) + 1514736000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($datetime !== null && $datetime !== '') {
|
if ($datetime !== null && $datetime !== '') {
|
||||||
$date = DateTimeImmutable::createFromFormat(
|
$date = DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d H:i:s',
|
'Y-m-d H:i:s',
|
||||||
$datetime,
|
$datetime,
|
||||||
new DateTimeZone(IM_DB_TIMEZONE)
|
new DateTimeZone(IM_DB_TIMEZONE)
|
||||||
);
|
);
|
||||||
if ($date instanceof DateTimeImmutable) {
|
if ($date instanceof DateTimeImmutable) {
|
||||||
return $date->getTimestamp() * 1000;
|
return $date->getTimestamp() * 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailResolveUserId(PDO $db, string $value): string
|
function detailResolveUserId(PDO $db, string $value): string
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$value, $value, $value]);
|
$statement->execute([$value, $value, $value]);
|
||||||
$uid = $statement->fetchColumn();
|
$uid = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_string($uid) && $uid !== '' ? $uid : $value;
|
return is_string($uid) && $uid !== '' ? $uid : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailConversationType(mixed $value): string
|
function detailConversationType(mixed $value): string
|
||||||
{
|
{
|
||||||
$type = strtolower(trim((string)$value));
|
$type = strtolower(trim((string)$value));
|
||||||
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
|
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailDeliveryMap(PDO $db, array $userIds): array
|
function detailDeliveryMap(PDO $db, array $userIds): array
|
||||||
{
|
{
|
||||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||||
if (!$userIds) {
|
if (!$userIds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
|
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
|
||||||
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
|
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
|
||||||
);
|
);
|
||||||
$statement->execute($userIds);
|
$statement->execute($userIds);
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($statement->fetchAll() as $row) {
|
foreach ($statement->fetchAll() as $row) {
|
||||||
$items[(string)$row['_uid']] = (int)$row['_dt'];
|
$items[(string)$row['_uid']] = (int)$row['_dt'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailReadAt(PDO $db, string $readerId, array $targets): int
|
function detailReadAt(PDO $db, string $readerId, array $targets): int
|
||||||
{
|
{
|
||||||
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
|
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
|
||||||
if ($readerId === '' || !$targets) {
|
if ($readerId === '' || !$targets) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select max(`_dt`) from `t_read_report`'
|
'select max(`_dt`) from `t_read_report`'
|
||||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
||||||
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
|
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
|
||||||
);
|
);
|
||||||
$statement->execute(array_merge([$readerId], $targets));
|
$statement->execute(array_merge([$readerId], $targets));
|
||||||
$value = $statement->fetchColumn();
|
$value = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_numeric($value) ? (int)$value : 0;
|
return is_numeric($value) ? (int)$value : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
|
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
|
||||||
{
|
{
|
||||||
$table = imUserMessagesTable($userId);
|
$table = imUserMessagesTable($userId);
|
||||||
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
|
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select 1 from `' . $table . '`'
|
'select 1 from `' . $table . '`'
|
||||||
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
|
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
|
||||||
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
|
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
|
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
|
||||||
|
|
||||||
return (bool)$statement->fetchColumn();
|
return (bool)$statement->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
|
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
|
||||||
{
|
{
|
||||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||||
if (!$userIds) {
|
if (!$userIds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $imDb->prepare(
|
$statement = $imDb->prepare(
|
||||||
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
|
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
|
||||||
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
|
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
|
||||||
);
|
);
|
||||||
$statement->execute($userIds);
|
$statement->execute($userIds);
|
||||||
|
|
||||||
$users = [];
|
$users = [];
|
||||||
foreach ($statement->fetchAll() as $row) {
|
foreach ($statement->fetchAll() as $row) {
|
||||||
$uid = (string)$row['_uid'];
|
$uid = (string)$row['_uid'];
|
||||||
$rawName = trim((string)($row['_display_name'] ?? ''))
|
$rawName = trim((string)($row['_display_name'] ?? ''))
|
||||||
?: trim((string)($row['_name'] ?? ''))
|
?: trim((string)($row['_name'] ?? ''))
|
||||||
?: trim((string)($row['_mobile'] ?? ''))
|
?: trim((string)($row['_mobile'] ?? ''))
|
||||||
?: $uid;
|
?: $uid;
|
||||||
$users[$uid] = [
|
$users[$uid] = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'rawName' => $rawName,
|
'rawName' => $rawName,
|
||||||
'mobile' => (string)($row['_mobile'] ?? ''),
|
'mobile' => (string)($row['_mobile'] ?? ''),
|
||||||
'portrait' => (string)($row['_portrait'] ?? ''),
|
'portrait' => (string)($row['_portrait'] ?? ''),
|
||||||
'contactAlias' => '',
|
'contactAlias' => '',
|
||||||
'remarkAlias' => '',
|
'remarkAlias' => '',
|
||||||
'groupAlias' => '',
|
'groupAlias' => '',
|
||||||
'role' => '',
|
'role' => '',
|
||||||
'displayName' => $rawName,
|
'displayName' => $rawName,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ownerId !== '') {
|
if ($ownerId !== '') {
|
||||||
$aliasStatement = $imDb->prepare(
|
$aliasStatement = $imDb->prepare(
|
||||||
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
|
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
|
||||||
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
|
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
|
||||||
);
|
);
|
||||||
$aliasStatement->execute(array_merge([$ownerId], $userIds));
|
$aliasStatement->execute(array_merge([$ownerId], $userIds));
|
||||||
foreach ($aliasStatement->fetchAll() as $row) {
|
foreach ($aliasStatement->fetchAll() as $row) {
|
||||||
$uid = (string)$row['_friend_uid'];
|
$uid = (string)$row['_friend_uid'];
|
||||||
if (isset($users[$uid])) {
|
if (isset($users[$uid])) {
|
||||||
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
|
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
|
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$remarkMap = [];
|
$remarkMap = [];
|
||||||
}
|
}
|
||||||
foreach ($remarkMap as $uid => $alias) {
|
foreach ($remarkMap as $uid => $alias) {
|
||||||
if (isset($users[$uid])) {
|
if (isset($users[$uid])) {
|
||||||
$users[$uid]['remarkAlias'] = trim((string)$alias);
|
$users[$uid]['remarkAlias'] = trim((string)$alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($groupId !== '') {
|
if ($groupId !== '') {
|
||||||
$memberStatement = $appDb->prepare(
|
$memberStatement = $appDb->prepare(
|
||||||
'select `user_id`, `alias`, `role` from `t_web_group_members`'
|
'select `user_id`, `alias`, `role` from `t_web_group_members`'
|
||||||
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
|
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
|
||||||
);
|
);
|
||||||
$memberStatement->execute(array_merge([$groupId], $userIds));
|
$memberStatement->execute(array_merge([$groupId], $userIds));
|
||||||
foreach ($memberStatement->fetchAll() as $row) {
|
foreach ($memberStatement->fetchAll() as $row) {
|
||||||
$uid = (string)$row['user_id'];
|
$uid = (string)$row['user_id'];
|
||||||
if (isset($users[$uid])) {
|
if (isset($users[$uid])) {
|
||||||
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
|
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
|
||||||
$users[$uid]['role'] = (string)($row['role'] ?? '');
|
$users[$uid]['role'] = (string)($row['role'] ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($users as $uid => $user) {
|
foreach ($users as $uid => $user) {
|
||||||
$alias = (string)$user['contactAlias'] !== ''
|
$alias = (string)$user['contactAlias'] !== ''
|
||||||
? (string)$user['contactAlias']
|
? (string)$user['contactAlias']
|
||||||
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
|
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
|
||||||
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
|
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($userIds as $uid) {
|
foreach ($userIds as $uid) {
|
||||||
if (!isset($users[$uid])) {
|
if (!isset($users[$uid])) {
|
||||||
$users[$uid] = [
|
$users[$uid] = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'rawName' => $uid,
|
'rawName' => $uid,
|
||||||
'mobile' => '',
|
'mobile' => '',
|
||||||
'portrait' => '',
|
'portrait' => '',
|
||||||
'contactAlias' => '',
|
'contactAlias' => '',
|
||||||
'remarkAlias' => '',
|
'remarkAlias' => '',
|
||||||
'groupAlias' => '',
|
'groupAlias' => '',
|
||||||
'role' => '',
|
'role' => '',
|
||||||
'displayName' => $uid,
|
'displayName' => $uid,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailMember(array $user, int $time = 0): array
|
function detailMember(array $user, int $time = 0): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'uid' => (string)$user['uid'],
|
'uid' => (string)$user['uid'],
|
||||||
'name' => (string)$user['displayName'],
|
'name' => (string)$user['displayName'],
|
||||||
'rawName' => (string)$user['rawName'],
|
'rawName' => (string)$user['rawName'],
|
||||||
'mobile' => (string)($user['mobile'] ?? ''),
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
'portrait' => (string)($user['portrait'] ?? ''),
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
'role' => (string)($user['role'] ?? ''),
|
'role' => (string)($user['role'] ?? ''),
|
||||||
'time' => $time,
|
'time' => $time,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
|
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
|
||||||
{
|
{
|
||||||
if (!preg_match('/^\d+$/', $messageUid)) {
|
if (!preg_match('/^\d+$/', $messageUid)) {
|
||||||
respond(400, '缺少可查询的消息ID');
|
respond(400, '缺少可查询的消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$table = imMessageTable($messageUid);
|
$table = imMessageTable($messageUid);
|
||||||
$statement = $imDb->prepare(
|
$statement = $imDb->prepare(
|
||||||
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
|
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
|
||||||
. ' from `' . $table . '` where `_mid` = ? limit 1'
|
. ' from `' . $table . '` where `_mid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$messageUid]);
|
$statement->execute([$messageUid]);
|
||||||
$message = $statement->fetch();
|
$message = $statement->fetch();
|
||||||
if (!is_array($message)) {
|
if (!is_array($message)) {
|
||||||
respond(404, '没有找到该消息');
|
respond(404, '没有找到该消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$senderId = (string)$message['_from'];
|
$senderId = (string)$message['_from'];
|
||||||
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
|
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
|
||||||
respond(403, '没有权限查看该消息');
|
respond(403, '没有权限查看该消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$receiverId = $senderId === $userId ? $targetId : $userId;
|
$receiverId = $senderId === $userId ? $targetId : $userId;
|
||||||
if ($senderId !== $userId && $senderId !== $targetId) {
|
if ($senderId !== $userId && $senderId !== $targetId) {
|
||||||
respond(403, '没有权限查看该消息');
|
respond(403, '没有权限查看该消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
|
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
|
||||||
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
|
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
|
||||||
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
|
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
|
||||||
$readRaw = detailReadAt($imDb, $receiverId, $targets);
|
$readRaw = detailReadAt($imDb, $receiverId, $targets);
|
||||||
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
|
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'conversationType' => 'single',
|
'conversationType' => 'single',
|
||||||
'sentAt' => $sentAt,
|
'sentAt' => $sentAt,
|
||||||
'sender' => detailMember($users[$senderId]),
|
'sender' => detailMember($users[$senderId]),
|
||||||
'receiver' => detailMember($users[$receiverId]),
|
'receiver' => detailMember($users[$receiverId]),
|
||||||
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
|
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
|
||||||
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
|
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
|
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
if (!preg_match('/^\d+$/', $messageUid)) {
|
if (!preg_match('/^\d+$/', $messageUid)) {
|
||||||
respond(400, '缺少可查询的消息ID');
|
respond(400, '缺少可查询的消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$myMember = $appDb->prepare(
|
$myMember = $appDb->prepare(
|
||||||
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
||||||
);
|
);
|
||||||
$myMember->execute([$groupId, $userId]);
|
$myMember->execute([$groupId, $userId]);
|
||||||
if (!$myMember->fetchColumn()) {
|
if (!$myMember->fetchColumn()) {
|
||||||
respond(403, '你不在该群组中');
|
respond(403, '你不在该群组中');
|
||||||
}
|
}
|
||||||
|
|
||||||
$messageStatement = $appDb->prepare(
|
$messageStatement = $appDb->prepare(
|
||||||
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
|
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
|
||||||
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
|
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$messageStatement->execute([$groupId, $messageUid]);
|
$messageStatement->execute([$groupId, $messageUid]);
|
||||||
$message = $messageStatement->fetch();
|
$message = $messageStatement->fetch();
|
||||||
if (!is_array($message)) {
|
if (!is_array($message)) {
|
||||||
respond(404, '没有找到该群消息');
|
respond(404, '没有找到该群消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sentAt = (int)$message['created_at_ms'];
|
$sentAt = (int)$message['created_at_ms'];
|
||||||
$editedAt = (int)($message['edited_at_ms'] ?? 0);
|
$editedAt = (int)($message['edited_at_ms'] ?? 0);
|
||||||
$activityAt = max($sentAt, $editedAt);
|
$activityAt = max($sentAt, $editedAt);
|
||||||
$senderId = (string)$message['sender_uid'];
|
$senderId = (string)$message['sender_uid'];
|
||||||
|
|
||||||
$memberStatement = $appDb->prepare(
|
$memberStatement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
|
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
|
||||||
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
|
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$groupId, $sentAt]);
|
$memberStatement->execute([$groupId, $sentAt]);
|
||||||
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
|
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
|
||||||
return (string)$member['user_id'] !== $senderId;
|
return (string)$member['user_id'] !== $senderId;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
|
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
|
||||||
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
|
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
|
||||||
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
|
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
|
||||||
|
|
||||||
$readMap = [];
|
$readMap = [];
|
||||||
if ($memberIds) {
|
if ($memberIds) {
|
||||||
$readStatement = $appDb->prepare(
|
$readStatement = $appDb->prepare(
|
||||||
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
|
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
|
||||||
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
|
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
|
||||||
);
|
);
|
||||||
$readStatement->execute(array_merge([$groupId], $memberIds));
|
$readStatement->execute(array_merge([$groupId], $memberIds));
|
||||||
foreach ($readStatement->fetchAll() as $row) {
|
foreach ($readStatement->fetchAll() as $row) {
|
||||||
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
|
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$read = [];
|
$read = [];
|
||||||
$unread = [];
|
$unread = [];
|
||||||
$undelivered = [];
|
$undelivered = [];
|
||||||
foreach ($memberIds as $uid) {
|
foreach ($memberIds as $uid) {
|
||||||
$readAt = (int)($readMap[$uid] ?? 0);
|
$readAt = (int)($readMap[$uid] ?? 0);
|
||||||
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
|
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
|
||||||
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
|
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
|
||||||
if ($readAt >= $activityAt) {
|
if ($readAt >= $activityAt) {
|
||||||
$read[] = $member;
|
$read[] = $member;
|
||||||
} elseif ($deliveredAt >= $sentAt) {
|
} elseif ($deliveredAt >= $sentAt) {
|
||||||
$unread[] = $member;
|
$unread[] = $member;
|
||||||
} else {
|
} else {
|
||||||
$undelivered[] = $member;
|
$undelivered[] = $member;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'conversationType' => 'group',
|
'conversationType' => 'group',
|
||||||
'groupId' => $groupId,
|
'groupId' => $groupId,
|
||||||
'sentAt' => $sentAt,
|
'sentAt' => $sentAt,
|
||||||
'editedAt' => $editedAt,
|
'editedAt' => $editedAt,
|
||||||
'recalled' => (bool)$message['recalled'],
|
'recalled' => (bool)$message['recalled'],
|
||||||
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
|
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
|
||||||
'group' => [
|
'group' => [
|
||||||
'read' => $read,
|
'read' => $read,
|
||||||
'unread' => $unread,
|
'unread' => $unread,
|
||||||
'undelivered' => $undelivered,
|
'undelivered' => $undelivered,
|
||||||
'counts' => [
|
'counts' => [
|
||||||
'read' => count($read),
|
'read' => count($read),
|
||||||
'unread' => count($unread),
|
'unread' => count($unread),
|
||||||
'undelivered' => count($undelivered),
|
'undelivered' => count($undelivered),
|
||||||
'total' => count($memberIds),
|
'total' => count($memberIds),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
|
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
|
||||||
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
|
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
if ($target === '') {
|
if ($target === '') {
|
||||||
respond(400, '缺少会话对象');
|
respond(400, '缺少会话对象');
|
||||||
}
|
}
|
||||||
if ($messageUid === '') {
|
if ($messageUid === '') {
|
||||||
respond(400, '缺少消息ID');
|
respond(400, '缺少消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$imDb = imDb();
|
$imDb = imDb();
|
||||||
$appDb = imAppDb();
|
$appDb = imAppDb();
|
||||||
$userId = detailResolveUserId($imDb, $userId);
|
$userId = detailResolveUserId($imDb, $userId);
|
||||||
if ($conversationType === 'group') {
|
if ($conversationType === 'group') {
|
||||||
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
|
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetId = detailResolveUserId($imDb, $target);
|
$targetId = detailResolveUserId($imDb, $target);
|
||||||
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
|
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '消息详情加载失败');
|
respond(500, $error->getMessage() ?: '消息详情加载失败');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1608
-900
File diff suppressed because it is too large
Load Diff
Vendored
+39
-39
@@ -1,39 +1,39 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$defaultName = 'Web IM';
|
$defaultName = 'Web IM';
|
||||||
$defaultLogo = '/img/logo.png';
|
$defaultLogo = '/img/logo.ico';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
|
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
|
||||||
);
|
);
|
||||||
$statement->execute(['PlatformName', 'sys_logo']);
|
$statement->execute(['PlatformName', 'sys_logo']);
|
||||||
|
|
||||||
$config = [];
|
$config = [];
|
||||||
$updatedAtValues = [];
|
$updatedAtValues = [];
|
||||||
foreach ($statement->fetchAll() as $row) {
|
foreach ($statement->fetchAll() as $row) {
|
||||||
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
|
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
|
||||||
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
|
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
$platformName = $config['PlatformName'] ?? '';
|
$platformName = $config['PlatformName'] ?? '';
|
||||||
$logo = $config['sys_logo'] ?? '';
|
$logo = $config['sys_logo'] ?? '';
|
||||||
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
|
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'platformName' => $platformName !== '' ? $platformName : $defaultName,
|
'platformName' => $platformName !== '' ? $platformName : $defaultName,
|
||||||
'logo' => $logo !== '' ? $logo : $defaultLogo,
|
'logo' => $logo !== '' ? $logo : $defaultLogo,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '平台配置加载失败', [
|
respond(500, $error->getMessage() ?: '平台配置加载失败', [
|
||||||
'platformName' => $defaultName,
|
'platformName' => $defaultName,
|
||||||
'logo' => $defaultLogo,
|
'logo' => $defaultLogo,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+321
-321
@@ -1,321 +1,321 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
|
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
|
||||||
const PROFILE_PORTRAIT_FLAG = 0x02;
|
const PROFILE_PORTRAIT_FLAG = 0x02;
|
||||||
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
|
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
|
||||||
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
|
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
|
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
|
||||||
|
|
||||||
function profileRequestValue(string $key, string $default = ''): string
|
function profileRequestValue(string $key, string $default = ''): string
|
||||||
{
|
{
|
||||||
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
|
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileUser(array $user): array
|
function normalizeProfileUser(array $user): array
|
||||||
{
|
{
|
||||||
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
|
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
|
||||||
return [
|
return [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'userId' => $uid,
|
'userId' => $uid,
|
||||||
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
|
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
|
||||||
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
|
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
|
||||||
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
|
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
|
||||||
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
|
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
|
||||||
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
|
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryProfileUserInfo(array $payload): ?array
|
function tryProfileUserInfo(array $payload): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
|
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
|
||||||
return $result ?: null;
|
return $result ?: null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfileUserFromDb(string $identity): ?array
|
function loadProfileUserFromDb(string $identity): ?array
|
||||||
{
|
{
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
|
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
|
||||||
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
|
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
|
||||||
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
|
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
|
||||||
. ' limit 1'
|
. ' limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
|
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
return $row ? normalizeProfileUser($row) : null;
|
return $row ? normalizeProfileUser($row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeProfileUser(array $primary, ?array $fallback): array
|
function mergeProfileUser(array $primary, ?array $fallback): array
|
||||||
{
|
{
|
||||||
if (!$fallback) {
|
if (!$fallback) {
|
||||||
return $primary;
|
return $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fallback = normalizeProfileUser($fallback);
|
$fallback = normalizeProfileUser($fallback);
|
||||||
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
|
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
|
||||||
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
|
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
|
||||||
$primary[$key] = (string)$fallback[$key];
|
$primary[$key] = (string)$fallback[$key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $primary;
|
return $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
|
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
|
||||||
{
|
{
|
||||||
if ($portrait !== '') {
|
if ($portrait !== '') {
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
|
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$displayName, webImNowMs(), $userId]);
|
$statement->execute([$displayName, webImNowMs(), $userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfileUser(string $identity, bool $preferMobile = true): array
|
function loadProfileUser(string $identity, bool $preferMobile = true): array
|
||||||
{
|
{
|
||||||
$identity = trim($identity);
|
$identity = trim($identity);
|
||||||
if ($identity === '') {
|
if ($identity === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$dbUser = loadProfileUserFromDb($identity);
|
$dbUser = loadProfileUserFromDb($identity);
|
||||||
if ($dbUser) {
|
if ($dbUser) {
|
||||||
$fallback = null;
|
$fallback = null;
|
||||||
if ((string)($dbUser['portrait'] ?? '') === '') {
|
if ((string)($dbUser['portrait'] ?? '') === '') {
|
||||||
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
|
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
|
||||||
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
|
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
|
||||||
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
|
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
|
||||||
}
|
}
|
||||||
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
|
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
|
||||||
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
|
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeProfileUser($dbUser, $fallback);
|
return mergeProfileUser($dbUser, $fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = null;
|
$user = null;
|
||||||
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
|
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
|
||||||
$user = tryProfileUserInfo(['mobile' => $identity]);
|
$user = tryProfileUserInfo(['mobile' => $identity]);
|
||||||
}
|
}
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$user = tryProfileUserInfo(['userId' => $identity]);
|
$user = tryProfileUserInfo(['userId' => $identity]);
|
||||||
}
|
}
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$user = tryProfileUserInfo(['name' => $identity]);
|
$user = tryProfileUserInfo(['name' => $identity]);
|
||||||
}
|
}
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
respond(404, '用户资料不存在');
|
respond(404, '用户资料不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeProfileUser($user);
|
return normalizeProfileUser($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProfileJavaSerializedString(string $data, int $offset): ?array
|
function readProfileJavaSerializedString(string $data, int $offset): ?array
|
||||||
{
|
{
|
||||||
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
|
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
|
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
|
||||||
$start = $offset + 3;
|
$start = $offset + 3;
|
||||||
if ($start + $length > strlen($data)) {
|
if ($start + $length > strlen($data)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [substr($data, $start, $length), $start + $length];
|
return [substr($data, $start, $length), $start + $length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileSessionUserId(string $sessionData): string
|
function profileSessionUserId(string $sessionData): string
|
||||||
{
|
{
|
||||||
$marker = "\x74\x00\x06userId";
|
$marker = "\x74\x00\x06userId";
|
||||||
$offset = strpos($sessionData, $marker);
|
$offset = strpos($sessionData, $marker);
|
||||||
if ($offset === false) {
|
if ($offset === false) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
|
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
|
||||||
$userId = is_array($decoded) ? (string)$decoded[0] : '';
|
$userId = is_array($decoded) ? (string)$decoded[0] : '';
|
||||||
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
|
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $userId;
|
return $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireProfileUpdateAuth(string $targetUserId): void
|
function requireProfileUpdateAuth(string $targetUserId): void
|
||||||
{
|
{
|
||||||
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
|
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
|
||||||
if ($authToken === '') {
|
if ($authToken === '') {
|
||||||
respond(401, '请重新登录后再修改资料');
|
respond(401, '请重新登录后再修改资料');
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
|
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
|
||||||
$statement->execute([$authToken]);
|
$statement->execute([$authToken]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
respond(401, '登录状态已失效,请重新登录');
|
respond(401, '登录状态已失效,请重新登录');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sessionUserId = profileSessionUserId((string)$row['session_data']);
|
$sessionUserId = profileSessionUserId((string)$row['session_data']);
|
||||||
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
|
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
|
||||||
respond(403, '只能修改自己的资料');
|
respond(403, '只能修改自己的资料');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileUploadedFileExtension(string $fileName, string $mime): string
|
function profileUploadedFileExtension(string $fileName, string $mime): string
|
||||||
{
|
{
|
||||||
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
||||||
if ($extension === 'jpeg') {
|
if ($extension === 'jpeg') {
|
||||||
$extension = 'jpg';
|
$extension = 'jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
|
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
|
||||||
if (in_array($extension, $allowedExtensions, true)) {
|
if (in_array($extension, $allowedExtensions, true)) {
|
||||||
return $extension;
|
return $extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($mime) {
|
return match ($mime) {
|
||||||
'image/jpeg' => 'jpg',
|
'image/jpeg' => 'jpg',
|
||||||
'image/png' => 'png',
|
'image/png' => 'png',
|
||||||
'image/gif' => 'gif',
|
'image/gif' => 'gif',
|
||||||
'image/webp' => 'webp',
|
'image/webp' => 'webp',
|
||||||
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
||||||
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
|
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
|
||||||
default => 'jpg',
|
default => 'jpg',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function profilePublicMediaBaseUrl(): string
|
function profilePublicMediaBaseUrl(): string
|
||||||
{
|
{
|
||||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||||
$scheme = $isHttps ? 'https' : 'http';
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
|
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
|
||||||
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
|
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
|
||||||
|
|
||||||
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
|
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadProfilePortrait(array $file): string
|
function uploadProfilePortrait(array $file): string
|
||||||
{
|
{
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
throw new RuntimeException('头像上传失败');
|
throw new RuntimeException('头像上传失败');
|
||||||
}
|
}
|
||||||
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
||||||
throw new RuntimeException('头像不能超过5MB');
|
throw new RuntimeException('头像不能超过5MB');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
$mime = mime_content_type($tmpName) ?: '';
|
$mime = mime_content_type($tmpName) ?: '';
|
||||||
if (!str_starts_with($mime, 'image/')) {
|
if (!str_starts_with($mime, 'image/')) {
|
||||||
throw new RuntimeException('头像必须是图片文件');
|
throw new RuntimeException('头像必须是图片文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
|
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
|
||||||
throw new RuntimeException('头像存储目录不可写');
|
throw new RuntimeException('头像存储目录不可写');
|
||||||
}
|
}
|
||||||
|
|
||||||
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
|
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
|
||||||
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
|
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
||||||
throw new RuntimeException('头像存储目录创建失败');
|
throw new RuntimeException('头像存储目录创建失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
|
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
|
||||||
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
||||||
$targetPath = $targetDir . '/' . $targetName;
|
$targetPath = $targetDir . '/' . $targetName;
|
||||||
if (!move_uploaded_file($tmpName, $targetPath)) {
|
if (!move_uploaded_file($tmpName, $targetPath)) {
|
||||||
throw new RuntimeException('头像文件保存失败');
|
throw new RuntimeException('头像文件保存失败');
|
||||||
}
|
}
|
||||||
@chmod($targetPath, 0644);
|
@chmod($targetPath, 0644);
|
||||||
|
|
||||||
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
|
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
|
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
|
||||||
$payload = $isMultipart ? [] : readJsonPayload();
|
$payload = $isMultipart ? [] : readJsonPayload();
|
||||||
$action = $isMultipart
|
$action = $isMultipart
|
||||||
? profileRequestValue('action', 'update')
|
? profileRequestValue('action', 'update')
|
||||||
: trim((string)($payload['action'] ?? 'get'));
|
: trim((string)($payload['action'] ?? 'get'));
|
||||||
$userId = $isMultipart
|
$userId = $isMultipart
|
||||||
? profileRequestValue('userId')
|
? profileRequestValue('userId')
|
||||||
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
||||||
|
|
||||||
$currentProfile = loadProfileUser($userId);
|
$currentProfile = loadProfileUser($userId);
|
||||||
|
|
||||||
if ($action === 'get') {
|
if ($action === 'get') {
|
||||||
respond(0, 'success', ['profile' => $currentProfile]);
|
respond(0, 'success', ['profile' => $currentProfile]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action !== 'update') {
|
if ($action !== 'update') {
|
||||||
respond(400, '不支持的资料操作');
|
respond(400, '不支持的资料操作');
|
||||||
}
|
}
|
||||||
requireProfileUpdateAuth((string)$currentProfile['userId']);
|
requireProfileUpdateAuth((string)$currentProfile['userId']);
|
||||||
|
|
||||||
$displayName = $isMultipart
|
$displayName = $isMultipart
|
||||||
? profileRequestValue('displayName')
|
? profileRequestValue('displayName')
|
||||||
: trim((string)($payload['displayName'] ?? ''));
|
: trim((string)($payload['displayName'] ?? ''));
|
||||||
if ($displayName === '') {
|
if ($displayName === '') {
|
||||||
respond(400, '昵称不能为空');
|
respond(400, '昵称不能为空');
|
||||||
}
|
}
|
||||||
if (mb_strlen($displayName, 'UTF-8') > 64) {
|
if (mb_strlen($displayName, 'UTF-8') > 64) {
|
||||||
respond(400, '昵称不能超过64个字');
|
respond(400, '昵称不能超过64个字');
|
||||||
}
|
}
|
||||||
|
|
||||||
$portrait = '';
|
$portrait = '';
|
||||||
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||||
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
|
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$flag = PROFILE_DISPLAY_NAME_FLAG;
|
$flag = PROFILE_DISPLAY_NAME_FLAG;
|
||||||
$userInfo = [
|
$userInfo = [
|
||||||
'userId' => $currentProfile['userId'],
|
'userId' => $currentProfile['userId'],
|
||||||
'displayName' => $displayName,
|
'displayName' => $displayName,
|
||||||
];
|
];
|
||||||
if ($portrait !== '') {
|
if ($portrait !== '') {
|
||||||
$flag |= PROFILE_PORTRAIT_FLAG;
|
$flag |= PROFILE_PORTRAIT_FLAG;
|
||||||
$userInfo['portrait'] = $portrait;
|
$userInfo['portrait'] = $portrait;
|
||||||
}
|
}
|
||||||
|
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/user/update', [
|
imAdminPost('/admin/user/update', [
|
||||||
'flag' => $flag,
|
'flag' => $flag,
|
||||||
'userInfo' => $userInfo,
|
'userInfo' => $userInfo,
|
||||||
]),
|
]),
|
||||||
'用户资料保存失败'
|
'用户资料保存失败'
|
||||||
);
|
);
|
||||||
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
|
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
|
||||||
|
|
||||||
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
|
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
|
||||||
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
|
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
|
||||||
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
|
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
|
||||||
}
|
}
|
||||||
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
|
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
|
||||||
$updatedProfile['portrait'] = $portrait;
|
$updatedProfile['portrait'] = $portrait;
|
||||||
}
|
}
|
||||||
if ((string)$updatedProfile['displayName'] === '') {
|
if ((string)$updatedProfile['displayName'] === '') {
|
||||||
$updatedProfile['displayName'] = $displayName;
|
$updatedProfile['displayName'] = $displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(0, 'success', ['profile' => $updatedProfile]);
|
respond(0, 'success', ['profile' => $updatedProfile]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
|
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+18
-18
@@ -1,18 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'message' => 'success',
|
'message' => 'success',
|
||||||
'data' => [
|
'data' => [
|
||||||
'methods' => [
|
'methods' => [
|
||||||
'phone' => true,
|
'phone' => true,
|
||||||
'email' => false,
|
'email' => false,
|
||||||
'username' => false,
|
'username' => false,
|
||||||
],
|
],
|
||||||
'mobile_real_verify' => false,
|
'mobile_real_verify' => false,
|
||||||
],
|
],
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
Vendored
+579
-411
@@ -1,411 +1,579 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
define(
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
'WEB_MEDIA_ROOT',
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
webImEnv(
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
'JC_WEB_IM_MEDIA_ROOT',
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
is_dir('/var/www/im/public/media')
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
? '/var/www/im/public/media'
|
||||||
|
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
|
||||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
)
|
||||||
{
|
);
|
||||||
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
|
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
|
||||||
return false;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
}
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
|
const WEB_MINIO_PROJECT_CODE = '001';
|
||||||
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
|
|
||||||
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
|
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||||
$startMinutes = $startHour * 60 + $startMinute;
|
{
|
||||||
$endMinutes = $endHour * 60 + $endMinute;
|
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
|
||||||
if ($startMinutes === $endMinutes) {
|
return false;
|
||||||
return true;
|
}
|
||||||
}
|
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
|
||||||
if ($startMinutes < $endMinutes) {
|
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||||
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
|
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
|
||||||
}
|
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
|
||||||
|
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
|
||||||
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
|
$startMinutes = $startHour * 60 + $startMinute;
|
||||||
}
|
$endMinutes = $endHour * 60 + $endMinute;
|
||||||
|
if ($startMinutes === $endMinutes) {
|
||||||
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
|
return true;
|
||||||
{
|
}
|
||||||
if ($role === 'owner') {
|
if ($startMinutes < $endMinutes) {
|
||||||
return false;
|
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
|
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
|
||||||
$statement->execute([$groupId]);
|
}
|
||||||
$rule = $statement->fetch();
|
|
||||||
if (!is_array($rule)) {
|
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
|
||||||
return false;
|
{
|
||||||
}
|
if ($role === 'owner') {
|
||||||
|
return false;
|
||||||
$mode = (string)$rule['mode'];
|
}
|
||||||
if ($mode === 'off') {
|
|
||||||
return false;
|
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
|
||||||
}
|
$statement->execute([$groupId]);
|
||||||
if ($mode === 'all_except_admins' && $role === 'admin') {
|
$rule = $statement->fetch();
|
||||||
return false;
|
if (!is_array($rule)) {
|
||||||
}
|
return false;
|
||||||
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
|
}
|
||||||
return false;
|
|
||||||
}
|
$mode = (string)$rule['mode'];
|
||||||
|
if ($mode === 'off') {
|
||||||
$scheduleType = (string)$rule['schedule_type'];
|
return false;
|
||||||
if ($scheduleType === 'manual') {
|
}
|
||||||
return true;
|
if ($mode === 'all_except_admins' && $role === 'admin') {
|
||||||
}
|
return false;
|
||||||
if ($scheduleType === 'daily') {
|
}
|
||||||
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
|
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
$startsAtMs = (int)$rule['starts_at_ms'];
|
|
||||||
$endsAtMs = (int)$rule['ends_at_ms'];
|
$scheduleType = (string)$rule['schedule_type'];
|
||||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
if ($scheduleType === 'manual') {
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
if ($scheduleType === 'daily') {
|
||||||
{
|
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
|
||||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
}
|
||||||
}
|
|
||||||
|
$startsAtMs = (int)$rule['starts_at_ms'];
|
||||||
function safeUploadName(string $fileName): string
|
$endsAtMs = (int)$rule['ends_at_ms'];
|
||||||
{
|
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||||
$name = basename(str_replace('\\', '/', $fileName));
|
}
|
||||||
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
|
|
||||||
$name = trim($name, " .\t\n\r\0\x0B");
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
|
$value = (int)$type;
|
||||||
}
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
|
}
|
||||||
{
|
if ($value === 1) {
|
||||||
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
return 'admin';
|
||||||
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
}
|
||||||
if ($extension === 'jpeg') {
|
|
||||||
$extension = 'jpg';
|
return 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($attachmentType === 'image') {
|
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
|
||||||
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
|
{
|
||||||
return $extension;
|
$statement = $db->prepare(
|
||||||
}
|
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
|
||||||
return match ($mime) {
|
. ' from `t_group` g'
|
||||||
'image/jpeg' => 'jpg',
|
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||||
'image/png' => 'png',
|
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||||
'image/gif' => 'gif',
|
);
|
||||||
'image/webp' => 'webp',
|
$statement->execute([$groupId, $sender]);
|
||||||
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
$group = $statement->fetch();
|
||||||
'image/svg+xml' => 'svg',
|
|
||||||
'image/avif' => 'avif',
|
return is_array($group) ? $group : [];
|
||||||
'image/heic' => 'heic',
|
}
|
||||||
'image/heif' => 'heif',
|
|
||||||
default => 'jpg',
|
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||||
};
|
{
|
||||||
}
|
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||||
|
}
|
||||||
if ($extension !== '') {
|
|
||||||
return $extension;
|
function safeUploadName(string $fileName): string
|
||||||
}
|
{
|
||||||
|
$name = basename(str_replace('\\', '/', $fileName));
|
||||||
return 'bin';
|
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
|
||||||
}
|
$name = trim($name, " .\t\n\r\0\x0B");
|
||||||
|
|
||||||
function detectedUploadMime(string $tmpName): string
|
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
|
||||||
{
|
}
|
||||||
$mime = mime_content_type($tmpName) ?: '';
|
|
||||||
if ($mime !== '' && $mime !== 'application/octet-stream') {
|
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
|
||||||
return $mime;
|
{
|
||||||
}
|
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
||||||
$imageInfo = @getimagesize($tmpName);
|
if ($extension === 'jpeg') {
|
||||||
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
|
$extension = 'jpg';
|
||||||
return (string)$imageInfo['mime'];
|
}
|
||||||
}
|
|
||||||
|
if ($attachmentType === 'image') {
|
||||||
return $mime !== '' ? $mime : 'application/octet-stream';
|
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
|
||||||
}
|
return $extension;
|
||||||
|
}
|
||||||
function normalizedImageUploadMime(string $tmpName, string $mime): string
|
return match ($mime) {
|
||||||
{
|
'image/jpeg' => 'jpg',
|
||||||
if (str_starts_with($mime, 'image/')) {
|
'image/png' => 'png',
|
||||||
return $mime;
|
'image/gif' => 'gif',
|
||||||
}
|
'image/webp' => 'webp',
|
||||||
|
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
||||||
$imageInfo = @getimagesize($tmpName);
|
'image/svg+xml' => 'svg',
|
||||||
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
|
'image/avif' => 'avif',
|
||||||
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
|
'image/heic' => 'heic',
|
||||||
return $imageMime;
|
'image/heif' => 'heif',
|
||||||
}
|
default => 'jpg',
|
||||||
|
};
|
||||||
throw new RuntimeException('请选择图片文件');
|
}
|
||||||
}
|
|
||||||
|
if ($extension !== '') {
|
||||||
function mediaPublicBaseUrl(): string
|
return $extension;
|
||||||
{
|
}
|
||||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
|
||||||
$scheme = $isHttps ? 'https' : 'http';
|
return 'bin';
|
||||||
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
}
|
||||||
|
|
||||||
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
|
function detectedUploadMime(string $tmpName): string
|
||||||
}
|
{
|
||||||
|
$mime = mime_content_type($tmpName) ?: '';
|
||||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
if ($mime !== '' && $mime !== 'application/octet-stream') {
|
||||||
{
|
return $mime;
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
}
|
||||||
throw new RuntimeException('文件上传失败');
|
|
||||||
}
|
$imageInfo = @getimagesize($tmpName);
|
||||||
$size = (int)($file['size'] ?? 0);
|
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
|
||||||
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
|
return (string)$imageInfo['mime'];
|
||||||
if ($size <= 0 || $size > $maxSize) {
|
}
|
||||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
|
||||||
}
|
return $mime !== '' ? $mime : 'application/octet-stream';
|
||||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
}
|
||||||
throw new RuntimeException('媒体存储目录不可写');
|
|
||||||
}
|
function normalizedImageUploadMime(string $tmpName, string $mime): string
|
||||||
|
{
|
||||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
if (str_starts_with($mime, 'image/')) {
|
||||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
return $mime;
|
||||||
throw new RuntimeException('文件上传失败');
|
}
|
||||||
}
|
|
||||||
$mime = detectedUploadMime($tmpName);
|
$imageInfo = @getimagesize($tmpName);
|
||||||
if ($attachmentType === 'image') {
|
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
|
||||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
|
||||||
}
|
return $imageMime;
|
||||||
|
}
|
||||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
|
||||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
throw new RuntimeException('请选择图片文件');
|
||||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
}
|
||||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
|
||||||
throw new RuntimeException('媒体存储目录创建失败');
|
function mediaPublicBaseUrl(): string
|
||||||
}
|
{
|
||||||
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||||
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
$extension = uploadExtension($originalName, $mime, $attachmentType);
|
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
||||||
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
$port = (int)WEB_MEDIA_PUBLIC_PORT;
|
||||||
$targetPath = $targetDir . '/' . $targetName;
|
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
|
||||||
if (!move_uploaded_file($tmpName, $targetPath)) {
|
return $scheme . '://' . $host;
|
||||||
throw new RuntimeException('文件保存失败');
|
}
|
||||||
}
|
|
||||||
@chmod($targetPath, 0644);
|
return $scheme . '://' . $host . ':' . $port;
|
||||||
|
}
|
||||||
return [
|
|
||||||
'type' => $attachmentType,
|
function messageStorageConfig(): array
|
||||||
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
|
{
|
||||||
'name' => $originalName,
|
$config = ['enabled' => false, 'url' => ''];
|
||||||
'mime' => $mime,
|
foreach (['t_app_system_conf', 'system_conf'] as $table) {
|
||||||
'size' => $size,
|
try {
|
||||||
'mediaType' => $mediaType,
|
$statement = imAppDb()->query(
|
||||||
];
|
'select `conf_name`, `conf_value` from `' . $table . '`'
|
||||||
}
|
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
|
||||||
|
);
|
||||||
function storeMessageAttachment(
|
$rows = $statement->fetchAll();
|
||||||
PDO $appDb,
|
if (!$rows) {
|
||||||
string $messageUid,
|
continue;
|
||||||
string $conversationType,
|
}
|
||||||
string $target,
|
foreach ($rows as $row) {
|
||||||
string $sender,
|
$name = strtolower((string)($row['conf_name'] ?? ''));
|
||||||
array $attachment,
|
if ($name === 'is_minioplus') {
|
||||||
int $timestamp
|
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
|
||||||
): void {
|
} elseif ($name === 'minio_url') {
|
||||||
if ($messageUid === '' || empty($attachment['url'])) {
|
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
$statement = $appDb->prepare(
|
} catch (Throwable $error) {
|
||||||
'insert into `t_web_message_attachments`'
|
continue;
|
||||||
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
|
}
|
||||||
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
}
|
||||||
. ' on duplicate key update'
|
|
||||||
. ' `attachment_type` = values(`attachment_type`),'
|
return $config;
|
||||||
. ' `url` = values(`url`),'
|
}
|
||||||
. ' `name` = values(`name`),'
|
|
||||||
. ' `mime` = values(`mime`),'
|
function saveMessageAttachmentToMinio(
|
||||||
. ' `size_bytes` = values(`size_bytes`)'
|
array $file,
|
||||||
);
|
string $attachmentType,
|
||||||
$statement->execute([
|
string $sender,
|
||||||
$messageUid,
|
string $baseUrl
|
||||||
$conversationType,
|
): array {
|
||||||
$target,
|
$size = (int)($file['size'] ?? 0);
|
||||||
$sender,
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
(string)$attachment['type'],
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
(string)$attachment['url'],
|
throw new RuntimeException('文件上传失败');
|
||||||
(string)$attachment['name'],
|
}
|
||||||
(string)$attachment['mime'],
|
$mime = detectedUploadMime($tmpName);
|
||||||
(int)$attachment['size'],
|
if ($attachmentType === 'image') {
|
||||||
$timestamp,
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
]);
|
}
|
||||||
}
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||||
|
$content = file_get_contents($tmpName);
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
if ($content === false) {
|
||||||
{
|
throw new RuntimeException('读取上传文件失败');
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
}
|
||||||
return null;
|
$requestBody = json_encode([
|
||||||
}
|
'projectCode' => WEB_MINIO_PROJECT_CODE,
|
||||||
|
'fileScene' => 'temp',
|
||||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
'fileName' => $originalName,
|
||||||
if (!is_array($attachmentFile)) {
|
'contentType' => $mime,
|
||||||
respond(400, '缺少上传文件');
|
'base64Content' => base64_encode($content),
|
||||||
}
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($requestBody)) {
|
||||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
throw new RuntimeException('生成图片上传请求失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
$handle = curl_init($baseUrl . '/project-storage/upload');
|
||||||
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
|
curl_setopt_array($handle, [
|
||||||
$payload = $isMultipart ? [] : readJsonPayload();
|
CURLOPT_POST => true,
|
||||||
$sender = messageRequestValue($payload, 'sender');
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
$target = messageRequestValue($payload, 'target');
|
CURLOPT_CONNECTTIMEOUT => 8,
|
||||||
$text = messageRequestValue($payload, 'text');
|
CURLOPT_TIMEOUT => 60,
|
||||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
CURLOPT_HTTPHEADER => [
|
||||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
'Content-Type: application/json',
|
||||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
|
||||||
$attachment = null;
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $requestBody,
|
||||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
]);
|
||||||
respond(400, '缺少发送者、接收者或消息内容');
|
$responseBody = curl_exec($handle);
|
||||||
}
|
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlError = curl_error($handle);
|
||||||
try {
|
curl_close($handle);
|
||||||
if ($isGroupConversation) {
|
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
|
||||||
ensureWebImAppTables();
|
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||||
$appDb = imAppDb();
|
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
|
||||||
$memberStatement = $appDb->prepare(
|
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
|
||||||
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
|
$message = trim((string)($response['message'] ?? ''));
|
||||||
. ' from `t_web_groups` g'
|
throw new RuntimeException(
|
||||||
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
|
$message !== ''
|
||||||
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
? 'MinIO上传失败:' . $message
|
||||||
. ' limit 1'
|
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$target, $sender]);
|
}
|
||||||
$group = $memberStatement->fetch();
|
|
||||||
if (!$group) {
|
return [
|
||||||
respond(403, '你不在该群组中');
|
'type' => $attachmentType,
|
||||||
}
|
'url' => $url,
|
||||||
$category = (string)($group['category'] ?? 'group');
|
'name' => $originalName,
|
||||||
$role = (string)($group['role'] ?? 'member');
|
'mime' => $mime,
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
'size' => $size,
|
||||||
respond(403, '公告群仅管理员可发言');
|
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
|
||||||
}
|
];
|
||||||
|
}
|
||||||
$now = webImNowMs();
|
|
||||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
|
||||||
respond(403, '当前群组处于禁言时段');
|
{
|
||||||
}
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
$attachment = attachmentFromRequest($messageType);
|
throw new RuntimeException('文件上传失败');
|
||||||
$messageUid = webImMessageUid();
|
}
|
||||||
$insert = $appDb->prepare(
|
$size = (int)($file['size'] ?? 0);
|
||||||
'insert into `t_web_group_messages`'
|
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
|
||||||
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
|
if ($size <= 0 || $size > $maxSize) {
|
||||||
. ' values (?, ?, ?, ?, ?, 0, 0)'
|
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
||||||
);
|
}
|
||||||
$insert->execute([$messageUid, $target, $sender, $text, $now]);
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
if ($attachment) {
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
|
throw new RuntimeException('文件上传失败');
|
||||||
}
|
}
|
||||||
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
$mime = detectedUploadMime($tmpName);
|
||||||
$updateGroup->execute([$now, $target]);
|
if ($attachmentType === 'image') {
|
||||||
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
$nativeError = '';
|
}
|
||||||
try {
|
|
||||||
$pushContent = $text !== ''
|
$storage = messageStorageConfig();
|
||||||
? $text
|
if ($storage['enabled']) {
|
||||||
: ($attachment
|
if ($storage['url'] === '') {
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
throw new RuntimeException('MinIO已开启但未配置服务地址');
|
||||||
: $text);
|
}
|
||||||
$payloadBody = [
|
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
}
|
||||||
'searchableContent' => $pushContent,
|
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||||
'pushContent' => $pushContent,
|
throw new RuntimeException('媒体存储目录不可写');
|
||||||
'content' => $text,
|
}
|
||||||
'persistFlag' => 3,
|
|
||||||
];
|
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||||
if ($attachment) {
|
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
||||||
$payloadBody['extra'] = json_encode([
|
throw new RuntimeException('媒体存储目录创建失败');
|
||||||
'name' => (string)$attachment['name'],
|
}
|
||||||
'mime' => (string)$attachment['mime'],
|
|
||||||
'size' => (int)$attachment['size'],
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$extension = uploadExtension($originalName, $mime, $attachmentType);
|
||||||
}
|
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
||||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
$targetPath = $targetDir . '/' . $targetName;
|
||||||
'sender' => $sender,
|
if (!move_uploaded_file($tmpName, $targetPath)) {
|
||||||
'conv' => [
|
throw new RuntimeException('文件保存失败');
|
||||||
'type' => 1,
|
}
|
||||||
'target' => $target,
|
@chmod($targetPath, 0644);
|
||||||
'line' => 0,
|
|
||||||
],
|
return [
|
||||||
'payload' => $payloadBody,
|
'type' => $attachmentType,
|
||||||
'userMessage' => true,
|
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
|
||||||
]), '原生群消息发送失败');
|
'name' => $originalName,
|
||||||
} catch (Throwable $error) {
|
'mime' => $mime,
|
||||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
'size' => $size,
|
||||||
}
|
'mediaType' => $mediaType,
|
||||||
|
];
|
||||||
respond(0, 'success', [
|
}
|
||||||
'messageUid' => $messageUid,
|
|
||||||
'timestamp' => $now,
|
function storeMessageAttachment(
|
||||||
'senderRole' => $role,
|
PDO $appDb,
|
||||||
'attachment' => $attachment,
|
string $messageUid,
|
||||||
'nativeError' => $nativeError,
|
string $conversationType,
|
||||||
]);
|
string $target,
|
||||||
}
|
string $sender,
|
||||||
|
array $attachment,
|
||||||
$attachment = attachmentFromRequest($messageType);
|
int $timestamp
|
||||||
$pushContent = $text !== ''
|
): void {
|
||||||
? $text
|
if ($messageUid === '' || empty($attachment['url'])) {
|
||||||
: ($attachment
|
return;
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
}
|
||||||
: $text);
|
|
||||||
$payloadBody = [
|
$statement = $appDb->prepare(
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
'insert into `t_web_message_attachments`'
|
||||||
'searchableContent' => $pushContent,
|
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
|
||||||
'pushContent' => $pushContent,
|
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
'content' => $text,
|
. ' on duplicate key update'
|
||||||
'persistFlag' => 3,
|
. ' `attachment_type` = values(`attachment_type`),'
|
||||||
];
|
. ' `url` = values(`url`),'
|
||||||
if ($attachment) {
|
. ' `name` = values(`name`),'
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
. ' `mime` = values(`mime`),'
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
. ' `size_bytes` = values(`size_bytes`)'
|
||||||
$payloadBody['extra'] = json_encode([
|
);
|
||||||
'name' => (string)$attachment['name'],
|
$statement->execute([
|
||||||
'mime' => (string)$attachment['mime'],
|
$messageUid,
|
||||||
'size' => (int)$attachment['size'],
|
$conversationType,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$target,
|
||||||
}
|
$sender,
|
||||||
|
(string)$attachment['type'],
|
||||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
(string)$attachment['url'],
|
||||||
'sender' => $sender,
|
(string)$attachment['name'],
|
||||||
'conv' => [
|
(string)$attachment['mime'],
|
||||||
'type' => 0,
|
(int)$attachment['size'],
|
||||||
'target' => $target,
|
$timestamp,
|
||||||
'line' => 0,
|
]);
|
||||||
],
|
}
|
||||||
'payload' => $payloadBody,
|
|
||||||
'userMessage' => true,
|
function attachmentFromRequest(string $messageType, string $sender): ?array
|
||||||
]), '发送失败');
|
{
|
||||||
|
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
return null;
|
||||||
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
}
|
||||||
if ($attachment && $messageUid !== '') {
|
|
||||||
ensureWebImAppTables();
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||||
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
|
if (!is_array($attachmentFile)) {
|
||||||
}
|
respond(400, '缺少上传文件');
|
||||||
|
}
|
||||||
respond(0, 'success', [
|
|
||||||
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
|
||||||
'timestamp' => $timestamp,
|
}
|
||||||
'attachment' => $attachment,
|
|
||||||
]);
|
function messagePayloadBody(string $text, ?array $attachment): array
|
||||||
} catch (Throwable $error) {
|
{
|
||||||
respond(500, $error->getMessage() ?: '发送失败');
|
$pushContent = $text !== ''
|
||||||
}
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payloadBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
|
||||||
|
{
|
||||||
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 1,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
|
||||||
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
|
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
|
||||||
|
$payload = $isMultipart ? [] : readJsonPayload();
|
||||||
|
$sender = messageRequestValue($payload, 'sender');
|
||||||
|
$target = messageRequestValue($payload, 'target');
|
||||||
|
$text = messageRequestValue($payload, 'text');
|
||||||
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||||
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||||
|
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||||
|
$attachment = null;
|
||||||
|
|
||||||
|
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
||||||
|
respond(400, '缺少发送者、接收者或消息内容');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($isGroupConversation) {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
$appDb = imAppDb();
|
||||||
|
$memberStatement = $appDb->prepare(
|
||||||
|
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
|
||||||
|
. ' from `t_web_groups` g'
|
||||||
|
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
|
||||||
|
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
||||||
|
. ' limit 1'
|
||||||
|
);
|
||||||
|
$memberStatement->execute([$target, $sender]);
|
||||||
|
$group = $memberStatement->fetch();
|
||||||
|
if (!$group) {
|
||||||
|
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
|
||||||
|
if (!$nativeGroup) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
|
||||||
|
respond(0, 'success', [
|
||||||
|
...$nativeResult,
|
||||||
|
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
|
||||||
|
'native' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$category = (string)($group['category'] ?? 'group');
|
||||||
|
$role = (string)($group['role'] ?? 'member');
|
||||||
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
|
respond(403, '公告群仅管理员可发言');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = webImNowMs();
|
||||||
|
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||||
|
respond(403, '当前群组处于禁言时段');
|
||||||
|
}
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
$messageUid = webImMessageUid();
|
||||||
|
$insert = $appDb->prepare(
|
||||||
|
'insert into `t_web_group_messages`'
|
||||||
|
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
|
||||||
|
. ' values (?, ?, ?, ?, ?, 0, 0)'
|
||||||
|
);
|
||||||
|
$insert->execute([$messageUid, $target, $sender, $text, $now]);
|
||||||
|
if ($attachment) {
|
||||||
|
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
|
||||||
|
}
|
||||||
|
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||||
|
$updateGroup->execute([$now, $target]);
|
||||||
|
|
||||||
|
$nativeError = '';
|
||||||
|
try {
|
||||||
|
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 1,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid,
|
||||||
|
'timestamp' => $now,
|
||||||
|
'senderRole' => $role,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'nativeError' => $nativeError,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
|
||||||
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 0,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '发送失败');
|
||||||
|
|
||||||
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
respond(500, $error->getMessage() ?: '发送失败');
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+61
-61
@@ -1,61 +1,61 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
|
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
|
||||||
$alias = trim((string)($payload['alias'] ?? ''));
|
$alias = trim((string)($payload['alias'] ?? ''));
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
if ($targetId === '') {
|
if ($targetId === '') {
|
||||||
respond(400, '缺少联系人ID');
|
respond(400, '缺少联系人ID');
|
||||||
}
|
}
|
||||||
if ($targetId === $userId) {
|
if ($targetId === $userId) {
|
||||||
respond(400, '不能给自己设置备注名');
|
respond(400, '不能给自己设置备注名');
|
||||||
}
|
}
|
||||||
if (mb_strlen($alias, 'UTF-8') > 32) {
|
if (mb_strlen($alias, 'UTF-8') > 32) {
|
||||||
respond(400, '备注名不能超过32个字');
|
respond(400, '备注名不能超过32个字');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
$imDb = imDb();
|
$imDb = imDb();
|
||||||
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
|
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
|
||||||
$exists->execute([$targetId]);
|
$exists->execute([$targetId]);
|
||||||
if (!$exists->fetchColumn()) {
|
if (!$exists->fetchColumn()) {
|
||||||
respond(404, '没有找到该用户');
|
respond(404, '没有找到该用户');
|
||||||
}
|
}
|
||||||
|
|
||||||
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
|
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
|
||||||
$friend->execute([$userId, $targetId]);
|
$friend->execute([$userId, $targetId]);
|
||||||
$isContact = (bool)$friend->fetchColumn();
|
$isContact = (bool)$friend->fetchColumn();
|
||||||
|
|
||||||
if ($isContact) {
|
if ($isContact) {
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/friend/set_alias', [
|
imAdminPost('/admin/friend/set_alias', [
|
||||||
'operator' => $userId,
|
'operator' => $userId,
|
||||||
'targetId' => $targetId,
|
'targetId' => $targetId,
|
||||||
'alias' => $alias,
|
'alias' => $alias,
|
||||||
]),
|
]),
|
||||||
'备注名修改失败'
|
'备注名修改失败'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
|
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'contact' => [
|
'contact' => [
|
||||||
'uid' => $targetId,
|
'uid' => $targetId,
|
||||||
'alias' => $alias,
|
'alias' => $alias,
|
||||||
'isContact' => $isContact,
|
'isContact' => $isContact,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
|
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+21
-12
@@ -1,12 +1,21 @@
|
|||||||
window.JC_WEB_IM_CONFIG = {
|
(() => {
|
||||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
const protocol = window.location.protocol || 'http:';
|
||||||
appServer: 'http://winchen.ezpay.asia:8888',
|
const host = window.location.hostname || '127.0.0.1';
|
||||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
|
||||||
platformConfigApi: '/api/platform_config.php',
|
window.JC_WEB_IM_CONFIG = {
|
||||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
adminServer: `${protocol}//${host}:8100`,
|
||||||
imServerHost: 'winchen.ezpay.asia',
|
appServer: `${protocol}//${host}:8888`,
|
||||||
imRoutePort: 18000,
|
platformConfigApi: '/api/platform_config.php',
|
||||||
useWss: false,
|
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||||
wsPath: '/ws',
|
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||||
sdkMode: 'http'
|
imServerHost: host,
|
||||||
};
|
imRoutePort: 80,
|
||||||
|
useWss: false,
|
||||||
|
wsPath: '/ws',
|
||||||
|
sdkMode: 'http',
|
||||||
|
syncIntervalMs: 15000,
|
||||||
|
sseEnabled: true,
|
||||||
|
sseTicketApi: '/api/sse_ticket.php',
|
||||||
|
sseUrl: '/ws-events'
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
+2911
-2966
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 958 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.cd0faf68.js"></script><script defer="defer" src="js/app.26bc1008.js"></script><link href="css/app.5af3ef7e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.86756863.js"></script><script defer="defer" src="js/app.631b8b2d.js"></script><link href="css/app.2c52dabb.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||||
Vendored
-3213
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+9903
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
|
|||||||
/* harmony export */ R1: function() { return /* binding */ unref; },
|
/* harmony export */ R1: function() { return /* binding */ unref; },
|
||||||
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
|
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
|
||||||
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
|
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
|
||||||
|
/* harmony export */ fE: function() { return /* binding */ isShallow; },
|
||||||
/* harmony export */ g8: function() { return /* binding */ isReactive; },
|
/* harmony export */ g8: function() { return /* binding */ isReactive; },
|
||||||
/* harmony export */ hV: function() { return /* binding */ traverse; },
|
/* harmony export */ hV: function() { return /* binding */ traverse; },
|
||||||
/* harmony export */ hZ: function() { return /* binding */ trigger; },
|
/* harmony export */ hZ: function() { return /* binding */ trigger; },
|
||||||
@@ -23,13 +24,14 @@
|
|||||||
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
|
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
|
||||||
/* harmony export */ ju: function() { return /* binding */ isProxy; },
|
/* harmony export */ ju: function() { return /* binding */ isProxy; },
|
||||||
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
|
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
|
||||||
|
/* harmony export */ qA: function() { return /* binding */ shallowReadArray; },
|
||||||
/* harmony export */ u4: function() { return /* binding */ track; },
|
/* harmony export */ u4: function() { return /* binding */ track; },
|
||||||
/* harmony export */ uY: function() { return /* binding */ effectScope; },
|
/* harmony export */ uY: function() { return /* binding */ effectScope; },
|
||||||
/* harmony export */ ux: function() { return /* binding */ toRaw; },
|
/* harmony export */ ux: function() { return /* binding */ toRaw; },
|
||||||
/* harmony export */ wB: function() { return /* binding */ watch; },
|
/* harmony export */ wB: function() { return /* binding */ watch; },
|
||||||
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
|
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
|
||||||
/* harmony export */ });
|
/* harmony export */ });
|
||||||
/* unused harmony exports ARRAY_ITERATE_KEY, EffectFlags, ITERATE_KEY, MAP_KEY_ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, WatchErrorCodes, customRef, effect, enableTracking, getCurrentWatcher, isReadonly, isShallow, onEffectCleanup, onWatcherCleanup, reactiveReadArray, readonly, shallowReadArray, shallowReadonly, shallowRef, stop, toReactive, toReadonly, toRef, toValue, triggerRef */
|
/* unused harmony exports ARRAY_ITERATE_KEY, EffectFlags, ITERATE_KEY, MAP_KEY_ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, WatchErrorCodes, customRef, effect, enableTracking, getCurrentWatcher, isReadonly, onEffectCleanup, onWatcherCleanup, reactiveReadArray, readonly, shallowReadonly, shallowRef, stop, toReadonly, toRef, toValue, triggerRef */
|
||||||
/* unused harmony import specifier */ var extend;
|
/* unused harmony import specifier */ var extend;
|
||||||
/* unused harmony import specifier */ var toRawType;
|
/* unused harmony import specifier */ var toRawType;
|
||||||
/* unused harmony import specifier */ var isFunction;
|
/* unused harmony import specifier */ var isFunction;
|
||||||
@@ -1825,6 +1827,10 @@ function traverse(value, depth = Infinity, seen) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||||
|
/* harmony export */ "lJ", 0, /* binding */ toReactive
|
||||||
|
/* harmony export */ ]);
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
@@ -2363,11 +2369,15 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/* harmony export */ Wv: function() { return /* binding */ createBlock; },
|
/* harmony export */ Wv: function() { return /* binding */ createBlock; },
|
||||||
/* harmony export */ bo: function() { return /* binding */ withDirectives; },
|
/* harmony export */ bo: function() { return /* binding */ withDirectives; },
|
||||||
/* harmony export */ dY: function() { return /* binding */ nextTick; },
|
/* harmony export */ dY: function() { return /* binding */ nextTick; },
|
||||||
|
/* harmony export */ eW: function() { return /* binding */ createTextVNode; },
|
||||||
|
/* harmony export */ h: function() { return /* binding */ h; },
|
||||||
|
/* harmony export */ pI: function() { return /* binding */ renderList; },
|
||||||
|
/* harmony export */ pM: function() { return /* binding */ defineComponent; },
|
||||||
/* harmony export */ qL: function() { return /* binding */ callWithAsyncErrorHandling; },
|
/* harmony export */ qL: function() { return /* binding */ callWithAsyncErrorHandling; },
|
||||||
/* harmony export */ uX: function() { return /* binding */ openBlock; },
|
/* harmony export */ uX: function() { return /* binding */ openBlock; },
|
||||||
/* harmony export */ wB: function() { return /* binding */ watch; }
|
/* harmony export */ wB: function() { return /* binding */ watch; }
|
||||||
/* harmony export */ });
|
/* harmony export */ });
|
||||||
/* unused harmony exports BaseTransition, BaseTransitionPropsValidators, Comment, DeprecationTypes, ErrorCodes, ErrorTypeStrings, KeepAlive, Static, Suspense, Teleport, Text, assertNumber, callWithErrorHandling, cloneVNode, compatUtils, createHydrationRenderer, createPropsRestProxy, createSlots, createTextVNode, defineAsyncComponent, defineComponent, defineEmits, defineExpose, defineModel, defineOptions, defineProps, defineSlots, devtools, getCurrentInstance, getTransitionRawChildren, guardReactiveProps, h, handleError, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, initCustomFormatter, isMemoSame, isRuntimeOnly, isVNode, mergeDefaults, mergeModels, mergeProps, onActivated, onBeforeMount, onBeforeUpdate, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, popScopeId, provide, pushScopeId, queuePostFlushCb, registerRuntimeCompiler, renderList, renderSlot, resolveComponent, resolveDirective, resolveDynamicComponent, resolveFilter, resolveTransitionHooks, setBlockTracking, setDevtoolsHook, setTransitionHooks, ssrContextKey, ssrUtils, toHandlers, transformVNodeArgs, useAttrs, useId, useModel, useSSRContext, useSlots, useTemplateRef, useTransitionState, version, warn, watchEffect, watchPostEffect, watchSyncEffect, withAsyncContext, withCtx, withDefaults, withMemo, withScopeId */
|
/* unused harmony exports BaseTransition, BaseTransitionPropsValidators, Comment, DeprecationTypes, ErrorCodes, ErrorTypeStrings, KeepAlive, Static, Suspense, Text, assertNumber, callWithErrorHandling, cloneVNode, compatUtils, createHydrationRenderer, createPropsRestProxy, createSlots, defineAsyncComponent, defineEmits, defineExpose, defineModel, defineOptions, defineProps, defineSlots, devtools, getCurrentInstance, getTransitionRawChildren, guardReactiveProps, handleError, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, initCustomFormatter, isMemoSame, isRuntimeOnly, isVNode, mergeDefaults, mergeModels, mergeProps, onActivated, onBeforeMount, onBeforeUpdate, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, popScopeId, provide, pushScopeId, queuePostFlushCb, registerRuntimeCompiler, renderSlot, resolveComponent, resolveDirective, resolveDynamicComponent, resolveFilter, resolveTransitionHooks, setBlockTracking, setDevtoolsHook, setTransitionHooks, ssrContextKey, ssrUtils, toHandlers, transformVNodeArgs, useAttrs, useId, useModel, useSSRContext, useSlots, useTemplateRef, useTransitionState, version, warn, watchEffect, watchPostEffect, watchSyncEffect, withAsyncContext, withCtx, withDefaults, withMemo, withScopeId */
|
||||||
/* unused harmony import specifier */ var pauseTracking;
|
/* unused harmony import specifier */ var pauseTracking;
|
||||||
/* unused harmony import specifier */ var resetTracking;
|
/* unused harmony import specifier */ var resetTracking;
|
||||||
/* unused harmony import specifier */ var isRef;
|
/* unused harmony import specifier */ var isRef;
|
||||||
@@ -2375,11 +2385,9 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/* unused harmony import specifier */ var shallowRef;
|
/* unused harmony import specifier */ var shallowRef;
|
||||||
/* unused harmony import specifier */ var isReactive;
|
/* unused harmony import specifier */ var isReactive;
|
||||||
/* unused harmony import specifier */ var ref;
|
/* unused harmony import specifier */ var ref;
|
||||||
/* unused harmony import specifier */ var isShallow;
|
|
||||||
/* unused harmony import specifier */ var shallowReadArray;
|
|
||||||
/* unused harmony import specifier */ var toReactive;
|
|
||||||
/* unused harmony import specifier */ var customRef;
|
/* unused harmony import specifier */ var customRef;
|
||||||
/* unused harmony import specifier */ var track;
|
/* unused harmony import specifier */ var track;
|
||||||
|
/* unused harmony import specifier */ var isShallow;
|
||||||
/* unused harmony import specifier */ var isString;
|
/* unused harmony import specifier */ var isString;
|
||||||
/* unused harmony import specifier */ var isFunction;
|
/* unused harmony import specifier */ var isFunction;
|
||||||
/* unused harmony import specifier */ var extend;
|
/* unused harmony import specifier */ var extend;
|
||||||
@@ -2401,13 +2409,13 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/* unused harmony import specifier */ var isRegExp;
|
/* unused harmony import specifier */ var isRegExp;
|
||||||
/* unused harmony import specifier */ var camelize;
|
/* unused harmony import specifier */ var camelize;
|
||||||
/* unused harmony import specifier */ var capitalize;
|
/* unused harmony import specifier */ var capitalize;
|
||||||
/* unused harmony import specifier */ var isObject;
|
|
||||||
/* unused harmony import specifier */ var isSymbol;
|
/* unused harmony import specifier */ var isSymbol;
|
||||||
/* unused harmony import specifier */ var toHandlerKey;
|
/* unused harmony import specifier */ var toHandlerKey;
|
||||||
/* unused harmony import specifier */ var isGloballyAllowed;
|
/* unused harmony import specifier */ var isGloballyAllowed;
|
||||||
/* unused harmony import specifier */ var NOOP;
|
/* unused harmony import specifier */ var NOOP;
|
||||||
/* unused harmony import specifier */ var isPromise;
|
/* unused harmony import specifier */ var isPromise;
|
||||||
/* unused harmony import specifier */ var makeMap;
|
/* unused harmony import specifier */ var makeMap;
|
||||||
|
/* unused harmony import specifier */ var isObject;
|
||||||
/* unused harmony import specifier */ var toRawType;
|
/* unused harmony import specifier */ var toRawType;
|
||||||
/* unused harmony import specifier */ var hyphenate;
|
/* unused harmony import specifier */ var hyphenate;
|
||||||
/* unused harmony import specifier */ var hasChanged;
|
/* unused harmony import specifier */ var hasChanged;
|
||||||
@@ -3174,7 +3182,7 @@ const isTargetSVG = target => typeof SVGElement !== "undefined" && target instan
|
|||||||
const isTargetMathML = target => typeof MathMLElement === "function" && target instanceof MathMLElement;
|
const isTargetMathML = target => typeof MathMLElement === "function" && target instanceof MathMLElement;
|
||||||
const resolveTarget = (props, select) => {
|
const resolveTarget = (props, select) => {
|
||||||
const targetSelector = props && props.to;
|
const targetSelector = props && props.to;
|
||||||
if (isString(targetSelector)) {
|
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(targetSelector)) {
|
||||||
if (!select) {
|
if (!select) {
|
||||||
false && 0;
|
false && 0;
|
||||||
return null;
|
return null;
|
||||||
@@ -3190,7 +3198,7 @@ const resolveTarget = (props, select) => {
|
|||||||
return targetSelector;
|
return targetSelector;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const TeleportImpl = (/* unused pure expression or super */ null && ({
|
const TeleportImpl = {
|
||||||
name: "Teleport",
|
name: "Teleport",
|
||||||
__isTeleport: true,
|
__isTeleport: true,
|
||||||
process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) {
|
process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) {
|
||||||
@@ -3333,7 +3341,7 @@ const TeleportImpl = (/* unused pure expression or super */ null && ({
|
|||||||
},
|
},
|
||||||
move: moveTeleport,
|
move: moveTeleport,
|
||||||
hydrate: hydrateTeleport
|
hydrate: hydrateTeleport
|
||||||
}));
|
};
|
||||||
function moveTeleport(vnode, container, parentAnchor, {
|
function moveTeleport(vnode, container, parentAnchor, {
|
||||||
o: {
|
o: {
|
||||||
insert
|
insert
|
||||||
@@ -3408,7 +3416,7 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
|
|||||||
}
|
}
|
||||||
return vnode.anchor && nextSibling(vnode.anchor);
|
return vnode.anchor && nextSibling(vnode.anchor);
|
||||||
}
|
}
|
||||||
const Teleport = (/* unused pure expression or super */ null && (TeleportImpl));
|
const Teleport = TeleportImpl;
|
||||||
function updateCssVars(vnode, isDisabled) {
|
function updateCssVars(vnode, isDisabled) {
|
||||||
const ctx = vnode.ctx;
|
const ctx = vnode.ctx;
|
||||||
if (ctx && ctx.ut) {
|
if (ctx && ctx.ut) {
|
||||||
@@ -3774,11 +3782,11 @@ function getTransitionRawChildren(children, keepComment = false, parentKey) {
|
|||||||
/*! #__NO_SIDE_EFFECTS__ */
|
/*! #__NO_SIDE_EFFECTS__ */
|
||||||
// @__NO_SIDE_EFFECTS__
|
// @__NO_SIDE_EFFECTS__
|
||||||
function defineComponent(options, extraOptions) {
|
function defineComponent(options, extraOptions) {
|
||||||
return isFunction(options) ?
|
return (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isFunction */ .Tn)(options) ?
|
||||||
// #8236: extend call and options.name access are considered side-effects
|
// #8236: extend call and options.name access are considered side-effects
|
||||||
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
|
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
|
||||||
/* @__PURE__ */
|
/* @__PURE__ */
|
||||||
(() => extend({
|
(() => (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .extend */ .X$)({
|
||||||
name: options.name
|
name: options.name
|
||||||
}, extraOptions, {
|
}, extraOptions, {
|
||||||
setup: options
|
setup: options
|
||||||
@@ -4998,17 +5006,17 @@ function resolve(registry, name) {
|
|||||||
function renderList(source, renderItem, cache, index) {
|
function renderList(source, renderItem, cache, index) {
|
||||||
let ret;
|
let ret;
|
||||||
const cached = cache && cache[index];
|
const cached = cache && cache[index];
|
||||||
const sourceIsArray = isArray(source);
|
const sourceIsArray = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(source);
|
||||||
if (sourceIsArray || isString(source)) {
|
if (sourceIsArray || (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(source)) {
|
||||||
const sourceIsReactiveArray = sourceIsArray && isReactive(source);
|
const sourceIsReactiveArray = sourceIsArray && (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isReactive */ .g8)(source);
|
||||||
let needsWrap = false;
|
let needsWrap = false;
|
||||||
if (sourceIsReactiveArray) {
|
if (sourceIsReactiveArray) {
|
||||||
needsWrap = !isShallow(source);
|
needsWrap = !(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isShallow */ .fE)(source);
|
||||||
source = shallowReadArray(source);
|
source = (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .shallowReadArray */ .qA)(source);
|
||||||
}
|
}
|
||||||
ret = new Array(source.length);
|
ret = new Array(source.length);
|
||||||
for (let i = 0, l = source.length; i < l; i++) {
|
for (let i = 0, l = source.length; i < l; i++) {
|
||||||
ret[i] = renderItem(needsWrap ? toReactive(source[i]) : source[i], i, void 0, cached && cached[i]);
|
ret[i] = renderItem(needsWrap ? (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .toReactive */ .lJ)(source[i]) : source[i], i, void 0, cached && cached[i]);
|
||||||
}
|
}
|
||||||
} else if (typeof source === "number") {
|
} else if (typeof source === "number") {
|
||||||
if (false) // removed by dead control flow
|
if (false) // removed by dead control flow
|
||||||
@@ -5017,7 +5025,7 @@ function renderList(source, renderItem, cache, index) {
|
|||||||
for (let i = 0; i < source; i++) {
|
for (let i = 0; i < source; i++) {
|
||||||
ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]);
|
ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]);
|
||||||
}
|
}
|
||||||
} else if (isObject(source)) {
|
} else if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isObject */ .Gv)(source)) {
|
||||||
if (source[Symbol.iterator]) {
|
if (source[Symbol.iterator]) {
|
||||||
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
|
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
|
||||||
} else {
|
} else {
|
||||||
@@ -9183,7 +9191,7 @@ function finishComponentSetup(instance, isSSR, skipOptions) {
|
|||||||
try {
|
try {
|
||||||
applyOptions(instance);
|
applyOptions(instance);
|
||||||
} finally {
|
} finally {
|
||||||
(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .resetTracking */ .bl)();
|
;(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .resetTracking */ .bl)();
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9192,7 +9200,7 @@ function finishComponentSetup(instance, isSSR, skipOptions) {
|
|||||||
}
|
}
|
||||||
const attrsProxyHandlers = false ? 0 : {
|
const attrsProxyHandlers = false ? 0 : {
|
||||||
get(target, key) {
|
get(target, key) {
|
||||||
(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .track */ .u4)(target, "get", "");
|
;(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .track */ .u4)(target, "get", "");
|
||||||
return target[key];
|
return target[key];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -9275,7 +9283,7 @@ const computed = (getterOrOptions, debugOptions) => {
|
|||||||
function h(type, propsOrChildren, children) {
|
function h(type, propsOrChildren, children) {
|
||||||
const l = arguments.length;
|
const l = arguments.length;
|
||||||
if (l === 2) {
|
if (l === 2) {
|
||||||
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
|
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isObject */ .Gv)(propsOrChildren) && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(propsOrChildren)) {
|
||||||
if (isVNode(propsOrChildren)) {
|
if (isVNode(propsOrChildren)) {
|
||||||
return createVNode(type, null, [propsOrChildren]);
|
return createVNode(type, null, [propsOrChildren]);
|
||||||
}
|
}
|
||||||
@@ -9449,6 +9457,7 @@ const DeprecationTypes = null;
|
|||||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||||
/* harmony export */ "EW", 0, /* binding */ computed,
|
/* harmony export */ "EW", 0, /* binding */ computed,
|
||||||
/* harmony export */ "FK", 0, /* binding */ Fragment,
|
/* harmony export */ "FK", 0, /* binding */ Fragment,
|
||||||
|
/* harmony export */ "Im", 0, /* binding */ Teleport,
|
||||||
/* harmony export */ "bF", 0, /* binding */ createVNode,
|
/* harmony export */ "bF", 0, /* binding */ createVNode,
|
||||||
/* harmony export */ "sV", 0, /* binding */ onMounted,
|
/* harmony export */ "sV", 0, /* binding */ onMounted,
|
||||||
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
|
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
|
||||||
@@ -9989,7 +9998,7 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
/* unused harmony exports Transition, TransitionGroup, VueElement, createSSRApp, defineCustomElement, defineSSRCustomElement, hydrate, initDirectivesForSSR, render, useCssModule, useCssVars, useHost, useShadowRoot, vModelCheckbox, vModelDynamic, vModelRadio, vModelSelect, vShow, withKeys */
|
/* unused harmony exports Transition, TransitionGroup, VueElement, createSSRApp, defineCustomElement, defineSSRCustomElement, hydrate, initDirectivesForSSR, render, useCssModule, useCssVars, useHost, useShadowRoot, vModelCheckbox, vModelDynamic, vModelRadio, vShow */
|
||||||
/* unused harmony import specifier */ var BaseTransitionPropsValidators;
|
/* unused harmony import specifier */ var BaseTransitionPropsValidators;
|
||||||
/* unused harmony import specifier */ var h;
|
/* unused harmony import specifier */ var h;
|
||||||
/* unused harmony import specifier */ var BaseTransition;
|
/* unused harmony import specifier */ var BaseTransition;
|
||||||
@@ -10009,7 +10018,6 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/* unused harmony import specifier */ var resolveTransitionHooks;
|
/* unused harmony import specifier */ var resolveTransitionHooks;
|
||||||
/* unused harmony import specifier */ var getTransitionRawChildren;
|
/* unused harmony import specifier */ var getTransitionRawChildren;
|
||||||
/* unused harmony import specifier */ var createVNode;
|
/* unused harmony import specifier */ var createVNode;
|
||||||
/* unused harmony import specifier */ var nextTick;
|
|
||||||
/* unused harmony import specifier */ var createHydrationRenderer;
|
/* unused harmony import specifier */ var createHydrationRenderer;
|
||||||
/* unused harmony import specifier */ var isRuntimeOnly;
|
/* unused harmony import specifier */ var isRuntimeOnly;
|
||||||
/* unused harmony import specifier */ var toRaw;
|
/* unused harmony import specifier */ var toRaw;
|
||||||
@@ -10024,8 +10032,6 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/* unused harmony import specifier */ var looseIndexOf;
|
/* unused harmony import specifier */ var looseIndexOf;
|
||||||
/* unused harmony import specifier */ var isSet;
|
/* unused harmony import specifier */ var isSet;
|
||||||
/* unused harmony import specifier */ var looseEqual;
|
/* unused harmony import specifier */ var looseEqual;
|
||||||
/* unused harmony import specifier */ var looseToNumber;
|
|
||||||
/* unused harmony import specifier */ var hyphenate;
|
|
||||||
/* unused harmony import specifier */ var isHTMLTag;
|
/* unused harmony import specifier */ var isHTMLTag;
|
||||||
/* unused harmony import specifier */ var isSVGTag;
|
/* unused harmony import specifier */ var isSVGTag;
|
||||||
/* unused harmony import specifier */ var isMathMLTag;
|
/* unused harmony import specifier */ var isMathMLTag;
|
||||||
@@ -10807,7 +10813,7 @@ function createInvoker(initialValue, instance) {
|
|||||||
} else if (e._vts <= invoker.attached) {
|
} else if (e._vts <= invoker.attached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .callWithAsyncErrorHandling */ .qL)(patchStopImmediatePropagation(e, invoker.value), instance, 5, [e]);
|
;(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .callWithAsyncErrorHandling */ .qL)(patchStopImmediatePropagation(e, invoker.value), instance, 5, [e]);
|
||||||
};
|
};
|
||||||
invoker.value = initialValue;
|
invoker.value = initialValue;
|
||||||
invoker.attached = getNow();
|
invoker.attached = getNow();
|
||||||
@@ -11571,7 +11577,7 @@ const vModelRadio = (/* unused pure expression or super */ null && ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const vModelSelect = (/* unused pure expression or super */ null && ({
|
const vModelSelect = {
|
||||||
// <select multiple> value need to be deep traversed
|
// <select multiple> value need to be deep traversed
|
||||||
deep: true,
|
deep: true,
|
||||||
created(el, {
|
created(el, {
|
||||||
@@ -11580,12 +11586,12 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
|||||||
number
|
number
|
||||||
}
|
}
|
||||||
}, vnode) {
|
}, vnode) {
|
||||||
const isSetModel = isSet(value);
|
const isSetModel = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value);
|
||||||
addEventListener(el, "change", () => {
|
addEventListener(el, "change", () => {
|
||||||
const selectedVal = Array.prototype.filter.call(el.options, o => o.selected).map(o => number ? looseToNumber(getValue(o)) : getValue(o));
|
const selectedVal = Array.prototype.filter.call(el.options, o => o.selected).map(o => number ? (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseToNumber */ .bB)(getValue(o)) : getValue(o));
|
||||||
el[assignKey](el.multiple ? isSetModel ? new Set(selectedVal) : selectedVal : selectedVal[0]);
|
el[assignKey](el.multiple ? isSetModel ? new Set(selectedVal) : selectedVal : selectedVal[0]);
|
||||||
el._assigning = true;
|
el._assigning = true;
|
||||||
nextTick(() => {
|
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .nextTick */ .dY)(() => {
|
||||||
el._assigning = false;
|
el._assigning = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -11608,11 +11614,11 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
|||||||
setSelected(el, value);
|
setSelected(el, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
function setSelected(el, value) {
|
function setSelected(el, value) {
|
||||||
const isMultiple = el.multiple;
|
const isMultiple = el.multiple;
|
||||||
const isArrayValue = isArray(value);
|
const isArrayValue = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(value);
|
||||||
if (isMultiple && !isArrayValue && !isSet(value)) {
|
if (isMultiple && !isArrayValue && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value)) {
|
||||||
false && 0;
|
false && 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -11625,12 +11631,12 @@ function setSelected(el, value) {
|
|||||||
if (optionType === "string" || optionType === "number") {
|
if (optionType === "string" || optionType === "number") {
|
||||||
option.selected = value.some(v => String(v) === String(optionValue));
|
option.selected = value.some(v => String(v) === String(optionValue));
|
||||||
} else {
|
} else {
|
||||||
option.selected = looseIndexOf(value, optionValue) > -1;
|
option.selected = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseIndexOf */ .u3)(value, optionValue) > -1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
option.selected = value.has(optionValue);
|
option.selected = value.has(optionValue);
|
||||||
}
|
}
|
||||||
} else if (looseEqual(getValue(option), value)) {
|
} else if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseEqual */ .BX)(getValue(option), value)) {
|
||||||
if (el.selectedIndex !== i) el.selectedIndex = i;
|
if (el.selectedIndex !== i) el.selectedIndex = i;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -11755,7 +11761,7 @@ const withModifiers = (fn, modifiers) => {
|
|||||||
return fn(event, ...args);
|
return fn(event, ...args);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const keyNames = (/* unused pure expression or super */ null && ({
|
const keyNames = {
|
||||||
esc: "escape",
|
esc: "escape",
|
||||||
space: " ",
|
space: " ",
|
||||||
up: "arrow-up",
|
up: "arrow-up",
|
||||||
@@ -11763,7 +11769,7 @@ const keyNames = (/* unused pure expression or super */ null && ({
|
|||||||
right: "arrow-right",
|
right: "arrow-right",
|
||||||
down: "arrow-down",
|
down: "arrow-down",
|
||||||
delete: "backspace"
|
delete: "backspace"
|
||||||
}));
|
};
|
||||||
const withKeys = (fn, modifiers) => {
|
const withKeys = (fn, modifiers) => {
|
||||||
const cache = fn._withKeys || (fn._withKeys = {});
|
const cache = fn._withKeys || (fn._withKeys = {});
|
||||||
const cacheKey = modifiers.join(".");
|
const cacheKey = modifiers.join(".");
|
||||||
@@ -11771,7 +11777,7 @@ const withKeys = (fn, modifiers) => {
|
|||||||
if (!("key" in event)) {
|
if (!("key" in event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eventKey = hyphenate(event.key);
|
const eventKey = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .hyphenate */ .Tg)(event.key);
|
||||||
if (modifiers.some(k => k === eventKey || keyNames[k] === eventKey)) {
|
if (modifiers.some(k => k === eventKey || keyNames[k] === eventKey)) {
|
||||||
return fn(event);
|
return fn(event);
|
||||||
}
|
}
|
||||||
@@ -11901,7 +11907,9 @@ const initDirectivesForSSR = () => {
|
|||||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||||
/* harmony export */ "D$", 0, /* binding */ withModifiers,
|
/* harmony export */ "D$", 0, /* binding */ withModifiers,
|
||||||
/* harmony export */ "Ef", 0, /* binding */ createApp,
|
/* harmony export */ "Ef", 0, /* binding */ createApp,
|
||||||
/* harmony export */ "Jo", 0, /* binding */ vModelText
|
/* harmony export */ "Jo", 0, /* binding */ vModelText,
|
||||||
|
/* harmony export */ "jR", 0, /* binding */ withKeys,
|
||||||
|
/* harmony export */ "u1", 0, /* binding */ vModelSelect
|
||||||
/* harmony export */ ]);
|
/* harmony export */ ]);
|
||||||
|
|
||||||
|
|
||||||
@@ -11912,9 +11920,11 @@ const initDirectivesForSSR = () => {
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||||
/* harmony export */ Y2: function() { return /* binding */ includeBooleanAttr; }
|
/* harmony export */ BX: function() { return /* binding */ looseEqual; },
|
||||||
|
/* harmony export */ Y2: function() { return /* binding */ includeBooleanAttr; },
|
||||||
|
/* harmony export */ u3: function() { return /* binding */ looseIndexOf; }
|
||||||
/* harmony export */ });
|
/* harmony export */ });
|
||||||
/* unused harmony exports EMPTY_ARR, EMPTY_OBJ, NO, NOOP, PatchFlagNames, PatchFlags, ShapeFlags, SlotFlags, cssVarNameEscapeSymbolsRE, def, escapeHtml, escapeHtmlComment, genCacheKey, genPropsAccessExp, generateCodeFrame, getEscapedCssVarName, getGlobalThis, hasChanged, isBooleanAttr, isBuiltInDirective, isDate, isGloballyAllowed, isGloballyWhitelisted, isHTMLTag, isIntegerKey, isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, isMap, isMathMLTag, isObject, isPromise, isRegExp, isRenderableAttrValue, isReservedProp, isSSRSafeAttrName, isSVGTag, isSet, isVoidTag, looseEqual, looseIndexOf, makeMap, normalizeClass, normalizeProps, normalizeStyle, objectToString, parseStringStyle, propsToAttrMap, remove, slotFlagsText, stringifyStyle, toDisplayString, toHandlerKey, toRawType, toTypeString */
|
/* unused harmony exports EMPTY_ARR, EMPTY_OBJ, NO, NOOP, PatchFlagNames, PatchFlags, ShapeFlags, SlotFlags, cssVarNameEscapeSymbolsRE, def, escapeHtml, escapeHtmlComment, genCacheKey, genPropsAccessExp, generateCodeFrame, getEscapedCssVarName, getGlobalThis, hasChanged, isBooleanAttr, isBuiltInDirective, isDate, isGloballyAllowed, isGloballyWhitelisted, isHTMLTag, isIntegerKey, isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, isMap, isMathMLTag, isObject, isPromise, isRegExp, isRenderableAttrValue, isReservedProp, isSSRSafeAttrName, isSVGTag, isVoidTag, makeMap, normalizeClass, normalizeProps, normalizeStyle, objectToString, parseStringStyle, propsToAttrMap, remove, slotFlagsText, stringifyStyle, toDisplayString, toHandlerKey, toRawType, toTypeString */
|
||||||
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4114);
|
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4114);
|
||||||
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__);
|
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__);
|
||||||
/* harmony import */ var core_js_modules_es_iterator_constructor_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8111);
|
/* harmony import */ var core_js_modules_es_iterator_constructor_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8111);
|
||||||
@@ -12421,7 +12431,8 @@ const stringifySymbol = (v, i = "") => {
|
|||||||
/* harmony export */ "X$", 0, /* binding */ extend,
|
/* harmony export */ "X$", 0, /* binding */ extend,
|
||||||
/* harmony export */ "ZH", 0, /* binding */ capitalize,
|
/* harmony export */ "ZH", 0, /* binding */ capitalize,
|
||||||
/* harmony export */ "bB", 0, /* binding */ looseToNumber,
|
/* harmony export */ "bB", 0, /* binding */ looseToNumber,
|
||||||
/* harmony export */ "cy", 0, /* binding */ isArray
|
/* harmony export */ "cy", 0, /* binding */ isArray,
|
||||||
|
/* harmony export */ "vM", 0, /* binding */ isSet
|
||||||
/* harmony export */ ]);
|
/* harmony export */ ]);
|
||||||
|
|
||||||
|
|
||||||
@@ -14690,7 +14701,7 @@ module.exports = function (it) {
|
|||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 3463:
|
/***/ 5844:
|
||||||
/***/ (function(module) {
|
/***/ (function(module) {
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -15965,6 +15976,25 @@ module.exports = function (obj) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/***/ }),
|
||||||
|
|
||||||
|
/***/ 8646:
|
||||||
|
/***/ (function(module, __unused_webpack_exports, __webpack_require__) {
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var call = __webpack_require__(9565);
|
||||||
|
var anObject = __webpack_require__(8551);
|
||||||
|
var getIteratorDirect = __webpack_require__(1767);
|
||||||
|
var getIteratorMethod = __webpack_require__(851);
|
||||||
|
|
||||||
|
module.exports = function (obj, stringHandling) {
|
||||||
|
if (!stringHandling || typeof obj !== 'string') anObject(obj);
|
||||||
|
var method = getIteratorMethod(obj);
|
||||||
|
return getIteratorDirect(anObject(method !== undefined ? call(method, obj) : obj));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 851:
|
/***/ 851:
|
||||||
@@ -18170,7 +18200,7 @@ module.exports = function (key) {
|
|||||||
var globalThis = __webpack_require__(4576);
|
var globalThis = __webpack_require__(4576);
|
||||||
var uncurryThis = __webpack_require__(9504);
|
var uncurryThis = __webpack_require__(9504);
|
||||||
var anObjectOrUndefined = __webpack_require__(3972);
|
var anObjectOrUndefined = __webpack_require__(3972);
|
||||||
var aString = __webpack_require__(3463);
|
var aString = __webpack_require__(5844);
|
||||||
var hasOwn = __webpack_require__(9297);
|
var hasOwn = __webpack_require__(9297);
|
||||||
var base64Map = __webpack_require__(2804);
|
var base64Map = __webpack_require__(2804);
|
||||||
var getAlphabetOption = __webpack_require__(944);
|
var getAlphabetOption = __webpack_require__(944);
|
||||||
@@ -18757,6 +18787,89 @@ $({ target: 'Iterator', proto: true, real: true, forced: findWithoutClosingOnEar
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/***/ }),
|
||||||
|
|
||||||
|
/***/ 531:
|
||||||
|
/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var $ = __webpack_require__(6518);
|
||||||
|
var call = __webpack_require__(9565);
|
||||||
|
var aCallable = __webpack_require__(9306);
|
||||||
|
var anObject = __webpack_require__(8551);
|
||||||
|
var getIteratorDirect = __webpack_require__(1767);
|
||||||
|
var getIteratorFlattenable = __webpack_require__(8646);
|
||||||
|
var createIteratorProxy = __webpack_require__(9462);
|
||||||
|
var iteratorClose = __webpack_require__(9539);
|
||||||
|
var IS_PURE = __webpack_require__(6395);
|
||||||
|
var iteratorHelperThrowsOnInvalidIterator = __webpack_require__(684);
|
||||||
|
var iteratorHelperWithoutClosingOnEarlyError = __webpack_require__(4549);
|
||||||
|
|
||||||
|
// Should not throw an error for an iterator without `return` method. Fixed in Safari 26.2
|
||||||
|
// https://bugs.webkit.org/show_bug.cgi?id=297532
|
||||||
|
function throwsOnIteratorWithoutReturn() {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line es/no-map, es/no-iterator, es/no-iterator-prototype-flatmap -- required for testing
|
||||||
|
var it = Iterator.prototype.flatMap.call(new Map([[4, 5]]).entries(), function (v) { return v; });
|
||||||
|
it.next();
|
||||||
|
it['return']();
|
||||||
|
} catch (error) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR = !IS_PURE
|
||||||
|
&& !iteratorHelperThrowsOnInvalidIterator('flatMap', function () { /* empty */ });
|
||||||
|
var flatMapWithoutClosingOnEarlyError = !IS_PURE && !FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR
|
||||||
|
&& iteratorHelperWithoutClosingOnEarlyError('flatMap', TypeError);
|
||||||
|
|
||||||
|
var FORCED = IS_PURE || FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR || flatMapWithoutClosingOnEarlyError
|
||||||
|
|| throwsOnIteratorWithoutReturn();
|
||||||
|
|
||||||
|
var IteratorProxy = createIteratorProxy(function () {
|
||||||
|
var iterator = this.iterator;
|
||||||
|
var mapper = this.mapper;
|
||||||
|
var result, inner;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (inner = this.inner) try {
|
||||||
|
result = anObject(call(inner.next, inner.iterator));
|
||||||
|
if (!result.done) return result.value;
|
||||||
|
this.inner = null;
|
||||||
|
} catch (error) { iteratorClose(iterator, 'throw', error); }
|
||||||
|
|
||||||
|
result = anObject(call(this.next, iterator));
|
||||||
|
|
||||||
|
if (this.done = !!result.done) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.inner = getIteratorFlattenable(mapper(result.value, this.counter++), false);
|
||||||
|
} catch (error) { iteratorClose(iterator, 'throw', error); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// `Iterator.prototype.flatMap` method
|
||||||
|
// https://tc39.es/ecma262/#sec-iterator.prototype.flatmap
|
||||||
|
$({ target: 'Iterator', proto: true, real: true, forced: FORCED }, {
|
||||||
|
flatMap: function flatMap(mapper) {
|
||||||
|
anObject(this);
|
||||||
|
try {
|
||||||
|
aCallable(mapper);
|
||||||
|
} catch (error) {
|
||||||
|
iteratorClose(this, 'throw', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatMapWithoutClosingOnEarlyError) return call(flatMapWithoutClosingOnEarlyError, this, mapper);
|
||||||
|
|
||||||
|
return new IteratorProxy(getIteratorDirect(this), {
|
||||||
|
mapper: mapper,
|
||||||
|
inner: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 7588:
|
/***/ 7588:
|
||||||
@@ -19323,7 +19436,7 @@ if (Uint8Array) $({ target: 'Uint8Array', proto: true, forced: INCORRECT_BEHAVIO
|
|||||||
|
|
||||||
var $ = __webpack_require__(6518);
|
var $ = __webpack_require__(6518);
|
||||||
var globalThis = __webpack_require__(4576);
|
var globalThis = __webpack_require__(4576);
|
||||||
var aString = __webpack_require__(3463);
|
var aString = __webpack_require__(5844);
|
||||||
var anUint8Array = __webpack_require__(4154);
|
var anUint8Array = __webpack_require__(4154);
|
||||||
var notDetached = __webpack_require__(5169);
|
var notDetached = __webpack_require__(5169);
|
||||||
var $fromHex = __webpack_require__(2303);
|
var $fromHex = __webpack_require__(2303);
|
||||||
@@ -23157,7 +23270,7 @@ const isThenable = thing => thing && (isObject(thing) || isFunction(thing)) && i
|
|||||||
/* harmony import */ var core_js_modules_es_set_is_superset_of_v2_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(2475);
|
/* harmony import */ var core_js_modules_es_set_is_superset_of_v2_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(2475);
|
||||||
/* harmony import */ var core_js_modules_es_set_symmetric_difference_v2_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(5024);
|
/* harmony import */ var core_js_modules_es_set_symmetric_difference_v2_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(5024);
|
||||||
/* harmony import */ var core_js_modules_es_set_union_v2_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(1698);
|
/* harmony import */ var core_js_modules_es_set_union_v2_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(1698);
|
||||||
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(7958);
|
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(3463);
|
||||||
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(6768);
|
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(6768);
|
||||||
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(144);
|
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(144);
|
||||||
|
|
||||||
@@ -24928,7 +25041,7 @@ const PiniaVuePlugin = function (_Vue) {
|
|||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 7958:
|
/***/ 3463:
|
||||||
/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
|
/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -24964,4 +25077,4 @@ function del(target, key) {
|
|||||||
/***/ })
|
/***/ })
|
||||||
|
|
||||||
}]);
|
}]);
|
||||||
//# sourceMappingURL=chunk-vendors.cd0faf68.js.map
|
//# sourceMappingURL=chunk-vendors.86756863.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+651
-651
File diff suppressed because it is too large
Load Diff
Generated
-11384
File diff suppressed because it is too large
Load Diff
+28
-29
@@ -1,29 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "jc-web-im",
|
"name": "jc-web-im",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
|
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
|
||||||
"build": "vue-cli-service build",
|
"build": "vue-cli-service build",
|
||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^10.8.0",
|
"@vueuse/core": "^10.8.0",
|
||||||
"axios": "1.6.7",
|
"axios": "1.6.7",
|
||||||
"jr-qrcode": "^1.1.4",
|
"jr-qrcode": "^1.1.4",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"process": "^0.11.10",
|
"vue": "3.5.13",
|
||||||
"vue": "3.5.13",
|
"vue-router": "^4.3.0"
|
||||||
"vue-router": "^4.3.0"
|
},
|
||||||
},
|
"devDependencies": {
|
||||||
"devDependencies": {
|
"@vue/cli-plugin-babel": "~5.0.8",
|
||||||
"@vue/cli-plugin-babel": "~5.0.8",
|
"@vue/cli-service": "~5.0.8",
|
||||||
"@vue/cli-service": "~5.0.8",
|
"@vue/compiler-sfc": "^3.5.13"
|
||||||
"@vue/compiler-sfc": "^3.5.13"
|
},
|
||||||
},
|
"browserslist": [
|
||||||
"browserslist": [
|
"> 1%",
|
||||||
"> 1%",
|
"last 2 versions",
|
||||||
"last 2 versions",
|
"not dead"
|
||||||
"not dead"
|
]
|
||||||
]
|
}
|
||||||
}
|
|
||||||
|
|||||||
+95
-95
@@ -1,95 +1,95 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$keyword = trim((string)($payload['keyword'] ?? ''));
|
$keyword = trim((string)($payload['keyword'] ?? ''));
|
||||||
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
|
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少当前用户ID');
|
respond(400, '缺少当前用户ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($keyword === '') {
|
if ($keyword === '') {
|
||||||
respond(400, '请输入要添加的联系人');
|
respond(400, '请输入要添加的联系人');
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryImUserInfo(array $payload): ?array
|
function tryImUserInfo(array $payload): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
|
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
|
||||||
return $result ?: null;
|
return $result ?: null;
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUserInfo(array $user): array
|
function normalizeUserInfo(array $user): array
|
||||||
{
|
{
|
||||||
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
|
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
|
||||||
return [
|
return [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'name' => (string)($user['name'] ?? $uid),
|
'name' => (string)($user['name'] ?? $uid),
|
||||||
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
||||||
'mobile' => (string)($user['mobile'] ?? ''),
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
'portrait' => (string)($user['portrait'] ?? ''),
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
'alias' => '',
|
'alias' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$target = tryImUserInfo(['userId' => $keyword]);
|
$target = tryImUserInfo(['userId' => $keyword]);
|
||||||
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
|
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
|
||||||
$target = tryImUserInfo(['mobile' => $keyword]);
|
$target = tryImUserInfo(['mobile' => $keyword]);
|
||||||
}
|
}
|
||||||
if (!$target) {
|
if (!$target) {
|
||||||
$target = tryImUserInfo(['name' => $keyword]);
|
$target = tryImUserInfo(['name' => $keyword]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$target) {
|
if (!$target) {
|
||||||
respond(404, '没有找到该用户');
|
respond(404, '没有找到该用户');
|
||||||
}
|
}
|
||||||
|
|
||||||
$contact = normalizeUserInfo($target);
|
$contact = normalizeUserInfo($target);
|
||||||
if ($contact['uid'] === '') {
|
if ($contact['uid'] === '') {
|
||||||
respond(404, '没有找到该用户');
|
respond(404, '没有找到该用户');
|
||||||
}
|
}
|
||||||
if ($contact['uid'] === $userId) {
|
if ($contact['uid'] === $userId) {
|
||||||
respond(400, '不能添加自己为联系人');
|
respond(400, '不能添加自己为联系人');
|
||||||
}
|
}
|
||||||
|
|
||||||
$friendResult = requireImSuccess(
|
$friendResult = requireImSuccess(
|
||||||
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
||||||
'好友列表检查失败'
|
'好友列表检查失败'
|
||||||
);
|
);
|
||||||
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
|
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
|
||||||
if (in_array($contact['uid'], $friendIds, true)) {
|
if (in_array($contact['uid'], $friendIds, true)) {
|
||||||
respond(409, '该用户已经是联系人', ['contact' => $contact]);
|
respond(409, '该用户已经是联系人', ['contact' => $contact]);
|
||||||
}
|
}
|
||||||
|
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/friend/status', [
|
imAdminPost('/admin/friend/status', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
'friendUid' => $contact['uid'],
|
'friendUid' => $contact['uid'],
|
||||||
'status' => 0,
|
'status' => 0,
|
||||||
]),
|
]),
|
||||||
'添加联系人失败'
|
'添加联系人失败'
|
||||||
);
|
);
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/friend/status', [
|
imAdminPost('/admin/friend/status', [
|
||||||
'userId' => $contact['uid'],
|
'userId' => $contact['uid'],
|
||||||
'friendUid' => $userId,
|
'friendUid' => $userId,
|
||||||
'status' => 0,
|
'status' => 0,
|
||||||
]),
|
]),
|
||||||
'添加联系人失败'
|
'添加联系人失败'
|
||||||
);
|
);
|
||||||
|
|
||||||
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
|
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
|
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-71
@@ -1,71 +1,67 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$friendResult = requireImSuccess(
|
$friendResult = requireImSuccess(
|
||||||
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
imAdminPost('/admin/friend/list', ['userId' => $userId]),
|
||||||
'联系人加载失败'
|
'联系人加载失败'
|
||||||
);
|
);
|
||||||
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
|
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
|
||||||
return trim((string)$uid);
|
return trim((string)$uid);
|
||||||
}, $friendResult['list'] ?? []))));
|
}, $friendResult['list'] ?? []))));
|
||||||
if (!$friendIds) {
|
if (!$friendIds) {
|
||||||
respond(0, 'success', ['contacts' => []]);
|
respond(0, 'success', ['contacts' => []]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$usersResult = requireImSuccess(
|
$usersResult = requireImSuccess(
|
||||||
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
|
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
|
||||||
'联系人资料加载失败'
|
'联系人资料加载失败'
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
|
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$remarkMap = [];
|
$remarkMap = [];
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
$contactsByUid = [];
|
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
|
||||||
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
} catch (Throwable) {
|
||||||
$uid = (string)($user['userId'] ?? '');
|
$friendAliasMap = [];
|
||||||
if ($uid === '' || isset($contactsByUid[$uid])) {
|
}
|
||||||
continue;
|
|
||||||
}
|
$contactsByUid = [];
|
||||||
$alias = '';
|
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
||||||
try {
|
$uid = (string)($user['userId'] ?? '');
|
||||||
$aliasResult = requireImSuccess(
|
if ($uid === '' || isset($contactsByUid[$uid])) {
|
||||||
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
|
continue;
|
||||||
'联系人备注加载失败'
|
}
|
||||||
);
|
$alias = $friendAliasMap[$uid] ?? '';
|
||||||
$alias = trim((string)($aliasResult['alias'] ?? ''));
|
if ($alias === '') {
|
||||||
} catch (Throwable $error) {
|
$alias = $remarkMap[$uid] ?? '';
|
||||||
$alias = '';
|
}
|
||||||
}
|
$contactsByUid[$uid] = [
|
||||||
if ($alias === '') {
|
'uid' => $uid,
|
||||||
$alias = $remarkMap[$uid] ?? '';
|
'name' => (string)($user['name'] ?? $uid),
|
||||||
}
|
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
||||||
$contactsByUid[$uid] = [
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
'uid' => $uid,
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
'name' => (string)($user['name'] ?? $uid),
|
'address' => (string)($user['address'] ?? ''),
|
||||||
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
|
'alias' => $alias,
|
||||||
'mobile' => (string)($user['mobile'] ?? ''),
|
];
|
||||||
'portrait' => (string)($user['portrait'] ?? ''),
|
}
|
||||||
'address' => (string)($user['address'] ?? ''),
|
|
||||||
'alias' => $alias,
|
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
|
||||||
];
|
} catch (Throwable $error) {
|
||||||
}
|
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
|
||||||
|
}
|
||||||
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
|
|
||||||
} catch (Throwable $error) {
|
|
||||||
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
|
|
||||||
}
|
|
||||||
|
|||||||
+1173
-990
File diff suppressed because it is too large
Load Diff
+443
-348
@@ -1,348 +1,443 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
function webImEnv(string $key, string $fallback = ''): string
|
||||||
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
|
{
|
||||||
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
|
$runtimeValue = getenv($key);
|
||||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
if ($runtimeValue !== false && $runtimeValue !== '') {
|
||||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
return $runtimeValue;
|
||||||
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
|
}
|
||||||
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
|
|
||||||
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
|
static $env = null;
|
||||||
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
|
if ($env === null) {
|
||||||
|
$env = [];
|
||||||
function respond(int $code, string $message, array $result = []): void
|
$paths = array_filter([
|
||||||
{
|
getenv('JC_WEB_IM_ENV_FILE') ?: '',
|
||||||
echo json_encode([
|
dirname(__DIR__, 2) . '/.env',
|
||||||
'code' => $code,
|
'/var/www/im-admin/.env',
|
||||||
'message' => $message,
|
'/var/www/im/.env',
|
||||||
'result' => $result,
|
]);
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
exit;
|
foreach ($paths as $path) {
|
||||||
}
|
if (!is_readable($path)) {
|
||||||
|
continue;
|
||||||
function readJsonPayload(): array
|
}
|
||||||
{
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
$line = trim($line);
|
||||||
return is_array($payload) ? $payload : [];
|
if ($line === '' || strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
function imAdminPost(string $path, array $payload): array
|
[$name, $value] = array_map('trim', explode('=', $line, 2));
|
||||||
{
|
if ($name === '') {
|
||||||
$nonce = (string)random_int(1, 1000000);
|
continue;
|
||||||
$timestamp = (string)((int)floor(microtime(true) * 1000));
|
}
|
||||||
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
|
$value = trim($value);
|
||||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
if (
|
||||||
|
strlen($value) >= 2
|
||||||
$curl = curl_init(IM_ADMIN_URL . $path);
|
&& (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))
|
||||||
if ($curl === false) {
|
) {
|
||||||
throw new RuntimeException('IM admin client init failed');
|
$value = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
|
$env[$name] = $value;
|
||||||
curl_setopt_array($curl, [
|
}
|
||||||
CURLOPT_POST => true,
|
break;
|
||||||
CURLOPT_POSTFIELDS => $body,
|
}
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
}
|
||||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
|
|
||||||
CURLOPT_CONNECTTIMEOUT_MS => 1000,
|
return $env[$key] ?? $fallback;
|
||||||
CURLOPT_TIMEOUT_MS => 4000,
|
}
|
||||||
CURLOPT_FRESH_CONNECT => true,
|
|
||||||
CURLOPT_FORBID_REUSE => true,
|
function webImFirstEnv(array $keys, string $fallback = ''): string
|
||||||
CURLOPT_HTTPHEADER => [
|
{
|
||||||
'Content-Type: application/json; charset=utf-8',
|
foreach ($keys as $key) {
|
||||||
'Connection: close',
|
$value = webImEnv($key);
|
||||||
'nonce: ' . $nonce,
|
if ($value !== '') {
|
||||||
'timestamp: ' . $timestamp,
|
return $value;
|
||||||
'sign: ' . $sign,
|
}
|
||||||
],
|
}
|
||||||
]);
|
return $fallback;
|
||||||
|
}
|
||||||
$responseBody = curl_exec($curl);
|
|
||||||
$curlError = curl_error($curl);
|
function webImMysqlDsn(string $host, string $port, string $database): string
|
||||||
curl_close($curl);
|
{
|
||||||
|
return sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||||
if ($responseBody === false || $responseBody === '') {
|
}
|
||||||
throw new RuntimeException($curlError ?: 'IM admin request failed');
|
|
||||||
}
|
define('IM_ADMIN_URL', webImEnv('JC_WEB_IM_ADMIN_URL', 'http://127.0.0.1:18080'));
|
||||||
|
define('IM_ADMIN_SECRET', webImFirstEnv(['JC_WEB_IM_ADMIN_SECRET', 'SecretKey'], '123456'));
|
||||||
$data = json_decode($responseBody, true);
|
define('IM_DB_DSN', webImMysqlDsn(
|
||||||
if (!is_array($data)) {
|
webImEnv('JC_WEB_IM_DB_HOST', webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
throw new RuntimeException('Invalid IM admin response');
|
webImEnv('JC_WEB_IM_DB_PORT', webImEnv('DB_PORT', '3306')),
|
||||||
}
|
webImEnv('JC_WEB_IM_DB_DATABASE', webImEnv('DB_DATABASE', 'wfchat'))
|
||||||
|
));
|
||||||
return $data;
|
define('IM_DB_USER', webImEnv('JC_WEB_IM_DB_USER', webImEnv('DB_USERNAME', 'winchen')));
|
||||||
}
|
define('IM_DB_PASSWORD', webImEnv('JC_WEB_IM_DB_PASSWORD', webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
|
define('IM_APP_DB_DSN', webImMysqlDsn(
|
||||||
function requireImSuccess(array $response, string $fallbackMessage): array
|
webImFirstEnv(['JC_WEB_IM_APP_DB_HOST', 'DB_HOST_2', 'DB_HOST2_2'], webImEnv('DB_HOST', '127.0.0.1')),
|
||||||
{
|
webImFirstEnv(['JC_WEB_IM_APP_DB_PORT', 'DB_PORT_2', 'DB_PORT2_2'], webImEnv('DB_PORT', '3306')),
|
||||||
if (($response['code'] ?? -1) !== 0) {
|
webImFirstEnv(['JC_WEB_IM_APP_DB_DATABASE', 'DB_DATABASE_2', 'DB_DATABASE2_2'], webImEnv('DB_DATABASE', 'appdata'))
|
||||||
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
|
));
|
||||||
}
|
define('IM_APP_DB_USER', webImFirstEnv(['JC_WEB_IM_APP_DB_USER', 'DB_USERNAME_2', 'DB_USERNAME2_2'], webImEnv('DB_USERNAME', 'winchen')));
|
||||||
|
define('IM_APP_DB_PASSWORD', webImFirstEnv(['JC_WEB_IM_APP_DB_PASSWORD', 'DB_PASSWORD_2', 'DB_PASSWORD2_2'], webImEnv('DB_PASSWORD', 'winchen@0904')));
|
||||||
return is_array($response['result'] ?? null) ? $response['result'] : [];
|
define('IM_DB_TIMEZONE', webImEnv('JC_WEB_IM_DB_TIMEZONE', 'Asia/Bangkok'));
|
||||||
}
|
|
||||||
|
function respond(int $code, string $message, array $result = []): void
|
||||||
function imDb(): PDO
|
{
|
||||||
{
|
echo json_encode([
|
||||||
static $pdo = null;
|
'code' => $code,
|
||||||
if ($pdo instanceof PDO) {
|
'message' => $message,
|
||||||
return $pdo;
|
'result' => $result,
|
||||||
}
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
|
}
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
function readJsonPayload(): array
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
{
|
||||||
]);
|
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
||||||
|
return is_array($payload) ? $payload : [];
|
||||||
return $pdo;
|
}
|
||||||
}
|
|
||||||
|
function imAdminPost(string $path, array $payload): array
|
||||||
function imAppDb(): PDO
|
{
|
||||||
{
|
$nonce = (string)random_int(1, 1000000);
|
||||||
static $pdo = null;
|
$timestamp = (string)((int)floor(microtime(true) * 1000));
|
||||||
if ($pdo instanceof PDO) {
|
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
|
||||||
return $pdo;
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
}
|
|
||||||
|
$curl = curl_init(IM_ADMIN_URL . $path);
|
||||||
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
|
if ($curl === false) {
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
throw new RuntimeException('IM admin client init failed');
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
}
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
|
||||||
]);
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
return $pdo;
|
CURLOPT_POSTFIELDS => $body,
|
||||||
}
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
|
||||||
function ensureWebImAppTables(): void
|
CURLOPT_CONNECTTIMEOUT_MS => 1000,
|
||||||
{
|
CURLOPT_TIMEOUT_MS => 4000,
|
||||||
static $ensured = false;
|
CURLOPT_FRESH_CONNECT => true,
|
||||||
if ($ensured) {
|
CURLOPT_FORBID_REUSE => true,
|
||||||
return;
|
CURLOPT_HTTPHEADER => [
|
||||||
}
|
'Content-Type: application/json; charset=utf-8',
|
||||||
$ensured = true;
|
'Connection: close',
|
||||||
|
'nonce: ' . $nonce,
|
||||||
$db = imAppDb();
|
'timestamp: ' . $timestamp,
|
||||||
$db->exec(
|
'sign: ' . $sign,
|
||||||
"create table if not exists `t_web_groups` (
|
],
|
||||||
`id` bigint unsigned not null auto_increment,
|
]);
|
||||||
`group_id` varchar(64) not null,
|
|
||||||
`category` varchar(20) not null default 'group',
|
$responseBody = curl_exec($curl);
|
||||||
`name` varchar(128) not null,
|
$curlError = curl_error($curl);
|
||||||
`portrait` varchar(1024) not null default '',
|
curl_close($curl);
|
||||||
`owner_uid` varchar(64) not null,
|
|
||||||
`join_mode` varchar(20) not null default 'open',
|
if ($responseBody === false || $responseBody === '') {
|
||||||
`announcement` text null,
|
throw new RuntimeException($curlError ?: 'IM admin request failed');
|
||||||
`native_group_id` varchar(64) not null default '',
|
}
|
||||||
`native_status` varchar(32) not null default 'pending',
|
|
||||||
`native_error` varchar(255) not null default '',
|
$data = json_decode($responseBody, true);
|
||||||
`created_by` varchar(64) not null,
|
if (!is_array($data)) {
|
||||||
`created_at_ms` bigint not null,
|
throw new RuntimeException('Invalid IM admin response');
|
||||||
`updated_at_ms` bigint not null,
|
}
|
||||||
`deleted` tinyint not null default 0,
|
|
||||||
primary key (`id`),
|
return $data;
|
||||||
unique key `uk_t_web_groups_group_id` (`group_id`),
|
}
|
||||||
key `idx_t_web_groups_owner` (`owner_uid`),
|
|
||||||
key `idx_t_web_groups_category` (`category`)
|
function requireImSuccess(array $response, string $fallbackMessage): array
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
{
|
||||||
);
|
if (($response['code'] ?? -1) !== 0) {
|
||||||
try {
|
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
|
||||||
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
|
}
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
return is_array($response['result'] ?? null) ? $response['result'] : [];
|
||||||
$db->exec(
|
}
|
||||||
"create table if not exists `t_web_group_members` (
|
|
||||||
`id` bigint unsigned not null auto_increment,
|
function imDb(): PDO
|
||||||
`group_id` varchar(64) not null,
|
{
|
||||||
`user_id` varchar(64) not null,
|
static $pdo = null;
|
||||||
`role` varchar(20) not null default 'member',
|
if ($pdo instanceof PDO) {
|
||||||
`alias` varchar(128) not null default '',
|
return $pdo;
|
||||||
`joined_at_ms` bigint not null,
|
}
|
||||||
`updated_at_ms` bigint not null,
|
|
||||||
`status` tinyint not null default 0,
|
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
|
||||||
primary key (`id`),
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
key `idx_t_web_group_members_user` (`user_id`)
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
]);
|
||||||
);
|
|
||||||
$db->exec(
|
return $pdo;
|
||||||
"create table if not exists `t_web_group_messages` (
|
}
|
||||||
`id` bigint unsigned not null auto_increment,
|
|
||||||
`message_uid` varchar(64) not null,
|
function imAppDb(): PDO
|
||||||
`group_id` varchar(64) not null,
|
{
|
||||||
`sender_uid` varchar(64) not null,
|
static $pdo = null;
|
||||||
`text` text not null,
|
if ($pdo instanceof PDO) {
|
||||||
`created_at_ms` bigint not null,
|
return $pdo;
|
||||||
`edited_at_ms` bigint not null default 0,
|
}
|
||||||
`recalled` tinyint not null default 0,
|
|
||||||
primary key (`id`),
|
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
|
||||||
unique key `uk_t_web_group_messages_uid` (`message_uid`),
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
key `idx_t_web_group_messages_sender` (`sender_uid`)
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
]);
|
||||||
);
|
|
||||||
$db->exec(
|
return $pdo;
|
||||||
"create table if not exists `t_web_message_attachments` (
|
}
|
||||||
`id` bigint unsigned not null auto_increment,
|
|
||||||
`message_uid` varchar(64) not null,
|
function ensureWebImAppTables(): void
|
||||||
`conversation_type` varchar(16) not null default 'single',
|
{
|
||||||
`target_id` varchar(128) not null default '',
|
static $ensured = false;
|
||||||
`sender_uid` varchar(64) not null default '',
|
if ($ensured) {
|
||||||
`attachment_type` varchar(16) not null,
|
return;
|
||||||
`url` varchar(1024) not null,
|
}
|
||||||
`name` varchar(255) not null default '',
|
$ensured = true;
|
||||||
`mime` varchar(128) not null default '',
|
|
||||||
`size_bytes` bigint not null default 0,
|
$db = imAppDb();
|
||||||
`created_at_ms` bigint not null,
|
$db->exec(
|
||||||
primary key (`id`),
|
"create table if not exists `t_web_groups` (
|
||||||
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
|
`id` bigint unsigned not null auto_increment,
|
||||||
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
|
`group_id` varchar(64) not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`category` varchar(20) not null default 'group',
|
||||||
);
|
`name` varchar(128) not null,
|
||||||
$db->exec(
|
`portrait` varchar(1024) not null default '',
|
||||||
"create table if not exists `t_web_group_reads` (
|
`owner_uid` varchar(64) not null,
|
||||||
`id` bigint unsigned not null auto_increment,
|
`join_mode` varchar(20) not null default 'open',
|
||||||
`group_id` varchar(64) not null,
|
`announcement` text null,
|
||||||
`user_id` varchar(64) not null,
|
`native_group_id` varchar(64) not null default '',
|
||||||
`read_at_ms` bigint not null default 0,
|
`native_status` varchar(32) not null default 'pending',
|
||||||
`updated_at_ms` bigint not null,
|
`native_error` varchar(255) not null default '',
|
||||||
primary key (`id`),
|
`created_by` varchar(64) not null,
|
||||||
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
|
`created_at_ms` bigint not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`updated_at_ms` bigint not null,
|
||||||
);
|
`deleted` tinyint not null default 0,
|
||||||
$db->exec(
|
primary key (`id`),
|
||||||
"create table if not exists `t_web_group_mute_rules` (
|
unique key `uk_t_web_groups_group_id` (`group_id`),
|
||||||
`id` bigint unsigned not null auto_increment,
|
key `idx_t_web_groups_owner` (`owner_uid`),
|
||||||
`group_id` varchar(64) not null,
|
key `idx_t_web_groups_category` (`category`)
|
||||||
`mode` varchar(32) not null default 'off',
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
`schedule_type` varchar(16) not null default 'once',
|
);
|
||||||
`starts_at_ms` bigint not null default 0,
|
try {
|
||||||
`ends_at_ms` bigint not null default 0,
|
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
|
||||||
`daily_start` varchar(8) not null default '',
|
} catch (Throwable) {
|
||||||
`daily_end` varchar(8) not null default '',
|
}
|
||||||
`enabled` tinyint not null default 0,
|
$db->exec(
|
||||||
`created_by` varchar(64) not null default '',
|
"create table if not exists `t_web_group_members` (
|
||||||
`updated_at_ms` bigint not null,
|
`id` bigint unsigned not null auto_increment,
|
||||||
primary key (`id`),
|
`group_id` varchar(64) not null,
|
||||||
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
|
`user_id` varchar(64) not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`role` varchar(20) not null default 'member',
|
||||||
);
|
`alias` varchar(128) not null default '',
|
||||||
$db->exec(
|
`joined_at_ms` bigint not null,
|
||||||
"create table if not exists `t_web_broadcast_logs` (
|
`updated_at_ms` bigint not null,
|
||||||
`id` bigint unsigned not null auto_increment,
|
`status` tinyint not null default 0,
|
||||||
`sender_uid` varchar(64) not null,
|
primary key (`id`),
|
||||||
`target_count` int not null default 0,
|
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
|
||||||
`success_count` int not null default 0,
|
key `idx_t_web_group_members_user` (`user_id`)
|
||||||
`failed_count` int not null default 0,
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
`text` text not null,
|
);
|
||||||
`target_json` text null,
|
$db->exec(
|
||||||
`result_json` text null,
|
"create table if not exists `t_web_group_messages` (
|
||||||
`created_at_ms` bigint not null,
|
`id` bigint unsigned not null auto_increment,
|
||||||
primary key (`id`),
|
`message_uid` varchar(64) not null,
|
||||||
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
|
`group_id` varchar(64) not null,
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`sender_uid` varchar(64) not null,
|
||||||
);
|
`text` text not null,
|
||||||
$db->exec(
|
`created_at_ms` bigint not null,
|
||||||
"create table if not exists `t_web_user_remarks` (
|
`edited_at_ms` bigint not null default 0,
|
||||||
`id` bigint unsigned not null auto_increment,
|
`recalled` tinyint not null default 0,
|
||||||
`owner_uid` varchar(64) not null,
|
primary key (`id`),
|
||||||
`target_uid` varchar(64) not null,
|
unique key `uk_t_web_group_messages_uid` (`message_uid`),
|
||||||
`alias` varchar(128) not null default '',
|
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
|
||||||
`created_at_ms` bigint not null,
|
key `idx_t_web_group_messages_sender` (`sender_uid`)
|
||||||
`updated_at_ms` bigint not null,
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
primary key (`id`),
|
);
|
||||||
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
|
$db->exec(
|
||||||
key `idx_t_web_user_remarks_target` (`target_uid`)
|
"create table if not exists `t_web_message_attachments` (
|
||||||
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
`id` bigint unsigned not null auto_increment,
|
||||||
);
|
`message_uid` varchar(64) not null,
|
||||||
}
|
`conversation_type` varchar(16) not null default 'single',
|
||||||
|
`target_id` varchar(128) not null default '',
|
||||||
function webImPlaceholders(array $values): string
|
`sender_uid` varchar(64) not null default '',
|
||||||
{
|
`attachment_type` varchar(16) not null,
|
||||||
return implode(',', array_fill(0, count($values), '?'));
|
`url` varchar(1024) not null,
|
||||||
}
|
`name` varchar(255) not null default '',
|
||||||
|
`mime` varchar(128) not null default '',
|
||||||
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
|
`size_bytes` bigint not null default 0,
|
||||||
{
|
`created_at_ms` bigint not null,
|
||||||
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
|
primary key (`id`),
|
||||||
return trim((string)$targetId);
|
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
|
||||||
}, $targetIds))));
|
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
|
||||||
if ($ownerId === '' || !$targetIds) {
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
return [];
|
);
|
||||||
}
|
$db->exec(
|
||||||
|
"create table if not exists `t_web_group_reads` (
|
||||||
$statement = $db->prepare(
|
`id` bigint unsigned not null auto_increment,
|
||||||
'select `target_uid`, `alias` from `t_web_user_remarks`'
|
`group_id` varchar(64) not null,
|
||||||
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
|
`user_id` varchar(64) not null,
|
||||||
);
|
`read_at_ms` bigint not null default 0,
|
||||||
$statement->execute(array_merge([$ownerId], $targetIds));
|
`updated_at_ms` bigint not null,
|
||||||
|
primary key (`id`),
|
||||||
$remarks = [];
|
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
|
||||||
foreach ($statement->fetchAll() as $row) {
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
|
);
|
||||||
}
|
$db->exec(
|
||||||
|
"create table if not exists `t_web_group_mute_rules` (
|
||||||
return $remarks;
|
`id` bigint unsigned not null auto_increment,
|
||||||
}
|
`group_id` varchar(64) not null,
|
||||||
|
`mode` varchar(32) not null default 'off',
|
||||||
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
`schedule_type` varchar(16) not null default 'once',
|
||||||
{
|
`starts_at_ms` bigint not null default 0,
|
||||||
if ($ownerId === '' || $targetId === '') {
|
`ends_at_ms` bigint not null default 0,
|
||||||
return;
|
`daily_start` varchar(8) not null default '',
|
||||||
}
|
`daily_end` varchar(8) not null default '',
|
||||||
|
`enabled` tinyint not null default 0,
|
||||||
if ($alias === '') {
|
`created_by` varchar(64) not null default '',
|
||||||
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
|
`updated_at_ms` bigint not null,
|
||||||
$delete->execute([$ownerId, $targetId]);
|
primary key (`id`),
|
||||||
return;
|
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
|
||||||
}
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
$now = webImNowMs();
|
$db->exec(
|
||||||
$statement = $db->prepare(
|
"create table if not exists `t_web_broadcast_logs` (
|
||||||
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
|
`id` bigint unsigned not null auto_increment,
|
||||||
. ' values (?, ?, ?, ?, ?)'
|
`sender_uid` varchar(64) not null,
|
||||||
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
|
`target_count` int not null default 0,
|
||||||
);
|
`success_count` int not null default 0,
|
||||||
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
|
`failed_count` int not null default 0,
|
||||||
}
|
`text` text not null,
|
||||||
|
`target_json` text null,
|
||||||
function webImNowMs(): int
|
`result_json` text null,
|
||||||
{
|
`created_at_ms` bigint not null,
|
||||||
return (int)floor(microtime(true) * 1000);
|
primary key (`id`),
|
||||||
}
|
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
|
||||||
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
function webImMessageUid(): string
|
);
|
||||||
{
|
$db->exec(
|
||||||
$timestampPart = (webImNowMs() - 1514736000000) << 21;
|
"create table if not exists `t_web_user_remarks` (
|
||||||
return (string)($timestampPart | random_int(1, 0x1FFFFF));
|
`id` bigint unsigned not null auto_increment,
|
||||||
}
|
`owner_uid` varchar(64) not null,
|
||||||
|
`target_uid` varchar(64) not null,
|
||||||
function javaStringHashCode(string $value): int
|
`alias` varchar(128) not null default '',
|
||||||
{
|
`created_at_ms` bigint not null,
|
||||||
$hash = 0;
|
`updated_at_ms` bigint not null,
|
||||||
$length = strlen($value);
|
primary key (`id`),
|
||||||
for ($i = 0; $i < $length; $i++) {
|
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
|
||||||
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
|
key `idx_t_web_user_remarks_target` (`target_uid`)
|
||||||
if ($hash >= 0x80000000) {
|
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
|
||||||
$hash -= 0x100000000;
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
function webImPlaceholders(array $values): string
|
||||||
return $hash;
|
{
|
||||||
}
|
return implode(',', array_fill(0, count($values), '?'));
|
||||||
|
}
|
||||||
function imUserMessagesTable(string $userId): string
|
|
||||||
{
|
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
|
||||||
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
|
{
|
||||||
}
|
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
|
||||||
|
return trim((string)$targetId);
|
||||||
function imMessageTable(string $messageId): string
|
}, $targetIds))));
|
||||||
{
|
if ($ownerId === '' || !$targetIds) {
|
||||||
$timestamp = ((int)$messageId >> 21) + 1514736000000;
|
return [];
|
||||||
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
|
}
|
||||||
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
|
||||||
$year = (int)$date->format('Y');
|
$statement = $db->prepare(
|
||||||
$month = (int)$date->format('n') - 1;
|
'select `target_uid`, `alias` from `t_web_user_remarks`'
|
||||||
|
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
|
||||||
return 't_messages_' . (($year % 3) * 12 + $month);
|
);
|
||||||
}
|
$statement->execute(array_merge([$ownerId], $targetIds));
|
||||||
|
|
||||||
|
$remarks = [];
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
|
||||||
|
{
|
||||||
|
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
|
||||||
|
return trim((string)$friendId);
|
||||||
|
}, $friendIds))));
|
||||||
|
if ($ownerId === '' || !$friendIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliases = [];
|
||||||
|
foreach (array_chunk($friendIds, 500) as $chunk) {
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'select `_friend_uid`, `_alias` from `t_friend`'
|
||||||
|
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
|
||||||
|
);
|
||||||
|
$statement->execute(array_merge([$ownerId], $chunk));
|
||||||
|
|
||||||
|
foreach ($statement->fetchAll() as $row) {
|
||||||
|
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
||||||
|
{
|
||||||
|
if ($ownerId === '' || $targetId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($alias === '') {
|
||||||
|
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
|
||||||
|
$delete->execute([$ownerId, $targetId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = webImNowMs();
|
||||||
|
$statement = $db->prepare(
|
||||||
|
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
|
||||||
|
. ' values (?, ?, ?, ?, ?)'
|
||||||
|
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
|
);
|
||||||
|
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImNowMs(): int
|
||||||
|
{
|
||||||
|
return (int)floor(microtime(true) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webImMessageUid(): string
|
||||||
|
{
|
||||||
|
$timestampPart = (webImNowMs() - 1514736000000) << 21;
|
||||||
|
return (string)($timestampPart | random_int(1, 0x1FFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
function javaStringHashCode(string $value): int
|
||||||
|
{
|
||||||
|
$hash = 0;
|
||||||
|
$length = strlen($value);
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
|
||||||
|
if ($hash >= 0x80000000) {
|
||||||
|
$hash -= 0x100000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imUserMessagesTable(string $userId): string
|
||||||
|
{
|
||||||
|
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imMessageTable(string $messageId): string
|
||||||
|
{
|
||||||
|
$timestamp = ((int)$messageId >> 21) + 1514736000000;
|
||||||
|
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
|
||||||
|
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||||
|
$year = (int)$date->format('Y');
|
||||||
|
$month = (int)$date->format('n') - 1;
|
||||||
|
|
||||||
|
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
|
|
||||||
function failDownload(int $status, string $message): never
|
function failDownload(int $status, string $message): never
|
||||||
{
|
{
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
echo $message;
|
echo $message;
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDownloadName(string $fileName, string $fallback): string
|
function safeDownloadName(string $fileName, string $fallback): string
|
||||||
{
|
{
|
||||||
$name = basename(str_replace('\\', '/', $fileName));
|
$name = basename(str_replace('\\', '/', $fileName));
|
||||||
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
|
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
|
||||||
$name = trim($name, " .\t\n\r\0\x0B");
|
$name = trim($name, " .\t\n\r\0\x0B");
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
$name = $fallback;
|
$name = $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mb_substr($name, 0, 180, 'UTF-8');
|
return mb_substr($name, 0, 180, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = trim((string)($_GET['url'] ?? ''));
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
$requestedName = trim((string)($_GET['name'] ?? ''));
|
$requestedName = trim((string)($_GET['name'] ?? ''));
|
||||||
if ($url === '') {
|
if ($url === '') {
|
||||||
failDownload(400, '缺少媒体地址');
|
failDownload(400, '缺少媒体地址');
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
|
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
|
||||||
$path = rawurldecode($path);
|
$path = rawurldecode($path);
|
||||||
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||||
failDownload(400, '媒体地址不合法');
|
failDownload(400, '媒体地址不合法');
|
||||||
}
|
}
|
||||||
|
|
||||||
$root = realpath(WEB_MEDIA_ROOT);
|
$root = realpath(WEB_MEDIA_ROOT);
|
||||||
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
|
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
|
||||||
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||||
failDownload(404, '媒体文件不存在');
|
failDownload(404, '媒体文件不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
$fallbackName = basename($fullPath);
|
$fallbackName = basename($fullPath);
|
||||||
$downloadName = safeDownloadName($requestedName, $fallbackName);
|
$downloadName = safeDownloadName($requestedName, $fallbackName);
|
||||||
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
|
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
|
||||||
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
|
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
|
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
|
||||||
header('Content-Type: ' . $mime);
|
header('Content-Type: ' . $mime);
|
||||||
header('Content-Length: ' . (string)filesize($fullPath));
|
header('Content-Length: ' . (string)filesize($fullPath));
|
||||||
header(
|
header(
|
||||||
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
|
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
|
||||||
. rawurlencode($downloadName)
|
. rawurlencode($downloadName)
|
||||||
);
|
);
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
readfile($fullPath);
|
readfile($fullPath);
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
|
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||||
|
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||||
|
|
||||||
|
function failMediaProxy(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo $message;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyAllowedHosts(): array
|
||||||
|
{
|
||||||
|
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||||
|
$hosts = array_map(
|
||||||
|
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||||
|
preg_split('/[,\s]+/', $configured) ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($hosts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedMediaProxyUrl(string $url): array
|
||||||
|
{
|
||||||
|
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||||
|
failMediaProxy(400, '媒体地址不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($url);
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||||
|
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||||
|
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||||
|
if (
|
||||||
|
$scheme !== 'https'
|
||||||
|
|| $host === ''
|
||||||
|
|| $port !== 443
|
||||||
|
|| isset($parts['user'])
|
||||||
|
|| isset($parts['pass'])
|
||||||
|
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||||
|
) {
|
||||||
|
failMediaProxy(403, '媒体来源不允许代理');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$url, $host];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyCacheRoot(): string
|
||||||
|
{
|
||||||
|
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||||
|
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||||
|
failMediaProxy(503, '媒体缓存目录不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyPreferredIps(): array
|
||||||
|
{
|
||||||
|
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||||
|
$ips = [];
|
||||||
|
foreach ($configured as $ip) {
|
||||||
|
$ip = trim($ip);
|
||||||
|
if (
|
||||||
|
$ip !== ''
|
||||||
|
&& filter_var(
|
||||||
|
$ip,
|
||||||
|
FILTER_VALIDATE_IP,
|
||||||
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||||
|
) !== false
|
||||||
|
) {
|
||||||
|
$ips[$ip] = $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($ips);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaProxyMime(string $path): string
|
||||||
|
{
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||||
|
if ($finfo !== false) {
|
||||||
|
finfo_close($finfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||||
|
$lastError = '媒体源无响应';
|
||||||
|
$preferredIps = mediaProxyPreferredIps();
|
||||||
|
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||||
|
|
||||||
|
foreach ($targets as $index => $preferredIp) {
|
||||||
|
if (microtime(true) >= $deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$attempt = $index + 1;
|
||||||
|
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||||
|
$stream = @fopen($temporaryFile, 'x+b');
|
||||||
|
if ($stream === false) {
|
||||||
|
$lastError = '无法创建媒体缓存文件';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = 0;
|
||||||
|
$tooLarge = false;
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
fclose($stream);
|
||||||
|
@unlink($temporaryFile);
|
||||||
|
$lastError = '无法初始化媒体请求';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||||
|
$curlOptions = [
|
||||||
|
CURLOPT_RETURNTRANSFER => false,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||||
|
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||||
|
CURLOPT_NOSIGNAL => true,
|
||||||
|
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||||
|
CURLOPT_FRESH_CONNECT => true,
|
||||||
|
CURLOPT_FORBID_REUSE => true,
|
||||||
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||||
|
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||||
|
$length = strlen($chunk);
|
||||||
|
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||||
|
$tooLarge = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$written = fwrite($stream, $chunk);
|
||||||
|
if ($written === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$bytes += $written;
|
||||||
|
return $written;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||||
|
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||||
|
}
|
||||||
|
curl_setopt_array($curl, $curlOptions);
|
||||||
|
|
||||||
|
$result = curl_exec($curl);
|
||||||
|
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlError = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
fflush($stream);
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||||
|
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||||
|
if (!@rename($temporaryFile, $cacheFile)) {
|
||||||
|
@unlink($temporaryFile);
|
||||||
|
$lastError = '媒体缓存保存失败';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
@chmod($cacheFile, 0644);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($temporaryFile);
|
||||||
|
$lastError = $tooLarge
|
||||||
|
? '图片超过代理大小限制'
|
||||||
|
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||||
|
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||||
|
usleep(150000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||||
|
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||||
|
{
|
||||||
|
if (!is_file($cacheFile)) {
|
||||||
|
failMediaProxy(404, '媒体缓存不存在');
|
||||||
|
}
|
||||||
|
$mime = mediaProxyMime($cacheFile);
|
||||||
|
if (!str_starts_with($mime, 'image/')) {
|
||||||
|
failMediaProxy(415, '媒体内容不是图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
header('ETag: "' . $etag . '"');
|
||||||
|
header('Cache-Control: public, max-age=86400, immutable');
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||||
|
header('Content-Disposition: inline');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||||
|
http_response_code(304);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
readfile($cacheFile);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||||
|
$cacheRoot = mediaProxyCacheRoot();
|
||||||
|
$cacheKey = hash('sha256', $url);
|
||||||
|
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||||
|
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||||
|
|
||||||
|
if (!is_file($cacheFile)) {
|
||||||
|
$lock = @fopen($lockFile, 'c');
|
||||||
|
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||||
|
if (is_resource($lock)) {
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||||
|
}
|
||||||
|
if (!is_file($cacheFile)) {
|
||||||
|
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||||
|
}
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||||
+288
-288
@@ -1,288 +1,288 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
class UserActionException extends RuntimeException
|
class UserActionException extends RuntimeException
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActionUserId(string $value): string
|
function resolveActionUserId(string $value): string
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = imDb();
|
$db = imDb();
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$value, $value, $value]);
|
$statement->execute([$value, $value, $value]);
|
||||||
$uid = $statement->fetchColumn();
|
$uid = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_string($uid) && $uid !== '' ? $uid : $value;
|
return is_string($uid) && $uid !== '' ? $uid : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageTimestampMs(string $messageUid): int
|
function messageTimestampMs(string $messageUid): int
|
||||||
{
|
{
|
||||||
return (((int)$messageUid) >> 21) + 1514736000000;
|
return (((int)$messageUid) >> 21) + 1514736000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowTimestampMs(): int
|
function nowTimestampMs(): int
|
||||||
{
|
{
|
||||||
return (int)floor(microtime(true) * 1000);
|
return (int)floor(microtime(true) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recallLimitMinutes(): float
|
function recallLimitMinutes(): float
|
||||||
{
|
{
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `conf_value` from `system_conf`'
|
'select `conf_value` from `system_conf`'
|
||||||
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
|
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute(['RetractMessage']);
|
$statement->execute(['RetractMessage']);
|
||||||
$value = trim((string)($statement->fetchColumn() ?: '0'));
|
$value = trim((string)($statement->fetchColumn() ?: '0'));
|
||||||
|
|
||||||
if ($value === '' || !is_numeric($value)) {
|
if ($value === '' || !is_numeric($value)) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return max(0.0, (float)$value);
|
return max(0.0, (float)$value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertRecallWithinLimit(string $messageUid): void
|
function assertRecallWithinLimit(string $messageUid): void
|
||||||
{
|
{
|
||||||
$limitMinutes = recallLimitMinutes();
|
$limitMinutes = recallLimitMinutes();
|
||||||
if ($limitMinutes <= 0) {
|
if ($limitMinutes <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
|
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
|
||||||
$limitMs = (int)round($limitMinutes * 60 * 1000);
|
$limitMs = (int)round($limitMinutes * 60 * 1000);
|
||||||
if ($messageAgeMs > $limitMs) {
|
if ($messageAgeMs > $limitMs) {
|
||||||
throw new UserActionException('已超过可撤回时间');
|
throw new UserActionException('已超过可撤回时间');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchActionMessageRow(string $messageUid): array
|
function fetchActionMessageRow(string $messageUid): array
|
||||||
{
|
{
|
||||||
$table = imMessageTable($messageUid);
|
$table = imMessageTable($messageUid);
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
|
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$messageUid]);
|
$statement->execute([$messageUid]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
|
|
||||||
return is_array($row) ? $row : [];
|
return is_array($row) ? $row : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMessageEditsTable(): void
|
function ensureMessageEditsTable(): void
|
||||||
{
|
{
|
||||||
imDb()->exec(
|
imDb()->exec(
|
||||||
'create table if not exists `t_web_message_edits` ('
|
'create table if not exists `t_web_message_edits` ('
|
||||||
. '`message_uid` varchar(32) not null,'
|
. '`message_uid` varchar(32) not null,'
|
||||||
. '`editor_uid` varchar(64) not null,'
|
. '`editor_uid` varchar(64) not null,'
|
||||||
. '`edited_text` text null,'
|
. '`edited_text` text null,'
|
||||||
. '`edited_at_ms` bigint not null,'
|
. '`edited_at_ms` bigint not null,'
|
||||||
. '`created_at` datetime not null default current_timestamp,'
|
. '`created_at` datetime not null default current_timestamp,'
|
||||||
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
|
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
|
||||||
. 'primary key (`message_uid`),'
|
. 'primary key (`message_uid`),'
|
||||||
. 'key `idx_editor_uid` (`editor_uid`),'
|
. 'key `idx_editor_uid` (`editor_uid`),'
|
||||||
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||||
{
|
{
|
||||||
$row = fetchActionMessageRow($messageUid);
|
$row = fetchActionMessageRow($messageUid);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
return editWebGroupMessage($operatorId, $messageUid, $text);
|
return editWebGroupMessage($operatorId, $messageUid, $text);
|
||||||
}
|
}
|
||||||
if ((string)$row['_from'] !== $operatorId) {
|
if ((string)$row['_from'] !== $operatorId) {
|
||||||
throw new UserActionException('只能编辑自己发送的消息');
|
throw new UserActionException('只能编辑自己发送的消息');
|
||||||
}
|
}
|
||||||
if ((int)$row['_content_type'] !== 1) {
|
if ((int)$row['_content_type'] !== 1) {
|
||||||
throw new UserActionException('只能编辑文字消息');
|
throw new UserActionException('只能编辑文字消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$editedAt = nowTimestampMs();
|
$editedAt = nowTimestampMs();
|
||||||
$table = imMessageTable($messageUid);
|
$table = imMessageTable($messageUid);
|
||||||
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
|
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
|
||||||
$update->execute([$text, $messageUid]);
|
$update->execute([$text, $messageUid]);
|
||||||
|
|
||||||
ensureMessageEditsTable();
|
ensureMessageEditsTable();
|
||||||
$upsert = imDb()->prepare(
|
$upsert = imDb()->prepare(
|
||||||
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
|
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
|
||||||
. ' values (?, ?, ?, ?)'
|
. ' values (?, ?, ?, ?)'
|
||||||
. ' on duplicate key update'
|
. ' on duplicate key update'
|
||||||
. ' `editor_uid` = values(`editor_uid`),'
|
. ' `editor_uid` = values(`editor_uid`),'
|
||||||
. ' `edited_text` = values(`edited_text`),'
|
. ' `edited_text` = values(`edited_text`),'
|
||||||
. ' `edited_at_ms` = values(`edited_at_ms`),'
|
. ' `edited_at_ms` = values(`edited_at_ms`),'
|
||||||
. ' `updated_at` = current_timestamp'
|
. ' `updated_at` = current_timestamp'
|
||||||
);
|
);
|
||||||
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
|
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'text' => $text,
|
'text' => $text,
|
||||||
'editedAt' => $editedAt,
|
'editedAt' => $editedAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchWebGroupActionMessage(string $messageUid): array
|
function fetchWebGroupActionMessage(string $messageUid): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select m.*, g.`category` from `t_web_group_messages` m'
|
'select m.*, g.`category` from `t_web_group_messages` m'
|
||||||
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
|
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
|
||||||
. ' where m.`message_uid` = ? limit 1'
|
. ' where m.`message_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$messageUid]);
|
$statement->execute([$messageUid]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
|
|
||||||
return is_array($row) ? $row : [];
|
return is_array($row) ? $row : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
|
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
|
||||||
{
|
{
|
||||||
$row = fetchWebGroupActionMessage($messageUid);
|
$row = fetchWebGroupActionMessage($messageUid);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new UserActionException('消息不存在');
|
throw new UserActionException('消息不存在');
|
||||||
}
|
}
|
||||||
if ((string)$row['sender_uid'] !== $operatorId) {
|
if ((string)$row['sender_uid'] !== $operatorId) {
|
||||||
throw new UserActionException('只能编辑自己发送的消息');
|
throw new UserActionException('只能编辑自己发送的消息');
|
||||||
}
|
}
|
||||||
if ((int)($row['recalled'] ?? 0) !== 0) {
|
if ((int)($row['recalled'] ?? 0) !== 0) {
|
||||||
throw new UserActionException('已撤回的消息请重新编辑发送');
|
throw new UserActionException('已撤回的消息请重新编辑发送');
|
||||||
}
|
}
|
||||||
|
|
||||||
$editedAt = nowTimestampMs();
|
$editedAt = nowTimestampMs();
|
||||||
$update = imAppDb()->prepare(
|
$update = imAppDb()->prepare(
|
||||||
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
|
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
|
||||||
);
|
);
|
||||||
$update->execute([$text, $editedAt, $messageUid]);
|
$update->execute([$text, $editedAt, $messageUid]);
|
||||||
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||||
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
|
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'text' => $text,
|
'text' => $text,
|
||||||
'editedAt' => $editedAt,
|
'editedAt' => $editedAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function webGroupMemberRoleForAction(string $groupId, string $userId): string
|
function webGroupMemberRoleForAction(string $groupId, string $userId): string
|
||||||
{
|
{
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `role` from `t_web_group_members`'
|
'select `role` from `t_web_group_members`'
|
||||||
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$groupId, $userId]);
|
$statement->execute([$groupId, $userId]);
|
||||||
$role = $statement->fetchColumn();
|
$role = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_string($role) ? $role : '';
|
return is_string($role) ? $role : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
|
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
|
||||||
{
|
{
|
||||||
$row = fetchWebGroupActionMessage($messageUid);
|
$row = fetchWebGroupActionMessage($messageUid);
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$isSender = (string)$row['sender_uid'] === $operatorId;
|
$isSender = (string)$row['sender_uid'] === $operatorId;
|
||||||
if (!$isSender) {
|
if (!$isSender) {
|
||||||
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
|
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
|
||||||
if (!in_array($role, ['owner', 'admin'], true)) {
|
if (!in_array($role, ['owner', 'admin'], true)) {
|
||||||
throw new UserActionException('只有群主或管理员可以撤回他人消息');
|
throw new UserActionException('只有群主或管理员可以撤回他人消息');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertRecallWithinLimit($messageUid);
|
assertRecallWithinLimit($messageUid);
|
||||||
|
|
||||||
$now = nowTimestampMs();
|
$now = nowTimestampMs();
|
||||||
$update = imAppDb()->prepare(
|
$update = imAppDb()->prepare(
|
||||||
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
|
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
|
||||||
);
|
);
|
||||||
$update->execute([$messageUid]);
|
$update->execute([$messageUid]);
|
||||||
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||||
$groupUpdate->execute([$now, (string)$row['group_id']]);
|
$groupUpdate->execute([$now, (string)$row['group_id']]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$action = trim((string)($payload['action'] ?? ''));
|
$action = trim((string)($payload['action'] ?? ''));
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
|
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
|
||||||
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
|
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
|
||||||
|
|
||||||
if ($action === '' || $messageUid <= 0) {
|
if ($action === '' || $messageUid <= 0) {
|
||||||
respond(400, '缺少操作类型或消息ID');
|
respond(400, '缺少操作类型或消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($action === 'recall') {
|
if ($action === 'recall') {
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少操作者ID');
|
respond(400, '缺少操作者ID');
|
||||||
}
|
}
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
$row = fetchActionMessageRow($messageUidText);
|
$row = fetchActionMessageRow($messageUidText);
|
||||||
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
|
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'messageUid' => (string)$messageUid,
|
'messageUid' => (string)$messageUid,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new UserActionException('消息不存在');
|
throw new UserActionException('消息不存在');
|
||||||
}
|
}
|
||||||
assertRecallWithinLimit($messageUidText);
|
assertRecallWithinLimit($messageUidText);
|
||||||
requireImSuccess(imAdminPost('/admin/message/recall', [
|
requireImSuccess(imAdminPost('/admin/message/recall', [
|
||||||
'operator' => $userId,
|
'operator' => $userId,
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'userRecall' => true,
|
'userRecall' => true,
|
||||||
'isUserRecall' => true,
|
'isUserRecall' => true,
|
||||||
]), '撤回失败');
|
]), '撤回失败');
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'messageUid' => (string)$messageUid,
|
'messageUid' => (string)$messageUid,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'edit') {
|
if ($action === 'edit') {
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少操作者ID');
|
respond(400, '缺少操作者ID');
|
||||||
}
|
}
|
||||||
$text = trim((string)($payload['text'] ?? ''));
|
$text = trim((string)($payload['text'] ?? ''));
|
||||||
if ($text === '') {
|
if ($text === '') {
|
||||||
respond(400, '编辑内容不能为空');
|
respond(400, '编辑内容不能为空');
|
||||||
}
|
}
|
||||||
if (mb_strlen($text, 'UTF-8') > 5000) {
|
if (mb_strlen($text, 'UTF-8') > 5000) {
|
||||||
respond(400, '编辑内容过长');
|
respond(400, '编辑内容过长');
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'message' => editTextMessage($userId, $messageUidText, $text),
|
'message' => editTextMessage($userId, $messageUidText, $text),
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'delete') {
|
if ($action === 'delete') {
|
||||||
requireImSuccess(imAdminPost('/admin/message/delete', [
|
requireImSuccess(imAdminPost('/admin/message/delete', [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
]), '删除失败');
|
]), '删除失败');
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'messageUid' => (string)$messageUid,
|
'messageUid' => (string)$messageUid,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(400, '不支持的消息操作');
|
respond(400, '不支持的消息操作');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
|
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
|
||||||
}
|
}
|
||||||
|
|||||||
+387
-387
@@ -1,387 +1,387 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
function detailPlaceholders(array $values): string
|
function detailPlaceholders(array $values): string
|
||||||
{
|
{
|
||||||
return implode(',', array_fill(0, count($values), '?'));
|
return implode(',', array_fill(0, count($values), '?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
|
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
|
||||||
{
|
{
|
||||||
$id = (int)$messageId;
|
$id = (int)$messageId;
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
return ($id >> 21) + 1514736000000;
|
return ($id >> 21) + 1514736000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($datetime !== null && $datetime !== '') {
|
if ($datetime !== null && $datetime !== '') {
|
||||||
$date = DateTimeImmutable::createFromFormat(
|
$date = DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d H:i:s',
|
'Y-m-d H:i:s',
|
||||||
$datetime,
|
$datetime,
|
||||||
new DateTimeZone(IM_DB_TIMEZONE)
|
new DateTimeZone(IM_DB_TIMEZONE)
|
||||||
);
|
);
|
||||||
if ($date instanceof DateTimeImmutable) {
|
if ($date instanceof DateTimeImmutable) {
|
||||||
return $date->getTimestamp() * 1000;
|
return $date->getTimestamp() * 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailResolveUserId(PDO $db, string $value): string
|
function detailResolveUserId(PDO $db, string $value): string
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$value, $value, $value]);
|
$statement->execute([$value, $value, $value]);
|
||||||
$uid = $statement->fetchColumn();
|
$uid = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_string($uid) && $uid !== '' ? $uid : $value;
|
return is_string($uid) && $uid !== '' ? $uid : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailConversationType(mixed $value): string
|
function detailConversationType(mixed $value): string
|
||||||
{
|
{
|
||||||
$type = strtolower(trim((string)$value));
|
$type = strtolower(trim((string)$value));
|
||||||
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
|
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailDeliveryMap(PDO $db, array $userIds): array
|
function detailDeliveryMap(PDO $db, array $userIds): array
|
||||||
{
|
{
|
||||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||||
if (!$userIds) {
|
if (!$userIds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
|
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
|
||||||
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
|
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
|
||||||
);
|
);
|
||||||
$statement->execute($userIds);
|
$statement->execute($userIds);
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($statement->fetchAll() as $row) {
|
foreach ($statement->fetchAll() as $row) {
|
||||||
$items[(string)$row['_uid']] = (int)$row['_dt'];
|
$items[(string)$row['_uid']] = (int)$row['_dt'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailReadAt(PDO $db, string $readerId, array $targets): int
|
function detailReadAt(PDO $db, string $readerId, array $targets): int
|
||||||
{
|
{
|
||||||
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
|
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
|
||||||
if ($readerId === '' || !$targets) {
|
if ($readerId === '' || !$targets) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select max(`_dt`) from `t_read_report`'
|
'select max(`_dt`) from `t_read_report`'
|
||||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
||||||
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
|
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
|
||||||
);
|
);
|
||||||
$statement->execute(array_merge([$readerId], $targets));
|
$statement->execute(array_merge([$readerId], $targets));
|
||||||
$value = $statement->fetchColumn();
|
$value = $statement->fetchColumn();
|
||||||
|
|
||||||
return is_numeric($value) ? (int)$value : 0;
|
return is_numeric($value) ? (int)$value : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
|
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
|
||||||
{
|
{
|
||||||
$table = imUserMessagesTable($userId);
|
$table = imUserMessagesTable($userId);
|
||||||
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
|
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select 1 from `' . $table . '`'
|
'select 1 from `' . $table . '`'
|
||||||
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
|
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
|
||||||
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
|
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
|
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
|
||||||
|
|
||||||
return (bool)$statement->fetchColumn();
|
return (bool)$statement->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
|
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
|
||||||
{
|
{
|
||||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||||
if (!$userIds) {
|
if (!$userIds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $imDb->prepare(
|
$statement = $imDb->prepare(
|
||||||
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
|
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
|
||||||
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
|
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
|
||||||
);
|
);
|
||||||
$statement->execute($userIds);
|
$statement->execute($userIds);
|
||||||
|
|
||||||
$users = [];
|
$users = [];
|
||||||
foreach ($statement->fetchAll() as $row) {
|
foreach ($statement->fetchAll() as $row) {
|
||||||
$uid = (string)$row['_uid'];
|
$uid = (string)$row['_uid'];
|
||||||
$rawName = trim((string)($row['_display_name'] ?? ''))
|
$rawName = trim((string)($row['_display_name'] ?? ''))
|
||||||
?: trim((string)($row['_name'] ?? ''))
|
?: trim((string)($row['_name'] ?? ''))
|
||||||
?: trim((string)($row['_mobile'] ?? ''))
|
?: trim((string)($row['_mobile'] ?? ''))
|
||||||
?: $uid;
|
?: $uid;
|
||||||
$users[$uid] = [
|
$users[$uid] = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'rawName' => $rawName,
|
'rawName' => $rawName,
|
||||||
'mobile' => (string)($row['_mobile'] ?? ''),
|
'mobile' => (string)($row['_mobile'] ?? ''),
|
||||||
'portrait' => (string)($row['_portrait'] ?? ''),
|
'portrait' => (string)($row['_portrait'] ?? ''),
|
||||||
'contactAlias' => '',
|
'contactAlias' => '',
|
||||||
'remarkAlias' => '',
|
'remarkAlias' => '',
|
||||||
'groupAlias' => '',
|
'groupAlias' => '',
|
||||||
'role' => '',
|
'role' => '',
|
||||||
'displayName' => $rawName,
|
'displayName' => $rawName,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ownerId !== '') {
|
if ($ownerId !== '') {
|
||||||
$aliasStatement = $imDb->prepare(
|
$aliasStatement = $imDb->prepare(
|
||||||
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
|
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
|
||||||
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
|
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
|
||||||
);
|
);
|
||||||
$aliasStatement->execute(array_merge([$ownerId], $userIds));
|
$aliasStatement->execute(array_merge([$ownerId], $userIds));
|
||||||
foreach ($aliasStatement->fetchAll() as $row) {
|
foreach ($aliasStatement->fetchAll() as $row) {
|
||||||
$uid = (string)$row['_friend_uid'];
|
$uid = (string)$row['_friend_uid'];
|
||||||
if (isset($users[$uid])) {
|
if (isset($users[$uid])) {
|
||||||
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
|
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
|
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$remarkMap = [];
|
$remarkMap = [];
|
||||||
}
|
}
|
||||||
foreach ($remarkMap as $uid => $alias) {
|
foreach ($remarkMap as $uid => $alias) {
|
||||||
if (isset($users[$uid])) {
|
if (isset($users[$uid])) {
|
||||||
$users[$uid]['remarkAlias'] = trim((string)$alias);
|
$users[$uid]['remarkAlias'] = trim((string)$alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($groupId !== '') {
|
if ($groupId !== '') {
|
||||||
$memberStatement = $appDb->prepare(
|
$memberStatement = $appDb->prepare(
|
||||||
'select `user_id`, `alias`, `role` from `t_web_group_members`'
|
'select `user_id`, `alias`, `role` from `t_web_group_members`'
|
||||||
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
|
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
|
||||||
);
|
);
|
||||||
$memberStatement->execute(array_merge([$groupId], $userIds));
|
$memberStatement->execute(array_merge([$groupId], $userIds));
|
||||||
foreach ($memberStatement->fetchAll() as $row) {
|
foreach ($memberStatement->fetchAll() as $row) {
|
||||||
$uid = (string)$row['user_id'];
|
$uid = (string)$row['user_id'];
|
||||||
if (isset($users[$uid])) {
|
if (isset($users[$uid])) {
|
||||||
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
|
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
|
||||||
$users[$uid]['role'] = (string)($row['role'] ?? '');
|
$users[$uid]['role'] = (string)($row['role'] ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($users as $uid => $user) {
|
foreach ($users as $uid => $user) {
|
||||||
$alias = (string)$user['contactAlias'] !== ''
|
$alias = (string)$user['contactAlias'] !== ''
|
||||||
? (string)$user['contactAlias']
|
? (string)$user['contactAlias']
|
||||||
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
|
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
|
||||||
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
|
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($userIds as $uid) {
|
foreach ($userIds as $uid) {
|
||||||
if (!isset($users[$uid])) {
|
if (!isset($users[$uid])) {
|
||||||
$users[$uid] = [
|
$users[$uid] = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'rawName' => $uid,
|
'rawName' => $uid,
|
||||||
'mobile' => '',
|
'mobile' => '',
|
||||||
'portrait' => '',
|
'portrait' => '',
|
||||||
'contactAlias' => '',
|
'contactAlias' => '',
|
||||||
'remarkAlias' => '',
|
'remarkAlias' => '',
|
||||||
'groupAlias' => '',
|
'groupAlias' => '',
|
||||||
'role' => '',
|
'role' => '',
|
||||||
'displayName' => $uid,
|
'displayName' => $uid,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailMember(array $user, int $time = 0): array
|
function detailMember(array $user, int $time = 0): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'uid' => (string)$user['uid'],
|
'uid' => (string)$user['uid'],
|
||||||
'name' => (string)$user['displayName'],
|
'name' => (string)$user['displayName'],
|
||||||
'rawName' => (string)$user['rawName'],
|
'rawName' => (string)$user['rawName'],
|
||||||
'mobile' => (string)($user['mobile'] ?? ''),
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
'portrait' => (string)($user['portrait'] ?? ''),
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
'role' => (string)($user['role'] ?? ''),
|
'role' => (string)($user['role'] ?? ''),
|
||||||
'time' => $time,
|
'time' => $time,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
|
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
|
||||||
{
|
{
|
||||||
if (!preg_match('/^\d+$/', $messageUid)) {
|
if (!preg_match('/^\d+$/', $messageUid)) {
|
||||||
respond(400, '缺少可查询的消息ID');
|
respond(400, '缺少可查询的消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$table = imMessageTable($messageUid);
|
$table = imMessageTable($messageUid);
|
||||||
$statement = $imDb->prepare(
|
$statement = $imDb->prepare(
|
||||||
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
|
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
|
||||||
. ' from `' . $table . '` where `_mid` = ? limit 1'
|
. ' from `' . $table . '` where `_mid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$messageUid]);
|
$statement->execute([$messageUid]);
|
||||||
$message = $statement->fetch();
|
$message = $statement->fetch();
|
||||||
if (!is_array($message)) {
|
if (!is_array($message)) {
|
||||||
respond(404, '没有找到该消息');
|
respond(404, '没有找到该消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$senderId = (string)$message['_from'];
|
$senderId = (string)$message['_from'];
|
||||||
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
|
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
|
||||||
respond(403, '没有权限查看该消息');
|
respond(403, '没有权限查看该消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$receiverId = $senderId === $userId ? $targetId : $userId;
|
$receiverId = $senderId === $userId ? $targetId : $userId;
|
||||||
if ($senderId !== $userId && $senderId !== $targetId) {
|
if ($senderId !== $userId && $senderId !== $targetId) {
|
||||||
respond(403, '没有权限查看该消息');
|
respond(403, '没有权限查看该消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
|
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
|
||||||
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
|
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
|
||||||
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
|
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
|
||||||
$readRaw = detailReadAt($imDb, $receiverId, $targets);
|
$readRaw = detailReadAt($imDb, $receiverId, $targets);
|
||||||
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
|
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'conversationType' => 'single',
|
'conversationType' => 'single',
|
||||||
'sentAt' => $sentAt,
|
'sentAt' => $sentAt,
|
||||||
'sender' => detailMember($users[$senderId]),
|
'sender' => detailMember($users[$senderId]),
|
||||||
'receiver' => detailMember($users[$receiverId]),
|
'receiver' => detailMember($users[$receiverId]),
|
||||||
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
|
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
|
||||||
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
|
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
|
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
if (!preg_match('/^\d+$/', $messageUid)) {
|
if (!preg_match('/^\d+$/', $messageUid)) {
|
||||||
respond(400, '缺少可查询的消息ID');
|
respond(400, '缺少可查询的消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$myMember = $appDb->prepare(
|
$myMember = $appDb->prepare(
|
||||||
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
||||||
);
|
);
|
||||||
$myMember->execute([$groupId, $userId]);
|
$myMember->execute([$groupId, $userId]);
|
||||||
if (!$myMember->fetchColumn()) {
|
if (!$myMember->fetchColumn()) {
|
||||||
respond(403, '你不在该群组中');
|
respond(403, '你不在该群组中');
|
||||||
}
|
}
|
||||||
|
|
||||||
$messageStatement = $appDb->prepare(
|
$messageStatement = $appDb->prepare(
|
||||||
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
|
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
|
||||||
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
|
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$messageStatement->execute([$groupId, $messageUid]);
|
$messageStatement->execute([$groupId, $messageUid]);
|
||||||
$message = $messageStatement->fetch();
|
$message = $messageStatement->fetch();
|
||||||
if (!is_array($message)) {
|
if (!is_array($message)) {
|
||||||
respond(404, '没有找到该群消息');
|
respond(404, '没有找到该群消息');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sentAt = (int)$message['created_at_ms'];
|
$sentAt = (int)$message['created_at_ms'];
|
||||||
$editedAt = (int)($message['edited_at_ms'] ?? 0);
|
$editedAt = (int)($message['edited_at_ms'] ?? 0);
|
||||||
$activityAt = max($sentAt, $editedAt);
|
$activityAt = max($sentAt, $editedAt);
|
||||||
$senderId = (string)$message['sender_uid'];
|
$senderId = (string)$message['sender_uid'];
|
||||||
|
|
||||||
$memberStatement = $appDb->prepare(
|
$memberStatement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
|
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
|
||||||
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
|
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$groupId, $sentAt]);
|
$memberStatement->execute([$groupId, $sentAt]);
|
||||||
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
|
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
|
||||||
return (string)$member['user_id'] !== $senderId;
|
return (string)$member['user_id'] !== $senderId;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
|
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
|
||||||
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
|
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
|
||||||
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
|
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
|
||||||
|
|
||||||
$readMap = [];
|
$readMap = [];
|
||||||
if ($memberIds) {
|
if ($memberIds) {
|
||||||
$readStatement = $appDb->prepare(
|
$readStatement = $appDb->prepare(
|
||||||
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
|
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
|
||||||
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
|
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
|
||||||
);
|
);
|
||||||
$readStatement->execute(array_merge([$groupId], $memberIds));
|
$readStatement->execute(array_merge([$groupId], $memberIds));
|
||||||
foreach ($readStatement->fetchAll() as $row) {
|
foreach ($readStatement->fetchAll() as $row) {
|
||||||
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
|
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$read = [];
|
$read = [];
|
||||||
$unread = [];
|
$unread = [];
|
||||||
$undelivered = [];
|
$undelivered = [];
|
||||||
foreach ($memberIds as $uid) {
|
foreach ($memberIds as $uid) {
|
||||||
$readAt = (int)($readMap[$uid] ?? 0);
|
$readAt = (int)($readMap[$uid] ?? 0);
|
||||||
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
|
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
|
||||||
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
|
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
|
||||||
if ($readAt >= $activityAt) {
|
if ($readAt >= $activityAt) {
|
||||||
$read[] = $member;
|
$read[] = $member;
|
||||||
} elseif ($deliveredAt >= $sentAt) {
|
} elseif ($deliveredAt >= $sentAt) {
|
||||||
$unread[] = $member;
|
$unread[] = $member;
|
||||||
} else {
|
} else {
|
||||||
$undelivered[] = $member;
|
$undelivered[] = $member;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'messageUid' => $messageUid,
|
'messageUid' => $messageUid,
|
||||||
'conversationType' => 'group',
|
'conversationType' => 'group',
|
||||||
'groupId' => $groupId,
|
'groupId' => $groupId,
|
||||||
'sentAt' => $sentAt,
|
'sentAt' => $sentAt,
|
||||||
'editedAt' => $editedAt,
|
'editedAt' => $editedAt,
|
||||||
'recalled' => (bool)$message['recalled'],
|
'recalled' => (bool)$message['recalled'],
|
||||||
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
|
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
|
||||||
'group' => [
|
'group' => [
|
||||||
'read' => $read,
|
'read' => $read,
|
||||||
'unread' => $unread,
|
'unread' => $unread,
|
||||||
'undelivered' => $undelivered,
|
'undelivered' => $undelivered,
|
||||||
'counts' => [
|
'counts' => [
|
||||||
'read' => count($read),
|
'read' => count($read),
|
||||||
'unread' => count($unread),
|
'unread' => count($unread),
|
||||||
'undelivered' => count($undelivered),
|
'undelivered' => count($undelivered),
|
||||||
'total' => count($memberIds),
|
'total' => count($memberIds),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
|
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
|
||||||
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
|
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
if ($target === '') {
|
if ($target === '') {
|
||||||
respond(400, '缺少会话对象');
|
respond(400, '缺少会话对象');
|
||||||
}
|
}
|
||||||
if ($messageUid === '') {
|
if ($messageUid === '') {
|
||||||
respond(400, '缺少消息ID');
|
respond(400, '缺少消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$imDb = imDb();
|
$imDb = imDb();
|
||||||
$appDb = imAppDb();
|
$appDb = imAppDb();
|
||||||
$userId = detailResolveUserId($imDb, $userId);
|
$userId = detailResolveUserId($imDb, $userId);
|
||||||
if ($conversationType === 'group') {
|
if ($conversationType === 'group') {
|
||||||
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
|
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetId = detailResolveUserId($imDb, $target);
|
$targetId = detailResolveUserId($imDb, $target);
|
||||||
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
|
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '消息详情加载失败');
|
respond(500, $error->getMessage() ?: '消息详情加载失败');
|
||||||
}
|
}
|
||||||
|
|||||||
+1608
-900
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,39 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$defaultName = 'Web IM';
|
$defaultName = 'Web IM';
|
||||||
$defaultLogo = '/img/logo.png';
|
$defaultLogo = '/img/logo.ico';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
|
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
|
||||||
);
|
);
|
||||||
$statement->execute(['PlatformName', 'sys_logo']);
|
$statement->execute(['PlatformName', 'sys_logo']);
|
||||||
|
|
||||||
$config = [];
|
$config = [];
|
||||||
$updatedAtValues = [];
|
$updatedAtValues = [];
|
||||||
foreach ($statement->fetchAll() as $row) {
|
foreach ($statement->fetchAll() as $row) {
|
||||||
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
|
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
|
||||||
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
|
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
$platformName = $config['PlatformName'] ?? '';
|
$platformName = $config['PlatformName'] ?? '';
|
||||||
$logo = $config['sys_logo'] ?? '';
|
$logo = $config['sys_logo'] ?? '';
|
||||||
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
|
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'platformName' => $platformName !== '' ? $platformName : $defaultName,
|
'platformName' => $platformName !== '' ? $platformName : $defaultName,
|
||||||
'logo' => $logo !== '' ? $logo : $defaultLogo,
|
'logo' => $logo !== '' ? $logo : $defaultLogo,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '平台配置加载失败', [
|
respond(500, $error->getMessage() ?: '平台配置加载失败', [
|
||||||
'platformName' => $defaultName,
|
'platformName' => $defaultName,
|
||||||
'logo' => $defaultLogo,
|
'logo' => $defaultLogo,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+321
-321
@@ -1,321 +1,321 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
|
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
|
||||||
const PROFILE_PORTRAIT_FLAG = 0x02;
|
const PROFILE_PORTRAIT_FLAG = 0x02;
|
||||||
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
|
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
|
||||||
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
|
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
|
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
|
||||||
|
|
||||||
function profileRequestValue(string $key, string $default = ''): string
|
function profileRequestValue(string $key, string $default = ''): string
|
||||||
{
|
{
|
||||||
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
|
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileUser(array $user): array
|
function normalizeProfileUser(array $user): array
|
||||||
{
|
{
|
||||||
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
|
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
|
||||||
return [
|
return [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'userId' => $uid,
|
'userId' => $uid,
|
||||||
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
|
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
|
||||||
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
|
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
|
||||||
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
|
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
|
||||||
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
|
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
|
||||||
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
|
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryProfileUserInfo(array $payload): ?array
|
function tryProfileUserInfo(array $payload): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
|
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
|
||||||
return $result ?: null;
|
return $result ?: null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfileUserFromDb(string $identity): ?array
|
function loadProfileUserFromDb(string $identity): ?array
|
||||||
{
|
{
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
|
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
|
||||||
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
|
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
|
||||||
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
|
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
|
||||||
. ' limit 1'
|
. ' limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
|
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
return $row ? normalizeProfileUser($row) : null;
|
return $row ? normalizeProfileUser($row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeProfileUser(array $primary, ?array $fallback): array
|
function mergeProfileUser(array $primary, ?array $fallback): array
|
||||||
{
|
{
|
||||||
if (!$fallback) {
|
if (!$fallback) {
|
||||||
return $primary;
|
return $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fallback = normalizeProfileUser($fallback);
|
$fallback = normalizeProfileUser($fallback);
|
||||||
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
|
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
|
||||||
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
|
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
|
||||||
$primary[$key] = (string)$fallback[$key];
|
$primary[$key] = (string)$fallback[$key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $primary;
|
return $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
|
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
|
||||||
{
|
{
|
||||||
if ($portrait !== '') {
|
if ($portrait !== '') {
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
|
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = imDb()->prepare(
|
$statement = imDb()->prepare(
|
||||||
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$displayName, webImNowMs(), $userId]);
|
$statement->execute([$displayName, webImNowMs(), $userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfileUser(string $identity, bool $preferMobile = true): array
|
function loadProfileUser(string $identity, bool $preferMobile = true): array
|
||||||
{
|
{
|
||||||
$identity = trim($identity);
|
$identity = trim($identity);
|
||||||
if ($identity === '') {
|
if ($identity === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$dbUser = loadProfileUserFromDb($identity);
|
$dbUser = loadProfileUserFromDb($identity);
|
||||||
if ($dbUser) {
|
if ($dbUser) {
|
||||||
$fallback = null;
|
$fallback = null;
|
||||||
if ((string)($dbUser['portrait'] ?? '') === '') {
|
if ((string)($dbUser['portrait'] ?? '') === '') {
|
||||||
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
|
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
|
||||||
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
|
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
|
||||||
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
|
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
|
||||||
}
|
}
|
||||||
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
|
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
|
||||||
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
|
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeProfileUser($dbUser, $fallback);
|
return mergeProfileUser($dbUser, $fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = null;
|
$user = null;
|
||||||
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
|
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
|
||||||
$user = tryProfileUserInfo(['mobile' => $identity]);
|
$user = tryProfileUserInfo(['mobile' => $identity]);
|
||||||
}
|
}
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$user = tryProfileUserInfo(['userId' => $identity]);
|
$user = tryProfileUserInfo(['userId' => $identity]);
|
||||||
}
|
}
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$user = tryProfileUserInfo(['name' => $identity]);
|
$user = tryProfileUserInfo(['name' => $identity]);
|
||||||
}
|
}
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
respond(404, '用户资料不存在');
|
respond(404, '用户资料不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeProfileUser($user);
|
return normalizeProfileUser($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProfileJavaSerializedString(string $data, int $offset): ?array
|
function readProfileJavaSerializedString(string $data, int $offset): ?array
|
||||||
{
|
{
|
||||||
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
|
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
|
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
|
||||||
$start = $offset + 3;
|
$start = $offset + 3;
|
||||||
if ($start + $length > strlen($data)) {
|
if ($start + $length > strlen($data)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [substr($data, $start, $length), $start + $length];
|
return [substr($data, $start, $length), $start + $length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileSessionUserId(string $sessionData): string
|
function profileSessionUserId(string $sessionData): string
|
||||||
{
|
{
|
||||||
$marker = "\x74\x00\x06userId";
|
$marker = "\x74\x00\x06userId";
|
||||||
$offset = strpos($sessionData, $marker);
|
$offset = strpos($sessionData, $marker);
|
||||||
if ($offset === false) {
|
if ($offset === false) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
|
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
|
||||||
$userId = is_array($decoded) ? (string)$decoded[0] : '';
|
$userId = is_array($decoded) ? (string)$decoded[0] : '';
|
||||||
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
|
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $userId;
|
return $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireProfileUpdateAuth(string $targetUserId): void
|
function requireProfileUpdateAuth(string $targetUserId): void
|
||||||
{
|
{
|
||||||
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
|
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
|
||||||
if ($authToken === '') {
|
if ($authToken === '') {
|
||||||
respond(401, '请重新登录后再修改资料');
|
respond(401, '请重新登录后再修改资料');
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
|
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
|
||||||
$statement->execute([$authToken]);
|
$statement->execute([$authToken]);
|
||||||
$row = $statement->fetch();
|
$row = $statement->fetch();
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
respond(401, '登录状态已失效,请重新登录');
|
respond(401, '登录状态已失效,请重新登录');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sessionUserId = profileSessionUserId((string)$row['session_data']);
|
$sessionUserId = profileSessionUserId((string)$row['session_data']);
|
||||||
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
|
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
|
||||||
respond(403, '只能修改自己的资料');
|
respond(403, '只能修改自己的资料');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileUploadedFileExtension(string $fileName, string $mime): string
|
function profileUploadedFileExtension(string $fileName, string $mime): string
|
||||||
{
|
{
|
||||||
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
||||||
if ($extension === 'jpeg') {
|
if ($extension === 'jpeg') {
|
||||||
$extension = 'jpg';
|
$extension = 'jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
|
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
|
||||||
if (in_array($extension, $allowedExtensions, true)) {
|
if (in_array($extension, $allowedExtensions, true)) {
|
||||||
return $extension;
|
return $extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($mime) {
|
return match ($mime) {
|
||||||
'image/jpeg' => 'jpg',
|
'image/jpeg' => 'jpg',
|
||||||
'image/png' => 'png',
|
'image/png' => 'png',
|
||||||
'image/gif' => 'gif',
|
'image/gif' => 'gif',
|
||||||
'image/webp' => 'webp',
|
'image/webp' => 'webp',
|
||||||
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
||||||
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
|
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
|
||||||
default => 'jpg',
|
default => 'jpg',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function profilePublicMediaBaseUrl(): string
|
function profilePublicMediaBaseUrl(): string
|
||||||
{
|
{
|
||||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||||
$scheme = $isHttps ? 'https' : 'http';
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
|
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
|
||||||
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
|
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
|
||||||
|
|
||||||
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
|
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadProfilePortrait(array $file): string
|
function uploadProfilePortrait(array $file): string
|
||||||
{
|
{
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
throw new RuntimeException('头像上传失败');
|
throw new RuntimeException('头像上传失败');
|
||||||
}
|
}
|
||||||
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
||||||
throw new RuntimeException('头像不能超过5MB');
|
throw new RuntimeException('头像不能超过5MB');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
$mime = mime_content_type($tmpName) ?: '';
|
$mime = mime_content_type($tmpName) ?: '';
|
||||||
if (!str_starts_with($mime, 'image/')) {
|
if (!str_starts_with($mime, 'image/')) {
|
||||||
throw new RuntimeException('头像必须是图片文件');
|
throw new RuntimeException('头像必须是图片文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
|
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
|
||||||
throw new RuntimeException('头像存储目录不可写');
|
throw new RuntimeException('头像存储目录不可写');
|
||||||
}
|
}
|
||||||
|
|
||||||
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
|
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
|
||||||
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
|
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
||||||
throw new RuntimeException('头像存储目录创建失败');
|
throw new RuntimeException('头像存储目录创建失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
|
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
|
||||||
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
||||||
$targetPath = $targetDir . '/' . $targetName;
|
$targetPath = $targetDir . '/' . $targetName;
|
||||||
if (!move_uploaded_file($tmpName, $targetPath)) {
|
if (!move_uploaded_file($tmpName, $targetPath)) {
|
||||||
throw new RuntimeException('头像文件保存失败');
|
throw new RuntimeException('头像文件保存失败');
|
||||||
}
|
}
|
||||||
@chmod($targetPath, 0644);
|
@chmod($targetPath, 0644);
|
||||||
|
|
||||||
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
|
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
|
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
|
||||||
$payload = $isMultipart ? [] : readJsonPayload();
|
$payload = $isMultipart ? [] : readJsonPayload();
|
||||||
$action = $isMultipart
|
$action = $isMultipart
|
||||||
? profileRequestValue('action', 'update')
|
? profileRequestValue('action', 'update')
|
||||||
: trim((string)($payload['action'] ?? 'get'));
|
: trim((string)($payload['action'] ?? 'get'));
|
||||||
$userId = $isMultipart
|
$userId = $isMultipart
|
||||||
? profileRequestValue('userId')
|
? profileRequestValue('userId')
|
||||||
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
||||||
|
|
||||||
$currentProfile = loadProfileUser($userId);
|
$currentProfile = loadProfileUser($userId);
|
||||||
|
|
||||||
if ($action === 'get') {
|
if ($action === 'get') {
|
||||||
respond(0, 'success', ['profile' => $currentProfile]);
|
respond(0, 'success', ['profile' => $currentProfile]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action !== 'update') {
|
if ($action !== 'update') {
|
||||||
respond(400, '不支持的资料操作');
|
respond(400, '不支持的资料操作');
|
||||||
}
|
}
|
||||||
requireProfileUpdateAuth((string)$currentProfile['userId']);
|
requireProfileUpdateAuth((string)$currentProfile['userId']);
|
||||||
|
|
||||||
$displayName = $isMultipart
|
$displayName = $isMultipart
|
||||||
? profileRequestValue('displayName')
|
? profileRequestValue('displayName')
|
||||||
: trim((string)($payload['displayName'] ?? ''));
|
: trim((string)($payload['displayName'] ?? ''));
|
||||||
if ($displayName === '') {
|
if ($displayName === '') {
|
||||||
respond(400, '昵称不能为空');
|
respond(400, '昵称不能为空');
|
||||||
}
|
}
|
||||||
if (mb_strlen($displayName, 'UTF-8') > 64) {
|
if (mb_strlen($displayName, 'UTF-8') > 64) {
|
||||||
respond(400, '昵称不能超过64个字');
|
respond(400, '昵称不能超过64个字');
|
||||||
}
|
}
|
||||||
|
|
||||||
$portrait = '';
|
$portrait = '';
|
||||||
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||||
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
|
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$flag = PROFILE_DISPLAY_NAME_FLAG;
|
$flag = PROFILE_DISPLAY_NAME_FLAG;
|
||||||
$userInfo = [
|
$userInfo = [
|
||||||
'userId' => $currentProfile['userId'],
|
'userId' => $currentProfile['userId'],
|
||||||
'displayName' => $displayName,
|
'displayName' => $displayName,
|
||||||
];
|
];
|
||||||
if ($portrait !== '') {
|
if ($portrait !== '') {
|
||||||
$flag |= PROFILE_PORTRAIT_FLAG;
|
$flag |= PROFILE_PORTRAIT_FLAG;
|
||||||
$userInfo['portrait'] = $portrait;
|
$userInfo['portrait'] = $portrait;
|
||||||
}
|
}
|
||||||
|
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/user/update', [
|
imAdminPost('/admin/user/update', [
|
||||||
'flag' => $flag,
|
'flag' => $flag,
|
||||||
'userInfo' => $userInfo,
|
'userInfo' => $userInfo,
|
||||||
]),
|
]),
|
||||||
'用户资料保存失败'
|
'用户资料保存失败'
|
||||||
);
|
);
|
||||||
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
|
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
|
||||||
|
|
||||||
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
|
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
|
||||||
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
|
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
|
||||||
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
|
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
|
||||||
}
|
}
|
||||||
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
|
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
|
||||||
$updatedProfile['portrait'] = $portrait;
|
$updatedProfile['portrait'] = $portrait;
|
||||||
}
|
}
|
||||||
if ((string)$updatedProfile['displayName'] === '') {
|
if ((string)$updatedProfile['displayName'] === '') {
|
||||||
$updatedProfile['displayName'] = $displayName;
|
$updatedProfile['displayName'] = $displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(0, 'success', ['profile' => $updatedProfile]);
|
respond(0, 'success', ['profile' => $updatedProfile]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
|
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'message' => 'success',
|
'message' => 'success',
|
||||||
'data' => [
|
'data' => [
|
||||||
'methods' => [
|
'methods' => [
|
||||||
'phone' => true,
|
'phone' => true,
|
||||||
'email' => false,
|
'email' => false,
|
||||||
'username' => false,
|
'username' => false,
|
||||||
],
|
],
|
||||||
'mobile_real_verify' => false,
|
'mobile_real_verify' => false,
|
||||||
],
|
],
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
+579
-411
@@ -1,411 +1,579 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
define(
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
'WEB_MEDIA_ROOT',
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
webImEnv(
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
'JC_WEB_IM_MEDIA_ROOT',
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
is_dir('/var/www/im/public/media')
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
? '/var/www/im/public/media'
|
||||||
|
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
|
||||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
)
|
||||||
{
|
);
|
||||||
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
|
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
|
||||||
return false;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
}
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
|
const WEB_MINIO_PROJECT_CODE = '001';
|
||||||
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
|
|
||||||
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
|
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||||
$startMinutes = $startHour * 60 + $startMinute;
|
{
|
||||||
$endMinutes = $endHour * 60 + $endMinute;
|
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
|
||||||
if ($startMinutes === $endMinutes) {
|
return false;
|
||||||
return true;
|
}
|
||||||
}
|
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
|
||||||
if ($startMinutes < $endMinutes) {
|
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||||
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
|
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
|
||||||
}
|
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
|
||||||
|
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
|
||||||
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
|
$startMinutes = $startHour * 60 + $startMinute;
|
||||||
}
|
$endMinutes = $endHour * 60 + $endMinute;
|
||||||
|
if ($startMinutes === $endMinutes) {
|
||||||
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
|
return true;
|
||||||
{
|
}
|
||||||
if ($role === 'owner') {
|
if ($startMinutes < $endMinutes) {
|
||||||
return false;
|
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
|
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
|
||||||
$statement->execute([$groupId]);
|
}
|
||||||
$rule = $statement->fetch();
|
|
||||||
if (!is_array($rule)) {
|
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
|
||||||
return false;
|
{
|
||||||
}
|
if ($role === 'owner') {
|
||||||
|
return false;
|
||||||
$mode = (string)$rule['mode'];
|
}
|
||||||
if ($mode === 'off') {
|
|
||||||
return false;
|
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
|
||||||
}
|
$statement->execute([$groupId]);
|
||||||
if ($mode === 'all_except_admins' && $role === 'admin') {
|
$rule = $statement->fetch();
|
||||||
return false;
|
if (!is_array($rule)) {
|
||||||
}
|
return false;
|
||||||
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
|
}
|
||||||
return false;
|
|
||||||
}
|
$mode = (string)$rule['mode'];
|
||||||
|
if ($mode === 'off') {
|
||||||
$scheduleType = (string)$rule['schedule_type'];
|
return false;
|
||||||
if ($scheduleType === 'manual') {
|
}
|
||||||
return true;
|
if ($mode === 'all_except_admins' && $role === 'admin') {
|
||||||
}
|
return false;
|
||||||
if ($scheduleType === 'daily') {
|
}
|
||||||
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
|
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
$startsAtMs = (int)$rule['starts_at_ms'];
|
|
||||||
$endsAtMs = (int)$rule['ends_at_ms'];
|
$scheduleType = (string)$rule['schedule_type'];
|
||||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
if ($scheduleType === 'manual') {
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
if ($scheduleType === 'daily') {
|
||||||
{
|
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
|
||||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
}
|
||||||
}
|
|
||||||
|
$startsAtMs = (int)$rule['starts_at_ms'];
|
||||||
function safeUploadName(string $fileName): string
|
$endsAtMs = (int)$rule['ends_at_ms'];
|
||||||
{
|
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||||
$name = basename(str_replace('\\', '/', $fileName));
|
}
|
||||||
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
|
|
||||||
$name = trim($name, " .\t\n\r\0\x0B");
|
function nativeGroupRoleFromType(mixed $type): string
|
||||||
|
{
|
||||||
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
|
$value = (int)$type;
|
||||||
}
|
if ($value === 2) {
|
||||||
|
return 'owner';
|
||||||
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
|
}
|
||||||
{
|
if ($value === 1) {
|
||||||
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
return 'admin';
|
||||||
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
}
|
||||||
if ($extension === 'jpeg') {
|
|
||||||
$extension = 'jpg';
|
return 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($attachmentType === 'image') {
|
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
|
||||||
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
|
{
|
||||||
return $extension;
|
$statement = $db->prepare(
|
||||||
}
|
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
|
||||||
return match ($mime) {
|
. ' from `t_group` g'
|
||||||
'image/jpeg' => 'jpg',
|
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||||
'image/png' => 'png',
|
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||||
'image/gif' => 'gif',
|
);
|
||||||
'image/webp' => 'webp',
|
$statement->execute([$groupId, $sender]);
|
||||||
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
$group = $statement->fetch();
|
||||||
'image/svg+xml' => 'svg',
|
|
||||||
'image/avif' => 'avif',
|
return is_array($group) ? $group : [];
|
||||||
'image/heic' => 'heic',
|
}
|
||||||
'image/heif' => 'heif',
|
|
||||||
default => 'jpg',
|
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||||
};
|
{
|
||||||
}
|
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||||
|
}
|
||||||
if ($extension !== '') {
|
|
||||||
return $extension;
|
function safeUploadName(string $fileName): string
|
||||||
}
|
{
|
||||||
|
$name = basename(str_replace('\\', '/', $fileName));
|
||||||
return 'bin';
|
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
|
||||||
}
|
$name = trim($name, " .\t\n\r\0\x0B");
|
||||||
|
|
||||||
function detectedUploadMime(string $tmpName): string
|
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
|
||||||
{
|
}
|
||||||
$mime = mime_content_type($tmpName) ?: '';
|
|
||||||
if ($mime !== '' && $mime !== 'application/octet-stream') {
|
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
|
||||||
return $mime;
|
{
|
||||||
}
|
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
||||||
$imageInfo = @getimagesize($tmpName);
|
if ($extension === 'jpeg') {
|
||||||
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
|
$extension = 'jpg';
|
||||||
return (string)$imageInfo['mime'];
|
}
|
||||||
}
|
|
||||||
|
if ($attachmentType === 'image') {
|
||||||
return $mime !== '' ? $mime : 'application/octet-stream';
|
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
|
||||||
}
|
return $extension;
|
||||||
|
}
|
||||||
function normalizedImageUploadMime(string $tmpName, string $mime): string
|
return match ($mime) {
|
||||||
{
|
'image/jpeg' => 'jpg',
|
||||||
if (str_starts_with($mime, 'image/')) {
|
'image/png' => 'png',
|
||||||
return $mime;
|
'image/gif' => 'gif',
|
||||||
}
|
'image/webp' => 'webp',
|
||||||
|
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
||||||
$imageInfo = @getimagesize($tmpName);
|
'image/svg+xml' => 'svg',
|
||||||
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
|
'image/avif' => 'avif',
|
||||||
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
|
'image/heic' => 'heic',
|
||||||
return $imageMime;
|
'image/heif' => 'heif',
|
||||||
}
|
default => 'jpg',
|
||||||
|
};
|
||||||
throw new RuntimeException('请选择图片文件');
|
}
|
||||||
}
|
|
||||||
|
if ($extension !== '') {
|
||||||
function mediaPublicBaseUrl(): string
|
return $extension;
|
||||||
{
|
}
|
||||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
|
||||||
$scheme = $isHttps ? 'https' : 'http';
|
return 'bin';
|
||||||
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
}
|
||||||
|
|
||||||
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
|
function detectedUploadMime(string $tmpName): string
|
||||||
}
|
{
|
||||||
|
$mime = mime_content_type($tmpName) ?: '';
|
||||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
if ($mime !== '' && $mime !== 'application/octet-stream') {
|
||||||
{
|
return $mime;
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
}
|
||||||
throw new RuntimeException('文件上传失败');
|
|
||||||
}
|
$imageInfo = @getimagesize($tmpName);
|
||||||
$size = (int)($file['size'] ?? 0);
|
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
|
||||||
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
|
return (string)$imageInfo['mime'];
|
||||||
if ($size <= 0 || $size > $maxSize) {
|
}
|
||||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
|
||||||
}
|
return $mime !== '' ? $mime : 'application/octet-stream';
|
||||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
}
|
||||||
throw new RuntimeException('媒体存储目录不可写');
|
|
||||||
}
|
function normalizedImageUploadMime(string $tmpName, string $mime): string
|
||||||
|
{
|
||||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
if (str_starts_with($mime, 'image/')) {
|
||||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
return $mime;
|
||||||
throw new RuntimeException('文件上传失败');
|
}
|
||||||
}
|
|
||||||
$mime = detectedUploadMime($tmpName);
|
$imageInfo = @getimagesize($tmpName);
|
||||||
if ($attachmentType === 'image') {
|
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
|
||||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
|
||||||
}
|
return $imageMime;
|
||||||
|
}
|
||||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
|
||||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
throw new RuntimeException('请选择图片文件');
|
||||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
}
|
||||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
|
||||||
throw new RuntimeException('媒体存储目录创建失败');
|
function mediaPublicBaseUrl(): string
|
||||||
}
|
{
|
||||||
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||||
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
$extension = uploadExtension($originalName, $mime, $attachmentType);
|
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
||||||
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
$port = (int)WEB_MEDIA_PUBLIC_PORT;
|
||||||
$targetPath = $targetDir . '/' . $targetName;
|
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
|
||||||
if (!move_uploaded_file($tmpName, $targetPath)) {
|
return $scheme . '://' . $host;
|
||||||
throw new RuntimeException('文件保存失败');
|
}
|
||||||
}
|
|
||||||
@chmod($targetPath, 0644);
|
return $scheme . '://' . $host . ':' . $port;
|
||||||
|
}
|
||||||
return [
|
|
||||||
'type' => $attachmentType,
|
function messageStorageConfig(): array
|
||||||
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
|
{
|
||||||
'name' => $originalName,
|
$config = ['enabled' => false, 'url' => ''];
|
||||||
'mime' => $mime,
|
foreach (['t_app_system_conf', 'system_conf'] as $table) {
|
||||||
'size' => $size,
|
try {
|
||||||
'mediaType' => $mediaType,
|
$statement = imAppDb()->query(
|
||||||
];
|
'select `conf_name`, `conf_value` from `' . $table . '`'
|
||||||
}
|
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
|
||||||
|
);
|
||||||
function storeMessageAttachment(
|
$rows = $statement->fetchAll();
|
||||||
PDO $appDb,
|
if (!$rows) {
|
||||||
string $messageUid,
|
continue;
|
||||||
string $conversationType,
|
}
|
||||||
string $target,
|
foreach ($rows as $row) {
|
||||||
string $sender,
|
$name = strtolower((string)($row['conf_name'] ?? ''));
|
||||||
array $attachment,
|
if ($name === 'is_minioplus') {
|
||||||
int $timestamp
|
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
|
||||||
): void {
|
} elseif ($name === 'minio_url') {
|
||||||
if ($messageUid === '' || empty($attachment['url'])) {
|
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
$statement = $appDb->prepare(
|
} catch (Throwable $error) {
|
||||||
'insert into `t_web_message_attachments`'
|
continue;
|
||||||
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
|
}
|
||||||
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
}
|
||||||
. ' on duplicate key update'
|
|
||||||
. ' `attachment_type` = values(`attachment_type`),'
|
return $config;
|
||||||
. ' `url` = values(`url`),'
|
}
|
||||||
. ' `name` = values(`name`),'
|
|
||||||
. ' `mime` = values(`mime`),'
|
function saveMessageAttachmentToMinio(
|
||||||
. ' `size_bytes` = values(`size_bytes`)'
|
array $file,
|
||||||
);
|
string $attachmentType,
|
||||||
$statement->execute([
|
string $sender,
|
||||||
$messageUid,
|
string $baseUrl
|
||||||
$conversationType,
|
): array {
|
||||||
$target,
|
$size = (int)($file['size'] ?? 0);
|
||||||
$sender,
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
(string)$attachment['type'],
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
(string)$attachment['url'],
|
throw new RuntimeException('文件上传失败');
|
||||||
(string)$attachment['name'],
|
}
|
||||||
(string)$attachment['mime'],
|
$mime = detectedUploadMime($tmpName);
|
||||||
(int)$attachment['size'],
|
if ($attachmentType === 'image') {
|
||||||
$timestamp,
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
]);
|
}
|
||||||
}
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||||
|
$content = file_get_contents($tmpName);
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
if ($content === false) {
|
||||||
{
|
throw new RuntimeException('读取上传文件失败');
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
}
|
||||||
return null;
|
$requestBody = json_encode([
|
||||||
}
|
'projectCode' => WEB_MINIO_PROJECT_CODE,
|
||||||
|
'fileScene' => 'temp',
|
||||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
'fileName' => $originalName,
|
||||||
if (!is_array($attachmentFile)) {
|
'contentType' => $mime,
|
||||||
respond(400, '缺少上传文件');
|
'base64Content' => base64_encode($content),
|
||||||
}
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($requestBody)) {
|
||||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
throw new RuntimeException('生成图片上传请求失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
$handle = curl_init($baseUrl . '/project-storage/upload');
|
||||||
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
|
curl_setopt_array($handle, [
|
||||||
$payload = $isMultipart ? [] : readJsonPayload();
|
CURLOPT_POST => true,
|
||||||
$sender = messageRequestValue($payload, 'sender');
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
$target = messageRequestValue($payload, 'target');
|
CURLOPT_CONNECTTIMEOUT => 8,
|
||||||
$text = messageRequestValue($payload, 'text');
|
CURLOPT_TIMEOUT => 60,
|
||||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
CURLOPT_HTTPHEADER => [
|
||||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
'Content-Type: application/json',
|
||||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
|
||||||
$attachment = null;
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $requestBody,
|
||||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
]);
|
||||||
respond(400, '缺少发送者、接收者或消息内容');
|
$responseBody = curl_exec($handle);
|
||||||
}
|
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlError = curl_error($handle);
|
||||||
try {
|
curl_close($handle);
|
||||||
if ($isGroupConversation) {
|
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
|
||||||
ensureWebImAppTables();
|
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||||
$appDb = imAppDb();
|
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
|
||||||
$memberStatement = $appDb->prepare(
|
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
|
||||||
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
|
$message = trim((string)($response['message'] ?? ''));
|
||||||
. ' from `t_web_groups` g'
|
throw new RuntimeException(
|
||||||
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
|
$message !== ''
|
||||||
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
? 'MinIO上传失败:' . $message
|
||||||
. ' limit 1'
|
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$target, $sender]);
|
}
|
||||||
$group = $memberStatement->fetch();
|
|
||||||
if (!$group) {
|
return [
|
||||||
respond(403, '你不在该群组中');
|
'type' => $attachmentType,
|
||||||
}
|
'url' => $url,
|
||||||
$category = (string)($group['category'] ?? 'group');
|
'name' => $originalName,
|
||||||
$role = (string)($group['role'] ?? 'member');
|
'mime' => $mime,
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
'size' => $size,
|
||||||
respond(403, '公告群仅管理员可发言');
|
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
|
||||||
}
|
];
|
||||||
|
}
|
||||||
$now = webImNowMs();
|
|
||||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
|
||||||
respond(403, '当前群组处于禁言时段');
|
{
|
||||||
}
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
$attachment = attachmentFromRequest($messageType);
|
throw new RuntimeException('文件上传失败');
|
||||||
$messageUid = webImMessageUid();
|
}
|
||||||
$insert = $appDb->prepare(
|
$size = (int)($file['size'] ?? 0);
|
||||||
'insert into `t_web_group_messages`'
|
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
|
||||||
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
|
if ($size <= 0 || $size > $maxSize) {
|
||||||
. ' values (?, ?, ?, ?, ?, 0, 0)'
|
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
||||||
);
|
}
|
||||||
$insert->execute([$messageUid, $target, $sender, $text, $now]);
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||||
if ($attachment) {
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
|
throw new RuntimeException('文件上传失败');
|
||||||
}
|
}
|
||||||
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
$mime = detectedUploadMime($tmpName);
|
||||||
$updateGroup->execute([$now, $target]);
|
if ($attachmentType === 'image') {
|
||||||
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||||
$nativeError = '';
|
}
|
||||||
try {
|
|
||||||
$pushContent = $text !== ''
|
$storage = messageStorageConfig();
|
||||||
? $text
|
if ($storage['enabled']) {
|
||||||
: ($attachment
|
if ($storage['url'] === '') {
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
throw new RuntimeException('MinIO已开启但未配置服务地址');
|
||||||
: $text);
|
}
|
||||||
$payloadBody = [
|
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
}
|
||||||
'searchableContent' => $pushContent,
|
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||||
'pushContent' => $pushContent,
|
throw new RuntimeException('媒体存储目录不可写');
|
||||||
'content' => $text,
|
}
|
||||||
'persistFlag' => 3,
|
|
||||||
];
|
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||||
if ($attachment) {
|
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
||||||
$payloadBody['extra'] = json_encode([
|
throw new RuntimeException('媒体存储目录创建失败');
|
||||||
'name' => (string)$attachment['name'],
|
}
|
||||||
'mime' => (string)$attachment['mime'],
|
|
||||||
'size' => (int)$attachment['size'],
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$extension = uploadExtension($originalName, $mime, $attachmentType);
|
||||||
}
|
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
||||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
$targetPath = $targetDir . '/' . $targetName;
|
||||||
'sender' => $sender,
|
if (!move_uploaded_file($tmpName, $targetPath)) {
|
||||||
'conv' => [
|
throw new RuntimeException('文件保存失败');
|
||||||
'type' => 1,
|
}
|
||||||
'target' => $target,
|
@chmod($targetPath, 0644);
|
||||||
'line' => 0,
|
|
||||||
],
|
return [
|
||||||
'payload' => $payloadBody,
|
'type' => $attachmentType,
|
||||||
'userMessage' => true,
|
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
|
||||||
]), '原生群消息发送失败');
|
'name' => $originalName,
|
||||||
} catch (Throwable $error) {
|
'mime' => $mime,
|
||||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
'size' => $size,
|
||||||
}
|
'mediaType' => $mediaType,
|
||||||
|
];
|
||||||
respond(0, 'success', [
|
}
|
||||||
'messageUid' => $messageUid,
|
|
||||||
'timestamp' => $now,
|
function storeMessageAttachment(
|
||||||
'senderRole' => $role,
|
PDO $appDb,
|
||||||
'attachment' => $attachment,
|
string $messageUid,
|
||||||
'nativeError' => $nativeError,
|
string $conversationType,
|
||||||
]);
|
string $target,
|
||||||
}
|
string $sender,
|
||||||
|
array $attachment,
|
||||||
$attachment = attachmentFromRequest($messageType);
|
int $timestamp
|
||||||
$pushContent = $text !== ''
|
): void {
|
||||||
? $text
|
if ($messageUid === '' || empty($attachment['url'])) {
|
||||||
: ($attachment
|
return;
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
}
|
||||||
: $text);
|
|
||||||
$payloadBody = [
|
$statement = $appDb->prepare(
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
'insert into `t_web_message_attachments`'
|
||||||
'searchableContent' => $pushContent,
|
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
|
||||||
'pushContent' => $pushContent,
|
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
'content' => $text,
|
. ' on duplicate key update'
|
||||||
'persistFlag' => 3,
|
. ' `attachment_type` = values(`attachment_type`),'
|
||||||
];
|
. ' `url` = values(`url`),'
|
||||||
if ($attachment) {
|
. ' `name` = values(`name`),'
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
. ' `mime` = values(`mime`),'
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
. ' `size_bytes` = values(`size_bytes`)'
|
||||||
$payloadBody['extra'] = json_encode([
|
);
|
||||||
'name' => (string)$attachment['name'],
|
$statement->execute([
|
||||||
'mime' => (string)$attachment['mime'],
|
$messageUid,
|
||||||
'size' => (int)$attachment['size'],
|
$conversationType,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$target,
|
||||||
}
|
$sender,
|
||||||
|
(string)$attachment['type'],
|
||||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
(string)$attachment['url'],
|
||||||
'sender' => $sender,
|
(string)$attachment['name'],
|
||||||
'conv' => [
|
(string)$attachment['mime'],
|
||||||
'type' => 0,
|
(int)$attachment['size'],
|
||||||
'target' => $target,
|
$timestamp,
|
||||||
'line' => 0,
|
]);
|
||||||
],
|
}
|
||||||
'payload' => $payloadBody,
|
|
||||||
'userMessage' => true,
|
function attachmentFromRequest(string $messageType, string $sender): ?array
|
||||||
]), '发送失败');
|
{
|
||||||
|
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
return null;
|
||||||
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
}
|
||||||
if ($attachment && $messageUid !== '') {
|
|
||||||
ensureWebImAppTables();
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||||
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
|
if (!is_array($attachmentFile)) {
|
||||||
}
|
respond(400, '缺少上传文件');
|
||||||
|
}
|
||||||
respond(0, 'success', [
|
|
||||||
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
|
||||||
'timestamp' => $timestamp,
|
}
|
||||||
'attachment' => $attachment,
|
|
||||||
]);
|
function messagePayloadBody(string $text, ?array $attachment): array
|
||||||
} catch (Throwable $error) {
|
{
|
||||||
respond(500, $error->getMessage() ?: '发送失败');
|
$pushContent = $text !== ''
|
||||||
}
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payloadBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
|
||||||
|
{
|
||||||
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 1,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
|
||||||
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
|
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
|
||||||
|
$payload = $isMultipart ? [] : readJsonPayload();
|
||||||
|
$sender = messageRequestValue($payload, 'sender');
|
||||||
|
$target = messageRequestValue($payload, 'target');
|
||||||
|
$text = messageRequestValue($payload, 'text');
|
||||||
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||||
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||||
|
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||||
|
$attachment = null;
|
||||||
|
|
||||||
|
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
||||||
|
respond(400, '缺少发送者、接收者或消息内容');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($isGroupConversation) {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
$appDb = imAppDb();
|
||||||
|
$memberStatement = $appDb->prepare(
|
||||||
|
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
|
||||||
|
. ' from `t_web_groups` g'
|
||||||
|
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
|
||||||
|
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
||||||
|
. ' limit 1'
|
||||||
|
);
|
||||||
|
$memberStatement->execute([$target, $sender]);
|
||||||
|
$group = $memberStatement->fetch();
|
||||||
|
if (!$group) {
|
||||||
|
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
|
||||||
|
if (!$nativeGroup) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
|
||||||
|
respond(0, 'success', [
|
||||||
|
...$nativeResult,
|
||||||
|
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
|
||||||
|
'native' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$category = (string)($group['category'] ?? 'group');
|
||||||
|
$role = (string)($group['role'] ?? 'member');
|
||||||
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
|
respond(403, '公告群仅管理员可发言');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = webImNowMs();
|
||||||
|
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||||
|
respond(403, '当前群组处于禁言时段');
|
||||||
|
}
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
$messageUid = webImMessageUid();
|
||||||
|
$insert = $appDb->prepare(
|
||||||
|
'insert into `t_web_group_messages`'
|
||||||
|
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
|
||||||
|
. ' values (?, ?, ?, ?, ?, 0, 0)'
|
||||||
|
);
|
||||||
|
$insert->execute([$messageUid, $target, $sender, $text, $now]);
|
||||||
|
if ($attachment) {
|
||||||
|
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
|
||||||
|
}
|
||||||
|
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||||
|
$updateGroup->execute([$now, $target]);
|
||||||
|
|
||||||
|
$nativeError = '';
|
||||||
|
try {
|
||||||
|
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 1,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid,
|
||||||
|
'timestamp' => $now,
|
||||||
|
'senderRole' => $role,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'nativeError' => $nativeError,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = attachmentFromRequest($messageType, $sender);
|
||||||
|
|
||||||
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => [
|
||||||
|
'type' => 0,
|
||||||
|
'target' => $target,
|
||||||
|
'line' => 0,
|
||||||
|
],
|
||||||
|
'payload' => messagePayloadBody($text, $attachment),
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '发送失败');
|
||||||
|
|
||||||
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
ensureWebImAppTables();
|
||||||
|
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
respond(500, $error->getMessage() ?: '发送失败');
|
||||||
|
}
|
||||||
|
|||||||
+61
-61
@@ -1,61 +1,61 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require __DIR__ . '/im_web_bootstrap.php';
|
require __DIR__ . '/im_web_bootstrap.php';
|
||||||
|
|
||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
|
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
|
||||||
$alias = trim((string)($payload['alias'] ?? ''));
|
$alias = trim((string)($payload['alias'] ?? ''));
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少用户ID');
|
respond(400, '缺少用户ID');
|
||||||
}
|
}
|
||||||
if ($targetId === '') {
|
if ($targetId === '') {
|
||||||
respond(400, '缺少联系人ID');
|
respond(400, '缺少联系人ID');
|
||||||
}
|
}
|
||||||
if ($targetId === $userId) {
|
if ($targetId === $userId) {
|
||||||
respond(400, '不能给自己设置备注名');
|
respond(400, '不能给自己设置备注名');
|
||||||
}
|
}
|
||||||
if (mb_strlen($alias, 'UTF-8') > 32) {
|
if (mb_strlen($alias, 'UTF-8') > 32) {
|
||||||
respond(400, '备注名不能超过32个字');
|
respond(400, '备注名不能超过32个字');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
$imDb = imDb();
|
$imDb = imDb();
|
||||||
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
|
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
|
||||||
$exists->execute([$targetId]);
|
$exists->execute([$targetId]);
|
||||||
if (!$exists->fetchColumn()) {
|
if (!$exists->fetchColumn()) {
|
||||||
respond(404, '没有找到该用户');
|
respond(404, '没有找到该用户');
|
||||||
}
|
}
|
||||||
|
|
||||||
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
|
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
|
||||||
$friend->execute([$userId, $targetId]);
|
$friend->execute([$userId, $targetId]);
|
||||||
$isContact = (bool)$friend->fetchColumn();
|
$isContact = (bool)$friend->fetchColumn();
|
||||||
|
|
||||||
if ($isContact) {
|
if ($isContact) {
|
||||||
requireImSuccess(
|
requireImSuccess(
|
||||||
imAdminPost('/admin/friend/set_alias', [
|
imAdminPost('/admin/friend/set_alias', [
|
||||||
'operator' => $userId,
|
'operator' => $userId,
|
||||||
'targetId' => $targetId,
|
'targetId' => $targetId,
|
||||||
'alias' => $alias,
|
'alias' => $alias,
|
||||||
]),
|
]),
|
||||||
'备注名修改失败'
|
'备注名修改失败'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
|
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
|
||||||
|
|
||||||
respond(0, 'success', [
|
respond(0, 'success', [
|
||||||
'contact' => [
|
'contact' => [
|
||||||
'uid' => $targetId,
|
'uid' => $targetId,
|
||||||
'alias' => $alias,
|
'alias' => $alias,
|
||||||
'isContact' => $isContact,
|
'isContact' => $isContact,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
|
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
window.JC_WEB_IM_CONFIG = {
|
(() => {
|
||||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
const protocol = window.location.protocol || 'http:';
|
||||||
appServer: 'http://winchen.ezpay.asia:8888',
|
const host = window.location.hostname || '127.0.0.1';
|
||||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
|
||||||
platformConfigApi: '/api/platform_config.php',
|
window.JC_WEB_IM_CONFIG = {
|
||||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
adminServer: `${protocol}//${host}:8100`,
|
||||||
imServerHost: 'winchen.ezpay.asia',
|
appServer: `${protocol}//${host}:8888`,
|
||||||
imRoutePort: 18000,
|
platformConfigApi: '/api/platform_config.php',
|
||||||
useWss: false,
|
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
|
||||||
wsPath: '/ws',
|
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
|
||||||
sdkMode: 'http'
|
imServerHost: host,
|
||||||
};
|
imRoutePort: 80,
|
||||||
|
useWss: false,
|
||||||
|
wsPath: '/ws',
|
||||||
|
sdkMode: 'http',
|
||||||
|
syncIntervalMs: 15000,
|
||||||
|
sseEnabled: true,
|
||||||
|
sseTicketApi: '/api/sse_ticket.php',
|
||||||
|
sseUrl: '/ws-events'
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 958 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
+14
-14
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Web IM</title>
|
<title>Web IM</title>
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<link rel="icon" href="<%= BASE_URL %>img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico">
|
||||||
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
|
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
|
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-3
@@ -1,17 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<LoginView v-if="!session.isAuthenticated" />
|
<LoginView v-if="!session.isAuthenticated" />
|
||||||
<DocumentedShell v-else />
|
<ChatShell v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted, watch } from 'vue';
|
||||||
import LoginView from './components/LoginView.vue';
|
import LoginView from './components/LoginView.vue';
|
||||||
import DocumentedShell from './components/DocumentedShell.vue';
|
import ChatShell from './components/ChatShell.vue';
|
||||||
import { useSessionStore } from './stores/session';
|
import { useSessionStore } from './stores/session';
|
||||||
|
import { useConversationStore } from './stores/conversations';
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
|
const conversations = useConversationStore();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
session.restore();
|
session.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => session.userId, userId => {
|
||||||
|
if (userId && session.isAuthenticated) {
|
||||||
|
conversations.bootstrapDemoData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conversations.clearUserState();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<NavigationRail />
|
<NavigationRail />
|
||||||
<ConversationList v-if="navigation.section === 'messages'" />
|
<ConversationList v-if="navigation.section === 'messages'" />
|
||||||
<FavoriteList v-else-if="navigation.section === 'favorites'" />
|
<FavoriteList v-else-if="navigation.section === 'favorites'" />
|
||||||
<ContactList v-else />
|
<ContactList v-else />
|
||||||
<ChatPanel v-if="navigation.section === 'messages'" />
|
<ChatPanel v-if="navigation.section === 'messages'" />
|
||||||
<FavoritePanel v-else-if="navigation.section === 'favorites'" />
|
<FavoritePanel v-else-if="navigation.section === 'favorites'" />
|
||||||
<ContactPanel v-else />
|
<ContactPanel v-else />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import NavigationRail from './sidebar/NavigationRail.vue';
|
import NavigationRail from './sidebar/NavigationRail.vue';
|
||||||
import ConversationList from './sidebar/ConversationList.vue';
|
import ConversationList from './sidebar/ConversationList.vue';
|
||||||
import ContactList from './sidebar/ContactList.vue';
|
import ContactList from './sidebar/ContactList.vue';
|
||||||
import ChatPanel from './chat/ChatPanel.vue';
|
import ChatPanel from './chat/ChatPanel.vue';
|
||||||
import ContactPanel from './contacts/ContactPanel.vue';
|
import ContactPanel from './contacts/ContactPanel.vue';
|
||||||
import FavoriteList from './favorites/FavoriteList.vue';
|
import FavoriteList from './favorites/FavoriteList.vue';
|
||||||
import FavoritePanel from './favorites/FavoritePanel.vue';
|
import FavoritePanel from './favorites/FavoritePanel.vue';
|
||||||
import { useConversationStore } from '@/stores/conversations';
|
import { useConversationStore } from '@/stores/conversations';
|
||||||
import { useNavigationStore } from '@/stores/navigation';
|
import { useNavigationStore } from '@/stores/navigation';
|
||||||
import { useContactStore } from '@/stores/contacts';
|
import { useContactStore } from '@/stores/contacts';
|
||||||
|
|
||||||
const conversations = useConversationStore();
|
const conversations = useConversationStore();
|
||||||
const navigation = useNavigationStore();
|
const navigation = useNavigationStore();
|
||||||
const contacts = useContactStore();
|
const contacts = useContactStore();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
conversations.refreshRemoteConversations();
|
conversations.refreshRemoteConversations();
|
||||||
contacts.refreshContacts();
|
contacts.refreshContacts();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="documented-shell">
|
|
||||||
<aside class="documented-rail">
|
|
||||||
<div class="documented-avatar">
|
|
||||||
<img v-if="session.portrait" :src="session.portrait" alt="">
|
|
||||||
<span v-else>{{ initials }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="documented-rail-copy">
|
|
||||||
<strong>{{ session.displayName || session.userId }}</strong>
|
|
||||||
<small>{{ session.userId }}</small>
|
|
||||||
</div>
|
|
||||||
<button class="documented-rail-button" type="button" @click="openPasswordDialog">修改密码</button>
|
|
||||||
<button class="documented-rail-button ghost" type="button" @click="logout">退出登录</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="documented-main">
|
|
||||||
<header class="documented-header">
|
|
||||||
<div>
|
|
||||||
<h1>文档对接模式</h1>
|
|
||||||
<p>当前界面只保留这两份文档已经明确提供的能力。</p>
|
|
||||||
</div>
|
|
||||||
<div class="documented-status">
|
|
||||||
<span>连接状态</span>
|
|
||||||
<strong>{{ session.connectionStatusText || '已登录' }}</strong>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="documented-card">
|
|
||||||
<h2>已按文档接入</h2>
|
|
||||||
<ul class="documented-list">
|
|
||||||
<li>`/auth/login_pwd` 密码登录</li>
|
|
||||||
<li>`/auth/register` 注册并返回 `imToken`、`accessToken`、`refreshToken`</li>
|
|
||||||
<li>`/auth/sendEmailCode` 邮箱验证码</li>
|
|
||||||
<li>`/auth/refreshToken` 过期自动刷新</li>
|
|
||||||
<li>`/auth/changePassword` 修改密码</li>
|
|
||||||
<li>`/auth/logout` 退出登录</li>
|
|
||||||
<li>扫码登录兼容接口:`/pc_session` 与 `/session_login/{token}`</li>
|
|
||||||
<li>AES-128-CBC 请求加密、响应解密、`X-Timestamp`、排除路径规则</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="documented-card">
|
|
||||||
<h2>当前按 1 比 1 已停用</h2>
|
|
||||||
<p class="documented-text">下面这些旧页面功能已经不再走本地 PHP 或旧接口,因为你给的文档里没有对应定义。</p>
|
|
||||||
<ul class="documented-list muted">
|
|
||||||
<li>会话列表与消息列表</li>
|
|
||||||
<li>发送文字、图片、文件消息</li>
|
|
||||||
<li>联系人列表、添加联系人、备注</li>
|
|
||||||
<li>群详情、群成员、群管理</li>
|
|
||||||
<li>个人资料编辑与头像上传</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="documented-card">
|
|
||||||
<h2>当前登录态</h2>
|
|
||||||
<dl class="documented-meta">
|
|
||||||
<div>
|
|
||||||
<dt>用户 ID</dt>
|
|
||||||
<dd>{{ session.userId || '-' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>显示名</dt>
|
|
||||||
<dd>{{ session.displayName || '-' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>IM Token</dt>
|
|
||||||
<dd>{{ session.token ? '已保存' : '未保存' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>App Server</dt>
|
|
||||||
<dd>{{ appConfig.appServer }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>扫码服务</dt>
|
|
||||||
<dd>{{ appConfig.qrAppServer }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="passwordDialogOpen" class="modal-backdrop" @click.self="closePasswordDialog">
|
|
||||||
<form class="modal-panel documented-password-panel" @submit.prevent="savePassword">
|
|
||||||
<header>
|
|
||||||
<strong>修改密码</strong>
|
|
||||||
<button type="button" class="icon-button" title="关闭" @click="closePasswordDialog">×</button>
|
|
||||||
</header>
|
|
||||||
<label>
|
|
||||||
<span>当前密码</span>
|
|
||||||
<input v-model="passwordDraft.oldPassword" type="password" autocomplete="current-password">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>新密码</span>
|
|
||||||
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>确认新密码</span>
|
|
||||||
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<p v-if="passwordError" class="form-error">{{ passwordError }}</p>
|
|
||||||
<p v-if="passwordNotice" class="form-info">{{ passwordNotice }}</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost-inline-action" :disabled="passwordSaving" @click="closePasswordDialog">取消</button>
|
|
||||||
<button class="primary-inline-action" :disabled="passwordSaving">
|
|
||||||
{{ passwordSaving ? '保存中...' : '保存' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, reactive, ref } from 'vue';
|
|
||||||
import { useSessionStore } from '@/stores/session';
|
|
||||||
import { appConfig } from '@/config';
|
|
||||||
|
|
||||||
const session = useSessionStore();
|
|
||||||
const passwordDialogOpen = ref(false);
|
|
||||||
const passwordSaving = ref(false);
|
|
||||||
const passwordError = ref('');
|
|
||||||
const passwordNotice = ref('');
|
|
||||||
const passwordDraft = reactive({
|
|
||||||
oldPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const initials = computed(() => String(session.displayName || session.userId || 'U').slice(0, 1).toUpperCase());
|
|
||||||
|
|
||||||
function resetPasswordDraft() {
|
|
||||||
passwordDraft.oldPassword = '';
|
|
||||||
passwordDraft.newPassword = '';
|
|
||||||
passwordDraft.confirmPassword = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPasswordDialog() {
|
|
||||||
passwordError.value = '';
|
|
||||||
passwordNotice.value = '';
|
|
||||||
resetPasswordDraft();
|
|
||||||
passwordDialogOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePasswordDialog() {
|
|
||||||
if (passwordSaving.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
passwordDialogOpen.value = false;
|
|
||||||
passwordError.value = '';
|
|
||||||
passwordNotice.value = '';
|
|
||||||
resetPasswordDraft();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePassword() {
|
|
||||||
passwordError.value = '';
|
|
||||||
passwordNotice.value = '';
|
|
||||||
if (!passwordDraft.oldPassword || !passwordDraft.newPassword || !passwordDraft.confirmPassword) {
|
|
||||||
passwordError.value = '请填写完整密码';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordDraft.newPassword !== passwordDraft.confirmPassword) {
|
|
||||||
passwordError.value = '两次输入的新密码不一致';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordDraft.oldPassword === passwordDraft.newPassword) {
|
|
||||||
passwordError.value = '新密码不能与当前密码相同';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordSaving.value = true;
|
|
||||||
try {
|
|
||||||
await session.changePassword({
|
|
||||||
oldPassword: passwordDraft.oldPassword,
|
|
||||||
newPassword: passwordDraft.newPassword
|
|
||||||
});
|
|
||||||
passwordNotice.value = '密码已修改,当前登录态会按文档要求失效。';
|
|
||||||
} catch (error) {
|
|
||||||
passwordError.value = error.message || '密码修改失败';
|
|
||||||
} finally {
|
|
||||||
passwordSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await session.logout();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+598
-182
File diff suppressed because it is too large
Load Diff
+1081
-1067
File diff suppressed because it is too large
Load Diff
@@ -1,223 +1,297 @@
|
|||||||
<template>
|
<template>
|
||||||
<form
|
<form
|
||||||
class="message-composer"
|
class="message-composer"
|
||||||
:class="{ 'drop-active': dropActive }"
|
:class="{ 'drop-active': dropActive }"
|
||||||
@submit.prevent="submit"
|
@submit.prevent="submit"
|
||||||
@dragenter.prevent="handleDragEnter"
|
@dragenter.prevent="handleDragEnter"
|
||||||
@dragover.prevent="handleDragOver"
|
@dragover.prevent="handleDragOver"
|
||||||
@dragleave.prevent="handleDragLeave"
|
@dragleave.prevent="handleDragLeave"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
@paste="handlePaste"
|
@paste="handlePaste"
|
||||||
>
|
>
|
||||||
<div v-if="quoteMessage" class="quote-preview">
|
<div v-if="quoteMessage" class="quote-preview">
|
||||||
<span>
|
<span>
|
||||||
<strong>引用</strong>
|
<strong>引用</strong>
|
||||||
{{ quoteMessage.quoteText || quoteMessage.text }}
|
{{ quoteMessage.quoteText || quoteMessage.text }}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
<button type="button" title="表情" aria-label="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
||||||
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
<button
|
||||||
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
type="button"
|
||||||
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
class="composer-tool-button image-tool"
|
||||||
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
title="图片"
|
||||||
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
|
aria-label="图片"
|
||||||
<button
|
:disabled="disabled"
|
||||||
v-for="emoji in emojiList"
|
@click="chooseImage"
|
||||||
:key="emoji"
|
>
|
||||||
type="button"
|
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@click="insertEmoji(emoji)"
|
<rect x="3" y="5" width="18" height="14" rx="2.5"></rect>
|
||||||
>
|
<circle cx="8.2" cy="9.2" r="1.6"></circle>
|
||||||
{{ emoji }}
|
<path d="M6.5 17l4.2-4.7 3.1 3.4 1.8-2 2.9 3.3"></path>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<textarea
|
type="button"
|
||||||
ref="textareaEl"
|
class="composer-tool-button file-tool"
|
||||||
v-model="text"
|
title="文件"
|
||||||
:disabled="disabled"
|
aria-label="文件"
|
||||||
placeholder="输入消息"
|
:disabled="disabled"
|
||||||
rows="3"
|
@click="chooseFile"
|
||||||
@keydown.enter.exact.prevent="submit"
|
>
|
||||||
></textarea>
|
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<div class="composer-footer">
|
<path d="M7 3.8h7.2L19 8.6V20a1.8 1.8 0 0 1-1.8 1.8H7A1.8 1.8 0 0 1 5.2 20V5.6A1.8 1.8 0 0 1 7 3.8z"></path>
|
||||||
<span>Enter 发送,Shift + Enter 换行</span>
|
<path d="M14 4v5h5"></path>
|
||||||
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
|
<path d="M8.5 13h7"></path>
|
||||||
</div>
|
<path d="M8.5 16.5h5.2"></path>
|
||||||
</form>
|
</svg>
|
||||||
</template>
|
</button>
|
||||||
|
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
||||||
<script setup>
|
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
||||||
import { ref } from 'vue';
|
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
|
||||||
|
<button
|
||||||
const props = defineProps({
|
v-for="emoji in emojiList"
|
||||||
disabled: {
|
:key="emoji"
|
||||||
type: Boolean,
|
type="button"
|
||||||
default: false
|
@click="insertEmoji(emoji)"
|
||||||
},
|
>
|
||||||
quoteMessage: {
|
{{ emoji }}
|
||||||
type: Object,
|
</button>
|
||||||
default: null
|
</div>
|
||||||
}
|
</div>
|
||||||
});
|
<div v-if="pendingAttachments.length" class="composer-attachment-preview">
|
||||||
|
<article
|
||||||
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
|
v-for="(attachment, index) in pendingAttachments"
|
||||||
const text = ref('');
|
:key="attachment.id"
|
||||||
const emojiOpen = ref(false);
|
class="composer-attachment-item"
|
||||||
const dropActive = ref(false);
|
>
|
||||||
const textareaEl = ref(null);
|
<img
|
||||||
const imageInput = ref(null);
|
v-if="attachment.type === 'image'"
|
||||||
const fileInput = ref(null);
|
:src="attachment.previewUrl"
|
||||||
let dragDepth = 0;
|
:alt="attachment.file.name || '待发送图片'"
|
||||||
const emojiList = [
|
>
|
||||||
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
|
<span v-else class="composer-attachment-file-icon">▤</span>
|
||||||
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
|
<span class="composer-attachment-name">{{ attachment.file.name }}</span>
|
||||||
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
|
<button type="button" title="移除" aria-label="移除待发送文件" @click="removePendingAttachment(index)">×</button>
|
||||||
];
|
</article>
|
||||||
|
</div>
|
||||||
function submit() {
|
<textarea
|
||||||
const value = text.value.trim();
|
ref="textareaEl"
|
||||||
if (!value) {
|
v-model="text"
|
||||||
return;
|
:disabled="disabled"
|
||||||
}
|
placeholder="输入消息"
|
||||||
emit('send', value);
|
rows="3"
|
||||||
text.value = '';
|
@keydown.enter.exact.prevent="submit"
|
||||||
emojiOpen.value = false;
|
></textarea>
|
||||||
}
|
<div class="composer-footer">
|
||||||
|
<span>Enter 发送,Shift + Enter 换行</span>
|
||||||
function toggleEmojiPicker() {
|
<button class="send-button" :disabled="disabled || (!text.trim() && !pendingAttachments.length)">发送</button>
|
||||||
emojiOpen.value = !emojiOpen.value;
|
</div>
|
||||||
}
|
</form>
|
||||||
|
</template>
|
||||||
function insertEmoji(emoji) {
|
|
||||||
const element = textareaEl.value;
|
<script setup>
|
||||||
if (!element) {
|
import { onBeforeUnmount, ref } from 'vue';
|
||||||
text.value += emoji;
|
|
||||||
return;
|
const props = defineProps({
|
||||||
}
|
disabled: {
|
||||||
const start = element.selectionStart ?? text.value.length;
|
type: Boolean,
|
||||||
const end = element.selectionEnd ?? text.value.length;
|
default: false
|
||||||
text.value = `${text.value.slice(0, start)}${emoji}${text.value.slice(end)}`;
|
},
|
||||||
requestAnimationFrame(() => {
|
quoteMessage: {
|
||||||
element.focus();
|
type: Object,
|
||||||
const cursor = start + emoji.length;
|
default: null
|
||||||
element.setSelectionRange(cursor, cursor);
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
|
||||||
function chooseImage() {
|
const text = ref('');
|
||||||
imageInput.value?.click();
|
const emojiOpen = ref(false);
|
||||||
}
|
const dropActive = ref(false);
|
||||||
|
const textareaEl = ref(null);
|
||||||
function chooseFile() {
|
const imageInput = ref(null);
|
||||||
fileInput.value?.click();
|
const fileInput = ref(null);
|
||||||
}
|
const pendingAttachments = ref([]);
|
||||||
|
let dragDepth = 0;
|
||||||
function attachmentTypeForFile(file, fallbackType = 'file') {
|
const emojiList = [
|
||||||
if (file?.type?.startsWith('image/')) {
|
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
|
||||||
return 'image';
|
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
|
||||||
}
|
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
|
||||||
return fallbackType === 'image' ? 'image' : 'file';
|
];
|
||||||
}
|
|
||||||
|
function submit() {
|
||||||
function emitFiles(files, fallbackType = 'file') {
|
const value = text.value.trim();
|
||||||
if (props.disabled) {
|
const attachments = [...pendingAttachments.value];
|
||||||
return;
|
if (!value && !attachments.length) {
|
||||||
}
|
return;
|
||||||
const validFiles = Array.from(files || []).filter(file => file instanceof File && file.size > 0);
|
}
|
||||||
if (!validFiles.length) {
|
if (attachments.length) {
|
||||||
return;
|
attachments.forEach((attachment, index) => {
|
||||||
}
|
emit('send-attachment', {
|
||||||
|
type: attachment.type,
|
||||||
const caption = text.value.trim();
|
file: attachment.file,
|
||||||
validFiles.forEach((file, index) => {
|
text: index === 0 ? value : ''
|
||||||
emit('send-attachment', {
|
});
|
||||||
type: attachmentTypeForFile(file, fallbackType),
|
});
|
||||||
file,
|
clearPendingAttachments();
|
||||||
text: index === 0 ? caption : ''
|
} else {
|
||||||
});
|
emit('send', value);
|
||||||
});
|
}
|
||||||
text.value = '';
|
text.value = '';
|
||||||
emojiOpen.value = false;
|
emojiOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAttachment(event, type) {
|
function toggleEmojiPicker() {
|
||||||
const input = event.target;
|
emojiOpen.value = !emojiOpen.value;
|
||||||
const files = input.files ? Array.from(input.files) : [];
|
}
|
||||||
input.value = '';
|
|
||||||
if (!files.length || props.disabled) {
|
function insertEmoji(emoji) {
|
||||||
return;
|
const element = textareaEl.value;
|
||||||
}
|
if (!element) {
|
||||||
emitFiles(files, type);
|
text.value += emoji;
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
function hasDraggedFiles(event) {
|
const start = element.selectionStart ?? text.value.length;
|
||||||
const types = Array.from(event.dataTransfer?.types || []);
|
const end = element.selectionEnd ?? text.value.length;
|
||||||
return types.includes('Files');
|
text.value = `${text.value.slice(0, start)}${emoji}${text.value.slice(end)}`;
|
||||||
}
|
requestAnimationFrame(() => {
|
||||||
|
element.focus();
|
||||||
function handleDragEnter(event) {
|
const cursor = start + emoji.length;
|
||||||
if (props.disabled || !hasDraggedFiles(event)) {
|
element.setSelectionRange(cursor, cursor);
|
||||||
return;
|
});
|
||||||
}
|
}
|
||||||
dragDepth += 1;
|
|
||||||
dropActive.value = true;
|
function chooseImage() {
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
imageInput.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(event) {
|
function chooseFile() {
|
||||||
if (props.disabled || !hasDraggedFiles(event)) {
|
fileInput.value?.click();
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
dropActive.value = true;
|
function attachmentTypeForFile(file, fallbackType = 'file') {
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
if (file?.type?.startsWith('image/')) {
|
||||||
}
|
return 'image';
|
||||||
|
}
|
||||||
function handleDragLeave() {
|
return fallbackType === 'image' ? 'image' : 'file';
|
||||||
dragDepth = Math.max(0, dragDepth - 1);
|
}
|
||||||
if (dragDepth === 0) {
|
|
||||||
dropActive.value = false;
|
function emitFiles(files, fallbackType = 'file') {
|
||||||
}
|
if (props.disabled) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
function filesFromDataTransfer(dataTransfer) {
|
const validFiles = Array.from(files || []).filter(file => file instanceof File && file.size > 0);
|
||||||
const items = Array.from(dataTransfer?.items || []);
|
if (!validFiles.length) {
|
||||||
const files = items
|
return;
|
||||||
.filter(item => item.kind === 'file' && !item.webkitGetAsEntry?.()?.isDirectory)
|
}
|
||||||
.map(item => item.getAsFile())
|
|
||||||
.filter(Boolean);
|
validFiles.forEach(file => {
|
||||||
if (files.length) {
|
const type = attachmentTypeForFile(file, fallbackType);
|
||||||
return files;
|
pendingAttachments.value.push({
|
||||||
}
|
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
return Array.from(dataTransfer?.files || []);
|
type,
|
||||||
}
|
file,
|
||||||
|
previewUrl: type === 'image' ? URL.createObjectURL(file) : ''
|
||||||
function handleDrop(event) {
|
});
|
||||||
dragDepth = 0;
|
});
|
||||||
dropActive.value = false;
|
emojiOpen.value = false;
|
||||||
if (props.disabled) {
|
}
|
||||||
return;
|
|
||||||
}
|
function releasePendingAttachment(attachment) {
|
||||||
emitFiles(filesFromDataTransfer(event.dataTransfer));
|
if (attachment?.previewUrl) {
|
||||||
}
|
URL.revokeObjectURL(attachment.previewUrl);
|
||||||
|
}
|
||||||
function imageFilesFromClipboard(clipboardData) {
|
}
|
||||||
return Array.from(clipboardData?.items || [])
|
|
||||||
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
|
function removePendingAttachment(index) {
|
||||||
.map(item => item.getAsFile())
|
const [attachment] = pendingAttachments.value.splice(index, 1);
|
||||||
.filter(Boolean);
|
releasePendingAttachment(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePaste(event) {
|
function clearPendingAttachments() {
|
||||||
if (props.disabled) {
|
pendingAttachments.value.forEach(releasePendingAttachment);
|
||||||
return;
|
pendingAttachments.value = [];
|
||||||
}
|
}
|
||||||
const imageFiles = imageFilesFromClipboard(event.clipboardData);
|
|
||||||
if (!imageFiles.length) {
|
function selectAttachment(event, type) {
|
||||||
return;
|
const input = event.target;
|
||||||
}
|
const files = input.files ? Array.from(input.files) : [];
|
||||||
event.preventDefault();
|
input.value = '';
|
||||||
emitFiles(imageFiles, 'image');
|
if (!files.length || props.disabled) {
|
||||||
}
|
return;
|
||||||
</script>
|
}
|
||||||
|
emitFiles(files, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDraggedFiles(event) {
|
||||||
|
const types = Array.from(event.dataTransfer?.types || []);
|
||||||
|
return types.includes('Files');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter(event) {
|
||||||
|
if (props.disabled || !hasDraggedFiles(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragDepth += 1;
|
||||||
|
dropActive.value = true;
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event) {
|
||||||
|
if (props.disabled || !hasDraggedFiles(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dropActive.value = true;
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1);
|
||||||
|
if (dragDepth === 0) {
|
||||||
|
dropActive.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesFromDataTransfer(dataTransfer) {
|
||||||
|
const items = Array.from(dataTransfer?.items || []);
|
||||||
|
const files = items
|
||||||
|
.filter(item => item.kind === 'file' && !item.webkitGetAsEntry?.()?.isDirectory)
|
||||||
|
.map(item => item.getAsFile())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (files.length) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
return Array.from(dataTransfer?.files || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event) {
|
||||||
|
dragDepth = 0;
|
||||||
|
dropActive.value = false;
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitFiles(filesFromDataTransfer(event.dataTransfer));
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFilesFromClipboard(clipboardData) {
|
||||||
|
return Array.from(clipboardData?.items || [])
|
||||||
|
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
|
||||||
|
.map(item => item.getAsFile())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(event) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const imageFiles = imageFilesFromClipboard(event.clipboardData);
|
||||||
|
if (!imageFiles.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
emitFiles(imageFiles, 'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(clearPendingAttachments);
|
||||||
|
</script>
|
||||||
|
|||||||
+821
-757
File diff suppressed because it is too large
Load Diff
@@ -1,111 +1,111 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="contact-panel">
|
<section class="contact-panel">
|
||||||
<template v-if="contact">
|
<template v-if="contact">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ remarkName }}</h2>
|
<h2>{{ remarkName }}</h2>
|
||||||
<p>{{ username }}</p>
|
<p>{{ username }}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="contact-profile">
|
<div class="contact-profile">
|
||||||
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
|
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
|
||||||
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
|
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
|
||||||
<div class="profile-main">
|
<div class="profile-main">
|
||||||
<div class="remark-row">
|
<div class="remark-row">
|
||||||
<h3>{{ remarkName }}</h3>
|
<h3>{{ remarkName }}</h3>
|
||||||
<button type="button" class="icon-button remark-edit-button" title="编辑备注名" @click="openAliasEditor">✏️</button>
|
<button type="button" class="icon-button remark-edit-button" title="编辑备注名" @click="openAliasEditor">✏️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl class="profile-fields">
|
<dl class="profile-fields">
|
||||||
<div>
|
<div>
|
||||||
<dt>用户名</dt>
|
<dt>用户名</dt>
|
||||||
<dd>{{ username }}</dd>
|
<dd>{{ username }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>手机号</dt>
|
<dt>手机号</dt>
|
||||||
<dd>{{ mobile }}</dd>
|
<dd>{{ mobile }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>地区</dt>
|
<dt>地区</dt>
|
||||||
<dd>{{ address }}</dd>
|
<dd>{{ address }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button class="primary-action profile-action" @click="contacts.openChat(contact)">发消息</button>
|
<button class="primary-action profile-action" @click="contacts.openChat(contact)">发消息</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editingAlias" class="modal-backdrop contact-modal-backdrop" @click.self="closeAliasEditor">
|
<div v-if="editingAlias" class="modal-backdrop contact-modal-backdrop" @click.self="closeAliasEditor">
|
||||||
<form class="modal-panel" @submit.prevent="submitAlias">
|
<form class="modal-panel" @submit.prevent="submitAlias">
|
||||||
<header>
|
<header>
|
||||||
<strong>编辑备注名</strong>
|
<strong>编辑备注名</strong>
|
||||||
<button type="button" class="icon-button" title="关闭" @click="closeAliasEditor">×</button>
|
<button type="button" class="icon-button" title="关闭" @click="closeAliasEditor">×</button>
|
||||||
</header>
|
</header>
|
||||||
<label>
|
<label>
|
||||||
<span>备注名</span>
|
<span>备注名</span>
|
||||||
<input v-model.trim="aliasDraft" maxlength="32" autocomplete="off" autofocus>
|
<input v-model.trim="aliasDraft" maxlength="32" autocomplete="off" autofocus>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="aliasError" class="form-error">{{ aliasError }}</p>
|
<p v-if="aliasError" class="form-error">{{ aliasError }}</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="ghost-inline-action" :disabled="aliasSaving" @click="closeAliasEditor">取消</button>
|
<button type="button" class="ghost-inline-action" :disabled="aliasSaving" @click="closeAliasEditor">取消</button>
|
||||||
<button type="submit" class="primary-inline-action" :disabled="aliasSaving">{{ aliasSaving ? '保存中' : '保存' }}</button>
|
<button type="submit" class="primary-inline-action" :disabled="aliasSaving">{{ aliasSaving ? '保存中' : '保存' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="empty-chat">
|
<div v-else class="empty-chat">
|
||||||
<strong>选择一个联系人</strong>
|
<strong>选择一个联系人</strong>
|
||||||
<span>好友列表同步完成后,可以在左侧查看资料</span>
|
<span>好友列表同步完成后,可以在左侧查看资料</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contacts';
|
import { useContactStore } from '@/stores/contacts';
|
||||||
|
|
||||||
const contacts = useContactStore();
|
const contacts = useContactStore();
|
||||||
const contact = computed(() => contacts.activeContact);
|
const contact = computed(() => contacts.activeContact);
|
||||||
const remarkName = computed(() => contact.value?.alias || '未设置');
|
const remarkName = computed(() => contact.value?.alias || '未设置');
|
||||||
const username = computed(() => contact.value?.rawUser?.displayName || contact.value?.rawUser?.name || contact.value?.uid || '');
|
const username = computed(() => contact.value?.rawUser?.displayName || contact.value?.rawUser?.name || contact.value?.uid || '');
|
||||||
const mobile = computed(() => contact.value?.rawUser?.mobile || '未知');
|
const mobile = computed(() => contact.value?.rawUser?.mobile || '未知');
|
||||||
const address = computed(() => contact.value?.rawUser?.address || '未知');
|
const address = computed(() => contact.value?.rawUser?.address || '未知');
|
||||||
const editingAlias = ref(false);
|
const editingAlias = ref(false);
|
||||||
const aliasDraft = ref('');
|
const aliasDraft = ref('');
|
||||||
const aliasSaving = ref(false);
|
const aliasSaving = ref(false);
|
||||||
const aliasError = ref('');
|
const aliasError = ref('');
|
||||||
|
|
||||||
watch(contact, () => {
|
watch(contact, () => {
|
||||||
editingAlias.value = false;
|
editingAlias.value = false;
|
||||||
aliasError.value = '';
|
aliasError.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
function openAliasEditor() {
|
function openAliasEditor() {
|
||||||
aliasDraft.value = contact.value?.alias || '';
|
aliasDraft.value = contact.value?.alias || '';
|
||||||
aliasError.value = '';
|
aliasError.value = '';
|
||||||
editingAlias.value = true;
|
editingAlias.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAliasEditor() {
|
function closeAliasEditor() {
|
||||||
if (aliasSaving.value) {
|
if (aliasSaving.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editingAlias.value = false;
|
editingAlias.value = false;
|
||||||
aliasError.value = '';
|
aliasError.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAlias() {
|
async function submitAlias() {
|
||||||
if (!contact.value) {
|
if (!contact.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
aliasSaving.value = true;
|
aliasSaving.value = true;
|
||||||
aliasError.value = '';
|
aliasError.value = '';
|
||||||
const ok = await contacts.updateContactAlias(contact.value.uid, aliasDraft.value);
|
const ok = await contacts.updateContactAlias(contact.value.uid, aliasDraft.value);
|
||||||
aliasSaving.value = false;
|
aliasSaving.value = false;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
editingAlias.value = false;
|
editingAlias.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
aliasError.value = contacts.actionError || '备注名修改失败';
|
aliasError.value = contacts.actionError || '备注名修改失败';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,79 +1,79 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="conversation-list favorite-list">
|
<aside class="conversation-list favorite-list">
|
||||||
<header class="list-header">
|
<header class="list-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>收藏</h2>
|
<h2>收藏</h2>
|
||||||
<p>{{ conversations.favoriteMessages.length }} 条消息</p>
|
<p>{{ conversations.favoriteMessages.length }} 条消息</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="sidebar-scroll">
|
<div class="sidebar-scroll">
|
||||||
<button
|
<button
|
||||||
v-for="item in conversations.favoriteMessages"
|
v-for="item in conversations.favoriteMessages"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="favorite-item"
|
class="favorite-item"
|
||||||
:class="{ active: activeKey === item.key }"
|
:class="{ active: activeKey === item.key }"
|
||||||
@click="conversations.setActiveFavorite(item.key)"
|
@click="conversations.setActiveFavorite(item.key)"
|
||||||
>
|
>
|
||||||
<span class="favorite-item-icon">★</span>
|
<span class="favorite-item-icon">★</span>
|
||||||
<span class="favorite-item-main">
|
<span class="favorite-item-main">
|
||||||
<strong>{{ favoriteTitle(item) }}</strong>
|
<strong>{{ favoriteTitle(item) }}</strong>
|
||||||
<small>{{ favoritePreview(item) }}</small>
|
<small>{{ favoritePreview(item) }}</small>
|
||||||
</span>
|
</span>
|
||||||
<time>{{ formatFavoriteTime(item.timestamp) }}</time>
|
<time>{{ formatFavoriteTime(item.timestamp) }}</time>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="!conversations.favoriteMessages.length" class="empty-list">
|
<div v-if="!conversations.favoriteMessages.length" class="empty-list">
|
||||||
暂无收藏消息
|
暂无收藏消息
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useConversationStore } from '@/stores/conversations';
|
import { useConversationStore } from '@/stores/conversations';
|
||||||
|
|
||||||
const conversations = useConversationStore();
|
const conversations = useConversationStore();
|
||||||
const activeKey = computed(() => conversations.activeFavorite?.key || '');
|
const activeKey = computed(() => conversations.activeFavorite?.key || '');
|
||||||
|
|
||||||
function favoriteTitle(item) {
|
function favoriteTitle(item) {
|
||||||
if (item.conversationTitle) {
|
if (item.conversationTitle) {
|
||||||
return item.conversationTitle;
|
return item.conversationTitle;
|
||||||
}
|
}
|
||||||
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
|
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
|
||||||
return conversation?.title || item.conversationId || '未知会话';
|
return conversation?.title || item.conversationId || '未知会话';
|
||||||
}
|
}
|
||||||
|
|
||||||
function favoritePreview(item) {
|
function favoritePreview(item) {
|
||||||
if (item.recalled) {
|
if (item.recalled) {
|
||||||
return `[${item.recallStatus || '已撤回'}]`;
|
return `[${item.recallStatus || '已撤回'}]`;
|
||||||
}
|
}
|
||||||
if (item.preview) {
|
if (item.preview) {
|
||||||
return item.preview;
|
return item.preview;
|
||||||
}
|
}
|
||||||
if (item.text) {
|
if (item.text) {
|
||||||
return item.text;
|
return item.text;
|
||||||
}
|
}
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
return '[图片]';
|
return '[图片]';
|
||||||
}
|
}
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file') {
|
||||||
return `[文件] ${item.attachment?.name || '文件'}`;
|
return `[文件] ${item.attachment?.name || '文件'}`;
|
||||||
}
|
}
|
||||||
return '(无文字内容)';
|
return '(无文字内容)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFavoriteTime(timestamp) {
|
function formatFavoriteTime(timestamp) {
|
||||||
const value = Number(timestamp || 0);
|
const value = Number(timestamp || 0);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: false
|
hour12: false
|
||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,202 +1,203 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="favorite-panel">
|
<section class="favorite-panel">
|
||||||
<template v-if="favorite">
|
<template v-if="favorite">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>收藏详情</h2>
|
<h2>收藏详情</h2>
|
||||||
<p>{{ favoriteTitle(favorite) }}</p>
|
<p>{{ favoriteTitle(favorite) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="favorite-detail">
|
<div class="favorite-detail">
|
||||||
<div class="favorite-detail-message" :class="{ recalled: favorite.recalled }">
|
<div class="favorite-detail-message" :class="{ recalled: favorite.recalled }">
|
||||||
<span class="favorite-detail-star">★</span>
|
<span class="favorite-detail-star">★</span>
|
||||||
<div class="favorite-detail-body">
|
<div class="favorite-detail-body">
|
||||||
<span v-if="favorite.recalled" class="recall-mark">{{ favorite.recallStatus || '已撤回' }}</span>
|
<span v-if="favorite.recalled" class="recall-mark">{{ favorite.recallStatus || '已撤回' }}</span>
|
||||||
<a
|
<a
|
||||||
v-if="isImageFavorite(favorite)"
|
v-if="isImageFavorite(favorite)"
|
||||||
class="favorite-detail-image"
|
class="favorite-detail-image"
|
||||||
:href="favorite.attachment.url"
|
:href="imageDisplayUrl(favorite.attachment.url)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
|
<img :src="imageDisplayUrl(favorite.attachment.url)" :alt="favorite.attachment.name || '图片'">
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-else-if="isFileFavorite(favorite)"
|
v-else-if="isFileFavorite(favorite)"
|
||||||
class="message-file favorite-detail-file"
|
class="message-file favorite-detail-file"
|
||||||
:href="favorite.attachment.url"
|
:href="favorite.attachment.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
download
|
download
|
||||||
>
|
>
|
||||||
<span class="message-file-icon">▤</span>
|
<span class="message-file-icon">▤</span>
|
||||||
<span class="message-file-main">
|
<span class="message-file-main">
|
||||||
<strong>{{ favorite.attachment.name || '文件' }}</strong>
|
<strong>{{ favorite.attachment.name || '文件' }}</strong>
|
||||||
<small>{{ formatFileSize(favorite.attachment.size) }}</small>
|
<small>{{ formatFileSize(favorite.attachment.size) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<p v-if="favoriteText(favorite)">{{ favoriteText(favorite) }}</p>
|
<p v-if="favoriteText(favorite)">{{ favoriteText(favorite) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<time>{{ formatFavoriteTime(favorite.timestamp) }}</time>
|
<time>{{ formatFavoriteTime(favorite.timestamp) }}</time>
|
||||||
</div>
|
</div>
|
||||||
<div class="favorite-detail-actions">
|
<div class="favorite-detail-actions">
|
||||||
<span v-if="notice" class="inline-save-status">{{ notice }}</span>
|
<span v-if="notice" class="inline-save-status">{{ notice }}</span>
|
||||||
<span v-else-if="error" class="inline-save-status error">{{ error }}</span>
|
<span v-else-if="error" class="inline-save-status error">{{ error }}</span>
|
||||||
<button type="button" class="ghost-inline-action" @click="copyFavorite">复制</button>
|
<button type="button" class="ghost-inline-action" @click="copyFavorite">复制</button>
|
||||||
<button type="button" class="ghost-inline-action" @click="openConversation">打开原会话</button>
|
<button type="button" class="ghost-inline-action" @click="openConversation">打开原会话</button>
|
||||||
<button type="button" class="danger-inline-action" @click="removeFavorite">取消收藏</button>
|
<button type="button" class="danger-inline-action" @click="removeFavorite">取消收藏</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-chat">
|
<div v-else class="empty-chat">
|
||||||
<strong>暂无收藏消息</strong>
|
<strong>暂无收藏消息</strong>
|
||||||
<span>右键消息气泡选择“收藏”后,会显示在这里</span>
|
<span>右键消息气泡选择“收藏”后,会显示在这里</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useConversationStore } from '@/stores/conversations';
|
import { useConversationStore } from '@/stores/conversations';
|
||||||
import { useNavigationStore } from '@/stores/navigation';
|
import { useNavigationStore } from '@/stores/navigation';
|
||||||
|
import { imageDisplayUrl } from '@/services/mediaProxy';
|
||||||
const conversations = useConversationStore();
|
|
||||||
const navigation = useNavigationStore();
|
const conversations = useConversationStore();
|
||||||
const favorite = computed(() => conversations.activeFavorite);
|
const navigation = useNavigationStore();
|
||||||
const notice = ref('');
|
const favorite = computed(() => conversations.activeFavorite);
|
||||||
const error = ref('');
|
const notice = ref('');
|
||||||
|
const error = ref('');
|
||||||
function clearFeedback() {
|
|
||||||
notice.value = '';
|
function clearFeedback() {
|
||||||
error.value = '';
|
notice.value = '';
|
||||||
}
|
error.value = '';
|
||||||
|
}
|
||||||
function favoriteTitle(item) {
|
|
||||||
if (item.conversationTitle) {
|
function favoriteTitle(item) {
|
||||||
return item.conversationTitle;
|
if (item.conversationTitle) {
|
||||||
}
|
return item.conversationTitle;
|
||||||
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
|
}
|
||||||
return conversation?.title || item.conversationId || '未知会话';
|
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
|
||||||
}
|
return conversation?.title || item.conversationId || '未知会话';
|
||||||
|
}
|
||||||
function favoriteText(item) {
|
|
||||||
return item.text || '';
|
function favoriteText(item) {
|
||||||
}
|
return item.text || '';
|
||||||
|
}
|
||||||
function isImageFavorite(item) {
|
|
||||||
return item?.type === 'image' && item?.attachment?.url;
|
function isImageFavorite(item) {
|
||||||
}
|
return item?.type === 'image' && item?.attachment?.url;
|
||||||
|
}
|
||||||
function isFileFavorite(item) {
|
|
||||||
return item?.type === 'file' && item?.attachment?.url;
|
function isFileFavorite(item) {
|
||||||
}
|
return item?.type === 'file' && item?.attachment?.url;
|
||||||
|
}
|
||||||
function favoriteCopyText(item) {
|
|
||||||
if (!item) {
|
function favoriteCopyText(item) {
|
||||||
return '';
|
if (!item) {
|
||||||
}
|
return '';
|
||||||
if (item.text) {
|
}
|
||||||
return item.text;
|
if (item.text) {
|
||||||
}
|
return item.text;
|
||||||
if (item.recalled) {
|
}
|
||||||
return item.recallStatus || '已撤回';
|
if (item.recalled) {
|
||||||
}
|
return item.recallStatus || '已撤回';
|
||||||
if (isImageFavorite(item)) {
|
}
|
||||||
return item.attachment.url;
|
if (isImageFavorite(item)) {
|
||||||
}
|
return item.attachment.url;
|
||||||
if (isFileFavorite(item)) {
|
}
|
||||||
return `${item.attachment.name || '文件'} ${item.attachment.url}`.trim();
|
if (isFileFavorite(item)) {
|
||||||
}
|
return `${item.attachment.name || '文件'} ${item.attachment.url}`.trim();
|
||||||
return item.preview || '';
|
}
|
||||||
}
|
return item.preview || '';
|
||||||
|
}
|
||||||
function formatFileSize(size) {
|
|
||||||
const bytes = Number(size || 0);
|
function formatFileSize(size) {
|
||||||
if (bytes >= 1024 * 1024) {
|
const bytes = Number(size || 0);
|
||||||
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
|
if (bytes >= 1024 * 1024) {
|
||||||
}
|
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
|
||||||
if (bytes >= 1024) {
|
}
|
||||||
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
|
if (bytes >= 1024) {
|
||||||
}
|
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
|
||||||
return bytes > 0 ? `${bytes} B` : '';
|
}
|
||||||
}
|
return bytes > 0 ? `${bytes} B` : '';
|
||||||
|
}
|
||||||
function formatFavoriteTime(timestamp) {
|
|
||||||
const value = Number(timestamp || 0);
|
function formatFavoriteTime(timestamp) {
|
||||||
if (!value) {
|
const value = Number(timestamp || 0);
|
||||||
return '';
|
if (!value) {
|
||||||
}
|
return '';
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
}
|
||||||
year: 'numeric',
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
month: '2-digit',
|
year: 'numeric',
|
||||||
day: '2-digit',
|
month: '2-digit',
|
||||||
hour: '2-digit',
|
day: '2-digit',
|
||||||
minute: '2-digit',
|
hour: '2-digit',
|
||||||
hour12: false
|
minute: '2-digit',
|
||||||
}).format(value);
|
hour12: false
|
||||||
}
|
}).format(value);
|
||||||
|
}
|
||||||
async function copyFavorite() {
|
|
||||||
clearFeedback();
|
async function copyFavorite() {
|
||||||
const text = favoriteCopyText(favorite.value || {}).trim();
|
clearFeedback();
|
||||||
if (!text) {
|
const text = favoriteCopyText(favorite.value || {}).trim();
|
||||||
error.value = '没有可复制的文字内容';
|
if (!text) {
|
||||||
return;
|
error.value = '没有可复制的文字内容';
|
||||||
}
|
return;
|
||||||
try {
|
}
|
||||||
await writeClipboard(text);
|
try {
|
||||||
notice.value = '已复制';
|
await writeClipboard(text);
|
||||||
} catch (copyError) {
|
notice.value = '已复制';
|
||||||
error.value = '复制失败';
|
} catch (copyError) {
|
||||||
}
|
error.value = '复制失败';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
async function writeClipboard(text) {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
async function writeClipboard(text) {
|
||||||
await navigator.clipboard.writeText(text);
|
if (navigator.clipboard?.writeText) {
|
||||||
return;
|
await navigator.clipboard.writeText(text);
|
||||||
}
|
return;
|
||||||
const element = document.createElement('textarea');
|
}
|
||||||
element.value = text;
|
const element = document.createElement('textarea');
|
||||||
element.setAttribute('readonly', 'readonly');
|
element.value = text;
|
||||||
element.style.position = 'fixed';
|
element.setAttribute('readonly', 'readonly');
|
||||||
element.style.left = '-9999px';
|
element.style.position = 'fixed';
|
||||||
document.body.appendChild(element);
|
element.style.left = '-9999px';
|
||||||
element.select();
|
document.body.appendChild(element);
|
||||||
document.execCommand('copy');
|
element.select();
|
||||||
document.body.removeChild(element);
|
document.execCommand('copy');
|
||||||
}
|
document.body.removeChild(element);
|
||||||
|
}
|
||||||
function openConversation() {
|
|
||||||
clearFeedback();
|
function openConversation() {
|
||||||
const item = favorite.value;
|
clearFeedback();
|
||||||
if (!item?.conversationId) {
|
const item = favorite.value;
|
||||||
error.value = '缺少原会话信息';
|
if (!item?.conversationId) {
|
||||||
return;
|
error.value = '缺少原会话信息';
|
||||||
}
|
return;
|
||||||
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
|
}
|
||||||
if (!conversation) {
|
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
|
||||||
error.value = '原会话暂未加载,请刷新会话后再试';
|
if (!conversation) {
|
||||||
conversations.refreshRemoteConversations();
|
error.value = '原会话暂未加载,请刷新会话后再试';
|
||||||
return;
|
conversations.refreshRemoteConversations();
|
||||||
}
|
return;
|
||||||
navigation.showMessages();
|
}
|
||||||
conversations.setActiveConversation(item.conversationId);
|
navigation.showMessages();
|
||||||
window.setTimeout(() => {
|
conversations.setActiveConversation(item.conversationId);
|
||||||
conversations.focusMessage({
|
window.setTimeout(() => {
|
||||||
id: item.id || item.key,
|
conversations.focusMessage({
|
||||||
messageUid: item.messageUid || item.key
|
id: item.id || item.key,
|
||||||
});
|
messageUid: item.messageUid || item.key
|
||||||
}, 450);
|
});
|
||||||
}
|
}, 450);
|
||||||
|
}
|
||||||
function removeFavorite() {
|
|
||||||
clearFeedback();
|
function removeFavorite() {
|
||||||
const key = favorite.value?.key || '';
|
clearFeedback();
|
||||||
if (!key) {
|
const key = favorite.value?.key || '';
|
||||||
return;
|
if (!key) {
|
||||||
}
|
return;
|
||||||
conversations.removeFavorite(key);
|
}
|
||||||
notice.value = '已取消收藏';
|
conversations.removeFavorite(key);
|
||||||
}
|
notice.value = '已取消收藏';
|
||||||
</script>
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,103 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="conversation-list">
|
<aside class="conversation-list">
|
||||||
<header class="list-header">
|
<header class="list-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>联系人</h2>
|
<h2>联系人</h2>
|
||||||
<p>{{ contacts.contacts.length }} 位好友</p>
|
<p>{{ contacts.contacts.length }} 位好友</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="icon-button" title="添加联系人" @click="openAddContact">+</button>
|
<button class="icon-button" title="添加联系人" @click="openAddContact">+</button>
|
||||||
<button class="icon-button" title="刷新" @click="contacts.refreshContacts({ force: true })">↻</button>
|
<button class="icon-button" title="刷新" @click="contacts.refreshContacts({ force: true })">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input v-model.trim="keyword" placeholder="搜索联系人">
|
<input v-model.trim="keyword" placeholder="搜索联系人">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-scroll">
|
<div class="sidebar-scroll">
|
||||||
<button
|
<button
|
||||||
v-for="item in filteredContacts"
|
v-for="item in filteredContacts"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="conversation-item"
|
class="conversation-item"
|
||||||
:class="{ active: item.id === contacts.activeId }"
|
:class="{ active: item.id === contacts.activeId }"
|
||||||
@click="contacts.setActiveContact(item.id)"
|
@click="contacts.setActiveContact(item.id)"
|
||||||
>
|
>
|
||||||
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
|
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
|
||||||
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
|
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
|
||||||
<span class="conversation-copy">
|
<span class="conversation-copy">
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<small>{{ item.subtitle }}</small>
|
<small>{{ item.subtitle }}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="!filteredContacts.length" class="empty-list">
|
<div v-if="!filteredContacts.length" class="empty-list">
|
||||||
<strong>{{ contacts.loading ? '正在加载' : '暂无联系人' }}</strong>
|
<strong>{{ contacts.loading ? '正在加载' : '暂无联系人' }}</strong>
|
||||||
<span>{{ contacts.loading ? '请稍候' : '好友同步完成后会显示在这里' }}</span>
|
<span>{{ contacts.loading ? '请稍候' : '好友同步完成后会显示在这里' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="addingContact" class="modal-backdrop" @click.self="closeAddContact">
|
<div v-if="addingContact" class="modal-backdrop" @click.self="closeAddContact">
|
||||||
<form class="modal-panel" @submit.prevent="submitAddContact">
|
<form class="modal-panel" @submit.prevent="submitAddContact">
|
||||||
<header>
|
<header>
|
||||||
<strong>添加联系人</strong>
|
<strong>添加联系人</strong>
|
||||||
<button type="button" class="icon-button" title="关闭" @click="closeAddContact">×</button>
|
<button type="button" class="icon-button" title="关闭" @click="closeAddContact">×</button>
|
||||||
</header>
|
</header>
|
||||||
<label>
|
<label>
|
||||||
<span>用户ID / 手机号 / 用户名</span>
|
<span>用户ID / 手机号 / 用户名</span>
|
||||||
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
|
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
|
||||||
</label>
|
</label>
|
||||||
<p v-if="contacts.actionError" class="form-error">{{ contacts.actionError }}</p>
|
<p v-if="contacts.actionError" class="form-error">{{ contacts.actionError }}</p>
|
||||||
<p v-if="contacts.actionInfo" class="form-info">{{ contacts.actionInfo }}</p>
|
<p v-if="contacts.actionInfo" class="form-info">{{ contacts.actionInfo }}</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="ghost-inline-action" @click="closeAddContact">取消</button>
|
<button type="button" class="ghost-inline-action" @click="closeAddContact">取消</button>
|
||||||
<button class="primary-inline-action" :disabled="contacts.loading || !addKeyword">
|
<button class="primary-inline-action" :disabled="contacts.loading || !addKeyword">
|
||||||
{{ contacts.loading ? '添加中...' : '添加' }}
|
{{ contacts.loading ? '添加中...' : '添加' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contacts';
|
import { useContactStore } from '@/stores/contacts';
|
||||||
|
|
||||||
const contacts = useContactStore();
|
const contacts = useContactStore();
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
const addingContact = ref(false);
|
const addingContact = ref(false);
|
||||||
const addKeyword = ref('');
|
const addKeyword = ref('');
|
||||||
|
|
||||||
const filteredContacts = computed(() => {
|
const filteredContacts = computed(() => {
|
||||||
const value = keyword.value.toLowerCase();
|
const value = keyword.value.toLowerCase();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return contacts.contacts;
|
return contacts.contacts;
|
||||||
}
|
}
|
||||||
return contacts.contacts.filter(item => {
|
return contacts.contacts.filter(item => {
|
||||||
return item.title.toLowerCase().includes(value)
|
return item.title.toLowerCase().includes(value)
|
||||||
|| item.subtitle.toLowerCase().includes(value)
|
|| item.subtitle.toLowerCase().includes(value)
|
||||||
|| item.uid.toLowerCase().includes(value);
|
|| item.uid.toLowerCase().includes(value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function openAddContact() {
|
function openAddContact() {
|
||||||
contacts.actionError = '';
|
contacts.actionError = '';
|
||||||
contacts.actionInfo = '';
|
contacts.actionInfo = '';
|
||||||
addKeyword.value = '';
|
addKeyword.value = '';
|
||||||
addingContact.value = true;
|
addingContact.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAddContact() {
|
function closeAddContact() {
|
||||||
addingContact.value = false;
|
addingContact.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAddContact() {
|
async function submitAddContact() {
|
||||||
const added = await contacts.addContact(addKeyword.value);
|
const added = await contacts.addContact(addKeyword.value);
|
||||||
if (added) {
|
if (added) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
addingContact.value = false;
|
addingContact.value = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+22
-21
@@ -1,35 +1,36 @@
|
|||||||
const currentHost = window.location.hostname || '127.0.0.1';
|
const currentHost = window.location.hostname || '127.0.0.1';
|
||||||
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
|
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
|
||||||
const appServerOrigin = `${window.location.protocol}//${currentHost}:8888`;
|
const appServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
|
||||||
const qrAppServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
|
|
||||||
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
|
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
|
||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
appName: 'Web IM',
|
appName: 'Web IM',
|
||||||
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
|
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
|
||||||
appServer: runtimeConfig.appServer || process.env.VUE_APP_APP_SERVER || appServerOrigin,
|
appServer: runtimeConfig.appServer || process.env.VUE_APP_APP_SERVER || appServerOrigin,
|
||||||
qrAppServer: runtimeConfig.qrAppServer || process.env.VUE_APP_QR_APP_SERVER || qrAppServerOrigin,
|
|
||||||
platformConfigApi: runtimeConfig.platformConfigApi || process.env.VUE_APP_PLATFORM_CONFIG_API || (process.env.NODE_ENV === 'development' ? '/local-api/platform_config.php' : '/api/platform_config.php'),
|
platformConfigApi: runtimeConfig.platformConfigApi || process.env.VUE_APP_PLATFORM_CONFIG_API || (process.env.NODE_ENV === 'development' ? '/local-api/platform_config.php' : '/api/platform_config.php'),
|
||||||
registerConfigApi: runtimeConfig.registerConfigApi || process.env.VUE_APP_REGISTER_CONFIG_API || `${adminServerOrigin}/api/register-config`,
|
registerConfigApi: runtimeConfig.registerConfigApi || process.env.VUE_APP_REGISTER_CONFIG_API || `${adminServerOrigin}/api/register-config`,
|
||||||
registerPhoneExistsApi: runtimeConfig.registerPhoneExistsApi || process.env.VUE_APP_REGISTER_PHONE_EXISTS_API || `${adminServerOrigin}/api/register-phone-exists`,
|
registerPhoneExistsApi: runtimeConfig.registerPhoneExistsApi || process.env.VUE_APP_REGISTER_PHONE_EXISTS_API || `${adminServerOrigin}/api/register-phone-exists`,
|
||||||
contactsApi: runtimeConfig.contactsApi || process.env.VUE_APP_CONTACTS_API || (process.env.NODE_ENV === 'development' ? '/local-api/contacts.php' : '/api/contacts.php'),
|
contactsApi: runtimeConfig.contactsApi || process.env.VUE_APP_CONTACTS_API || (process.env.NODE_ENV === 'development' ? '/local-api/contacts.php' : '/api/contacts.php'),
|
||||||
updateAliasApi: runtimeConfig.updateAliasApi || process.env.VUE_APP_UPDATE_ALIAS_API || (process.env.NODE_ENV === 'development' ? '/local-api/update_alias.php' : '/api/update_alias.php'),
|
updateAliasApi: runtimeConfig.updateAliasApi || process.env.VUE_APP_UPDATE_ALIAS_API || (process.env.NODE_ENV === 'development' ? '/local-api/update_alias.php' : '/api/update_alias.php'),
|
||||||
addContactApi: runtimeConfig.addContactApi || process.env.VUE_APP_ADD_CONTACT_API || (process.env.NODE_ENV === 'development' ? '/local-api/add_contact.php' : '/api/add_contact.php'),
|
addContactApi: runtimeConfig.addContactApi || process.env.VUE_APP_ADD_CONTACT_API || (process.env.NODE_ENV === 'development' ? '/local-api/add_contact.php' : '/api/add_contact.php'),
|
||||||
conversationActionsApi: runtimeConfig.conversationActionsApi || process.env.VUE_APP_CONVERSATION_ACTIONS_API || (process.env.NODE_ENV === 'development' ? '/local-api/conversation_actions.php' : '/api/conversation_actions.php'),
|
conversationActionsApi: runtimeConfig.conversationActionsApi || process.env.VUE_APP_CONVERSATION_ACTIONS_API || (process.env.NODE_ENV === 'development' ? '/local-api/conversation_actions.php' : '/api/conversation_actions.php'),
|
||||||
sendMessageApi: runtimeConfig.sendMessageApi || process.env.VUE_APP_SEND_MESSAGE_API || (process.env.NODE_ENV === 'development' ? '/local-api/send_message.php' : '/api/send_message.php'),
|
sendMessageApi: runtimeConfig.sendMessageApi || process.env.VUE_APP_SEND_MESSAGE_API || (process.env.NODE_ENV === 'development' ? '/local-api/send_message.php' : '/api/send_message.php'),
|
||||||
messageActionApi: runtimeConfig.messageActionApi || process.env.VUE_APP_MESSAGE_ACTION_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_action.php' : '/api/message_action.php'),
|
messageActionApi: runtimeConfig.messageActionApi || process.env.VUE_APP_MESSAGE_ACTION_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_action.php' : '/api/message_action.php'),
|
||||||
messageDetailApi: runtimeConfig.messageDetailApi || process.env.VUE_APP_MESSAGE_DETAIL_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_detail.php' : '/api/message_detail.php'),
|
messageDetailApi: runtimeConfig.messageDetailApi || process.env.VUE_APP_MESSAGE_DETAIL_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_detail.php' : '/api/message_detail.php'),
|
||||||
messagesApi: runtimeConfig.messagesApi || process.env.VUE_APP_MESSAGES_API || (process.env.NODE_ENV === 'development' ? '/local-api/messages.php' : '/api/messages.php'),
|
messagesApi: runtimeConfig.messagesApi || process.env.VUE_APP_MESSAGES_API || (process.env.NODE_ENV === 'development' ? '/local-api/messages.php' : '/api/messages.php'),
|
||||||
profileApi: runtimeConfig.profileApi || process.env.VUE_APP_PROFILE_API || (process.env.NODE_ENV === 'development' ? '/local-api/profile.php' : '/api/profile.php'),
|
profileApi: runtimeConfig.profileApi || process.env.VUE_APP_PROFILE_API || (process.env.NODE_ENV === 'development' ? '/local-api/profile.php' : '/api/profile.php'),
|
||||||
changePasswordApi: runtimeConfig.changePasswordApi || process.env.VUE_APP_CHANGE_PASSWORD_API || `${appServerOrigin}/auth/changePassword`,
|
changePasswordApi: runtimeConfig.changePasswordApi || process.env.VUE_APP_CHANGE_PASSWORD_API || `${appServerOrigin}/change_pwd`,
|
||||||
logoutApi: runtimeConfig.logoutApi || process.env.VUE_APP_LOGOUT_API || `${appServerOrigin}/auth/logout`,
|
resetPasswordApi: runtimeConfig.resetPasswordApi || process.env.VUE_APP_RESET_PASSWORD_API || `${appServerOrigin}/reset_pwd`,
|
||||||
imServerHost: runtimeConfig.imServerHost || process.env.VUE_APP_IM_SERVER_HOST || currentHost,
|
imServerHost: runtimeConfig.imServerHost || process.env.VUE_APP_IM_SERVER_HOST || currentHost,
|
||||||
imRoutePort: Number(runtimeConfig.imRoutePort || process.env.VUE_APP_IM_ROUTE_PORT || 18000),
|
imRoutePort: Number(runtimeConfig.imRoutePort || process.env.VUE_APP_IM_ROUTE_PORT || 18000),
|
||||||
useWss: runtimeConfig.useWss ?? process.env.VUE_APP_USE_WSS === 'true',
|
useWss: runtimeConfig.useWss ?? process.env.VUE_APP_USE_WSS === 'true',
|
||||||
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
|
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
|
||||||
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
|
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
|
||||||
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
|
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
|
||||||
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
|
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 15000),
|
||||||
|
sseEnabled: runtimeConfig.sseEnabled ?? process.env.VUE_APP_SSE_ENABLED === 'true',
|
||||||
|
sseTicketApi: runtimeConfig.sseTicketApi || process.env.VUE_APP_SSE_TICKET_API || '/api/sse_ticket.php',
|
||||||
|
sseUrl: runtimeConfig.sseUrl || process.env.VUE_APP_SSE_URL || '/ws-events',
|
||||||
platform: 5,
|
platform: 5,
|
||||||
clientIdStorageKey: 'qmt-web-im-client-id',
|
clientIdStorageKey: 'qmt-web-im-client-id',
|
||||||
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import './styles/base.css';
|
import './styles/base.css';
|
||||||
|
|
||||||
createApp(App).use(createPinia()).mount('#app');
|
createApp(App).use(createPinia()).mount('#app');
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { appConfig } from '@/config';
|
|
||||||
|
|
||||||
const AES_KEY = 'xczx2026!@#secKe';
|
|
||||||
const ENCRYPTION_EXCLUDE_PATHS = ['/avatar/', '/f/', '/amr2mp3', '/appVersion/check'];
|
|
||||||
const AUTH_STORAGE_KEY = 'qmt-web-im-app-auth-v1';
|
|
||||||
const AUTH_ERROR_CODE = 13;
|
|
||||||
const REFRESH_ERROR_CODE = 20045;
|
|
||||||
|
|
||||||
let importedCryptoKeyPromise = null;
|
|
||||||
let refreshPromise = null;
|
|
||||||
|
|
||||||
function storageScope() {
|
|
||||||
return appConfig.appServer || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAuthStore() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) || '{}');
|
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAuthStore(store) {
|
|
||||||
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(store));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAuthBundle(bundle = {}) {
|
|
||||||
const accessToken = String(bundle.accessToken || '').trim();
|
|
||||||
const refreshToken = String(bundle.refreshToken || '').trim();
|
|
||||||
if (!accessToken || !refreshToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
tokenType: String(bundle.tokenType || 'Bearer').trim() || 'Bearer',
|
|
||||||
accessTokenExpireIn: Number(bundle.accessTokenExpireIn || 0) || 0,
|
|
||||||
accessTokenExpireAt: Number(bundle.accessTokenExpireAt || 0) || 0,
|
|
||||||
refreshTokenExpireIn: Number(bundle.refreshTokenExpireIn || 0) || 0,
|
|
||||||
refreshTokenExpireAt: Number(bundle.refreshTokenExpireAt || 0) || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredAppAuth() {
|
|
||||||
const store = readAuthStore();
|
|
||||||
return normalizeAuthBundle(store[storageScope()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function persistAppAuth(bundle) {
|
|
||||||
const normalized = normalizeAuthBundle(bundle);
|
|
||||||
const store = readAuthStore();
|
|
||||||
if (!normalized) {
|
|
||||||
delete store[storageScope()];
|
|
||||||
writeAuthStore(store);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
store[storageScope()] = normalized;
|
|
||||||
writeAuthStore(store);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearStoredAppAuth() {
|
|
||||||
persistAppAuth(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFormData(value) {
|
|
||||||
return typeof FormData !== 'undefined' && value instanceof FormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function absoluteUrl(url) {
|
|
||||||
return new URL(url, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldEncrypt(url) {
|
|
||||||
const pathname = absoluteUrl(url).pathname;
|
|
||||||
return !ENCRYPTION_EXCLUDE_PATHS.some(path => pathname === path || pathname.startsWith(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAttachAuth(config) {
|
|
||||||
return !config.skipAuth;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textEncoder() {
|
|
||||||
return new TextEncoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function textDecoder() {
|
|
||||||
return new TextDecoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToBase64(bytes) {
|
|
||||||
let binary = '';
|
|
||||||
bytes.forEach(byte => {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
});
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64ToBytes(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let index = 0; index < binary.length; index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCryptoKey() {
|
|
||||||
if (!importedCryptoKeyPromise) {
|
|
||||||
importedCryptoKeyPromise = window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
textEncoder().encode(AES_KEY),
|
|
||||||
{ name: 'AES-CBC' },
|
|
||||||
false,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return importedCryptoKeyPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptPayload(plainText) {
|
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const key = await getCryptoKey();
|
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-CBC', iv },
|
|
||||||
key,
|
|
||||||
textEncoder().encode(plainText)
|
|
||||||
);
|
|
||||||
return `${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decryptPayload(cipherText) {
|
|
||||||
const [ivBase64, encryptedBase64] = String(cipherText || '').split(':', 2);
|
|
||||||
if (!ivBase64 || !encryptedBase64) {
|
|
||||||
throw new Error('Encrypted payload format is invalid');
|
|
||||||
}
|
|
||||||
const key = await getCryptoKey();
|
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
|
||||||
{ name: 'AES-CBC', iv: base64ToBytes(ivBase64) },
|
|
||||||
key,
|
|
||||||
base64ToBytes(encryptedBase64)
|
|
||||||
);
|
|
||||||
return textDecoder().decode(decrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBusinessResponse(payload) {
|
|
||||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
message: 'success',
|
|
||||||
data: payload
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decodeResponsePayload(response) {
|
|
||||||
const encrypted = String(response.headers?.['x-encrypted'] || '').toLowerCase() === 'true';
|
|
||||||
if (encrypted && typeof response.data === 'string') {
|
|
||||||
const plainText = await decryptPayload(response.data);
|
|
||||||
response.data = normalizeBusinessResponse(JSON.parse(plainText));
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
response.data = normalizeBusinessResponse(response.data);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = axios.create({
|
|
||||||
withCredentials: false
|
|
||||||
});
|
|
||||||
|
|
||||||
client.interceptors.request.use(async config => {
|
|
||||||
const nextConfig = { ...config };
|
|
||||||
const url = absoluteUrl(nextConfig.url || '').toString();
|
|
||||||
|
|
||||||
if (shouldAttachAuth(nextConfig)) {
|
|
||||||
const auth = getStoredAppAuth();
|
|
||||||
if (auth?.accessToken) {
|
|
||||||
nextConfig.headers = {
|
|
||||||
...(nextConfig.headers || {}),
|
|
||||||
Authorization: `${auth.tokenType || 'Bearer'} ${auth.accessToken}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasFormData(nextConfig.data) && nextConfig.method?.toLowerCase() !== 'get' && shouldEncrypt(url)) {
|
|
||||||
const plainBody = JSON.stringify(nextConfig.data ?? {});
|
|
||||||
nextConfig.data = await encryptPayload(plainBody);
|
|
||||||
nextConfig.headers = {
|
|
||||||
...(nextConfig.headers || {}),
|
|
||||||
'Content-Type': 'text/plain;charset=UTF-8',
|
|
||||||
'X-Timestamp': `${Math.floor(Date.now() / 1000)}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
client.interceptors.response.use(async response => decodeResponsePayload(response));
|
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
|
||||||
if (refreshPromise) {
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
const auth = getStoredAppAuth();
|
|
||||||
if (!auth?.refreshToken) {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
throw new Error('登录状态已失效,请重新登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshPromise = client.post(`${appConfig.appServer}/auth/refreshToken`, {
|
|
||||||
refreshToken: auth.refreshToken
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
}).then(response => {
|
|
||||||
const payload = response.data || {};
|
|
||||||
if (Number(payload.code) !== 0 || !payload.data) {
|
|
||||||
if (Number(payload.code) === REFRESH_ERROR_CODE) {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
}
|
|
||||||
throw new Error(payload.message || '刷新登录状态失败');
|
|
||||||
}
|
|
||||||
persistAppAuth(payload.data);
|
|
||||||
return payload.data;
|
|
||||||
}).finally(() => {
|
|
||||||
refreshPromise = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(config) {
|
|
||||||
const response = await client.request(config);
|
|
||||||
const payload = response.data || {};
|
|
||||||
if (Number(payload.code) === AUTH_ERROR_CODE && shouldAttachAuth(config) && !config._retriedAfterRefresh) {
|
|
||||||
await refreshAccessToken();
|
|
||||||
return request({
|
|
||||||
...config,
|
|
||||||
_retriedAfterRefresh: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appServerRequest(config) {
|
|
||||||
return request(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appServerGet(url, config = {}) {
|
|
||||||
return request({
|
|
||||||
...config,
|
|
||||||
method: 'get',
|
|
||||||
url
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appServerPost(url, data = {}, config = {}) {
|
|
||||||
return request({
|
|
||||||
...config,
|
|
||||||
method: 'post',
|
|
||||||
url,
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { appConfig } from '@/config';
|
|
||||||
import { HttpImClient } from './httpImClient';
|
|
||||||
import axios from 'axios';
|
|
||||||
import {
|
|
||||||
appServerGet,
|
|
||||||
appServerPost,
|
|
||||||
clearStoredAppAuth,
|
|
||||||
getStoredAppAuth,
|
|
||||||
persistAppAuth
|
|
||||||
} from './appServerClient';
|
|
||||||
|
|
||||||
function inferAccountType(account) {
|
|
||||||
return String(account || '').includes('@') ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapData(response) {
|
|
||||||
return response.data?.data ?? response.data?.result ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapMessage(response, fallback) {
|
|
||||||
return response.data?.message || response.data?.msg || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DocumentedHttpImClient extends HttpImClient {
|
|
||||||
unsupported(feature) {
|
|
||||||
throw new Error(`当前对接文档未提供“${feature}”接口,已禁用旧本地代理链路`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithPassword(account, password, type = inferAccountType(account)) {
|
|
||||||
const response = await appServerPost(`${appConfig.appServer}/auth/login_pwd`, {
|
|
||||||
type,
|
|
||||||
number: account,
|
|
||||||
password,
|
|
||||||
platform: appConfig.platform,
|
|
||||||
clientId: this.getClientId()
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '登录失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
persistAppAuth(unwrapData(response));
|
|
||||||
return unwrapData(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async requestAuthCode(email) {
|
|
||||||
const response = await appServerPost(`${appConfig.appServer}/auth/sendEmailCode`, {
|
|
||||||
email
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '验证码发送失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return unwrapData(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithCode(account, code) {
|
|
||||||
void account;
|
|
||||||
void code;
|
|
||||||
throw new Error('接口文档未提供验证码登录接口,请使用密码登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
async register({ type, number, code = '', password, invitationCode = '' }) {
|
|
||||||
const response = await appServerPost(`${appConfig.appServer}/auth/register`, {
|
|
||||||
type,
|
|
||||||
number,
|
|
||||||
code,
|
|
||||||
password,
|
|
||||||
clientId: this.getClientId(),
|
|
||||||
platform: appConfig.platform,
|
|
||||||
invitationCode
|
|
||||||
}, {
|
|
||||||
skipAuth: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '注册失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
persistAppAuth(unwrapData(response));
|
|
||||||
return unwrapData(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPcSession(userId = '') {
|
|
||||||
const response = await axios.post(`${appConfig.qrAppServer}/pc_session`, {
|
|
||||||
flag: 1,
|
|
||||||
device_name: 'Web IM',
|
|
||||||
userId,
|
|
||||||
clientId: this.getClientId(),
|
|
||||||
platform: appConfig.platform
|
|
||||||
}, { withCredentials: false });
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '二维码登录会话创建失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return unwrapData(response) || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithPcSession(token) {
|
|
||||||
const url = `${appConfig.qrAppServer}/session_login/${encodeURIComponent(token)}`;
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await axios.post(url, null, { withCredentials: false });
|
|
||||||
} catch (postError) {
|
|
||||||
response = await axios.get(url, { withCredentials: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.persistAppToken(url, response);
|
|
||||||
|
|
||||||
const payload = response.data || {};
|
|
||||||
const result = payload.result ?? payload.data ?? null;
|
|
||||||
|
|
||||||
if (Number(payload.code) === 0 && result) {
|
|
||||||
persistAppAuth(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([0, 8, 9, 10, 13, 18, 20042].includes(Number(payload.code))) {
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
data: result
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(payload.message || '扫码登录失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
async changePassword({ oldPassword, newPassword }) {
|
|
||||||
const response = await appServerPost(appConfig.changePasswordApi, {
|
|
||||||
oldPassword,
|
|
||||||
newPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '密码修改失败'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
|
||||||
void mobile;
|
|
||||||
void resetCode;
|
|
||||||
void newPassword;
|
|
||||||
throw new Error('接口文档未提供重置密码接口,请联系服务端确认流程');
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
const auth = getStoredAppAuth();
|
|
||||||
if (!auth?.refreshToken) {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await appServerPost(appConfig.logoutApi, {
|
|
||||||
refreshToken: auth.refreshToken
|
|
||||||
});
|
|
||||||
if (response.data?.code !== 0) {
|
|
||||||
throw new Error(unwrapMessage(response, '退出登录失败'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearStoredAppAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchRemoteConversations() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchRemoteMessages() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getServerContactList() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProfile() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTextMessage() {
|
|
||||||
this.unsupported('发送消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendAttachmentMessage() {
|
|
||||||
this.unsupported('发送附件消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async addContact() {
|
|
||||||
this.unsupported('添加联系人');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateContactAlias() {
|
|
||||||
this.unsupported('联系人备注');
|
|
||||||
}
|
|
||||||
|
|
||||||
async createGroup() {
|
|
||||||
this.unsupported('创建群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async joinGroup() {
|
|
||||||
this.unsupported('加入群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async broadcastMessage() {
|
|
||||||
this.unsupported('群发消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroupDetail() {
|
|
||||||
this.unsupported('群详情');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroupMembers() {
|
|
||||||
this.unsupported('群成员');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroupAlias() {
|
|
||||||
this.unsupported('群备注');
|
|
||||||
}
|
|
||||||
|
|
||||||
async leaveGroup() {
|
|
||||||
this.unsupported('退出群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async dismissGroup() {
|
|
||||||
this.unsupported('解散群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async transferGroup() {
|
|
||||||
this.unsupported('转让群组');
|
|
||||||
}
|
|
||||||
|
|
||||||
async setGroupAdmins() {
|
|
||||||
this.unsupported('设置群管理员');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroupProfile() {
|
|
||||||
this.unsupported('更新群资料');
|
|
||||||
}
|
|
||||||
|
|
||||||
async kickGroupMembers() {
|
|
||||||
this.unsupported('移除群成员');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGroupMuteSetting() {
|
|
||||||
this.unsupported('群禁言设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateProfile() {
|
|
||||||
this.unsupported('更新个人资料');
|
|
||||||
}
|
|
||||||
|
|
||||||
async recallMessage() {
|
|
||||||
this.unsupported('撤回消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async editMessage() {
|
|
||||||
this.unsupported('编辑消息');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessageDetail() {
|
|
||||||
this.unsupported('消息详情');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1088
-1067
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,17 @@
|
|||||||
import { appConfig } from '@/config';
|
import { appConfig } from '@/config';
|
||||||
import { HttpImClient } from './httpImClient';
|
import { HttpImClient } from './httpImClient';
|
||||||
import { LongConnectionImClient } from './longConnectionImClient';
|
import { LongConnectionImClient } from './longConnectionImClient';
|
||||||
import { DocumentedHttpImClient } from './documentedHttpImClient';
|
|
||||||
|
class UnsupportedWildfireImClient {
|
||||||
class UnsupportedWildfireImClient {
|
constructor() {
|
||||||
constructor() {
|
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
|
||||||
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export const imClient = appConfig.sdkMode === 'wildfire'
|
export const imClient = appConfig.sdkMode === 'wildfire'
|
||||||
? new UnsupportedWildfireImClient()
|
? new UnsupportedWildfireImClient()
|
||||||
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
|
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
|
||||||
? new LongConnectionImClient()
|
? new LongConnectionImClient()
|
||||||
: new DocumentedHttpImClient();
|
: new HttpImClient();
|
||||||
|
|
||||||
export {
|
export { HttpImClient, LongConnectionImClient, UnsupportedWildfireImClient as WildfireImClient };
|
||||||
DocumentedHttpImClient,
|
|
||||||
HttpImClient,
|
|
||||||
LongConnectionImClient,
|
|
||||||
UnsupportedWildfireImClient as WildfireImClient
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,236 +1,236 @@
|
|||||||
import { appConfig } from '@/config';
|
import { appConfig } from '@/config';
|
||||||
import { HttpImClient } from './httpImClient';
|
import { HttpImClient } from './httpImClient';
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 25000;
|
const HEARTBEAT_INTERVAL_MS = 25000;
|
||||||
const RECONNECT_MIN_MS = 1000;
|
const RECONNECT_MIN_MS = 1000;
|
||||||
const RECONNECT_MAX_MS = 15000;
|
const RECONNECT_MAX_MS = 15000;
|
||||||
|
|
||||||
function buildWsUrl() {
|
function buildWsUrl() {
|
||||||
if (appConfig.wsUrl) {
|
if (appConfig.wsUrl) {
|
||||||
return appConfig.wsUrl;
|
return appConfig.wsUrl;
|
||||||
}
|
}
|
||||||
const protocol = appConfig.useWss ? 'wss' : 'ws';
|
const protocol = appConfig.useWss ? 'wss' : 'ws';
|
||||||
const host = appConfig.imServerHost || window.location.hostname;
|
const host = appConfig.imServerHost || window.location.hostname;
|
||||||
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
|
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
|
||||||
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
|
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
|
||||||
return `${protocol}://${host}${port}${path}`;
|
return `${protocol}://${host}${port}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConversationId(payload) {
|
function normalizeConversationId(payload) {
|
||||||
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
|
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
|
||||||
return payload.conversationId || (target ? `single-${target}` : '');
|
return payload.conversationId || (target ? `single-${target}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingMessage(payload, currentUserId) {
|
function normalizeIncomingMessage(payload, currentUserId) {
|
||||||
const item = payload.message || payload;
|
const item = payload.message || payload;
|
||||||
const from = item.from || item.sender || item.fromUser || '';
|
const from = item.from || item.sender || item.fromUser || '';
|
||||||
const target = item.target || item.to || item.receiver || '';
|
const target = item.target || item.to || item.receiver || '';
|
||||||
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
|
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
|
||||||
const conversationId = item.conversationId || normalizeConversationId({
|
const conversationId = item.conversationId || normalizeConversationId({
|
||||||
...item,
|
...item,
|
||||||
target: direction === 'out' ? target : from
|
target: direction === 'out' ? target : from
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = item.status || item.deliveryStatus || 'sent';
|
const status = item.status || item.deliveryStatus || 'sent';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
|
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
|
||||||
messageUid: item.messageUid || item.uid || item.messageId || '',
|
messageUid: item.messageUid || item.uid || item.messageId || '',
|
||||||
conversationId,
|
conversationId,
|
||||||
from,
|
from,
|
||||||
target,
|
target,
|
||||||
direction,
|
direction,
|
||||||
type: item.type || 'text',
|
type: item.type || 'text',
|
||||||
text: item.text || item.content || item.searchableContent || '',
|
text: item.text || item.content || item.searchableContent || '',
|
||||||
status,
|
status,
|
||||||
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
|
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
|
||||||
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
|
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
|
||||||
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
|
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
|
||||||
raw: item
|
raw: item
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LongConnectionImClient extends HttpImClient {
|
export class LongConnectionImClient extends HttpImClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.wsUrl = buildWsUrl();
|
this.wsUrl = buildWsUrl();
|
||||||
this.heartbeatTimer = null;
|
this.heartbeatTimer = null;
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.manualClose = false;
|
this.manualClose = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSdkVersion() {
|
getSdkVersion() {
|
||||||
return 'JC-LC/0.1';
|
return 'JC-LC/0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUseMessagePolling() {
|
shouldUseMessagePolling() {
|
||||||
return !this.connected;
|
return !this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureSdk() {
|
async ensureSdk() {
|
||||||
if (typeof WebSocket === 'undefined') {
|
if (typeof WebSocket === 'undefined') {
|
||||||
throw new Error('当前浏览器不支持 WebSocket 长连接');
|
throw new Error('当前浏览器不支持 WebSocket 长连接');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(userId, token) {
|
connect(userId, token) {
|
||||||
this.currentUser = { userId, token };
|
this.currentUser = { userId, token };
|
||||||
this.manualClose = false;
|
this.manualClose = false;
|
||||||
this.openSocket();
|
this.openSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.manualClose = true;
|
this.manualClose = true;
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
this.clearHeartbeatTimer();
|
this.clearHeartbeatTimer();
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
super.disconnect();
|
super.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
openSocket() {
|
openSocket() {
|
||||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
this.sendAuthFrame();
|
this.sendAuthFrame();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.socket?.readyState === WebSocket.CONNECTING) {
|
if (this.socket?.readyState === WebSocket.CONNECTING) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.socket = new WebSocket(this.wsUrl);
|
this.socket = new WebSocket(this.wsUrl);
|
||||||
this.socket.binaryType = 'arraybuffer';
|
this.socket.binaryType = 'arraybuffer';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.onopen = () => {
|
this.socket.onopen = () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
|
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
|
||||||
this.sendAuthFrame();
|
this.sendAuthFrame();
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
this.refreshConversationList();
|
this.refreshConversationList();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket.onmessage = event => {
|
this.socket.onmessage = event => {
|
||||||
this.handleFrame(event.data);
|
this.handleFrame(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket.onerror = () => {
|
this.socket.onerror = () => {
|
||||||
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket.onclose = () => {
|
this.socket.onclose = () => {
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.clearHeartbeatTimer();
|
this.clearHeartbeatTimer();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
if (this.manualClose) {
|
if (this.manualClose) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAuthFrame() {
|
sendAuthFrame() {
|
||||||
this.sendFrame({
|
this.sendFrame({
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
userId: this.currentUser?.userId || '',
|
userId: this.currentUser?.userId || '',
|
||||||
token: this.currentUser?.token || '',
|
token: this.currentUser?.token || '',
|
||||||
clientId: this.getClientId(),
|
clientId: this.getClientId(),
|
||||||
platform: appConfig.platform,
|
platform: appConfig.platform,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFrame(frame) {
|
sendFrame(frame) {
|
||||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.socket.send(JSON.stringify(frame));
|
this.socket.send(JSON.stringify(frame));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFrame(rawData) {
|
handleFrame(rawData) {
|
||||||
if (rawData instanceof ArrayBuffer) {
|
if (rawData instanceof ArrayBuffer) {
|
||||||
this.emit('rawFrameReceived', rawData);
|
this.emit('rawFrameReceived', rawData);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = payload.type || payload.event || payload.cmd || '';
|
const type = payload.type || payload.event || payload.cmd || '';
|
||||||
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
|
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'contactsChanged') {
|
if (type === 'contactsChanged') {
|
||||||
this.emit('contactsChanged', payload.contacts || []);
|
this.emit('contactsChanged', payload.contacts || []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'conversationsChanged') {
|
if (type === 'conversationsChanged') {
|
||||||
this.emit('conversationsChanged', payload.conversations || []);
|
this.emit('conversationsChanged', payload.conversations || []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'messages' && Array.isArray(payload.messages)) {
|
if (type === 'messages' && Array.isArray(payload.messages)) {
|
||||||
payload.messages
|
payload.messages
|
||||||
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
|
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach(message => this.emit('messageReceived', message));
|
.forEach(message => this.emit('messageReceived', message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
|
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
|
||||||
if (message) {
|
if (message) {
|
||||||
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
|
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startHeartbeat() {
|
startHeartbeat() {
|
||||||
this.clearHeartbeatTimer();
|
this.clearHeartbeatTimer();
|
||||||
this.heartbeatTimer = window.setInterval(() => {
|
this.heartbeatTimer = window.setInterval(() => {
|
||||||
this.sendFrame({ type: 'ping', timestamp: Date.now() });
|
this.sendFrame({ type: 'ping', timestamp: Date.now() });
|
||||||
}, HEARTBEAT_INTERVAL_MS);
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHeartbeatTimer() {
|
clearHeartbeatTimer() {
|
||||||
if (this.heartbeatTimer) {
|
if (this.heartbeatTimer) {
|
||||||
window.clearInterval(this.heartbeatTimer);
|
window.clearInterval(this.heartbeatTimer);
|
||||||
this.heartbeatTimer = null;
|
this.heartbeatTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleReconnect() {
|
scheduleReconnect() {
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
|
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
|
||||||
this.reconnectAttempts += 1;
|
this.reconnectAttempts += 1;
|
||||||
this.reconnectTimer = window.setTimeout(() => {
|
this.reconnectTimer = window.setTimeout(() => {
|
||||||
this.openSocket();
|
this.openSocket();
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearReconnectTimer() {
|
clearReconnectTimer() {
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
window.clearTimeout(this.reconnectTimer);
|
window.clearTimeout(this.reconnectTimer);
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
function configuredProxyHosts() {
|
||||||
|
const configured = window.JC_WEB_IM_CONFIG?.mediaProxyHosts;
|
||||||
|
const values = Array.isArray(configured)
|
||||||
|
? configured
|
||||||
|
: String(configured || '').split(/[\s,]+/);
|
||||||
|
return new Set(
|
||||||
|
values
|
||||||
|
.map(value => String(value || '').trim().toLowerCase().replace(/\.$/, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageDisplayUrl(value) {
|
||||||
|
const source = String(value || '').trim();
|
||||||
|
if (!source || source.startsWith('blob:') || source.startsWith('data:')) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(source, window.location.origin);
|
||||||
|
if (url.protocol !== 'https:' || !configuredProxyHosts().has(url.hostname.toLowerCase())) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
const proxyUrl = new URL('/api/media_proxy.php', window.location.origin);
|
||||||
|
proxyUrl.searchParams.set('url', url.toString());
|
||||||
|
return proxyUrl.toString();
|
||||||
|
} catch (error) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { appConfig } from '@/config';
|
||||||
|
import { imClient } from '@/services/imClient';
|
||||||
|
|
||||||
|
class RealtimeNotificationClient {
|
||||||
|
constructor() {
|
||||||
|
this.source = null;
|
||||||
|
this.userId = '';
|
||||||
|
this.handler = null;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.generation = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected() {
|
||||||
|
return this.source?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(userId, handler) {
|
||||||
|
this.stop();
|
||||||
|
if (!appConfig.sseEnabled || !userId || typeof window.WebSocket !== 'function') return;
|
||||||
|
this.userId = userId;
|
||||||
|
this.handler = handler;
|
||||||
|
this.generation += 1;
|
||||||
|
this.open(this.generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.generation += 1;
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
if (this.source) this.source.close();
|
||||||
|
this.source = null;
|
||||||
|
this.userId = '';
|
||||||
|
this.handler = null;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(generation) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(appConfig.sseTicketApi, { userId: this.userId }, {
|
||||||
|
withCredentials: false,
|
||||||
|
headers: imClient.getAppAuthHeaders(appConfig.appServer)
|
||||||
|
});
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
if (response.data?.code !== 0) throw new Error(response.data?.message || '实时通知凭证申请失败');
|
||||||
|
const ticket = response.data?.result?.ticket || '';
|
||||||
|
if (!ticket) throw new Error('实时通知凭证为空');
|
||||||
|
const separator = appConfig.sseUrl.includes('?') ? '&' : '?';
|
||||||
|
const endpoint = `${appConfig.sseUrl}${separator}ticket=${encodeURIComponent(ticket)}`;
|
||||||
|
const socketUrl = endpoint.startsWith('ws://') || endpoint.startsWith('wss://')
|
||||||
|
? endpoint
|
||||||
|
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||||
|
const source = new WebSocket(socketUrl);
|
||||||
|
this.source = source;
|
||||||
|
source.onopen = () => {
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
};
|
||||||
|
source.onmessage = event => {
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
let payload = {};
|
||||||
|
try { payload = JSON.parse(event.data || '{}'); } catch (error) { payload = {}; }
|
||||||
|
if (payload.type !== 'message-notification') return;
|
||||||
|
this.handler?.(payload);
|
||||||
|
};
|
||||||
|
source.onerror = () => {
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
source.close();
|
||||||
|
if (this.source === source) this.source = null;
|
||||||
|
this.scheduleReconnect(generation);
|
||||||
|
};
|
||||||
|
source.onclose = () => {
|
||||||
|
if (generation !== this.generation || this.source !== source) return;
|
||||||
|
this.source = null;
|
||||||
|
this.scheduleReconnect(generation);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation) this.scheduleReconnect(generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect(generation) {
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
const delay = Math.min(30000, 1000 * (2 ** Math.min(this.reconnectAttempt, 5)));
|
||||||
|
this.reconnectAttempt += 1;
|
||||||
|
this.reconnectTimer = window.setTimeout(() => this.open(generation), delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const realtimeNotifications = new RealtimeNotificationClient();
|
||||||
+233
-233
@@ -1,233 +1,233 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { imClient } from '@/services/imClient';
|
import { imClient } from '@/services/imClient';
|
||||||
import { useConversationStore } from '@/stores/conversations';
|
import { useConversationStore } from '@/stores/conversations';
|
||||||
import { useNavigationStore } from '@/stores/navigation';
|
import { useNavigationStore } from '@/stores/navigation';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
const CONTACT_CACHE_MS = 15000;
|
const CONTACT_CACHE_MS = 15000;
|
||||||
|
|
||||||
function normalizeContacts(contacts) {
|
function normalizeContacts(contacts) {
|
||||||
const unique = new Map();
|
const unique = new Map();
|
||||||
contacts.forEach(contact => {
|
contacts.forEach(contact => {
|
||||||
const uid = String(contact.uid || contact.id || '').trim();
|
const uid = String(contact.uid || contact.id || '').trim();
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...contact,
|
...contact,
|
||||||
id: String(contact.id || uid),
|
id: String(contact.id || uid),
|
||||||
uid
|
uid
|
||||||
};
|
};
|
||||||
unique.set(uid, {
|
unique.set(uid, {
|
||||||
...(unique.get(uid) || {}),
|
...(unique.get(uid) || {}),
|
||||||
...normalized
|
...normalized
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Array.from(unique.values()).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
|
return Array.from(unique.values()).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useContactStore = defineStore('contacts', {
|
export const useContactStore = defineStore('contacts', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
contacts: [],
|
contacts: [],
|
||||||
activeId: '',
|
activeId: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
eventsBound: false,
|
eventsBound: false,
|
||||||
refreshSerial: 0,
|
refreshSerial: 0,
|
||||||
refreshPromise: null,
|
refreshPromise: null,
|
||||||
loadedForUserId: '',
|
loadedForUserId: '',
|
||||||
loadedAt: 0,
|
loadedAt: 0,
|
||||||
actionError: '',
|
actionError: '',
|
||||||
actionInfo: ''
|
actionInfo: ''
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
activeContact(state) {
|
activeContact(state) {
|
||||||
return state.contacts.find(item => item.id === state.activeId) || state.contacts[0] || null;
|
return state.contacts.find(item => item.id === state.activeId) || state.contacts[0] || null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
bindImEvents() {
|
bindImEvents() {
|
||||||
if (this.eventsBound) {
|
if (this.eventsBound) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.eventsBound = true;
|
this.eventsBound = true;
|
||||||
imClient.on('contactsChanged', contacts => {
|
imClient.on('contactsChanged', contacts => {
|
||||||
if (!contacts.length && this.contacts.length) {
|
if (!contacts.length && this.contacts.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.contacts = normalizeContacts(contacts);
|
this.contacts = normalizeContacts(contacts);
|
||||||
if (!this.contacts.find(item => item.id === this.activeId)) {
|
if (!this.contacts.find(item => item.id === this.activeId)) {
|
||||||
this.activeId = this.contacts[0]?.id || '';
|
this.activeId = this.contacts[0]?.id || '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async refreshContacts(options = {}) {
|
async refreshContacts(options = {}) {
|
||||||
this.bindImEvents();
|
this.bindImEvents();
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const requestUserId = session.userId;
|
const requestUserId = session.userId;
|
||||||
const force = Boolean(options.force);
|
const force = Boolean(options.force);
|
||||||
if (!requestUserId) {
|
if (!requestUserId) {
|
||||||
this.clearContacts();
|
this.clearContacts();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (!force
|
if (!force
|
||||||
&& this.contacts.length
|
&& this.contacts.length
|
||||||
&& this.loadedForUserId === requestUserId
|
&& this.loadedForUserId === requestUserId
|
||||||
&& Date.now() - this.loadedAt < CONTACT_CACHE_MS
|
&& Date.now() - this.loadedAt < CONTACT_CACHE_MS
|
||||||
) {
|
) {
|
||||||
return this.contacts;
|
return this.contacts;
|
||||||
}
|
}
|
||||||
if (this.refreshPromise && !force) {
|
if (this.refreshPromise && !force) {
|
||||||
return this.refreshPromise;
|
return this.refreshPromise;
|
||||||
}
|
}
|
||||||
const serial = this.refreshSerial + 1;
|
const serial = this.refreshSerial + 1;
|
||||||
this.refreshSerial = serial;
|
this.refreshSerial = serial;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
this.refreshPromise = (async () => {
|
this.refreshPromise = (async () => {
|
||||||
this.contacts = normalizeContacts(imClient.refreshContactList());
|
this.contacts = normalizeContacts(imClient.refreshContactList());
|
||||||
if (!this.contacts.find(item => item.id === this.activeId)) {
|
if (!this.contacts.find(item => item.id === this.activeId)) {
|
||||||
this.activeId = this.contacts[0]?.id || '';
|
this.activeId = this.contacts[0]?.id || '';
|
||||||
}
|
}
|
||||||
if (!this.contacts.length || force) {
|
if (!this.contacts.length || force) {
|
||||||
const serverContacts = await imClient.getServerContactList(requestUserId);
|
const serverContacts = await imClient.getServerContactList(requestUserId);
|
||||||
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
|
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
|
||||||
return this.contacts;
|
return this.contacts;
|
||||||
}
|
}
|
||||||
this.contacts = normalizeContacts(serverContacts);
|
this.contacts = normalizeContacts(serverContacts);
|
||||||
this.loadedForUserId = requestUserId;
|
this.loadedForUserId = requestUserId;
|
||||||
this.loadedAt = Date.now();
|
this.loadedAt = Date.now();
|
||||||
if (!this.contacts.find(item => item.id === this.activeId)) {
|
if (!this.contacts.find(item => item.id === this.activeId)) {
|
||||||
this.activeId = this.contacts[0]?.id || '';
|
this.activeId = this.contacts[0]?.id || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
|
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const latest = imClient.getContactList(false);
|
const latest = imClient.getContactList(false);
|
||||||
if (latest.length || !this.contacts.length) {
|
if (latest.length || !this.contacts.length) {
|
||||||
this.contacts = normalizeContacts(latest);
|
this.contacts = normalizeContacts(latest);
|
||||||
this.loadedForUserId = requestUserId;
|
this.loadedForUserId = requestUserId;
|
||||||
this.loadedAt = Date.now();
|
this.loadedAt = Date.now();
|
||||||
if (!this.contacts.find(item => item.id === this.activeId)) {
|
if (!this.contacts.find(item => item.id === this.activeId)) {
|
||||||
this.activeId = this.contacts[0]?.id || '';
|
this.activeId = this.contacts[0]?.id || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1200);
|
}, 1200);
|
||||||
return this.contacts;
|
return this.contacts;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.refreshPromise;
|
return await this.refreshPromise;
|
||||||
} finally {
|
} finally {
|
||||||
if (serial === this.refreshSerial) {
|
if (serial === this.refreshSerial) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.refreshPromise = null;
|
this.refreshPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async addContact(keyword) {
|
async addContact(keyword) {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const value = String(keyword || '').trim();
|
const value = String(keyword || '').trim();
|
||||||
this.actionError = '';
|
this.actionError = '';
|
||||||
this.actionInfo = '';
|
this.actionInfo = '';
|
||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
this.actionError = '请先登录';
|
this.actionError = '请先登录';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!value) {
|
if (!value) {
|
||||||
this.actionError = '请输入用户ID、手机号或用户名';
|
this.actionError = '请输入用户ID、手机号或用户名';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const contact = await imClient.addContact(session.userId, value, '添加联系人');
|
const contact = await imClient.addContact(session.userId, value, '添加联系人');
|
||||||
if (contact) {
|
if (contact) {
|
||||||
this.contacts = normalizeContacts([...this.contacts, contact]);
|
this.contacts = normalizeContacts([...this.contacts, contact]);
|
||||||
this.activeId = contact.id;
|
this.activeId = contact.id;
|
||||||
}
|
}
|
||||||
this.actionInfo = '联系人已添加';
|
this.actionInfo = '联系人已添加';
|
||||||
await this.refreshContacts({ force: true });
|
await this.refreshContacts({ force: true });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.actionError = error.message || '添加联系人失败';
|
this.actionError = error.message || '添加联系人失败';
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateContactAlias(contactId, alias) {
|
async updateContactAlias(contactId, alias) {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const targetId = String(contactId || '').trim();
|
const targetId = String(contactId || '').trim();
|
||||||
const nextAlias = String(alias || '').trim();
|
const nextAlias = String(alias || '').trim();
|
||||||
this.actionError = '';
|
this.actionError = '';
|
||||||
this.actionInfo = '';
|
this.actionInfo = '';
|
||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
this.actionError = '请先登录';
|
this.actionError = '请先登录';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
this.actionError = '缺少联系人ID';
|
this.actionError = '缺少联系人ID';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedAlias = await imClient.updateContactAlias(session.userId, targetId, nextAlias);
|
const savedAlias = await imClient.updateContactAlias(session.userId, targetId, nextAlias);
|
||||||
this.contacts = normalizeContacts(this.contacts.map(item => {
|
this.contacts = normalizeContacts(this.contacts.map(item => {
|
||||||
if (item.uid !== targetId && item.id !== targetId) {
|
if (item.uid !== targetId && item.id !== targetId) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
const rawUser = {
|
const rawUser = {
|
||||||
...(item.rawUser || {}),
|
...(item.rawUser || {}),
|
||||||
friendAlias: savedAlias
|
friendAlias: savedAlias
|
||||||
};
|
};
|
||||||
const rawFriend = {
|
const rawFriend = {
|
||||||
...(item.rawFriend || {}),
|
...(item.rawFriend || {}),
|
||||||
alias: savedAlias
|
alias: savedAlias
|
||||||
};
|
};
|
||||||
const title = savedAlias || rawUser.displayName || rawUser.name || item.uid;
|
const title = savedAlias || rawUser.displayName || rawUser.name || item.uid;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
title,
|
title,
|
||||||
avatarText: title.slice(0, 1).toUpperCase(),
|
avatarText: title.slice(0, 1).toUpperCase(),
|
||||||
alias: savedAlias,
|
alias: savedAlias,
|
||||||
rawFriend,
|
rawFriend,
|
||||||
rawUser
|
rawUser
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
this.loadedAt = Date.now();
|
this.loadedAt = Date.now();
|
||||||
this.actionInfo = '备注名已修改';
|
this.actionInfo = '备注名已修改';
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.actionError = error.message || '备注名修改失败';
|
this.actionError = error.message || '备注名修改失败';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearContacts() {
|
clearContacts() {
|
||||||
this.refreshSerial += 1;
|
this.refreshSerial += 1;
|
||||||
this.contacts = [];
|
this.contacts = [];
|
||||||
this.activeId = '';
|
this.activeId = '';
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.refreshPromise = null;
|
this.refreshPromise = null;
|
||||||
this.loadedForUserId = '';
|
this.loadedForUserId = '';
|
||||||
this.loadedAt = 0;
|
this.loadedAt = 0;
|
||||||
this.actionError = '';
|
this.actionError = '';
|
||||||
this.actionInfo = '';
|
this.actionInfo = '';
|
||||||
},
|
},
|
||||||
setActiveContact(id) {
|
setActiveContact(id) {
|
||||||
this.activeId = id;
|
this.activeId = id;
|
||||||
},
|
},
|
||||||
openChat(contact) {
|
openChat(contact) {
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const conversations = useConversationStore();
|
const conversations = useConversationStore();
|
||||||
const navigation = useNavigationStore();
|
const navigation = useNavigationStore();
|
||||||
const conversation = imClient.createSingleConversation(contact.uid, contact);
|
const conversation = imClient.createSingleConversation(contact.uid, contact);
|
||||||
conversations.upsertConversation(conversation);
|
conversations.upsertConversation(conversation);
|
||||||
conversations.setActiveConversation(conversation.id);
|
conversations.setActiveConversation(conversation.id);
|
||||||
navigation.showMessages();
|
navigation.showMessages();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+1433
-1187
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -1,18 +1,18 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
export const useNavigationStore = defineStore('navigation', {
|
export const useNavigationStore = defineStore('navigation', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
section: 'messages'
|
section: 'messages'
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
showMessages() {
|
showMessages() {
|
||||||
this.section = 'messages';
|
this.section = 'messages';
|
||||||
},
|
},
|
||||||
showContacts() {
|
showContacts() {
|
||||||
this.section = 'contacts';
|
this.section = 'contacts';
|
||||||
},
|
},
|
||||||
showFavorites() {
|
showFavorites() {
|
||||||
this.section = 'favorites';
|
this.section = 'favorites';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-42
@@ -12,10 +12,6 @@ function nextProfilePortrait(profilePortrait, currentPortrait) {
|
|||||||
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
|
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferAccountType(account) {
|
|
||||||
return String(account || '').includes('@') ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@@ -36,14 +32,12 @@ export const useSessionStore = defineStore('session', {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = JSON.parse(raw);
|
const session = JSON.parse(raw);
|
||||||
if (session.appServer !== appConfig.appServer) {
|
if (session.appServer !== appConfig.appServer) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.userId && session.token) {
|
if (session.userId && session.token) {
|
||||||
if (!session.clientId) {
|
if (!session.clientId) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
@@ -55,6 +49,9 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.portrait = normalizeStoredPortrait(session.portrait);
|
this.portrait = normalizeStoredPortrait(session.portrait);
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
imClient.restoreClientId(session.clientId);
|
imClient.restoreClientId(session.clientId);
|
||||||
|
if (session.localOnly) {
|
||||||
|
this.connectionStatusText = '本地调试登录';
|
||||||
|
}
|
||||||
this.connect();
|
this.connect();
|
||||||
this.refreshProfile();
|
this.refreshProfile();
|
||||||
}
|
}
|
||||||
@@ -62,12 +59,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loginWithPassword(mobile, password) {
|
||||||
async loginWithPassword(account, password, type = inferAccountType(account)) {
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
try {
|
try {
|
||||||
const result = await imClient.loginWithPassword(account, password, type);
|
const result = await imClient.loginWithPassword(mobile, password);
|
||||||
this.applyLoginResult(result);
|
this.applyLoginResult(result);
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -76,12 +72,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loginWithCode(mobile, code) {
|
||||||
async loginWithCode(account, code) {
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
try {
|
try {
|
||||||
const result = await imClient.loginWithCode(account, code);
|
const result = await imClient.loginWithCode(mobile, code);
|
||||||
this.applyLoginResult(result);
|
this.applyLoginResult(result);
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,7 +85,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async completePcSessionLogin(result) {
|
async completePcSessionLogin(result) {
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
@@ -103,15 +97,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async registerWithPhone(mobile, code) {
|
||||||
async register(payload) {
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
try {
|
try {
|
||||||
const result = await imClient.register(payload);
|
return await imClient.registerWithPhone(mobile, code);
|
||||||
this.applyLoginResult(result);
|
|
||||||
await this.connect();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message || '注册失败';
|
this.error = error.message || '注册失败';
|
||||||
return null;
|
return null;
|
||||||
@@ -119,27 +109,28 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applyLoginResult(result) {
|
applyLoginResult(result) {
|
||||||
this.userId = result.userId || result.uid || result.id || '';
|
this.userId = result.userId || result.uid || result.id || '';
|
||||||
this.token = result.token || result.imToken || '';
|
this.token = result.token || result.imToken || '';
|
||||||
this.displayName = result.displayName || result.userName || result.name || this.userId;
|
this.displayName = result.displayName || result.userName || result.name || this.userId;
|
||||||
this.portrait = normalizeStoredPortrait(result.portrait);
|
this.portrait = normalizeStoredPortrait(result.portrait);
|
||||||
this.isAuthenticated = Boolean(this.userId && this.token);
|
this.isAuthenticated = Boolean(this.userId && this.token);
|
||||||
this.persistSession();
|
if (result.localOnly) {
|
||||||
|
this.connectionStatusText = '本地调试登录';
|
||||||
|
}
|
||||||
|
this.persistSession(Boolean(result.localOnly));
|
||||||
},
|
},
|
||||||
|
persistSession(localOnly = false) {
|
||||||
persistSession() {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
token: this.token,
|
token: this.token,
|
||||||
displayName: this.displayName,
|
displayName: this.displayName,
|
||||||
portrait: this.portrait,
|
portrait: this.portrait,
|
||||||
clientId: imClient.getClientId(),
|
clientId: imClient.getClientId(),
|
||||||
|
localOnly,
|
||||||
appServer: appConfig.appServer
|
appServer: appConfig.appServer
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
applyProfile(profile) {
|
applyProfile(profile) {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return;
|
return;
|
||||||
@@ -149,7 +140,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
|
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
|
||||||
this.persistSession();
|
this.persistSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshProfile() {
|
async refreshProfile() {
|
||||||
if (!this.userId) {
|
if (!this.userId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -162,7 +152,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveProfile({ displayName, portraitFile }) {
|
async saveProfile({ displayName, portraitFile }) {
|
||||||
if (!this.userId) {
|
if (!this.userId) {
|
||||||
throw new Error('请先登录');
|
throw new Error('请先登录');
|
||||||
@@ -174,21 +163,17 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.applyProfile(profile);
|
this.applyProfile(profile);
|
||||||
return profile;
|
return profile;
|
||||||
},
|
},
|
||||||
|
|
||||||
async changePassword({ oldPassword, newPassword }) {
|
async changePassword({ oldPassword, newPassword }) {
|
||||||
if (!this.userId) {
|
if (!this.userId) {
|
||||||
throw new Error('请先登录');
|
throw new Error('请先登录');
|
||||||
}
|
}
|
||||||
await imClient.changePassword({ oldPassword, newPassword });
|
await imClient.changePassword({ oldPassword, newPassword });
|
||||||
await this.logout();
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
||||||
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
|
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
if (!this.userId || !this.token) {
|
if (!this.userId || !this.token) {
|
||||||
return;
|
return;
|
||||||
@@ -198,7 +183,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.bindImEvents();
|
this.bindImEvents();
|
||||||
await imClient.connect(this.userId, this.token);
|
await imClient.connect(this.userId, this.token);
|
||||||
},
|
},
|
||||||
|
|
||||||
bindImEvents() {
|
bindImEvents() {
|
||||||
if (this.eventsBound) {
|
if (this.eventsBound) {
|
||||||
return;
|
return;
|
||||||
@@ -209,17 +193,10 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
|
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
logout() {
|
||||||
async logout() {
|
imClient.disconnect();
|
||||||
try {
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
await imClient.logout?.();
|
this.$reset();
|
||||||
} catch (error) {
|
|
||||||
this.error = error.message || '退出登录失败';
|
|
||||||
} finally {
|
|
||||||
imClient.disconnect();
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
this.$reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+2886
-2942
File diff suppressed because it is too large
Load Diff
+51
-51
@@ -1,51 +1,51 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
outputDir: 'dist',
|
outputDir: 'dist',
|
||||||
publicPath: './',
|
publicPath: './',
|
||||||
transpileDependencies: true,
|
transpileDependencies: true,
|
||||||
configureWebpack: {
|
configureWebpack: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
|
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
|
||||||
'legacy-config': path.resolve(__dirname, '../src/config.js')
|
'legacy-config': path.resolve(__dirname, '../src/config.js')
|
||||||
},
|
},
|
||||||
fallback: {
|
fallback: {
|
||||||
path: false,
|
path: false,
|
||||||
fs: false,
|
fs: false,
|
||||||
assert: false,
|
assert: false,
|
||||||
util: false,
|
util: false,
|
||||||
os: false,
|
os: false,
|
||||||
crypto: false,
|
crypto: false,
|
||||||
buffer: require.resolve('buffer/')
|
buffer: require.resolve('buffer/')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new webpack.ProvidePlugin({
|
new webpack.ProvidePlugin({
|
||||||
process: 'process/browser',
|
process: 'process/browser',
|
||||||
Buffer: ['buffer', 'Buffer']
|
Buffer: ['buffer', 'Buffer']
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
allowedHosts: 'all',
|
allowedHosts: 'all',
|
||||||
client: {
|
client: {
|
||||||
overlay: false
|
overlay: false
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/local-api': {
|
'/local-api': {
|
||||||
target: 'http://127.0.0.1:8024',
|
target: 'http://127.0.0.1:8024',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: {
|
pathRewrite: {
|
||||||
'^/local-api': '/api'
|
'^/local-api': '/api'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
chainWebpack(config) {
|
chainWebpack(config) {
|
||||||
config.optimization.concatenateModules(false);
|
config.optimization.concatenateModules(false);
|
||||||
config.optimization.minimize(false);
|
config.optimization.minimize(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user