16 Commits
85 changed files with 35937 additions and 37246 deletions
+8 -9
View File
@@ -1,10 +1,9 @@
node_modules/
.deploy/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.local
.env
node_modules/
.deploy/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.local
.env
.env.*
SESSION_HANDOFF.md
+3 -3
View File
@@ -1,3 +1,3 @@
# JC Web IM 开发记录
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
# JC Web IM 开发记录
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
+58 -58
View File
@@ -1,58 +1,58 @@
# 绿地信通 Web IM
这是从零重写的 Web IM 前端,UI 和状态管理独立于原 `vue-chat`,底层通过授权的 WildFire Web SDK 连接既有 IM 服务。
## 当前工作范围
- 当前工作是在 `jc-web-im` 目录新写 Web 版 IM 客户端。
- 不要默认回到仓库根目录老 `src/` 客户端里继续改,除非明确需要兼容或参考旧实现。
- 中断恢复时优先阅读 `/var/www/im-web/SESSION_HANDOFF.md`。
## 服务地址
- App Server: `http://52.76.146.193:8888`
- IM route: `http://52.76.146.193:80/route`
- WebSocket: `ws://52.76.146.193:8083/ws`
- HTTPS/WSS 当前未启用为默认连接方式。
远端部署路径:
- App Server: `/opt/app-server`
- IM Server: `/opt/im-server`
## 运行
```bash
cd /var/www/im-web/jc-web-im
npm run serve
```
开发访问地址:
```text
http://localhost:8023
http://10.0.0.120:8023
```
## 构建
```bash
npm run build
```
## 部署
生产部署请看 [WEB_DEPLOYMENT.md](./WEB_DEPLOYMENT.md)。当前 Web 版需要 Nginx 托管静态文件,并通过 PHP-FPM 运行 `dist/api/*.php` 代理接口。
## 连接方式
`src/services/imClient.js` 是唯一的 IM 适配层。上层 UI 只调用这里的方法:
- `loginWithPassword`
- `loginWithCode`
- `connect`
- `getConversationList`
- `getMessages`
- `sendTextMessage`
后续如果替换 SDK 或自研协议,只改这个适配层,不需要改 UI 组件。
# 绿地信通 Web IM
这是从零重写的 Web IM 前端,UI 和状态管理独立于原 `vue-chat`,底层通过授权的 WildFire Web SDK 连接既有 IM 服务。
## 当前工作范围
- 当前工作是在 `jc-web-im` 目录新写 Web 版 IM 客户端。
- 不要默认回到仓库根目录老 `src/` 客户端里继续改,除非明确需要兼容或参考旧实现。
- 中断恢复时优先阅读 `/var/www/im-web/SESSION_HANDOFF.md`。
## 服务地址
- App Server: `http://52.76.146.193:8888`
- IM route: `http://52.76.146.193:80/route`
- WebSocket: `ws://52.76.146.193:8083/ws`
- HTTPS/WSS 当前未启用为默认连接方式。
远端部署路径:
- App Server: `/opt/app-server`
- IM Server: `/opt/im-server`
## 运行
```bash
cd /var/www/im-web/jc-web-im
npm run serve
```
开发访问地址:
```text
http://localhost:8023
http://10.0.0.120:8023
```
## 构建
```bash
npm run build
```
## 部署
生产部署请看 [WEB_DEPLOYMENT.md](./WEB_DEPLOYMENT.md)。当前 Web 版需要 Nginx 托管静态文件,并通过 PHP-FPM 运行 `dist/api/*.php` 代理接口。
## 连接方式
`src/services/imClient.js` 是唯一的 IM 适配层。上层 UI 只调用这里的方法:
- `loginWithPassword`
- `loginWithCode`
- `connect`
- `getConversationList`
- `getMessages`
- `sendTextMessage`
后续如果替换 SDK 或自研协议,只改这个适配层,不需要改 UI 组件。
+299
View File
@@ -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
View File
@@ -1,284 +1,277 @@
# Web 版部署说明
本文档说明 `jc-web-im` 的生产部署方式。当前 Web 版由两部分组成:
- Vue 构建后的静态文件,部署到 Web 根目录。
- `dist/api/*.php` PHP 代理接口,负责安全调用本机 `im-server` / `app-server` 相关接口。
浏览器不能直接携带 `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
```
需要配置:
# Web 版部署说明
本文档说明 `jc-web-im` 的生产部署方式。当前 Web 版由两部分组成:
- Vue 构建后的静态文件,部署到 Web 根目录。
- `dist/api/*.php` PHP 代理接口,负责安全调用本机 `im-server` / `app-server` 相关接口。
浏览器不能直接携带 `im-server` admin secret 访问 `18080`,所以联系人列表、备注名修改、消息发送等功能通过 PHP 代理完成。
## 目录
```text
IM_ADMIN_URL=http://127.0.0.1:18080
IM_ADMIN_SECRET=请填写管理端密钥
IM_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4
IM_DB_USER=请填写数据库用户
IM_DB_PASSWORD=请填写数据库密码
IM_APP_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4
IM_APP_DB_USER=请填写应用数据库用户
IM_APP_DB_PASSWORD=请填写应用数据库密码
IM_DB_TIMEZONE=Asia/Bangkok
/var/www/jc-im/jc-web-im
├── public/api/ # PHP 代理源码,构建时复制到 dist/api
├── public/config/ # 运行时配置
├── src/ # Vue 前端源码
├── deploy/ # Nginx 示例配置
└── dist/ # npm run build 后的生产目录
```
请在 PHP-FPM pool、容器 secret 或部署平台的环境变量中配置,禁止把真实密钥提交到仓库。
## 构建
在项目目录执行:
```bash
cd /var/www/jc-im/jc-web-im
npm run build
```
构建完成后,生产文件位于:
```text
/var/www/jc-im/jc-web-im/dist
```
## 运行时配置
运行时配置文件:
```text
dist/config/runtime-config.js
```
示例:
```js
window.JC_WEB_IM_CONFIG = {
appServer: 'http://your-domain:18888',
registerConfigApi: 'http://your-domain:8024/api/register-config',
imServerHost: 'your-domain',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'custom-ws'
};
```
常用字段说明:
| 字段 | 说明 |
| --- | --- |
| `appServer` | Web 登录、验证码等 App Server 地址 |
| `registerConfigApi` | 注册配置接口地址 |
| `imServerHost` | IM HTTP/API 和 WebSocket 使用的主机名 |
| `imRoutePort` | IM route/API 端口,当前为 `18000` |
| `useWss` | 是否使用 WSS |
| `wsPath` | WebSocket 路径 |
| `sdkMode` | 当前使用 `custom-ws` |
联系人相关 PHP 代理默认使用同源地址:
```text
/api/contacts.php
/api/update_alias.php
/api/add_contact.php
/api/send_message.php
/api/messages.php
```
通常不需要在 `runtime-config.js` 里单独配置这些地址。
## Nginx 示例
根据实际 PHP-FPM socket 调整 `fastcgi_pass`,例如 PHP 8.3:
```nginx
server {
listen 8023;
listen [::]:8023;
server_name your-domain _;
root /var/www/jc-im/jc-web-im/dist;
index index.html;
client_max_body_size 100m;
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
}
location = /config/runtime-config.js {
expires -1;
add_header Cache-Control "no-store";
}
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;
expires -1;
add_header Cache-Control "no-store";
}
location / {
etag on;
try_files $uri $uri/ /index.html;
expires 12h;
}
}
```
启用配置后检查并重载:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
## 防火墙建议
生产环境建议:
- 对外开放 Web 端口,例如 `80` / `443` / `8023`。
- `18888` 如需浏览器直接访问 app-server,需要对外开放。
- `18000` 如 WebSocket/route 需要浏览器直连,需要对外开放。
- `18080` admin 端口不要对公网开放,只允许本机 PHP 代理访问。
## 验证
检查 PHP 语法:
```bash
php -l /var/www/jc-im/jc-web-im/dist/api/contacts.php
php -l /var/www/jc-im/jc-web-im/dist/api/update_alias.php
```
检查 Web 页面:
```bash
curl -I http://127.0.0.1:8023/
```
检查联系人接口:
```bash
curl -s -H 'Content-Type: application/json' \
--data '{"userId":"你的用户ID"}' \
http://127.0.0.1:8023/api/contacts.php
```
成功时返回:
```json
{
"code": 0,
"message": "success",
"result": {
"contacts": []
}
}
```
检查备注名修改接口:
```bash
curl -s -H 'Content-Type: application/json' \
--data '{"userId":"你的用户ID","targetId":"联系人ID","alias":"测试备注"}' \
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`。
生产环境建议让 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` 的地址和密钥在:
```text
public/api/im_web_bootstrap.php
```
默认配置:
```php
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
const IM_ADMIN_SECRET = '123456';
```
如果生产环境 admin 端口或 secret 不同,需要同步修改该文件后重新构建。
## 构建
在项目目录执行:
```bash
cd /var/www/jc-im/jc-web-im
npm run build
```
构建完成后,生产文件位于:
```text
/var/www/jc-im/jc-web-im/dist
```
## 运行时配置
运行时配置文件:
```text
dist/config/runtime-config.js
```
示例:
```js
window.JC_WEB_IM_CONFIG = {
appServer: 'http://your-domain:18888',
registerConfigApi: 'http://your-domain:8024/api/register-config',
imServerHost: 'your-domain',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'custom-ws'
};
```
常用字段说明:
| 字段 | 说明 |
| --- | --- |
| `appServer` | Web 登录、验证码等 App Server 地址 |
| `registerConfigApi` | 注册配置接口地址 |
| `imServerHost` | IM HTTP/API 和 WebSocket 使用的主机名 |
| `imRoutePort` | IM route/API 端口,当前为 `18000` |
| `useWss` | 是否使用 WSS |
| `wsPath` | WebSocket 路径 |
| `sdkMode` | 当前使用 `custom-ws` |
联系人相关 PHP 代理默认使用同源地址:
```text
/api/contacts.php
/api/update_alias.php
/api/add_contact.php
/api/send_message.php
/api/messages.php
```
通常不需要在 `runtime-config.js` 里单独配置这些地址。
## Nginx 示例
根据实际 PHP-FPM socket 调整 `fastcgi_pass`,例如 PHP 8.3:
```nginx
server {
listen 8023;
listen [::]:8023;
server_name your-domain _;
root /var/www/jc-im/jc-web-im/dist;
index index.html;
client_max_body_size 100m;
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
}
location = /config/runtime-config.js {
expires -1;
add_header Cache-Control "no-store";
}
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;
expires -1;
add_header Cache-Control "no-store";
}
location / {
etag on;
try_files $uri $uri/ /index.html;
expires 12h;
}
}
```
启用配置后检查并重载:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
## 防火墙建议
生产环境建议:
- 对外开放 Web 端口,例如 `80` / `443` / `8023`。
- `18888` 如需浏览器直接访问 app-server,需要对外开放。
- `18000` 如 WebSocket/route 需要浏览器直连,需要对外开放。
- `18080` admin 端口不要对公网开放,只允许本机 PHP 代理访问。
## 验证
检查 PHP 语法:
```bash
php -l /var/www/jc-im/jc-web-im/dist/api/contacts.php
php -l /var/www/jc-im/jc-web-im/dist/api/update_alias.php
```
检查 Web 页面:
```bash
curl -I http://127.0.0.1:8023/
```
检查联系人接口:
```bash
curl -s -H 'Content-Type: application/json' \
--data '{"userId":"你的用户ID"}' \
http://127.0.0.1:8023/api/contacts.php
```
成功时返回:
```json
{
"code": 0,
"message": "success",
"result": {
"contacts": []
}
}
```
检查备注名修改接口:
```bash
curl -s -H 'Content-Type: application/json' \
--data '{"userId":"你的用户ID","targetId":"联系人ID","alias":"测试备注"}' \
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
View File
@@ -1,3 +1,3 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset']
};
module.exports = {
presets: ['@vue/cli-plugin-babel/preset']
};
+44 -33
View File
@@ -1,33 +1,44 @@
server {
listen 8023;
listen [::]:8023;
server_name winchen.ezpay.asia 10.0.0.120 _;
root /var/www/im-web;
index index.html;
client_max_body_size 100m;
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
}
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;
}
}
server {
listen 8023;
listen [::]:8023;
server_name winchen.ezpay.asia 10.0.0.120 _;
root /var/www/im-web;
index index.html;
client_max_body_size 100m;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types application/json;
location = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
}
location = /api/media_proxy.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;
fastcgi_read_timeout 300;
}
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;
}
}
+75
View File
@@ -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"
+95 -95
View File
@@ -1,95 +1,95 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
+67 -71
View File
@@ -1,71 +1,67 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'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() ?: '联系人服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
try {
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
} catch (Throwable) {
$friendAliasMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = $friendAliasMap[$uid] ?? '';
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'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() ?: '联系人服务暂不可用');
}
+1173 -990
View File
File diff suppressed because it is too large Load Diff
+443 -348
View File
@@ -1,348 +1,443 @@
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
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');
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
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') ?: '');
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$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,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"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',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
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`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`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 '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
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"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$statement->execute(array_merge([$ownerId], $targetIds));
$remarks = [];
foreach ($statement->fetchAll() as $row) {
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
}
return $remarks;
}
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);
}
<?php
declare(strict_types=1);
function webImEnv(string $key, string $fallback = ''): string
{
$runtimeValue = getenv($key);
if ($runtimeValue !== false && $runtimeValue !== '') {
return $runtimeValue;
}
static $env = null;
if ($env === null) {
$env = [];
$paths = array_filter([
getenv('JC_WEB_IM_ENV_FILE') ?: '',
dirname(__DIR__, 2) . '/.env',
'/var/www/im-admin/.env',
'/var/www/im/.env',
]);
foreach ($paths as $path) {
if (!is_readable($path)) {
continue;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$name, $value] = array_map('trim', explode('=', $line, 2));
if ($name === '') {
continue;
}
$value = trim($value);
if (
strlen($value) >= 2
&& (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))
) {
$value = substr($value, 1, -1);
}
$env[$name] = $value;
}
break;
}
}
return $env[$key] ?? $fallback;
}
function webImFirstEnv(array $keys, string $fallback = ''): string
{
foreach ($keys as $key) {
$value = webImEnv($key);
if ($value !== '') {
return $value;
}
}
return $fallback;
}
function webImMysqlDsn(string $host, string $port, string $database): string
{
return sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
}
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'));
define('IM_DB_DSN', webImMysqlDsn(
webImEnv('JC_WEB_IM_DB_HOST', webImEnv('DB_HOST', '127.0.0.1')),
webImEnv('JC_WEB_IM_DB_PORT', webImEnv('DB_PORT', '3306')),
webImEnv('JC_WEB_IM_DB_DATABASE', webImEnv('DB_DATABASE', 'wfchat'))
));
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(
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')),
webImFirstEnv(['JC_WEB_IM_APP_DB_DATABASE', 'DB_DATABASE_2', 'DB_DATABASE2_2'], webImEnv('DB_DATABASE', 'appdata'))
));
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')));
define('IM_DB_TIMEZONE', webImEnv('JC_WEB_IM_DB_TIMEZONE', 'Asia/Bangkok'));
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$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,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"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',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
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`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`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 '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
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"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$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);
}
+58 -58
View File
@@ -1,58 +1,58 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
+236
View File
@@ -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
View File
@@ -1,288 +1,288 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'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'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'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'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
+387 -387
View File
@@ -1,387 +1,387 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'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'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$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);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'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'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$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);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
+1608 -900
View File
File diff suppressed because it is too large Load Diff
+39 -39
View File
@@ -1,39 +1,39 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/logo.png';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/logo.ico';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
+321 -321
View File
@@ -1,321 +1,321 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' 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'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' 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'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
+18 -18
View File
@@ -1,18 +1,18 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'code' => 0,
'message' => 'success',
'data' => [
'methods' => [
'phone' => true,
'email' => false,
'username' => false,
],
'mobile_real_verify' => false,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'code' => 0,
'message' => 'success',
'data' => [
'methods' => [
'phone' => true,
'email' => false,
'username' => false,
],
'mobile_real_verify' => false,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+579 -411
View File
@@ -1,411 +1,579 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
$startMinutes = $startHour * 60 + $startMinute;
$endMinutes = $endHour * 60 + $endMinute;
if ($startMinutes === $endMinutes) {
return true;
}
if ($startMinutes < $endMinutes) {
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
}
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
}
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
{
if ($role === 'owner') {
return false;
}
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
$statement->execute([$groupId]);
$rule = $statement->fetch();
if (!is_array($rule)) {
return false;
}
$mode = (string)$rule['mode'];
if ($mode === 'off') {
return false;
}
if ($mode === 'all_except_admins' && $role === 'admin') {
return false;
}
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
return false;
}
$scheduleType = (string)$rule['schedule_type'];
if ($scheduleType === 'manual') {
return true;
}
if ($scheduleType === 'daily') {
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
}
$startsAtMs = (int)$rule['starts_at_ms'];
$endsAtMs = (int)$rule['ends_at_ms'];
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
}
function safeUploadName(string $fileName): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
$name = trim($name, " .\t\n\r\0\x0B");
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
}
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
if ($attachmentType === 'image') {
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/avif' => 'avif',
'image/heic' => 'heic',
'image/heif' => 'heif',
default => 'jpg',
};
}
if ($extension !== '') {
return $extension;
}
return 'bin';
}
function detectedUploadMime(string $tmpName): string
{
$mime = mime_content_type($tmpName) ?: '';
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
return (string)$imageInfo['mime'];
}
return $mime !== '' ? $mime : 'application/octet-stream';
}
function normalizedImageUploadMime(string $tmpName, string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
return $imageMime;
}
throw new RuntimeException('请选择图片文件');
}
function mediaPublicBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$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 saveMessageAttachmentFile(array $file, string $attachmentType): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
}
$size = (int)($file['size'] ?? 0);
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('媒体存储目录创建失败');
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$extension = uploadExtension($originalName, $mime, $attachmentType);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('文件保存失败');
}
@chmod($targetPath, 0644);
return [
'type' => $attachmentType,
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $mediaType,
];
}
function storeMessageAttachment(
PDO $appDb,
string $messageUid,
string $conversationType,
string $target,
string $sender,
array $attachment,
int $timestamp
): void {
if ($messageUid === '' || empty($attachment['url'])) {
return;
}
$statement = $appDb->prepare(
'insert into `t_web_message_attachments`'
. ' (`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`),'
. ' `url` = values(`url`),'
. ' `name` = values(`name`),'
. ' `mime` = values(`mime`),'
. ' `size_bytes` = values(`size_bytes`)'
);
$statement->execute([
$messageUid,
$conversationType,
$target,
$sender,
(string)$attachment['type'],
(string)$attachment['url'],
(string)$attachment['name'],
(string)$attachment['mime'],
(int)$attachment['size'],
$timestamp,
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
}
$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) {
respond(403, '你不在该群组中');
}
$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);
$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 {
$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);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
}
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $now,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => $nativeError,
]);
}
$attachment = attachmentFromRequest($messageType);
$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);
}
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 0,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'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() ?: '发送失败');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
define(
'WEB_MEDIA_ROOT',
webImEnv(
'JC_WEB_IM_MEDIA_ROOT',
is_dir('/var/www/im/public/media')
? '/var/www/im/public/media'
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
)
);
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
const WEB_MINIO_PROJECT_CODE = '001';
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
$startMinutes = $startHour * 60 + $startMinute;
$endMinutes = $endHour * 60 + $endMinute;
if ($startMinutes === $endMinutes) {
return true;
}
if ($startMinutes < $endMinutes) {
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
}
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
}
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
{
if ($role === 'owner') {
return false;
}
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
$statement->execute([$groupId]);
$rule = $statement->fetch();
if (!is_array($rule)) {
return false;
}
$mode = (string)$rule['mode'];
if ($mode === 'off') {
return false;
}
if ($mode === 'all_except_admins' && $role === 'admin') {
return false;
}
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
return false;
}
$scheduleType = (string)$rule['schedule_type'];
if ($scheduleType === 'manual') {
return true;
}
if ($scheduleType === 'daily') {
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
}
$startsAtMs = (int)$rule['starts_at_ms'];
$endsAtMs = (int)$rule['ends_at_ms'];
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
{
$statement = $db->prepare(
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $sender]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
}
function safeUploadName(string $fileName): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
$name = trim($name, " .\t\n\r\0\x0B");
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
}
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
if ($attachmentType === 'image') {
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/avif' => 'avif',
'image/heic' => 'heic',
'image/heif' => 'heif',
default => 'jpg',
};
}
if ($extension !== '') {
return $extension;
}
return 'bin';
}
function detectedUploadMime(string $tmpName): string
{
$mime = mime_content_type($tmpName) ?: '';
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
return (string)$imageInfo['mime'];
}
return $mime !== '' ? $mime : 'application/octet-stream';
}
function normalizedImageUploadMime(string $tmpName, string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
return $imageMime;
}
throw new RuntimeException('请选择图片文件');
}
function mediaPublicBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
$port = (int)WEB_MEDIA_PUBLIC_PORT;
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
return $scheme . '://' . $host;
}
return $scheme . '://' . $host . ':' . $port;
}
function messageStorageConfig(): array
{
$config = ['enabled' => false, 'url' => ''];
foreach (['t_app_system_conf', 'system_conf'] as $table) {
try {
$statement = imAppDb()->query(
'select `conf_name`, `conf_value` from `' . $table . '`'
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
);
$rows = $statement->fetchAll();
if (!$rows) {
continue;
}
foreach ($rows as $row) {
$name = strtolower((string)($row['conf_name'] ?? ''));
if ($name === 'is_minioplus') {
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
} elseif ($name === 'minio_url') {
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
}
}
break;
} catch (Throwable $error) {
continue;
}
}
return $config;
}
function saveMessageAttachmentToMinio(
array $file,
string $attachmentType,
string $sender,
string $baseUrl
): array {
$size = (int)($file['size'] ?? 0);
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$content = file_get_contents($tmpName);
if ($content === false) {
throw new RuntimeException('读取上传文件失败');
}
$requestBody = json_encode([
'projectCode' => WEB_MINIO_PROJECT_CODE,
'fileScene' => 'temp',
'fileName' => $originalName,
'contentType' => $mime,
'base64Content' => base64_encode($content),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($requestBody)) {
throw new RuntimeException('生成图片上传请求失败');
}
$handle = curl_init($baseUrl . '/project-storage/upload');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
],
CURLOPT_POSTFIELDS => $requestBody,
]);
$responseBody = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
$message = trim((string)($response['message'] ?? ''));
throw new RuntimeException(
$message !== ''
? 'MinIO上传失败:' . $message
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
);
}
return [
'type' => $attachmentType,
'url' => $url,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
];
}
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
}
$size = (int)($file['size'] ?? 0);
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$storage = messageStorageConfig();
if ($storage['enabled']) {
if ($storage['url'] === '') {
throw new RuntimeException('MinIO已开启但未配置服务地址');
}
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('媒体存储目录创建失败');
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$extension = uploadExtension($originalName, $mime, $attachmentType);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('文件保存失败');
}
@chmod($targetPath, 0644);
return [
'type' => $attachmentType,
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $mediaType,
];
}
function storeMessageAttachment(
PDO $appDb,
string $messageUid,
string $conversationType,
string $target,
string $sender,
array $attachment,
int $timestamp
): void {
if ($messageUid === '' || empty($attachment['url'])) {
return;
}
$statement = $appDb->prepare(
'insert into `t_web_message_attachments`'
. ' (`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`),'
. ' `url` = values(`url`),'
. ' `name` = values(`name`),'
. ' `mime` = values(`mime`),'
. ' `size_bytes` = values(`size_bytes`)'
);
$statement->execute([
$messageUid,
$conversationType,
$target,
$sender,
(string)$attachment['type'],
(string)$attachment['url'],
(string)$attachment['name'],
(string)$attachment['mime'],
(int)$attachment['size'],
$timestamp,
]);
}
function attachmentFromRequest(string $messageType, string $sender): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
}
function messagePayloadBody(string $text, ?array $attachment): array
{
$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
View File
@@ -1,61 +1,61 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
+21 -12
View File
@@ -1,12 +1,21 @@
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://winchen.ezpay.asia:8024',
appServer: 'http://winchen.ezpay.asia:8888',
qrAppServer: 'http://winchen.ezpay.asia:18888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
imServerHost: 'winchen.ezpay.asia',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
};
(() => {
const protocol = window.location.protocol || 'http:';
const host = window.location.hostname || '127.0.0.1';
window.JC_WEB_IM_CONFIG = {
adminServer: `${protocol}//${host}:8100`,
appServer: `${protocol}//${host}:8888`,
platformConfigApi: '/api/platform_config.php',
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
imServerHost: host,
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'http',
syncIntervalMs: 15000,
sseEnabled: true,
sseTicketApi: '/api/sse_ticket.php',
sseUrl: '/ws-events'
};
})();
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

+1 -1
View File
@@ -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>
-3213
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
+9903
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
/* harmony export */ R1: function() { return /* binding */ unref; },
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
/* harmony export */ fE: function() { return /* binding */ isShallow; },
/* harmony export */ g8: function() { return /* binding */ isReactive; },
/* harmony export */ hV: function() { return /* binding */ traverse; },
/* harmony export */ hZ: function() { return /* binding */ trigger; },
@@ -23,13 +24,14 @@
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
/* harmony export */ ju: function() { return /* binding */ isProxy; },
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
/* harmony export */ qA: function() { return /* binding */ shallowReadArray; },
/* harmony export */ u4: function() { return /* binding */ track; },
/* harmony export */ uY: function() { return /* binding */ effectScope; },
/* harmony export */ ux: function() { return /* binding */ toRaw; },
/* harmony export */ wB: function() { return /* binding */ watch; },
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
/* 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 toRawType;
/* unused harmony import specifier */ var isFunction;
@@ -1825,6 +1827,10 @@ function traverse(value, depth = Infinity, seen) {
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 */ bo: function() { return /* binding */ withDirectives; },
/* 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 */ uX: function() { return /* binding */ openBlock; },
/* harmony export */ wB: function() { return /* binding */ watch; }
/* 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 resetTracking;
/* 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 isReactive;
/* 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 track;
/* unused harmony import specifier */ var isShallow;
/* unused harmony import specifier */ var isString;
/* unused harmony import specifier */ var isFunction;
/* 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 camelize;
/* unused harmony import specifier */ var capitalize;
/* unused harmony import specifier */ var isObject;
/* unused harmony import specifier */ var isSymbol;
/* unused harmony import specifier */ var toHandlerKey;
/* unused harmony import specifier */ var isGloballyAllowed;
/* unused harmony import specifier */ var NOOP;
/* unused harmony import specifier */ var isPromise;
/* unused harmony import specifier */ var makeMap;
/* unused harmony import specifier */ var isObject;
/* unused harmony import specifier */ var toRawType;
/* unused harmony import specifier */ var hyphenate;
/* 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 resolveTarget = (props, select) => {
const targetSelector = props && props.to;
if (isString(targetSelector)) {
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(targetSelector)) {
if (!select) {
false && 0;
return null;
@@ -3190,7 +3198,7 @@ const resolveTarget = (props, select) => {
return targetSelector;
}
};
const TeleportImpl = (/* unused pure expression or super */ null && ({
const TeleportImpl = {
name: "Teleport",
__isTeleport: true,
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,
hydrate: hydrateTeleport
}));
};
function moveTeleport(vnode, container, parentAnchor, {
o: {
insert
@@ -3408,7 +3416,7 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
}
return vnode.anchor && nextSibling(vnode.anchor);
}
const Teleport = (/* unused pure expression or super */ null && (TeleportImpl));
const Teleport = TeleportImpl;
function updateCssVars(vnode, isDisabled) {
const ctx = vnode.ctx;
if (ctx && ctx.ut) {
@@ -3774,11 +3782,11 @@ function getTransitionRawChildren(children, keepComment = false, parentKey) {
/*! #__NO_SIDE_EFFECTS__ */
// @__NO_SIDE_EFFECTS__
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
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
/* @__PURE__ */
(() => extend({
(() => (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .extend */ .X$)({
name: options.name
}, extraOptions, {
setup: options
@@ -4998,17 +5006,17 @@ function resolve(registry, name) {
function renderList(source, renderItem, cache, index) {
let ret;
const cached = cache && cache[index];
const sourceIsArray = isArray(source);
if (sourceIsArray || isString(source)) {
const sourceIsReactiveArray = sourceIsArray && isReactive(source);
const sourceIsArray = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(source);
if (sourceIsArray || (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(source)) {
const sourceIsReactiveArray = sourceIsArray && (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isReactive */ .g8)(source);
let needsWrap = false;
if (sourceIsReactiveArray) {
needsWrap = !isShallow(source);
source = shallowReadArray(source);
needsWrap = !(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isShallow */ .fE)(source);
source = (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .shallowReadArray */ .qA)(source);
}
ret = new Array(source.length);
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") {
if (false) // removed by dead control flow
@@ -5017,7 +5025,7 @@ function renderList(source, renderItem, cache, index) {
for (let i = 0; i < source; 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]) {
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
} else {
@@ -9183,7 +9191,7 @@ function finishComponentSetup(instance, isSSR, skipOptions) {
try {
applyOptions(instance);
} finally {
(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .resetTracking */ .bl)();
;(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .resetTracking */ .bl)();
reset();
}
}
@@ -9192,7 +9200,7 @@ function finishComponentSetup(instance, isSSR, skipOptions) {
}
const attrsProxyHandlers = false ? 0 : {
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];
}
};
@@ -9275,7 +9283,7 @@ const computed = (getterOrOptions, debugOptions) => {
function h(type, propsOrChildren, children) {
const l = arguments.length;
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)) {
return createVNode(type, null, [propsOrChildren]);
}
@@ -9449,6 +9457,7 @@ const DeprecationTypes = null;
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
/* harmony export */ "EW", 0, /* binding */ computed,
/* harmony export */ "FK", 0, /* binding */ Fragment,
/* harmony export */ "Im", 0, /* binding */ Teleport,
/* harmony export */ "bF", 0, /* binding */ createVNode,
/* harmony export */ "sV", 0, /* binding */ onMounted,
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
@@ -9989,7 +9998,7 @@ const stringifySymbol = (v, i = "") => {
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
"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 h;
/* 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 getTransitionRawChildren;
/* unused harmony import specifier */ var createVNode;
/* unused harmony import specifier */ var nextTick;
/* unused harmony import specifier */ var createHydrationRenderer;
/* unused harmony import specifier */ var isRuntimeOnly;
/* 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 isSet;
/* 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 isSVGTag;
/* unused harmony import specifier */ var isMathMLTag;
@@ -10807,7 +10813,7 @@ function createInvoker(initialValue, instance) {
} else if (e._vts <= invoker.attached) {
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.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
deep: true,
created(el, {
@@ -11580,12 +11586,12 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
number
}
}, vnode) {
const isSetModel = isSet(value);
const isSetModel = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value);
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._assigning = true;
nextTick(() => {
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .nextTick */ .dY)(() => {
el._assigning = false;
});
});
@@ -11608,11 +11614,11 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
setSelected(el, value);
}
}
}));
};
function setSelected(el, value) {
const isMultiple = el.multiple;
const isArrayValue = isArray(value);
if (isMultiple && !isArrayValue && !isSet(value)) {
const isArrayValue = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(value);
if (isMultiple && !isArrayValue && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value)) {
false && 0;
return;
}
@@ -11625,12 +11631,12 @@ function setSelected(el, value) {
if (optionType === "string" || optionType === "number") {
option.selected = value.some(v => String(v) === String(optionValue));
} else {
option.selected = looseIndexOf(value, optionValue) > -1;
option.selected = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseIndexOf */ .u3)(value, optionValue) > -1;
}
} else {
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;
return;
}
@@ -11755,7 +11761,7 @@ const withModifiers = (fn, modifiers) => {
return fn(event, ...args);
});
};
const keyNames = (/* unused pure expression or super */ null && ({
const keyNames = {
esc: "escape",
space: " ",
up: "arrow-up",
@@ -11763,7 +11769,7 @@ const keyNames = (/* unused pure expression or super */ null && ({
right: "arrow-right",
down: "arrow-down",
delete: "backspace"
}));
};
const withKeys = (fn, modifiers) => {
const cache = fn._withKeys || (fn._withKeys = {});
const cacheKey = modifiers.join(".");
@@ -11771,7 +11777,7 @@ const withKeys = (fn, modifiers) => {
if (!("key" in event)) {
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)) {
return fn(event);
}
@@ -11901,7 +11907,9 @@ const initDirectivesForSSR = () => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
/* harmony export */ "D$", 0, /* binding */ withModifiers,
/* 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 */ ]);
@@ -11912,9 +11920,11 @@ const initDirectivesForSSR = () => {
"use strict";
/* 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 */ });
/* 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___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);
@@ -12421,7 +12431,8 @@ const stringifySymbol = (v, i = "") => {
/* harmony export */ "X$", 0, /* binding */ extend,
/* harmony export */ "ZH", 0, /* binding */ capitalize,
/* 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 */ ]);
@@ -14690,7 +14701,7 @@ module.exports = function (it) {
/***/ }),
/***/ 3463:
/***/ 5844:
/***/ (function(module) {
"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:
@@ -18170,7 +18200,7 @@ module.exports = function (key) {
var globalThis = __webpack_require__(4576);
var uncurryThis = __webpack_require__(9504);
var anObjectOrUndefined = __webpack_require__(3972);
var aString = __webpack_require__(3463);
var aString = __webpack_require__(5844);
var hasOwn = __webpack_require__(9297);
var base64Map = __webpack_require__(2804);
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:
@@ -19323,7 +19436,7 @@ if (Uint8Array) $({ target: 'Uint8Array', proto: true, forced: INCORRECT_BEHAVIO
var $ = __webpack_require__(6518);
var globalThis = __webpack_require__(4576);
var aString = __webpack_require__(3463);
var aString = __webpack_require__(5844);
var anUint8Array = __webpack_require__(4154);
var notDetached = __webpack_require__(5169);
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_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 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_18__ = __webpack_require__(144);
@@ -24928,7 +25041,7 @@ const PiniaVuePlugin = function (_Vue) {
/***/ }),
/***/ 7958:
/***/ 3463:
/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
"use strict";
@@ -24964,4 +25077,4 @@ function del(target, key) {
/***/ })
}]);
//# sourceMappingURL=chunk-vendors.cd0faf68.js.map
//# sourceMappingURL=chunk-vendors.86756863.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+651 -651
View File
File diff suppressed because it is too large Load Diff
-11384
View File
File diff suppressed because it is too large Load Diff
+28 -29
View File
@@ -1,29 +1,28 @@
{
"name": "jc-web-im",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@vueuse/core": "^10.8.0",
"axios": "1.6.7",
"jr-qrcode": "^1.1.4",
"pinia": "^2.1.7",
"process": "^0.11.10",
"vue": "3.5.13",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/compiler-sfc": "^3.5.13"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
{
"name": "jc-web-im",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@vueuse/core": "^10.8.0",
"axios": "1.6.7",
"jr-qrcode": "^1.1.4",
"pinia": "^2.1.7",
"vue": "3.5.13",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/compiler-sfc": "^3.5.13"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
+95 -95
View File
@@ -1,95 +1,95 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
+67 -71
View File
@@ -1,71 +1,67 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'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() ?: '联系人服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
try {
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
} catch (Throwable) {
$friendAliasMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = $friendAliasMap[$uid] ?? '';
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'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() ?: '联系人服务暂不可用');
}
File diff suppressed because it is too large Load Diff
+443 -348
View File
@@ -1,348 +1,443 @@
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
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');
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
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') ?: '');
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$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,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"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',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
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`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`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 '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
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"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$statement->execute(array_merge([$ownerId], $targetIds));
$remarks = [];
foreach ($statement->fetchAll() as $row) {
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
}
return $remarks;
}
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);
}
<?php
declare(strict_types=1);
function webImEnv(string $key, string $fallback = ''): string
{
$runtimeValue = getenv($key);
if ($runtimeValue !== false && $runtimeValue !== '') {
return $runtimeValue;
}
static $env = null;
if ($env === null) {
$env = [];
$paths = array_filter([
getenv('JC_WEB_IM_ENV_FILE') ?: '',
dirname(__DIR__, 2) . '/.env',
'/var/www/im-admin/.env',
'/var/www/im/.env',
]);
foreach ($paths as $path) {
if (!is_readable($path)) {
continue;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || strpos($line, '#') === 0 || strpos($line, '=') === false) {
continue;
}
[$name, $value] = array_map('trim', explode('=', $line, 2));
if ($name === '') {
continue;
}
$value = trim($value);
if (
strlen($value) >= 2
&& (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))
) {
$value = substr($value, 1, -1);
}
$env[$name] = $value;
}
break;
}
}
return $env[$key] ?? $fallback;
}
function webImFirstEnv(array $keys, string $fallback = ''): string
{
foreach ($keys as $key) {
$value = webImEnv($key);
if ($value !== '') {
return $value;
}
}
return $fallback;
}
function webImMysqlDsn(string $host, string $port, string $database): string
{
return sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
}
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'));
define('IM_DB_DSN', webImMysqlDsn(
webImEnv('JC_WEB_IM_DB_HOST', webImEnv('DB_HOST', '127.0.0.1')),
webImEnv('JC_WEB_IM_DB_PORT', webImEnv('DB_PORT', '3306')),
webImEnv('JC_WEB_IM_DB_DATABASE', webImEnv('DB_DATABASE', 'wfchat'))
));
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(
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')),
webImFirstEnv(['JC_WEB_IM_APP_DB_DATABASE', 'DB_DATABASE_2', 'DB_DATABASE2_2'], webImEnv('DB_DATABASE', 'appdata'))
));
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')));
define('IM_DB_TIMEZONE', webImEnv('JC_WEB_IM_DB_TIMEZONE', 'Asia/Bangkok'));
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$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,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"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',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
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`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`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 '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
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"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$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);
}
+58 -58
View File
@@ -1,58 +1,58 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
+236
View File
@@ -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
View File
@@ -1,288 +1,288 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'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'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'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'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
+387 -387
View File
@@ -1,387 +1,387 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'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'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$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);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'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'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$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);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
+1608 -900
View File
File diff suppressed because it is too large Load Diff
+39 -39
View File
@@ -1,39 +1,39 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/logo.png';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/logo.ico';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
+321 -321
View File
@@ -1,321 +1,321 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' 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'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' 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'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
+18 -18
View File
@@ -1,18 +1,18 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'code' => 0,
'message' => 'success',
'data' => [
'methods' => [
'phone' => true,
'email' => false,
'username' => false,
],
'mobile_real_verify' => false,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'code' => 0,
'message' => 'success',
'data' => [
'methods' => [
'phone' => true,
'email' => false,
'username' => false,
],
'mobile_real_verify' => false,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+579 -411
View File
@@ -1,411 +1,579 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
$startMinutes = $startHour * 60 + $startMinute;
$endMinutes = $endHour * 60 + $endMinute;
if ($startMinutes === $endMinutes) {
return true;
}
if ($startMinutes < $endMinutes) {
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
}
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
}
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
{
if ($role === 'owner') {
return false;
}
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
$statement->execute([$groupId]);
$rule = $statement->fetch();
if (!is_array($rule)) {
return false;
}
$mode = (string)$rule['mode'];
if ($mode === 'off') {
return false;
}
if ($mode === 'all_except_admins' && $role === 'admin') {
return false;
}
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
return false;
}
$scheduleType = (string)$rule['schedule_type'];
if ($scheduleType === 'manual') {
return true;
}
if ($scheduleType === 'daily') {
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
}
$startsAtMs = (int)$rule['starts_at_ms'];
$endsAtMs = (int)$rule['ends_at_ms'];
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
}
function safeUploadName(string $fileName): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
$name = trim($name, " .\t\n\r\0\x0B");
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
}
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
if ($attachmentType === 'image') {
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/avif' => 'avif',
'image/heic' => 'heic',
'image/heif' => 'heif',
default => 'jpg',
};
}
if ($extension !== '') {
return $extension;
}
return 'bin';
}
function detectedUploadMime(string $tmpName): string
{
$mime = mime_content_type($tmpName) ?: '';
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
return (string)$imageInfo['mime'];
}
return $mime !== '' ? $mime : 'application/octet-stream';
}
function normalizedImageUploadMime(string $tmpName, string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
return $imageMime;
}
throw new RuntimeException('请选择图片文件');
}
function mediaPublicBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$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 saveMessageAttachmentFile(array $file, string $attachmentType): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
}
$size = (int)($file['size'] ?? 0);
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('媒体存储目录创建失败');
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$extension = uploadExtension($originalName, $mime, $attachmentType);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('文件保存失败');
}
@chmod($targetPath, 0644);
return [
'type' => $attachmentType,
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $mediaType,
];
}
function storeMessageAttachment(
PDO $appDb,
string $messageUid,
string $conversationType,
string $target,
string $sender,
array $attachment,
int $timestamp
): void {
if ($messageUid === '' || empty($attachment['url'])) {
return;
}
$statement = $appDb->prepare(
'insert into `t_web_message_attachments`'
. ' (`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`),'
. ' `url` = values(`url`),'
. ' `name` = values(`name`),'
. ' `mime` = values(`mime`),'
. ' `size_bytes` = values(`size_bytes`)'
);
$statement->execute([
$messageUid,
$conversationType,
$target,
$sender,
(string)$attachment['type'],
(string)$attachment['url'],
(string)$attachment['name'],
(string)$attachment['mime'],
(int)$attachment['size'],
$timestamp,
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
}
$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) {
respond(403, '你不在该群组中');
}
$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);
$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 {
$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);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
}
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $now,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => $nativeError,
]);
}
$attachment = attachmentFromRequest($messageType);
$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);
}
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 0,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'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() ?: '发送失败');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
define(
'WEB_MEDIA_ROOT',
webImEnv(
'JC_WEB_IM_MEDIA_ROOT',
is_dir('/var/www/im/public/media')
? '/var/www/im/public/media'
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
)
);
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
const WEB_MINIO_PROJECT_CODE = '001';
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
$startMinutes = $startHour * 60 + $startMinute;
$endMinutes = $endHour * 60 + $endMinute;
if ($startMinutes === $endMinutes) {
return true;
}
if ($startMinutes < $endMinutes) {
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
}
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
}
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
{
if ($role === 'owner') {
return false;
}
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
$statement->execute([$groupId]);
$rule = $statement->fetch();
if (!is_array($rule)) {
return false;
}
$mode = (string)$rule['mode'];
if ($mode === 'off') {
return false;
}
if ($mode === 'all_except_admins' && $role === 'admin') {
return false;
}
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
return false;
}
$scheduleType = (string)$rule['schedule_type'];
if ($scheduleType === 'manual') {
return true;
}
if ($scheduleType === 'daily') {
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
}
$startsAtMs = (int)$rule['starts_at_ms'];
$endsAtMs = (int)$rule['ends_at_ms'];
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
{
$statement = $db->prepare(
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $sender]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
}
function safeUploadName(string $fileName): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
$name = trim($name, " .\t\n\r\0\x0B");
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
}
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
if ($attachmentType === 'image') {
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/avif' => 'avif',
'image/heic' => 'heic',
'image/heif' => 'heif',
default => 'jpg',
};
}
if ($extension !== '') {
return $extension;
}
return 'bin';
}
function detectedUploadMime(string $tmpName): string
{
$mime = mime_content_type($tmpName) ?: '';
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
return (string)$imageInfo['mime'];
}
return $mime !== '' ? $mime : 'application/octet-stream';
}
function normalizedImageUploadMime(string $tmpName, string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return $mime;
}
$imageInfo = @getimagesize($tmpName);
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
return $imageMime;
}
throw new RuntimeException('请选择图片文件');
}
function mediaPublicBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
$port = (int)WEB_MEDIA_PUBLIC_PORT;
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
return $scheme . '://' . $host;
}
return $scheme . '://' . $host . ':' . $port;
}
function messageStorageConfig(): array
{
$config = ['enabled' => false, 'url' => ''];
foreach (['t_app_system_conf', 'system_conf'] as $table) {
try {
$statement = imAppDb()->query(
'select `conf_name`, `conf_value` from `' . $table . '`'
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
);
$rows = $statement->fetchAll();
if (!$rows) {
continue;
}
foreach ($rows as $row) {
$name = strtolower((string)($row['conf_name'] ?? ''));
if ($name === 'is_minioplus') {
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
} elseif ($name === 'minio_url') {
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
}
}
break;
} catch (Throwable $error) {
continue;
}
}
return $config;
}
function saveMessageAttachmentToMinio(
array $file,
string $attachmentType,
string $sender,
string $baseUrl
): array {
$size = (int)($file['size'] ?? 0);
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$content = file_get_contents($tmpName);
if ($content === false) {
throw new RuntimeException('读取上传文件失败');
}
$requestBody = json_encode([
'projectCode' => WEB_MINIO_PROJECT_CODE,
'fileScene' => 'temp',
'fileName' => $originalName,
'contentType' => $mime,
'base64Content' => base64_encode($content),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($requestBody)) {
throw new RuntimeException('生成图片上传请求失败');
}
$handle = curl_init($baseUrl . '/project-storage/upload');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
],
CURLOPT_POSTFIELDS => $requestBody,
]);
$responseBody = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
$message = trim((string)($response['message'] ?? ''));
throw new RuntimeException(
$message !== ''
? 'MinIO上传失败:' . $message
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
);
}
return [
'type' => $attachmentType,
'url' => $url,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
];
}
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
}
$size = (int)($file['size'] ?? 0);
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$storage = messageStorageConfig();
if ($storage['enabled']) {
if ($storage['url'] === '') {
throw new RuntimeException('MinIO已开启但未配置服务地址');
}
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('媒体存储目录创建失败');
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$extension = uploadExtension($originalName, $mime, $attachmentType);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('文件保存失败');
}
@chmod($targetPath, 0644);
return [
'type' => $attachmentType,
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $mediaType,
];
}
function storeMessageAttachment(
PDO $appDb,
string $messageUid,
string $conversationType,
string $target,
string $sender,
array $attachment,
int $timestamp
): void {
if ($messageUid === '' || empty($attachment['url'])) {
return;
}
$statement = $appDb->prepare(
'insert into `t_web_message_attachments`'
. ' (`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`),'
. ' `url` = values(`url`),'
. ' `name` = values(`name`),'
. ' `mime` = values(`mime`),'
. ' `size_bytes` = values(`size_bytes`)'
);
$statement->execute([
$messageUid,
$conversationType,
$target,
$sender,
(string)$attachment['type'],
(string)$attachment['url'],
(string)$attachment['name'],
(string)$attachment['mime'],
(int)$attachment['size'],
$timestamp,
]);
}
function attachmentFromRequest(string $messageType, string $sender): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
}
function messagePayloadBody(string $text, ?array $attachment): array
{
$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
View File
@@ -1,61 +1,61 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
+21 -12
View File
@@ -1,12 +1,21 @@
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://winchen.ezpay.asia:8024',
appServer: 'http://winchen.ezpay.asia:8888',
qrAppServer: 'http://winchen.ezpay.asia:18888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
imServerHost: 'winchen.ezpay.asia',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
};
(() => {
const protocol = window.location.protocol || 'http:';
const host = window.location.hostname || '127.0.0.1';
window.JC_WEB_IM_CONFIG = {
adminServer: `${protocol}//${host}:8100`,
appServer: `${protocol}//${host}:8888`,
platformConfigApi: '/api/platform_config.php',
registerConfigApi: `${protocol}//${host}:8100/api/register-config`,
registerPhoneExistsApi: `${protocol}//${host}:8100/api/register-phone-exists`,
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
View File
@@ -1,14 +1,14 @@
<!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="<%= BASE_URL %>favicon.ico">
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
</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="<%= BASE_URL %>img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico">
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
</head>
<body>
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
<div id="app"></div>
</body>
</html>
+13 -3
View File
@@ -1,17 +1,27 @@
<template>
<LoginView v-if="!session.isAuthenticated" />
<DocumentedShell v-else />
<ChatShell v-else />
</template>
<script setup>
import { onMounted } from 'vue';
import { onMounted, watch } from '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 { useConversationStore } from './stores/conversations';
const session = useSessionStore();
const conversations = useConversationStore();
onMounted(() => {
session.restore();
});
watch(() => session.userId, userId => {
if (userId && session.isAuthenticated) {
conversations.bootstrapDemoData();
return;
}
conversations.clearUserState();
});
</script>
+34 -34
View File
@@ -1,34 +1,34 @@
<template>
<main class="app-shell">
<NavigationRail />
<ConversationList v-if="navigation.section === 'messages'" />
<FavoriteList v-else-if="navigation.section === 'favorites'" />
<ContactList v-else />
<ChatPanel v-if="navigation.section === 'messages'" />
<FavoritePanel v-else-if="navigation.section === 'favorites'" />
<ContactPanel v-else />
</main>
</template>
<script setup>
import { onMounted } from 'vue';
import NavigationRail from './sidebar/NavigationRail.vue';
import ConversationList from './sidebar/ConversationList.vue';
import ContactList from './sidebar/ContactList.vue';
import ChatPanel from './chat/ChatPanel.vue';
import ContactPanel from './contacts/ContactPanel.vue';
import FavoriteList from './favorites/FavoriteList.vue';
import FavoritePanel from './favorites/FavoritePanel.vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
onMounted(() => {
conversations.refreshRemoteConversations();
contacts.refreshContacts();
});
</script>
<template>
<main class="app-shell">
<NavigationRail />
<ConversationList v-if="navigation.section === 'messages'" />
<FavoriteList v-else-if="navigation.section === 'favorites'" />
<ContactList v-else />
<ChatPanel v-if="navigation.section === 'messages'" />
<FavoritePanel v-else-if="navigation.section === 'favorites'" />
<ContactPanel v-else />
</main>
</template>
<script setup>
import { onMounted } from 'vue';
import NavigationRail from './sidebar/NavigationRail.vue';
import ConversationList from './sidebar/ConversationList.vue';
import ContactList from './sidebar/ContactList.vue';
import ChatPanel from './chat/ChatPanel.vue';
import ContactPanel from './contacts/ContactPanel.vue';
import FavoriteList from './favorites/FavoriteList.vue';
import FavoritePanel from './favorites/FavoritePanel.vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
onMounted(() => {
conversations.refreshRemoteConversations();
contacts.refreshContacts();
});
</script>
-186
View File
@@ -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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+297 -223
View File
@@ -1,223 +1,297 @@
<template>
<form
class="message-composer"
:class="{ 'drop-active': dropActive }"
@submit.prevent="submit"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@paste="handlePaste"
>
<div v-if="quoteMessage" class="quote-preview">
<span>
<strong>引用</strong>
{{ quoteMessage.quoteText || quoteMessage.text }}
</span>
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
</div>
<div class="composer-tools">
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
<button
v-for="emoji in emojiList"
:key="emoji"
type="button"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
<textarea
ref="textareaEl"
v-model="text"
:disabled="disabled"
placeholder="输入消息"
rows="3"
@keydown.enter.exact.prevent="submit"
></textarea>
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
</div>
</form>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
quoteMessage: {
type: Object,
default: null
}
});
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
const text = ref('');
const emojiOpen = ref(false);
const dropActive = ref(false);
const textareaEl = ref(null);
const imageInput = ref(null);
const fileInput = ref(null);
let dragDepth = 0;
const emojiList = [
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
];
function submit() {
const value = text.value.trim();
if (!value) {
return;
}
emit('send', value);
text.value = '';
emojiOpen.value = false;
}
function toggleEmojiPicker() {
emojiOpen.value = !emojiOpen.value;
}
function insertEmoji(emoji) {
const element = textareaEl.value;
if (!element) {
text.value += emoji;
return;
}
const start = element.selectionStart ?? text.value.length;
const end = element.selectionEnd ?? text.value.length;
text.value = `${text.value.slice(0, start)}${emoji}${text.value.slice(end)}`;
requestAnimationFrame(() => {
element.focus();
const cursor = start + emoji.length;
element.setSelectionRange(cursor, cursor);
});
}
function chooseImage() {
imageInput.value?.click();
}
function chooseFile() {
fileInput.value?.click();
}
function attachmentTypeForFile(file, fallbackType = 'file') {
if (file?.type?.startsWith('image/')) {
return 'image';
}
return fallbackType === 'image' ? 'image' : 'file';
}
function emitFiles(files, fallbackType = 'file') {
if (props.disabled) {
return;
}
const validFiles = Array.from(files || []).filter(file => file instanceof File && file.size > 0);
if (!validFiles.length) {
return;
}
const caption = text.value.trim();
validFiles.forEach((file, index) => {
emit('send-attachment', {
type: attachmentTypeForFile(file, fallbackType),
file,
text: index === 0 ? caption : ''
});
});
text.value = '';
emojiOpen.value = false;
}
function selectAttachment(event, type) {
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (!files.length || props.disabled) {
return;
}
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');
}
</script>
<template>
<form
class="message-composer"
:class="{ 'drop-active': dropActive }"
@submit.prevent="submit"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@paste="handlePaste"
>
<div v-if="quoteMessage" class="quote-preview">
<span>
<strong>引用</strong>
{{ quoteMessage.quoteText || quoteMessage.text }}
</span>
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
</div>
<div class="composer-tools">
<button type="button" title="表情" aria-label="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button
type="button"
class="composer-tool-button image-tool"
title="图片"
aria-label="图片"
:disabled="disabled"
@click="chooseImage"
>
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2.5"></rect>
<circle cx="8.2" cy="9.2" r="1.6"></circle>
<path d="M6.5 17l4.2-4.7 3.1 3.4 1.8-2 2.9 3.3"></path>
</svg>
</button>
<button
type="button"
class="composer-tool-button file-tool"
title="文件"
aria-label="文件"
:disabled="disabled"
@click="chooseFile"
>
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
<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>
<path d="M14 4v5h5"></path>
<path d="M8.5 13h7"></path>
<path d="M8.5 16.5h5.2"></path>
</svg>
</button>
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
<button
v-for="emoji in emojiList"
:key="emoji"
type="button"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
<div v-if="pendingAttachments.length" class="composer-attachment-preview">
<article
v-for="(attachment, index) in pendingAttachments"
:key="attachment.id"
class="composer-attachment-item"
>
<img
v-if="attachment.type === 'image'"
:src="attachment.previewUrl"
:alt="attachment.file.name || '待发送图片'"
>
<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>
<textarea
ref="textareaEl"
v-model="text"
:disabled="disabled"
placeholder="输入消息"
rows="3"
@keydown.enter.exact.prevent="submit"
></textarea>
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || (!text.trim() && !pendingAttachments.length)">发送</button>
</div>
</form>
</template>
<script setup>
import { onBeforeUnmount, ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
quoteMessage: {
type: Object,
default: null
}
});
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
const text = ref('');
const emojiOpen = ref(false);
const dropActive = ref(false);
const textareaEl = ref(null);
const imageInput = ref(null);
const fileInput = ref(null);
const pendingAttachments = ref([]);
let dragDepth = 0;
const emojiList = [
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
];
function submit() {
const value = text.value.trim();
const attachments = [...pendingAttachments.value];
if (!value && !attachments.length) {
return;
}
if (attachments.length) {
attachments.forEach((attachment, index) => {
emit('send-attachment', {
type: attachment.type,
file: attachment.file,
text: index === 0 ? value : ''
});
});
clearPendingAttachments();
} else {
emit('send', value);
}
text.value = '';
emojiOpen.value = false;
}
function toggleEmojiPicker() {
emojiOpen.value = !emojiOpen.value;
}
function insertEmoji(emoji) {
const element = textareaEl.value;
if (!element) {
text.value += emoji;
return;
}
const start = element.selectionStart ?? text.value.length;
const end = element.selectionEnd ?? text.value.length;
text.value = `${text.value.slice(0, start)}${emoji}${text.value.slice(end)}`;
requestAnimationFrame(() => {
element.focus();
const cursor = start + emoji.length;
element.setSelectionRange(cursor, cursor);
});
}
function chooseImage() {
imageInput.value?.click();
}
function chooseFile() {
fileInput.value?.click();
}
function attachmentTypeForFile(file, fallbackType = 'file') {
if (file?.type?.startsWith('image/')) {
return 'image';
}
return fallbackType === 'image' ? 'image' : 'file';
}
function emitFiles(files, fallbackType = 'file') {
if (props.disabled) {
return;
}
const validFiles = Array.from(files || []).filter(file => file instanceof File && file.size > 0);
if (!validFiles.length) {
return;
}
validFiles.forEach(file => {
const type = attachmentTypeForFile(file, fallbackType);
pendingAttachments.value.push({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
type,
file,
previewUrl: type === 'image' ? URL.createObjectURL(file) : ''
});
});
emojiOpen.value = false;
}
function releasePendingAttachment(attachment) {
if (attachment?.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
}
function removePendingAttachment(index) {
const [attachment] = pendingAttachments.value.splice(index, 1);
releasePendingAttachment(attachment);
}
function clearPendingAttachments() {
pendingAttachments.value.forEach(releasePendingAttachment);
pendingAttachments.value = [];
}
function selectAttachment(event, type) {
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (!files.length || props.disabled) {
return;
}
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>
File diff suppressed because it is too large Load Diff
+111 -111
View File
@@ -1,111 +1,111 @@
<template>
<section class="contact-panel">
<template v-if="contact">
<header class="chat-header">
<div>
<h2>{{ remarkName }}</h2>
<p>{{ username }}</p>
</div>
</header>
<div class="contact-profile">
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
<div class="profile-main">
<div class="remark-row">
<h3>{{ remarkName }}</h3>
<button type="button" class="icon-button remark-edit-button" title="编辑备注名" @click="openAliasEditor">✏️</button>
</div>
</div>
<dl class="profile-fields">
<div>
<dt>用户名</dt>
<dd>{{ username }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ mobile }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ address }}</dd>
</div>
</dl>
<button class="primary-action profile-action" @click="contacts.openChat(contact)">发消息</button>
</div>
<div v-if="editingAlias" class="modal-backdrop contact-modal-backdrop" @click.self="closeAliasEditor">
<form class="modal-panel" @submit.prevent="submitAlias">
<header>
<strong>编辑备注名</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAliasEditor">×</button>
</header>
<label>
<span>备注名</span>
<input v-model.trim="aliasDraft" maxlength="32" autocomplete="off" autofocus>
</label>
<p v-if="aliasError" class="form-error">{{ aliasError }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" :disabled="aliasSaving" @click="closeAliasEditor">取消</button>
<button type="submit" class="primary-inline-action" :disabled="aliasSaving">{{ aliasSaving ? '保存中' : '保存' }}</button>
</div>
</form>
</div>
</template>
<div v-else class="empty-chat">
<strong>选择一个联系人</strong>
<span>好友列表同步完成后,可以在左侧查看资料</span>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const contact = computed(() => contacts.activeContact);
const remarkName = computed(() => contact.value?.alias || '未设置');
const username = computed(() => contact.value?.rawUser?.displayName || contact.value?.rawUser?.name || contact.value?.uid || '');
const mobile = computed(() => contact.value?.rawUser?.mobile || '未知');
const address = computed(() => contact.value?.rawUser?.address || '未知');
const editingAlias = ref(false);
const aliasDraft = ref('');
const aliasSaving = ref(false);
const aliasError = ref('');
watch(contact, () => {
editingAlias.value = false;
aliasError.value = '';
});
function openAliasEditor() {
aliasDraft.value = contact.value?.alias || '';
aliasError.value = '';
editingAlias.value = true;
}
function closeAliasEditor() {
if (aliasSaving.value) {
return;
}
editingAlias.value = false;
aliasError.value = '';
}
async function submitAlias() {
if (!contact.value) {
return;
}
aliasSaving.value = true;
aliasError.value = '';
const ok = await contacts.updateContactAlias(contact.value.uid, aliasDraft.value);
aliasSaving.value = false;
if (ok) {
editingAlias.value = false;
return;
}
aliasError.value = contacts.actionError || '备注名修改失败';
}
</script>
<template>
<section class="contact-panel">
<template v-if="contact">
<header class="chat-header">
<div>
<h2>{{ remarkName }}</h2>
<p>{{ username }}</p>
</div>
</header>
<div class="contact-profile">
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
<div class="profile-main">
<div class="remark-row">
<h3>{{ remarkName }}</h3>
<button type="button" class="icon-button remark-edit-button" title="编辑备注名" @click="openAliasEditor">✏️</button>
</div>
</div>
<dl class="profile-fields">
<div>
<dt>用户名</dt>
<dd>{{ username }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ mobile }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ address }}</dd>
</div>
</dl>
<button class="primary-action profile-action" @click="contacts.openChat(contact)">发消息</button>
</div>
<div v-if="editingAlias" class="modal-backdrop contact-modal-backdrop" @click.self="closeAliasEditor">
<form class="modal-panel" @submit.prevent="submitAlias">
<header>
<strong>编辑备注名</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAliasEditor">×</button>
</header>
<label>
<span>备注名</span>
<input v-model.trim="aliasDraft" maxlength="32" autocomplete="off" autofocus>
</label>
<p v-if="aliasError" class="form-error">{{ aliasError }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" :disabled="aliasSaving" @click="closeAliasEditor">取消</button>
<button type="submit" class="primary-inline-action" :disabled="aliasSaving">{{ aliasSaving ? '保存中' : '保存' }}</button>
</div>
</form>
</div>
</template>
<div v-else class="empty-chat">
<strong>选择一个联系人</strong>
<span>好友列表同步完成后,可以在左侧查看资料</span>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const contact = computed(() => contacts.activeContact);
const remarkName = computed(() => contact.value?.alias || '未设置');
const username = computed(() => contact.value?.rawUser?.displayName || contact.value?.rawUser?.name || contact.value?.uid || '');
const mobile = computed(() => contact.value?.rawUser?.mobile || '未知');
const address = computed(() => contact.value?.rawUser?.address || '未知');
const editingAlias = ref(false);
const aliasDraft = ref('');
const aliasSaving = ref(false);
const aliasError = ref('');
watch(contact, () => {
editingAlias.value = false;
aliasError.value = '';
});
function openAliasEditor() {
aliasDraft.value = contact.value?.alias || '';
aliasError.value = '';
editingAlias.value = true;
}
function closeAliasEditor() {
if (aliasSaving.value) {
return;
}
editingAlias.value = false;
aliasError.value = '';
}
async function submitAlias() {
if (!contact.value) {
return;
}
aliasSaving.value = true;
aliasError.value = '';
const ok = await contacts.updateContactAlias(contact.value.uid, aliasDraft.value);
aliasSaving.value = false;
if (ok) {
editingAlias.value = false;
return;
}
aliasError.value = contacts.actionError || '备注名修改失败';
}
</script>
+79 -79
View File
@@ -1,79 +1,79 @@
<template>
<aside class="conversation-list favorite-list">
<header class="list-header">
<div>
<h2>收藏</h2>
<p>{{ conversations.favoriteMessages.length }} 条消息</p>
</div>
</header>
<div class="sidebar-scroll">
<button
v-for="item in conversations.favoriteMessages"
:key="item.key"
type="button"
class="favorite-item"
:class="{ active: activeKey === item.key }"
@click="conversations.setActiveFavorite(item.key)"
>
<span class="favorite-item-icon">★</span>
<span class="favorite-item-main">
<strong>{{ favoriteTitle(item) }}</strong>
<small>{{ favoritePreview(item) }}</small>
</span>
<time>{{ formatFavoriteTime(item.timestamp) }}</time>
</button>
<div v-if="!conversations.favoriteMessages.length" class="empty-list">
暂无收藏消息
</div>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { useConversationStore } from '@/stores/conversations';
const conversations = useConversationStore();
const activeKey = computed(() => conversations.activeFavorite?.key || '');
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoritePreview(item) {
if (item.recalled) {
return `[${item.recallStatus || '已撤回'}]`;
}
if (item.preview) {
return item.preview;
}
if (item.text) {
return item.text;
}
if (item.type === 'image') {
return '[图片]';
}
if (item.type === 'file') {
return `[文件] ${item.attachment?.name || '文件'}`;
}
return '(无文字内容)';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
</script>
<template>
<aside class="conversation-list favorite-list">
<header class="list-header">
<div>
<h2>收藏</h2>
<p>{{ conversations.favoriteMessages.length }} 条消息</p>
</div>
</header>
<div class="sidebar-scroll">
<button
v-for="item in conversations.favoriteMessages"
:key="item.key"
type="button"
class="favorite-item"
:class="{ active: activeKey === item.key }"
@click="conversations.setActiveFavorite(item.key)"
>
<span class="favorite-item-icon">★</span>
<span class="favorite-item-main">
<strong>{{ favoriteTitle(item) }}</strong>
<small>{{ favoritePreview(item) }}</small>
</span>
<time>{{ formatFavoriteTime(item.timestamp) }}</time>
</button>
<div v-if="!conversations.favoriteMessages.length" class="empty-list">
暂无收藏消息
</div>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { useConversationStore } from '@/stores/conversations';
const conversations = useConversationStore();
const activeKey = computed(() => conversations.activeFavorite?.key || '');
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoritePreview(item) {
if (item.recalled) {
return `[${item.recallStatus || '已撤回'}]`;
}
if (item.preview) {
return item.preview;
}
if (item.text) {
return item.text;
}
if (item.type === 'image') {
return '[图片]';
}
if (item.type === 'file') {
return `[文件] ${item.attachment?.name || '文件'}`;
}
return '(无文字内容)';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
</script>
+203 -202
View File
@@ -1,202 +1,203 @@
<template>
<section class="favorite-panel">
<template v-if="favorite">
<header class="chat-header">
<div>
<h2>收藏详情</h2>
<p>{{ favoriteTitle(favorite) }}</p>
</div>
</header>
<div class="favorite-detail">
<div class="favorite-detail-message" :class="{ recalled: favorite.recalled }">
<span class="favorite-detail-star">★</span>
<div class="favorite-detail-body">
<span v-if="favorite.recalled" class="recall-mark">{{ favorite.recallStatus || '已撤回' }}</span>
<a
v-if="isImageFavorite(favorite)"
class="favorite-detail-image"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
>
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
</a>
<a
v-else-if="isFileFavorite(favorite)"
class="message-file favorite-detail-file"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ favorite.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(favorite.attachment.size) }}</small>
</span>
</a>
<p v-if="favoriteText(favorite)">{{ favoriteText(favorite) }}</p>
</div>
<time>{{ formatFavoriteTime(favorite.timestamp) }}</time>
</div>
<div class="favorite-detail-actions">
<span v-if="notice" class="inline-save-status">{{ notice }}</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="openConversation">打开原会话</button>
<button type="button" class="danger-inline-action" @click="removeFavorite">取消收藏</button>
</div>
</div>
</template>
<div v-else class="empty-chat">
<strong>暂无收藏消息</strong>
<span>右键消息气泡选择“收藏”后,会显示在这里</span>
</div>
</section>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const favorite = computed(() => conversations.activeFavorite);
const notice = ref('');
const error = ref('');
function clearFeedback() {
notice.value = '';
error.value = '';
}
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoriteText(item) {
return item.text || '';
}
function isImageFavorite(item) {
return item?.type === 'image' && item?.attachment?.url;
}
function isFileFavorite(item) {
return item?.type === 'file' && item?.attachment?.url;
}
function favoriteCopyText(item) {
if (!item) {
return '';
}
if (item.text) {
return item.text;
}
if (item.recalled) {
return item.recallStatus || '已撤回';
}
if (isImageFavorite(item)) {
return item.attachment.url;
}
if (isFileFavorite(item)) {
return `${item.attachment.name || '文件'} ${item.attachment.url}`.trim();
}
return item.preview || '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
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`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
async function copyFavorite() {
clearFeedback();
const text = favoriteCopyText(favorite.value || {}).trim();
if (!text) {
error.value = '没有可复制的文字内容';
return;
}
try {
await writeClipboard(text);
notice.value = '已复制';
} catch (copyError) {
error.value = '复制失败';
}
}
async function writeClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const element = document.createElement('textarea');
element.value = text;
element.setAttribute('readonly', 'readonly');
element.style.position = 'fixed';
element.style.left = '-9999px';
document.body.appendChild(element);
element.select();
document.execCommand('copy');
document.body.removeChild(element);
}
function openConversation() {
clearFeedback();
const item = favorite.value;
if (!item?.conversationId) {
error.value = '缺少原会话信息';
return;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
if (!conversation) {
error.value = '原会话暂未加载,请刷新会话后再试';
conversations.refreshRemoteConversations();
return;
}
navigation.showMessages();
conversations.setActiveConversation(item.conversationId);
window.setTimeout(() => {
conversations.focusMessage({
id: item.id || item.key,
messageUid: item.messageUid || item.key
});
}, 450);
}
function removeFavorite() {
clearFeedback();
const key = favorite.value?.key || '';
if (!key) {
return;
}
conversations.removeFavorite(key);
notice.value = '已取消收藏';
}
</script>
<template>
<section class="favorite-panel">
<template v-if="favorite">
<header class="chat-header">
<div>
<h2>收藏详情</h2>
<p>{{ favoriteTitle(favorite) }}</p>
</div>
</header>
<div class="favorite-detail">
<div class="favorite-detail-message" :class="{ recalled: favorite.recalled }">
<span class="favorite-detail-star">★</span>
<div class="favorite-detail-body">
<span v-if="favorite.recalled" class="recall-mark">{{ favorite.recallStatus || '已撤回' }}</span>
<a
v-if="isImageFavorite(favorite)"
class="favorite-detail-image"
:href="imageDisplayUrl(favorite.attachment.url)"
target="_blank"
rel="noreferrer"
>
<img :src="imageDisplayUrl(favorite.attachment.url)" :alt="favorite.attachment.name || '图片'">
</a>
<a
v-else-if="isFileFavorite(favorite)"
class="message-file favorite-detail-file"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ favorite.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(favorite.attachment.size) }}</small>
</span>
</a>
<p v-if="favoriteText(favorite)">{{ favoriteText(favorite) }}</p>
</div>
<time>{{ formatFavoriteTime(favorite.timestamp) }}</time>
</div>
<div class="favorite-detail-actions">
<span v-if="notice" class="inline-save-status">{{ notice }}</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="openConversation">打开原会话</button>
<button type="button" class="danger-inline-action" @click="removeFavorite">取消收藏</button>
</div>
</div>
</template>
<div v-else class="empty-chat">
<strong>暂无收藏消息</strong>
<span>右键消息气泡选择“收藏”后,会显示在这里</span>
</div>
</section>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { imageDisplayUrl } from '@/services/mediaProxy';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const favorite = computed(() => conversations.activeFavorite);
const notice = ref('');
const error = ref('');
function clearFeedback() {
notice.value = '';
error.value = '';
}
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoriteText(item) {
return item.text || '';
}
function isImageFavorite(item) {
return item?.type === 'image' && item?.attachment?.url;
}
function isFileFavorite(item) {
return item?.type === 'file' && item?.attachment?.url;
}
function favoriteCopyText(item) {
if (!item) {
return '';
}
if (item.text) {
return item.text;
}
if (item.recalled) {
return item.recallStatus || '已撤回';
}
if (isImageFavorite(item)) {
return item.attachment.url;
}
if (isFileFavorite(item)) {
return `${item.attachment.name || '文件'} ${item.attachment.url}`.trim();
}
return item.preview || '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
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`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
async function copyFavorite() {
clearFeedback();
const text = favoriteCopyText(favorite.value || {}).trim();
if (!text) {
error.value = '没有可复制的文字内容';
return;
}
try {
await writeClipboard(text);
notice.value = '已复制';
} catch (copyError) {
error.value = '复制失败';
}
}
async function writeClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const element = document.createElement('textarea');
element.value = text;
element.setAttribute('readonly', 'readonly');
element.style.position = 'fixed';
element.style.left = '-9999px';
document.body.appendChild(element);
element.select();
document.execCommand('copy');
document.body.removeChild(element);
}
function openConversation() {
clearFeedback();
const item = favorite.value;
if (!item?.conversationId) {
error.value = '缺少原会话信息';
return;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
if (!conversation) {
error.value = '原会话暂未加载,请刷新会话后再试';
conversations.refreshRemoteConversations();
return;
}
navigation.showMessages();
conversations.setActiveConversation(item.conversationId);
window.setTimeout(() => {
conversations.focusMessage({
id: item.id || item.key,
messageUid: item.messageUid || item.key
});
}, 450);
}
function removeFavorite() {
clearFeedback();
const key = favorite.value?.key || '';
if (!key) {
return;
}
conversations.removeFavorite(key);
notice.value = '已取消收藏';
}
</script>
+103 -103
View File
@@ -1,103 +1,103 @@
<template>
<aside class="conversation-list">
<header class="list-header">
<div>
<h2>联系人</h2>
<p>{{ contacts.contacts.length }} 位好友</p>
</div>
<div class="header-actions">
<button class="icon-button" title="添加联系人" @click="openAddContact">+</button>
<button class="icon-button" title="刷新" @click="contacts.refreshContacts({ force: true })">↻</button>
</div>
</header>
<div class="search-box">
<input v-model.trim="keyword" placeholder="搜索联系人">
</div>
<div class="sidebar-scroll">
<button
v-for="item in filteredContacts"
:key="item.id"
class="conversation-item"
:class="{ active: item.id === contacts.activeId }"
@click="contacts.setActiveContact(item.id)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
</button>
<div v-if="!filteredContacts.length" class="empty-list">
<strong>{{ contacts.loading ? '正在加载' : '暂无联系人' }}</strong>
<span>{{ contacts.loading ? '请稍候' : '好友同步完成后会显示在这里' }}</span>
</div>
</div>
<div v-if="addingContact" class="modal-backdrop" @click.self="closeAddContact">
<form class="modal-panel" @submit.prevent="submitAddContact">
<header>
<strong>添加联系人</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAddContact">×</button>
</header>
<label>
<span>用户ID / 手机号 / 用户名</span>
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
</label>
<p v-if="contacts.actionError" class="form-error">{{ contacts.actionError }}</p>
<p v-if="contacts.actionInfo" class="form-info">{{ contacts.actionInfo }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeAddContact">取消</button>
<button class="primary-inline-action" :disabled="contacts.loading || !addKeyword">
{{ contacts.loading ? '添加中...' : '添加' }}
</button>
</div>
</form>
</div>
</aside>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const keyword = ref('');
const addingContact = ref(false);
const addKeyword = ref('');
const filteredContacts = computed(() => {
const value = keyword.value.toLowerCase();
if (!value) {
return contacts.contacts;
}
return contacts.contacts.filter(item => {
return item.title.toLowerCase().includes(value)
|| item.subtitle.toLowerCase().includes(value)
|| item.uid.toLowerCase().includes(value);
});
});
function openAddContact() {
contacts.actionError = '';
contacts.actionInfo = '';
addKeyword.value = '';
addingContact.value = true;
}
function closeAddContact() {
addingContact.value = false;
}
async function submitAddContact() {
const added = await contacts.addContact(addKeyword.value);
if (added) {
window.setTimeout(() => {
addingContact.value = false;
}, 500);
}
}
</script>
<template>
<aside class="conversation-list">
<header class="list-header">
<div>
<h2>联系人</h2>
<p>{{ contacts.contacts.length }} 位好友</p>
</div>
<div class="header-actions">
<button class="icon-button" title="添加联系人" @click="openAddContact">+</button>
<button class="icon-button" title="刷新" @click="contacts.refreshContacts({ force: true })">↻</button>
</div>
</header>
<div class="search-box">
<input v-model.trim="keyword" placeholder="搜索联系人">
</div>
<div class="sidebar-scroll">
<button
v-for="item in filteredContacts"
:key="item.id"
class="conversation-item"
:class="{ active: item.id === contacts.activeId }"
@click="contacts.setActiveContact(item.id)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
</button>
<div v-if="!filteredContacts.length" class="empty-list">
<strong>{{ contacts.loading ? '正在加载' : '暂无联系人' }}</strong>
<span>{{ contacts.loading ? '请稍候' : '好友同步完成后会显示在这里' }}</span>
</div>
</div>
<div v-if="addingContact" class="modal-backdrop" @click.self="closeAddContact">
<form class="modal-panel" @submit.prevent="submitAddContact">
<header>
<strong>添加联系人</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAddContact">×</button>
</header>
<label>
<span>用户ID / 手机号 / 用户名</span>
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
</label>
<p v-if="contacts.actionError" class="form-error">{{ contacts.actionError }}</p>
<p v-if="contacts.actionInfo" class="form-info">{{ contacts.actionInfo }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeAddContact">取消</button>
<button class="primary-inline-action" :disabled="contacts.loading || !addKeyword">
{{ contacts.loading ? '添加中...' : '添加' }}
</button>
</div>
</form>
</div>
</aside>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const keyword = ref('');
const addingContact = ref(false);
const addKeyword = ref('');
const filteredContacts = computed(() => {
const value = keyword.value.toLowerCase();
if (!value) {
return contacts.contacts;
}
return contacts.contacts.filter(item => {
return item.title.toLowerCase().includes(value)
|| item.subtitle.toLowerCase().includes(value)
|| item.uid.toLowerCase().includes(value);
});
});
function openAddContact() {
contacts.actionError = '';
contacts.actionInfo = '';
addKeyword.value = '';
addingContact.value = true;
}
function closeAddContact() {
addingContact.value = false;
}
async function submitAddContact() {
const added = await contacts.addContact(addKeyword.value);
if (added) {
window.setTimeout(() => {
addingContact.value = false;
}, 500);
}
}
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -1,35 +1,36 @@
const currentHost = window.location.hostname || '127.0.0.1';
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
const appServerOrigin = `${window.location.protocol}//${currentHost}:8888`;
const qrAppServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
const appServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
export const appConfig = {
appName: 'Web IM',
export const appConfig = {
appName: 'Web IM',
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
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'),
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`,
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'),
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'),
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'),
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'),
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`,
logoutApi: runtimeConfig.logoutApi || process.env.VUE_APP_LOGOUT_API || `${appServerOrigin}/auth/logout`,
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`,
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'),
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'),
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'),
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'),
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}/change_pwd`,
resetPasswordApi: runtimeConfig.resetPasswordApi || process.env.VUE_APP_RESET_PASSWORD_API || `${appServerOrigin}/reset_pwd`,
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',
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
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,
clientIdStorageKey: 'qmt-web-im-client-id',
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
+6 -6
View File
@@ -1,6 +1,6 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles/base.css';
createApp(App).use(createPinia()).mount('#app');
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles/base.css';
createApp(App).use(createPinia()).mount('#app');
-266
View File
@@ -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
});
}
-273
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+9 -15
View File
@@ -1,23 +1,17 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
import { LongConnectionImClient } from './longConnectionImClient';
import { DocumentedHttpImClient } from './documentedHttpImClient';
class UnsupportedWildfireImClient {
constructor() {
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
}
}
class UnsupportedWildfireImClient {
constructor() {
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
}
}
export const imClient = appConfig.sdkMode === 'wildfire'
? new UnsupportedWildfireImClient()
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
? new LongConnectionImClient()
: new DocumentedHttpImClient();
: new HttpImClient();
export {
DocumentedHttpImClient,
HttpImClient,
LongConnectionImClient,
UnsupportedWildfireImClient as WildfireImClient
};
export { HttpImClient, LongConnectionImClient, UnsupportedWildfireImClient as WildfireImClient };
+236 -236
View File
@@ -1,236 +1,236 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
const HEARTBEAT_INTERVAL_MS = 25000;
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 15000;
function buildWsUrl() {
if (appConfig.wsUrl) {
return appConfig.wsUrl;
}
const protocol = appConfig.useWss ? 'wss' : 'ws';
const host = appConfig.imServerHost || window.location.hostname;
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
return `${protocol}://${host}${port}${path}`;
}
function normalizeConversationId(payload) {
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
return payload.conversationId || (target ? `single-${target}` : '');
}
function normalizeIncomingMessage(payload, currentUserId) {
const item = payload.message || payload;
const from = item.from || item.sender || item.fromUser || '';
const target = item.target || item.to || item.receiver || '';
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
const conversationId = item.conversationId || normalizeConversationId({
...item,
target: direction === 'out' ? target : from
});
if (!conversationId) {
return null;
}
const status = item.status || item.deliveryStatus || 'sent';
return {
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
messageUid: item.messageUid || item.uid || item.messageId || '',
conversationId,
from,
target,
direction,
type: item.type || 'text',
text: item.text || item.content || item.searchableContent || '',
status,
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
raw: item
};
}
export class LongConnectionImClient extends HttpImClient {
constructor() {
super();
this.socket = null;
this.wsUrl = buildWsUrl();
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.manualClose = false;
}
getSdkVersion() {
return 'JC-LC/0.1';
}
shouldUseMessagePolling() {
return !this.connected;
}
async ensureSdk() {
if (typeof WebSocket === 'undefined') {
throw new Error('当前浏览器不支持 WebSocket 长连接');
}
return true;
}
connect(userId, token) {
this.currentUser = { userId, token };
this.manualClose = false;
this.openSocket();
}
disconnect() {
this.manualClose = true;
this.clearReconnectTimer();
this.clearHeartbeatTimer();
if (this.socket) {
this.socket.close();
this.socket = null;
}
super.disconnect();
}
openSocket() {
if (this.socket?.readyState === WebSocket.OPEN) {
this.sendAuthFrame();
return;
}
if (this.socket?.readyState === WebSocket.CONNECTING) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
try {
this.socket = new WebSocket(this.wsUrl);
this.socket.binaryType = 'arraybuffer';
} catch (error) {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
this.scheduleReconnect();
return;
}
this.socket.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
this.sendAuthFrame();
this.startHeartbeat();
this.refreshConversationList();
};
this.socket.onmessage = event => {
this.handleFrame(event.data);
};
this.socket.onerror = () => {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
};
this.socket.onclose = () => {
this.connected = false;
this.clearHeartbeatTimer();
this.socket = null;
if (this.manualClose) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
this.scheduleReconnect();
};
}
sendAuthFrame() {
this.sendFrame({
type: 'auth',
userId: this.currentUser?.userId || '',
token: this.currentUser?.token || '',
clientId: this.getClientId(),
platform: appConfig.platform,
timestamp: Date.now()
});
}
sendFrame(frame) {
if (this.socket?.readyState !== WebSocket.OPEN) {
return false;
}
this.socket.send(JSON.stringify(frame));
return true;
}
handleFrame(rawData) {
if (rawData instanceof ArrayBuffer) {
this.emit('rawFrameReceived', rawData);
return;
}
let payload;
try {
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
} catch (error) {
return;
}
const type = payload.type || payload.event || payload.cmd || '';
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
return;
}
if (type === 'contactsChanged') {
this.emit('contactsChanged', payload.contacts || []);
return;
}
if (type === 'conversationsChanged') {
this.emit('conversationsChanged', payload.conversations || []);
return;
}
if (type === 'messages' && Array.isArray(payload.messages)) {
payload.messages
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
.filter(Boolean)
.forEach(message => this.emit('messageReceived', message));
return;
}
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
if (message) {
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
}
}
startHeartbeat() {
this.clearHeartbeatTimer();
this.heartbeatTimer = window.setInterval(() => {
this.sendFrame({ type: 'ping', timestamp: Date.now() });
}, HEARTBEAT_INTERVAL_MS);
}
clearHeartbeatTimer() {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
this.clearReconnectTimer();
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
this.reconnectAttempts += 1;
this.reconnectTimer = window.setTimeout(() => {
this.openSocket();
}, delay);
}
clearReconnectTimer() {
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
const HEARTBEAT_INTERVAL_MS = 25000;
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 15000;
function buildWsUrl() {
if (appConfig.wsUrl) {
return appConfig.wsUrl;
}
const protocol = appConfig.useWss ? 'wss' : 'ws';
const host = appConfig.imServerHost || window.location.hostname;
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
return `${protocol}://${host}${port}${path}`;
}
function normalizeConversationId(payload) {
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
return payload.conversationId || (target ? `single-${target}` : '');
}
function normalizeIncomingMessage(payload, currentUserId) {
const item = payload.message || payload;
const from = item.from || item.sender || item.fromUser || '';
const target = item.target || item.to || item.receiver || '';
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
const conversationId = item.conversationId || normalizeConversationId({
...item,
target: direction === 'out' ? target : from
});
if (!conversationId) {
return null;
}
const status = item.status || item.deliveryStatus || 'sent';
return {
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
messageUid: item.messageUid || item.uid || item.messageId || '',
conversationId,
from,
target,
direction,
type: item.type || 'text',
text: item.text || item.content || item.searchableContent || '',
status,
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
raw: item
};
}
export class LongConnectionImClient extends HttpImClient {
constructor() {
super();
this.socket = null;
this.wsUrl = buildWsUrl();
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.manualClose = false;
}
getSdkVersion() {
return 'JC-LC/0.1';
}
shouldUseMessagePolling() {
return !this.connected;
}
async ensureSdk() {
if (typeof WebSocket === 'undefined') {
throw new Error('当前浏览器不支持 WebSocket 长连接');
}
return true;
}
connect(userId, token) {
this.currentUser = { userId, token };
this.manualClose = false;
this.openSocket();
}
disconnect() {
this.manualClose = true;
this.clearReconnectTimer();
this.clearHeartbeatTimer();
if (this.socket) {
this.socket.close();
this.socket = null;
}
super.disconnect();
}
openSocket() {
if (this.socket?.readyState === WebSocket.OPEN) {
this.sendAuthFrame();
return;
}
if (this.socket?.readyState === WebSocket.CONNECTING) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
try {
this.socket = new WebSocket(this.wsUrl);
this.socket.binaryType = 'arraybuffer';
} catch (error) {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
this.scheduleReconnect();
return;
}
this.socket.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
this.sendAuthFrame();
this.startHeartbeat();
this.refreshConversationList();
};
this.socket.onmessage = event => {
this.handleFrame(event.data);
};
this.socket.onerror = () => {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
};
this.socket.onclose = () => {
this.connected = false;
this.clearHeartbeatTimer();
this.socket = null;
if (this.manualClose) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
this.scheduleReconnect();
};
}
sendAuthFrame() {
this.sendFrame({
type: 'auth',
userId: this.currentUser?.userId || '',
token: this.currentUser?.token || '',
clientId: this.getClientId(),
platform: appConfig.platform,
timestamp: Date.now()
});
}
sendFrame(frame) {
if (this.socket?.readyState !== WebSocket.OPEN) {
return false;
}
this.socket.send(JSON.stringify(frame));
return true;
}
handleFrame(rawData) {
if (rawData instanceof ArrayBuffer) {
this.emit('rawFrameReceived', rawData);
return;
}
let payload;
try {
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
} catch (error) {
return;
}
const type = payload.type || payload.event || payload.cmd || '';
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
return;
}
if (type === 'contactsChanged') {
this.emit('contactsChanged', payload.contacts || []);
return;
}
if (type === 'conversationsChanged') {
this.emit('conversationsChanged', payload.conversations || []);
return;
}
if (type === 'messages' && Array.isArray(payload.messages)) {
payload.messages
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
.filter(Boolean)
.forEach(message => this.emit('messageReceived', message));
return;
}
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
if (message) {
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
}
}
startHeartbeat() {
this.clearHeartbeatTimer();
this.heartbeatTimer = window.setInterval(() => {
this.sendFrame({ type: 'ping', timestamp: Date.now() });
}, HEARTBEAT_INTERVAL_MS);
}
clearHeartbeatTimer() {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
this.clearReconnectTimer();
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
this.reconnectAttempts += 1;
this.reconnectTimer = window.setTimeout(() => {
this.openSocket();
}, delay);
}
clearReconnectTimer() {
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
+30
View File
@@ -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;
}
}
+90
View File
@@ -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
View File
@@ -1,233 +1,233 @@
import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useSessionStore } from '@/stores/session';
const CONTACT_CACHE_MS = 15000;
function normalizeContacts(contacts) {
const unique = new Map();
contacts.forEach(contact => {
const uid = String(contact.uid || contact.id || '').trim();
if (!uid) {
return;
}
const normalized = {
...contact,
id: String(contact.id || uid),
uid
};
unique.set(uid, {
...(unique.get(uid) || {}),
...normalized
});
});
return Array.from(unique.values()).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
}
export const useContactStore = defineStore('contacts', {
state: () => ({
contacts: [],
activeId: '',
loading: false,
eventsBound: false,
refreshSerial: 0,
refreshPromise: null,
loadedForUserId: '',
loadedAt: 0,
actionError: '',
actionInfo: ''
}),
getters: {
activeContact(state) {
return state.contacts.find(item => item.id === state.activeId) || state.contacts[0] || null;
}
},
actions: {
bindImEvents() {
if (this.eventsBound) {
return;
}
this.eventsBound = true;
imClient.on('contactsChanged', contacts => {
if (!contacts.length && this.contacts.length) {
return;
}
this.contacts = normalizeContacts(contacts);
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
});
},
async refreshContacts(options = {}) {
this.bindImEvents();
const session = useSessionStore();
const requestUserId = session.userId;
const force = Boolean(options.force);
if (!requestUserId) {
this.clearContacts();
return [];
}
if (!force
&& this.contacts.length
&& this.loadedForUserId === requestUserId
&& Date.now() - this.loadedAt < CONTACT_CACHE_MS
) {
return this.contacts;
}
if (this.refreshPromise && !force) {
return this.refreshPromise;
}
const serial = this.refreshSerial + 1;
this.refreshSerial = serial;
this.loading = true;
this.refreshPromise = (async () => {
this.contacts = normalizeContacts(imClient.refreshContactList());
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
if (!this.contacts.length || force) {
const serverContacts = await imClient.getServerContactList(requestUserId);
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return this.contacts;
}
this.contacts = normalizeContacts(serverContacts);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
window.setTimeout(() => {
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return;
}
const latest = imClient.getContactList(false);
if (latest.length || !this.contacts.length) {
this.contacts = normalizeContacts(latest);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
}, 1200);
return this.contacts;
})();
try {
return await this.refreshPromise;
} finally {
if (serial === this.refreshSerial) {
this.loading = false;
this.refreshPromise = null;
}
}
},
async addContact(keyword) {
const session = useSessionStore();
const value = String(keyword || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!value) {
this.actionError = '请输入用户ID、手机号或用户名';
return false;
}
this.loading = true;
try {
const contact = await imClient.addContact(session.userId, value, '添加联系人');
if (contact) {
this.contacts = normalizeContacts([...this.contacts, contact]);
this.activeId = contact.id;
}
this.actionInfo = '联系人已添加';
await this.refreshContacts({ force: true });
return true;
} catch (error) {
this.actionError = error.message || '添加联系人失败';
return false;
} finally {
this.loading = false;
}
},
async updateContactAlias(contactId, alias) {
const session = useSessionStore();
const targetId = String(contactId || '').trim();
const nextAlias = String(alias || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!targetId) {
this.actionError = '缺少联系人ID';
return false;
}
try {
const savedAlias = await imClient.updateContactAlias(session.userId, targetId, nextAlias);
this.contacts = normalizeContacts(this.contacts.map(item => {
if (item.uid !== targetId && item.id !== targetId) {
return item;
}
const rawUser = {
...(item.rawUser || {}),
friendAlias: savedAlias
};
const rawFriend = {
...(item.rawFriend || {}),
alias: savedAlias
};
const title = savedAlias || rawUser.displayName || rawUser.name || item.uid;
return {
...item,
title,
avatarText: title.slice(0, 1).toUpperCase(),
alias: savedAlias,
rawFriend,
rawUser
};
}));
this.loadedAt = Date.now();
this.actionInfo = '备注名已修改';
return true;
} catch (error) {
this.actionError = error.message || '备注名修改失败';
return false;
}
},
clearContacts() {
this.refreshSerial += 1;
this.contacts = [];
this.activeId = '';
this.loading = false;
this.refreshPromise = null;
this.loadedForUserId = '';
this.loadedAt = 0;
this.actionError = '';
this.actionInfo = '';
},
setActiveContact(id) {
this.activeId = id;
},
openChat(contact) {
if (!contact) {
return;
}
const conversations = useConversationStore();
const navigation = useNavigationStore();
const conversation = imClient.createSingleConversation(contact.uid, contact);
conversations.upsertConversation(conversation);
conversations.setActiveConversation(conversation.id);
navigation.showMessages();
}
}
});
import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useSessionStore } from '@/stores/session';
const CONTACT_CACHE_MS = 15000;
function normalizeContacts(contacts) {
const unique = new Map();
contacts.forEach(contact => {
const uid = String(contact.uid || contact.id || '').trim();
if (!uid) {
return;
}
const normalized = {
...contact,
id: String(contact.id || uid),
uid
};
unique.set(uid, {
...(unique.get(uid) || {}),
...normalized
});
});
return Array.from(unique.values()).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
}
export const useContactStore = defineStore('contacts', {
state: () => ({
contacts: [],
activeId: '',
loading: false,
eventsBound: false,
refreshSerial: 0,
refreshPromise: null,
loadedForUserId: '',
loadedAt: 0,
actionError: '',
actionInfo: ''
}),
getters: {
activeContact(state) {
return state.contacts.find(item => item.id === state.activeId) || state.contacts[0] || null;
}
},
actions: {
bindImEvents() {
if (this.eventsBound) {
return;
}
this.eventsBound = true;
imClient.on('contactsChanged', contacts => {
if (!contacts.length && this.contacts.length) {
return;
}
this.contacts = normalizeContacts(contacts);
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
});
},
async refreshContacts(options = {}) {
this.bindImEvents();
const session = useSessionStore();
const requestUserId = session.userId;
const force = Boolean(options.force);
if (!requestUserId) {
this.clearContacts();
return [];
}
if (!force
&& this.contacts.length
&& this.loadedForUserId === requestUserId
&& Date.now() - this.loadedAt < CONTACT_CACHE_MS
) {
return this.contacts;
}
if (this.refreshPromise && !force) {
return this.refreshPromise;
}
const serial = this.refreshSerial + 1;
this.refreshSerial = serial;
this.loading = true;
this.refreshPromise = (async () => {
this.contacts = normalizeContacts(imClient.refreshContactList());
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
if (!this.contacts.length || force) {
const serverContacts = await imClient.getServerContactList(requestUserId);
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return this.contacts;
}
this.contacts = normalizeContacts(serverContacts);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
window.setTimeout(() => {
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return;
}
const latest = imClient.getContactList(false);
if (latest.length || !this.contacts.length) {
this.contacts = normalizeContacts(latest);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
}, 1200);
return this.contacts;
})();
try {
return await this.refreshPromise;
} finally {
if (serial === this.refreshSerial) {
this.loading = false;
this.refreshPromise = null;
}
}
},
async addContact(keyword) {
const session = useSessionStore();
const value = String(keyword || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!value) {
this.actionError = '请输入用户ID、手机号或用户名';
return false;
}
this.loading = true;
try {
const contact = await imClient.addContact(session.userId, value, '添加联系人');
if (contact) {
this.contacts = normalizeContacts([...this.contacts, contact]);
this.activeId = contact.id;
}
this.actionInfo = '联系人已添加';
await this.refreshContacts({ force: true });
return true;
} catch (error) {
this.actionError = error.message || '添加联系人失败';
return false;
} finally {
this.loading = false;
}
},
async updateContactAlias(contactId, alias) {
const session = useSessionStore();
const targetId = String(contactId || '').trim();
const nextAlias = String(alias || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!targetId) {
this.actionError = '缺少联系人ID';
return false;
}
try {
const savedAlias = await imClient.updateContactAlias(session.userId, targetId, nextAlias);
this.contacts = normalizeContacts(this.contacts.map(item => {
if (item.uid !== targetId && item.id !== targetId) {
return item;
}
const rawUser = {
...(item.rawUser || {}),
friendAlias: savedAlias
};
const rawFriend = {
...(item.rawFriend || {}),
alias: savedAlias
};
const title = savedAlias || rawUser.displayName || rawUser.name || item.uid;
return {
...item,
title,
avatarText: title.slice(0, 1).toUpperCase(),
alias: savedAlias,
rawFriend,
rawUser
};
}));
this.loadedAt = Date.now();
this.actionInfo = '备注名已修改';
return true;
} catch (error) {
this.actionError = error.message || '备注名修改失败';
return false;
}
},
clearContacts() {
this.refreshSerial += 1;
this.contacts = [];
this.activeId = '';
this.loading = false;
this.refreshPromise = null;
this.loadedForUserId = '';
this.loadedAt = 0;
this.actionError = '';
this.actionInfo = '';
},
setActiveContact(id) {
this.activeId = id;
},
openChat(contact) {
if (!contact) {
return;
}
const conversations = useConversationStore();
const navigation = useNavigationStore();
const conversation = imClient.createSingleConversation(contact.uid, contact);
conversations.upsertConversation(conversation);
conversations.setActiveConversation(conversation.id);
navigation.showMessages();
}
}
});
+1433 -1187
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -1,18 +1,18 @@
import { defineStore } from 'pinia';
export const useNavigationStore = defineStore('navigation', {
state: () => ({
section: 'messages'
}),
actions: {
showMessages() {
this.section = 'messages';
},
showContacts() {
this.section = 'contacts';
},
showFavorites() {
this.section = 'favorites';
}
}
});
import { defineStore } from 'pinia';
export const useNavigationStore = defineStore('navigation', {
state: () => ({
section: 'messages'
}),
actions: {
showMessages() {
this.section = 'messages';
},
showContacts() {
this.section = 'contacts';
},
showFavorites() {
this.section = 'favorites';
}
}
});
+19 -42
View File
@@ -12,10 +12,6 @@ function nextProfilePortrait(profilePortrait, currentPortrait) {
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
}
function inferAccountType(account) {
return String(account || '').includes('@') ? 2 : 1;
}
export const useSessionStore = defineStore('session', {
state: () => ({
isAuthenticated: false,
@@ -36,14 +32,12 @@ export const useSessionStore = defineStore('session', {
if (!raw) {
return;
}
try {
const session = JSON.parse(raw);
if (session.appServer !== appConfig.appServer) {
localStorage.removeItem(STORAGE_KEY);
return;
}
if (session.userId && session.token) {
if (!session.clientId) {
localStorage.removeItem(STORAGE_KEY);
@@ -55,6 +49,9 @@ export const useSessionStore = defineStore('session', {
this.portrait = normalizeStoredPortrait(session.portrait);
this.isAuthenticated = true;
imClient.restoreClientId(session.clientId);
if (session.localOnly) {
this.connectionStatusText = '本地调试登录';
}
this.connect();
this.refreshProfile();
}
@@ -62,12 +59,11 @@ export const useSessionStore = defineStore('session', {
localStorage.removeItem(STORAGE_KEY);
}
},
async loginWithPassword(account, password, type = inferAccountType(account)) {
async loginWithPassword(mobile, password) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.loginWithPassword(account, password, type);
const result = await imClient.loginWithPassword(mobile, password);
this.applyLoginResult(result);
await this.connect();
} catch (error) {
@@ -76,12 +72,11 @@ export const useSessionStore = defineStore('session', {
this.connecting = false;
}
},
async loginWithCode(account, code) {
async loginWithCode(mobile, code) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.loginWithCode(account, code);
const result = await imClient.loginWithCode(mobile, code);
this.applyLoginResult(result);
await this.connect();
} catch (error) {
@@ -90,7 +85,6 @@ export const useSessionStore = defineStore('session', {
this.connecting = false;
}
},
async completePcSessionLogin(result) {
this.error = '';
this.connecting = true;
@@ -103,15 +97,11 @@ export const useSessionStore = defineStore('session', {
this.connecting = false;
}
},
async register(payload) {
async registerWithPhone(mobile, code) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.register(payload);
this.applyLoginResult(result);
await this.connect();
return result;
return await imClient.registerWithPhone(mobile, code);
} catch (error) {
this.error = error.message || '注册失败';
return null;
@@ -119,27 +109,28 @@ export const useSessionStore = defineStore('session', {
this.connecting = false;
}
},
applyLoginResult(result) {
this.userId = result.userId || result.uid || result.id || '';
this.token = result.token || result.imToken || '';
this.displayName = result.displayName || result.userName || result.name || this.userId;
this.portrait = normalizeStoredPortrait(result.portrait);
this.isAuthenticated = Boolean(this.userId && this.token);
this.persistSession();
if (result.localOnly) {
this.connectionStatusText = '本地调试登录';
}
this.persistSession(Boolean(result.localOnly));
},
persistSession() {
persistSession(localOnly = false) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
userId: this.userId,
token: this.token,
displayName: this.displayName,
portrait: this.portrait,
clientId: imClient.getClientId(),
localOnly,
appServer: appConfig.appServer
}));
},
applyProfile(profile) {
if (!profile) {
return;
@@ -149,7 +140,6 @@ export const useSessionStore = defineStore('session', {
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
this.persistSession();
},
async refreshProfile() {
if (!this.userId) {
return null;
@@ -162,7 +152,6 @@ export const useSessionStore = defineStore('session', {
return null;
}
},
async saveProfile({ displayName, portraitFile }) {
if (!this.userId) {
throw new Error('请先登录');
@@ -174,21 +163,17 @@ export const useSessionStore = defineStore('session', {
this.applyProfile(profile);
return profile;
},
async changePassword({ oldPassword, newPassword }) {
if (!this.userId) {
throw new Error('请先登录');
}
await imClient.changePassword({ oldPassword, newPassword });
await this.logout();
return true;
},
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
return true;
},
async connect() {
if (!this.userId || !this.token) {
return;
@@ -198,7 +183,6 @@ export const useSessionStore = defineStore('session', {
this.bindImEvents();
await imClient.connect(this.userId, this.token);
},
bindImEvents() {
if (this.eventsBound) {
return;
@@ -209,17 +193,10 @@ export const useSessionStore = defineStore('session', {
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
});
},
async logout() {
try {
await imClient.logout?.();
} catch (error) {
this.error = error.message || '退出登录失败';
} finally {
imClient.disconnect();
localStorage.removeItem(STORAGE_KEY);
this.$reset();
}
logout() {
imClient.disconnect();
localStorage.removeItem(STORAGE_KEY);
this.$reset();
}
}
});
+2886 -2942
View File
File diff suppressed because it is too large Load Diff
+51 -51
View File
@@ -1,51 +1,51 @@
const path = require('path');
const webpack = require('webpack');
module.exports = {
outputDir: 'dist',
publicPath: './',
transpileDependencies: true,
configureWebpack: {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
'legacy-config': path.resolve(__dirname, '../src/config.js')
},
fallback: {
path: false,
fs: false,
assert: false,
util: false,
os: false,
crypto: false,
buffer: require.resolve('buffer/')
}
},
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer']
})
]
},
devServer: {
allowedHosts: 'all',
client: {
overlay: false
},
proxy: {
'/local-api': {
target: 'http://127.0.0.1:8024',
changeOrigin: true,
pathRewrite: {
'^/local-api': '/api'
}
}
}
},
chainWebpack(config) {
config.optimization.concatenateModules(false);
config.optimization.minimize(false);
}
};
const path = require('path');
const webpack = require('webpack');
module.exports = {
outputDir: 'dist',
publicPath: './',
transpileDependencies: true,
configureWebpack: {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
'legacy-config': path.resolve(__dirname, '../src/config.js')
},
fallback: {
path: false,
fs: false,
assert: false,
util: false,
os: false,
crypto: false,
buffer: require.resolve('buffer/')
}
},
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer']
})
]
},
devServer: {
allowedHosts: 'all',
client: {
overlay: false
},
proxy: {
'/local-api': {
target: 'http://127.0.0.1:8024',
changeOrigin: true,
pathRewrite: {
'^/local-api': '/api'
}
}
}
},
chainWebpack(config) {
config.optimization.concatenateModules(false);
config.optimization.minimize(false);
}
};