Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cabf661ca | ||
|
|
cf66f936e5 | ||
|
|
a5efae932b | ||
|
|
3377b6072a |
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
VUE_APP_RUNTIME_ENV=production
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# JC Web IM 开发记录
|
||||
|
||||
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
|
||||
# JC Web IM 开发记录
|
||||
|
||||
- 业务操作的成功、失败、保存结果提示,不要使用几秒后自动消失的 toast / popup。优先放在当前表单、按钮附近或弹窗内部,以常驻文字显示;在用户再次编辑、切换或关闭当前界面时再清除。
|
||||
|
||||
@@ -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 组件。
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset']
|
||||
};
|
||||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset']
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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() ?: '添加联系人服务暂不可用');
|
||||
}
|
||||
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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() ?: '消息操作失败');
|
||||
}
|
||||
@@ -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() ?: '消息详情加载失败');
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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() ?: '用户资料服务暂不可用');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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() ?: '发送失败');
|
||||
}
|
||||
@@ -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() ?: '备注名服务暂不可用');
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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>
|
||||
@@ -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() ?: '添加联系人服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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() ?: '消息操作失败');
|
||||
}
|
||||
|
||||
@@ -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() ?: '消息详情加载失败');
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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() ?: '用户资料服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() ?: '备注名服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 958 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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() ?: '添加联系人服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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() ?: '消息操作失败');
|
||||
}
|
||||
|
||||
@@ -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() ?: '消息详情加载失败');
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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() ?: '用户资料服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() ?: '备注名服务暂不可用');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 958 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||