4 Commits
118 changed files with 45882 additions and 23418 deletions
+3
View File
@@ -0,0 +1,3 @@
VUE_APP_RUNTIME_ENV=local
VUE_APP_APP_SERVER_TARGET=http://18.141.44.165:8888
VUE_APP_WEB_API_TARGET=http://18.141.44.165:9000
+1
View File
@@ -0,0 +1 @@
VUE_APP_RUNTIME_ENV=production
+9 -7
View File
@@ -1,9 +1,11 @@
node_modules/
.deploy/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.local
node_modules/
.deploy/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.local
.env
.env.*
!.env.development
!.env.production
+3 -3
View File
@@ -1,3 +1,3 @@
# JC Web IM 开发记录
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
# JC Web IM 开发记录
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
Binary file not shown.
+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 -299
View File
@@ -1,299 +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`.
# 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`.
+277 -277
View File
@@ -1,277 +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` 的地址和密钥在:
```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`。
# 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` 的地址和密钥在:
```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']
};
+27 -27
View File
@@ -1,25 +1,25 @@
server {
listen 8023;
listen [::]:8023;
server_name winchen.ezpay.asia 10.0.0.120 _;
root /var/www/im-web;
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 = / {
try_files /index.html =404;
expires -1;
add_header Cache-Control "no-store";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store";
expires -1;
add_header Cache-Control "no-store";
}
location = /api/media_proxy.php {
@@ -30,15 +30,15 @@ server {
}
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;
}
}
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
@@ -1,75 +0,0 @@
#!/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"
Binary file not shown.
+95
View File
@@ -0,0 +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() ?: '添加联系人服务暂不可用');
}
+71
View File
@@ -0,0 +1,71 @@
<?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() ?: '联系人服务暂不可用');
}
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('JC_WEB_IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
define('IM_ADMIN_SECRET', getenv('JC_WEB_IM_ADMIN_SECRET') ?: '123456');
const IM_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_wfchat;charset=utf8mb4';
const IM_DB_USER = 'zhongliaoMysql';
const IM_DB_PASSWORD = 'ZhongLiao@admin';
const IM_APP_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_app;charset=utf8mb4';
const IM_APP_DB_USER = 'zhongliaoMysql';
const IM_APP_DB_PASSWORD = 'ZhongLiao@admin';
const IM_DB_TIMEZONE = 'Asia/Shanghai';
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);
}
+58
View File
@@ -0,0 +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);
+252
View File
@@ -0,0 +1,252 @@
<?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 mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
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 = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
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(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
}
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', mediaProxyEnv('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);
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+351
View File
@@ -0,0 +1,351 @@
<?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 ensurePinnedMessagesTable(): void
{
$db = imAppDb();
$db->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null,'
. '`conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null,'
. '`message_uid` varchar(32) not null,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
nowTimestampMs(),
]);
}
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
);
$statement->execute([$userId, $conversationType, $targetId]);
}
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 === 'pin' || $action === 'unpin') {
if ($userId === '') respond(400, '缺少操作者ID');
$targetId = trim((string)($payload['target'] ?? ''));
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
if ($action === 'pin') {
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
}
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
View File
@@ -0,0 +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() ?: '消息详情加载失败');
}
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +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,
]);
}
+321
View File
@@ -0,0 +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() ?: '用户资料服务暂不可用');
}
+18
View File
@@ -0,0 +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);
+498
View File
@@ -0,0 +1,498 @@
<?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_VIDEO = 3;
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', 'video', 'file'], true)) {
return null;
}
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
if ($forwardUrl !== '') {
$parts = parse_url($forwardUrl);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
respond(400, '媒体地址不合法');
}
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
return [
'type' => $messageType,
'url' => $forwardUrl,
'name' => $name,
'mime' => $mime,
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
];
}
$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);
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
$mentionedUserIds = is_array($mentionedUserIds)
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
: [];
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', '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) {
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
$imDb = imDb();
$nativeGroupStatement = $imDb->prepare(
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
);
$nativeGroupStatement->execute([$target]);
$nativeMemberStatement = $imDb->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$nativeMemberStatement->execute([$target, $sender]);
$nativeMemberType = $nativeMemberStatement->fetchColumn();
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
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);
}
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $timestamp,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => '',
]);
}
$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['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
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['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 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() ?: '发送失败');
}
+61
View File
@@ -0,0 +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() ?: '备注名服务暂不可用');
}
+13
View File
@@ -0,0 +1,13 @@
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://18.141.44.165:8100',
appServer: '/app-server',
qrAppServer: 'http://18.141.44.165:8888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
imServerHost: '18.141.44.165',
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'legacy-http',
syncIntervalMs: 2500
};
+18
View File
@@ -0,0 +1,18 @@
window.JC_WEB_IM_CONFIG = {
runtimeEnvironment: 'local',
adminServer: 'http://18.141.44.165:8100',
appServer: '/app-server',
qrAppServer: 'http://18.141.44.165:8888',
platformConfigApi: '/local-api/platform_config.php',
registerConfigApi: '/local-api/register_config.php',
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
imServerHost: '18.141.44.165',
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'legacy-http',
syncIntervalMs: 2500,
sseEnabled: false,
sseTicketApi: '/local-api/sse_ticket.php',
sseUrl: '/ws-events'
};
@@ -0,0 +1,23 @@
(() => {
const protocol = window.location.protocol || 'http:';
const host = window.location.hostname || '18.141.44.165';
window.JC_WEB_IM_CONFIG = {
runtimeEnvironment: 'production',
adminServer: `${protocol}//${host}:8100`,
appServer: `${protocol}//${host}:8888`,
qrAppServer: `${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: 'legacy-http',
syncIntervalMs: 15000,
sseEnabled: true,
sseTicketApi: '/api/sse_ticket.php',
sseUrl: '/ws-events'
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+14
View File
@@ -0,0 +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="favicon.ico">
<script src="config/runtime-config.local.js"></script>
<script defer src="js/chunk-vendors.js"></script><script defer src="js/app.js"></script></head>
<body>
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
<div id="app"></div>
</body>
</html>
+1056
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+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() ?: '添加联系人服务暂不可用');
}
+71 -67
View File
@@ -1,67 +1,71 @@
<?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() ?: '联系人服务暂不可用');
}
<?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() ?: '联系人服务暂不可用');
}
+1025 -1150
View File
File diff suppressed because it is too large Load Diff
+348 -443
View File
@@ -1,443 +1,348 @@
<?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);
}
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('JC_WEB_IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
define('IM_ADMIN_SECRET', getenv('JC_WEB_IM_ADMIN_SECRET') ?: '123456');
const IM_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_wfchat;charset=utf8mb4';
const IM_DB_USER = 'zhongliaoMysql';
const IM_DB_PASSWORD = 'ZhongLiao@admin';
const IM_APP_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_app;charset=utf8mb4';
const IM_APP_DB_USER = 'zhongliaoMysql';
const IM_APP_DB_PASSWORD = 'ZhongLiao@admin';
const IM_DB_TIMEZONE = 'Asia/Shanghai';
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);
}
+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);
+20 -4
View File
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
function mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
function failMediaProxy(int $status, string $message): never
{
http_response_code($status);
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
function mediaProxyAllowedHosts(): array
{
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
return array_values(array_unique(array_filter($hosts)));
}
@@ -53,9 +66,12 @@ function validatedMediaProxyUrl(string $url): array
function mediaProxyCacheRoot(): string
{
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
@@ -63,7 +79,7 @@ function mediaProxyCacheRoot(): string
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$ips = [];
foreach ($configured as $ip) {
$ip = trim($ip);
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+330 -267
View File
@@ -1,288 +1,351 @@
<?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 : [];
}
<?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 ensurePinnedMessagesTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
$db = imAppDb();
$db->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null,'
. '`conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null,'
. '`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`)'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
{
$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();
ensurePinnedMessagesTable();
$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'
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
nowTimestampMs(),
]);
}
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
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
$statement->execute([$userId, $conversationType, $targetId]);
}
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');
}
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');
}
if ($action === 'pin' || $action === 'unpin') {
if ($userId === '') respond(400, '缺少操作者ID');
$targetId = trim((string)($payload['target'] ?? ''));
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
if ($action === 'pin') {
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
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,
]);
respond(0, 'success', ['messageUid' => $messageUidText, '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() ?: '消息操作失败');
}
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() ?: '消息详情加载失败');
}
+134 -18
View File
@@ -213,6 +213,9 @@ function attachmentFallbackText(?array $attachment): string
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
if ((string)$attachment['type'] === 'video') {
return '[视频]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
@@ -594,6 +597,7 @@ function formatWebGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -641,6 +645,9 @@ function nativeMediaUrlScore(string $url): int
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
$score += 30;
}
if (preg_match('/\.(?:mp4|mov|m4v|webm|avi|mkv|3gp)$/i', $path)) {
$score += 30;
}
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
$score += 5;
}
@@ -691,11 +698,21 @@ function nativeAttachmentName(string $url, string $type): string
return $name;
}
return $type === 'image' ? '图片' : '文件';
return $type === 'image' ? '图片' : ($type === 'video' ? '视频' : '文件');
}
function nativeAttachmentMime(string $url, string $type): string
{
if ($type === 'video') {
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (preg_match('/\.webm$/', $path)) {
return 'video/webm';
}
if (preg_match('/\.(?:mov|m4v)$/', $path)) {
return 'video/quicktime';
}
return 'video/mp4';
}
if ($type !== 'image') {
return '';
}
@@ -730,7 +747,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
}
$contentType = (int)($row['_content_type'] ?? 0);
if ($contentType !== 3 && $contentType !== 5) {
if (!in_array($contentType, [3, 5, 6], true)) {
return null;
}
@@ -739,7 +756,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
return null;
}
$type = $contentType === 3 ? 'image' : 'file';
$type = $contentType === 3 ? 'image' : ($contentType === 6 ? 'video' : 'file');
return [
'type' => $type,
@@ -767,7 +784,7 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
{
$text = trim($searchableText);
if (isNativePlaceholderText($text, $contentType)) {
return '';
$text = '';
}
if ($text === '' && !$isRecalled) {
return nativeMessageFallbackText($contentType, $hasAttachment);
@@ -776,6 +793,29 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
return $text;
}
function nativeRedPacketFromText(string $text): ?array
{
$decoded = json_decode(trim($text), true);
if (!is_array($decoded)) {
return null;
}
$packet = $decoded['redPackets'] ?? $decoded['redPacket'] ?? null;
if (!is_array($packet)) {
return null;
}
return [
'id' => (string)($packet['id'] ?? ''),
'senderId' => (string)($packet['userId'] ?? $packet['senderId'] ?? ''),
'target' => (string)($packet['target'] ?? $decoded['target'] ?? ''),
'blessing' => trim((string)($packet['blessing'] ?? '')) ?: '恭喜发财,大吉大利',
'status' => (int)($packet['status'] ?? 0),
'expireTime' => (string)($packet['expireTime'] ?? ''),
'createDate' => (string)($packet['createDate'] ?? ''),
];
}
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
{
if ($hasAttachment) {
@@ -784,6 +824,21 @@ function nativeMessageFallbackText(int $contentType, bool $hasAttachment): strin
if ($contentType === 5) {
return '[文件]';
}
if ($contentType === 6) {
return '[视频加载失败]';
}
if ($contentType === 90) {
return '[红包]';
}
if ($contentType === 92) {
return '以上是打招呼的内容';
}
if ($contentType === 93) {
return '你们已经是好友了,可以开始聊天了。';
}
if ($contentType === 105) {
return '[领取了红包]';
}
return '';
}
@@ -886,11 +941,19 @@ function formatNativeGroupMessage(
$messageId = (string)$row['_mid'];
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$text = $redPacket !== null
? '[红包]'
: nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
return [
'id' => $messageId,
@@ -902,6 +965,7 @@ function formatNativeGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -921,6 +985,7 @@ function formatNativeGroupMessage(
'contentType' => $contentType,
'text' => attachmentDisplayText($text, $attachment),
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
'recalled' => $isRecalled,
@@ -1001,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$scanLimit = min(max($limit * 4, $limit), 5000);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
@@ -1325,13 +1390,21 @@ function formatMessage(
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$messageText = $redPacket !== null
? '[红包]'
: nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
@@ -1348,9 +1421,12 @@ function formatMessage(
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'contentType' => $attachment
? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5))
: $contentType,
'text' => $messageText,
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
@@ -1500,7 +1576,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
'raw' => [
'type' => 'single',
'target' => $peerId,
'line' => 0,
'userId' => (string)($users[$peerId]['_name'] ?? $peerId),
'userUid' => $peerId,
'displayName' => (string)($users[$peerId]['_display_name'] ?? ''),
'mobile' => (string)($users[$peerId]['_mobile'] ?? ''),
'address' => (string)($users[$peerId]['_address'] ?? ''),
],
];
}
@@ -1526,20 +1611,51 @@ function messageSyncResult(
string $userId,
array $messages,
bool $includeConversations,
int $limit
int $limit,
string $targetId = '',
string $conversationType = 'single'
): array {
$result = ['messages' => $messages];
if ($includeConversations) {
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
}
if ($targetId !== '') {
try {
imAppDb()->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
$statement = imAppDb()->prepare(
'select `message_uid`, `message_json` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
);
$statement->execute([$userId, $conversationType, $targetId]);
$pinnedRow = $statement->fetch();
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
$result['pinnedMessage'] = null;
}
}
return $result;
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
$markRead = payloadBool($payload['markRead'] ?? false);
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
@@ -1561,7 +1677,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isNativeGroupTarget($db, $target)) {
@@ -1570,7 +1686,7 @@ try {
$userId,
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
respond(0, 'success', messageSyncResult(
@@ -1578,7 +1694,7 @@ try {
$userId,
[],
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isWebGroupTarget(imAppDb(), $target)) {
@@ -1587,7 +1703,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
$targetId = resolveUserId($db, $target);
@@ -1596,7 +1712,7 @@ try {
$userId,
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
$includeConversations,
$limit
$limit, $targetId, 'single'
));
}
+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.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,
]);
}
<?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,
]);
}
+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);
+478 -562
View File
File diff suppressed because it is too large Load Diff
+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() ?: '备注名服务暂不可用');
}
+13 -21
View File
@@ -1,21 +1,13 @@
(() => {
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'
};
})();
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://18.141.44.165:8100',
appServer: '/app-server',
qrAppServer: 'http://18.141.44.165:8888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
imServerHost: '18.141.44.165',
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'legacy-http',
syncIntervalMs: 2500
};
+18
View File
@@ -0,0 +1,18 @@
window.JC_WEB_IM_CONFIG = {
runtimeEnvironment: 'local',
adminServer: 'http://18.141.44.165:8100',
appServer: '/app-server',
qrAppServer: 'http://18.141.44.165:8888',
platformConfigApi: '/local-api/platform_config.php',
registerConfigApi: '/local-api/register_config.php',
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
imServerHost: '18.141.44.165',
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'legacy-http',
syncIntervalMs: 2500,
sseEnabled: false,
sseTicketApi: '/local-api/sse_ticket.php',
sseUrl: '/ws-events'
};
+23
View File
@@ -0,0 +1,23 @@
(() => {
const protocol = window.location.protocol || 'http:';
const host = window.location.hostname || '18.141.44.165';
window.JC_WEB_IM_CONFIG = {
runtimeEnvironment: 'production',
adminServer: `${protocol}//${host}:8100`,
appServer: `${protocol}//${host}:8888`,
qrAppServer: `${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: 'legacy-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.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

After

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="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>
<!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.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.21e75967.js"></script><link href="css/app.0488f79e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
@@ -9191,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();
}
}
@@ -9200,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];
}
};
@@ -10813,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();
@@ -14701,7 +14701,7 @@ module.exports = function (it) {
/***/ }),
/***/ 5844:
/***/ 3463:
/***/ (function(module) {
"use strict";
@@ -18200,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__(5844);
var aString = __webpack_require__(3463);
var hasOwn = __webpack_require__(9297);
var base64Map = __webpack_require__(2804);
var getAlphabetOption = __webpack_require__(944);
@@ -19436,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__(5844);
var aString = __webpack_require__(3463);
var anUint8Array = __webpack_require__(4154);
var notDetached = __webpack_require__(5169);
var $fromHex = __webpack_require__(2303);
@@ -23270,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__(3463);
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(7958);
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(6768);
/* harmony import */ var vue_demi__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(144);
@@ -25041,7 +25041,7 @@ const PiniaVuePlugin = function (_Vue) {
/***/ }),
/***/ 3463:
/***/ 7958:
/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
"use strict";
@@ -25077,4 +25077,4 @@ function del(target, key) {
/***/ })
}]);
//# sourceMappingURL=chunk-vendors.86756863.js.map
//# sourceMappingURL=chunk-vendors.d3ec3fbc.js.map
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
+11407
View File
File diff suppressed because it is too large Load Diff
+27 -22
View File
@@ -1,28 +1,33 @@
{
"name": "jc-web-im",
"version": "0.1.0",
{
"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",
"serve": "vue-cli-service serve --mode development --host 0.0.0.0 --port 8026",
"serve:local": "vue-cli-service serve --mode development --host 0.0.0.0 --port 8026",
"build": "vue-cli-service build --mode production",
"build:local": "vue-cli-service build --mode development --dest dist-local",
"build:production": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@vueuse/core": "^10.8.0",
"dependencies": {
"@vueuse/core": "^10.8.0",
"axios": "1.6.7",
"buffer": "^6.0.3",
"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"
]
}
"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"
]
}
BIN
View File
Binary file not shown.
+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() ?: '添加联系人服务暂不可用');
}
+71 -67
View File
@@ -1,67 +1,71 @@
<?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() ?: '联系人服务暂不可用');
}
<?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() ?: '联系人服务暂不可用');
}
File diff suppressed because it is too large Load Diff
+348 -443
View File
@@ -1,443 +1,348 @@
<?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);
}
<?php
declare(strict_types=1);
define('IM_ADMIN_URL', getenv('JC_WEB_IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
define('IM_ADMIN_SECRET', getenv('JC_WEB_IM_ADMIN_SECRET') ?: '123456');
const IM_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_wfchat;charset=utf8mb4';
const IM_DB_USER = 'zhongliaoMysql';
const IM_DB_PASSWORD = 'ZhongLiao@admin';
const IM_APP_DB_DSN = 'mysql:host=im-mysql-005.cbgumoq8g1ks.ap-southeast-1.rds.amazonaws.com;port=3306;dbname=zhongliao_app;charset=utf8mb4';
const IM_APP_DB_USER = 'zhongliaoMysql';
const IM_APP_DB_PASSWORD = 'ZhongLiao@admin';
const IM_DB_TIMEZONE = 'Asia/Shanghai';
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);
}
+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);
+20 -4
View File
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
function mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
function failMediaProxy(int $status, string $message): never
{
http_response_code($status);
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
function mediaProxyAllowedHosts(): array
{
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
return array_values(array_unique(array_filter($hosts)));
}
@@ -53,9 +66,12 @@ function validatedMediaProxyUrl(string $url): array
function mediaProxyCacheRoot(): string
{
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
@@ -63,7 +79,7 @@ function mediaProxyCacheRoot(): string
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$ips = [];
foreach ($configured as $ip) {
$ip = trim($ip);
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+330 -267
View File
@@ -1,288 +1,351 @@
<?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 : [];
}
<?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 ensurePinnedMessagesTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
$db = imAppDb();
$db->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null,'
. '`conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null,'
. '`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`)'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
{
$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();
ensurePinnedMessagesTable();
$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'
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
nowTimestampMs(),
]);
}
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
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
$statement->execute([$userId, $conversationType, $targetId]);
}
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');
}
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');
}
if ($action === 'pin' || $action === 'unpin') {
if ($userId === '') respond(400, '缺少操作者ID');
$targetId = trim((string)($payload['target'] ?? ''));
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
if ($action === 'pin') {
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
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,
]);
respond(0, 'success', ['messageUid' => $messageUidText, '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() ?: '消息操作失败');
}
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() ?: '消息详情加载失败');
}
+134 -18
View File
@@ -213,6 +213,9 @@ function attachmentFallbackText(?array $attachment): string
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
if ((string)$attachment['type'] === 'video') {
return '[视频]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
@@ -594,6 +597,7 @@ function formatWebGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -641,6 +645,9 @@ function nativeMediaUrlScore(string $url): int
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
$score += 30;
}
if (preg_match('/\.(?:mp4|mov|m4v|webm|avi|mkv|3gp)$/i', $path)) {
$score += 30;
}
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
$score += 5;
}
@@ -691,11 +698,21 @@ function nativeAttachmentName(string $url, string $type): string
return $name;
}
return $type === 'image' ? '图片' : '文件';
return $type === 'image' ? '图片' : ($type === 'video' ? '视频' : '文件');
}
function nativeAttachmentMime(string $url, string $type): string
{
if ($type === 'video') {
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (preg_match('/\.webm$/', $path)) {
return 'video/webm';
}
if (preg_match('/\.(?:mov|m4v)$/', $path)) {
return 'video/quicktime';
}
return 'video/mp4';
}
if ($type !== 'image') {
return '';
}
@@ -730,7 +747,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
}
$contentType = (int)($row['_content_type'] ?? 0);
if ($contentType !== 3 && $contentType !== 5) {
if (!in_array($contentType, [3, 5, 6], true)) {
return null;
}
@@ -739,7 +756,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
return null;
}
$type = $contentType === 3 ? 'image' : 'file';
$type = $contentType === 3 ? 'image' : ($contentType === 6 ? 'video' : 'file');
return [
'type' => $type,
@@ -767,7 +784,7 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
{
$text = trim($searchableText);
if (isNativePlaceholderText($text, $contentType)) {
return '';
$text = '';
}
if ($text === '' && !$isRecalled) {
return nativeMessageFallbackText($contentType, $hasAttachment);
@@ -776,6 +793,29 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
return $text;
}
function nativeRedPacketFromText(string $text): ?array
{
$decoded = json_decode(trim($text), true);
if (!is_array($decoded)) {
return null;
}
$packet = $decoded['redPackets'] ?? $decoded['redPacket'] ?? null;
if (!is_array($packet)) {
return null;
}
return [
'id' => (string)($packet['id'] ?? ''),
'senderId' => (string)($packet['userId'] ?? $packet['senderId'] ?? ''),
'target' => (string)($packet['target'] ?? $decoded['target'] ?? ''),
'blessing' => trim((string)($packet['blessing'] ?? '')) ?: '恭喜发财,大吉大利',
'status' => (int)($packet['status'] ?? 0),
'expireTime' => (string)($packet['expireTime'] ?? ''),
'createDate' => (string)($packet['createDate'] ?? ''),
];
}
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
{
if ($hasAttachment) {
@@ -784,6 +824,21 @@ function nativeMessageFallbackText(int $contentType, bool $hasAttachment): strin
if ($contentType === 5) {
return '[文件]';
}
if ($contentType === 6) {
return '[视频加载失败]';
}
if ($contentType === 90) {
return '[红包]';
}
if ($contentType === 92) {
return '以上是打招呼的内容';
}
if ($contentType === 93) {
return '你们已经是好友了,可以开始聊天了。';
}
if ($contentType === 105) {
return '[领取了红包]';
}
return '';
}
@@ -886,11 +941,19 @@ function formatNativeGroupMessage(
$messageId = (string)$row['_mid'];
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$text = $redPacket !== null
? '[红包]'
: nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
return [
'id' => $messageId,
@@ -902,6 +965,7 @@ function formatNativeGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -921,6 +985,7 @@ function formatNativeGroupMessage(
'contentType' => $contentType,
'text' => attachmentDisplayText($text, $attachment),
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
'recalled' => $isRecalled,
@@ -1001,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$scanLimit = min(max($limit * 4, $limit), 5000);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
@@ -1325,13 +1390,21 @@ function formatMessage(
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$messageText = $redPacket !== null
? '[红包]'
: nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
@@ -1348,9 +1421,12 @@ function formatMessage(
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'contentType' => $attachment
? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5))
: $contentType,
'text' => $messageText,
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
@@ -1500,7 +1576,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
'raw' => [
'type' => 'single',
'target' => $peerId,
'line' => 0,
'userId' => (string)($users[$peerId]['_name'] ?? $peerId),
'userUid' => $peerId,
'displayName' => (string)($users[$peerId]['_display_name'] ?? ''),
'mobile' => (string)($users[$peerId]['_mobile'] ?? ''),
'address' => (string)($users[$peerId]['_address'] ?? ''),
],
];
}
@@ -1526,20 +1611,51 @@ function messageSyncResult(
string $userId,
array $messages,
bool $includeConversations,
int $limit
int $limit,
string $targetId = '',
string $conversationType = 'single'
): array {
$result = ['messages' => $messages];
if ($includeConversations) {
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
}
if ($targetId !== '') {
try {
imAppDb()->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
$statement = imAppDb()->prepare(
'select `message_uid`, `message_json` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
);
$statement->execute([$userId, $conversationType, $targetId]);
$pinnedRow = $statement->fetch();
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
$result['pinnedMessage'] = null;
}
}
return $result;
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
$markRead = payloadBool($payload['markRead'] ?? false);
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
@@ -1561,7 +1677,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isNativeGroupTarget($db, $target)) {
@@ -1570,7 +1686,7 @@ try {
$userId,
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
respond(0, 'success', messageSyncResult(
@@ -1578,7 +1694,7 @@ try {
$userId,
[],
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isWebGroupTarget(imAppDb(), $target)) {
@@ -1587,7 +1703,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
$targetId = resolveUserId($db, $target);
@@ -1596,7 +1712,7 @@ try {
$userId,
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
$includeConversations,
$limit
$limit, $targetId, 'single'
));
}
+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.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,
]);
}
<?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,
]);
}
+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);
File diff suppressed because it is too large Load Diff
+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() ?: '备注名服务暂不可用');
}
+13 -21
View File
@@ -1,21 +1,13 @@
(() => {
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'
};
})();
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://18.141.44.165:8100',
appServer: '/app-server',
qrAppServer: 'http://18.141.44.165:8888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
imServerHost: '18.141.44.165',
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'legacy-http',
syncIntervalMs: 2500
};
+18
View File
@@ -0,0 +1,18 @@
window.JC_WEB_IM_CONFIG = {
runtimeEnvironment: 'local',
adminServer: 'http://18.141.44.165:8100',
appServer: '/app-server',
qrAppServer: 'http://18.141.44.165:8888',
platformConfigApi: '/local-api/platform_config.php',
registerConfigApi: '/local-api/register_config.php',
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
imServerHost: '18.141.44.165',
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'legacy-http',
syncIntervalMs: 2500,
sseEnabled: false,
sseTicketApi: '/local-api/sse_ticket.php',
sseUrl: '/ws-events'
};
@@ -0,0 +1,23 @@
(() => {
const protocol = window.location.protocol || 'http:';
const host = window.location.hostname || '18.141.44.165';
window.JC_WEB_IM_CONFIG = {
runtimeEnvironment: 'production',
adminServer: `${protocol}//${host}:8100`,
appServer: `${protocol}//${host}:8888`,
qrAppServer: `${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: 'legacy-http',
syncIntervalMs: 15000,
sseEnabled: true,
sseTicketApi: '/api/sse_ticket.php',
sseUrl: '/ws-events'
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

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 %>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>
<!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/<%= htmlWebpackPlugin.options.runtimeConfigFile %>"></script>
</head>
<body>
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
<div id="app"></div>
</body>
</html>
+1 -11
View File
@@ -4,24 +4,14 @@
</template>
<script setup>
import { onMounted, watch } from 'vue';
import { onMounted } from 'vue';
import LoginView from './components/LoginView.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>
+51 -31
View File
@@ -1,34 +1,54 @@
<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';
<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();
import { useAutoDismiss } from '@/composables/useAutoDismiss';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
onMounted(() => {
conversations.refreshRemoteConversations();
contacts.refreshContacts();
});
</script>
useAutoDismiss(
() => conversations.actionError || conversations.actionNotice,
value => {
if ((conversations.actionError || conversations.actionNotice) === value) {
conversations.actionError = '';
conversations.actionNotice = '';
}
}
);
useAutoDismiss(
() => contacts.actionError || contacts.actionInfo,
value => {
if ((contacts.actionError || contacts.actionInfo) === value) {
contacts.actionError = '';
contacts.actionInfo = '';
}
}
);
onMounted(() => {
conversations.refreshRemoteConversations();
contacts.refreshContacts();
});
</script>
+194
View File
@@ -0,0 +1,194 @@
<template>
<main class="documented-shell">
<aside class="documented-rail">
<div class="documented-avatar">
<img v-if="session.portrait" :src="session.portrait" alt="" @error="useImageProxyFallback($event, session.portrait)">
<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';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
import { useImageProxyFallback } from '@/services/mediaProxy';
const session = useSessionStore();
const passwordDialogOpen = ref(false);
const passwordSaving = ref(false);
const passwordError = ref('');
const passwordNotice = ref('');
useAutoDismiss(() => passwordError.value || passwordNotice.value, value => {
if ((passwordError.value || passwordNotice.value) === value) {
passwordError.value = '';
passwordNotice.value = '';
}
});
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
+279 -275
View File
@@ -1,297 +1,301 @@
<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>
<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 || !mentionUsers.length" @click="openMentionPicker">@</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="输入消息"
ref="textareaEl"
v-model="text"
:disabled="disabled"
placeholder="输入消息"
rows="3"
@input="handleMentionInput"
@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 v-if="mentionOpen && filteredMentionUsers.length" class="mention-picker">
<button
v-for="member in filteredMentionUsers"
:key="member.uid"
type="button"
@click="selectMention(member)"
>
<img v-if="member.portrait" :src="member.portrait" alt="">
<span v-else>{{ mentionName(member).slice(0, 1) }}</span>
<strong>{{ mentionName(member) }}</strong>
</button>
</div>
</form>
</template>
<script setup>
import { onBeforeUnmount, ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
</div>
</form>
</template>
<script setup>
import { computed, nextTick, ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
quoteMessage: {
type: Object,
default: null
type: Object,
default: null
},
mentionUsers: {
type: Array,
default: () => []
}
});
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 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 mentionOpen = ref(false);
const mentionQuery = ref('');
const mentionStart = ref(-1);
const mentionedUserIds = 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);
}
const filteredMentionUsers = computed(() => {
const query = mentionQuery.value.toLowerCase();
return props.mentionUsers
.filter(member => member?.uid)
.filter(member => !query || mentionName(member).toLowerCase().includes(query))
.slice(0, 12);
});
function submit() {
const value = text.value.trim();
if (!value) {
return;
}
emit('send', { text: value, mentionedUserIds: [...mentionedUserIds.value] });
text.value = '';
emojiOpen.value = false;
mentionedUserIds.value = [];
mentionOpen.value = false;
emojiOpen.value = false;
}
function toggleEmojiPicker() {
emojiOpen.value = !emojiOpen.value;
function mentionName(member) {
return String(member?.displayName || member?.name || member?.rawName || member?.uid || '群成员');
}
function insertEmoji(emoji) {
function handleMentionInput(event) {
const cursor = event?.target?.selectionStart ?? text.value.length;
const beforeCursor = text.value.slice(0, cursor);
const match = beforeCursor.match(/@([^\s@]*)$/);
mentionOpen.value = Boolean(match && props.mentionUsers.length);
mentionQuery.value = match?.[1] || '';
mentionStart.value = match ? cursor - match[1].length - 1 : -1;
}
function openMentionPicker() {
if (!props.mentionUsers.length) return;
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);
const start = element?.selectionStart ?? text.value.length;
const end = element?.selectionEnd ?? start;
text.value = `${text.value.slice(0, start)}@${text.value.slice(end)}`;
mentionStart.value = start;
mentionQuery.value = '';
mentionOpen.value = true;
nextTick(() => {
element?.focus();
element?.setSelectionRange(start + 1, start + 1);
});
}
function chooseImage() {
imageInput.value?.click();
}
function chooseFile() {
fileInput.value?.click();
}
function attachmentTypeForFile(file, fallbackType = 'file') {
if (file?.type?.startsWith('image/')) {
return 'image';
function selectMention(member) {
const element = textareaEl.value;
const cursor = element?.selectionStart ?? text.value.length;
const start = mentionStart.value >= 0 ? mentionStart.value : cursor;
const name = mentionName(member);
text.value = `${text.value.slice(0, start)}@${name} ${text.value.slice(cursor)}`;
if (!mentionedUserIds.value.includes(String(member.uid))) {
mentionedUserIds.value.push(String(member.uid));
}
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) : ''
});
mentionOpen.value = false;
mentionQuery.value = '';
mentionStart.value = -1;
nextTick(() => {
const nextCursor = start + name.length + 2;
element?.focus();
element?.setSelectionRange(nextCursor, nextCursor);
});
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>
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>
File diff suppressed because it is too large Load Diff
+113 -108
View File
@@ -1,111 +1,116 @@
<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';
<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="" @error="useImageProxyFallback($event, contact.portrait)">
<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);
import { useAutoDismiss } from '@/composables/useAutoDismiss';
import { useImageProxyFallback } from '@/services/mediaProxy';
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 = '';
useAutoDismiss(aliasError, value => {
if (aliasError.value === value) 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>
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 -199
View File
@@ -1,203 +1,207 @@
<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';
<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';
import { imageDisplayUrl } from '@/services/mediaProxy';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const favorite = computed(() => conversations.activeFavorite);
const notice = ref('');
import { useAutoDismiss } from '@/composables/useAutoDismiss';
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>
useAutoDismiss(() => error.value || notice.value, value => {
if ((error.value || notice.value) === value) clearFeedback();
});
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>

Some files were not shown because too many files have changed in this diff Show More