Sync latest web IM fixes
This commit is contained in:
@@ -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
|
||||
+3
-2
@@ -5,6 +5,7 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.local
|
||||
.env
|
||||
.env
|
||||
.env.*
|
||||
SESSION_HANDOFF.md
|
||||
!.env.development
|
||||
!.env.production
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# JC Web IM Session Handoff
|
||||
|
||||
Updated: 2026-05-02 03:18:54 UTC
|
||||
|
||||
## Current Goal
|
||||
|
||||
Continue the Web IM version under:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im
|
||||
```
|
||||
|
||||
The current focus is the contact list and contact detail flow:
|
||||
|
||||
- Load contacts quickly.
|
||||
- Avoid duplicate contact rows.
|
||||
- Show contact detail fields correctly.
|
||||
- Allow editing the friend alias / remark name.
|
||||
|
||||
Important user instruction:
|
||||
|
||||
- Do not modify `app-server` code.
|
||||
- Local config may be adjusted to fit the local test environment.
|
||||
- Web-side code and PHP proxy code may be changed as needed.
|
||||
|
||||
## Runtime Services
|
||||
|
||||
Current local runtime:
|
||||
|
||||
```text
|
||||
im-server: /opt/im-server
|
||||
app-server: /opt/app-server
|
||||
web source: /var/www/jc-im/jc-web-im
|
||||
web dist: /var/www/jc-im/jc-web-im/dist
|
||||
```
|
||||
|
||||
PM2 process names:
|
||||
|
||||
```text
|
||||
jc-im-server
|
||||
jc-app-server
|
||||
```
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
curl -s http://127.0.0.1:18000/api/version
|
||||
curl -s http://127.0.0.1:18888/
|
||||
curl -I http://127.0.0.1:8023/
|
||||
```
|
||||
|
||||
Important ports:
|
||||
|
||||
```text
|
||||
18000 im-server HTTP/API
|
||||
18080 im-server admin
|
||||
1883 im-server long connection/MQTT
|
||||
18888 app-server
|
||||
8023 Web IM Nginx
|
||||
```
|
||||
|
||||
## App Server Status
|
||||
|
||||
Remote `app-server` was previously pulled from:
|
||||
|
||||
```text
|
||||
[email protected]:/opt/app-server
|
||||
```
|
||||
|
||||
It is a colleague-modified deployment and is no longer started from a jar. It runs from exploded classes/lib, equivalent to:
|
||||
|
||||
```bash
|
||||
java -Dfile.encoding=UTF-8 -Xms256m -Xmx1024m \
|
||||
-classpath classes:lib/* cn.wildfirechat.app.Application
|
||||
```
|
||||
|
||||
Do not change its code unless the user explicitly reverses that instruction.
|
||||
|
||||
Current local config was preserved/adapted under:
|
||||
|
||||
```text
|
||||
/opt/app-server/config/application.properties
|
||||
```
|
||||
|
||||
Known important setting:
|
||||
|
||||
```properties
|
||||
AppCode=676d5adc3ee049b4aeb69f440305c953
|
||||
```
|
||||
|
||||
Redis was configured locally to accept the password expected by this app-server:
|
||||
|
||||
```text
|
||||
Mjfg2U@YZdLx&wmH
|
||||
```
|
||||
|
||||
Be careful: other Redis clients may need auth if they reconnect.
|
||||
|
||||
## Contact List Architecture
|
||||
|
||||
The Web version currently uses PHP proxy endpoints under:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/public/api
|
||||
```
|
||||
|
||||
Build copies them to:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/dist/api
|
||||
```
|
||||
|
||||
Current PHP APIs:
|
||||
|
||||
```text
|
||||
contacts.php
|
||||
update_alias.php
|
||||
add_contact.php
|
||||
send_message.php
|
||||
messages.php
|
||||
im_web_bootstrap.php
|
||||
```
|
||||
|
||||
The PHP proxy exists because the browser must not directly carry the `im-server` admin secret.
|
||||
|
||||
Admin config is in:
|
||||
|
||||
```text
|
||||
public/api/im_web_bootstrap.php
|
||||
```
|
||||
|
||||
Current values:
|
||||
|
||||
```php
|
||||
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||
const IM_ADMIN_SECRET = '123456';
|
||||
```
|
||||
|
||||
### Contact List API
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/contacts.php
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{"userId":"当前登录用户ID"}
|
||||
```
|
||||
|
||||
Current logic:
|
||||
|
||||
1. Calls `im-server` admin `/admin/friend/list`.
|
||||
2. Calls `/admin/user/batch_get_infos`.
|
||||
3. Calls `/admin/friend/get_alias` for each friend.
|
||||
4. Returns unique contacts with `uid`, `displayName`, `mobile`, `portrait`, `address`, `alias`.
|
||||
|
||||
This was verified to be fast locally, around milliseconds rather than the old 8-10 seconds.
|
||||
|
||||
### Remark / Alias Update API
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/update_alias.php
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{"userId":"当前登录用户ID","targetId":"联系人ID","alias":"备注名"}
|
||||
```
|
||||
|
||||
Current logic:
|
||||
|
||||
1. Validates `userId`, `targetId`.
|
||||
2. Allows empty alias to clear remark.
|
||||
3. Limits alias to 32 UTF-8 characters.
|
||||
4. Calls `im-server` admin `/admin/friend/set_alias`.
|
||||
|
||||
Verified manually:
|
||||
|
||||
- Setting alias to `测试备注` succeeded.
|
||||
- `contacts.php` returned the new alias.
|
||||
- Test alias was then cleared back to empty.
|
||||
|
||||
## Frontend Changes Completed
|
||||
|
||||
Files touched for contact details and alias editing:
|
||||
|
||||
```text
|
||||
src/config.js
|
||||
src/services/httpImClient.js
|
||||
src/stores/contacts.js
|
||||
src/components/contacts/ContactPanel.vue
|
||||
src/styles/base.css
|
||||
public/api/update_alias.php
|
||||
```
|
||||
|
||||
Important behavior now:
|
||||
|
||||
- `contactsApi` defaults to `/api/contacts.php` in production.
|
||||
- `updateAliasApi` defaults to `/api/update_alias.php` in production.
|
||||
- `HttpImClient.getServerContactList()` maps `address` and `alias`.
|
||||
- `HttpImClient.updateContactAlias()` posts to the PHP proxy.
|
||||
- `contacts.updateContactAlias()` updates local contact state immediately after successful save.
|
||||
- Contact detail display is:
|
||||
- avatar / portrait
|
||||
- remark name from `t_friend._alias`; if empty, show `未设置`
|
||||
- username from `_display_name`
|
||||
- mobile from `_mobile`
|
||||
- address from `_address`; if empty, show `未知`
|
||||
- A `✏️` button appears next to the remark name. Clicking it opens a modal to edit the remark.
|
||||
|
||||
## Build / Deploy Status
|
||||
|
||||
Last successful build:
|
||||
|
||||
```bash
|
||||
cd /var/www/jc-im/jc-web-im
|
||||
npm run build
|
||||
```
|
||||
|
||||
Build output included:
|
||||
|
||||
```text
|
||||
dist/js/app.28165923.js
|
||||
dist/css/app.08b643a5.css
|
||||
dist/api/update_alias.php
|
||||
```
|
||||
|
||||
Build warnings were only webpack asset-size warnings for `chunk-vendors`, not functional errors.
|
||||
|
||||
Syntax checks passed:
|
||||
|
||||
```bash
|
||||
php -l jc-web-im/public/api/update_alias.php
|
||||
php -l jc-web-im/dist/api/update_alias.php
|
||||
```
|
||||
|
||||
## Deployment Documentation
|
||||
|
||||
Deployment doc has been added:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/WEB_DEPLOYMENT.md
|
||||
```
|
||||
|
||||
README links to it:
|
||||
|
||||
```text
|
||||
/var/www/jc-im/jc-web-im/README.md
|
||||
```
|
||||
|
||||
Key deployment note:
|
||||
|
||||
- Production needs Nginx + PHP-FPM.
|
||||
- PHP minimum recommended version: 7.4.
|
||||
- PHP recommended versions: 8.1 / 8.2 / 8.3.
|
||||
- Required PHP extensions: `curl`, `mbstring`.
|
||||
- Nginx root should point to `/var/www/jc-im/jc-web-im/dist`.
|
||||
- `18080` admin port should not be exposed publicly.
|
||||
|
||||
## Known Current Test User Data
|
||||
|
||||
Recent contact list test used:
|
||||
|
||||
```text
|
||||
current userId: 58f71e6245e145409e69b30216c6ebad
|
||||
```
|
||||
|
||||
Contacts returned:
|
||||
|
||||
```text
|
||||
FireRobot
|
||||
571356e9237c460698329c1653d6fd63 用户5050 183019202118
|
||||
55737875bf7a4d089102cbba03b1a8b8 用户8592 18301902118
|
||||
```
|
||||
|
||||
Both human contacts had empty alias after the manual test was restored.
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
Good continuation points:
|
||||
|
||||
1. Open the Web page on port `8023` and manually confirm the alias edit modal in the browser.
|
||||
2. Check whether the contact header should also show the `✏️` edit control or only the profile card should show it.
|
||||
3. Continue message send/sync polish if contact flow is accepted.
|
||||
4. Consider optimizing `contacts.php` alias loading if a user has many friends, because it currently calls `get_alias` once per friend.
|
||||
|
||||
## Do Not Forget
|
||||
|
||||
- Do not modify `/opt/app-server` code.
|
||||
- If changing PHP proxy admin secret or admin URL, update source under `public/api` and rebuild.
|
||||
- If editing runtime config after build, check both `public/config/runtime-config.js` and `dist/config/runtime-config.js` depending on whether the change should survive future builds.
|
||||
- The current workspace is `/var/www/jc-im`, not the old `/var/www/im-web`.
|
||||
+9
-16
@@ -50,27 +50,20 @@ im-server HTTP/API: http://127.0.0.1:18000
|
||||
im-server admin: http://127.0.0.1:18080
|
||||
```
|
||||
|
||||
`im-server admin`、IM 数据库和应用数据库配置通过 PHP-FPM 环境变量提供:
|
||||
`im-server admin` 的地址和密钥在:
|
||||
|
||||
```text
|
||||
public/api/im_web_bootstrap.php
|
||||
```
|
||||
|
||||
需要配置:
|
||||
|
||||
```text
|
||||
IM_ADMIN_URL=http://127.0.0.1:18080
|
||||
IM_ADMIN_SECRET=请填写管理端密钥
|
||||
IM_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4
|
||||
IM_DB_USER=请填写数据库用户
|
||||
IM_DB_PASSWORD=请填写数据库密码
|
||||
IM_APP_DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4
|
||||
IM_APP_DB_USER=请填写应用数据库用户
|
||||
IM_APP_DB_PASSWORD=请填写应用数据库密码
|
||||
IM_DB_TIMEZONE=Asia/Bangkok
|
||||
```
|
||||
|
||||
请在 PHP-FPM pool、容器 secret 或部署平台的环境变量中配置,禁止把真实密钥提交到仓库。
|
||||
默认配置:
|
||||
|
||||
```php
|
||||
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
|
||||
const IM_ADMIN_SECRET = '123456';
|
||||
```
|
||||
|
||||
如果生产环境 admin 端口或 secret 不同,需要同步修改该文件后重新构建。
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ server {
|
||||
server_name winchen.ezpay.asia 10.0.0.120 _;
|
||||
|
||||
root /var/www/im-web;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
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;
|
||||
@@ -13,12 +17,19 @@ server {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location ~ ^/api/.+\.php$ {
|
||||
}
|
||||
|
||||
location = /api/media_proxy.php {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||
fastcgi_read_timeout 300;
|
||||
}
|
||||
|
||||
location ~ ^/api/.+\.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/var/www/im-web/:/tmp/";
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
|
||||
|
||||
@@ -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,990 @@
|
||||
<?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 webActionList(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$items = $value;
|
||||
} else {
|
||||
$items = preg_split('/[\s,,;;]+/u', (string)$value) ?: [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(static function ($item): string {
|
||||
return trim((string)$item);
|
||||
}, $items))));
|
||||
}
|
||||
|
||||
function appPlaceholders(array $values): string
|
||||
{
|
||||
return implode(',', array_fill(0, count($values), '?'));
|
||||
}
|
||||
|
||||
function resolveWebUserIds(PDO $db, array $values): array
|
||||
{
|
||||
$values = webActionList($values);
|
||||
if (!$values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($values as $value) {
|
||||
$statement = $db->prepare(
|
||||
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$value, $value, $value]);
|
||||
$uid = $statement->fetchColumn();
|
||||
if (is_string($uid) && $uid !== '') {
|
||||
$resolved[] = $uid;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($resolved));
|
||||
}
|
||||
|
||||
function webGroupConversation(array $group, string $role = 'member'): array
|
||||
{
|
||||
$name = (string)($group['name'] ?? $group['_name'] ?? $group['group_id'] ?? $group['_gid'] ?? '');
|
||||
$groupId = (string)($group['group_id'] ?? $group['_gid'] ?? '');
|
||||
$category = (string)($group['category'] ?? 'group');
|
||||
$title = $name !== '' ? $name : $groupId;
|
||||
$updatedAt = (int)($group['updated_at_ms'] ?? $group['_dt'] ?? webImNowMs());
|
||||
|
||||
return [
|
||||
'id' => 'group-' . $groupId,
|
||||
'type' => $category === 'channel' ? 'channel' : 'group',
|
||||
'target' => $groupId,
|
||||
'title' => $title,
|
||||
'subtitle' => $category === 'channel' ? '公告群' : '群组',
|
||||
'unread' => 0,
|
||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||
'updatedAt' => $updatedAt,
|
||||
'role' => $role,
|
||||
'raw' => [
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'line' => 0,
|
||||
'category' => $category,
|
||||
'role' => $role,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function fetchWebGroup(PDO $db, string $groupKey): ?array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select * from `t_web_groups`'
|
||||
. ' where `deleted` = 0 and (`group_id` = ? or `name` = ?)'
|
||||
. ' order by (`group_id` = ?) desc limit 1'
|
||||
);
|
||||
$statement->execute([$groupKey, $groupKey, $groupKey]);
|
||||
$group = $statement->fetch();
|
||||
|
||||
return is_array($group) ? $group : null;
|
||||
}
|
||||
|
||||
function fetchWebGroupRole(PDO $db, string $groupId, string $userId): string
|
||||
{
|
||||
$statement = $db->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 !== '' ? $role : 'member';
|
||||
}
|
||||
|
||||
function fetchWebGroupMember(PDO $db, string $groupId, string $userId): array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select * from `t_web_group_members`'
|
||||
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$member = $statement->fetch();
|
||||
|
||||
return is_array($member) ? $member : [];
|
||||
}
|
||||
|
||||
function requireWebGroupMember(PDO $db, string $groupId, string $userId): array
|
||||
{
|
||||
$member = fetchWebGroupMember($db, $groupId, $userId);
|
||||
if (!$member) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
function requireWebGroupRole(PDO $db, string $groupId, string $userId, array $roles): array
|
||||
{
|
||||
$member = requireWebGroupMember($db, $groupId, $userId);
|
||||
if (!in_array((string)$member['role'], $roles, true)) {
|
||||
respond(403, '没有群组管理权限');
|
||||
}
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
function upsertWebGroupMember(PDO $db, string $groupId, string $userId, string $role): void
|
||||
{
|
||||
$now = webImNowMs();
|
||||
$statement = $db->prepare(
|
||||
'insert into `t_web_group_members`'
|
||||
. ' (`group_id`, `user_id`, `role`, `joined_at_ms`, `updated_at_ms`, `status`)'
|
||||
. ' values (?, ?, ?, ?, ?, 0)'
|
||||
. ' on duplicate key update'
|
||||
. ' `role` = if(`role` = "owner", `role`, values(`role`)),'
|
||||
. ' `updated_at_ms` = values(`updated_at_ms`),'
|
||||
. ' `status` = 0'
|
||||
);
|
||||
$statement->execute([$groupId, $userId, $role, $now, $now]);
|
||||
}
|
||||
|
||||
function createNativeGroup(string $operator, string $groupId, string $name, string $category, array $members): array
|
||||
{
|
||||
$nativeMembers = [];
|
||||
foreach ($members as $memberId) {
|
||||
$nativeMembers[] = [
|
||||
'member_id' => $memberId,
|
||||
'type' => $memberId === $operator ? 2 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return requireImSuccess(imAdminPost('/admin/group/create', [
|
||||
'operator' => $operator,
|
||||
'group' => [
|
||||
'group_info' => [
|
||||
'target_id' => $groupId,
|
||||
'name' => $name,
|
||||
'owner' => $operator,
|
||||
'type' => 0,
|
||||
'mute' => $category === 'channel' ? 1 : 0,
|
||||
'join_type' => 0,
|
||||
'searchable' => 1,
|
||||
'history_message' => 1,
|
||||
'extra' => json_encode(['webCategory' => $category], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
'members' => $nativeMembers,
|
||||
],
|
||||
'to_lines' => [0],
|
||||
]), '原生群组创建失败');
|
||||
}
|
||||
|
||||
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
||||
{
|
||||
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
||||
'operator' => $operator,
|
||||
'group_id' => $groupId,
|
||||
'members' => [
|
||||
['member_id' => $userId, 'type' => 0],
|
||||
],
|
||||
'to_lines' => [0],
|
||||
]), '加入原生群组失败');
|
||||
}
|
||||
|
||||
function removeNativeGroupMember(string $operator, string $groupId, array $userIds): void
|
||||
{
|
||||
if (!$userIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
requireImSuccess(imAdminPost('/admin/group/member/del', [
|
||||
'operator' => $operator,
|
||||
'group_id' => $groupId,
|
||||
'members' => array_values($userIds),
|
||||
'to_lines' => [0],
|
||||
]), '移除原生成员失败');
|
||||
}
|
||||
|
||||
function dismissNativeGroup(string $operator, string $groupId): void
|
||||
{
|
||||
requireImSuccess(imAdminPost('/admin/group/del', [
|
||||
'operator' => $operator,
|
||||
'group_id' => $groupId,
|
||||
'to_lines' => [0],
|
||||
]), '解散原生群组失败');
|
||||
}
|
||||
|
||||
function transferNativeGroup(string $operator, string $groupId, string $newOwner): void
|
||||
{
|
||||
requireImSuccess(imAdminPost('/admin/group/transfer', [
|
||||
'operator' => $operator,
|
||||
'group_id' => $groupId,
|
||||
'new_owner' => $newOwner,
|
||||
'to_lines' => [0],
|
||||
]), '转让原生群组失败');
|
||||
}
|
||||
|
||||
function sendWebGroupWelcomeMessage(PDO $appDb, string $groupId, string $senderId, string $welcomeText): void
|
||||
{
|
||||
$text = trim($welcomeText);
|
||||
if ($text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$messageUid = webImMessageUid();
|
||||
$statement = $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)'
|
||||
);
|
||||
$statement->execute([$messageUid, $groupId, $senderId, $text, $now]);
|
||||
$update = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||
$update->execute([$now, $groupId]);
|
||||
|
||||
try {
|
||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $senderId,
|
||||
'conv' => [
|
||||
'type' => 1,
|
||||
'target' => $groupId,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => [
|
||||
'type' => 1,
|
||||
'searchableContent' => $text,
|
||||
'pushContent' => $text,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
],
|
||||
'userMessage' => true,
|
||||
]), '欢迎词发送失败');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
function createWebGroupAction(PDO $imDb, PDO $appDb, string $userId, array $payload, string $category): array
|
||||
{
|
||||
$name = trim((string)($payload['name'] ?? ''));
|
||||
$announcement = trim((string)($payload['announcement'] ?? ''));
|
||||
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
|
||||
if ($name === '') {
|
||||
respond(400, $category === 'channel' ? '请输入公告群名称' : '请输入群组名称');
|
||||
}
|
||||
|
||||
$memberIds = resolveWebUserIds($imDb, webActionList($payload['memberIds'] ?? []));
|
||||
$memberIds[] = $userId;
|
||||
$memberIds = array_values(array_unique(array_filter($memberIds)));
|
||||
if ($category === 'group' && count($memberIds) < 2) {
|
||||
respond(400, '请选择至少一位联系人');
|
||||
}
|
||||
|
||||
$groupId = 'webg_' . bin2hex(random_bytes(8));
|
||||
$now = webImNowMs();
|
||||
$nativeStatus = 'pending';
|
||||
$nativeGroupId = '';
|
||||
$nativeError = '';
|
||||
|
||||
try {
|
||||
$nativeResult = createNativeGroup($userId, $groupId, $name, $category, $memberIds);
|
||||
$nativeGroupId = (string)($nativeResult['group_id'] ?? $nativeResult['groupId'] ?? $groupId);
|
||||
$nativeStatus = 'ok';
|
||||
} catch (Throwable $error) {
|
||||
$nativeStatus = 'fallback';
|
||||
$nativeError = mb_substr($error->getMessage() ?: '原生群组创建失败', 0, 255, 'UTF-8');
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_groups`'
|
||||
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
||||
. ' `welcome_text`, `native_group_id`, `native_status`, `native_error`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
|
||||
. ' values (?, ?, ?, "", ?, "open", ?, ?, ?, ?, ?, ?, ?, ?, 0)'
|
||||
);
|
||||
$statement->execute([
|
||||
$groupId,
|
||||
$category,
|
||||
$name,
|
||||
$userId,
|
||||
$announcement,
|
||||
$welcomeText,
|
||||
$nativeGroupId,
|
||||
$nativeStatus,
|
||||
$nativeError,
|
||||
$userId,
|
||||
$now,
|
||||
$now,
|
||||
]);
|
||||
|
||||
foreach ($memberIds as $memberId) {
|
||||
upsertWebGroupMember($appDb, $groupId, $memberId, $memberId === $userId ? 'owner' : 'member');
|
||||
}
|
||||
sendWebGroupWelcomeMessage($appDb, $groupId, $userId, $welcomeText);
|
||||
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
return [
|
||||
'group' => $group,
|
||||
'conversation' => webGroupConversation($group ?: [
|
||||
'group_id' => $groupId,
|
||||
'category' => $category,
|
||||
'name' => $name,
|
||||
'updated_at_ms' => $now,
|
||||
], 'owner'),
|
||||
'nativeStatus' => $nativeStatus,
|
||||
'nativeError' => $nativeError,
|
||||
];
|
||||
}
|
||||
|
||||
function fetchWebUserMap(PDO $imDb, array $userIds, string $ownerId = ''): array
|
||||
{
|
||||
$userIds = webActionList($userIds);
|
||||
if (!$userIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $imDb->prepare(
|
||||
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
|
||||
. ' where `_uid` in (' . appPlaceholders($userIds) . ')'
|
||||
);
|
||||
$statement->execute($userIds);
|
||||
|
||||
$users = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$uid = (string)$row['_uid'];
|
||||
$displayName = trim((string)($row['_display_name'] ?? ''))
|
||||
?: trim((string)($row['_name'] ?? ''))
|
||||
?: trim((string)($row['_mobile'] ?? ''))
|
||||
?: $uid;
|
||||
$users[$uid] = [
|
||||
'uid' => $uid,
|
||||
'name' => (string)($row['_name'] ?? ''),
|
||||
'displayName' => $displayName,
|
||||
'mobile' => (string)($row['_mobile'] ?? ''),
|
||||
'portrait' => (string)($row['_portrait'] ?? ''),
|
||||
'contactAlias' => '',
|
||||
'friendAlias' => '',
|
||||
'remarkAlias' => '',
|
||||
'isContact' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($ownerId !== '') {
|
||||
$aliasStatement = $imDb->prepare(
|
||||
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
|
||||
. ' and `_friend_uid` in (' . appPlaceholders($userIds) . ')'
|
||||
);
|
||||
$aliasStatement->execute(array_merge([$ownerId], $userIds));
|
||||
foreach ($aliasStatement->fetchAll() as $row) {
|
||||
$friendUid = (string)$row['_friend_uid'];
|
||||
if (!isset($users[$friendUid])) {
|
||||
continue;
|
||||
}
|
||||
$users[$friendUid]['isContact'] = true;
|
||||
$users[$friendUid]['friendAlias'] = trim((string)($row['_alias'] ?? ''));
|
||||
}
|
||||
|
||||
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
|
||||
foreach ($remarkMap as $targetUid => $alias) {
|
||||
if (!isset($users[$targetUid])) {
|
||||
continue;
|
||||
}
|
||||
$users[$targetUid]['remarkAlias'] = $alias;
|
||||
}
|
||||
|
||||
foreach ($users as $uid => $user) {
|
||||
$friendAlias = trim((string)($user['friendAlias'] ?? ''));
|
||||
$remarkAlias = trim((string)($user['remarkAlias'] ?? ''));
|
||||
$users[$uid]['contactAlias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
function currentMuteSetting(PDO $appDb, string $groupId): array
|
||||
{
|
||||
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? limit 1');
|
||||
$statement->execute([$groupId]);
|
||||
$row = $statement->fetch();
|
||||
if (!is_array($row)) {
|
||||
return [
|
||||
'mode' => 'off',
|
||||
'scheduleType' => 'once',
|
||||
'startsAtMs' => 0,
|
||||
'endsAtMs' => 0,
|
||||
'dailyStart' => '',
|
||||
'dailyEnd' => '',
|
||||
'enabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => (string)$row['mode'],
|
||||
'scheduleType' => (string)$row['schedule_type'],
|
||||
'startsAtMs' => (int)$row['starts_at_ms'],
|
||||
'endsAtMs' => (int)$row['ends_at_ms'],
|
||||
'dailyStart' => (string)$row['daily_start'],
|
||||
'dailyEnd' => (string)$row['daily_end'],
|
||||
'enabled' => (bool)$row['enabled'],
|
||||
];
|
||||
}
|
||||
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||
if (!$member) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$ownerId = (string)$group['owner_uid'];
|
||||
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
|
||||
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
|
||||
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
|
||||
$myAlias = (string)($member['alias'] ?? '');
|
||||
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'name' => (string)$group['name'],
|
||||
'category' => (string)$group['category'],
|
||||
'announcement' => (string)($group['announcement'] ?? ''),
|
||||
'welcomeText' => (string)($group['welcome_text'] ?? ''),
|
||||
'ownerUid' => $ownerId,
|
||||
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
|
||||
'myRole' => (string)$member['role'],
|
||||
'myAlias' => $myAlias,
|
||||
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
|
||||
'muteSetting' => currentMuteSetting($appDb, (string)$group['group_id']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||
. ' where `group_id` = ? and `status` = 0 order by (`role` = "owner") desc, `joined_at_ms` asc'
|
||||
);
|
||||
$statement->execute([$groupId]);
|
||||
$members = $statement->fetchAll();
|
||||
$users = fetchWebUserMap($imDb, array_column($members, 'user_id'), $userId);
|
||||
|
||||
return [
|
||||
'members' => array_map(static function (array $member) use ($users): array {
|
||||
$uid = (string)$member['user_id'];
|
||||
$user = $users[$uid] ?? [
|
||||
'uid' => $uid,
|
||||
'displayName' => $uid,
|
||||
'mobile' => '',
|
||||
'portrait' => '',
|
||||
'contactAlias' => '',
|
||||
'friendAlias' => '',
|
||||
'remarkAlias' => '',
|
||||
'isContact' => false,
|
||||
];
|
||||
$alias = trim((string)($member['alias'] ?? ''));
|
||||
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'role' => (string)$member['role'],
|
||||
'alias' => $alias,
|
||||
'contactAlias' => $contactAlias,
|
||||
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||
'isContact' => (bool)($user['isContact'] ?? false),
|
||||
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
|
||||
'rawDisplayName' => (string)$user['displayName'],
|
||||
'groupDisplayName' => $alias,
|
||||
'mobile' => (string)($user['mobile'] ?? ''),
|
||||
'portrait' => (string)($user['portrait'] ?? ''),
|
||||
];
|
||||
}, $members),
|
||||
];
|
||||
}
|
||||
|
||||
function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, string $alias): array
|
||||
{
|
||||
if (mb_strlen($alias, 'UTF-8') > 64) {
|
||||
respond(400, '本群昵称过长');
|
||||
}
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'update `t_web_group_members` set `alias` = ?, `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `user_id` = ? and `status` = 0'
|
||||
);
|
||||
$statement->execute([$alias, $now, $groupId, $userId]);
|
||||
|
||||
return ['alias' => $alias];
|
||||
}
|
||||
|
||||
function leaveGroupAction(PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||
if (!$member) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'update `t_web_group_members` set `status` = 1, `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `user_id` = ?'
|
||||
);
|
||||
$statement->execute([$now, $groupId, $userId]);
|
||||
$groupUpdate = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||
$groupUpdate->execute([$now, $groupId]);
|
||||
|
||||
try {
|
||||
requireImSuccess(imAdminPost('/admin/group/member/quit', [
|
||||
'operator' => $userId,
|
||||
'group_id' => $groupId,
|
||||
'to_lines' => [0],
|
||||
]), '退出原生群组失败');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return ['groupId' => $groupId];
|
||||
}
|
||||
|
||||
function dismissGroupAction(PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
|
||||
|
||||
$now = webImNowMs();
|
||||
$update = $appDb->prepare('update `t_web_groups` set `deleted` = 1, `updated_at_ms` = ? where `group_id` = ?');
|
||||
$update->execute([$now, $groupId]);
|
||||
$memberUpdate = $appDb->prepare('update `t_web_group_members` set `status` = 1, `updated_at_ms` = ? where `group_id` = ?');
|
||||
$memberUpdate->execute([$now, $groupId]);
|
||||
|
||||
try {
|
||||
dismissNativeGroup($userId, $groupId);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return ['groupId' => $groupId];
|
||||
}
|
||||
|
||||
function transferGroupAction(PDO $appDb, string $userId, string $groupId, string $targetUserId): array
|
||||
{
|
||||
if ($targetUserId === '' || $targetUserId === $userId) {
|
||||
respond(400, '请选择新的群主');
|
||||
}
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
|
||||
$target = requireWebGroupMember($appDb, $groupId, $targetUserId);
|
||||
if ((string)$target['role'] === 'owner') {
|
||||
respond(400, '该成员已经是群主');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$appDb->beginTransaction();
|
||||
try {
|
||||
$groupUpdate = $appDb->prepare('update `t_web_groups` set `owner_uid` = ?, `updated_at_ms` = ? where `group_id` = ?');
|
||||
$groupUpdate->execute([$targetUserId, $now, $groupId]);
|
||||
$oldOwnerUpdate = $appDb->prepare(
|
||||
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `user_id` = ?'
|
||||
);
|
||||
$oldOwnerUpdate->execute([$now, $groupId, $userId]);
|
||||
$newOwnerUpdate = $appDb->prepare(
|
||||
'update `t_web_group_members` set `role` = "owner", `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `user_id` = ?'
|
||||
);
|
||||
$newOwnerUpdate->execute([$now, $groupId, $targetUserId]);
|
||||
$appDb->commit();
|
||||
} catch (Throwable $error) {
|
||||
$appDb->rollBack();
|
||||
throw $error;
|
||||
}
|
||||
|
||||
try {
|
||||
transferNativeGroup($userId, $groupId, $targetUserId);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return ['groupId' => $groupId, 'newOwner' => $targetUserId];
|
||||
}
|
||||
|
||||
function setGroupAdminsAction(PDO $appDb, string $userId, string $groupId, array $adminIds): array
|
||||
{
|
||||
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
|
||||
$adminIds = array_values(array_unique(array_filter(array_map('strval', $adminIds), static fn(string $uid): bool => $uid !== $userId)));
|
||||
$now = webImNowMs();
|
||||
|
||||
$reset = $appDb->prepare(
|
||||
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `role` = "admin" and `status` = 0'
|
||||
);
|
||||
$reset->execute([$now, $groupId]);
|
||||
|
||||
if ($adminIds) {
|
||||
$update = $appDb->prepare(
|
||||
'update `t_web_group_members` set `role` = "admin", `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($adminIds) . ')'
|
||||
. ' and `role` <> "owner" and `status` = 0'
|
||||
);
|
||||
$update->execute(array_merge([$now, $groupId], $adminIds));
|
||||
}
|
||||
|
||||
return ['adminIds' => $adminIds];
|
||||
}
|
||||
|
||||
function updateGroupProfileAction(PDO $appDb, string $userId, string $groupId, array $payload): array
|
||||
{
|
||||
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
|
||||
$name = trim((string)($payload['name'] ?? ''));
|
||||
$announcement = trim((string)($payload['announcement'] ?? ''));
|
||||
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
|
||||
if ($name === '') {
|
||||
respond(400, '请输入群名称');
|
||||
}
|
||||
if (mb_strlen($name, 'UTF-8') > 128) {
|
||||
respond(400, '群名称过长');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'update `t_web_groups` set `name` = ?, `announcement` = ?, `welcome_text` = ?, `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `deleted` = 0'
|
||||
);
|
||||
$statement->execute([$name, $announcement, $welcomeText, $now, $groupId]);
|
||||
|
||||
return [
|
||||
'groupId' => $groupId,
|
||||
'name' => $name,
|
||||
'announcement' => $announcement,
|
||||
'welcomeText' => $welcomeText,
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
function kickGroupMembersAction(PDO $appDb, string $userId, string $groupId, array $targetIds): array
|
||||
{
|
||||
$operator = requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
|
||||
$operatorRole = (string)$operator['role'];
|
||||
$targetIds = array_values(array_unique(array_filter(array_map('strval', $targetIds), static fn(string $uid): bool => $uid !== '')));
|
||||
$targetIds = array_values(array_filter($targetIds, static fn(string $uid): bool => $uid !== $userId));
|
||||
if (!$targetIds) {
|
||||
respond(400, '请选择要移除的群员');
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'select `user_id`, `role` from `t_web_group_members`'
|
||||
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($targetIds) . ') and `status` = 0'
|
||||
);
|
||||
$statement->execute(array_merge([$groupId], $targetIds));
|
||||
$targets = $statement->fetchAll();
|
||||
if (!$targets) {
|
||||
respond(400, '没有可移除的群员');
|
||||
}
|
||||
|
||||
$allowedIds = [];
|
||||
foreach ($targets as $target) {
|
||||
$role = (string)$target['role'];
|
||||
if ($role === 'owner') {
|
||||
continue;
|
||||
}
|
||||
if ($operatorRole === 'admin' && $role !== 'member') {
|
||||
continue;
|
||||
}
|
||||
$allowedIds[] = (string)$target['user_id'];
|
||||
}
|
||||
if (!$allowedIds) {
|
||||
respond(403, '没有可移除的群员');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$update = $appDb->prepare(
|
||||
'update `t_web_group_members` set `status` = 2, `updated_at_ms` = ?'
|
||||
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($allowedIds) . ')'
|
||||
);
|
||||
$update->execute(array_merge([$now, $groupId], $allowedIds));
|
||||
|
||||
try {
|
||||
removeNativeGroupMember($userId, $groupId, $allowedIds);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return ['removedIds' => $allowedIds];
|
||||
}
|
||||
|
||||
function updateMuteSettingAction(PDO $appDb, string $userId, string $groupId, array $payload): array
|
||||
{
|
||||
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
|
||||
$mode = trim((string)($payload['mode'] ?? 'off'));
|
||||
$allowedModes = ['off', 'all_include_admins', 'all_except_admins'];
|
||||
if (!in_array($mode, $allowedModes, true)) {
|
||||
respond(400, '禁言模式不正确');
|
||||
}
|
||||
$scheduleType = trim((string)($payload['scheduleType'] ?? 'once'));
|
||||
if (!in_array($scheduleType, ['manual', 'once', 'daily'], true)) {
|
||||
respond(400, '禁言时段类型不正确');
|
||||
}
|
||||
|
||||
$startsAtMs = max(0, (int)($payload['startsAtMs'] ?? 0));
|
||||
$endsAtMs = max(0, (int)($payload['endsAtMs'] ?? 0));
|
||||
$dailyStart = trim((string)($payload['dailyStart'] ?? ''));
|
||||
$dailyEnd = trim((string)($payload['dailyEnd'] ?? ''));
|
||||
$enabled = $mode !== 'off';
|
||||
if ($enabled && $scheduleType === 'once' && ($startsAtMs <= 0 || $endsAtMs <= $startsAtMs)) {
|
||||
respond(400, '请设置有效的单次禁言时段');
|
||||
}
|
||||
if ($enabled && $scheduleType === 'daily' && (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd))) {
|
||||
respond(400, '请设置有效的每日禁言时段');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_group_mute_rules`'
|
||||
. ' (`group_id`, `mode`, `schedule_type`, `starts_at_ms`, `ends_at_ms`, `daily_start`, `daily_end`, `enabled`, `created_by`, `updated_at_ms`)'
|
||||
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
. ' on duplicate key update'
|
||||
. ' `mode` = values(`mode`),'
|
||||
. ' `schedule_type` = values(`schedule_type`),'
|
||||
. ' `starts_at_ms` = values(`starts_at_ms`),'
|
||||
. ' `ends_at_ms` = values(`ends_at_ms`),'
|
||||
. ' `daily_start` = values(`daily_start`),'
|
||||
. ' `daily_end` = values(`daily_end`),'
|
||||
. ' `enabled` = values(`enabled`),'
|
||||
. ' `created_by` = values(`created_by`),'
|
||||
. ' `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([$groupId, $mode, $scheduleType, $startsAtMs, $endsAtMs, $dailyStart, $dailyEnd, $enabled ? 1 : 0, $userId, $now]);
|
||||
|
||||
return ['muteSetting' => currentMuteSetting($appDb, $groupId)];
|
||||
}
|
||||
|
||||
function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
||||
{
|
||||
if ($groupKey === '') {
|
||||
respond(400, '请输入群组ID或群名称');
|
||||
}
|
||||
|
||||
$group = fetchWebGroup($appDb, $groupKey);
|
||||
if (!$group) {
|
||||
try {
|
||||
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
|
||||
$now = webImNowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_groups`'
|
||||
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
||||
. ' `native_group_id`, `native_status`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
|
||||
. ' values (?, "group", ?, ?, ?, "open", "", ?, "ok", ?, ?, ?, 0)'
|
||||
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$groupKey,
|
||||
(string)($native['name'] ?? $groupKey),
|
||||
(string)($native['portrait'] ?? ''),
|
||||
(string)($native['owner'] ?? ''),
|
||||
$groupKey,
|
||||
$userId,
|
||||
$now,
|
||||
$now,
|
||||
]);
|
||||
$group = fetchWebGroup($appDb, $groupKey);
|
||||
} catch (Throwable) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
}
|
||||
|
||||
$groupId = (string)$group['group_id'];
|
||||
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
||||
try {
|
||||
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
||||
} catch (Throwable $error) {
|
||||
// Web membership is authoritative for this web build; native group sync is best-effort.
|
||||
}
|
||||
|
||||
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
||||
return [
|
||||
'group' => fetchWebGroup($appDb, $groupId),
|
||||
'conversation' => webGroupConversation($group, $role),
|
||||
];
|
||||
}
|
||||
|
||||
function sendBroadcastAction(PDO $imDb, PDO $appDb, string $userId, array $payload): array
|
||||
{
|
||||
$text = trim((string)($payload['text'] ?? ''));
|
||||
if ($text === '') {
|
||||
respond(400, '请输入群发内容');
|
||||
}
|
||||
|
||||
$targetIds = resolveWebUserIds($imDb, webActionList($payload['targetIds'] ?? []));
|
||||
$targetIds = array_values(array_filter(array_unique($targetIds), static fn(string $uid): bool => $uid !== $userId));
|
||||
if (!$targetIds) {
|
||||
respond(400, '请选择群发对象');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($targetIds as $targetId) {
|
||||
try {
|
||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $userId,
|
||||
'conv' => [
|
||||
'type' => 0,
|
||||
'target' => $targetId,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => [
|
||||
'type' => 1,
|
||||
'searchableContent' => $text,
|
||||
'pushContent' => $text,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
],
|
||||
'userMessage' => true,
|
||||
]), '发送失败');
|
||||
$results[] = [
|
||||
'targetId' => $targetId,
|
||||
'ok' => true,
|
||||
'messageUid' => isset($result['messageUid']) ? (string)$result['messageUid'] : '',
|
||||
];
|
||||
} catch (Throwable $error) {
|
||||
$results[] = [
|
||||
'targetId' => $targetId,
|
||||
'ok' => false,
|
||||
'error' => $error->getMessage() ?: '发送失败',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$successCount = count(array_filter($results, static fn(array $item): bool => !empty($item['ok'])));
|
||||
$failedCount = count($results) - $successCount;
|
||||
$now = webImNowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_broadcast_logs`'
|
||||
. ' (`sender_uid`, `target_count`, `success_count`, `failed_count`, `text`, `target_json`, `result_json`, `created_at_ms`)'
|
||||
. ' values (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$statement->execute([
|
||||
$userId,
|
||||
count($targetIds),
|
||||
$successCount,
|
||||
$failedCount,
|
||||
$text,
|
||||
json_encode($targetIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$now,
|
||||
]);
|
||||
|
||||
return [
|
||||
'targetCount' => count($targetIds),
|
||||
'successCount' => $successCount,
|
||||
'failedCount' => $failedCount,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
$payload = readJsonPayload();
|
||||
$action = trim((string)($payload['action'] ?? ''));
|
||||
$userId = trim((string)($payload['userId'] ?? ''));
|
||||
|
||||
if ($userId === '') {
|
||||
respond(400, '缺少当前用户ID');
|
||||
}
|
||||
|
||||
try {
|
||||
ensureWebImAppTables();
|
||||
$imDb = imDb();
|
||||
$appDb = imAppDb();
|
||||
|
||||
if ($action === 'create_group') {
|
||||
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'group'));
|
||||
}
|
||||
if ($action === 'create_channel') {
|
||||
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'channel'));
|
||||
}
|
||||
if ($action === 'join_group') {
|
||||
respond(0, 'success', joinWebGroupAction($appDb, $userId, trim((string)($payload['groupKey'] ?? ''))));
|
||||
}
|
||||
if ($action === 'group_detail') {
|
||||
respond(0, 'success', groupDetailAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
|
||||
}
|
||||
if ($action === 'group_members') {
|
||||
respond(0, 'success', groupMembersAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
|
||||
}
|
||||
if ($action === 'update_group_alias') {
|
||||
respond(0, 'success', updateGroupAliasAction(
|
||||
$appDb,
|
||||
$userId,
|
||||
trim((string)($payload['groupId'] ?? '')),
|
||||
trim((string)($payload['alias'] ?? ''))
|
||||
));
|
||||
}
|
||||
if ($action === 'leave_group') {
|
||||
respond(0, 'success', leaveGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
|
||||
}
|
||||
if ($action === 'dismiss_group') {
|
||||
respond(0, 'success', dismissGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
|
||||
}
|
||||
if ($action === 'transfer_group') {
|
||||
respond(0, 'success', transferGroupAction(
|
||||
$appDb,
|
||||
$userId,
|
||||
trim((string)($payload['groupId'] ?? '')),
|
||||
trim((string)($payload['targetUserId'] ?? ''))
|
||||
));
|
||||
}
|
||||
if ($action === 'set_group_admins') {
|
||||
respond(0, 'success', setGroupAdminsAction(
|
||||
$appDb,
|
||||
$userId,
|
||||
trim((string)($payload['groupId'] ?? '')),
|
||||
is_array($payload['adminIds'] ?? null) ? $payload['adminIds'] : []
|
||||
));
|
||||
}
|
||||
if ($action === 'update_group_profile') {
|
||||
respond(0, 'success', updateGroupProfileAction(
|
||||
$appDb,
|
||||
$userId,
|
||||
trim((string)($payload['groupId'] ?? '')),
|
||||
$payload
|
||||
));
|
||||
}
|
||||
if ($action === 'kick_group_members') {
|
||||
respond(0, 'success', kickGroupMembersAction(
|
||||
$appDb,
|
||||
$userId,
|
||||
trim((string)($payload['groupId'] ?? '')),
|
||||
is_array($payload['targetIds'] ?? null) ? $payload['targetIds'] : []
|
||||
));
|
||||
}
|
||||
if ($action === 'update_mute_setting') {
|
||||
respond(0, 'success', updateMuteSettingAction(
|
||||
$appDb,
|
||||
$userId,
|
||||
trim((string)($payload['groupId'] ?? '')),
|
||||
$payload
|
||||
));
|
||||
}
|
||||
if ($action === 'broadcast') {
|
||||
$result = sendBroadcastAction($imDb, $appDb, $userId, $payload);
|
||||
if ((int)$result['successCount'] <= 0) {
|
||||
respond(500, '群发失败', $result);
|
||||
}
|
||||
respond(0, 'success', $result);
|
||||
}
|
||||
|
||||
respond(400, '未知操作');
|
||||
} 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,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||
|
||||
function failMediaProxy(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
function mediaProxyAllowedHosts(): array
|
||||
{
|
||||
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||
preg_split('/[,\s]+/', $configured) ?: []
|
||||
);
|
||||
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
function validatedMediaProxyUrl(string $url): array
|
||||
{
|
||||
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
failMediaProxy(400, '媒体地址不合法');
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||
if (
|
||||
$scheme !== 'https'
|
||||
|| $host === ''
|
||||
|| $port !== 443
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||
) {
|
||||
failMediaProxy(403, '媒体来源不允许代理');
|
||||
}
|
||||
|
||||
return [$url, $host];
|
||||
}
|
||||
|
||||
function mediaProxyCacheRoot(): string
|
||||
{
|
||||
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||
failMediaProxy(503, '媒体缓存目录不可用');
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function mediaProxyPreferredIps(): array
|
||||
{
|
||||
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||
$ips = [];
|
||||
foreach ($configured as $ip) {
|
||||
$ip = trim($ip);
|
||||
if (
|
||||
$ip !== ''
|
||||
&& filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false
|
||||
) {
|
||||
$ips[$ip] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($ips);
|
||||
}
|
||||
|
||||
function mediaProxyMime(string $path): string
|
||||
{
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||
if ($finfo !== false) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||
{
|
||||
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||
$lastError = '媒体源无响应';
|
||||
$preferredIps = mediaProxyPreferredIps();
|
||||
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||
|
||||
foreach ($targets as $index => $preferredIp) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$attempt = $index + 1;
|
||||
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||
$stream = @fopen($temporaryFile, 'x+b');
|
||||
if ($stream === false) {
|
||||
$lastError = '无法创建媒体缓存文件';
|
||||
break;
|
||||
}
|
||||
|
||||
$bytes = 0;
|
||||
$tooLarge = false;
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
fclose($stream);
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '无法初始化媒体请求';
|
||||
break;
|
||||
}
|
||||
|
||||
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||
CURLOPT_FRESH_CONNECT => true,
|
||||
CURLOPT_FORBID_REUSE => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||
$length = strlen($chunk);
|
||||
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$written = fwrite($stream, $chunk);
|
||||
if ($written === false) {
|
||||
return 0;
|
||||
}
|
||||
$bytes += $written;
|
||||
return $written;
|
||||
},
|
||||
];
|
||||
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||
}
|
||||
curl_setopt_array($curl, $curlOptions);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fflush($stream);
|
||||
fclose($stream);
|
||||
|
||||
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||
if (!@rename($temporaryFile, $cacheFile)) {
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '媒体缓存保存失败';
|
||||
break;
|
||||
}
|
||||
@chmod($cacheFile, 0644);
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($temporaryFile);
|
||||
$lastError = $tooLarge
|
||||
? '图片超过代理大小限制'
|
||||
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||
}
|
||||
|
||||
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||
{
|
||||
if (!is_file($cacheFile)) {
|
||||
failMediaProxy(404, '媒体缓存不存在');
|
||||
}
|
||||
$mime = mediaProxyMime($cacheFile);
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
failMediaProxy(415, '媒体内容不是图片');
|
||||
}
|
||||
|
||||
header('ETag: "' . $etag . '"');
|
||||
header('Cache-Control: public, max-age=86400, immutable');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
readfile($cacheFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||
$cacheRoot = mediaProxyCacheRoot();
|
||||
$cacheKey = hash('sha256', $url);
|
||||
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||
|
||||
if (!is_file($cacheFile)) {
|
||||
$lock = @fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||
}
|
||||
if (!is_file($cacheFile)) {
|
||||
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||
}
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
class UserActionException extends RuntimeException
|
||||
{
|
||||
}
|
||||
|
||||
function resolveActionUserId(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$db = imDb();
|
||||
$statement = $db->prepare(
|
||||
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$value, $value, $value]);
|
||||
$uid = $statement->fetchColumn();
|
||||
|
||||
return is_string($uid) && $uid !== '' ? $uid : $value;
|
||||
}
|
||||
|
||||
function messageTimestampMs(string $messageUid): int
|
||||
{
|
||||
return (((int)$messageUid) >> 21) + 1514736000000;
|
||||
}
|
||||
|
||||
function nowTimestampMs(): int
|
||||
{
|
||||
return (int)floor(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
function recallLimitMinutes(): float
|
||||
{
|
||||
$statement = imAppDb()->prepare(
|
||||
'select `conf_value` from `system_conf`'
|
||||
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
|
||||
);
|
||||
$statement->execute(['RetractMessage']);
|
||||
$value = trim((string)($statement->fetchColumn() ?: '0'));
|
||||
|
||||
if ($value === '' || !is_numeric($value)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return max(0.0, (float)$value);
|
||||
}
|
||||
|
||||
function assertRecallWithinLimit(string $messageUid): void
|
||||
{
|
||||
$limitMinutes = recallLimitMinutes();
|
||||
if ($limitMinutes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
|
||||
$limitMs = (int)round($limitMinutes * 60 * 1000);
|
||||
if ($messageAgeMs > $limitMs) {
|
||||
throw new UserActionException('已超过可撤回时间');
|
||||
}
|
||||
}
|
||||
|
||||
function fetchActionMessageRow(string $messageUid): array
|
||||
{
|
||||
$table = imMessageTable($messageUid);
|
||||
$statement = imDb()->prepare(
|
||||
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$messageUid]);
|
||||
$row = $statement->fetch();
|
||||
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
function ensureMessageEditsTable(): void
|
||||
{
|
||||
imDb()->exec(
|
||||
'create table if not exists `t_web_message_edits` ('
|
||||
. '`message_uid` varchar(32) not null,'
|
||||
. '`editor_uid` varchar(64) not null,'
|
||||
. '`edited_text` text null,'
|
||||
. '`edited_at_ms` bigint not null,'
|
||||
. '`created_at` datetime not null default current_timestamp,'
|
||||
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
|
||||
. 'primary key (`message_uid`),'
|
||||
. 'key `idx_editor_uid` (`editor_uid`),'
|
||||
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
||||
. ') engine=InnoDB default charset=utf8mb4'
|
||||
);
|
||||
}
|
||||
|
||||
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||
{
|
||||
$row = fetchActionMessageRow($messageUid);
|
||||
if (!$row) {
|
||||
return editWebGroupMessage($operatorId, $messageUid, $text);
|
||||
}
|
||||
if ((string)$row['_from'] !== $operatorId) {
|
||||
throw new UserActionException('只能编辑自己发送的消息');
|
||||
}
|
||||
if ((int)$row['_content_type'] !== 1) {
|
||||
throw new UserActionException('只能编辑文字消息');
|
||||
}
|
||||
|
||||
$editedAt = nowTimestampMs();
|
||||
$table = imMessageTable($messageUid);
|
||||
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
|
||||
$update->execute([$text, $messageUid]);
|
||||
|
||||
ensureMessageEditsTable();
|
||||
$upsert = imDb()->prepare(
|
||||
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
|
||||
. ' values (?, ?, ?, ?)'
|
||||
. ' on duplicate key update'
|
||||
. ' `editor_uid` = values(`editor_uid`),'
|
||||
. ' `edited_text` = values(`edited_text`),'
|
||||
. ' `edited_at_ms` = values(`edited_at_ms`),'
|
||||
. ' `updated_at` = current_timestamp'
|
||||
);
|
||||
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
|
||||
|
||||
return [
|
||||
'messageUid' => $messageUid,
|
||||
'text' => $text,
|
||||
'editedAt' => $editedAt,
|
||||
];
|
||||
}
|
||||
|
||||
function fetchWebGroupActionMessage(string $messageUid): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
$statement = imAppDb()->prepare(
|
||||
'select m.*, g.`category` from `t_web_group_messages` m'
|
||||
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
|
||||
. ' where m.`message_uid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$messageUid]);
|
||||
$row = $statement->fetch();
|
||||
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
|
||||
{
|
||||
$row = fetchWebGroupActionMessage($messageUid);
|
||||
if (!$row) {
|
||||
throw new UserActionException('消息不存在');
|
||||
}
|
||||
if ((string)$row['sender_uid'] !== $operatorId) {
|
||||
throw new UserActionException('只能编辑自己发送的消息');
|
||||
}
|
||||
if ((int)($row['recalled'] ?? 0) !== 0) {
|
||||
throw new UserActionException('已撤回的消息请重新编辑发送');
|
||||
}
|
||||
|
||||
$editedAt = nowTimestampMs();
|
||||
$update = imAppDb()->prepare(
|
||||
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
|
||||
);
|
||||
$update->execute([$text, $editedAt, $messageUid]);
|
||||
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
|
||||
|
||||
return [
|
||||
'messageUid' => $messageUid,
|
||||
'text' => $text,
|
||||
'editedAt' => $editedAt,
|
||||
];
|
||||
}
|
||||
|
||||
function webGroupMemberRoleForAction(string $groupId, string $userId): string
|
||||
{
|
||||
$statement = imAppDb()->prepare(
|
||||
'select `role` from `t_web_group_members`'
|
||||
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$role = $statement->fetchColumn();
|
||||
|
||||
return is_string($role) ? $role : '';
|
||||
}
|
||||
|
||||
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
|
||||
{
|
||||
$row = fetchWebGroupActionMessage($messageUid);
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
$isSender = (string)$row['sender_uid'] === $operatorId;
|
||||
if (!$isSender) {
|
||||
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
|
||||
if (!in_array($role, ['owner', 'admin'], true)) {
|
||||
throw new UserActionException('只有群主或管理员可以撤回他人消息');
|
||||
}
|
||||
}
|
||||
assertRecallWithinLimit($messageUid);
|
||||
|
||||
$now = nowTimestampMs();
|
||||
$update = imAppDb()->prepare(
|
||||
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
|
||||
);
|
||||
$update->execute([$messageUid]);
|
||||
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||
$groupUpdate->execute([$now, (string)$row['group_id']]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$payload = readJsonPayload();
|
||||
$action = trim((string)($payload['action'] ?? ''));
|
||||
$userId = trim((string)($payload['userId'] ?? ''));
|
||||
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
|
||||
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
|
||||
|
||||
if ($action === '' || $messageUid <= 0) {
|
||||
respond(400, '缺少操作类型或消息ID');
|
||||
}
|
||||
|
||||
try {
|
||||
if ($action === 'recall') {
|
||||
if ($userId === '') {
|
||||
respond(400, '缺少操作者ID');
|
||||
}
|
||||
$userId = resolveActionUserId($userId);
|
||||
$row = fetchActionMessageRow($messageUidText);
|
||||
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
|
||||
respond(0, 'success', [
|
||||
'messageUid' => (string)$messageUid,
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
if (!$row) {
|
||||
throw new UserActionException('消息不存在');
|
||||
}
|
||||
assertRecallWithinLimit($messageUidText);
|
||||
requireImSuccess(imAdminPost('/admin/message/recall', [
|
||||
'operator' => $userId,
|
||||
'messageUid' => $messageUid,
|
||||
'userRecall' => true,
|
||||
'isUserRecall' => true,
|
||||
]), '撤回失败');
|
||||
|
||||
respond(0, 'success', [
|
||||
'messageUid' => (string)$messageUid,
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'edit') {
|
||||
if ($userId === '') {
|
||||
respond(400, '缺少操作者ID');
|
||||
}
|
||||
$text = trim((string)($payload['text'] ?? ''));
|
||||
if ($text === '') {
|
||||
respond(400, '编辑内容不能为空');
|
||||
}
|
||||
if (mb_strlen($text, 'UTF-8') > 5000) {
|
||||
respond(400, '编辑内容过长');
|
||||
}
|
||||
|
||||
$userId = resolveActionUserId($userId);
|
||||
respond(0, 'success', [
|
||||
'message' => editTextMessage($userId, $messageUidText, $text),
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
requireImSuccess(imAdminPost('/admin/message/delete', [
|
||||
'messageUid' => $messageUid,
|
||||
]), '删除失败');
|
||||
|
||||
respond(0, 'success', [
|
||||
'messageUid' => (string)$messageUid,
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
|
||||
respond(400, '不支持的消息操作');
|
||||
} catch (Throwable $error) {
|
||||
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
|
||||
}
|
||||
@@ -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,900 @@
|
||||
<?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 clampLimit(mixed $value, int $default, int $max): int
|
||||
{
|
||||
$limit = (int)$value;
|
||||
if ($limit <= 0) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return min($limit, $max);
|
||||
}
|
||||
|
||||
function placeholders(array $values): string
|
||||
{
|
||||
return implode(',', array_fill(0, count($values), '?'));
|
||||
}
|
||||
|
||||
function payloadBool(mixed $value): bool
|
||||
{
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
function timestampMs(?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 (int)floor(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
function nowMs(): int
|
||||
{
|
||||
return (int)floor(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
function markUserDelivered(PDO $db, string $userId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'insert into `t_delivery_report` (`_uid`, `_dt`) values (?, ?)'
|
||||
. ' on duplicate key update `_dt` = if(`_dt` < values(`_dt`), values(`_dt`), `_dt`)'
|
||||
);
|
||||
$statement->execute([$userId, $timestamp]);
|
||||
}
|
||||
|
||||
function markConversationRead(PDO $db, string $userId, string $targetId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $targetId === '' || $timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `id`, `_dt` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_dt` desc limit 1'
|
||||
);
|
||||
$statement->execute([$userId, $targetId]);
|
||||
$row = $statement->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ($timestamp > (int)$row['_dt']) {
|
||||
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
|
||||
$update->execute([$timestamp, (int)$row['id']]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $db->prepare(
|
||||
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 0, 0, ?, ?)'
|
||||
);
|
||||
$insert->execute([$userId, $targetId, $timestamp]);
|
||||
}
|
||||
|
||||
function resolveUserId(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 peerFromConversationTarget(string $userId, string $target): string
|
||||
{
|
||||
$parts = array_values(array_filter(explode('|', $target), static fn($part) => $part !== ''));
|
||||
if (count($parts) < 2) {
|
||||
return $target !== $userId ? $target : '';
|
||||
}
|
||||
|
||||
return $parts[0] === $userId ? $parts[1] : $parts[0];
|
||||
}
|
||||
|
||||
function fetchMessageRows(PDO $db, array $messageIds): array
|
||||
{
|
||||
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
|
||||
if (!$messageIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$idsByTable = [];
|
||||
foreach ($messageIds as $messageId) {
|
||||
$idsByTable[imMessageTable($messageId)][] = $messageId;
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($idsByTable as $table => $ids) {
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||
$statement = $db->prepare($sql);
|
||||
$statement->execute($ids);
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$messages[(string)$row['_mid']] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function fetchMessageEditMap(PDO $db, array $messageIds): array
|
||||
{
|
||||
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
|
||||
if (!$messageIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$statement = $db->prepare(
|
||||
'select `message_uid`, `edited_at_ms` from `t_web_message_edits`'
|
||||
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
|
||||
);
|
||||
$statement->execute($messageIds);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$edits = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$edits[(string)$row['message_uid']] = [
|
||||
'editedAt' => (int)$row['edited_at_ms'],
|
||||
];
|
||||
}
|
||||
|
||||
return $edits;
|
||||
}
|
||||
|
||||
function normalizeAttachmentRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'type' => (string)$row['attachment_type'],
|
||||
'url' => (string)$row['url'],
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'mime' => (string)($row['mime'] ?? ''),
|
||||
'size' => (int)($row['size_bytes'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function fetchAttachmentMap(PDO $appDb, array $messageIds): array
|
||||
{
|
||||
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
|
||||
if (!$messageIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
ensureWebImAppTables();
|
||||
$statement = $appDb->prepare(
|
||||
'select `message_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
|
||||
. ' from `t_web_message_attachments`'
|
||||
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
|
||||
);
|
||||
$statement->execute($messageIds);
|
||||
|
||||
$attachments = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$attachments[(string)$row['message_uid']] = normalizeAttachmentRow($row);
|
||||
}
|
||||
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
function attachmentFallbackText(?array $attachment): string
|
||||
{
|
||||
if (!$attachment) {
|
||||
return '';
|
||||
}
|
||||
if ((string)$attachment['type'] === 'image') {
|
||||
return '[图片]';
|
||||
}
|
||||
|
||||
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
|
||||
}
|
||||
|
||||
function attachmentDisplayText(string $text, ?array $attachment): string
|
||||
{
|
||||
if (!$attachment) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return $text === attachmentFallbackText($attachment) ? '' : $text;
|
||||
}
|
||||
|
||||
function messagePreviewText(?array $message): string
|
||||
{
|
||||
if (!$message) {
|
||||
return '';
|
||||
}
|
||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||
if ($attachment) {
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||
}
|
||||
|
||||
return (string)($message['text'] ?? '');
|
||||
}
|
||||
|
||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||
if (!$userIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address` from `t_user`'
|
||||
. ' where `_uid` in (' . placeholders($userIds) . ')'
|
||||
);
|
||||
$statement->execute($userIds);
|
||||
|
||||
$users = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$row['_is_friend'] = false;
|
||||
$row['_alias'] = '';
|
||||
$row['_friend_alias'] = '';
|
||||
$row['_remark_alias'] = '';
|
||||
$users[(string)$row['_uid']] = $row;
|
||||
}
|
||||
|
||||
if ($ownerId !== '') {
|
||||
$aliasStatement = $db->prepare(
|
||||
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
|
||||
. ' and `_friend_uid` in (' . placeholders($userIds) . ')'
|
||||
);
|
||||
$aliasStatement->execute(array_merge([$ownerId], $userIds));
|
||||
foreach ($aliasStatement->fetchAll() as $row) {
|
||||
$friendUid = (string)$row['_friend_uid'];
|
||||
if (!isset($users[$friendUid])) {
|
||||
continue;
|
||||
}
|
||||
$users[$friendUid]['_is_friend'] = true;
|
||||
$users[$friendUid]['_friend_alias'] = trim((string)($row['_alias'] ?? ''));
|
||||
}
|
||||
|
||||
try {
|
||||
ensureWebImAppTables();
|
||||
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
|
||||
} catch (Throwable) {
|
||||
$remarkMap = [];
|
||||
}
|
||||
foreach ($remarkMap as $targetUid => $alias) {
|
||||
if (!isset($users[$targetUid])) {
|
||||
continue;
|
||||
}
|
||||
$users[$targetUid]['_remark_alias'] = $alias;
|
||||
}
|
||||
|
||||
foreach ($users as $uid => $user) {
|
||||
$friendAlias = trim((string)($user['_friend_alias'] ?? ''));
|
||||
$remarkAlias = trim((string)($user['_remark_alias'] ?? ''));
|
||||
$users[$uid]['_alias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
function userTitle(array $users, string $userId): string
|
||||
{
|
||||
$user = $users[$userId] ?? null;
|
||||
if (!$user) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
return (string)($user['_alias'] ?? '')
|
||||
?: (string)($user['_display_name'] ?? '')
|
||||
?: (string)($user['_name'] ?? '')
|
||||
?: (string)($user['_mobile'] ?? '')
|
||||
?: $userId;
|
||||
}
|
||||
|
||||
function loadPeerReceiptState(PDO $db, string $currentUserId, string $peerId): array
|
||||
{
|
||||
$deliveryStatement = $db->prepare('select `_dt` from `t_delivery_report` where `_uid` = ? limit 1');
|
||||
$deliveryStatement->execute([$peerId]);
|
||||
$deliveredAt = $deliveryStatement->fetchColumn();
|
||||
|
||||
$readTargets = array_values(array_unique([
|
||||
$currentUserId,
|
||||
$currentUserId . '|' . $peerId,
|
||||
$peerId . '|' . $currentUserId,
|
||||
]));
|
||||
$readStatement = $db->prepare(
|
||||
'select max(`_dt`) from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($readTargets) . ')'
|
||||
);
|
||||
$readStatement->execute(array_merge([$peerId], $readTargets));
|
||||
$readAt = $readStatement->fetchColumn();
|
||||
|
||||
return [
|
||||
'deliveredAt' => is_numeric($deliveredAt) ? (int)$deliveredAt : 0,
|
||||
'readAt' => is_numeric($readAt) ? (int)$readAt : 0,
|
||||
];
|
||||
}
|
||||
|
||||
function loadOwnReadStateMap(PDO $db, string $userId, array $peerIds): array
|
||||
{
|
||||
$peerIds = array_values(array_unique(array_filter(array_map('strval', $peerIds))));
|
||||
if (!$peerIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($peerIds) . ')'
|
||||
. ' group by `_target`'
|
||||
);
|
||||
$statement->execute(array_merge([$userId], $peerIds));
|
||||
|
||||
$readAtByPeer = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$readAtByPeer[(string)$row['_target']] = (int)$row['_read_at'];
|
||||
}
|
||||
|
||||
return $readAtByPeer;
|
||||
}
|
||||
|
||||
function outgoingDeliveryStatus(int $timestamp, array $receiptState): string
|
||||
{
|
||||
$readAt = (int)($receiptState['readAt'] ?? 0);
|
||||
if ($readAt > 0 && $timestamp <= $readAt) {
|
||||
return 'read';
|
||||
}
|
||||
|
||||
$deliveredAt = (int)($receiptState['deliveredAt'] ?? 0);
|
||||
if ($deliveredAt > 0 && $timestamp <= $deliveredAt) {
|
||||
return 'delivered';
|
||||
}
|
||||
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
function messageActivityTimestamp(int $timestamp, array $editState = []): int
|
||||
{
|
||||
return max($timestamp, (int)($editState['editedAt'] ?? 0));
|
||||
}
|
||||
|
||||
function normalizeConversationType(mixed $value): string
|
||||
{
|
||||
$type = strtolower(trim((string)$value));
|
||||
if (in_array($type, ['1', 'group', 'channel'], true)) {
|
||||
return 'group';
|
||||
}
|
||||
|
||||
return 'single';
|
||||
}
|
||||
|
||||
function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
||||
{
|
||||
if ($targetId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
ensureWebImAppTables();
|
||||
$statement = $appDb->prepare('select 1 from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
|
||||
$statement->execute([$targetId]);
|
||||
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = nowMs();
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_group_reads` (`group_id`, `user_id`, `read_at_ms`, `updated_at_ms`)'
|
||||
. ' values (?, ?, ?, ?)'
|
||||
. ' on duplicate key update'
|
||||
. ' `read_at_ms` = if(`read_at_ms` < values(`read_at_ms`), values(`read_at_ms`), `read_at_ms`),'
|
||||
. ' `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
||||
}
|
||||
|
||||
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $appDb->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 !== '' ? $role : '';
|
||||
}
|
||||
|
||||
function formatWebGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
array $group,
|
||||
?array $attachment = null,
|
||||
array $users = []
|
||||
): array
|
||||
{
|
||||
$groupId = (string)$row['group_id'];
|
||||
$from = (string)$row['sender_uid'];
|
||||
$senderUser = $users[$from] ?? [];
|
||||
$senderAlias = trim((string)($row['sender_alias'] ?? ''));
|
||||
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
|
||||
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
|
||||
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
|
||||
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_mobile'] ?? ''))
|
||||
?: $from;
|
||||
$senderName = $senderContactAlias
|
||||
?: $senderAlias
|
||||
?: $senderRawName;
|
||||
$senderRole = (string)($row['sender_role'] ?? 'member');
|
||||
if (!in_array($senderRole, ['owner', 'admin'], true)) {
|
||||
$senderRole = 'member';
|
||||
}
|
||||
$timestamp = (int)$row['created_at_ms'];
|
||||
$editedAt = (int)($row['edited_at_ms'] ?? 0);
|
||||
$activityTimestamp = max($timestamp, $editedAt);
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
|
||||
$messageType = $attachment ? (string)$attachment['type'] : 'text';
|
||||
$contentType = $messageType === 'image' ? 3 : ($messageType === 'file' ? 5 : 1);
|
||||
return [
|
||||
'id' => (string)$row['message_uid'],
|
||||
'messageUid' => (string)$row['message_uid'],
|
||||
'conversationId' => 'group-' . $groupId,
|
||||
'from' => $from,
|
||||
'senderRole' => $senderRole,
|
||||
'senderName' => $senderName,
|
||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'senderProfile' => [
|
||||
'uid' => $from,
|
||||
'displayName' => $senderName,
|
||||
'rawName' => $senderRawName,
|
||||
'contactAlias' => $senderContactAlias,
|
||||
'friendAlias' => $senderFriendAlias,
|
||||
'remarkAlias' => $senderRemarkAlias,
|
||||
'groupAlias' => $senderAlias,
|
||||
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
|
||||
'mobile' => (string)($senderUser['_mobile'] ?? ''),
|
||||
'address' => (string)($senderUser['_address'] ?? ''),
|
||||
'portrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'role' => $senderRole,
|
||||
],
|
||||
'to' => $groupId,
|
||||
'target' => $groupId,
|
||||
'direction' => $direction,
|
||||
'type' => $messageType,
|
||||
'contentType' => $contentType,
|
||||
'text' => attachmentDisplayText((string)$row['text'], $attachment),
|
||||
'attachment' => $attachment,
|
||||
'status' => $direction === 'out' ? 'sent' : 'sent',
|
||||
'deliveryStatus' => $direction === 'out' ? 'sent' : '',
|
||||
'recalled' => false,
|
||||
'recallStatus' => '',
|
||||
'editedAt' => $editedAt,
|
||||
'timestamp' => $timestamp,
|
||||
'activityTimestamp' => $activityTimestamp,
|
||||
'displayTimestamp' => $timestamp,
|
||||
'conversationType' => (string)($group['category'] ?? 'group'),
|
||||
];
|
||||
}
|
||||
|
||||
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
$role = webGroupRole($appDb, $userId, $groupId);
|
||||
if ($role === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupStatement = $appDb->prepare('select * from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
|
||||
$groupStatement->execute([$groupId]);
|
||||
$group = $groupStatement->fetch();
|
||||
if (!$group) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'select msg.*,'
|
||||
. ' coalesce(sender_member.`role`, if(g.`owner_uid` = msg.`sender_uid`, "owner", "member")) as `sender_role`,'
|
||||
. ' coalesce(sender_member.`alias`, "") as `sender_alias`'
|
||||
. ' from `t_web_group_messages` msg'
|
||||
. ' join `t_web_groups` g on g.`group_id` = msg.`group_id` and g.`deleted` = 0'
|
||||
. ' left join `t_web_group_members` sender_member'
|
||||
. ' on sender_member.`group_id` = msg.`group_id`'
|
||||
. ' and sender_member.`user_id` = msg.`sender_uid`'
|
||||
. ' and sender_member.`status` = 0'
|
||||
. ' where msg.`group_id` = ? and msg.`recalled` = 0'
|
||||
. ' order by msg.`created_at_ms` desc, msg.`id` desc limit ' . $limit
|
||||
);
|
||||
$statement->execute([$groupId]);
|
||||
$rows = array_reverse($statement->fetchAll());
|
||||
$attachmentMap = fetchAttachmentMap($appDb, array_column($rows, 'message_uid'));
|
||||
$users = fetchUserInfoMap(imDb(), array_column($rows, 'sender_uid'), $userId);
|
||||
|
||||
$readTimestamp = 0;
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
$messageId = (string)$row['message_uid'];
|
||||
$message = formatWebGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users);
|
||||
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||
$messages[] = $message;
|
||||
}
|
||||
|
||||
if ($markRead) {
|
||||
markWebGroupRead($appDb, $userId, $groupId, $readTimestamp);
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
$statement = $appDb->prepare(
|
||||
'select g.*, 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 m.`user_id` = ?'
|
||||
. ' order by g.`updated_at_ms` desc limit ' . min(max($limit, 50), 100)
|
||||
);
|
||||
$statement->execute([$userId]);
|
||||
$groups = $statement->fetchAll();
|
||||
if (!$groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lastMessageStatement = $appDb->prepare(
|
||||
'select * from `t_web_group_messages`'
|
||||
. ' where `group_id` = ? and `recalled` = 0'
|
||||
. ' order by greatest(`created_at_ms`, `edited_at_ms`) desc, `id` desc limit 1'
|
||||
);
|
||||
$readStatement = $appDb->prepare(
|
||||
'select `read_at_ms` from `t_web_group_reads` where `group_id` = ? and `user_id` = ? limit 1'
|
||||
);
|
||||
$lastAttachmentStatement = $appDb->prepare(
|
||||
'select `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
|
||||
. ' from `t_web_message_attachments` where `message_uid` = ? limit 1'
|
||||
);
|
||||
$unreadStatement = $appDb->prepare(
|
||||
'select count(*) from `t_web_group_messages`'
|
||||
. ' where `group_id` = ? and `sender_uid` <> ? and `recalled` = 0'
|
||||
. ' and greatest(`created_at_ms`, `edited_at_ms`) > ?'
|
||||
);
|
||||
$memberCountStatement = $appDb->prepare(
|
||||
'select count(*) from `t_web_group_members` where `group_id` = ? and `status` = 0'
|
||||
);
|
||||
|
||||
$conversations = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupId = (string)$group['group_id'];
|
||||
$lastMessageStatement->execute([$groupId]);
|
||||
$lastMessage = $lastMessageStatement->fetch();
|
||||
$readStatement->execute([$groupId, $userId]);
|
||||
$readAt = (int)($readStatement->fetchColumn() ?: 0);
|
||||
$unreadStatement->execute([$groupId, $userId, $readAt]);
|
||||
$unread = (int)$unreadStatement->fetchColumn();
|
||||
$memberCountStatement->execute([$groupId]);
|
||||
$memberCount = (int)$memberCountStatement->fetchColumn();
|
||||
|
||||
$title = (string)$group['name'];
|
||||
$category = (string)$group['category'];
|
||||
$isChannel = $category === 'channel';
|
||||
$lastAttachment = null;
|
||||
if ($lastMessage) {
|
||||
$lastAttachmentStatement->execute([(string)$lastMessage['message_uid']]);
|
||||
$lastAttachmentRow = $lastAttachmentStatement->fetch();
|
||||
$lastAttachment = $lastAttachmentRow ? normalizeAttachmentRow($lastAttachmentRow) : null;
|
||||
}
|
||||
$subtitle = $lastMessage
|
||||
? (messagePreviewText([
|
||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||
'attachment' => $lastAttachment,
|
||||
]) ?: (string)$lastMessage['text'])
|
||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||
$updatedAt = $lastMessage
|
||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
||||
: (int)$group['updated_at_ms'];
|
||||
|
||||
$conversations[] = [
|
||||
'id' => 'group-' . $groupId,
|
||||
'type' => $isChannel ? 'channel' : 'group',
|
||||
'target' => $groupId,
|
||||
'title' => $title,
|
||||
'subtitle' => $subtitle,
|
||||
'unread' => $unread,
|
||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||
'updatedAt' => $updatedAt,
|
||||
'role' => (string)$group['role'],
|
||||
'memberCount' => $memberCount,
|
||||
'raw' => [
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'line' => 0,
|
||||
'category' => $category,
|
||||
'role' => (string)$group['role'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function isRecallMessage(array $row): bool
|
||||
{
|
||||
return (int)($row['_content_type'] ?? 0) === 80;
|
||||
}
|
||||
|
||||
function shouldDisplayMessageForUser(array $row, string $currentUserId): bool
|
||||
{
|
||||
if (!isRecallMessage($row)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (string)$row['_from'] === $currentUserId;
|
||||
}
|
||||
|
||||
function formatMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
string $peerId,
|
||||
array $receiptState = [],
|
||||
array $editState = [],
|
||||
?array $attachment = null
|
||||
): array
|
||||
{
|
||||
$messageId = (string)$row['_mid'];
|
||||
$from = (string)$row['_from'];
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
$isRecalled = $contentType === 80;
|
||||
$text = (string)($row['_searchable_key'] ?? '');
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
|
||||
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||
$deliveryStatus = $direction === 'out' && !$isRecalled
|
||||
? outgoingDeliveryStatus($activityTimestamp, $receiptState)
|
||||
: '';
|
||||
|
||||
return [
|
||||
'id' => $messageId,
|
||||
'messageUid' => $messageId,
|
||||
'conversationId' => 'single-' . $peerId,
|
||||
'from' => $from,
|
||||
'to' => $from === $currentUserId ? $peerId : $currentUserId,
|
||||
'target' => $peerId,
|
||||
'direction' => $direction,
|
||||
'type' => $messageType,
|
||||
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
|
||||
'text' => $messageText,
|
||||
'attachment' => $attachment,
|
||||
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
|
||||
'deliveryStatus' => $deliveryStatus,
|
||||
'recalled' => $isRecalled,
|
||||
'recallStatus' => $isRecalled ? '已撤回' : '',
|
||||
'editedAt' => $editedAt,
|
||||
'timestamp' => $timestamp,
|
||||
'activityTimestamp' => $activityTimestamp,
|
||||
'displayTimestamp' => $timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
function loadConversationMessages(PDO $db, string $userId, string $targetId, int $limit, bool $markRead): array
|
||||
{
|
||||
$table = imUserMessagesTable($userId);
|
||||
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
|
||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
||||
. ' and `_target` in (?, ?)'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$statement->execute([$userId, $conversationKeys[0], $conversationKeys[1]]);
|
||||
$userRows = $statement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
|
||||
$editMap = fetchMessageEditMap($db, array_column($userRows, '_mid'));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
|
||||
$receiptState = loadPeerReceiptState($db, $userId, $targetId);
|
||||
|
||||
$messages = [];
|
||||
$seenMessageIds = [];
|
||||
$readTimestamp = 0;
|
||||
foreach ($userRows as $userRow) {
|
||||
$messageId = (string)$userRow['_mid'];
|
||||
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
|
||||
continue;
|
||||
}
|
||||
$message = formatMessage(
|
||||
$messageRows[$messageId],
|
||||
$userId,
|
||||
$targetId,
|
||||
$receiptState,
|
||||
$editMap[$messageId] ?? [],
|
||||
$attachmentMap[$messageId] ?? null
|
||||
);
|
||||
$messages[] = $message;
|
||||
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||
if (count($messages) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$messages = array_reverse($messages);
|
||||
|
||||
if ($markRead) {
|
||||
markConversationRead($db, $userId, $targetId, $readTimestamp);
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
||||
{
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 20, 200), 1000);
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$statement->execute([$userId]);
|
||||
|
||||
$scanRows = $statement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
|
||||
$editMap = fetchMessageEditMap($db, array_column($scanRows, '_mid'));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
|
||||
$latestByPeer = [];
|
||||
foreach ($scanRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
$message = $messageRows[$messageId] ?? null;
|
||||
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
|
||||
if ($peerId === '' || !$message) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldDisplayMessageForUser($message, $userId)) {
|
||||
continue;
|
||||
}
|
||||
$activityTimestamp = messageActivityTimestamp(
|
||||
timestampMs((string)($message['_dt'] ?? ''), $messageId),
|
||||
$editMap[$messageId] ?? []
|
||||
);
|
||||
if (!isset($latestByPeer[$peerId]) || $activityTimestamp > (int)($latestByPeer[$peerId]['_activity_at'] ?? 0)) {
|
||||
$row['_activity_at'] = $activityTimestamp;
|
||||
$latestByPeer[$peerId] = $row;
|
||||
}
|
||||
}
|
||||
uasort($latestByPeer, static fn($a, $b) => (int)($b['_activity_at'] ?? 0) <=> (int)($a['_activity_at'] ?? 0));
|
||||
$latestByPeer = array_slice($latestByPeer, 0, $limit, true);
|
||||
|
||||
$users = fetchUserInfoMap($db, array_keys($latestByPeer), $userId);
|
||||
$readAtByPeer = loadOwnReadStateMap($db, $userId, array_keys($latestByPeer));
|
||||
$unreadByPeer = [];
|
||||
$seenUnreadMessageIds = [];
|
||||
|
||||
foreach ($scanRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if (isset($seenUnreadMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenUnreadMessageIds[$messageId] = true;
|
||||
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
|
||||
$message = $messageRows[$messageId] ?? null;
|
||||
if ($peerId === '' || !$message || !isset($latestByPeer[$peerId])) {
|
||||
continue;
|
||||
}
|
||||
if ((string)$message['_from'] === $userId) {
|
||||
continue;
|
||||
}
|
||||
if (isRecallMessage($message)) {
|
||||
continue;
|
||||
}
|
||||
$timestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
|
||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editMap[$messageId] ?? []);
|
||||
if ($activityTimestamp > (int)($readAtByPeer[$peerId] ?? 0)) {
|
||||
$unreadByPeer[$peerId] = (int)($unreadByPeer[$peerId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$conversations = [];
|
||||
|
||||
foreach ($latestByPeer as $peerId => $userRow) {
|
||||
$messageId = (string)$userRow['_mid'];
|
||||
$message = isset($messageRows[$messageId])
|
||||
? formatMessage($messageRows[$messageId], $userId, $peerId, [], $editMap[$messageId] ?? [], $attachmentMap[$messageId] ?? null)
|
||||
: null;
|
||||
$title = userTitle($users, $peerId);
|
||||
$conversations[] = [
|
||||
'id' => 'single-' . $peerId,
|
||||
'type' => 'single',
|
||||
'target' => $peerId,
|
||||
'title' => $title,
|
||||
'subtitle' => $message ? (messagePreviewText($message) !== '' ? messagePreviewText($message) : (empty($message['recalled']) ? '' : '已撤回')) : '',
|
||||
'unread' => (int)($unreadByPeer[$peerId] ?? 0),
|
||||
'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],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable) {
|
||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
||||
}
|
||||
|
||||
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||
|
||||
return array_slice($conversations, 0, $limit);
|
||||
}
|
||||
|
||||
$payload = readJsonPayload();
|
||||
$userId = trim((string)($payload['userId'] ?? ''));
|
||||
$target = trim((string)($payload['target'] ?? ''));
|
||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||
|
||||
if ($userId === '') {
|
||||
respond(400, '缺少用户ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$db = imDb();
|
||||
$userId = resolveUserId($db, $userId);
|
||||
markUserDelivered($db, $userId, nowMs());
|
||||
|
||||
if ($target !== '') {
|
||||
$targetId = resolveUserId($db, $target);
|
||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
respond(0, 'success', [
|
||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
|
||||
respond(0, 'success', [
|
||||
'conversations' => loadRecentConversations($db, $userId, $limit),
|
||||
]);
|
||||
} 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,411 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||
{
|
||||
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
|
||||
return false;
|
||||
}
|
||||
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
|
||||
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
|
||||
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
|
||||
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
|
||||
$startMinutes = $startHour * 60 + $startMinute;
|
||||
$endMinutes = $endHour * 60 + $endMinute;
|
||||
if ($startMinutes === $endMinutes) {
|
||||
return true;
|
||||
}
|
||||
if ($startMinutes < $endMinutes) {
|
||||
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
|
||||
}
|
||||
|
||||
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
|
||||
}
|
||||
|
||||
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
|
||||
{
|
||||
if ($role === 'owner') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
|
||||
$statement->execute([$groupId]);
|
||||
$rule = $statement->fetch();
|
||||
if (!is_array($rule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mode = (string)$rule['mode'];
|
||||
if ($mode === 'off') {
|
||||
return false;
|
||||
}
|
||||
if ($mode === 'all_except_admins' && $role === 'admin') {
|
||||
return false;
|
||||
}
|
||||
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheduleType = (string)$rule['schedule_type'];
|
||||
if ($scheduleType === 'manual') {
|
||||
return true;
|
||||
}
|
||||
if ($scheduleType === 'daily') {
|
||||
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
|
||||
}
|
||||
|
||||
$startsAtMs = (int)$rule['starts_at_ms'];
|
||||
$endsAtMs = (int)$rule['ends_at_ms'];
|
||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||
}
|
||||
|
||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||
{
|
||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||
}
|
||||
|
||||
function safeUploadName(string $fileName): string
|
||||
{
|
||||
$name = basename(str_replace('\\', '/', $fileName));
|
||||
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
|
||||
$name = trim($name, " .\t\n\r\0\x0B");
|
||||
|
||||
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
|
||||
}
|
||||
|
||||
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
|
||||
{
|
||||
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
||||
if ($extension === 'jpeg') {
|
||||
$extension = 'jpg';
|
||||
}
|
||||
|
||||
if ($attachmentType === 'image') {
|
||||
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
|
||||
return $extension;
|
||||
}
|
||||
return match ($mime) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
||||
'image/svg+xml' => 'svg',
|
||||
'image/avif' => 'avif',
|
||||
'image/heic' => 'heic',
|
||||
'image/heif' => 'heif',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
if ($extension !== '') {
|
||||
return $extension;
|
||||
}
|
||||
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function detectedUploadMime(string $tmpName): string
|
||||
{
|
||||
$mime = mime_content_type($tmpName) ?: '';
|
||||
if ($mime !== '' && $mime !== 'application/octet-stream') {
|
||||
return $mime;
|
||||
}
|
||||
|
||||
$imageInfo = @getimagesize($tmpName);
|
||||
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
|
||||
return (string)$imageInfo['mime'];
|
||||
}
|
||||
|
||||
return $mime !== '' ? $mime : 'application/octet-stream';
|
||||
}
|
||||
|
||||
function normalizedImageUploadMime(string $tmpName, string $mime): string
|
||||
{
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
return $mime;
|
||||
}
|
||||
|
||||
$imageInfo = @getimagesize($tmpName);
|
||||
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
|
||||
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
|
||||
return $imageMime;
|
||||
}
|
||||
|
||||
throw new RuntimeException('请选择图片文件');
|
||||
}
|
||||
|
||||
function mediaPublicBaseUrl(): string
|
||||
{
|
||||
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
||||
$scheme = $isHttps ? 'https' : 'http';
|
||||
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
||||
|
||||
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
|
||||
}
|
||||
|
||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
}
|
||||
$size = (int)($file['size'] ?? 0);
|
||||
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
|
||||
if ($size <= 0 || $size > $maxSize) {
|
||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
||||
}
|
||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||
throw new RuntimeException('媒体存储目录不可写');
|
||||
}
|
||||
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
}
|
||||
$mime = detectedUploadMime($tmpName);
|
||||
if ($attachmentType === 'image') {
|
||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||
}
|
||||
|
||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
||||
throw new RuntimeException('媒体存储目录创建失败');
|
||||
}
|
||||
|
||||
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||
$extension = uploadExtension($originalName, $mime, $attachmentType);
|
||||
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
||||
$targetPath = $targetDir . '/' . $targetName;
|
||||
if (!move_uploaded_file($tmpName, $targetPath)) {
|
||||
throw new RuntimeException('文件保存失败');
|
||||
}
|
||||
@chmod($targetPath, 0644);
|
||||
|
||||
return [
|
||||
'type' => $attachmentType,
|
||||
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
|
||||
'name' => $originalName,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'mediaType' => $mediaType,
|
||||
];
|
||||
}
|
||||
|
||||
function storeMessageAttachment(
|
||||
PDO $appDb,
|
||||
string $messageUid,
|
||||
string $conversationType,
|
||||
string $target,
|
||||
string $sender,
|
||||
array $attachment,
|
||||
int $timestamp
|
||||
): void {
|
||||
if ($messageUid === '' || empty($attachment['url'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_message_attachments`'
|
||||
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
|
||||
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
. ' on duplicate key update'
|
||||
. ' `attachment_type` = values(`attachment_type`),'
|
||||
. ' `url` = values(`url`),'
|
||||
. ' `name` = values(`name`),'
|
||||
. ' `mime` = values(`mime`),'
|
||||
. ' `size_bytes` = values(`size_bytes`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$messageUid,
|
||||
$conversationType,
|
||||
$target,
|
||||
$sender,
|
||||
(string)$attachment['type'],
|
||||
(string)$attachment['url'],
|
||||
(string)$attachment['name'],
|
||||
(string)$attachment['mime'],
|
||||
(int)$attachment['size'],
|
||||
$timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||
if (!is_array($attachmentFile)) {
|
||||
respond(400, '缺少上传文件');
|
||||
}
|
||||
|
||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
||||
}
|
||||
|
||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
|
||||
$payload = $isMultipart ? [] : readJsonPayload();
|
||||
$sender = messageRequestValue($payload, 'sender');
|
||||
$target = messageRequestValue($payload, 'target');
|
||||
$text = messageRequestValue($payload, 'text');
|
||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||
$attachment = null;
|
||||
|
||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
||||
respond(400, '缺少发送者、接收者或消息内容');
|
||||
}
|
||||
|
||||
try {
|
||||
if ($isGroupConversation) {
|
||||
ensureWebImAppTables();
|
||||
$appDb = imAppDb();
|
||||
$memberStatement = $appDb->prepare(
|
||||
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
|
||||
. ' from `t_web_groups` g'
|
||||
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
|
||||
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
||||
. ' limit 1'
|
||||
);
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$category = (string)($group['category'] ?? 'group');
|
||||
$role = (string)($group['role'] ?? 'member');
|
||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||
respond(403, '公告群仅管理员可发言');
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||
respond(403, '当前群组处于禁言时段');
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$messageUid = webImMessageUid();
|
||||
$insert = $appDb->prepare(
|
||||
'insert into `t_web_group_messages`'
|
||||
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
|
||||
. ' values (?, ?, ?, ?, ?, 0, 0)'
|
||||
);
|
||||
$insert->execute([$messageUid, $target, $sender, $text, $now]);
|
||||
if ($attachment) {
|
||||
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
|
||||
}
|
||||
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
||||
$updateGroup->execute([$now, $target]);
|
||||
|
||||
$nativeError = '';
|
||||
try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($attachment) {
|
||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||
$payloadBody['extra'] = json_encode([
|
||||
'name' => (string)$attachment['name'],
|
||||
'mime' => (string)$attachment['mime'],
|
||||
'size' => (int)$attachment['size'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => [
|
||||
'type' => 1,
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
} catch (Throwable $error) {
|
||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||
}
|
||||
|
||||
respond(0, 'success', [
|
||||
'messageUid' => $messageUid,
|
||||
'timestamp' => $now,
|
||||
'senderRole' => $role,
|
||||
'attachment' => $attachment,
|
||||
'nativeError' => $nativeError,
|
||||
]);
|
||||
}
|
||||
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
if ($attachment) {
|
||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||
$payloadBody['extra'] = json_encode([
|
||||
'name' => (string)$attachment['name'],
|
||||
'mime' => (string)$attachment['mime'],
|
||||
'size' => (int)$attachment['size'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => [
|
||||
'type' => 0,
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '发送失败');
|
||||
|
||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||
if ($attachment && $messageUid !== '') {
|
||||
ensureWebImAppTables();
|
||||
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
|
||||
}
|
||||
|
||||
respond(0, 'success', [
|
||||
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||
'timestamp' => $timestamp,
|
||||
'attachment' => $attachment,
|
||||
]);
|
||||
} catch (Throwable $error) {
|
||||
respond(500, $error->getMessage() ?: '发送失败');
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 958 KiB |
Binary file not shown.
|
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>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+9
-9
@@ -1,15 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
||||
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
|
||||
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
|
||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
||||
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
|
||||
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
|
||||
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
|
||||
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
|
||||
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
|
||||
{
|
||||
|
||||
Vendored
+236
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||
|
||||
function failMediaProxy(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
function mediaProxyAllowedHosts(): array
|
||||
{
|
||||
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||
preg_split('/[,\s]+/', $configured) ?: []
|
||||
);
|
||||
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
function validatedMediaProxyUrl(string $url): array
|
||||
{
|
||||
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
failMediaProxy(400, '媒体地址不合法');
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||
if (
|
||||
$scheme !== 'https'
|
||||
|| $host === ''
|
||||
|| $port !== 443
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||
) {
|
||||
failMediaProxy(403, '媒体来源不允许代理');
|
||||
}
|
||||
|
||||
return [$url, $host];
|
||||
}
|
||||
|
||||
function mediaProxyCacheRoot(): string
|
||||
{
|
||||
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||
failMediaProxy(503, '媒体缓存目录不可用');
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function mediaProxyPreferredIps(): array
|
||||
{
|
||||
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||
$ips = [];
|
||||
foreach ($configured as $ip) {
|
||||
$ip = trim($ip);
|
||||
if (
|
||||
$ip !== ''
|
||||
&& filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false
|
||||
) {
|
||||
$ips[$ip] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($ips);
|
||||
}
|
||||
|
||||
function mediaProxyMime(string $path): string
|
||||
{
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||
if ($finfo !== false) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||
{
|
||||
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||
$lastError = '媒体源无响应';
|
||||
$preferredIps = mediaProxyPreferredIps();
|
||||
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||
|
||||
foreach ($targets as $index => $preferredIp) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$attempt = $index + 1;
|
||||
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||
$stream = @fopen($temporaryFile, 'x+b');
|
||||
if ($stream === false) {
|
||||
$lastError = '无法创建媒体缓存文件';
|
||||
break;
|
||||
}
|
||||
|
||||
$bytes = 0;
|
||||
$tooLarge = false;
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
fclose($stream);
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '无法初始化媒体请求';
|
||||
break;
|
||||
}
|
||||
|
||||
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||
CURLOPT_FRESH_CONNECT => true,
|
||||
CURLOPT_FORBID_REUSE => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||
$length = strlen($chunk);
|
||||
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$written = fwrite($stream, $chunk);
|
||||
if ($written === false) {
|
||||
return 0;
|
||||
}
|
||||
$bytes += $written;
|
||||
return $written;
|
||||
},
|
||||
];
|
||||
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||
}
|
||||
curl_setopt_array($curl, $curlOptions);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fflush($stream);
|
||||
fclose($stream);
|
||||
|
||||
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||
if (!@rename($temporaryFile, $cacheFile)) {
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '媒体缓存保存失败';
|
||||
break;
|
||||
}
|
||||
@chmod($cacheFile, 0644);
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($temporaryFile);
|
||||
$lastError = $tooLarge
|
||||
? '图片超过代理大小限制'
|
||||
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||
}
|
||||
|
||||
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||
{
|
||||
if (!is_file($cacheFile)) {
|
||||
failMediaProxy(404, '媒体缓存不存在');
|
||||
}
|
||||
$mime = mediaProxyMime($cacheFile);
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
failMediaProxy(415, '媒体内容不是图片');
|
||||
}
|
||||
|
||||
header('ETag: "' . $etag . '"');
|
||||
header('Cache-Control: public, max-age=86400, immutable');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
readfile($cacheFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||
$cacheRoot = mediaProxyCacheRoot();
|
||||
$cacheKey = hash('sha256', $url);
|
||||
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||
|
||||
if (!is_file($cacheFile)) {
|
||||
$lock = @fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||
}
|
||||
if (!is_file($cacheFile)) {
|
||||
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||
}
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||
Vendored
+1618
-900
File diff suppressed because it is too large
Load Diff
Vendored
+8
-7
@@ -1,12 +1,13 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
||||
appServer: 'http://winchen.ezpay.asia:8888',
|
||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
||||
imServerHost: 'winchen.ezpay.asia',
|
||||
imRoutePort: 18000,
|
||||
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'http'
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
runtimeEnvironment: 'local',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/local-api/platform_config.php',
|
||||
registerConfigApi: '/local-api/register_config.php',
|
||||
registerPhoneExistsApi: 'http://18.141.44.165:8100/api/register-phone-exists',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'legacy-http',
|
||||
syncIntervalMs: 2500,
|
||||
sseEnabled: false,
|
||||
sseTicketApi: '/local-api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
+23
@@ -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'
|
||||
};
|
||||
})();
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.cd0faf68.js"></script><script defer="defer" src="js/app.26bc1008.js"></script><link href="css/app.5af3ef7e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="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.d5ef3122.js"></script><link href="css/app.5af3ef7e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||
Vendored
-3213
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+9540
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
|
||||
/* harmony export */ R1: function() { return /* binding */ unref; },
|
||||
/* harmony export */ X2: function() { return /* binding */ ReactiveEffect; },
|
||||
/* harmony export */ bl: function() { return /* binding */ resetTracking; },
|
||||
/* harmony export */ fE: function() { return /* binding */ isShallow; },
|
||||
/* harmony export */ g8: function() { return /* binding */ isReactive; },
|
||||
/* harmony export */ hV: function() { return /* binding */ traverse; },
|
||||
/* harmony export */ hZ: function() { return /* binding */ trigger; },
|
||||
@@ -23,13 +24,14 @@
|
||||
/* harmony export */ jr: function() { return /* binding */ onScopeDispose; },
|
||||
/* harmony export */ ju: function() { return /* binding */ isProxy; },
|
||||
/* harmony export */ o5: function() { return /* binding */ getCurrentScope; },
|
||||
/* harmony export */ qA: function() { return /* binding */ shallowReadArray; },
|
||||
/* harmony export */ u4: function() { return /* binding */ track; },
|
||||
/* harmony export */ uY: function() { return /* binding */ effectScope; },
|
||||
/* harmony export */ ux: function() { return /* binding */ toRaw; },
|
||||
/* harmony export */ wB: function() { return /* binding */ watch; },
|
||||
/* harmony export */ yC: function() { return /* binding */ EffectScope; }
|
||||
/* harmony export */ });
|
||||
/* unused harmony exports ARRAY_ITERATE_KEY, EffectFlags, ITERATE_KEY, MAP_KEY_ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, WatchErrorCodes, customRef, effect, enableTracking, getCurrentWatcher, isReadonly, isShallow, onEffectCleanup, onWatcherCleanup, reactiveReadArray, readonly, shallowReadArray, shallowReadonly, shallowRef, stop, toReactive, toReadonly, toRef, toValue, triggerRef */
|
||||
/* unused harmony exports ARRAY_ITERATE_KEY, EffectFlags, ITERATE_KEY, MAP_KEY_ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, WatchErrorCodes, customRef, effect, enableTracking, getCurrentWatcher, isReadonly, onEffectCleanup, onWatcherCleanup, reactiveReadArray, readonly, shallowReadonly, shallowRef, stop, toReadonly, toRef, toValue, triggerRef */
|
||||
/* unused harmony import specifier */ var extend;
|
||||
/* unused harmony import specifier */ var toRawType;
|
||||
/* unused harmony import specifier */ var isFunction;
|
||||
@@ -1825,6 +1827,10 @@ function traverse(value, depth = Infinity, seen) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||
/* harmony export */ "lJ", 0, /* binding */ toReactive
|
||||
/* harmony export */ ]);
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
@@ -2363,11 +2369,15 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* harmony export */ Wv: function() { return /* binding */ createBlock; },
|
||||
/* harmony export */ bo: function() { return /* binding */ withDirectives; },
|
||||
/* harmony export */ dY: function() { return /* binding */ nextTick; },
|
||||
/* harmony export */ eW: function() { return /* binding */ createTextVNode; },
|
||||
/* harmony export */ h: function() { return /* binding */ h; },
|
||||
/* harmony export */ pI: function() { return /* binding */ renderList; },
|
||||
/* harmony export */ pM: function() { return /* binding */ defineComponent; },
|
||||
/* harmony export */ qL: function() { return /* binding */ callWithAsyncErrorHandling; },
|
||||
/* harmony export */ uX: function() { return /* binding */ openBlock; },
|
||||
/* harmony export */ wB: function() { return /* binding */ watch; }
|
||||
/* harmony export */ });
|
||||
/* unused harmony exports BaseTransition, BaseTransitionPropsValidators, Comment, DeprecationTypes, ErrorCodes, ErrorTypeStrings, KeepAlive, Static, Suspense, Teleport, Text, assertNumber, callWithErrorHandling, cloneVNode, compatUtils, createHydrationRenderer, createPropsRestProxy, createSlots, createTextVNode, defineAsyncComponent, defineComponent, defineEmits, defineExpose, defineModel, defineOptions, defineProps, defineSlots, devtools, getCurrentInstance, getTransitionRawChildren, guardReactiveProps, h, handleError, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, initCustomFormatter, isMemoSame, isRuntimeOnly, isVNode, mergeDefaults, mergeModels, mergeProps, onActivated, onBeforeMount, onBeforeUpdate, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, popScopeId, provide, pushScopeId, queuePostFlushCb, registerRuntimeCompiler, renderList, renderSlot, resolveComponent, resolveDirective, resolveDynamicComponent, resolveFilter, resolveTransitionHooks, setBlockTracking, setDevtoolsHook, setTransitionHooks, ssrContextKey, ssrUtils, toHandlers, transformVNodeArgs, useAttrs, useId, useModel, useSSRContext, useSlots, useTemplateRef, useTransitionState, version, warn, watchEffect, watchPostEffect, watchSyncEffect, withAsyncContext, withCtx, withDefaults, withMemo, withScopeId */
|
||||
/* unused harmony exports BaseTransition, BaseTransitionPropsValidators, Comment, DeprecationTypes, ErrorCodes, ErrorTypeStrings, KeepAlive, Static, Suspense, Text, assertNumber, callWithErrorHandling, cloneVNode, compatUtils, createHydrationRenderer, createPropsRestProxy, createSlots, defineAsyncComponent, defineEmits, defineExpose, defineModel, defineOptions, defineProps, defineSlots, devtools, getCurrentInstance, getTransitionRawChildren, guardReactiveProps, handleError, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, initCustomFormatter, isMemoSame, isRuntimeOnly, isVNode, mergeDefaults, mergeModels, mergeProps, onActivated, onBeforeMount, onBeforeUpdate, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, onServerPrefetch, onUnmounted, onUpdated, popScopeId, provide, pushScopeId, queuePostFlushCb, registerRuntimeCompiler, renderSlot, resolveComponent, resolveDirective, resolveDynamicComponent, resolveFilter, resolveTransitionHooks, setBlockTracking, setDevtoolsHook, setTransitionHooks, ssrContextKey, ssrUtils, toHandlers, transformVNodeArgs, useAttrs, useId, useModel, useSSRContext, useSlots, useTemplateRef, useTransitionState, version, warn, watchEffect, watchPostEffect, watchSyncEffect, withAsyncContext, withCtx, withDefaults, withMemo, withScopeId */
|
||||
/* unused harmony import specifier */ var pauseTracking;
|
||||
/* unused harmony import specifier */ var resetTracking;
|
||||
/* unused harmony import specifier */ var isRef;
|
||||
@@ -2375,11 +2385,9 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var shallowRef;
|
||||
/* unused harmony import specifier */ var isReactive;
|
||||
/* unused harmony import specifier */ var ref;
|
||||
/* unused harmony import specifier */ var isShallow;
|
||||
/* unused harmony import specifier */ var shallowReadArray;
|
||||
/* unused harmony import specifier */ var toReactive;
|
||||
/* unused harmony import specifier */ var customRef;
|
||||
/* unused harmony import specifier */ var track;
|
||||
/* unused harmony import specifier */ var isShallow;
|
||||
/* unused harmony import specifier */ var isString;
|
||||
/* unused harmony import specifier */ var isFunction;
|
||||
/* unused harmony import specifier */ var extend;
|
||||
@@ -2401,13 +2409,13 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var isRegExp;
|
||||
/* unused harmony import specifier */ var camelize;
|
||||
/* unused harmony import specifier */ var capitalize;
|
||||
/* unused harmony import specifier */ var isObject;
|
||||
/* unused harmony import specifier */ var isSymbol;
|
||||
/* unused harmony import specifier */ var toHandlerKey;
|
||||
/* unused harmony import specifier */ var isGloballyAllowed;
|
||||
/* unused harmony import specifier */ var NOOP;
|
||||
/* unused harmony import specifier */ var isPromise;
|
||||
/* unused harmony import specifier */ var makeMap;
|
||||
/* unused harmony import specifier */ var isObject;
|
||||
/* unused harmony import specifier */ var toRawType;
|
||||
/* unused harmony import specifier */ var hyphenate;
|
||||
/* unused harmony import specifier */ var hasChanged;
|
||||
@@ -3174,7 +3182,7 @@ const isTargetSVG = target => typeof SVGElement !== "undefined" && target instan
|
||||
const isTargetMathML = target => typeof MathMLElement === "function" && target instanceof MathMLElement;
|
||||
const resolveTarget = (props, select) => {
|
||||
const targetSelector = props && props.to;
|
||||
if (isString(targetSelector)) {
|
||||
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(targetSelector)) {
|
||||
if (!select) {
|
||||
false && 0;
|
||||
return null;
|
||||
@@ -3190,7 +3198,7 @@ const resolveTarget = (props, select) => {
|
||||
return targetSelector;
|
||||
}
|
||||
};
|
||||
const TeleportImpl = (/* unused pure expression or super */ null && ({
|
||||
const TeleportImpl = {
|
||||
name: "Teleport",
|
||||
__isTeleport: true,
|
||||
process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) {
|
||||
@@ -3333,7 +3341,7 @@ const TeleportImpl = (/* unused pure expression or super */ null && ({
|
||||
},
|
||||
move: moveTeleport,
|
||||
hydrate: hydrateTeleport
|
||||
}));
|
||||
};
|
||||
function moveTeleport(vnode, container, parentAnchor, {
|
||||
o: {
|
||||
insert
|
||||
@@ -3408,7 +3416,7 @@ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScope
|
||||
}
|
||||
return vnode.anchor && nextSibling(vnode.anchor);
|
||||
}
|
||||
const Teleport = (/* unused pure expression or super */ null && (TeleportImpl));
|
||||
const Teleport = TeleportImpl;
|
||||
function updateCssVars(vnode, isDisabled) {
|
||||
const ctx = vnode.ctx;
|
||||
if (ctx && ctx.ut) {
|
||||
@@ -3774,11 +3782,11 @@ function getTransitionRawChildren(children, keepComment = false, parentKey) {
|
||||
/*! #__NO_SIDE_EFFECTS__ */
|
||||
// @__NO_SIDE_EFFECTS__
|
||||
function defineComponent(options, extraOptions) {
|
||||
return isFunction(options) ?
|
||||
return (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isFunction */ .Tn)(options) ?
|
||||
// #8236: extend call and options.name access are considered side-effects
|
||||
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
|
||||
/* @__PURE__ */
|
||||
(() => extend({
|
||||
(() => (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .extend */ .X$)({
|
||||
name: options.name
|
||||
}, extraOptions, {
|
||||
setup: options
|
||||
@@ -4998,17 +5006,17 @@ function resolve(registry, name) {
|
||||
function renderList(source, renderItem, cache, index) {
|
||||
let ret;
|
||||
const cached = cache && cache[index];
|
||||
const sourceIsArray = isArray(source);
|
||||
if (sourceIsArray || isString(source)) {
|
||||
const sourceIsReactiveArray = sourceIsArray && isReactive(source);
|
||||
const sourceIsArray = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(source);
|
||||
if (sourceIsArray || (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isString */ .Kg)(source)) {
|
||||
const sourceIsReactiveArray = sourceIsArray && (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isReactive */ .g8)(source);
|
||||
let needsWrap = false;
|
||||
if (sourceIsReactiveArray) {
|
||||
needsWrap = !isShallow(source);
|
||||
source = shallowReadArray(source);
|
||||
needsWrap = !(0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .isShallow */ .fE)(source);
|
||||
source = (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .shallowReadArray */ .qA)(source);
|
||||
}
|
||||
ret = new Array(source.length);
|
||||
for (let i = 0, l = source.length; i < l; i++) {
|
||||
ret[i] = renderItem(needsWrap ? toReactive(source[i]) : source[i], i, void 0, cached && cached[i]);
|
||||
ret[i] = renderItem(needsWrap ? (0,_vue_reactivity__WEBPACK_IMPORTED_MODULE_20__/* .toReactive */ .lJ)(source[i]) : source[i], i, void 0, cached && cached[i]);
|
||||
}
|
||||
} else if (typeof source === "number") {
|
||||
if (false) // removed by dead control flow
|
||||
@@ -5017,7 +5025,7 @@ function renderList(source, renderItem, cache, index) {
|
||||
for (let i = 0; i < source; i++) {
|
||||
ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]);
|
||||
}
|
||||
} else if (isObject(source)) {
|
||||
} else if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isObject */ .Gv)(source)) {
|
||||
if (source[Symbol.iterator]) {
|
||||
ret = Array.from(source, (item, i) => renderItem(item, i, void 0, cached && cached[i]));
|
||||
} else {
|
||||
@@ -9275,7 +9283,7 @@ const computed = (getterOrOptions, debugOptions) => {
|
||||
function h(type, propsOrChildren, children) {
|
||||
const l = arguments.length;
|
||||
if (l === 2) {
|
||||
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
|
||||
if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isObject */ .Gv)(propsOrChildren) && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(propsOrChildren)) {
|
||||
if (isVNode(propsOrChildren)) {
|
||||
return createVNode(type, null, [propsOrChildren]);
|
||||
}
|
||||
@@ -9449,6 +9457,7 @@ const DeprecationTypes = null;
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||
/* harmony export */ "EW", 0, /* binding */ computed,
|
||||
/* harmony export */ "FK", 0, /* binding */ Fragment,
|
||||
/* harmony export */ "Im", 0, /* binding */ Teleport,
|
||||
/* harmony export */ "bF", 0, /* binding */ createVNode,
|
||||
/* harmony export */ "sV", 0, /* binding */ onMounted,
|
||||
/* harmony export */ "xo", 0, /* binding */ onBeforeUnmount
|
||||
@@ -9989,7 +9998,7 @@ const stringifySymbol = (v, i = "") => {
|
||||
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
/* unused harmony exports Transition, TransitionGroup, VueElement, createSSRApp, defineCustomElement, defineSSRCustomElement, hydrate, initDirectivesForSSR, render, useCssModule, useCssVars, useHost, useShadowRoot, vModelCheckbox, vModelDynamic, vModelRadio, vModelSelect, vShow, withKeys */
|
||||
/* unused harmony exports Transition, TransitionGroup, VueElement, createSSRApp, defineCustomElement, defineSSRCustomElement, hydrate, initDirectivesForSSR, render, useCssModule, useCssVars, useHost, useShadowRoot, vModelCheckbox, vModelDynamic, vModelRadio, vShow */
|
||||
/* unused harmony import specifier */ var BaseTransitionPropsValidators;
|
||||
/* unused harmony import specifier */ var h;
|
||||
/* unused harmony import specifier */ var BaseTransition;
|
||||
@@ -10009,7 +10018,6 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var resolveTransitionHooks;
|
||||
/* unused harmony import specifier */ var getTransitionRawChildren;
|
||||
/* unused harmony import specifier */ var createVNode;
|
||||
/* unused harmony import specifier */ var nextTick;
|
||||
/* unused harmony import specifier */ var createHydrationRenderer;
|
||||
/* unused harmony import specifier */ var isRuntimeOnly;
|
||||
/* unused harmony import specifier */ var toRaw;
|
||||
@@ -10024,8 +10032,6 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* unused harmony import specifier */ var looseIndexOf;
|
||||
/* unused harmony import specifier */ var isSet;
|
||||
/* unused harmony import specifier */ var looseEqual;
|
||||
/* unused harmony import specifier */ var looseToNumber;
|
||||
/* unused harmony import specifier */ var hyphenate;
|
||||
/* unused harmony import specifier */ var isHTMLTag;
|
||||
/* unused harmony import specifier */ var isSVGTag;
|
||||
/* unused harmony import specifier */ var isMathMLTag;
|
||||
@@ -11571,7 +11577,7 @@ const vModelRadio = (/* unused pure expression or super */ null && ({
|
||||
}
|
||||
}
|
||||
}));
|
||||
const vModelSelect = (/* unused pure expression or super */ null && ({
|
||||
const vModelSelect = {
|
||||
// <select multiple> value need to be deep traversed
|
||||
deep: true,
|
||||
created(el, {
|
||||
@@ -11580,12 +11586,12 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
||||
number
|
||||
}
|
||||
}, vnode) {
|
||||
const isSetModel = isSet(value);
|
||||
const isSetModel = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value);
|
||||
addEventListener(el, "change", () => {
|
||||
const selectedVal = Array.prototype.filter.call(el.options, o => o.selected).map(o => number ? looseToNumber(getValue(o)) : getValue(o));
|
||||
const selectedVal = Array.prototype.filter.call(el.options, o => o.selected).map(o => number ? (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseToNumber */ .bB)(getValue(o)) : getValue(o));
|
||||
el[assignKey](el.multiple ? isSetModel ? new Set(selectedVal) : selectedVal : selectedVal[0]);
|
||||
el._assigning = true;
|
||||
nextTick(() => {
|
||||
(0,_vue_runtime_core__WEBPACK_IMPORTED_MODULE_18__/* .nextTick */ .dY)(() => {
|
||||
el._assigning = false;
|
||||
});
|
||||
});
|
||||
@@ -11608,11 +11614,11 @@ const vModelSelect = (/* unused pure expression or super */ null && ({
|
||||
setSelected(el, value);
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
function setSelected(el, value) {
|
||||
const isMultiple = el.multiple;
|
||||
const isArrayValue = isArray(value);
|
||||
if (isMultiple && !isArrayValue && !isSet(value)) {
|
||||
const isArrayValue = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isArray */ .cy)(value);
|
||||
if (isMultiple && !isArrayValue && !(0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .isSet */ .vM)(value)) {
|
||||
false && 0;
|
||||
return;
|
||||
}
|
||||
@@ -11625,12 +11631,12 @@ function setSelected(el, value) {
|
||||
if (optionType === "string" || optionType === "number") {
|
||||
option.selected = value.some(v => String(v) === String(optionValue));
|
||||
} else {
|
||||
option.selected = looseIndexOf(value, optionValue) > -1;
|
||||
option.selected = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseIndexOf */ .u3)(value, optionValue) > -1;
|
||||
}
|
||||
} else {
|
||||
option.selected = value.has(optionValue);
|
||||
}
|
||||
} else if (looseEqual(getValue(option), value)) {
|
||||
} else if ((0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .looseEqual */ .BX)(getValue(option), value)) {
|
||||
if (el.selectedIndex !== i) el.selectedIndex = i;
|
||||
return;
|
||||
}
|
||||
@@ -11755,7 +11761,7 @@ const withModifiers = (fn, modifiers) => {
|
||||
return fn(event, ...args);
|
||||
});
|
||||
};
|
||||
const keyNames = (/* unused pure expression or super */ null && ({
|
||||
const keyNames = {
|
||||
esc: "escape",
|
||||
space: " ",
|
||||
up: "arrow-up",
|
||||
@@ -11763,7 +11769,7 @@ const keyNames = (/* unused pure expression or super */ null && ({
|
||||
right: "arrow-right",
|
||||
down: "arrow-down",
|
||||
delete: "backspace"
|
||||
}));
|
||||
};
|
||||
const withKeys = (fn, modifiers) => {
|
||||
const cache = fn._withKeys || (fn._withKeys = {});
|
||||
const cacheKey = modifiers.join(".");
|
||||
@@ -11771,7 +11777,7 @@ const withKeys = (fn, modifiers) => {
|
||||
if (!("key" in event)) {
|
||||
return;
|
||||
}
|
||||
const eventKey = hyphenate(event.key);
|
||||
const eventKey = (0,_vue_shared__WEBPACK_IMPORTED_MODULE_21__/* .hyphenate */ .Tg)(event.key);
|
||||
if (modifiers.some(k => k === eventKey || keyNames[k] === eventKey)) {
|
||||
return fn(event);
|
||||
}
|
||||
@@ -11901,7 +11907,9 @@ const initDirectivesForSSR = () => {
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, [
|
||||
/* harmony export */ "D$", 0, /* binding */ withModifiers,
|
||||
/* harmony export */ "Ef", 0, /* binding */ createApp,
|
||||
/* harmony export */ "Jo", 0, /* binding */ vModelText
|
||||
/* harmony export */ "Jo", 0, /* binding */ vModelText,
|
||||
/* harmony export */ "jR", 0, /* binding */ withKeys,
|
||||
/* harmony export */ "u1", 0, /* binding */ vModelSelect
|
||||
/* harmony export */ ]);
|
||||
|
||||
|
||||
@@ -11912,9 +11920,11 @@ const initDirectivesForSSR = () => {
|
||||
|
||||
"use strict";
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||
/* harmony export */ Y2: function() { return /* binding */ includeBooleanAttr; }
|
||||
/* harmony export */ BX: function() { return /* binding */ looseEqual; },
|
||||
/* harmony export */ Y2: function() { return /* binding */ includeBooleanAttr; },
|
||||
/* harmony export */ u3: function() { return /* binding */ looseIndexOf; }
|
||||
/* harmony export */ });
|
||||
/* unused harmony exports EMPTY_ARR, EMPTY_OBJ, NO, NOOP, PatchFlagNames, PatchFlags, ShapeFlags, SlotFlags, cssVarNameEscapeSymbolsRE, def, escapeHtml, escapeHtmlComment, genCacheKey, genPropsAccessExp, generateCodeFrame, getEscapedCssVarName, getGlobalThis, hasChanged, isBooleanAttr, isBuiltInDirective, isDate, isGloballyAllowed, isGloballyWhitelisted, isHTMLTag, isIntegerKey, isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, isMap, isMathMLTag, isObject, isPromise, isRegExp, isRenderableAttrValue, isReservedProp, isSSRSafeAttrName, isSVGTag, isSet, isVoidTag, looseEqual, looseIndexOf, makeMap, normalizeClass, normalizeProps, normalizeStyle, objectToString, parseStringStyle, propsToAttrMap, remove, slotFlagsText, stringifyStyle, toDisplayString, toHandlerKey, toRawType, toTypeString */
|
||||
/* unused harmony exports EMPTY_ARR, EMPTY_OBJ, NO, NOOP, PatchFlagNames, PatchFlags, ShapeFlags, SlotFlags, cssVarNameEscapeSymbolsRE, def, escapeHtml, escapeHtmlComment, genCacheKey, genPropsAccessExp, generateCodeFrame, getEscapedCssVarName, getGlobalThis, hasChanged, isBooleanAttr, isBuiltInDirective, isDate, isGloballyAllowed, isGloballyWhitelisted, isHTMLTag, isIntegerKey, isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, isMap, isMathMLTag, isObject, isPromise, isRegExp, isRenderableAttrValue, isReservedProp, isSSRSafeAttrName, isSVGTag, isVoidTag, makeMap, normalizeClass, normalizeProps, normalizeStyle, objectToString, parseStringStyle, propsToAttrMap, remove, slotFlagsText, stringifyStyle, toDisplayString, toHandlerKey, toRawType, toTypeString */
|
||||
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4114);
|
||||
/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__);
|
||||
/* harmony import */ var core_js_modules_es_iterator_constructor_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8111);
|
||||
@@ -12421,7 +12431,8 @@ const stringifySymbol = (v, i = "") => {
|
||||
/* harmony export */ "X$", 0, /* binding */ extend,
|
||||
/* harmony export */ "ZH", 0, /* binding */ capitalize,
|
||||
/* harmony export */ "bB", 0, /* binding */ looseToNumber,
|
||||
/* harmony export */ "cy", 0, /* binding */ isArray
|
||||
/* harmony export */ "cy", 0, /* binding */ isArray,
|
||||
/* harmony export */ "vM", 0, /* binding */ isSet
|
||||
/* harmony export */ ]);
|
||||
|
||||
|
||||
@@ -15965,6 +15976,25 @@ module.exports = function (obj) {
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 8646:
|
||||
/***/ (function(module, __unused_webpack_exports, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var call = __webpack_require__(9565);
|
||||
var anObject = __webpack_require__(8551);
|
||||
var getIteratorDirect = __webpack_require__(1767);
|
||||
var getIteratorMethod = __webpack_require__(851);
|
||||
|
||||
module.exports = function (obj, stringHandling) {
|
||||
if (!stringHandling || typeof obj !== 'string') anObject(obj);
|
||||
var method = getIteratorMethod(obj);
|
||||
return getIteratorDirect(anObject(method !== undefined ? call(method, obj) : obj));
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 851:
|
||||
@@ -18757,6 +18787,89 @@ $({ target: 'Iterator', proto: true, real: true, forced: findWithoutClosingOnEar
|
||||
});
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 531:
|
||||
/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var $ = __webpack_require__(6518);
|
||||
var call = __webpack_require__(9565);
|
||||
var aCallable = __webpack_require__(9306);
|
||||
var anObject = __webpack_require__(8551);
|
||||
var getIteratorDirect = __webpack_require__(1767);
|
||||
var getIteratorFlattenable = __webpack_require__(8646);
|
||||
var createIteratorProxy = __webpack_require__(9462);
|
||||
var iteratorClose = __webpack_require__(9539);
|
||||
var IS_PURE = __webpack_require__(6395);
|
||||
var iteratorHelperThrowsOnInvalidIterator = __webpack_require__(684);
|
||||
var iteratorHelperWithoutClosingOnEarlyError = __webpack_require__(4549);
|
||||
|
||||
// Should not throw an error for an iterator without `return` method. Fixed in Safari 26.2
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=297532
|
||||
function throwsOnIteratorWithoutReturn() {
|
||||
try {
|
||||
// eslint-disable-next-line es/no-map, es/no-iterator, es/no-iterator-prototype-flatmap -- required for testing
|
||||
var it = Iterator.prototype.flatMap.call(new Map([[4, 5]]).entries(), function (v) { return v; });
|
||||
it.next();
|
||||
it['return']();
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR = !IS_PURE
|
||||
&& !iteratorHelperThrowsOnInvalidIterator('flatMap', function () { /* empty */ });
|
||||
var flatMapWithoutClosingOnEarlyError = !IS_PURE && !FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR
|
||||
&& iteratorHelperWithoutClosingOnEarlyError('flatMap', TypeError);
|
||||
|
||||
var FORCED = IS_PURE || FLAT_MAP_WITHOUT_THROWING_ON_INVALID_ITERATOR || flatMapWithoutClosingOnEarlyError
|
||||
|| throwsOnIteratorWithoutReturn();
|
||||
|
||||
var IteratorProxy = createIteratorProxy(function () {
|
||||
var iterator = this.iterator;
|
||||
var mapper = this.mapper;
|
||||
var result, inner;
|
||||
|
||||
while (true) {
|
||||
if (inner = this.inner) try {
|
||||
result = anObject(call(inner.next, inner.iterator));
|
||||
if (!result.done) return result.value;
|
||||
this.inner = null;
|
||||
} catch (error) { iteratorClose(iterator, 'throw', error); }
|
||||
|
||||
result = anObject(call(this.next, iterator));
|
||||
|
||||
if (this.done = !!result.done) return;
|
||||
|
||||
try {
|
||||
this.inner = getIteratorFlattenable(mapper(result.value, this.counter++), false);
|
||||
} catch (error) { iteratorClose(iterator, 'throw', error); }
|
||||
}
|
||||
});
|
||||
|
||||
// `Iterator.prototype.flatMap` method
|
||||
// https://tc39.es/ecma262/#sec-iterator.prototype.flatmap
|
||||
$({ target: 'Iterator', proto: true, real: true, forced: FORCED }, {
|
||||
flatMap: function flatMap(mapper) {
|
||||
anObject(this);
|
||||
try {
|
||||
aCallable(mapper);
|
||||
} catch (error) {
|
||||
iteratorClose(this, 'throw', error);
|
||||
}
|
||||
|
||||
if (flatMapWithoutClosingOnEarlyError) return call(flatMapWithoutClosingOnEarlyError, this, mapper);
|
||||
|
||||
return new IteratorProxy(getIteratorDirect(this), {
|
||||
mapper: mapper,
|
||||
inner: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 7588:
|
||||
@@ -24964,4 +25077,4 @@ function del(target, key) {
|
||||
/***/ })
|
||||
|
||||
}]);
|
||||
//# sourceMappingURL=chunk-vendors.cd0faf68.js.map
|
||||
//# sourceMappingURL=chunk-vendors.d3ec3fbc.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Generated
+30
-7
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.8.0",
|
||||
"axios": "1.6.7",
|
||||
"buffer": "^6.0.3",
|
||||
"jr-qrcode": "^1.1.4",
|
||||
"pinia": "^2.1.7",
|
||||
"process": "^0.11.10",
|
||||
@@ -3533,7 +3534,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3605,6 +3605,31 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz",
|
||||
@@ -3731,10 +3756,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3752,7 +3776,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
@@ -6363,7 +6387,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
+12
-8
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "jc-web-im",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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",
|
||||
"axios": "1.6.7",
|
||||
"jr-qrcode": "^1.1.4",
|
||||
"axios": "1.6.7",
|
||||
"buffer": "^6.0.3",
|
||||
"jr-qrcode": "^1.1.4",
|
||||
"pinia": "^2.1.7",
|
||||
"process": "^0.11.10",
|
||||
"vue": "3.5.13",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
||||
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
|
||||
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
|
||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
||||
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
|
||||
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
|
||||
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
|
||||
define('IM_DB_TIMEZONE', getenv('IM_DB_TIMEZONE') ?: 'Asia/Bangkok');
|
||||
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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||
|
||||
function failMediaProxy(int $status, string $message): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
function mediaProxyAllowedHosts(): array
|
||||
{
|
||||
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||
$hosts = array_map(
|
||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||
preg_split('/[,\s]+/', $configured) ?: []
|
||||
);
|
||||
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
function validatedMediaProxyUrl(string $url): array
|
||||
{
|
||||
if ($url === '' || strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
failMediaProxy(400, '媒体地址不合法');
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
||||
if (
|
||||
$scheme !== 'https'
|
||||
|| $host === ''
|
||||
|| $port !== 443
|
||||
|| isset($parts['user'])
|
||||
|| isset($parts['pass'])
|
||||
|| !in_array($host, mediaProxyAllowedHosts(), true)
|
||||
) {
|
||||
failMediaProxy(403, '媒体来源不允许代理');
|
||||
}
|
||||
|
||||
return [$url, $host];
|
||||
}
|
||||
|
||||
function mediaProxyCacheRoot(): string
|
||||
{
|
||||
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||
failMediaProxy(503, '媒体缓存目录不可用');
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function mediaProxyPreferredIps(): array
|
||||
{
|
||||
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||
$ips = [];
|
||||
foreach ($configured as $ip) {
|
||||
$ip = trim($ip);
|
||||
if (
|
||||
$ip !== ''
|
||||
&& filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false
|
||||
) {
|
||||
$ips[$ip] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($ips);
|
||||
}
|
||||
|
||||
function mediaProxyMime(string $path): string
|
||||
{
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo !== false ? (string)(finfo_file($finfo, $path) ?: '') : '';
|
||||
if ($finfo !== false) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
return $mime;
|
||||
}
|
||||
|
||||
function fetchMediaProxyImage(string $url, string $host, string $cacheFile): void
|
||||
{
|
||||
$deadline = microtime(true) + WEB_MEDIA_PROXY_MAX_SECONDS;
|
||||
$lastError = '媒体源无响应';
|
||||
$preferredIps = mediaProxyPreferredIps();
|
||||
$targets = array_merge($preferredIps, array_fill(0, max(2, 6 - count($preferredIps)), null));
|
||||
|
||||
foreach ($targets as $index => $preferredIp) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$attempt = $index + 1;
|
||||
$temporaryFile = $cacheFile . '.tmp.' . getmypid() . '.' . $attempt;
|
||||
$stream = @fopen($temporaryFile, 'x+b');
|
||||
if ($stream === false) {
|
||||
$lastError = '无法创建媒体缓存文件';
|
||||
break;
|
||||
}
|
||||
|
||||
$bytes = 0;
|
||||
$tooLarge = false;
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
fclose($stream);
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '无法初始化媒体请求';
|
||||
break;
|
||||
}
|
||||
|
||||
$remainingMs = max(1000, (int)floor(($deadline - microtime(true)) * 1000));
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(2500, $remainingMs),
|
||||
CURLOPT_TIMEOUT_MS => min(12000, $remainingMs),
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_DNS_CACHE_TIMEOUT => 0,
|
||||
CURLOPT_FRESH_CONNECT => true,
|
||||
CURLOPT_FORBID_REUSE => true,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'JC-Web-IM-Media-Proxy/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: image/avif,image/webp,image/*,*/*;q=0.8'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($stream, &$bytes, &$tooLarge): int {
|
||||
$length = strlen($chunk);
|
||||
if ($bytes + $length > WEB_MEDIA_PROXY_MAX_BYTES) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
$written = fwrite($stream, $chunk);
|
||||
if ($written === false) {
|
||||
return 0;
|
||||
}
|
||||
$bytes += $written;
|
||||
return $written;
|
||||
},
|
||||
];
|
||||
if (is_string($preferredIp) && $preferredIp !== '') {
|
||||
$curlOptions[CURLOPT_RESOLVE] = [$host . ':443:' . $preferredIp];
|
||||
}
|
||||
curl_setopt_array($curl, $curlOptions);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fflush($stream);
|
||||
fclose($stream);
|
||||
|
||||
$mime = $bytes > 0 && is_file($temporaryFile) ? mediaProxyMime($temporaryFile) : '';
|
||||
if ($result !== false && $status >= 200 && $status < 300 && !$tooLarge && str_starts_with($mime, 'image/')) {
|
||||
if (!@rename($temporaryFile, $cacheFile)) {
|
||||
@unlink($temporaryFile);
|
||||
$lastError = '媒体缓存保存失败';
|
||||
break;
|
||||
}
|
||||
@chmod($cacheFile, 0644);
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($temporaryFile);
|
||||
$lastError = $tooLarge
|
||||
? '图片超过代理大小限制'
|
||||
: ($curlError !== '' ? $curlError : ($status > 0 ? '媒体源返回 HTTP ' . $status : '媒体源无响应'));
|
||||
if ($attempt < count($targets) && microtime(true) < $deadline) {
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf('media_proxy fetch failed host=%s error=%s', $host, $lastError));
|
||||
failMediaProxy(502, '图片源暂时不可用,请稍后重试');
|
||||
}
|
||||
|
||||
function serveMediaProxyImage(string $cacheFile, string $etag): never
|
||||
{
|
||||
if (!is_file($cacheFile)) {
|
||||
failMediaProxy(404, '媒体缓存不存在');
|
||||
}
|
||||
$mime = mediaProxyMime($cacheFile);
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
failMediaProxy(415, '媒体内容不是图片');
|
||||
}
|
||||
|
||||
header('ETag: "' . $etag . '"');
|
||||
header('Cache-Control: public, max-age=86400, immutable');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($cacheFile));
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), ' W/') === '"' . $etag . '"') {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
readfile($cacheFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$url, $host] = validatedMediaProxyUrl(trim((string)($_GET['url'] ?? '')));
|
||||
$cacheRoot = mediaProxyCacheRoot();
|
||||
$cacheKey = hash('sha256', $url);
|
||||
$cacheFile = $cacheRoot . '/' . $cacheKey . '.image';
|
||||
$lockFile = $cacheRoot . '/' . $cacheKey . '.lock';
|
||||
|
||||
if (!is_file($cacheFile)) {
|
||||
$lock = @fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
failMediaProxy(503, '媒体缓存暂时忙碌');
|
||||
}
|
||||
if (!is_file($cacheFile)) {
|
||||
fetchMediaProxyImage($url, $host, $cacheFile);
|
||||
}
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
serveMediaProxyImage($cacheFile, $cacheKey);
|
||||
+1618
-900
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
||||
appServer: 'http://winchen.ezpay.asia:8888',
|
||||
qrAppServer: 'http://winchen.ezpay.asia:18888',
|
||||
adminServer: 'http://18.141.44.165:8100',
|
||||
appServer: '/app-server',
|
||||
qrAppServer: 'http://18.141.44.165:8888',
|
||||
platformConfigApi: '/api/platform_config.php',
|
||||
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
|
||||
imServerHost: 'winchen.ezpay.asia',
|
||||
imRoutePort: 18000,
|
||||
registerConfigApi: 'http://18.141.44.165:8100/api/register-config',
|
||||
imServerHost: '18.141.44.165',
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'http'
|
||||
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'
|
||||
};
|
||||
})();
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Web IM</title>
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
|
||||
<script src="<%= BASE_URL %>config/<%= htmlWebpackPlugin.options.runtimeConfigFile %>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<LoginView v-if="!session.isAuthenticated" />
|
||||
<DocumentedShell v-else />
|
||||
<ChatShell v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import LoginView from './components/LoginView.vue';
|
||||
import DocumentedShell from './components/DocumentedShell.vue';
|
||||
import ChatShell from './components/ChatShell.vue';
|
||||
import { useSessionStore } from './stores/session';
|
||||
|
||||
const session = useSessionStore();
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
class="message-image"
|
||||
@click.stop="handleImageClick(message)"
|
||||
>
|
||||
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
|
||||
<img :src="imageDisplayUrl(message.attachment.url)" :alt="message.attachment.name || '图片'">
|
||||
</button>
|
||||
<a
|
||||
v-else-if="isFileMessage(message)"
|
||||
@@ -209,8 +209,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useContactStore } from '@/stores/contacts';
|
||||
import { imageDisplayUrl } from '@/services/mediaProxy';
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
@@ -573,7 +574,7 @@ function openImageViewer(message) {
|
||||
}
|
||||
closeMenu();
|
||||
imageViewer.visible = true;
|
||||
imageViewer.url = attachment.url;
|
||||
imageViewer.url = imageDisplayUrl(attachment.url);
|
||||
imageViewer.name = attachment.name || 'image';
|
||||
resetImageViewer();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
|
||||
<img :src="imageDisplayUrl(favorite.attachment.url)" :alt="favorite.attachment.name || '图片'">
|
||||
</a>
|
||||
<a
|
||||
v-else-if="isFileFavorite(favorite)"
|
||||
@@ -57,8 +57,9 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useConversationStore } from '@/stores/conversations';
|
||||
import { useNavigationStore } from '@/stores/navigation';
|
||||
import { useConversationStore } from '@/stores/conversations';
|
||||
import { useNavigationStore } from '@/stores/navigation';
|
||||
import { imageDisplayUrl } from '@/services/mediaProxy';
|
||||
|
||||
const conversations = useConversationStore();
|
||||
const navigation = useNavigationStore();
|
||||
|
||||
@@ -30,6 +30,9 @@ export const appConfig = {
|
||||
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
|
||||
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
|
||||
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
|
||||
sseEnabled: runtimeConfig.sseEnabled ?? process.env.VUE_APP_SSE_ENABLED === 'true',
|
||||
sseTicketApi: runtimeConfig.sseTicketApi || process.env.VUE_APP_SSE_TICKET_API || '/api/sse_ticket.php',
|
||||
sseUrl: runtimeConfig.sseUrl || process.env.VUE_APP_SSE_URL || '/ws-events',
|
||||
platform: 5,
|
||||
clientIdStorageKey: 'qmt-web-im-client-id',
|
||||
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
||||
|
||||
@@ -213,16 +213,14 @@ export class HttpImClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
async loginWithPassword(mobile, password) {
|
||||
const url = `${appConfig.appServer}/login_pwd`;
|
||||
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
password,
|
||||
platform: appConfig.platform,
|
||||
clientId: deviceId,
|
||||
uniqueEquipment: deviceId
|
||||
}, { withCredentials: false });
|
||||
async loginWithPassword(mobile, password) {
|
||||
const url = `${appConfig.appServer}/login_pwd`;
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
password,
|
||||
platform: appConfig.platform,
|
||||
clientId: this.getClientId()
|
||||
}, { withCredentials: false });
|
||||
|
||||
this.persistAppToken(url, response);
|
||||
if (response.data?.code !== 0) {
|
||||
@@ -247,16 +245,14 @@ export class HttpImClient {
|
||||
return response.data.result;
|
||||
}
|
||||
|
||||
async loginWithCode(mobile, code) {
|
||||
const url = `${appConfig.appServer}/login`;
|
||||
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
code,
|
||||
platform: appConfig.platform,
|
||||
clientId: deviceId,
|
||||
uniqueEquipment: deviceId
|
||||
}, { withCredentials: false });
|
||||
async loginWithCode(mobile, code) {
|
||||
const url = `${appConfig.appServer}/login`;
|
||||
const response = await axios.post(url, {
|
||||
mobile,
|
||||
code,
|
||||
platform: appConfig.platform,
|
||||
clientId: this.getClientId()
|
||||
}, { withCredentials: false });
|
||||
|
||||
this.persistAppToken(url, response);
|
||||
if (response.data?.code !== 0) {
|
||||
@@ -384,20 +380,30 @@ export class HttpImClient {
|
||||
const response = await axios.post(appConfig.messagesApi, {
|
||||
userId: this.currentUser.userId,
|
||||
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
||||
limit,
|
||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||
markRead: Boolean(options.markRead)
|
||||
limit,
|
||||
conversationType: conversation.type || conversation.raw?.type || 0,
|
||||
markRead: Boolean(options.markRead),
|
||||
includeConversations: Boolean(options.includeConversations)
|
||||
}, { withCredentials: false });
|
||||
|
||||
if (response.data?.code !== 0) {
|
||||
throw new Error(response.data?.message || '消息同步失败');
|
||||
}
|
||||
|
||||
const messages = (response.data?.result?.messages || []).map(message => ({
|
||||
const messages = (response.data?.result?.messages || []).map(message => ({
|
||||
...message,
|
||||
displayTimestamp: message.displayTimestamp || message.timestamp,
|
||||
conversationId: conversation.id
|
||||
}));
|
||||
conversationId: conversation.id
|
||||
}));
|
||||
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
|
||||
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
|
||||
...remoteConversation,
|
||||
updatedAt: Number(remoteConversation.updatedAt || Date.now()),
|
||||
avatarText: remoteConversation.avatarText || String(remoteConversation.title || remoteConversation.target || '?').slice(0, 1).toUpperCase(),
|
||||
raw: remoteConversation.raw || { type: 'single', target: remoteConversation.target, line: 0 }
|
||||
}));
|
||||
this.emit('conversationsChanged', remoteConversations);
|
||||
}
|
||||
const userId = this.currentUser?.userId || '';
|
||||
const messagesByConversation = readLocalMessages(userId);
|
||||
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||||
|
||||
@@ -2,18 +2,20 @@ import { appConfig } from '@/config';
|
||||
import { HttpImClient } from './httpImClient';
|
||||
import { LongConnectionImClient } from './longConnectionImClient';
|
||||
import { DocumentedHttpImClient } from './documentedHttpImClient';
|
||||
|
||||
class UnsupportedWildfireImClient {
|
||||
constructor() {
|
||||
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UnsupportedWildfireImClient {
|
||||
constructor() {
|
||||
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
|
||||
}
|
||||
}
|
||||
|
||||
export const imClient = appConfig.sdkMode === 'wildfire'
|
||||
? new UnsupportedWildfireImClient()
|
||||
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
|
||||
? new LongConnectionImClient()
|
||||
: new DocumentedHttpImClient();
|
||||
: appConfig.sdkMode === 'legacy-http'
|
||||
? new HttpImClient()
|
||||
: new DocumentedHttpImClient();
|
||||
|
||||
export {
|
||||
DocumentedHttpImClient,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
function configuredProxyHosts() {
|
||||
const configured = window.JC_WEB_IM_CONFIG?.mediaProxyHosts;
|
||||
const values = Array.isArray(configured)
|
||||
? configured
|
||||
: String(configured || '').split(/[\s,]+/);
|
||||
return new Set(
|
||||
values
|
||||
.map(value => String(value || '').trim().toLowerCase().replace(/\.$/, ''))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
export function imageDisplayUrl(value) {
|
||||
const source = String(value || '').trim();
|
||||
if (!source || source.startsWith('blob:') || source.startsWith('data:')) {
|
||||
return source;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(source, window.location.origin);
|
||||
if (url.protocol !== 'https:' || !configuredProxyHosts().has(url.hostname.toLowerCase())) {
|
||||
return source;
|
||||
}
|
||||
const proxyUrl = new URL('/api/media_proxy.php', window.location.origin);
|
||||
proxyUrl.searchParams.set('url', url.toString());
|
||||
return proxyUrl.toString();
|
||||
} catch (error) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import axios from 'axios';
|
||||
import { appConfig } from '@/config';
|
||||
import { imClient } from '@/services/imClient';
|
||||
|
||||
class RealtimeNotificationClient {
|
||||
constructor() {
|
||||
this.source = null;
|
||||
this.userId = '';
|
||||
this.handler = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectAttempt = 0;
|
||||
this.generation = 0;
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.source?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
start(userId, handler) {
|
||||
this.stop();
|
||||
if (!appConfig.sseEnabled || !userId || typeof window.WebSocket !== 'function') return;
|
||||
this.userId = userId;
|
||||
this.handler = handler;
|
||||
this.generation += 1;
|
||||
this.open(this.generation);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.generation += 1;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
if (this.source) this.source.close();
|
||||
this.source = null;
|
||||
this.userId = '';
|
||||
this.handler = null;
|
||||
this.reconnectAttempt = 0;
|
||||
}
|
||||
|
||||
async open(generation) {
|
||||
try {
|
||||
const response = await axios.post(appConfig.sseTicketApi, { userId: this.userId }, {
|
||||
withCredentials: false,
|
||||
headers: imClient.getAppAuthHeaders(appConfig.appServer)
|
||||
});
|
||||
if (generation !== this.generation) return;
|
||||
if (response.data?.code !== 0) throw new Error(response.data?.message || '实时通知凭证申请失败');
|
||||
const ticket = response.data?.result?.ticket || '';
|
||||
if (!ticket) throw new Error('实时通知凭证为空');
|
||||
const separator = appConfig.sseUrl.includes('?') ? '&' : '?';
|
||||
const endpoint = `${appConfig.sseUrl}${separator}ticket=${encodeURIComponent(ticket)}`;
|
||||
const socketUrl = endpoint.startsWith('ws://') || endpoint.startsWith('wss://')
|
||||
? endpoint
|
||||
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||
const source = new WebSocket(socketUrl);
|
||||
this.source = source;
|
||||
source.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
};
|
||||
source.onmessage = event => {
|
||||
if (generation !== this.generation) return;
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(event.data || '{}'); } catch (error) { payload = {}; }
|
||||
if (payload.type !== 'message-notification') return;
|
||||
this.handler?.(payload);
|
||||
};
|
||||
source.onerror = () => {
|
||||
if (generation !== this.generation) return;
|
||||
source.close();
|
||||
if (this.source === source) this.source = null;
|
||||
this.scheduleReconnect(generation);
|
||||
};
|
||||
source.onclose = () => {
|
||||
if (generation !== this.generation || this.source !== source) return;
|
||||
this.source = null;
|
||||
this.scheduleReconnect(generation);
|
||||
};
|
||||
} catch (error) {
|
||||
if (generation === this.generation) this.scheduleReconnect(generation);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect(generation) {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = Math.min(30000, 1000 * (2 ** Math.min(this.reconnectAttempt, 5)));
|
||||
this.reconnectAttempt += 1;
|
||||
this.reconnectTimer = window.setTimeout(() => this.open(generation), delay);
|
||||
}
|
||||
}
|
||||
|
||||
export const realtimeNotifications = new RealtimeNotificationClient();
|
||||
+95
-42
@@ -1,7 +1,8 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { imClient } from '@/services/imClient';
|
||||
import { appConfig } from '@/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { appConfig } from '@/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { realtimeNotifications } from '@/services/realtimeNotifications';
|
||||
|
||||
const LOCAL_CONVERSATION_STORAGE_KEY = 'qmt-web-im-local-conversations-v2';
|
||||
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
|
||||
@@ -125,7 +126,7 @@ function shouldUseMessagePolling() {
|
||||
return imClient.shouldUseMessagePolling ? imClient.shouldUseMessagePolling() : true;
|
||||
}
|
||||
|
||||
function canMarkReadNow(options = {}) {
|
||||
function canMarkReadNow(options = {}) {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
@@ -135,12 +136,33 @@ function canMarkReadNow(options = {}) {
|
||||
if (options.userAction) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
||||
const merged = [...syncedMessages];
|
||||
currentMessages.forEach(currentMessage => {
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeMessageForDisplay(message) {
|
||||
if (!message || message.attachment || String(message.text || '').trim()) {
|
||||
return message;
|
||||
}
|
||||
const contentType = Number(message.contentType || 0);
|
||||
const labels = {
|
||||
3: '[图片加载失败]',
|
||||
90: '[红包]',
|
||||
105: '[领取了红包]'
|
||||
};
|
||||
if (!labels[contentType]) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
type: 'text',
|
||||
text: labels[contentType]
|
||||
};
|
||||
}
|
||||
|
||||
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
||||
const merged = [...syncedMessages].map(normalizeMessageForDisplay);
|
||||
currentMessages.forEach(currentMessage => {
|
||||
currentMessage = normalizeMessageForDisplay(currentMessage);
|
||||
const exists = merged.some(message => isSameMessage(message, currentMessage));
|
||||
const shouldKeepLocal = currentMessage.status === 'sending'
|
||||
|| currentMessage.status === 'failed'
|
||||
@@ -247,8 +269,9 @@ export const useConversationStore = defineStore('conversations', {
|
||||
selectedMessageIds: [],
|
||||
favoriteMessages: [],
|
||||
activeFavoriteKey: '',
|
||||
actionNotice: '',
|
||||
actionError: ''
|
||||
actionNotice: '',
|
||||
actionError: '',
|
||||
realtimeSyncTimer: null
|
||||
}),
|
||||
getters: {
|
||||
activeConversation(state) {
|
||||
@@ -295,6 +318,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
this.loadScopedLocalState();
|
||||
this.startMessageSync();
|
||||
realtimeNotifications.start(session.userId, payload => this.handleRealtimeNotification(payload));
|
||||
},
|
||||
loadScopedLocalState() {
|
||||
this.conversations = [];
|
||||
@@ -319,12 +343,15 @@ export const useConversationStore = defineStore('conversations', {
|
||||
clearUserState() {
|
||||
window.clearInterval(this.syncTimer);
|
||||
window.clearInterval(this.conversationSyncTimer);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
window.clearTimeout(this.attentionMessageTimer);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
window.clearTimeout(this.attentionMessageTimer);
|
||||
window.clearTimeout(this.realtimeSyncTimer);
|
||||
realtimeNotifications.stop();
|
||||
this.syncTimer = null;
|
||||
this.conversationSyncTimer = null;
|
||||
this.syncAfterSendTimer = null;
|
||||
this.attentionMessageTimer = null;
|
||||
this.attentionMessageTimer = null;
|
||||
this.realtimeSyncTimer = null;
|
||||
this.syncInFlight = false;
|
||||
this.conversationSyncInFlight = false;
|
||||
this.pendingReadReceiptConversationId = '';
|
||||
@@ -358,10 +385,10 @@ export const useConversationStore = defineStore('conversations', {
|
||||
imClient.on('messageSent', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
});
|
||||
imClient.on('messageReceived', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
if (message.conversationId === this.activeId) {
|
||||
this.syncActiveMessages({ allowReadReceipt: true });
|
||||
imClient.on('messageReceived', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
if (message.conversationId === this.activeId) {
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 500);
|
||||
}
|
||||
});
|
||||
imClient.on('messageStatusChanged', message => {
|
||||
@@ -731,19 +758,20 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const pollingReadReceipt = canMarkReadNow();
|
||||
const markRead = explicitReadReceipt || pollingReadReceipt;
|
||||
|
||||
if (this.syncInFlight) {
|
||||
if (this.syncInFlight) {
|
||||
if (wantsReadReceipt) {
|
||||
this.pendingReadReceiptConversationId = conversation.id;
|
||||
}
|
||||
return;
|
||||
return;
|
||||
}
|
||||
this.syncInFlight = true;
|
||||
const syncedConversationId = conversation.id;
|
||||
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
||||
try {
|
||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
||||
markRead
|
||||
});
|
||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
||||
markRead,
|
||||
includeConversations: Boolean(options.includeConversations)
|
||||
});
|
||||
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
|
||||
this.pruneOptimisticMessages(syncedConversationId, messages);
|
||||
this.messagesByConversation = {
|
||||
@@ -774,30 +802,55 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.pendingReadReceiptConversationId = '';
|
||||
window.setTimeout(() => this.syncActiveMessages({ allowReadReceipt: true }), 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
startMessageSync() {
|
||||
this.bindReadPresenceEvents();
|
||||
if (!this.syncTimer) {
|
||||
this.syncTimer = window.setInterval(() => {
|
||||
this.syncActiveMessages();
|
||||
}, Math.max(250, appConfig.syncIntervalMs));
|
||||
}
|
||||
if (!this.conversationSyncTimer) {
|
||||
this.conversationSyncTimer = window.setInterval(() => {
|
||||
this.refreshRemoteConversations();
|
||||
}, 2000);
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
scheduleActiveMessagesSync(options = {}, delay = 500) {
|
||||
if (!this.activeId || (typeof document !== 'undefined' && document.visibilityState !== 'visible')) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
this.syncAfterSendTimer = window.setTimeout(() => {
|
||||
this.syncAfterSendTimer = null;
|
||||
this.syncActiveMessages(options);
|
||||
}, Math.max(0, Number(delay) || 0));
|
||||
},
|
||||
startMessageSync() {
|
||||
this.bindReadPresenceEvents();
|
||||
if (!this.syncTimer) {
|
||||
this.syncTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && !realtimeNotifications.isConnected()) {
|
||||
this.syncActiveMessages();
|
||||
}
|
||||
}, Math.max(250, appConfig.syncIntervalMs));
|
||||
}
|
||||
if (!this.conversationSyncTimer) {
|
||||
this.conversationSyncTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && !realtimeNotifications.isConnected()) {
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
}, Math.max(5000, appConfig.syncIntervalMs * 2));
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
},
|
||||
handleRealtimeNotification() {
|
||||
window.clearTimeout(this.realtimeSyncTimer);
|
||||
this.realtimeSyncTimer = window.setTimeout(() => {
|
||||
this.refreshRemoteConversations();
|
||||
this.scheduleActiveMessagesSync({
|
||||
allowReadReceipt: canMarkReadNow(),
|
||||
includeConversations: true
|
||||
}, 300);
|
||||
}, 300);
|
||||
},
|
||||
bindReadPresenceEvents() {
|
||||
if (this.readPresenceEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
this.readPresenceEventsBound = true;
|
||||
const syncWhenReadable = () => {
|
||||
if (canMarkReadNow()) {
|
||||
this.syncActiveMessages({ allowReadReceipt: true });
|
||||
const syncWhenReadable = () => {
|
||||
if (canMarkReadNow()) {
|
||||
this.refreshRemoteConversations();
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 200);
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', syncWhenReadable);
|
||||
|
||||
+36
-15
@@ -1,5 +1,11 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const runtimeEnvironment = process.env.VUE_APP_RUNTIME_ENV === 'production'
|
||||
? 'production'
|
||||
: 'local';
|
||||
const appServerTarget = process.env.VUE_APP_APP_SERVER_TARGET || 'http://18.141.44.165:8888';
|
||||
const webApiTarget = process.env.VUE_APP_WEB_API_TARGET || 'http://18.141.44.165:9000';
|
||||
|
||||
module.exports = {
|
||||
outputDir: 'dist',
|
||||
@@ -9,8 +15,8 @@ module.exports = {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
|
||||
'legacy-config': path.resolve(__dirname, '../src/config.js')
|
||||
'legacy-wfc': path.resolve(__dirname, '../src/wfc'),
|
||||
'legacy-config': path.resolve(__dirname, '../src/config.js')
|
||||
},
|
||||
fallback: {
|
||||
path: false,
|
||||
@@ -34,18 +40,33 @@ module.exports = {
|
||||
client: {
|
||||
overlay: false
|
||||
},
|
||||
proxy: {
|
||||
'/local-api': {
|
||||
target: 'http://127.0.0.1:8024',
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/local-api': '/api'
|
||||
}
|
||||
}
|
||||
proxy: {
|
||||
'/app-server': {
|
||||
target: appServerTarget,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/app-server': ''
|
||||
}
|
||||
},
|
||||
'/local-api': {
|
||||
target: webApiTarget,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/local-api': '/api'
|
||||
}
|
||||
},
|
||||
'/api': {
|
||||
target: webApiTarget,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
chainWebpack(config) {
|
||||
config.optimization.concatenateModules(false);
|
||||
},
|
||||
chainWebpack(config) {
|
||||
config.plugin('html').tap((args) => {
|
||||
args[0].runtimeConfigFile = `runtime-config.${runtimeEnvironment}.js`;
|
||||
return args;
|
||||
});
|
||||
config.optimization.concatenateModules(false);
|
||||
config.optimization.minimize(false);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user