feat(phase2c): 群聊与已读回执功能完整实现
Phase 2c 全部 14 个 Task 完成,包含: 后端(Go): - MinIO 文件存储服务集成(Docker + Go SDK + 通用上传 API) - Group 模块完整实现(DAO + Service + Controller + Router + Wire) - 18 个群管理 REST API + 11 个 WS 群事件推送 - 群创建/解散/邀请/踢人/退出/转让群主/设管理员/禁言/全体禁言/群公告/群昵称/免打扰/搜索 - IM Service 扩展(群消息发送/撤回 + @提醒 + 管理员无时限撤回) - 已读回执后端(单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads) - 管理端群组管理(列表/详情/解散) - 数据库迁移(3 张新表 + 2 张表字段扩展) 前端(uni-app): - 群聊 Store + API 封装 + 11 个 WS 事件监听 - 已读回执 UI(单聊已读/未读标记 + 群聊 X人已读 + 已读详情页) - 7 个群聊页面(对话/创建/设置/成员/邀请/审批/搜索) - 会话列表改造(全部/单聊/群聊 Tab + @标记 + 免打扰标识) 管理端(Vue 3 + Element Plus): - 群组列表页(搜索/分页/详情弹窗/解散群聊) - 侧边栏群组管理入口 文档同步:进度/架构/设计/API/规范文档全部更新 Made-with: Cursor
This commit is contained in:
@@ -18,10 +18,11 @@ alwaysApply: true
|
|||||||
- **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项)
|
- **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项)
|
||||||
- **Phase 2b(即时通讯消息系统)**:✅ 全部完成(10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md`
|
- **Phase 2b(即时通讯消息系统)**:✅ 全部完成(10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md`
|
||||||
- 分支:`feature/phase2b-instant-messaging`
|
- 分支:`feature/phase2b-instant-messaging`
|
||||||
- **Phase 2c(群聊与已读回执)**:📋 设计完成,待实施(14 个 Task),设计文档 `docs/plans/2026-03-04-phase2c-design.md`,实施计划 `docs/plans/2026-03-04-phase2c-implementation.plan.md`
|
- **Phase 2c(群聊与已读回执)**:✅ 全部完成(14 个 Task + 代码审查修复 14 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md`
|
||||||
- 分支:`feature/phase2c-group-read-receipt`
|
- 分支:`feature/phase2c-group-read-receipt`
|
||||||
- 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群聊管理
|
- 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群组管理
|
||||||
- **跨模块通信模式**:已建立接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService),Phase 2c 新增 im.GroupMemberChecker / im.GroupInfoGetter → group.GroupDAO
|
- 代码审查修复:Critical×5(角色类型/公告字段/at_user_ids/@导航/Wire 注册)+ Important×4 + Minor×2 + Suggestion×3
|
||||||
|
- **跨模块通信模式**:接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService,im.GroupInfoGetter → group.GroupDAO,im.MessageReadRecorder → group.MessageReadDAO,group.UserInfoProvider → auth.UserDAO,group.MessageWriter → im.MessageDAO)
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
@@ -30,8 +31,8 @@ EchoChat 是一个实时音视频通讯平台,包含三个子项目:
|
|||||||
- `frontend/` — 前台用户端(uni-app + Vue 3.4 + Pinia 2.x)
|
- `frontend/` — 前台用户端(uni-app + Vue 3.4 + Pinia 2.x)
|
||||||
- `admin/` — 后台管理端(Vue 3.5+ + Element Plus + Pinia 3.x)
|
- `admin/` — 后台管理端(Vue 3.5+ + Element Plus + Pinia 3.x)
|
||||||
|
|
||||||
已实现模块:auth(认证)、contact(联系人)、ws(WebSocket)、admin(管理端)、im(即时通讯)
|
已实现模块:auth(认证)、contact(联系人)、ws(WebSocket)、admin(管理端)、im(即时通讯 + 已读回执)、group(群聊管理)、file(文件上传/MinIO)
|
||||||
Phase 2c 待实现模块:group(群聊管理)、file(文件上传/MinIO)+ im 扩展(已读回执)
|
前端常量:`frontend/src/constants/group.js`(GROUP_ROLE / GROUP_STATUS / JOIN_REQUEST_STATUS,与后端 constants/group.go 对齐)
|
||||||
|
|
||||||
## 核心开发规则
|
## 核心开发规则
|
||||||
|
|
||||||
@@ -45,10 +46,48 @@ Phase 2c 待实现模块:group(群聊管理)、file(文件上传/MinIO
|
|||||||
8. **角色等级体系**:`auth_roles.level` 字段(值越小权限越高:1=超管, 10=管理员, 100=普通用户),所有管理操作强制执行层级权限校验
|
8. **角色等级体系**:`auth_roles.level` 字段(值越小权限越高:1=超管, 10=管理员, 100=普通用户),所有管理操作强制执行层级权限校验
|
||||||
9. **代码注释**:所有公开函数、组件、Store 必须有详细注释
|
9. **代码注释**:所有公开函数、组件、Store 必须有详细注释
|
||||||
10. **后端架构规范**:详见 `docs/conventions/backend-module-architecture.md`(模块分层/接口注入/日志/错误处理/批量查询/系统消息/Store 封装等)
|
10. **后端架构规范**:详见 `docs/conventions/backend-module-architecture.md`(模块分层/接口注入/日志/错误处理/批量查询/系统消息/Store 封装等)
|
||||||
11. **文档自动同步(强制)**:每个 Task 或功能开发完成后,必须自动执行文档同步,详见下方「文档自动同步规则」
|
11. **代码风格全局一致(最高优先级)**:新编写的任何模块代码,必须严格参照已有模块的实际代码实现和风格,禁止自创新封装、新 API、新模式。详见下方「代码风格全局一致规则」
|
||||||
12. **验证方式**:使用 Playwright MCP 进行页面自动化验证
|
12. **文档自动同步(强制)**:每个 Task 或功能开发完成后,必须自动执行文档同步,详见下方「文档自动同步规则」
|
||||||
13. **代码审查**:每个 Task 完成后,使用 `code-reviewer` 子代理进行结构化审查,确保代码质量和计划一致性
|
13. **验证方式**:使用 Playwright MCP 进行页面自动化验证
|
||||||
14. **完成验证**:使用 `verification-before-completion` 技能,在声称完成前运行验证命令并确认输出
|
14. **代码审查**:每个 Task 完成后,使用 `code-reviewer` 子代理进行结构化审查,确保代码质量和计划一致性
|
||||||
|
15. **完成验证**:使用 `verification-before-completion` 技能,在声称完成前运行验证命令并确认输出
|
||||||
|
|
||||||
|
## 代码风格全局一致规则(最高优先级,强制执行)
|
||||||
|
|
||||||
|
> **核心原则:编写任何新模块/新文件的代码前,必须先阅读同层级现有模块的实际代码,严格复制其风格,禁止引入不存在的 API、封装或模式。**
|
||||||
|
|
||||||
|
### 执行流程(强制)
|
||||||
|
|
||||||
|
1. **写代码前**:先用 Read/Grep 工具读取同类型现有文件(如写新 Controller 前先读 `im_controller.go` 和 `contact_controller.go`)
|
||||||
|
2. **写代码时**:逐行对照现有代码的导入、结构体定义、方法签名、日志调用、错误处理模式
|
||||||
|
3. **写代码后**:与参照文件做差异比对,确认风格完全一致
|
||||||
|
|
||||||
|
### Go 后端各层代码风格(以实际代码为准)
|
||||||
|
|
||||||
|
详细的风格规范和代码模板见 `docs/conventions/backend-module-architecture.md`。以下是关键约定摘要:
|
||||||
|
|
||||||
|
| 层级 | 接收器 | 日志 | funcName | 错误处理 |
|
||||||
|
|------|--------|------|----------|---------|
|
||||||
|
| Controller(前台业务) | `ctl` | 不记日志 | 无 | 方法级 `handleError` |
|
||||||
|
| Controller(auth/admin) | `ctrl`/`ctl` | 有日志 | 有 | `handleAuthError` 包级函数 / 内联 |
|
||||||
|
| Service | `s` | `logs.Info/Debug/Error` | `"service.{file}.{Method}"` | 包顶部 `var ErrXxx` |
|
||||||
|
| DAO | `d` | `logs.Info/Debug/Error` | `"dao.{file}.{Method}"` | `logs.Error` + 返回 err |
|
||||||
|
|
||||||
|
### 日志 API(以实际代码为准)
|
||||||
|
|
||||||
|
项目中 logs 包只有 `logs.Info/Debug/Warn/Error/Fatal` 五个方法,签名为 `logs.Xxx(ctx, funcName, message, ...zap.Field)`。
|
||||||
|
**不存在** `logs.LogFunctionEntry` / `logs.LogFunctionExit` / `logs.LogSuccess` 等方法,**严禁使用不存在的 API**。
|
||||||
|
|
||||||
|
### 依赖管理
|
||||||
|
|
||||||
|
- **禁止随意拉取最新版本的依赖包**,必须选择与当前 Go 版本(go.mod 中的 `go` 指令)兼容的版本
|
||||||
|
- **禁止触发 Go 工具链自动升级**,如果某依赖要求更高版本的 Go,必须选择兼容版本而非升级 Go
|
||||||
|
- 添加依赖前先检查 go.mod 中的 Go 版本和已有依赖,优先复用已有依赖
|
||||||
|
|
||||||
|
### 前端代码风格
|
||||||
|
|
||||||
|
- 前端新页面/组件/Store 的编写,同样必须先阅读现有同类文件,严格遵循已有的代码结构、命名规范、状态管理模式
|
||||||
|
- 禁止引入项目中未使用的新 UI 框架、状态管理库或工具函数库
|
||||||
|
|
||||||
## 文档自动同步规则(强制执行)
|
## 文档自动同步规则(强制执行)
|
||||||
|
|
||||||
|
|||||||
52
admin/src/api/group.js
Normal file
52
admin/src/api/group.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 群组管理 API 模块(管理端)
|
||||||
|
*
|
||||||
|
* 对应后端路由:/api/v1/admin/groups/*
|
||||||
|
* 所有接口需要 JWT + admin 角色权限
|
||||||
|
*
|
||||||
|
* 接口列表:
|
||||||
|
* - GET /api/v1/admin/groups 群组列表(分页 + 搜索)
|
||||||
|
* - GET /api/v1/admin/groups/:id 群组详情(含成员列表)
|
||||||
|
* - DELETE /api/v1/admin/groups/:id 解散群聊
|
||||||
|
*/
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {number} params.page - 页码(从 1 开始)
|
||||||
|
* @param {number} params.page_size - 每页数量
|
||||||
|
* @param {string} [params.keyword] - 搜索关键词(群名称)
|
||||||
|
* @returns {Promise<{data: {total: number, list: Array}}>}
|
||||||
|
*/
|
||||||
|
export function getGroupList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/api/v1/admin/groups',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组详情(含成员列表)
|
||||||
|
* @param {number} id - 群组 ID
|
||||||
|
* @returns {Promise<{data: Object}>}
|
||||||
|
*/
|
||||||
|
export function getGroupDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/v1/admin/groups/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解散群聊
|
||||||
|
* @param {number} id - 群组 ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function dissolveGroup(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/v1/admin/groups/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -56,6 +56,12 @@ const routes = [
|
|||||||
name: 'ContactManage',
|
name: 'ContactManage',
|
||||||
component: () => import('@/views/contact/list.vue'),
|
component: () => import('@/views/contact/list.vue'),
|
||||||
meta: { title: '好友管理' }
|
meta: { title: '好友管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'group/list',
|
||||||
|
name: 'GroupManage',
|
||||||
|
component: () => import('@/views/group/list.vue'),
|
||||||
|
meta: { title: '群组管理' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
322
admin/src/views/group/list.vue
Normal file
322
admin/src/views/group/list.vue
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<!--
|
||||||
|
管理端群组列表页面
|
||||||
|
|
||||||
|
设计系统:Data-Dense Dashboard 风格(与用户列表页保持一致)
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 搜索框(群名称关键词搜索)
|
||||||
|
- 群组数据表格(Element Plus Table)
|
||||||
|
- 分页组件
|
||||||
|
- 操作按钮(查看详情、解散群聊)
|
||||||
|
|
||||||
|
对应后端 API:GET /api/v1/admin/groups
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="group-list-page">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">群组管理</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索区 -->
|
||||||
|
<el-card class="filter-card" shadow="never">
|
||||||
|
<el-form :inline="true" class="filter-form" @submit.prevent="handleSearch">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索群名称"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 群组数据表格 -->
|
||||||
|
<el-card class="table-card" shadow="never">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="groupList"
|
||||||
|
stripe
|
||||||
|
border
|
||||||
|
style="width: 100%"
|
||||||
|
row-class-name="table-row"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
|
|
||||||
|
<el-table-column prop="name" label="群名称" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="group-name-cell">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="owner_name" label="群主" min-width="120" />
|
||||||
|
|
||||||
|
<el-table-column label="成员" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.member_count }}/{{ row.max_members }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.status === 1" type="success" size="small">正常</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 2" type="danger" size="small">已解散</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="全体禁言" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_all_muted" type="warning" size="small">是</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">否</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180" align="center" />
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="viewDetail(row)">
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 1"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleDissolve(row)"
|
||||||
|
>
|
||||||
|
解散
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="fetchGroupList"
|
||||||
|
@current-change="fetchGroupList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 群组详情弹窗 -->
|
||||||
|
<el-dialog v-model="detailVisible" title="群组详情" width="700px" destroy-on-close>
|
||||||
|
<div v-loading="detailLoading">
|
||||||
|
<template v-if="currentGroup">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="群 ID">{{ currentGroup.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="会话 ID">{{ currentGroup.conversation_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="群名称">{{ currentGroup.name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="群主">{{ currentGroup.owner_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="成员数">{{ currentGroup.member_count }}/{{ currentGroup.max_members }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag v-if="currentGroup.status === 1" type="success" size="small">正常</el-tag>
|
||||||
|
<el-tag v-else type="danger" size="small">已解散</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="全体禁言">
|
||||||
|
<el-tag v-if="currentGroup.is_all_muted" type="warning" size="small">是</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">否</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="可搜索">
|
||||||
|
{{ currentGroup.is_searchable ? '是' : '否' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间" :span="2">{{ currentGroup.created_at }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="群公告" :span="2">
|
||||||
|
{{ currentGroup.notice || '暂无公告' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 成员列表 -->
|
||||||
|
<h4 class="member-title">群成员({{ currentGroup.members?.length || 0 }})</h4>
|
||||||
|
<el-table :data="currentGroup.members || []" stripe border max-height="300" size="small">
|
||||||
|
<el-table-column prop="user_id" label="用户 ID" width="80" align="center" />
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||||
|
<el-table-column prop="nickname" label="群昵称" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.nickname || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="角色" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.role === 2" type="danger" size="small">群主</el-tag>
|
||||||
|
<el-tag v-else-if="row.role === 1" type="warning" size="small">管理员</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">成员</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="禁言" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_muted" type="danger" size="small">是</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="joined_at" label="加入时间" width="160" align="center" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 管理端群组列表逻辑
|
||||||
|
*
|
||||||
|
* 数据流:API → 列表渲染 → 分页 / 搜索 → 详情弹窗
|
||||||
|
*/
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getGroupList, getGroupDetail, dissolveGroup } from '@/api/group'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const groupList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const currentGroup = ref(null)
|
||||||
|
|
||||||
|
/** 获取群组列表 */
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGroupList({
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
keyword: searchKeyword.value || undefined
|
||||||
|
})
|
||||||
|
groupList.value = res.data?.list || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.data?.message || '获取群组列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索 */
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置搜索条件 */
|
||||||
|
const handleReset = () => {
|
||||||
|
searchKeyword.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看群组详情 */
|
||||||
|
const viewDetail = async (row) => {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
currentGroup.value = null
|
||||||
|
try {
|
||||||
|
const res = await getGroupDetail(row.id)
|
||||||
|
currentGroup.value = res.data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.data?.message || '获取群组详情失败')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解散群聊(二次确认) */
|
||||||
|
const handleDissolve = (row) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`确定要解散群聊「${row.name}」吗?此操作不可恢复。`,
|
||||||
|
'解散群聊',
|
||||||
|
{ confirmButtonText: '确定解散', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
await dissolveGroup(row.id)
|
||||||
|
ElMessage.success('群聊已解散')
|
||||||
|
fetchGroupList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.data?.message || '解散群聊失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGroupList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-list-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1E293B;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name-cell {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-title {
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -48,6 +48,14 @@
|
|||||||
<el-menu-item index="/contact/list">好友关系</el-menu-item>
|
<el-menu-item index="/contact/list">好友关系</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<el-sub-menu index="group-manage">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><ChatDotSquare /></el-icon>
|
||||||
|
<span>群组管理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/group/list">群组列表</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
<el-menu-item index="/monitor/online">
|
<el-menu-item index="/monitor/online">
|
||||||
<el-icon><Monitor /></el-icon>
|
<el-icon><Monitor /></el-icon>
|
||||||
<template #title>在线监控</template>
|
<template #title>在线监控</template>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Package controller 提供 admin 模块的 HTTP 接口处理
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/app/admin/service"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"github.com/echochat/backend/pkg/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupManageController 管理端群组管理控制器
|
||||||
|
type GroupManageController struct {
|
||||||
|
groupManageService *service.GroupManageService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroupManageController 创建群组管理控制器实例
|
||||||
|
func NewGroupManageController(svc *service.GroupManageService) *GroupManageController {
|
||||||
|
return &GroupManageController{groupManageService: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupList 获取群组列表
|
||||||
|
// GET /api/v1/admin/groups?page=1&page_size=20&keyword=xxx
|
||||||
|
func (ctl *GroupManageController) GetGroupList(c *gin.Context) {
|
||||||
|
funcName := "admin.group_manage_controller.GetGroupList"
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
list, total, err := ctl.groupManageService.ListGroups(ctx, page, pageSize, keyword)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取群组列表失败", zap.Error(err))
|
||||||
|
utils.ResponseError(c, "获取群组列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.Info(ctx, funcName, "获取群组列表成功",
|
||||||
|
zap.Int64("total", total), zap.Int("page", page))
|
||||||
|
utils.ResponseOK(c, gin.H{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupDetail 获取群组详情
|
||||||
|
// GET /api/v1/admin/groups/:id
|
||||||
|
func (ctl *GroupManageController) GetGroupDetail(c *gin.Context) {
|
||||||
|
funcName := "admin.group_manage_controller.GetGroupDetail"
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := ctl.groupManageService.GetGroupDetail(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取群组详情失败",
|
||||||
|
zap.Int64("group_id", id), zap.Error(err))
|
||||||
|
utils.ResponseError(c, "获取群组详情失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.Info(ctx, funcName, "获取群组详情成功", zap.Int64("group_id", id))
|
||||||
|
utils.ResponseOK(c, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DissolveGroup 解散群聊
|
||||||
|
// DELETE /api/v1/admin/groups/:id
|
||||||
|
func (ctl *GroupManageController) DissolveGroup(c *gin.Context) {
|
||||||
|
funcName := "admin.group_manage_controller.DissolveGroup"
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupManageService.DissolveGroup(ctx, id); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "解散群聊失败",
|
||||||
|
zap.Int64("group_id", id), zap.Error(err))
|
||||||
|
utils.ResponseError(c, "解散群聊失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.Info(ctx, funcName, "解散群聊成功", zap.Int64("group_id", id))
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
@@ -18,4 +18,6 @@ var AdminSet = wire.NewSet(
|
|||||||
controller.NewOnlineController,
|
controller.NewOnlineController,
|
||||||
service.NewContactManageService,
|
service.NewContactManageService,
|
||||||
controller.NewContactManageController,
|
controller.NewContactManageController,
|
||||||
|
service.NewGroupManageService,
|
||||||
|
controller.NewGroupManageController,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func RegisterRoutes(
|
|||||||
userCtrl *controller.UserManageController,
|
userCtrl *controller.UserManageController,
|
||||||
onlineCtrl *controller.OnlineController,
|
onlineCtrl *controller.OnlineController,
|
||||||
contactManageCtrl *controller.ContactManageController,
|
contactManageCtrl *controller.ContactManageController,
|
||||||
|
groupManageCtrl *controller.GroupManageController,
|
||||||
jwtAuth gin.HandlerFunc,
|
jwtAuth gin.HandlerFunc,
|
||||||
) {
|
) {
|
||||||
// 管理端路由组:JWT 认证 + admin/super_admin 角色检查
|
// 管理端路由组:JWT 认证 + admin/super_admin 角色检查
|
||||||
@@ -38,5 +39,10 @@ func RegisterRoutes(
|
|||||||
// 好友关系管理
|
// 好友关系管理
|
||||||
adminGroup.GET("/contacts", contactManageCtrl.GetAllContacts)
|
adminGroup.GET("/contacts", contactManageCtrl.GetAllContacts)
|
||||||
adminGroup.DELETE("/contacts/:id", contactManageCtrl.DeleteContact)
|
adminGroup.DELETE("/contacts/:id", contactManageCtrl.DeleteContact)
|
||||||
|
|
||||||
|
// 群组管理
|
||||||
|
adminGroup.GET("/groups", groupManageCtrl.GetGroupList)
|
||||||
|
adminGroup.GET("/groups/:id", groupManageCtrl.GetGroupDetail)
|
||||||
|
adminGroup.DELETE("/groups/:id", groupManageCtrl.DissolveGroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
247
backend/go-service/app/admin/service/group_manage_service.go
Normal file
247
backend/go-service/app/admin/service/group_manage_service.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// Package service 提供 admin 模块的核心业务逻辑
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
groupDAO "github.com/echochat/backend/app/group/dao"
|
||||||
|
groupModel "github.com/echochat/backend/app/group/model"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupManageService 管理端群组管理服务
|
||||||
|
type GroupManageService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
groupDAO *groupDAO.GroupDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroupManageService 创建群组管理服务实例
|
||||||
|
func NewGroupManageService(db *gorm.DB, groupDAO *groupDAO.GroupDAO) *GroupManageService {
|
||||||
|
return &GroupManageService{db: db, groupDAO: groupDAO}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupListItem 管理端群组列表项
|
||||||
|
type GroupListItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ConversationID int64 `json:"conversation_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
OwnerID int64 `json:"owner_id"`
|
||||||
|
OwnerName string `json:"owner_name"`
|
||||||
|
MemberCount int64 `json:"member_count"`
|
||||||
|
MaxMembers int `json:"max_members"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
IsAllMuted bool `json:"is_all_muted"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupDetailInfo 管理端群组详情
|
||||||
|
type GroupDetailInfo struct {
|
||||||
|
GroupListItem
|
||||||
|
Notice string `json:"notice"`
|
||||||
|
IsSearchable bool `json:"is_searchable"`
|
||||||
|
Members []GroupMemberInfo `json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupMemberInfo 群成员信息
|
||||||
|
type GroupMemberInfo struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role int `json:"role"`
|
||||||
|
IsMuted bool `json:"is_muted"`
|
||||||
|
JoinedAt string `json:"joined_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups 获取群组列表(分页 + 搜索)
|
||||||
|
func (s *GroupManageService) ListGroups(ctx context.Context, page, pageSize int, keyword string) ([]GroupListItem, int64, error) {
|
||||||
|
funcName := "service.group_manage_service.ListGroups"
|
||||||
|
logs.Info(ctx, funcName, "获取群组列表",
|
||||||
|
zap.Int("page", page), zap.Int("page_size", pageSize), zap.String("keyword", keyword))
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
var groups []groupModel.Group
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&groupModel.Group{})
|
||||||
|
if keyword != "" {
|
||||||
|
query = query.Where("name ILIKE ?", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&groups).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询群主名称,避免 N+1
|
||||||
|
ownerIDs := make([]int64, 0, len(groups))
|
||||||
|
convIDs := make([]int64, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
ownerIDs = append(ownerIDs, g.OwnerID)
|
||||||
|
convIDs = append(convIDs, g.ConversationID)
|
||||||
|
}
|
||||||
|
ownerNameMap := s.batchGetUsernames(ctx, ownerIDs)
|
||||||
|
memberCountMap := s.batchGetMemberCounts(ctx, convIDs)
|
||||||
|
|
||||||
|
list := make([]GroupListItem, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
list = append(list, GroupListItem{
|
||||||
|
ID: g.ID,
|
||||||
|
ConversationID: g.ConversationID,
|
||||||
|
Name: g.Name,
|
||||||
|
Avatar: g.Avatar,
|
||||||
|
OwnerID: g.OwnerID,
|
||||||
|
OwnerName: ownerNameMap[g.OwnerID],
|
||||||
|
MemberCount: memberCountMap[g.ConversationID],
|
||||||
|
MaxMembers: g.MaxMembers,
|
||||||
|
Status: g.Status,
|
||||||
|
IsAllMuted: g.IsAllMuted,
|
||||||
|
CreatedAt: g.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||||
|
logs.Info(ctx, funcName, "获取群组列表成功",
|
||||||
|
zap.Int64("total", total), zap.Int("total_pages", totalPages))
|
||||||
|
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupDetail 获取群组详情(含成员列表)
|
||||||
|
func (s *GroupManageService) GetGroupDetail(ctx context.Context, groupID int64) (*GroupDetailInfo, error) {
|
||||||
|
funcName := "service.group_manage_service.GetGroupDetail"
|
||||||
|
logs.Info(ctx, funcName, "获取群组详情", zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.groupDAO.GetByID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
memberCount, _ := s.groupDAO.GetMemberCount(ctx, group.ConversationID)
|
||||||
|
ownerName := s.getUsername(ctx, group.OwnerID)
|
||||||
|
|
||||||
|
members, err := s.groupDAO.GetMembers(ctx, group.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
memberInfos := make([]GroupMemberInfo, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
username := s.getUsername(ctx, m.UserID)
|
||||||
|
memberInfos = append(memberInfos, GroupMemberInfo{
|
||||||
|
UserID: m.UserID,
|
||||||
|
Username: username,
|
||||||
|
Nickname: m.Nickname,
|
||||||
|
Role: m.Role,
|
||||||
|
IsMuted: m.IsMuted,
|
||||||
|
JoinedAt: m.JoinedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := &GroupDetailInfo{
|
||||||
|
GroupListItem: GroupListItem{
|
||||||
|
ID: group.ID,
|
||||||
|
ConversationID: group.ConversationID,
|
||||||
|
Name: group.Name,
|
||||||
|
Avatar: group.Avatar,
|
||||||
|
OwnerID: group.OwnerID,
|
||||||
|
OwnerName: ownerName,
|
||||||
|
MemberCount: memberCount,
|
||||||
|
MaxMembers: group.MaxMembers,
|
||||||
|
Status: group.Status,
|
||||||
|
IsAllMuted: group.IsAllMuted,
|
||||||
|
CreatedAt: group.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
},
|
||||||
|
Notice: group.Notice,
|
||||||
|
IsSearchable: group.IsSearchable,
|
||||||
|
Members: memberInfos,
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DissolveGroup 管理端解散群聊
|
||||||
|
func (s *GroupManageService) DissolveGroup(ctx context.Context, groupID int64) error {
|
||||||
|
funcName := "service.group_manage_service.DissolveGroup"
|
||||||
|
logs.Info(ctx, funcName, "管理端解散群聊", zap.Int64("group_id", groupID))
|
||||||
|
return s.groupDAO.DissolveGroup(ctx, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUsername 通过用户 ID 获取用户名(优先昵称,其次用户名)
|
||||||
|
func (s *GroupManageService) getUsername(ctx context.Context, userID int64) string {
|
||||||
|
var username string
|
||||||
|
s.db.WithContext(ctx).Table("auth_users").
|
||||||
|
Where("id = ?", userID).
|
||||||
|
Pluck("COALESCE(NULLIF(nickname, ''), username)", &username)
|
||||||
|
if username == "" {
|
||||||
|
return strconv.FormatInt(userID, 10)
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchGetUsernames 批量查询用户名,返回 userID → 显示名 映射
|
||||||
|
func (s *GroupManageService) batchGetUsernames(ctx context.Context, userIDs []int64) map[int64]string {
|
||||||
|
result := make(map[int64]string, len(userIDs))
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID int64 `gorm:"column:id"`
|
||||||
|
Username string `gorm:"column:display_name"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
s.db.WithContext(ctx).Table("auth_users").
|
||||||
|
Select("id, COALESCE(NULLIF(nickname, ''), username) AS display_name").
|
||||||
|
Where("id IN ?", userIDs).
|
||||||
|
Find(&rows)
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.ID] = r.Username
|
||||||
|
}
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if _, ok := result[uid]; !ok {
|
||||||
|
result[uid] = strconv.FormatInt(uid, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchGetMemberCounts 批量查询会话的成员数,返回 conversationID → count 映射
|
||||||
|
func (s *GroupManageService) batchGetMemberCounts(ctx context.Context, conversationIDs []int64) map[int64]int64 {
|
||||||
|
result := make(map[int64]int64, len(conversationIDs))
|
||||||
|
if len(conversationIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ConversationID int64 `gorm:"column:conversation_id"`
|
||||||
|
Count int64 `gorm:"column:cnt"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
s.db.WithContext(ctx).Table("im_conversation_members").
|
||||||
|
Select("conversation_id, COUNT(*) AS cnt").
|
||||||
|
Where("conversation_id IN ?", conversationIDs).
|
||||||
|
Group("conversation_id").
|
||||||
|
Find(&rows)
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.ConversationID] = r.Count
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
46
backend/go-service/app/constants/group.go
Normal file
46
backend/go-service/app/constants/group.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 群聊状态
|
||||||
|
const (
|
||||||
|
GroupStatusNormal = 1 // 正常
|
||||||
|
GroupStatusDissolved = 2 // 已解散
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupStatusMap 群聊状态中文映射
|
||||||
|
var GroupStatusMap = map[int]string{
|
||||||
|
GroupStatusNormal: "正常",
|
||||||
|
GroupStatusDissolved: "已解散",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群成员角色(im_conversation_members.role)
|
||||||
|
const (
|
||||||
|
GroupRoleNormal = 0 // 普通成员
|
||||||
|
GroupRoleAdmin = 1 // 管理员
|
||||||
|
GroupRoleOwner = 2 // 群主
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupRoleMap 群成员角色中文映射
|
||||||
|
var GroupRoleMap = map[int]string{
|
||||||
|
GroupRoleNormal: "普通成员",
|
||||||
|
GroupRoleAdmin: "管理员",
|
||||||
|
GroupRoleOwner: "群主",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 入群申请状态(im_group_join_requests.status)
|
||||||
|
const (
|
||||||
|
JoinRequestStatusPending = 0 // 待审批
|
||||||
|
JoinRequestStatusApproved = 1 // 通过
|
||||||
|
JoinRequestStatusRejected = 2 // 拒绝
|
||||||
|
)
|
||||||
|
|
||||||
|
// JoinRequestStatusMap 入群申请状态中文映射
|
||||||
|
var JoinRequestStatusMap = map[int]string{
|
||||||
|
JoinRequestStatusPending: "待审批",
|
||||||
|
JoinRequestStatusApproved: "通过",
|
||||||
|
JoinRequestStatusRejected: "拒绝",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群聊默认配置
|
||||||
|
const (
|
||||||
|
GroupDefaultMaxMembers = 200 // 默认最大成员数
|
||||||
|
)
|
||||||
@@ -14,20 +14,22 @@ var ConversationTypeMap = map[int]string{
|
|||||||
|
|
||||||
// 消息类型
|
// 消息类型
|
||||||
const (
|
const (
|
||||||
MessageTypeText = 1 // 文本消息
|
MessageTypeText = 1 // 文本消息
|
||||||
MessageTypeImage = 2 // 图片消息(预留)
|
MessageTypeImage = 2 // 图片消息(预留)
|
||||||
MessageTypeVoice = 3 // 语音消息(预留)
|
MessageTypeVoice = 3 // 语音消息(预留)
|
||||||
MessageTypeVideo = 4 // 视频消息(预留)
|
MessageTypeVideo = 4 // 视频消息(预留)
|
||||||
MessageTypeFile = 5 // 文件消息(预留)
|
MessageTypeFile = 5 // 文件消息(预留)
|
||||||
|
MessageTypeSystem = 10 // 系统消息(群聊操作通知)
|
||||||
)
|
)
|
||||||
|
|
||||||
// MessageTypeMap 消息类型中文映射
|
// MessageTypeMap 消息类型中文映射
|
||||||
var MessageTypeMap = map[int]string{
|
var MessageTypeMap = map[int]string{
|
||||||
MessageTypeText: "文本",
|
MessageTypeText: "文本",
|
||||||
MessageTypeImage: "图片",
|
MessageTypeImage: "图片",
|
||||||
MessageTypeVoice: "语音",
|
MessageTypeVoice: "语音",
|
||||||
MessageTypeVideo: "视频",
|
MessageTypeVideo: "视频",
|
||||||
MessageTypeFile: "文件",
|
MessageTypeFile: "文件",
|
||||||
|
MessageTypeSystem: "系统消息",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息状态
|
// 消息状态
|
||||||
|
|||||||
177
backend/go-service/app/dto/group_dto.go
Normal file
177
backend/go-service/app/dto/group_dto.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ====== 群聊管理 DTO ======
|
||||||
|
|
||||||
|
// CreateGroupRequest 创建群聊请求
|
||||||
|
// POST /api/v1/groups
|
||||||
|
type CreateGroupRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,max=100"` // 群名称
|
||||||
|
MemberIDs []int64 `json:"member_ids" binding:"required"` // 初始成员用户 ID 列表(不含创建者自身)
|
||||||
|
Avatar string `json:"avatar"` // 群头像 URL(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroupRequest 更新群信息请求
|
||||||
|
// PUT /api/v1/groups/:id
|
||||||
|
type UpdateGroupRequest struct {
|
||||||
|
Name *string `json:"name"` // 群名称
|
||||||
|
Avatar *string `json:"avatar"` // 群头像 URL
|
||||||
|
Notice *string `json:"notice"` // 群公告
|
||||||
|
IsSearchable *bool `json:"is_searchable"` // 是否可被搜索
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupDTO 群聊信息传输对象(返回给前端)
|
||||||
|
type GroupDTO struct {
|
||||||
|
ID int64 `json:"id"` // 群 ID
|
||||||
|
ConversationID int64 `json:"conversation_id"` // 关联会话 ID
|
||||||
|
Name string `json:"name"` // 群名称
|
||||||
|
Avatar string `json:"avatar"` // 群头像
|
||||||
|
OwnerID int64 `json:"owner_id"` // 群主用户 ID
|
||||||
|
Notice string `json:"notice"` // 群公告
|
||||||
|
MaxMembers int `json:"max_members"` // 最大成员数
|
||||||
|
MemberCount int `json:"member_count"` // 当前成员数
|
||||||
|
IsSearchable bool `json:"is_searchable"` // 是否可被搜索
|
||||||
|
IsAllMuted bool `json:"is_all_muted"` // 是否全体禁言
|
||||||
|
Status int `json:"status"` // 群状态:1=正常,2=已解散
|
||||||
|
CreatedAt string `json:"created_at"` // 创建时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 群成员 DTO ======
|
||||||
|
|
||||||
|
// GroupMemberDTO 群成员信息传输对象
|
||||||
|
type GroupMemberDTO struct {
|
||||||
|
UserID int64 `json:"user_id"` // 用户 ID
|
||||||
|
Nickname string `json:"nickname"` // 群内昵称
|
||||||
|
UserNickname string `json:"user_nickname"` // 用户原始昵称
|
||||||
|
Avatar string `json:"avatar"` // 用户头像
|
||||||
|
Role int `json:"role"` // 角色:0=普通成员,1=管理员,2=群主
|
||||||
|
IsMuted bool `json:"is_muted"` // 是否被禁言
|
||||||
|
JoinedAt string `json:"joined_at"` // 加入时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteMembersRequest 邀请入群请求
|
||||||
|
// POST /api/v1/groups/:id/members
|
||||||
|
type InviteMembersRequest struct {
|
||||||
|
UserIDs []int64 `json:"user_ids" binding:"required"` // 被邀请的用户 ID 列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRoleRequest 设置/取消管理员请求
|
||||||
|
// PUT /api/v1/groups/:id/members/:uid/role
|
||||||
|
type SetRoleRequest struct {
|
||||||
|
Role int `json:"role" binding:"oneof=0 1"` // 目标角色:0=普通成员,1=管理员
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteRequest 禁言/解除禁言请求
|
||||||
|
// PUT /api/v1/groups/:id/members/:uid/mute
|
||||||
|
type MuteRequest struct {
|
||||||
|
IsMuted bool `json:"is_muted"` // true=禁言, false=解除
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferOwnerRequest 转让群主请求
|
||||||
|
// PUT /api/v1/groups/:id/transfer
|
||||||
|
type TransferOwnerRequest struct {
|
||||||
|
NewOwnerID int64 `json:"new_owner_id" binding:"required"` // 新群主用户 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNicknameRequest 修改群昵称请求
|
||||||
|
// PUT /api/v1/groups/:id/members/me/nickname
|
||||||
|
type UpdateNicknameRequest struct {
|
||||||
|
Nickname string `json:"nickname" binding:"max=50"` // 群内昵称
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 入群申请 DTO ======
|
||||||
|
|
||||||
|
// JoinGroupRequest 申请入群请求
|
||||||
|
// POST /api/v1/groups/:id/join-requests
|
||||||
|
type JoinGroupRequest struct {
|
||||||
|
Message string `json:"message"` // 申请附言
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewJoinRequest 审批入群申请请求
|
||||||
|
// PUT /api/v1/groups/:id/join-requests/:rid
|
||||||
|
type ReviewJoinRequest struct {
|
||||||
|
Action string `json:"action" binding:"required,oneof=approve reject"` // 操作:approve=通过, reject=拒绝
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinRequestDTO 入群申请传输对象
|
||||||
|
type JoinRequestDTO struct {
|
||||||
|
ID int64 `json:"id"` // 申请 ID
|
||||||
|
GroupID int64 `json:"group_id"` // 群 ID
|
||||||
|
UserID int64 `json:"user_id"` // 申请人用户 ID
|
||||||
|
UserNickname string `json:"user_nickname"` // 申请人昵称
|
||||||
|
UserAvatar string `json:"user_avatar"` // 申请人头像
|
||||||
|
Message string `json:"message"` // 申请附言
|
||||||
|
Status int `json:"status"` // 状态:0=待审批,1=通过,2=拒绝
|
||||||
|
CreatedAt string `json:"created_at"` // 申请时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 群搜索 DTO ======
|
||||||
|
|
||||||
|
// SearchGroupRequest 搜索群聊请求
|
||||||
|
// GET /api/v1/groups/search?keyword=xx&page=1&page_size=20
|
||||||
|
type SearchGroupRequest struct {
|
||||||
|
Keyword string `form:"keyword" binding:"required"` // 搜索关键词
|
||||||
|
Page int `form:"page"` // 页码(默认 1)
|
||||||
|
PageSize int `form:"page_size"` // 每页数量(默认 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchGroupResponse 搜索群聊响应
|
||||||
|
type SearchGroupResponse struct {
|
||||||
|
List []GroupDTO `json:"list"` // 搜索结果
|
||||||
|
Total int64 `json:"total"` // 总数
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 已读回执 DTO ======
|
||||||
|
|
||||||
|
// MarkGroupReadRequest 群聊标记已读请求(WS 事件 im.message.read 的 data 字段)
|
||||||
|
type MarkGroupReadRequest struct {
|
||||||
|
ConversationID int64 `json:"conversation_id"` // 会话 ID
|
||||||
|
MessageIDs []int64 `json:"message_ids"` // 已读消息 ID 列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageReadCountDTO 消息已读计数传输对象
|
||||||
|
type MessageReadCountDTO struct {
|
||||||
|
MessageID int64 `json:"message_id"` // 消息 ID
|
||||||
|
ReadCount int `json:"read_count"` // 已读人数
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageReadDetailDTO 消息已读详情传输对象
|
||||||
|
type MessageReadDetailDTO struct {
|
||||||
|
UserID int64 `json:"user_id"` // 已读用户 ID
|
||||||
|
UserNickname string `json:"user_nickname"` // 用户昵称
|
||||||
|
UserAvatar string `json:"user_avatar"` // 用户头像
|
||||||
|
ReadAt string `json:"read_at"` // 已读时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadDetailRequest 获取已读详情请求
|
||||||
|
// GET /api/v1/im/messages/:id/reads?page=1&page_size=20
|
||||||
|
type GetReadDetailRequest struct {
|
||||||
|
Page int `form:"page"` // 页码(默认 1)
|
||||||
|
PageSize int `form:"page_size"` // 每页数量(默认 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadDetailResponse 获取已读详情响应
|
||||||
|
type GetReadDetailResponse struct {
|
||||||
|
ReadList []MessageReadDetailDTO `json:"read_list"` // 已读用户列表
|
||||||
|
UnreadList []MessageReadDetailDTO `json:"unread_list"` // 未读用户列表
|
||||||
|
ReadCount int `json:"read_count"` // 已读人数
|
||||||
|
TotalCount int `json:"total_count"` // 群成员总数(不含发送者)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 免打扰 DTO ======
|
||||||
|
|
||||||
|
// SetDoNotDisturbRequest 设置/取消免打扰请求
|
||||||
|
// PUT /api/v1/im/conversations/:id/dnd
|
||||||
|
type SetDoNotDisturbRequest struct {
|
||||||
|
IsDoNotDisturb bool `json:"is_do_not_disturb"` // true=开启, false=关闭
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 跨模块接口数据传输 ======
|
||||||
|
|
||||||
|
// GroupBrief 群简要信息(IM 模块通过 GroupInfoGetter 接口获取)
|
||||||
|
type GroupBrief struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
IsAllMuted bool `json:"is_all_muted"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
@@ -4,23 +4,25 @@ package dto
|
|||||||
|
|
||||||
// SendMessageRequest 发送消息请求(WS 事件 im.message.send 的 data 字段)
|
// SendMessageRequest 发送消息请求(WS 事件 im.message.send 的 data 字段)
|
||||||
type SendMessageRequest struct {
|
type SendMessageRequest struct {
|
||||||
ConversationID int64 `json:"conversation_id"` // 会话 ID(与 TargetUserID 二选一)
|
ConversationID int64 `json:"conversation_id"` // 会话 ID(与 TargetUserID 二选一)
|
||||||
TargetUserID int64 `json:"target_user_id"` // 对方用户 ID(首次发消息时使用,自动创建会话)
|
TargetUserID int64 `json:"target_user_id"` // 对方用户 ID(首次发消息时使用,自动创建会话)
|
||||||
Type int `json:"type"` // 消息类型:1=文本
|
Type int `json:"type"` // 消息类型:1=文本
|
||||||
Content string `json:"content"` // 消息内容
|
Content string `json:"content"` // 消息内容
|
||||||
ClientMsgID string `json:"client_msg_id"` // 客户端消息唯一 ID,用于幂等去重
|
ClientMsgID string `json:"client_msg_id"` // 客户端消息唯一 ID,用于幂等去重
|
||||||
|
AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表(含 0 表示 @所有人)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageDTO 消息传输对象(返回给前端)
|
// MessageDTO 消息传输对象(返回给前端)
|
||||||
type MessageDTO struct {
|
type MessageDTO struct {
|
||||||
ID int64 `json:"id"` // 消息 ID
|
ID int64 `json:"id"` // 消息 ID
|
||||||
ConversationID int64 `json:"conversation_id"` // 所属会话 ID
|
ConversationID int64 `json:"conversation_id"` // 所属会话 ID
|
||||||
SenderID int64 `json:"sender_id"` // 发送者用户 ID
|
SenderID int64 `json:"sender_id"` // 发送者用户 ID
|
||||||
Type int `json:"type"` // 消息类型
|
Type int `json:"type"` // 消息类型
|
||||||
Content string `json:"content"` // 消息内容
|
Content string `json:"content"` // 消息内容
|
||||||
Status int `json:"status"` // 消息状态:1=正常,2=已撤回
|
Status int `json:"status"` // 消息状态:1=正常,2=已撤回
|
||||||
ClientMsgID string `json:"client_msg_id"` // 客户端消息 ID
|
ClientMsgID string `json:"client_msg_id"` // 客户端消息 ID
|
||||||
CreatedAt string `json:"created_at"` // 发送时间
|
AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表
|
||||||
|
CreatedAt string `json:"created_at"` // 发送时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecallMessageRequest 撤回消息请求(WS 事件 im.message.recall 的 data 字段)
|
// RecallMessageRequest 撤回消息请求(WS 事件 im.message.recall 的 data 字段)
|
||||||
@@ -32,16 +34,19 @@ type RecallMessageRequest struct {
|
|||||||
|
|
||||||
// ConversationDTO 会话列表条目(返回给前端)
|
// ConversationDTO 会话列表条目(返回给前端)
|
||||||
type ConversationDTO struct {
|
type ConversationDTO struct {
|
||||||
ID int64 `json:"id"` // 会话 ID
|
ID int64 `json:"id"` // 会话 ID
|
||||||
Type int `json:"type"` // 会话类型:1=单聊
|
Type int `json:"type"` // 会话类型:1=单聊,2=群聊
|
||||||
PeerUserID int64 `json:"peer_user_id"` // 单聊对方用户 ID
|
PeerUserID int64 `json:"peer_user_id"` // 单聊对方用户 ID(群聊为 0)
|
||||||
PeerNickname string `json:"peer_nickname"` // 对方昵称
|
PeerNickname string `json:"peer_nickname"` // 对方昵称(群聊时为群名称)
|
||||||
PeerAvatar string `json:"peer_avatar"` // 对方头像
|
PeerAvatar string `json:"peer_avatar"` // 对方头像(群聊时为群头像)
|
||||||
LastMsgContent string `json:"last_msg_content"` // 最后消息预览
|
LastMsgContent string `json:"last_msg_content"` // 最后消息预览
|
||||||
LastMsgTime string `json:"last_msg_time"` // 最后消息时间
|
LastMsgTime string `json:"last_msg_time"` // 最后消息时间
|
||||||
LastMsgSenderID *int64 `json:"last_msg_sender_id"` // 最后消息发送者 ID
|
LastMsgSenderID *int64 `json:"last_msg_sender_id"` // 最后消息发送者 ID
|
||||||
IsPinned bool `json:"is_pinned"` // 是否置顶
|
IsPinned bool `json:"is_pinned"` // 是否置顶
|
||||||
UnreadCount int `json:"unread_count"` // 未读消息数
|
UnreadCount int `json:"unread_count"` // 未读消息数
|
||||||
|
GroupID int64 `json:"group_id,omitempty"` // 群聊 ID(仅 type=2 有值)
|
||||||
|
IsDoNotDisturb bool `json:"is_do_not_disturb"` // 是否免打扰
|
||||||
|
AtMeCount int `json:"at_me_count"` // 被@提醒未读计数
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConversationListResponse 会话列表响应
|
// ConversationListResponse 会话列表响应
|
||||||
|
|||||||
70
backend/go-service/app/file/controller/file_controller.go
Normal file
70
backend/go-service/app/file/controller/file_controller.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Package controller 提供文件上传模块的 HTTP 接口处理
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/file/service"
|
||||||
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
|
"github.com/echochat/backend/pkg/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxUploadSize = 10 << 20 // 10 MB
|
||||||
|
|
||||||
|
// FileController 文件上传控制器
|
||||||
|
type FileController struct {
|
||||||
|
fileService *service.FileService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileController 创建文件上传控制器
|
||||||
|
func NewFileController(fileService *service.FileService) *FileController {
|
||||||
|
return &FileController{fileService: fileService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload 处理文件上传请求
|
||||||
|
// POST /api/v1/upload
|
||||||
|
// 支持 multipart/form-data,字段名为 "file"
|
||||||
|
func (ctl *FileController) Upload(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
_, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "请选择要上传的文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileHeader.Size > maxUploadSize {
|
||||||
|
utils.ResponseBadRequest(c, "文件大小不能超过 10MB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.fileService.Upload(ctx, fileHeader)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "文件上传失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleError 统一业务错误映射
|
||||||
|
// 已知业务错误 → 返回 Service 层定义的具体提示
|
||||||
|
// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误")
|
||||||
|
func (ctl *FileController) handleError(c *gin.Context, err error, fallbackMsg ...string) {
|
||||||
|
switch err {
|
||||||
|
case service.ErrFileOpen:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrUploadFailed:
|
||||||
|
utils.ResponseError(c, err.Error())
|
||||||
|
default:
|
||||||
|
msg := "服务器内部错误"
|
||||||
|
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
||||||
|
msg = fallbackMsg[0]
|
||||||
|
}
|
||||||
|
utils.ResponseError(c, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/go-service/app/file/provider.go
Normal file
14
backend/go-service/app/file/provider.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Package file 文件上传模块 Wire 依赖注入配置
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/file/controller"
|
||||||
|
"github.com/echochat/backend/app/file/service"
|
||||||
|
"github.com/google/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileSet 文件上传模块 Wire Provider Set
|
||||||
|
var FileSet = wire.NewSet(
|
||||||
|
service.NewFileService,
|
||||||
|
controller.NewFileController,
|
||||||
|
)
|
||||||
16
backend/go-service/app/file/router.go
Normal file
16
backend/go-service/app/file/router.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Package file 文件上传模块路由注册
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/file/controller"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRoutes 注册文件上传模块的所有路由(需要 JWT 中间件)
|
||||||
|
func RegisterRoutes(r *gin.Engine, ctrl *controller.FileController, jwtAuth gin.HandlerFunc) {
|
||||||
|
authed := r.Group("/api/v1")
|
||||||
|
authed.Use(jwtAuth)
|
||||||
|
{
|
||||||
|
authed.POST("/upload", ctrl.Upload)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/go-service/app/file/service/file_service.go
Normal file
90
backend/go-service/app/file/service/file_service.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Package service 提供文件上传模块的业务逻辑
|
||||||
|
// 封装 MinIO 对象存储的上传操作,返回可访问的文件 URL
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/config"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFileOpen = errors.New("打开上传文件失败")
|
||||||
|
ErrUploadFailed = errors.New("文件上传失败")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileService 文件上传服务
|
||||||
|
type FileService struct {
|
||||||
|
minioClient *minio.Client
|
||||||
|
minioCfg *config.MinioConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileService 创建文件上传服务实例
|
||||||
|
func NewFileService(client *minio.Client, cfg *config.MinioConfig) *FileService {
|
||||||
|
return &FileService{
|
||||||
|
minioClient: client,
|
||||||
|
minioCfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadResult 文件上传结果
|
||||||
|
type UploadResult struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload 上传文件到 MinIO,返回文件访问 URL
|
||||||
|
// 文件按日期目录组织:uploads/2026/03/04/{uuid}.{ext}
|
||||||
|
func (s *FileService) Upload(ctx context.Context, fileHeader *multipart.FileHeader) (*UploadResult, error) {
|
||||||
|
funcName := "service.file_service.Upload"
|
||||||
|
logs.Info(ctx, funcName, "上传文件",
|
||||||
|
zap.String("file_name", fileHeader.Filename),
|
||||||
|
zap.Int64("size", fileHeader.Size))
|
||||||
|
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "打开上传文件失败", zap.Error(err))
|
||||||
|
return nil, ErrFileOpen
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||||
|
now := time.Now()
|
||||||
|
objectName := fmt.Sprintf("uploads/%s/%s%s", now.Format("2006/01/02"), uuid.New().String(), ext)
|
||||||
|
|
||||||
|
contentType := fileHeader.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectName, file, fileHeader.Size, minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "上传文件到 MinIO 失败", zap.Error(err))
|
||||||
|
return nil, ErrUploadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if s.minioCfg.UseSSL {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectName)
|
||||||
|
|
||||||
|
return &UploadResult{
|
||||||
|
URL: url,
|
||||||
|
FileName: fileHeader.Filename,
|
||||||
|
Size: fileHeader.Size,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
542
backend/go-service/app/group/controller/group_controller.go
Normal file
542
backend/go-service/app/group/controller/group_controller.go
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
// Package controller 提供 group 模块的 HTTP 接口处理
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/app/dto"
|
||||||
|
"github.com/echochat/backend/app/group/service"
|
||||||
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
|
"github.com/echochat/backend/pkg/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupController 群聊管理控制器(REST API)
|
||||||
|
type GroupController struct {
|
||||||
|
groupService *service.GroupService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroupController 创建 GroupController 实例
|
||||||
|
func NewGroupController(groupService *service.GroupService) *GroupController {
|
||||||
|
return &GroupController{groupService: groupService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup 创建群聊
|
||||||
|
// POST /api/v1/groups
|
||||||
|
func (ctl *GroupController) CreateGroup(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.groupService.CreateGroup(ctx, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "创建群聊失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupDetail 获取群详情
|
||||||
|
// GET /api/v1/groups/:id
|
||||||
|
func (ctl *GroupController) GetGroupDetail(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.groupService.GetGroupDetail(ctx, userID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "获取群详情失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup 更新群信息
|
||||||
|
// PUT /api/v1/groups/:id
|
||||||
|
func (ctl *GroupController) UpdateGroup(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.UpdateGroup(ctx, userID, groupID, &req); err != nil {
|
||||||
|
ctl.handleError(c, err, "更新群信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DissolveGroup 解散群聊
|
||||||
|
// DELETE /api/v1/groups/:id
|
||||||
|
func (ctl *GroupController) DissolveGroup(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.DissolveGroup(ctx, userID, groupID); err != nil {
|
||||||
|
ctl.handleError(c, err, "解散群聊失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMembers 获取群成员列表
|
||||||
|
// GET /api/v1/groups/:id/members
|
||||||
|
func (ctl *GroupController) GetMembers(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.groupService.GetMembers(ctx, userID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "获取成员列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteMembers 邀请入群
|
||||||
|
// POST /api/v1/groups/:id/members
|
||||||
|
func (ctl *GroupController) InviteMembers(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.InviteMembersRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.InviteMembers(ctx, userID, groupID, req.UserIDs); err != nil {
|
||||||
|
ctl.handleError(c, err, "邀请入群失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickMember 踢出成员
|
||||||
|
// DELETE /api/v1/groups/:id/members/:uid
|
||||||
|
func (ctl *GroupController) KickMember(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := strconv.ParseInt(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "用户 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.KickMember(ctx, userID, groupID, targetID); err != nil {
|
||||||
|
ctl.handleError(c, err, "踢出成员失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberRole 设置/取消管理员
|
||||||
|
// PUT /api/v1/groups/:id/members/:uid/role
|
||||||
|
func (ctl *GroupController) SetMemberRole(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := strconv.ParseInt(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "用户 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.SetRoleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.SetMemberRole(ctx, userID, groupID, targetID, req.Role); err != nil {
|
||||||
|
ctl.handleError(c, err, "设置角色失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteMember 禁言/解除禁言
|
||||||
|
// PUT /api/v1/groups/:id/members/:uid/mute
|
||||||
|
func (ctl *GroupController) MuteMember(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := strconv.ParseInt(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "用户 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.MuteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.MuteMember(ctx, userID, groupID, targetID, req.IsMuted); err != nil {
|
||||||
|
ctl.handleError(c, err, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferOwner 转让群主
|
||||||
|
// PUT /api/v1/groups/:id/transfer
|
||||||
|
func (ctl *GroupController) TransferOwner(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.TransferOwnerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.TransferOwner(ctx, userID, groupID, req.NewOwnerID); err != nil {
|
||||||
|
ctl.handleError(c, err, "转让群主失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaveGroup 退出群聊
|
||||||
|
// DELETE /api/v1/groups/:id/members/me
|
||||||
|
func (ctl *GroupController) LeaveGroup(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.LeaveGroup(ctx, userID, groupID); err != nil {
|
||||||
|
ctl.handleError(c, err, "退出群聊失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNickname 修改群内昵称
|
||||||
|
// PUT /api/v1/groups/:id/members/me/nickname
|
||||||
|
func (ctl *GroupController) UpdateNickname(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateNicknameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.UpdateNickname(ctx, userID, groupID, req.Nickname); err != nil {
|
||||||
|
ctl.handleError(c, err, "修改昵称失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitJoinRequest 申请入群
|
||||||
|
// POST /api/v1/groups/:id/join-requests
|
||||||
|
func (ctl *GroupController) SubmitJoinRequest(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.JoinGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.SubmitJoinRequest(ctx, userID, groupID, req.Message); err != nil {
|
||||||
|
ctl.handleError(c, err, "申请入群失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJoinRequests 获取入群申请列表
|
||||||
|
// GET /api/v1/groups/:id/join-requests
|
||||||
|
func (ctl *GroupController) GetJoinRequests(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.groupService.GetJoinRequests(ctx, userID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "获取入群申请失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewJoinRequest 审批入群申请
|
||||||
|
// PUT /api/v1/groups/:id/join-requests/:rid
|
||||||
|
func (ctl *GroupController) ReviewJoinRequest(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID, err := strconv.ParseInt(c.Param("rid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "申请 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ReviewJoinRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.ReviewJoinRequest(ctx, userID, groupID, requestID, req.Action); err != nil {
|
||||||
|
ctl.handleError(c, err, "审批失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchGroups 搜索群聊
|
||||||
|
// GET /api/v1/groups/search?keyword=xx&page=1&page_size=20
|
||||||
|
func (ctl *GroupController) SearchGroups(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
_, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.SearchGroupRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.groupService.SearchGroups(ctx, &req)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "搜索群聊失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAllMuted 设置全体禁言
|
||||||
|
// PUT /api/v1/groups/:id/all-mute
|
||||||
|
func (ctl *GroupController) SetAllMuted(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "群 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
IsAllMuted bool `json:"is_all_muted"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.groupService.SetAllMuted(ctx, userID, groupID, body.IsAllMuted); err != nil {
|
||||||
|
ctl.handleError(c, err, "设置全体禁言失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleError 统一业务错误映射
|
||||||
|
// 已知业务错误 → 返回 Service 层定义的具体提示
|
||||||
|
// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误")
|
||||||
|
func (ctl *GroupController) handleError(c *gin.Context, err error, fallbackMsg ...string) {
|
||||||
|
switch err {
|
||||||
|
case service.ErrGroupNotFound:
|
||||||
|
utils.ResponseNotFound(c, err.Error())
|
||||||
|
case service.ErrGroupDissolved:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrNotGroupMember:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrNotGroupOwner:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrNotGroupAdmin:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrGroupFull:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrAlreadyMember:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrCannotKickHigherRole:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrOwnerCannotLeave:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrCannotMuteSelf:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrAlreadyMuted:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrUserMuted:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrGroupAllMuted:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrPendingRequestExists:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrJoinRequestNotFound:
|
||||||
|
utils.ResponseNotFound(c, err.Error())
|
||||||
|
default:
|
||||||
|
msg := "服务器内部错误"
|
||||||
|
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
||||||
|
msg = fallbackMsg[0]
|
||||||
|
}
|
||||||
|
utils.ResponseError(c, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
346
backend/go-service/app/group/dao/group_dao.go
Normal file
346
backend/go-service/app/group/dao/group_dao.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
// Package dao 提供 group 模块的数据库访问操作
|
||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/app/constants"
|
||||||
|
"github.com/echochat/backend/app/dto"
|
||||||
|
"github.com/echochat/backend/app/group/model"
|
||||||
|
imModel "github.com/echochat/backend/app/im/model"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupDAO 群聊数据访问对象
|
||||||
|
type GroupDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroupDAO 创建 GroupDAO 实例
|
||||||
|
func NewGroupDAO(db *gorm.DB) *GroupDAO {
|
||||||
|
return &GroupDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroupWithMembers 在事务中创建群聊(会话 + 群信息 + 群成员)
|
||||||
|
// 创建者自动成为群主(role=2)
|
||||||
|
func (d *GroupDAO) CreateGroupWithMembers(ctx context.Context, ownerID int64, name, avatar string, memberIDs []int64) (*model.Group, error) {
|
||||||
|
funcName := "dao.group_dao.CreateGroupWithMembers"
|
||||||
|
logs.Info(ctx, funcName, "创建群聊",
|
||||||
|
zap.Int64("owner_id", ownerID), zap.String("name", name), zap.Int("member_count", len(memberIDs)+1))
|
||||||
|
|
||||||
|
var group model.Group
|
||||||
|
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
conv := &imModel.Conversation{
|
||||||
|
Type: constants.ConversationTypeGroup,
|
||||||
|
CreatorID: ownerID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(conv).Error; err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建会话失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group = model.Group{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
Name: name,
|
||||||
|
Avatar: avatar,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
MaxMembers: constants.GroupDefaultMaxMembers,
|
||||||
|
Status: constants.GroupStatusNormal,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&group).Error; err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建群信息失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
ownerMember := &imModel.ConversationMember{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
UserID: ownerID,
|
||||||
|
Role: constants.GroupRoleOwner,
|
||||||
|
JoinedAt: &now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(ownerMember).Error; err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建群主成员记录失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range memberIDs {
|
||||||
|
member := &imModel.ConversationMember{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
UserID: uid,
|
||||||
|
Role: constants.GroupRoleNormal,
|
||||||
|
JoinedAt: &now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(member).Error; err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建群成员记录失败",
|
||||||
|
zap.Int64("user_id", uid), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建群聊失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据群 ID 获取群信息
|
||||||
|
func (d *GroupDAO) GetByID(ctx context.Context, groupID int64) (*model.Group, error) {
|
||||||
|
funcName := "dao.group_dao.GetByID"
|
||||||
|
logs.Debug(ctx, funcName, "获取群信息", zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
var group model.Group
|
||||||
|
err := d.db.WithContext(ctx).First(&group, groupID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByConversationID 根据会话 ID 获取群信息
|
||||||
|
func (d *GroupDAO) GetByConversationID(ctx context.Context, conversationID int64) (*model.Group, error) {
|
||||||
|
funcName := "dao.group_dao.GetByConversationID"
|
||||||
|
logs.Debug(ctx, funcName, "按会话 ID 获取群信息", zap.Int64("conversation_id", conversationID))
|
||||||
|
|
||||||
|
var group model.Group
|
||||||
|
err := d.db.WithContext(ctx).Where("conversation_id = ?", conversationID).First(&group).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroupInfo 更新群基本信息(名称/头像/公告/可搜索性)
|
||||||
|
func (d *GroupDAO) UpdateGroupInfo(ctx context.Context, groupID int64, updates map[string]interface{}) error {
|
||||||
|
funcName := "dao.group_dao.UpdateGroupInfo"
|
||||||
|
logs.Info(ctx, funcName, "更新群信息", zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.Group{}).
|
||||||
|
Where("id = ?", groupID).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DissolveGroup 解散群聊(标记状态为已解散)
|
||||||
|
func (d *GroupDAO) DissolveGroup(ctx context.Context, groupID int64) error {
|
||||||
|
funcName := "dao.group_dao.DissolveGroup"
|
||||||
|
logs.Info(ctx, funcName, "解散群聊", zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.Group{}).
|
||||||
|
Where("id = ?", groupID).
|
||||||
|
Update("status", constants.GroupStatusDissolved).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferOwner 转让群主
|
||||||
|
func (d *GroupDAO) TransferOwner(ctx context.Context, groupID, oldOwnerID, newOwnerID int64, conversationID int64) error {
|
||||||
|
funcName := "dao.group_dao.TransferOwner"
|
||||||
|
logs.Info(ctx, funcName, "转让群主",
|
||||||
|
zap.Int64("group_id", groupID), zap.Int64("old_owner", oldOwnerID), zap.Int64("new_owner", newOwnerID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.Group{}).
|
||||||
|
Where("id = ?", groupID).
|
||||||
|
Update("owner_id", newOwnerID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, oldOwnerID).
|
||||||
|
Update("role", constants.GroupRoleNormal).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, newOwnerID).
|
||||||
|
Update("role", constants.GroupRoleOwner).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAllMuted 设置/取消全体禁言
|
||||||
|
func (d *GroupDAO) SetAllMuted(ctx context.Context, groupID int64, isMuted bool) error {
|
||||||
|
funcName := "dao.group_dao.SetAllMuted"
|
||||||
|
logs.Info(ctx, funcName, "设置全体禁言", zap.Int64("group_id", groupID), zap.Bool("is_muted", isMuted))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.Group{}).
|
||||||
|
Where("id = ?", groupID).
|
||||||
|
Update("is_all_muted", isMuted).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupBrief 获取群简要信息(满足 im/service.GroupInfoGetter 接口)
|
||||||
|
func (d *GroupDAO) GetGroupBrief(ctx context.Context, conversationID int64) (*dto.GroupBrief, error) {
|
||||||
|
funcName := "dao.group_dao.GetGroupBrief"
|
||||||
|
logs.Debug(ctx, funcName, "获取群简要信息", zap.Int64("conversation_id", conversationID))
|
||||||
|
|
||||||
|
var group model.Group
|
||||||
|
err := d.db.WithContext(ctx).Where("conversation_id = ?", conversationID).First(&group).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto.GroupBrief{
|
||||||
|
ID: group.ID,
|
||||||
|
Name: group.Name,
|
||||||
|
Avatar: group.Avatar,
|
||||||
|
IsAllMuted: group.IsAllMuted,
|
||||||
|
Status: group.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberCount 获取群成员数量
|
||||||
|
func (d *GroupDAO) GetMemberCount(ctx context.Context, conversationID int64) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ?", conversationID).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMember 获取指定群成员记录
|
||||||
|
func (d *GroupDAO) GetMember(ctx context.Context, conversationID, userID int64) (*imModel.ConversationMember, error) {
|
||||||
|
funcName := "dao.group_dao.GetMember"
|
||||||
|
logs.Debug(ctx, funcName, "查询群成员",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
var member imModel.ConversationMember
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
First(&member).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &member, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMembers 获取群所有成员列表
|
||||||
|
func (d *GroupDAO) GetMembers(ctx context.Context, conversationID int64) ([]imModel.ConversationMember, error) {
|
||||||
|
funcName := "dao.group_dao.GetMembers"
|
||||||
|
logs.Debug(ctx, funcName, "获取群成员列表", zap.Int64("conversation_id", conversationID))
|
||||||
|
|
||||||
|
var members []imModel.ConversationMember
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("conversation_id = ?", conversationID).
|
||||||
|
Order("role DESC, joined_at ASC").
|
||||||
|
Find(&members).Error
|
||||||
|
return members, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberIDs 获取群所有成员 ID
|
||||||
|
func (d *GroupDAO) GetMemberIDs(ctx context.Context, conversationID int64) ([]int64, error) {
|
||||||
|
var ids []int64
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ?", conversationID).
|
||||||
|
Pluck("user_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminIDs 获取群主和管理员 ID 列表(用于推送入群申请通知)
|
||||||
|
func (d *GroupDAO) GetAdminIDs(ctx context.Context, conversationID int64) ([]int64, error) {
|
||||||
|
var ids []int64
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND role >= ?", conversationID, constants.GroupRoleAdmin).
|
||||||
|
Pluck("user_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember 添加群成员
|
||||||
|
func (d *GroupDAO) AddMember(ctx context.Context, conversationID, userID int64, role int) error {
|
||||||
|
funcName := "dao.group_dao.AddMember"
|
||||||
|
logs.Info(ctx, funcName, "添加群成员",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
member := &imModel.ConversationMember{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: role,
|
||||||
|
JoinedAt: &now,
|
||||||
|
}
|
||||||
|
return d.db.WithContext(ctx).Create(member).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember 移除群成员
|
||||||
|
func (d *GroupDAO) RemoveMember(ctx context.Context, conversationID, userID int64) error {
|
||||||
|
funcName := "dao.group_dao.RemoveMember"
|
||||||
|
logs.Info(ctx, funcName, "移除群成员",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Delete(&imModel.ConversationMember{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMemberRole 更新成员角色
|
||||||
|
func (d *GroupDAO) UpdateMemberRole(ctx context.Context, conversationID, userID int64, role int) error {
|
||||||
|
funcName := "dao.group_dao.UpdateMemberRole"
|
||||||
|
logs.Info(ctx, funcName, "更新成员角色",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID), zap.Int("role", role))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Update("role", role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMemberMuted 更新成员禁言状态
|
||||||
|
func (d *GroupDAO) UpdateMemberMuted(ctx context.Context, conversationID, userID int64, isMuted bool) error {
|
||||||
|
funcName := "dao.group_dao.UpdateMemberMuted"
|
||||||
|
logs.Info(ctx, funcName, "更新成员禁言状态",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID), zap.Bool("is_muted", isMuted))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Update("is_muted", isMuted).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMemberNickname 更新成员群内昵称
|
||||||
|
func (d *GroupDAO) UpdateMemberNickname(ctx context.Context, conversationID, userID int64, nickname string) error {
|
||||||
|
funcName := "dao.group_dao.UpdateMemberNickname"
|
||||||
|
logs.Info(ctx, funcName, "更新群昵称",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&imModel.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Update("nickname", nickname).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchGroups 搜索可发现的群聊(按群名称全文搜索)
|
||||||
|
func (d *GroupDAO) SearchGroups(ctx context.Context, keyword string, offset, limit int) ([]model.Group, int64, error) {
|
||||||
|
funcName := "dao.group_dao.SearchGroups"
|
||||||
|
logs.Debug(ctx, funcName, "搜索群聊", zap.String("keyword", keyword))
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
query := d.db.WithContext(ctx).
|
||||||
|
Model(&model.Group{}).
|
||||||
|
Where("status = ? AND is_searchable = true AND to_tsvector('simple', name) @@ plainto_tsquery('simple', ?)",
|
||||||
|
constants.GroupStatusNormal, keyword)
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
logs.Error(ctx, funcName, "搜索计数失败", zap.Error(err))
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups []model.Group
|
||||||
|
err := query.Offset(offset).Limit(limit).Order("created_at DESC").Find(&groups).Error
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "搜索查询失败", zap.Error(err))
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return groups, total, nil
|
||||||
|
}
|
||||||
112
backend/go-service/app/group/dao/join_request_dao.go
Normal file
112
backend/go-service/app/group/dao/join_request_dao.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/app/constants"
|
||||||
|
"github.com/echochat/backend/app/group/model"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JoinRequestDAO 入群申请数据访问对象
|
||||||
|
type JoinRequestDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJoinRequestDAO 创建 JoinRequestDAO 实例
|
||||||
|
func NewJoinRequestDAO(db *gorm.DB) *JoinRequestDAO {
|
||||||
|
return &JoinRequestDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建入群申请
|
||||||
|
func (d *JoinRequestDAO) Create(ctx context.Context, groupID, userID int64, message string) (*model.GroupJoinRequest, error) {
|
||||||
|
funcName := "dao.join_request_dao.Create"
|
||||||
|
logs.Info(ctx, funcName, "创建入群申请",
|
||||||
|
zap.Int64("group_id", groupID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
req := &model.GroupJoinRequest{
|
||||||
|
GroupID: groupID,
|
||||||
|
UserID: userID,
|
||||||
|
Message: message,
|
||||||
|
Status: constants.JoinRequestStatusPending,
|
||||||
|
}
|
||||||
|
err := d.db.WithContext(ctx).Create(req).Error
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建入群申请失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取入群申请
|
||||||
|
func (d *JoinRequestDAO) GetByID(ctx context.Context, id int64) (*model.GroupJoinRequest, error) {
|
||||||
|
var req model.GroupJoinRequest
|
||||||
|
err := d.db.WithContext(ctx).First(&req, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingByGroupAndUser 查找用户对某群的待处理申请
|
||||||
|
func (d *JoinRequestDAO) GetPendingByGroupAndUser(ctx context.Context, groupID, userID int64) (*model.GroupJoinRequest, error) {
|
||||||
|
funcName := "dao.join_request_dao.GetPendingByGroupAndUser"
|
||||||
|
logs.Debug(ctx, funcName, "查找待处理申请",
|
||||||
|
zap.Int64("group_id", groupID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
var req model.GroupJoinRequest
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("group_id = ? AND user_id = ? AND status = ?", groupID, userID, constants.JoinRequestStatusPending).
|
||||||
|
First(&req).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetListByGroup 获取群的入群申请列表
|
||||||
|
func (d *JoinRequestDAO) GetListByGroup(ctx context.Context, groupID int64) ([]model.GroupJoinRequest, error) {
|
||||||
|
funcName := "dao.join_request_dao.GetListByGroup"
|
||||||
|
logs.Debug(ctx, funcName, "获取入群申请列表", zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
var requests []model.GroupJoinRequest
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("group_id = ?", groupID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Find(&requests).Error
|
||||||
|
return requests, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve 通过入群申请
|
||||||
|
func (d *JoinRequestDAO) Approve(ctx context.Context, id, reviewerID int64) error {
|
||||||
|
funcName := "dao.join_request_dao.Approve"
|
||||||
|
logs.Info(ctx, funcName, "通过入群申请",
|
||||||
|
zap.Int64("request_id", id), zap.Int64("reviewer_id", reviewerID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.GroupJoinRequest{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": constants.JoinRequestStatusApproved,
|
||||||
|
"reviewer_id": reviewerID,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject 拒绝入群申请
|
||||||
|
func (d *JoinRequestDAO) Reject(ctx context.Context, id, reviewerID int64) error {
|
||||||
|
funcName := "dao.join_request_dao.Reject"
|
||||||
|
logs.Info(ctx, funcName, "拒绝入群申请",
|
||||||
|
zap.Int64("request_id", id), zap.Int64("reviewer_id", reviewerID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.GroupJoinRequest{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": constants.JoinRequestStatusRejected,
|
||||||
|
"reviewer_id": reviewerID,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
129
backend/go-service/app/group/dao/message_read_dao.go
Normal file
129
backend/go-service/app/group/dao/message_read_dao.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/app/group/model"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageReadDAO 消息已读记录数据访问对象
|
||||||
|
type MessageReadDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessageReadDAO 创建 MessageReadDAO 实例
|
||||||
|
func NewMessageReadDAO(db *gorm.DB) *MessageReadDAO {
|
||||||
|
return &MessageReadDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCreate 批量创建已读记录(忽略重复冲突)
|
||||||
|
func (d *MessageReadDAO) BatchCreate(ctx context.Context, reads []model.MessageRead) error {
|
||||||
|
funcName := "dao.message_read_dao.BatchCreate"
|
||||||
|
logs.Info(ctx, funcName, "批量创建已读记录", zap.Int("count", len(reads)))
|
||||||
|
|
||||||
|
if len(reads) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
|
Create(&reads).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadCount 获取消息的已读人数
|
||||||
|
func (d *MessageReadDAO) GetReadCount(ctx context.Context, messageID int64) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&model.MessageRead{}).
|
||||||
|
Where("message_id = ?", messageID).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadCountBatch 批量获取多条消息的已读人数
|
||||||
|
func (d *MessageReadDAO) GetReadCountBatch(ctx context.Context, messageIDs []int64) (map[int64]int, error) {
|
||||||
|
funcName := "dao.message_read_dao.GetReadCountBatch"
|
||||||
|
logs.Debug(ctx, funcName, "批量获取已读计数", zap.Int("count", len(messageIDs)))
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
MessageID int64 `gorm:"column:message_id"`
|
||||||
|
ReadCount int `gorm:"column:read_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []result
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&model.MessageRead{}).
|
||||||
|
Select("message_id, COUNT(*) as read_count").
|
||||||
|
Where("message_id IN ?", messageIDs).
|
||||||
|
Group("message_id").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m := make(map[int64]int, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
m[r.MessageID] = r.ReadCount
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadUsers 获取消息的已读用户列表
|
||||||
|
func (d *MessageReadDAO) GetReadUsers(ctx context.Context, messageID int64) ([]model.MessageRead, error) {
|
||||||
|
funcName := "dao.message_read_dao.GetReadUsers"
|
||||||
|
logs.Debug(ctx, funcName, "获取已读用户列表", zap.Int64("message_id", messageID))
|
||||||
|
|
||||||
|
var reads []model.MessageRead
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("message_id = ?", messageID).
|
||||||
|
Order("read_at ASC").
|
||||||
|
Find(&reads).Error
|
||||||
|
return reads, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCreateReads 批量标记消息已读(满足 im/service.MessageReadRecorder 接口)
|
||||||
|
func (d *MessageReadDAO) BatchCreateReads(ctx context.Context, messageIDs []int64, userID int64) error {
|
||||||
|
funcName := "dao.message_read_dao.BatchCreateReads"
|
||||||
|
logs.Info(ctx, funcName, "批量标记已读",
|
||||||
|
zap.Int64("user_id", userID), zap.Int("count", len(messageIDs)))
|
||||||
|
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reads := make([]model.MessageRead, 0, len(messageIDs))
|
||||||
|
for _, msgID := range messageIDs {
|
||||||
|
reads = append(reads, model.MessageRead{
|
||||||
|
MessageID: msgID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return d.BatchCreate(ctx, reads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadUserIDs 获取消息的已读用户 ID 列表(满足 im/service.MessageReadRecorder 接口)
|
||||||
|
func (d *MessageReadDAO) GetReadUserIDs(ctx context.Context, messageID int64) ([]int64, error) {
|
||||||
|
funcName := "dao.message_read_dao.GetReadUserIDs"
|
||||||
|
logs.Debug(ctx, funcName, "获取已读用户 ID 列表", zap.Int64("message_id", messageID))
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&model.MessageRead{}).
|
||||||
|
Where("message_id = ?", messageID).
|
||||||
|
Pluck("user_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRead 检查用户是否已读某消息
|
||||||
|
func (d *MessageReadDAO) HasRead(ctx context.Context, messageID, userID int64) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&model.MessageRead{}).
|
||||||
|
Where("message_id = ? AND user_id = ?", messageID, userID).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
27
backend/go-service/app/group/model/group.go
Normal file
27
backend/go-service/app/group/model/group.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Package model 定义 group 模块的数据库模型
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Group 群聊信息模型,对应 im_groups 表
|
||||||
|
// 与 im_conversations (type=2) 一对一关联
|
||||||
|
// Status: 1=正常,2=已解散
|
||||||
|
type Group struct {
|
||||||
|
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 群唯一标识
|
||||||
|
ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex"` // 关联 im_conversations.id
|
||||||
|
Name string `json:"name" gorm:"size:100;not null;default:''"` // 群名称
|
||||||
|
Avatar string `json:"avatar" gorm:"size:500;default:''"` // 群头像 URL(MinIO)
|
||||||
|
OwnerID int64 `json:"owner_id" gorm:"not null;index:idx_im_groups_owner"` // 群主用户 ID
|
||||||
|
Notice string `json:"notice" gorm:"type:text;default:''"` // 群公告内容
|
||||||
|
MaxMembers int `json:"max_members" gorm:"not null;default:200"` // 最大成员数
|
||||||
|
IsSearchable bool `json:"is_searchable" gorm:"not null;default:true"` // 是否可被搜索发现
|
||||||
|
IsAllMuted bool `json:"is_all_muted" gorm:"not null;default:false"` // 是否全体禁言
|
||||||
|
Status int `json:"status" gorm:"not null;default:1"` // 群状态:1=正常,2=已解散
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 创建时间
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定数据库表名
|
||||||
|
func (Group) TableName() string {
|
||||||
|
return "im_groups"
|
||||||
|
}
|
||||||
21
backend/go-service/app/group/model/join_request.go
Normal file
21
backend/go-service/app/group/model/join_request.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// GroupJoinRequest 入群申请模型,对应 im_group_join_requests 表
|
||||||
|
// Status: 0=待审批,1=通过,2=拒绝
|
||||||
|
type GroupJoinRequest struct {
|
||||||
|
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 申请唯一标识
|
||||||
|
GroupID int64 `json:"group_id" gorm:"not null;index:idx_group_join_req_group"` // 目标群 ID
|
||||||
|
UserID int64 `json:"user_id" gorm:"not null;index:idx_group_join_req_user"` // 申请人用户 ID
|
||||||
|
Message string `json:"message" gorm:"type:text;default:''"` // 申请附言
|
||||||
|
ReviewerID *int64 `json:"reviewer_id"` // 审批人用户 ID
|
||||||
|
Status int `json:"status" gorm:"not null;default:0"` // 状态:0=待审批,1=通过,2=拒绝
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 申请时间
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定数据库表名
|
||||||
|
func (GroupJoinRequest) TableName() string {
|
||||||
|
return "im_group_join_requests"
|
||||||
|
}
|
||||||
16
backend/go-service/app/group/model/message_read.go
Normal file
16
backend/go-service/app/group/model/message_read.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MessageRead 群聊消息已读记录模型,对应 im_message_reads 表
|
||||||
|
// 复合主键 (message_id, user_id),记录每条群消息的已读用户
|
||||||
|
type MessageRead struct {
|
||||||
|
MessageID int64 `json:"message_id" gorm:"primaryKey"` // 消息 ID
|
||||||
|
UserID int64 `json:"user_id" gorm:"primaryKey"` // 已读用户 ID
|
||||||
|
ReadAt time.Time `json:"read_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 已读时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定数据库表名
|
||||||
|
func (MessageRead) TableName() string {
|
||||||
|
return "im_message_reads"
|
||||||
|
}
|
||||||
18
backend/go-service/app/group/provider.go
Normal file
18
backend/go-service/app/group/provider.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Package group 群聊管理模块
|
||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/group/controller"
|
||||||
|
"github.com/echochat/backend/app/group/dao"
|
||||||
|
"github.com/echochat/backend/app/group/service"
|
||||||
|
"github.com/google/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupSet 群聊模块 Wire Provider Set
|
||||||
|
var GroupSet = wire.NewSet(
|
||||||
|
dao.NewGroupDAO,
|
||||||
|
dao.NewJoinRequestDAO,
|
||||||
|
dao.NewMessageReadDAO,
|
||||||
|
service.NewGroupService,
|
||||||
|
controller.NewGroupController,
|
||||||
|
)
|
||||||
38
backend/go-service/app/group/router.go
Normal file
38
backend/go-service/app/group/router.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/group/controller"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRoutes 注册 group 模块的所有路由(需要 JWT 中间件)
|
||||||
|
func RegisterRoutes(r *gin.Engine, ctrl *controller.GroupController, jwtAuth gin.HandlerFunc) {
|
||||||
|
authed := r.Group("/api/v1")
|
||||||
|
authed.Use(jwtAuth)
|
||||||
|
{
|
||||||
|
// 群聊管理
|
||||||
|
authed.POST("/groups", ctrl.CreateGroup)
|
||||||
|
authed.GET("/groups/search", ctrl.SearchGroups)
|
||||||
|
authed.GET("/groups/:id", ctrl.GetGroupDetail)
|
||||||
|
authed.PUT("/groups/:id", ctrl.UpdateGroup)
|
||||||
|
authed.DELETE("/groups/:id", ctrl.DissolveGroup)
|
||||||
|
|
||||||
|
// 群成员管理
|
||||||
|
authed.GET("/groups/:id/members", ctrl.GetMembers)
|
||||||
|
authed.POST("/groups/:id/members", ctrl.InviteMembers)
|
||||||
|
authed.DELETE("/groups/:id/members/me", ctrl.LeaveGroup)
|
||||||
|
authed.DELETE("/groups/:id/members/:uid", ctrl.KickMember)
|
||||||
|
authed.PUT("/groups/:id/members/me/nickname", ctrl.UpdateNickname)
|
||||||
|
authed.PUT("/groups/:id/members/:uid/role", ctrl.SetMemberRole)
|
||||||
|
authed.PUT("/groups/:id/members/:uid/mute", ctrl.MuteMember)
|
||||||
|
|
||||||
|
// 群主转让 + 全体禁言
|
||||||
|
authed.PUT("/groups/:id/transfer", ctrl.TransferOwner)
|
||||||
|
authed.PUT("/groups/:id/all-mute", ctrl.SetAllMuted)
|
||||||
|
|
||||||
|
// 入群申请
|
||||||
|
authed.POST("/groups/:id/join-requests", ctrl.SubmitJoinRequest)
|
||||||
|
authed.GET("/groups/:id/join-requests", ctrl.GetJoinRequests)
|
||||||
|
authed.PUT("/groups/:id/join-requests/:rid", ctrl.ReviewJoinRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
940
backend/go-service/app/group/service/group_service.go
Normal file
940
backend/go-service/app/group/service/group_service.go
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
// Package service 提供 group 模块的业务逻辑
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
authModel "github.com/echochat/backend/app/auth/model"
|
||||||
|
"github.com/echochat/backend/app/constants"
|
||||||
|
"github.com/echochat/backend/app/dto"
|
||||||
|
"github.com/echochat/backend/app/group/dao"
|
||||||
|
"github.com/echochat/backend/app/group/model"
|
||||||
|
imModel "github.com/echochat/backend/app/im/model"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"github.com/echochat/backend/pkg/ws"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrGroupNotFound = errors.New("群聊不存在")
|
||||||
|
ErrGroupDissolved = errors.New("群聊已解散")
|
||||||
|
ErrNotGroupMember = errors.New("你不是该群成员")
|
||||||
|
ErrNotGroupOwner = errors.New("仅群主可执行此操作")
|
||||||
|
ErrNotGroupAdmin = errors.New("仅群主或管理员可执行此操作")
|
||||||
|
ErrGroupFull = errors.New("群成员已满")
|
||||||
|
ErrAlreadyMember = errors.New("该用户已是群成员")
|
||||||
|
ErrCannotKickHigherRole = errors.New("不能操作同级或更高权限的成员")
|
||||||
|
ErrOwnerCannotLeave = errors.New("群主不能退出群聊,请先转让群主")
|
||||||
|
ErrCannotMuteSelf = errors.New("不能禁言自己")
|
||||||
|
ErrAlreadyMuted = errors.New("该成员已被禁言")
|
||||||
|
ErrUserMuted = errors.New("你已被禁言,无法发送消息")
|
||||||
|
ErrGroupAllMuted = errors.New("当前群已开启全体禁言")
|
||||||
|
ErrPendingRequestExists = errors.New("已有待处理的入群申请")
|
||||||
|
ErrJoinRequestNotFound = errors.New("入群申请不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserInfoProvider 获取用户信息的接口(通过接口注入,由 contact.FriendshipDAO 隐式实现)
|
||||||
|
type UserInfoProvider interface {
|
||||||
|
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageWriter 写入系统消息的接口(由 im.MessageDAO 隐式实现)
|
||||||
|
type MessageWriter interface {
|
||||||
|
Create(ctx context.Context, msg *imModel.Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupService 群聊业务服务
|
||||||
|
type GroupService struct {
|
||||||
|
groupDAO *dao.GroupDAO
|
||||||
|
joinRequestDAO *dao.JoinRequestDAO
|
||||||
|
userInfo UserInfoProvider
|
||||||
|
pubsub *ws.PubSub
|
||||||
|
msgWriter MessageWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroupService 创建 GroupService 实例
|
||||||
|
func NewGroupService(
|
||||||
|
groupDAO *dao.GroupDAO,
|
||||||
|
joinRequestDAO *dao.JoinRequestDAO,
|
||||||
|
userInfo UserInfoProvider,
|
||||||
|
pubsub *ws.PubSub,
|
||||||
|
msgWriter MessageWriter,
|
||||||
|
) *GroupService {
|
||||||
|
return &GroupService{
|
||||||
|
groupDAO: groupDAO,
|
||||||
|
joinRequestDAO: joinRequestDAO,
|
||||||
|
userInfo: userInfo,
|
||||||
|
pubsub: pubsub,
|
||||||
|
msgWriter: msgWriter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup 创建群聊
|
||||||
|
func (s *GroupService) CreateGroup(ctx context.Context, ownerID int64, req *dto.CreateGroupRequest) (*dto.GroupDTO, error) {
|
||||||
|
funcName := "service.group_service.CreateGroup"
|
||||||
|
logs.Info(ctx, funcName, "创建群聊",
|
||||||
|
zap.Int64("owner_id", ownerID), zap.String("name", req.Name), zap.Int("member_count", len(req.MemberIDs)))
|
||||||
|
|
||||||
|
group, err := s.groupDAO.CreateGroupWithMembers(ctx, ownerID, req.Name, req.Avatar, req.MemberIDs)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "创建群聊失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 创建了群聊", s.getUserNickname(ctx, ownerID)))
|
||||||
|
|
||||||
|
allMemberIDs := append([]int64{ownerID}, req.MemberIDs...)
|
||||||
|
s.pushToMembers(ctx, allMemberIDs, 0, "group.created", map[string]interface{}{
|
||||||
|
"group_id": group.ID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"name": group.Name,
|
||||||
|
"owner_id": ownerID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return s.toGroupDTO(group, int(len(req.MemberIDs)+1)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupDetail 获取群详情(需要是群成员)
|
||||||
|
func (s *GroupService) GetGroupDetail(ctx context.Context, userID, groupID int64) (*dto.GroupDTO, error) {
|
||||||
|
funcName := "service.group_service.GetGroupDetail"
|
||||||
|
logs.Debug(ctx, funcName, "获取群详情",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.groupDAO.GetByID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrGroupNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := s.groupDAO.GetMemberCount(ctx, group.ConversationID)
|
||||||
|
return s.toGroupDTO(group, int(count)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup 更新群信息(群主或管理员)
|
||||||
|
func (s *GroupService) UpdateGroup(ctx context.Context, userID, groupID int64, req *dto.UpdateGroupRequest) error {
|
||||||
|
funcName := "service.group_service.UpdateGroup"
|
||||||
|
logs.Info(ctx, funcName, "更新群信息",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, _, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := make(map[string]interface{})
|
||||||
|
if req.Name != nil {
|
||||||
|
updates["name"] = *req.Name
|
||||||
|
}
|
||||||
|
if req.Avatar != nil {
|
||||||
|
updates["avatar"] = *req.Avatar
|
||||||
|
}
|
||||||
|
if req.Notice != nil {
|
||||||
|
updates["notice"] = *req.Notice
|
||||||
|
}
|
||||||
|
if req.IsSearchable != nil {
|
||||||
|
updates["is_searchable"] = *req.IsSearchable
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.UpdateGroupInfo(ctx, groupID, updates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Notice != nil {
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID,
|
||||||
|
fmt.Sprintf("%s 更新了群公告", s.getUserNickname(ctx, userID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.info.update", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"operator_id": userID,
|
||||||
|
"updates": updates,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DissolveGroup 解散群聊(仅群主)
|
||||||
|
func (s *GroupService) DissolveGroup(ctx context.Context, userID, groupID int64) error {
|
||||||
|
funcName := "service.group_service.DissolveGroup"
|
||||||
|
logs.Info(ctx, funcName, "解散群聊",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group.OwnerID != userID {
|
||||||
|
return ErrNotGroupOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.groupDAO.GetMemberIDs(ctx, group.ConversationID)
|
||||||
|
|
||||||
|
if err := s.groupDAO.DissolveGroup(ctx, groupID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, "群聊已解散")
|
||||||
|
|
||||||
|
s.pushToMembers(ctx, memberIDs, 0, "group.dissolved", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMembers 获取群成员列表(需要是群成员)
|
||||||
|
func (s *GroupService) GetMembers(ctx context.Context, userID, groupID int64) ([]dto.GroupMemberDTO, error) {
|
||||||
|
funcName := "service.group_service.GetMembers"
|
||||||
|
logs.Debug(ctx, funcName, "获取群成员列表",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.groupDAO.GetMembers(ctx, group.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]int64, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
userIDs = append(userIDs, m.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[int64]*authModel.User)
|
||||||
|
if len(userIDs) > 0 && s.userInfo != nil {
|
||||||
|
users, uErr := s.userInfo.GetUsersByIDs(ctx, userIDs)
|
||||||
|
if uErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
|
||||||
|
} else {
|
||||||
|
for i := range users {
|
||||||
|
userMap[users[i].ID] = &users[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.GroupMemberDTO, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
item := dto.GroupMemberDTO{
|
||||||
|
UserID: m.UserID,
|
||||||
|
Nickname: m.Nickname,
|
||||||
|
Role: m.Role,
|
||||||
|
IsMuted: m.IsMuted,
|
||||||
|
}
|
||||||
|
if m.JoinedAt != nil {
|
||||||
|
item.JoinedAt = m.JoinedAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
if user, ok := userMap[m.UserID]; ok {
|
||||||
|
item.UserNickname = user.Nickname
|
||||||
|
item.Avatar = user.Avatar
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteMembers 邀请用户入群(群主/管理员)
|
||||||
|
func (s *GroupService) InviteMembers(ctx context.Context, userID, groupID int64, targetIDs []int64) error {
|
||||||
|
funcName := "service.group_service.InviteMembers"
|
||||||
|
logs.Info(ctx, funcName, "邀请入群",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Int("count", len(targetIDs)))
|
||||||
|
|
||||||
|
group, _, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := s.groupDAO.GetMemberCount(ctx, group.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if int(count)+len(targetIDs) > group.MaxMembers {
|
||||||
|
return ErrGroupFull
|
||||||
|
}
|
||||||
|
|
||||||
|
addedIDs := make([]int64, 0, len(targetIDs))
|
||||||
|
for _, uid := range targetIDs {
|
||||||
|
existing, _ := s.groupDAO.GetMember(ctx, group.ConversationID, uid)
|
||||||
|
if existing != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.groupDAO.AddMember(ctx, group.ConversationID, uid, constants.GroupRoleNormal); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "添加成员失败", zap.Int64("target_id", uid), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addedIDs = append(addedIDs, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(addedIDs) > 0 {
|
||||||
|
inviterName := s.getUserNickname(ctx, userID)
|
||||||
|
addedNames := s.getUserNicknames(ctx, addedIDs)
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID,
|
||||||
|
fmt.Sprintf("%s 邀请 %s 加入了群聊", inviterName, joinNames(addedNames)))
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.join", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_ids": addedIDs,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickMember 踢出群成员(群主/管理员,不能操作同级或更高权限的成员)
|
||||||
|
func (s *GroupService) KickMember(ctx context.Context, userID, groupID, targetID int64) error {
|
||||||
|
funcName := "service.group_service.KickMember"
|
||||||
|
logs.Info(ctx, funcName, "踢出成员",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Int64("target_id", targetID))
|
||||||
|
|
||||||
|
group, operatorMember, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetMember, err := s.groupDAO.GetMember(ctx, group.ConversationID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetMember.Role >= operatorMember.Role {
|
||||||
|
return ErrCannotKickHigherRole
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.RemoveMember(ctx, group.ConversationID, targetID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetName := s.getUserNickname(ctx, targetID)
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID,
|
||||||
|
fmt.Sprintf("%s 被移出了群聊", targetName))
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.kicked", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_id": targetID,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
s.pushToUser(ctx, targetID, "group.member.kicked", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_id": targetID,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberRole 设置/取消管理员(仅群主)
|
||||||
|
func (s *GroupService) SetMemberRole(ctx context.Context, userID, groupID, targetID int64, role int) error {
|
||||||
|
funcName := "service.group_service.SetMemberRole"
|
||||||
|
logs.Info(ctx, funcName, "设置成员角色",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID),
|
||||||
|
zap.Int64("target_id", targetID), zap.Int("role", role))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group.OwnerID != userID {
|
||||||
|
return ErrNotGroupOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupDAO.GetMember(ctx, group.ConversationID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.UpdateMemberRole(ctx, group.ConversationID, targetID, role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetName := s.getUserNickname(ctx, targetID)
|
||||||
|
if role == constants.GroupRoleAdmin {
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 被设为管理员", targetName))
|
||||||
|
} else {
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 被取消管理员", targetName))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.role.update", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_id": targetID,
|
||||||
|
"role": role,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteMember 禁言/解除禁言成员(群主/管理员)
|
||||||
|
func (s *GroupService) MuteMember(ctx context.Context, userID, groupID, targetID int64, isMuted bool) error {
|
||||||
|
funcName := "service.group_service.MuteMember"
|
||||||
|
logs.Info(ctx, funcName, "更新禁言状态",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID),
|
||||||
|
zap.Int64("target_id", targetID), zap.Bool("is_muted", isMuted))
|
||||||
|
|
||||||
|
if userID == targetID {
|
||||||
|
return ErrCannotMuteSelf
|
||||||
|
}
|
||||||
|
|
||||||
|
group, operatorMember, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetMember, err := s.groupDAO.GetMember(ctx, group.ConversationID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetMember.Role >= operatorMember.Role {
|
||||||
|
return ErrCannotKickHigherRole
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.UpdateMemberMuted(ctx, group.ConversationID, targetID, isMuted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.mute.update", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_id": targetID,
|
||||||
|
"is_muted": isMuted,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaveGroup 退出群聊(群主不能退出,需先转让)
|
||||||
|
func (s *GroupService) LeaveGroup(ctx context.Context, userID, groupID int64) error {
|
||||||
|
funcName := "service.group_service.LeaveGroup"
|
||||||
|
logs.Info(ctx, funcName, "退出群聊",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group.OwnerID == userID {
|
||||||
|
return ErrOwnerCannotLeave
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.RemoveMember(ctx, group.ConversationID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userName := s.getUserNickname(ctx, userID)
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 退出了群聊", userName))
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.leave", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_id": userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferOwner 转让群主(仅群主)
|
||||||
|
func (s *GroupService) TransferOwner(ctx context.Context, userID, groupID, newOwnerID int64) error {
|
||||||
|
funcName := "service.group_service.TransferOwner"
|
||||||
|
logs.Info(ctx, funcName, "转让群主",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Int64("new_owner", newOwnerID))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group.OwnerID != userID {
|
||||||
|
return ErrNotGroupOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupDAO.GetMember(ctx, group.ConversationID, newOwnerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.TransferOwner(ctx, groupID, userID, newOwnerID, group.ConversationID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldOwnerName := s.getUserNickname(ctx, userID)
|
||||||
|
newOwnerName := s.getUserNickname(ctx, newOwnerID)
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID,
|
||||||
|
fmt.Sprintf("%s 将群主转让给了 %s", oldOwnerName, newOwnerName))
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.owner.transfer", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"old_owner_id": userID,
|
||||||
|
"new_owner_id": newOwnerID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNickname 修改群内昵称
|
||||||
|
func (s *GroupService) UpdateNickname(ctx context.Context, userID, groupID int64, nickname string) error {
|
||||||
|
funcName := "service.group_service.UpdateNickname"
|
||||||
|
logs.Info(ctx, funcName, "修改群昵称",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.groupDAO.UpdateMemberNickname(ctx, group.ConversationID, userID, nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitJoinRequest 提交入群申请
|
||||||
|
func (s *GroupService) SubmitJoinRequest(ctx context.Context, userID, groupID int64, message string) error {
|
||||||
|
funcName := "service.group_service.SubmitJoinRequest"
|
||||||
|
logs.Info(ctx, funcName, "提交入群申请",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, _ := s.groupDAO.GetMember(ctx, group.ConversationID, userID)
|
||||||
|
if existing != nil {
|
||||||
|
return ErrAlreadyMember
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, _ := s.joinRequestDAO.GetPendingByGroupAndUser(ctx, groupID, userID)
|
||||||
|
if pending != nil {
|
||||||
|
return ErrPendingRequestExists
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.joinRequestDAO.Create(ctx, groupID, userID, message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
adminIDs, _ := s.groupDAO.GetAdminIDs(ctx, group.ConversationID)
|
||||||
|
userName := s.getUserNickname(ctx, userID)
|
||||||
|
s.pushToMembers(ctx, adminIDs, 0, "group.join.request", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"request_id": req.ID,
|
||||||
|
"user_id": userID,
|
||||||
|
"user_nickname": userName,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJoinRequests 获取入群申请列表(群主/管理员)
|
||||||
|
func (s *GroupService) GetJoinRequests(ctx context.Context, userID, groupID int64) ([]dto.JoinRequestDTO, error) {
|
||||||
|
funcName := "service.group_service.GetJoinRequests"
|
||||||
|
logs.Debug(ctx, funcName, "获取入群申请列表",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID))
|
||||||
|
|
||||||
|
_, _, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
requests, err := s.joinRequestDAO.GetListByGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]int64, 0, len(requests))
|
||||||
|
for _, r := range requests {
|
||||||
|
userIDs = append(userIDs, r.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[int64]*authModel.User)
|
||||||
|
if len(userIDs) > 0 && s.userInfo != nil {
|
||||||
|
users, uErr := s.userInfo.GetUsersByIDs(ctx, userIDs)
|
||||||
|
if uErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
|
||||||
|
} else {
|
||||||
|
for i := range users {
|
||||||
|
userMap[users[i].ID] = &users[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.JoinRequestDTO, 0, len(requests))
|
||||||
|
for _, r := range requests {
|
||||||
|
item := dto.JoinRequestDTO{
|
||||||
|
ID: r.ID,
|
||||||
|
GroupID: r.GroupID,
|
||||||
|
UserID: r.UserID,
|
||||||
|
Message: r.Message,
|
||||||
|
Status: r.Status,
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
if user, ok := userMap[r.UserID]; ok {
|
||||||
|
item.UserNickname = user.Nickname
|
||||||
|
item.UserAvatar = user.Avatar
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewJoinRequest 审批入群申请(群主/管理员)
|
||||||
|
func (s *GroupService) ReviewJoinRequest(ctx context.Context, userID, groupID, requestID int64, action string) error {
|
||||||
|
funcName := "service.group_service.ReviewJoinRequest"
|
||||||
|
logs.Info(ctx, funcName, "审批入群申请",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("request_id", requestID), zap.String("action", action))
|
||||||
|
|
||||||
|
group, _, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.joinRequestDAO.GetByID(ctx, requestID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrJoinRequestNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GroupID != groupID || req.Status != constants.JoinRequestStatusPending {
|
||||||
|
return ErrJoinRequestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "approve" {
|
||||||
|
count, _ := s.groupDAO.GetMemberCount(ctx, group.ConversationID)
|
||||||
|
if int(count) >= group.MaxMembers {
|
||||||
|
return ErrGroupFull
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.joinRequestDAO.Approve(ctx, requestID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.groupDAO.AddMember(ctx, group.ConversationID, req.UserID, constants.GroupRoleNormal); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newMemberName := s.getUserNickname(ctx, req.UserID)
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 加入了群聊", newMemberName))
|
||||||
|
|
||||||
|
s.pushToUser(ctx, req.UserID, "group.join.approved", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"request_id": requestID,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.join", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"user_ids": []int64{req.UserID},
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.joinRequestDAO.Reject(ctx, requestID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchGroups 搜索公开群
|
||||||
|
func (s *GroupService) SearchGroups(ctx context.Context, req *dto.SearchGroupRequest) (*dto.SearchGroupResponse, error) {
|
||||||
|
funcName := "service.group_service.SearchGroups"
|
||||||
|
logs.Debug(ctx, funcName, "搜索群聊", zap.String("keyword", req.Keyword))
|
||||||
|
|
||||||
|
page := req.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := req.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
groups, total, err := s.groupDAO.SearchGroups(ctx, req.Keyword, offset, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.GroupDTO, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
count, _ := s.groupDAO.GetMemberCount(ctx, g.ConversationID)
|
||||||
|
list = append(list, *s.toGroupDTO(&g, int(count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.SearchGroupResponse{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAllMuted 设置/取消全体禁言(群主/管理员)
|
||||||
|
func (s *GroupService) SetAllMuted(ctx context.Context, userID, groupID int64, isMuted bool) error {
|
||||||
|
funcName := "service.group_service.SetAllMuted"
|
||||||
|
logs.Info(ctx, funcName, "设置全体禁言",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Bool("is_muted", isMuted))
|
||||||
|
|
||||||
|
group, _, err := s.checkGroupAdmin(ctx, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupDAO.SetAllMuted(ctx, groupID, isMuted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorName := s.getUserNickname(ctx, userID)
|
||||||
|
if isMuted {
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 开启了全体禁言", operatorName))
|
||||||
|
} else {
|
||||||
|
s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 关闭了全体禁言", operatorName))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.mute.update", map[string]interface{}{
|
||||||
|
"group_id": groupID,
|
||||||
|
"conversation_id": group.ConversationID,
|
||||||
|
"is_all_muted": isMuted,
|
||||||
|
"operator_id": userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 内部辅助方法 ======
|
||||||
|
|
||||||
|
// getActiveGroup 获取群聊并校验是否存在且未解散
|
||||||
|
func (s *GroupService) getActiveGroup(ctx context.Context, groupID int64) (*model.Group, error) {
|
||||||
|
group, err := s.groupDAO.GetByID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrGroupNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if group.Status == constants.GroupStatusDissolved {
|
||||||
|
return nil, ErrGroupDissolved
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkGroupAdmin 校验用户是群主或管理员
|
||||||
|
func (s *GroupService) checkGroupAdmin(ctx context.Context, groupID, userID int64) (*model.Group, *imMember, error) {
|
||||||
|
group, err := s.getActiveGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
member, err := s.groupDAO.GetMember(ctx, group.ConversationID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, ErrNotGroupMember
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if member.Role < constants.GroupRoleAdmin {
|
||||||
|
return nil, nil, ErrNotGroupAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
return group, &imMember{
|
||||||
|
UserID: member.UserID,
|
||||||
|
Role: member.Role,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imMember 内部使用的成员简要信息
|
||||||
|
type imMember struct {
|
||||||
|
UserID int64
|
||||||
|
Role int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 推送和系统消息辅助方法 ======
|
||||||
|
|
||||||
|
// pushToUser 向单个用户推送通知
|
||||||
|
func (s *GroupService) pushToUser(ctx context.Context, userID int64, event string, data interface{}) {
|
||||||
|
if s.pubsub == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
push := ws.NewPushMessage(event, data)
|
||||||
|
bytes, err := ws.MarshalPush(push)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, "service.group_service.pushToUser", "序列化推送消息失败", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.pubsub.Publish(ctx, userID, bytes); err != nil {
|
||||||
|
logs.Error(ctx, "service.group_service.pushToUser", "推送失败",
|
||||||
|
zap.Int64("user_id", userID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushToMembers 向指定用户列表推送通知(排除 excludeUID)
|
||||||
|
func (s *GroupService) pushToMembers(ctx context.Context, memberIDs []int64, excludeUID int64, event string, data interface{}) {
|
||||||
|
if s.pubsub == nil || len(memberIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
push := ws.NewPushMessage(event, data)
|
||||||
|
bytes, err := ws.MarshalPush(push)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, "service.group_service.pushToMembers", "序列化推送消息失败", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, uid := range memberIDs {
|
||||||
|
if uid == excludeUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pErr := s.pubsub.Publish(ctx, uid, bytes); pErr != nil {
|
||||||
|
logs.Error(ctx, "service.group_service.pushToMembers", "推送失败",
|
||||||
|
zap.Int64("user_id", uid), zap.Error(pErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushToGroupMembers 向群所有成员推送通知(通过 conversationID 查成员)
|
||||||
|
func (s *GroupService) pushToGroupMembers(ctx context.Context, conversationID int64, excludeUID int64, event string, data interface{}) {
|
||||||
|
memberIDs, err := s.groupDAO.GetMemberIDs(ctx, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, "service.group_service.pushToGroupMembers", "获取成员列表失败", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pushToMembers(ctx, memberIDs, excludeUID, event, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSystemMessage 写入系统消息到群会话
|
||||||
|
func (s *GroupService) writeSystemMessage(ctx context.Context, conversationID int64, content string) {
|
||||||
|
if s.msgWriter == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := &imModel.Message{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
SenderID: 0,
|
||||||
|
Type: constants.MessageTypeSystem,
|
||||||
|
Content: content,
|
||||||
|
Status: constants.MessageStatusNormal,
|
||||||
|
}
|
||||||
|
if err := s.msgWriter.Create(ctx, msg); err != nil {
|
||||||
|
logs.Error(ctx, "service.group_service.writeSystemMessage", "写入系统消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserNickname 获取单个用户昵称(推送文案使用,查询失败返回默认值)
|
||||||
|
func (s *GroupService) getUserNickname(ctx context.Context, userID int64) string {
|
||||||
|
if s.userInfo == nil {
|
||||||
|
return "用户"
|
||||||
|
}
|
||||||
|
users, err := s.userInfo.GetUsersByIDs(ctx, []int64{userID})
|
||||||
|
if err != nil || len(users) == 0 {
|
||||||
|
return "用户"
|
||||||
|
}
|
||||||
|
return users[0].Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserNicknames 批量获取用户昵称列表
|
||||||
|
func (s *GroupService) getUserNicknames(ctx context.Context, userIDs []int64) []string {
|
||||||
|
if s.userInfo == nil || len(userIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
users, err := s.userInfo.GetUsersByIDs(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nameMap := make(map[int64]string, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
nameMap[u.ID] = u.Nickname
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(userIDs))
|
||||||
|
for _, id := range userIDs {
|
||||||
|
if n, ok := nameMap[id]; ok {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinNames 将名称列表用顿号连接(中文习惯)
|
||||||
|
func joinNames(names []string) string {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := names[0]
|
||||||
|
for i := 1; i < len(names); i++ {
|
||||||
|
result += "、" + names[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGroupDTO 将 model.Group 转换为 dto.GroupDTO
|
||||||
|
func (s *GroupService) toGroupDTO(g *model.Group, memberCount int) *dto.GroupDTO {
|
||||||
|
return &dto.GroupDTO{
|
||||||
|
ID: g.ID,
|
||||||
|
ConversationID: g.ConversationID,
|
||||||
|
Name: g.Name,
|
||||||
|
Avatar: g.Avatar,
|
||||||
|
OwnerID: g.OwnerID,
|
||||||
|
Notice: g.Notice,
|
||||||
|
MaxMembers: g.MaxMembers,
|
||||||
|
MemberCount: memberCount,
|
||||||
|
IsSearchable: g.IsSearchable,
|
||||||
|
IsAllMuted: g.IsAllMuted,
|
||||||
|
Status: g.Status,
|
||||||
|
CreatedAt: g.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,6 +180,30 @@ func (ctl *IMController) GetTotalUnread(c *gin.Context) {
|
|||||||
utils.ResponseOK(c, gin.H{"total_unread": count})
|
utils.ResponseOK(c, gin.H{"total_unread": count})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMessageReadDetail 获取消息已读详情
|
||||||
|
// GET /api/v1/im/messages/:id/reads
|
||||||
|
func (ctl *IMController) GetMessageReadDetail(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseBadRequest(c, "消息 ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.imService.GetMessageReadDetail(ctx, userID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "获取已读详情失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// handleError 统一业务错误映射
|
// handleError 统一业务错误映射
|
||||||
// 已知业务错误 → 返回 Service 层定义的具体提示
|
// 已知业务错误 → 返回 Service 层定义的具体提示
|
||||||
// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误")
|
// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误")
|
||||||
@@ -203,6 +227,12 @@ func (ctl *IMController) handleError(c *gin.Context, err error, fallbackMsg ...s
|
|||||||
utils.ResponseForbidden(c, err.Error())
|
utils.ResponseForbidden(c, err.Error())
|
||||||
case service.ErrDuplicateMsg:
|
case service.ErrDuplicateMsg:
|
||||||
utils.ResponseBadRequest(c, err.Error())
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrGroupDissolved:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrGroupAllMuted:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
|
case service.ErrUserMuted:
|
||||||
|
utils.ResponseForbidden(c, err.Error())
|
||||||
default:
|
default:
|
||||||
msg := "服务器内部错误"
|
msg := "服务器内部错误"
|
||||||
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
||||||
|
|||||||
@@ -86,13 +86,14 @@ func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64
|
|||||||
err := d.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Raw(`SELECT c.id, c.type, c.last_message_id, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id,
|
Raw(`SELECT c.id, c.type, c.last_message_id, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id,
|
||||||
cm.is_pinned, cm.unread_count, cm.clear_before_msg_id,
|
cm.is_pinned, cm.unread_count, cm.clear_before_msg_id,
|
||||||
peer.user_id AS peer_user_id
|
cm.is_do_not_disturb, cm.at_me_count,
|
||||||
|
COALESCE(peer.user_id, 0) AS peer_user_id
|
||||||
FROM im_conversations c
|
FROM im_conversations c
|
||||||
JOIN im_conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = ?
|
JOIN im_conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = ?
|
||||||
LEFT JOIN im_conversation_members peer ON peer.conversation_id = c.id AND peer.user_id != ?
|
LEFT JOIN im_conversation_members peer ON peer.conversation_id = c.id AND peer.user_id != ? AND c.type = ?
|
||||||
WHERE cm.is_deleted = false
|
WHERE cm.is_deleted = false
|
||||||
ORDER BY cm.is_pinned DESC, c.last_msg_time DESC NULLS LAST`,
|
ORDER BY cm.is_pinned DESC, c.last_msg_time DESC NULLS LAST`,
|
||||||
userID, userID).
|
userID, userID, constants.ConversationTypePrivate).
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,6 +113,8 @@ type ConversationWithMember struct {
|
|||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
UnreadCount int `json:"unread_count"`
|
UnreadCount int `json:"unread_count"`
|
||||||
ClearBeforeMsgID int64 `json:"clear_before_msg_id"`
|
ClearBeforeMsgID int64 `json:"clear_before_msg_id"`
|
||||||
|
IsDoNotDisturb bool `json:"is_do_not_disturb"`
|
||||||
|
AtMeCount int `json:"at_me_count"`
|
||||||
PeerUserID int64 `json:"peer_user_id"`
|
PeerUserID int64 `json:"peer_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +219,44 @@ func (d *ConversationDAO) IncrementUnread(ctx context.Context, conversationID, u
|
|||||||
UpdateColumn("unread_count", gorm.Expr("unread_count + 1")).Error
|
UpdateColumn("unread_count", gorm.Expr("unread_count + 1")).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IncrementAtMeCount 将指定成员的 @提醒计数 +1
|
||||||
|
func (d *ConversationDAO) IncrementAtMeCount(ctx context.Context, conversationID, userID int64) error {
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
UpdateColumn("at_me_count", gorm.Expr("at_me_count + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAtMeCount 清零指定成员的 @提醒计数
|
||||||
|
func (d *ConversationDAO) ClearAtMeCount(ctx context.Context, conversationID, userID int64) error {
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Update("at_me_count", 0).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberDNDMap 批量获取会话成员的免打扰状态(返回 userID → isDoNotDisturb 的映射)
|
||||||
|
func (d *ConversationDAO) GetMemberDNDMap(ctx context.Context, conversationID int64) (map[int64]bool, error) {
|
||||||
|
type memberDND struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
IsDoNotDisturb bool `json:"is_do_not_disturb"`
|
||||||
|
}
|
||||||
|
var members []memberDND
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Model(&model.ConversationMember{}).
|
||||||
|
Select("user_id, is_do_not_disturb").
|
||||||
|
Where("conversation_id = ?", conversationID).
|
||||||
|
Scan(&members).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[int64]bool, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
result[m.UserID] = m.IsDoNotDisturb
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ClearUnread 清零指定成员的未读消息数
|
// ClearUnread 清零指定成员的未读消息数
|
||||||
func (d *ConversationDAO) ClearUnread(ctx context.Context, conversationID, userID int64, lastMsgID int64) error {
|
func (d *ConversationDAO) ClearUnread(ctx context.Context, conversationID, userID int64, lastMsgID int64) error {
|
||||||
return d.db.WithContext(ctx).
|
return d.db.WithContext(ctx).
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func (h *EventHandler) registerEvents() {
|
|||||||
h.hub.RegisterEvent("im.message.send", h.handleSendMessage)
|
h.hub.RegisterEvent("im.message.send", h.handleSendMessage)
|
||||||
h.hub.RegisterEvent("im.message.recall", h.handleRecallMessage)
|
h.hub.RegisterEvent("im.message.recall", h.handleRecallMessage)
|
||||||
h.hub.RegisterEvent("im.conversation.read", h.handleMarkRead)
|
h.hub.RegisterEvent("im.conversation.read", h.handleMarkRead)
|
||||||
|
h.hub.RegisterEvent("im.group.read", h.handleGroupRead)
|
||||||
h.hub.RegisterEvent("im.typing", h.handleTyping)
|
h.hub.RegisterEvent("im.typing", h.handleTyping)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,29 @@ func (h *EventHandler) handleMarkRead(client *ws.Client, msg *ws.Message) {
|
|||||||
h.sendACK(client, msg, 0, "ok", nil)
|
h.sendACK(client, msg, 0, "ok", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGroupRead 处理群聊消息标记已读事件
|
||||||
|
func (h *EventHandler) handleGroupRead(client *ws.Client, msg *ws.Message) {
|
||||||
|
funcName := "handler.event_handler.handleGroupRead"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var req dto.MarkGroupReadRequest
|
||||||
|
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||||
|
logs.Warn(ctx, funcName, "解析群已读请求失败",
|
||||||
|
zap.Int64("user_id", client.UserID), zap.Error(err))
|
||||||
|
h.sendACK(client, msg, -1, "请求参数格式错误", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.imService.MarkGroupMessagesRead(ctx, client.UserID, &req); err != nil {
|
||||||
|
logs.Warn(ctx, funcName, "群已读标记失败",
|
||||||
|
zap.Int64("user_id", client.UserID), zap.Error(err))
|
||||||
|
h.sendACK(client, msg, -1, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendACK(client, msg, 0, "ok", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// handleTyping 处理正在输入事件(通过 PubSub 转发给对方,支持跨实例)
|
// handleTyping 处理正在输入事件(通过 PubSub 转发给对方,支持跨实例)
|
||||||
func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) {
|
func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) {
|
||||||
funcName := "handler.event_handler.handleTyping"
|
funcName := "handler.event_handler.handleTyping"
|
||||||
|
|||||||
@@ -4,17 +4,24 @@ import "time"
|
|||||||
|
|
||||||
// ConversationMember 会话成员模型,对应 im_conversation_members 表
|
// ConversationMember 会话成员模型,对应 im_conversation_members 表
|
||||||
// 每个会话的每个成员一条记录,存储置顶/未读/软删除等个人视图
|
// 每个会话的每个成员一条记录,存储置顶/未读/软删除等个人视图
|
||||||
|
// 群聊场景下额外使用 Role/Nickname/IsMuted/IsDoNotDisturb/JoinedAt/AtMeCount 字段
|
||||||
type ConversationMember struct {
|
type ConversationMember struct {
|
||||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 记录唯一标识
|
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 记录唯一标识
|
||||||
ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 所属会话 ID
|
ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 所属会话 ID
|
||||||
UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 成员用户 ID
|
UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 成员用户 ID
|
||||||
IsPinned bool `json:"is_pinned" gorm:"default:false"` // 是否置顶该会话
|
IsPinned bool `json:"is_pinned" gorm:"default:false"` // 是否置顶该会话
|
||||||
IsDeleted bool `json:"is_deleted" gorm:"default:false"` // 是否删除该会话(软删除,不影响对方)
|
IsDeleted bool `json:"is_deleted" gorm:"default:false"` // 是否删除该会话(软删除,不影响对方)
|
||||||
UnreadCount int `json:"unread_count" gorm:"default:0"` // 该成员在此会话中的未读消息数
|
UnreadCount int `json:"unread_count" gorm:"default:0"` // 该成员在此会话中的未读消息数
|
||||||
LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID
|
LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID
|
||||||
ClearBeforeMsgID int64 `json:"clear_before_msg_id" gorm:"default:0"` // 清空记录时的消息截止 ID(个人视图,不影响对方)
|
ClearBeforeMsgID int64 `json:"clear_before_msg_id" gorm:"default:0"` // 清空记录时的消息截止 ID(个人视图,不影响对方)
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 加入会话时间
|
Role int `json:"role" gorm:"not null;default:0"` // 成员角色:0=普通成员,1=管理员,2=群主
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 最后更新时间
|
Nickname string `json:"nickname" gorm:"size:50;default:''"` // 群内昵称(仅群聊有效)
|
||||||
|
IsMuted bool `json:"is_muted" gorm:"not null;default:false"` // 是否被禁言
|
||||||
|
IsDoNotDisturb bool `json:"is_do_not_disturb" gorm:"not null;default:false"` // 是否消息免打扰
|
||||||
|
JoinedAt *time.Time `json:"joined_at" gorm:"type:timestamp(0)"` // 加入群聊时间
|
||||||
|
AtMeCount int `json:"at_me_count" gorm:"default:0"` // 被@提醒未读计数
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 加入会话时间
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 最后更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定数据库表名
|
// TableName 指定数据库表名
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
// Message 消息模型,对应 im_messages 表
|
// Message 消息模型,对应 im_messages 表
|
||||||
// Type: 1=文本(预留 2=图片 3=语音 4=视频 5=文件)
|
// Type: 1=文本(预留 2=图片 3=语音 4=视频 5=文件),10=系统消息
|
||||||
// Status: 1=正常 2=已撤回 3=已删除
|
// Status: 1=正常 2=已撤回 3=已删除
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 消息唯一标识,自增主键
|
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 消息唯一标识,自增主键
|
||||||
ConversationID int64 `json:"conversation_id" gorm:"not null;index:idx_msg_conv_time;index:idx_msg_conv_id"` // 所属会话 ID
|
ConversationID int64 `json:"conversation_id" gorm:"not null;index:idx_msg_conv_time;index:idx_msg_conv_id"` // 所属会话 ID
|
||||||
SenderID int64 `json:"sender_id" gorm:"not null"` // 发送者用户 ID
|
SenderID int64 `json:"sender_id" gorm:"not null"` // 发送者用户 ID(系统消息为 0)
|
||||||
Type int `json:"type" gorm:"not null;default:1"` // 消息类型:1=文本(预留扩展)
|
Type int `json:"type" gorm:"not null;default:1"` // 消息类型:1=文本,10=系统消息
|
||||||
Content string `json:"content" gorm:"type:text;not null"` // 消息内容
|
Content string `json:"content" gorm:"type:text;not null"` // 消息内容
|
||||||
Extra *string `json:"extra" gorm:"type:jsonb"` // 扩展数据(JSON 格式,预留图片/语音等元信息)
|
Extra *string `json:"extra" gorm:"type:jsonb"` // 扩展数据(JSON 格式,预留图片/语音等元信息)
|
||||||
Status int `json:"status" gorm:"not null;default:1"` // 消息状态:1=正常,2=已撤回,3=已删除
|
Status int `json:"status" gorm:"not null;default:1"` // 消息状态:1=正常,2=已撤回,3=已删除
|
||||||
ClientMsgID string `json:"client_msg_id" gorm:"size:64;default:''"` // 客户端消息唯一 ID,用于幂等去重
|
ClientMsgID string `json:"client_msg_id" gorm:"size:64;default:''"` // 客户端消息唯一 ID,用于幂等去重
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 消息发送时间
|
AtUserIDs utils.Int64Array `json:"at_user_ids" gorm:"type:bigint[]"` // @提醒用户 ID 列表,nil=无@,含 0 表示 @所有人
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 消息发送时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定数据库表名
|
// TableName 指定数据库表名
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ func ProvideMessageDAO(db *gorm.DB) *dao.MessageDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProvideIMService 创建 IMService 实例
|
// ProvideIMService 创建 IMService 实例
|
||||||
// friendChecker 和 userInfoGetter 由 contact 模块的 FriendshipDAO 隐式实现
|
// friendChecker / userInfoGetter 由 contact.FriendshipDAO 隐式实现
|
||||||
|
// groupInfo 由 group.GroupDAO 隐式实现
|
||||||
func ProvideIMService(
|
func ProvideIMService(
|
||||||
convDAO *dao.ConversationDAO,
|
convDAO *dao.ConversationDAO,
|
||||||
msgDAO *dao.MessageDAO,
|
msgDAO *dao.MessageDAO,
|
||||||
@@ -30,8 +31,10 @@ func ProvideIMService(
|
|||||||
rdb *redis.Client,
|
rdb *redis.Client,
|
||||||
friendChecker service.FriendChecker,
|
friendChecker service.FriendChecker,
|
||||||
userInfoGetter service.UserInfoGetter,
|
userInfoGetter service.UserInfoGetter,
|
||||||
|
groupInfo service.GroupInfoGetter,
|
||||||
|
readRecorder service.MessageReadRecorder,
|
||||||
) *service.IMService {
|
) *service.IMService {
|
||||||
return service.NewIMService(convDAO, msgDAO, pubsub, rdb, friendChecker, userInfoGetter)
|
return service.NewIMService(convDAO, msgDAO, pubsub, rdb, friendChecker, userInfoGetter, groupInfo, readRecorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideIMEventHandler 创建 IM WS 事件处理器并注册事件到 Hub
|
// ProvideIMEventHandler 创建 IM WS 事件处理器并注册事件到 Hub
|
||||||
|
|||||||
@@ -23,5 +23,8 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.IMController, jwtAuth gin.Ha
|
|||||||
|
|
||||||
// 未读数
|
// 未读数
|
||||||
authed.GET("/unread", ctrl.GetTotalUnread)
|
authed.GET("/unread", ctrl.GetTotalUnread)
|
||||||
|
|
||||||
|
// 已读回执
|
||||||
|
authed.GET("/messages/:id/reads", ctrl.GetMessageReadDetail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,25 +23,30 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFriend = errors.New("对方不是你的好友")
|
ErrNotFriend = errors.New("对方不是你的好友")
|
||||||
ErrEmptyContent = errors.New("消息内容不能为空")
|
ErrEmptyContent = errors.New("消息内容不能为空")
|
||||||
ErrConvNotFound = errors.New("会话不存在")
|
ErrConvNotFound = errors.New("会话不存在")
|
||||||
ErrMsgNotFound = errors.New("消息不存在")
|
ErrMsgNotFound = errors.New("消息不存在")
|
||||||
ErrNotSender = errors.New("只能撤回自己发送的消息")
|
ErrNotSender = errors.New("只能撤回自己发送的消息")
|
||||||
ErrRecallTimeout = errors.New("超过撤回时限")
|
ErrRecallTimeout = errors.New("超过撤回时限")
|
||||||
ErrNotMember = errors.New("你不是该会话的成员")
|
ErrNotMember = errors.New("你不是该会话的成员")
|
||||||
ErrDuplicateMsg = errors.New("重复消息")
|
ErrDuplicateMsg = errors.New("重复消息")
|
||||||
ErrInvalidMsgType = errors.New("不支持的消息类型")
|
ErrInvalidMsgType = errors.New("不支持的消息类型")
|
||||||
|
ErrGroupDissolved = errors.New("群聊已解散")
|
||||||
|
ErrGroupAllMuted = errors.New("当前群已开启全体禁言")
|
||||||
|
ErrUserMuted = errors.New("你已被禁言,无法发送消息")
|
||||||
)
|
)
|
||||||
|
|
||||||
// IMService 即时通讯核心业务服务
|
// IMService 即时通讯核心业务服务
|
||||||
type IMService struct {
|
type IMService struct {
|
||||||
convDAO *dao.ConversationDAO
|
convDAO *dao.ConversationDAO
|
||||||
msgDAO *dao.MessageDAO
|
msgDAO *dao.MessageDAO
|
||||||
pubsub *ws.PubSub
|
pubsub *ws.PubSub
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
friendChecker FriendChecker
|
friendChecker FriendChecker
|
||||||
userInfoGetter UserInfoGetter
|
userInfoGetter UserInfoGetter
|
||||||
|
groupInfo GroupInfoGetter
|
||||||
|
readRecorder MessageReadRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIMService 创建 IMService 实例
|
// NewIMService 创建 IMService 实例
|
||||||
@@ -52,6 +57,8 @@ func NewIMService(
|
|||||||
rdb *redis.Client,
|
rdb *redis.Client,
|
||||||
friendChecker FriendChecker,
|
friendChecker FriendChecker,
|
||||||
userInfoGetter UserInfoGetter,
|
userInfoGetter UserInfoGetter,
|
||||||
|
groupInfo GroupInfoGetter,
|
||||||
|
readRecorder MessageReadRecorder,
|
||||||
) *IMService {
|
) *IMService {
|
||||||
return &IMService{
|
return &IMService{
|
||||||
convDAO: convDAO,
|
convDAO: convDAO,
|
||||||
@@ -60,15 +67,14 @@ func NewIMService(
|
|||||||
rdb: rdb,
|
rdb: rdb,
|
||||||
friendChecker: friendChecker,
|
friendChecker: friendChecker,
|
||||||
userInfoGetter: userInfoGetter,
|
userInfoGetter: userInfoGetter,
|
||||||
|
groupInfo: groupInfo,
|
||||||
|
readRecorder: readRecorder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage 发送消息(核心流程)
|
// SendMessage 发送消息(核心流程,同时支持单聊和群聊)
|
||||||
// 1. 校验好友关系
|
// 单聊:校验好友关系 → 查找/创建会话 → 写入消息 → 推送给对方
|
||||||
// 2. 查找或创建会话
|
// 群聊:校验群成员+禁言 → 写入消息(含 @信息)→ 推送给所有群成员
|
||||||
// 3. 幂等去重(client_msg_id)
|
|
||||||
// 4. 写入消息 + 更新会话最后消息 + 递增对方未读数
|
|
||||||
// 5. 通过 PubSub 推送给接收方
|
|
||||||
func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||||
funcName := "service.im_service.SendMessage"
|
funcName := "service.im_service.SendMessage"
|
||||||
logs.Info(ctx, funcName, "发送消息",
|
logs.Info(ctx, funcName, "发送消息",
|
||||||
@@ -87,40 +93,64 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
|||||||
}
|
}
|
||||||
|
|
||||||
convID := req.ConversationID
|
convID := req.ConversationID
|
||||||
var peerID int64
|
|
||||||
|
|
||||||
if convID == 0 && req.TargetUserID > 0 {
|
if convID == 0 && req.TargetUserID > 0 {
|
||||||
isFriend, err := s.friendChecker.IsFriend(ctx, senderID, req.TargetUserID)
|
return s.sendPrivateMessage(ctx, senderID, req)
|
||||||
if err != nil {
|
|
||||||
logs.Error(ctx, funcName, "检查好友关系失败", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !isFriend {
|
|
||||||
return nil, ErrNotFriend
|
|
||||||
}
|
|
||||||
|
|
||||||
conv, err := s.getOrCreatePrivateConversation(ctx, senderID, req.TargetUserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
convID = conv.ID
|
|
||||||
peerID = req.TargetUserID
|
|
||||||
} else if convID > 0 {
|
} else if convID > 0 {
|
||||||
member, err := s.convDAO.GetMember(ctx, convID, senderID)
|
conv, err := s.convDAO.GetByID(ctx, convID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrNotMember
|
return nil, ErrConvNotFound
|
||||||
}
|
}
|
||||||
if member == nil {
|
if conv.Type == constants.ConversationTypeGroup {
|
||||||
return nil, ErrNotMember
|
return s.sendGroupMessage(ctx, senderID, req)
|
||||||
}
|
}
|
||||||
peerID, err = s.convDAO.GetPeerUserID(ctx, convID, senderID)
|
return s.sendPrivateMessageByConvID(ctx, senderID, convID, req)
|
||||||
if err != nil {
|
|
||||||
logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, ErrConvNotFound
|
|
||||||
}
|
}
|
||||||
|
return nil, ErrConvNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPrivateMessage 发送单聊消息(首次发送,通过 TargetUserID)
|
||||||
|
func (s *IMService) sendPrivateMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||||
|
funcName := "service.im_service.sendPrivateMessage"
|
||||||
|
|
||||||
|
isFriend, err := s.friendChecker.IsFriend(ctx, senderID, req.TargetUserID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "检查好友关系失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isFriend {
|
||||||
|
return nil, ErrNotFriend
|
||||||
|
}
|
||||||
|
|
||||||
|
conv, err := s.getOrCreatePrivateConversation(ctx, senderID, req.TargetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.writeAndPushPrivateMessage(ctx, senderID, conv.ID, req.TargetUserID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPrivateMessageByConvID 发送单聊消息(已有会话 ID)
|
||||||
|
func (s *IMService) sendPrivateMessageByConvID(ctx context.Context, senderID, convID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||||
|
funcName := "service.im_service.sendPrivateMessageByConvID"
|
||||||
|
|
||||||
|
member, err := s.convDAO.GetMember(ctx, convID, senderID)
|
||||||
|
if err != nil || member == nil {
|
||||||
|
return nil, ErrNotMember
|
||||||
|
}
|
||||||
|
|
||||||
|
peerID, err := s.convDAO.GetPeerUserID(ctx, convID, senderID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.writeAndPushPrivateMessage(ctx, senderID, convID, peerID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAndPushPrivateMessage 单聊:幂等去重 + 写消息 + 推送
|
||||||
|
func (s *IMService) writeAndPushPrivateMessage(ctx context.Context, senderID, convID, peerID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||||
|
funcName := "service.im_service.writeAndPushPrivateMessage"
|
||||||
|
|
||||||
if req.ClientMsgID != "" {
|
if req.ClientMsgID != "" {
|
||||||
existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID)
|
existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID)
|
||||||
@@ -160,40 +190,161 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
|||||||
|
|
||||||
s.incrementTotalUnread(ctx, peerID)
|
s.incrementTotalUnread(ctx, peerID)
|
||||||
|
|
||||||
pushData := map[string]interface{}{
|
pushData := s.buildMessagePushData(ctx, msg, senderID)
|
||||||
"id": msg.ID,
|
pushData["conv_type"] = constants.ConversationTypePrivate
|
||||||
"conversation_id": convID,
|
|
||||||
"sender_id": senderID,
|
|
||||||
"type": msg.Type,
|
|
||||||
"content": msg.Content,
|
|
||||||
"client_msg_id": msg.ClientMsgID,
|
|
||||||
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
||||||
}
|
|
||||||
if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 {
|
|
||||||
pushData["sender_name"] = senderUsers[0].Nickname
|
|
||||||
pushData["sender_avatar"] = senderUsers[0].Avatar
|
|
||||||
}
|
|
||||||
s.pushToUser(ctx, peerID, "im.message.new", pushData)
|
s.pushToUser(ctx, peerID, "im.message.new", pushData)
|
||||||
|
|
||||||
return s.toMessageDTO(msg), nil
|
return s.toMessageDTO(msg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecallMessage 撤回消息(2分钟内)
|
// sendGroupMessage 发送群聊消息
|
||||||
// 撤回成功后,若被撤回消息是会话最后一条,则同步更新会话预览文本
|
func (s *IMService) sendGroupMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||||
func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID int64) error {
|
funcName := "service.im_service.sendGroupMessage"
|
||||||
|
convID := req.ConversationID
|
||||||
|
|
||||||
|
member, err := s.convDAO.GetMember(ctx, convID, senderID)
|
||||||
|
if err != nil || member == nil {
|
||||||
|
return nil, ErrNotMember
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.groupInfo != nil {
|
||||||
|
groupBrief, gErr := s.groupInfo.GetGroupBrief(ctx, convID)
|
||||||
|
if gErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取群信息失败", zap.Error(gErr))
|
||||||
|
return nil, ErrConvNotFound
|
||||||
|
}
|
||||||
|
if groupBrief.Status == constants.GroupStatusDissolved {
|
||||||
|
return nil, ErrGroupDissolved
|
||||||
|
}
|
||||||
|
if groupBrief.IsAllMuted && member.Role < constants.GroupRoleAdmin {
|
||||||
|
return nil, ErrGroupAllMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if member.IsMuted {
|
||||||
|
return nil, ErrUserMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ClientMsgID != "" {
|
||||||
|
existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "幂等去重查询失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return s.toMessageDTO(existing), ErrDuplicateMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &model.Message{
|
||||||
|
ConversationID: convID,
|
||||||
|
SenderID: senderID,
|
||||||
|
Type: req.Type,
|
||||||
|
Content: req.Content,
|
||||||
|
Status: constants.MessageStatusNormal,
|
||||||
|
ClientMsgID: req.ClientMsgID,
|
||||||
|
}
|
||||||
|
if len(req.AtUserIDs) > 0 {
|
||||||
|
msg.AtUserIDs = req.AtUserIDs
|
||||||
|
}
|
||||||
|
if err := s.msgDAO.Create(ctx, msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, truncateContent(req.Content, 100), senderID, now); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "更新最后消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, convID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取群成员列表失败", zap.Error(err))
|
||||||
|
return s.toMessageDTO(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dndMap, dndErr := s.convDAO.GetMemberDNDMap(ctx, convID)
|
||||||
|
if dndErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取免打扰状态失败", zap.Error(dndErr))
|
||||||
|
dndMap = make(map[int64]bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pushData := s.buildMessagePushData(ctx, msg, senderID)
|
||||||
|
pushData["conv_type"] = constants.ConversationTypeGroup
|
||||||
|
if len(req.AtUserIDs) > 0 {
|
||||||
|
pushData["at_user_ids"] = req.AtUserIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range memberIDs {
|
||||||
|
if uid == senderID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.convDAO.IncrementUnread(ctx, convID, uid); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "递增未读计数失败", zap.Int64("user_id", uid), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dndMap[uid] {
|
||||||
|
s.incrementTotalUnread(ctx, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.AtUserIDs) > 0 {
|
||||||
|
isAtMe := false
|
||||||
|
for _, atID := range req.AtUserIDs {
|
||||||
|
if atID == uid || atID == 0 {
|
||||||
|
isAtMe = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isAtMe {
|
||||||
|
if err := s.convDAO.IncrementAtMeCount(ctx, convID, uid); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "递增@计数失败", zap.Int64("user_id", uid), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pushToUser(ctx, uid, "im.message.new", pushData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toMessageDTO(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecallMessage 撤回消息
|
||||||
|
// 单聊:只能撤回自己的消息,2 分钟内
|
||||||
|
// 群聊:自己的消息 2 分钟内撤回;群主/管理员可无时限撤回任何消息
|
||||||
|
func (s *IMService) RecallMessage(ctx context.Context, operatorID int64, messageID int64) error {
|
||||||
funcName := "service.im_service.RecallMessage"
|
funcName := "service.im_service.RecallMessage"
|
||||||
logs.Info(ctx, funcName, "撤回消息",
|
logs.Info(ctx, funcName, "撤回消息",
|
||||||
zap.Int64("sender_id", senderID), zap.Int64("message_id", messageID))
|
zap.Int64("operator_id", operatorID), zap.Int64("message_id", messageID))
|
||||||
|
|
||||||
msg, err := s.msgDAO.GetByID(ctx, messageID)
|
msg, err := s.msgDAO.GetByID(ctx, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrMsgNotFound
|
return ErrMsgNotFound
|
||||||
}
|
}
|
||||||
if msg.SenderID != senderID {
|
|
||||||
return ErrNotSender
|
conv, err := s.convDAO.GetByID(ctx, msg.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err))
|
||||||
|
return ErrConvNotFound
|
||||||
}
|
}
|
||||||
if time.Since(msg.CreatedAt).Seconds() > float64(constants.MessageRecallTimeLimit) {
|
|
||||||
return ErrRecallTimeout
|
isAdmin := false
|
||||||
|
if conv.Type == constants.ConversationTypeGroup {
|
||||||
|
member, mErr := s.convDAO.GetMember(ctx, msg.ConversationID, operatorID)
|
||||||
|
if mErr != nil || member == nil {
|
||||||
|
return ErrNotMember
|
||||||
|
}
|
||||||
|
isAdmin = member.Role >= constants.GroupRoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.SenderID == operatorID {
|
||||||
|
if time.Since(msg.CreatedAt).Seconds() > float64(constants.MessageRecallTimeLimit) {
|
||||||
|
if !isAdmin {
|
||||||
|
return ErrRecallTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !isAdmin {
|
||||||
|
return ErrNotSender
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.msgDAO.UpdateStatus(ctx, messageID, constants.MessageStatusRecalled); err != nil {
|
if err := s.msgDAO.UpdateStatus(ctx, messageID, constants.MessageStatusRecalled); err != nil {
|
||||||
@@ -201,16 +352,31 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
conv, err := s.convDAO.GetByID(ctx, msg.ConversationID)
|
recallText := "撤回了一条消息"
|
||||||
if err != nil {
|
if msg.SenderID != operatorID && isAdmin {
|
||||||
logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err))
|
operatorInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{operatorID, msg.SenderID})
|
||||||
} else if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID {
|
operatorName := "管理员"
|
||||||
senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID})
|
senderName := "成员"
|
||||||
recallText := "撤回了一条消息"
|
if infoErr == nil {
|
||||||
|
for _, u := range operatorInfo {
|
||||||
|
if u.ID == operatorID {
|
||||||
|
operatorName = u.Nickname
|
||||||
|
}
|
||||||
|
if u.ID == msg.SenderID {
|
||||||
|
senderName = u.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recallText = fmt.Sprintf("管理员 %s 撤回了 %s 的一条消息", operatorName, senderName)
|
||||||
|
} else {
|
||||||
|
senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{operatorID})
|
||||||
if infoErr == nil && len(senderInfo) > 0 {
|
if infoErr == nil && len(senderInfo) > 0 {
|
||||||
recallText = senderInfo[0].Nickname + " 撤回了一条消息"
|
recallText = senderInfo[0].Nickname + " 撤回了一条消息"
|
||||||
}
|
}
|
||||||
if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, senderID, msg.CreatedAt); updateErr != nil {
|
}
|
||||||
|
|
||||||
|
if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID {
|
||||||
|
if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, operatorID, msg.CreatedAt); updateErr != nil {
|
||||||
logs.Error(ctx, funcName, "更新会话预览失败", zap.Error(updateErr))
|
logs.Error(ctx, funcName, "更新会话预览失败", zap.Error(updateErr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,20 +387,22 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, uid := range memberIDs {
|
for _, uid := range memberIDs {
|
||||||
if uid == senderID {
|
if uid == operatorID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{
|
s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
"conversation_id": msg.ConversationID,
|
"conversation_id": msg.ConversationID,
|
||||||
"sender_id": senderID,
|
"operator_id": operatorID,
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
"recall_text": recallText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConversationList 获取会话列表(含对方用户信息)
|
// GetConversationList 获取会话列表(含对方用户信息 / 群聊信息)
|
||||||
func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto.ConversationListResponse, error) {
|
func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto.ConversationListResponse, error) {
|
||||||
funcName := "service.im_service.GetConversationList"
|
funcName := "service.im_service.GetConversationList"
|
||||||
logs.Debug(ctx, funcName, "获取会话列表", zap.Int64("user_id", userID))
|
logs.Debug(ctx, funcName, "获取会话列表", zap.Int64("user_id", userID))
|
||||||
@@ -245,17 +413,20 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
|||||||
}
|
}
|
||||||
|
|
||||||
peerIDs := make([]int64, 0, len(convs))
|
peerIDs := make([]int64, 0, len(convs))
|
||||||
|
groupConvIDs := make([]int64, 0)
|
||||||
for _, c := range convs {
|
for _, c := range convs {
|
||||||
if c.PeerUserID > 0 {
|
if c.Type == constants.ConversationTypePrivate && c.PeerUserID > 0 {
|
||||||
peerIDs = append(peerIDs, c.PeerUserID)
|
peerIDs = append(peerIDs, c.PeerUserID)
|
||||||
|
} else if c.Type == constants.ConversationTypeGroup {
|
||||||
|
groupConvIDs = append(groupConvIDs, c.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userMap := make(map[int64]*userBrief)
|
userMap := make(map[int64]*userBrief)
|
||||||
if len(peerIDs) > 0 {
|
if len(peerIDs) > 0 {
|
||||||
users, err := s.userInfoGetter.GetUsersByIDs(ctx, peerIDs)
|
users, uErr := s.userInfoGetter.GetUsersByIDs(ctx, peerIDs)
|
||||||
if err != nil {
|
if uErr != nil {
|
||||||
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(err))
|
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
|
||||||
} else {
|
} else {
|
||||||
for i := range users {
|
for i := range users {
|
||||||
u := users[i]
|
u := users[i]
|
||||||
@@ -264,17 +435,31 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groupMap := make(map[int64]*dto.GroupBrief)
|
||||||
|
if s.groupInfo != nil && len(groupConvIDs) > 0 {
|
||||||
|
for _, convID := range groupConvIDs {
|
||||||
|
brief, gErr := s.groupInfo.GetGroupBrief(ctx, convID)
|
||||||
|
if gErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取群信息失败",
|
||||||
|
zap.Int64("conversation_id", convID), zap.Error(gErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groupMap[convID] = brief
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list := make([]dto.ConversationDTO, 0, len(convs))
|
list := make([]dto.ConversationDTO, 0, len(convs))
|
||||||
for _, c := range convs {
|
for _, c := range convs {
|
||||||
peerID := c.PeerUserID
|
|
||||||
item := dto.ConversationDTO{
|
item := dto.ConversationDTO{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Type: c.Type,
|
Type: c.Type,
|
||||||
PeerUserID: peerID,
|
PeerUserID: c.PeerUserID,
|
||||||
LastMsgContent: c.LastMsgContent,
|
LastMsgContent: c.LastMsgContent,
|
||||||
LastMsgSenderID: c.LastMsgSenderID,
|
LastMsgSenderID: c.LastMsgSenderID,
|
||||||
IsPinned: c.IsPinned,
|
IsPinned: c.IsPinned,
|
||||||
UnreadCount: c.UnreadCount,
|
UnreadCount: c.UnreadCount,
|
||||||
|
IsDoNotDisturb: c.IsDoNotDisturb,
|
||||||
|
AtMeCount: c.AtMeCount,
|
||||||
}
|
}
|
||||||
if c.ClearBeforeMsgID > 0 && c.LastMessageID != nil && *c.LastMessageID <= c.ClearBeforeMsgID {
|
if c.ClearBeforeMsgID > 0 && c.LastMessageID != nil && *c.LastMessageID <= c.ClearBeforeMsgID {
|
||||||
item.LastMsgContent = ""
|
item.LastMsgContent = ""
|
||||||
@@ -283,10 +468,20 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
|||||||
if c.LastMsgTime != nil {
|
if c.LastMsgTime != nil {
|
||||||
item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05")
|
item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
if brief, ok := userMap[peerID]; ok {
|
|
||||||
item.PeerNickname = brief.Nickname
|
if c.Type == constants.ConversationTypePrivate {
|
||||||
item.PeerAvatar = brief.Avatar
|
if brief, ok := userMap[c.PeerUserID]; ok {
|
||||||
|
item.PeerNickname = brief.Nickname
|
||||||
|
item.PeerAvatar = brief.Avatar
|
||||||
|
}
|
||||||
|
} else if c.Type == constants.ConversationTypeGroup {
|
||||||
|
if gBrief, ok := groupMap[c.ID]; ok {
|
||||||
|
item.PeerNickname = gBrief.Name
|
||||||
|
item.PeerAvatar = gBrief.Avatar
|
||||||
|
item.GroupID = gBrief.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
list = append(list, item)
|
list = append(list, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +555,133 @@ func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID i
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkGroupMessagesRead 标记群聊消息已读(消息级别)
|
||||||
|
// 将指定消息标记为已读 + 推送已读计数变化给消息发送者
|
||||||
|
func (s *IMService) MarkGroupMessagesRead(ctx context.Context, userID int64, req *dto.MarkGroupReadRequest) error {
|
||||||
|
funcName := "service.im_service.MarkGroupMessagesRead"
|
||||||
|
logs.Info(ctx, funcName, "群消息标记已读",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("conversation_id", req.ConversationID),
|
||||||
|
zap.Int("msg_count", len(req.MessageIDs)))
|
||||||
|
|
||||||
|
member, err := s.convDAO.GetMember(ctx, req.ConversationID, userID)
|
||||||
|
if err != nil || member == nil {
|
||||||
|
return ErrNotMember
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.readRecorder == nil || len(req.MessageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.readRecorder.BatchCreateReads(ctx, req.MessageIDs, userID); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "批量创建已读记录失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if member.AtMeCount > 0 {
|
||||||
|
if err := s.convDAO.ClearAtMeCount(ctx, req.ConversationID, userID); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "清零@计数失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readCounts, err := s.readRecorder.GetReadCountBatch(ctx, req.MessageIDs)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取已读计数失败", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msgID := range req.MessageIDs {
|
||||||
|
msg, mErr := s.msgDAO.GetByID(ctx, msgID)
|
||||||
|
if mErr != nil || msg.SenderID == userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := readCounts[msgID]
|
||||||
|
s.pushToUser(ctx, msg.SenderID, "im.message.read.count", map[string]interface{}{
|
||||||
|
"message_id": msgID,
|
||||||
|
"conversation_id": req.ConversationID,
|
||||||
|
"read_count": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageReadDetail 获取消息已读详情(已读/未读用户列表)
|
||||||
|
func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, messageID int64) (*dto.GetReadDetailResponse, error) {
|
||||||
|
funcName := "service.im_service.GetMessageReadDetail"
|
||||||
|
logs.Debug(ctx, funcName, "获取已读详情",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("message_id", messageID))
|
||||||
|
|
||||||
|
msg, err := s.msgDAO.GetByID(ctx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrMsgNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
member, err := s.convDAO.GetMember(ctx, msg.ConversationID, userID)
|
||||||
|
if err != nil || member == nil {
|
||||||
|
return nil, ErrNotMember
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, msg.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
readUserIDs := make(map[int64]bool)
|
||||||
|
if s.readRecorder != nil {
|
||||||
|
ids, rErr := s.readRecorder.GetReadUserIDs(ctx, messageID)
|
||||||
|
if rErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取已读用户列表失败", zap.Error(rErr))
|
||||||
|
} else {
|
||||||
|
for _, id := range ids {
|
||||||
|
readUserIDs[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allUserIDs := make([]int64, 0, len(memberIDs))
|
||||||
|
for _, id := range memberIDs {
|
||||||
|
if id != msg.SenderID {
|
||||||
|
allUserIDs = append(allUserIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[int64]*userBrief)
|
||||||
|
if len(allUserIDs) > 0 {
|
||||||
|
users, uErr := s.userInfoGetter.GetUsersByIDs(ctx, allUserIDs)
|
||||||
|
if uErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr))
|
||||||
|
} else {
|
||||||
|
for i := range users {
|
||||||
|
u := users[i]
|
||||||
|
userMap[u.ID] = &userBrief{Nickname: u.Nickname, Avatar: u.Avatar}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var readList, unreadList []dto.MessageReadDetailDTO
|
||||||
|
for _, uid := range allUserIDs {
|
||||||
|
item := dto.MessageReadDetailDTO{
|
||||||
|
UserID: uid,
|
||||||
|
}
|
||||||
|
if brief, ok := userMap[uid]; ok {
|
||||||
|
item.UserNickname = brief.Nickname
|
||||||
|
item.UserAvatar = brief.Avatar
|
||||||
|
}
|
||||||
|
if readUserIDs[uid] {
|
||||||
|
readList = append(readList, item)
|
||||||
|
} else {
|
||||||
|
unreadList = append(unreadList, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.GetReadDetailResponse{
|
||||||
|
ReadList: readList,
|
||||||
|
UnreadList: unreadList,
|
||||||
|
ReadCount: len(readList),
|
||||||
|
TotalCount: len(allUserIDs),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PinConversation 置顶/取消置顶会话
|
// PinConversation 置顶/取消置顶会话
|
||||||
func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error {
|
func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error {
|
||||||
funcName := "service.im_service.PinConversation"
|
funcName := "service.im_service.PinConversation"
|
||||||
@@ -580,7 +902,7 @@ func (s *IMService) decrementTotalUnread(ctx context.Context, userID int64, coun
|
|||||||
|
|
||||||
// toMessageDTO 将 model.Message 转换为 dto.MessageDTO
|
// toMessageDTO 将 model.Message 转换为 dto.MessageDTO
|
||||||
func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO {
|
func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO {
|
||||||
return &dto.MessageDTO{
|
d := &dto.MessageDTO{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
ConversationID: m.ConversationID,
|
ConversationID: m.ConversationID,
|
||||||
SenderID: m.SenderID,
|
SenderID: m.SenderID,
|
||||||
@@ -590,6 +912,28 @@ func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO {
|
|||||||
ClientMsgID: m.ClientMsgID,
|
ClientMsgID: m.ClientMsgID,
|
||||||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
|
if len(m.AtUserIDs) > 0 {
|
||||||
|
d.AtUserIDs = []int64(m.AtUserIDs)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessagePushData 构建消息推送数据(单聊/群聊通用)
|
||||||
|
func (s *IMService) buildMessagePushData(ctx context.Context, msg *model.Message, senderID int64) map[string]interface{} {
|
||||||
|
pushData := map[string]interface{}{
|
||||||
|
"id": msg.ID,
|
||||||
|
"conversation_id": msg.ConversationID,
|
||||||
|
"sender_id": senderID,
|
||||||
|
"type": msg.Type,
|
||||||
|
"content": msg.Content,
|
||||||
|
"client_msg_id": msg.ClientMsgID,
|
||||||
|
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 {
|
||||||
|
pushData["sender_name"] = senderUsers[0].Nickname
|
||||||
|
pushData["sender_avatar"] = senderUsers[0].Avatar
|
||||||
|
}
|
||||||
|
return pushData
|
||||||
}
|
}
|
||||||
|
|
||||||
// userBrief 用户简要信息(内部使用)
|
// userBrief 用户简要信息(内部使用)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
authModel "github.com/echochat/backend/app/auth/model"
|
authModel "github.com/echochat/backend/app/auth/model"
|
||||||
|
"github.com/echochat/backend/app/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FriendChecker 好友关系校验接口
|
// FriendChecker 好友关系校验接口
|
||||||
@@ -18,3 +19,19 @@ type FriendChecker interface {
|
|||||||
type UserInfoGetter interface {
|
type UserInfoGetter interface {
|
||||||
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
|
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupInfoGetter 群信息查询接口(Phase 2c)
|
||||||
|
// 由 group.GroupDAO 隐式实现,通过 Wire 接口注入
|
||||||
|
// IM 模块通过此接口获取群表信息(判断全体禁言、已解散等)
|
||||||
|
// 成员信息(角色/禁言/列表)通过 convDAO 直接查询 im_conversation_members 表
|
||||||
|
type GroupInfoGetter interface {
|
||||||
|
GetGroupBrief(ctx context.Context, conversationID int64) (*dto.GroupBrief, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageReadRecorder 群消息已读记录接口(Phase 2c)
|
||||||
|
// 由 group.MessageReadDAO 隐式实现
|
||||||
|
type MessageReadRecorder interface {
|
||||||
|
BatchCreateReads(ctx context.Context, messageIDs []int64, userID int64) error
|
||||||
|
GetReadCountBatch(ctx context.Context, messageIDs []int64) (map[int64]int, error)
|
||||||
|
GetReadUserIDs(ctx context.Context, messageID int64) ([]int64, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import (
|
|||||||
authController "github.com/echochat/backend/app/auth/controller"
|
authController "github.com/echochat/backend/app/auth/controller"
|
||||||
"github.com/echochat/backend/app/auth/service"
|
"github.com/echochat/backend/app/auth/service"
|
||||||
contactController "github.com/echochat/backend/app/contact/controller"
|
contactController "github.com/echochat/backend/app/contact/controller"
|
||||||
|
fileController "github.com/echochat/backend/app/file/controller"
|
||||||
|
groupController "github.com/echochat/backend/app/group/controller"
|
||||||
imController "github.com/echochat/backend/app/im/controller"
|
imController "github.com/echochat/backend/app/im/controller"
|
||||||
imHandler "github.com/echochat/backend/app/im/handler"
|
imHandler "github.com/echochat/backend/app/im/handler"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/db"
|
"github.com/echochat/backend/pkg/db"
|
||||||
|
"github.com/echochat/backend/pkg/storage"
|
||||||
"github.com/echochat/backend/pkg/ws"
|
"github.com/echochat/backend/pkg/ws"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -23,12 +27,14 @@ type App struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
|
MinioClient *minio.Client // MinIO 对象存储客户端
|
||||||
AuthService *service.AuthService // Auth 认证服务
|
AuthService *service.AuthService // Auth 认证服务
|
||||||
AuthController *authController.AuthController // 前台认证控制器
|
AuthController *authController.AuthController // 前台认证控制器
|
||||||
AdminAuthController *authController.AdminAuthController // 后台认证控制器
|
AdminAuthController *authController.AdminAuthController // 后台认证控制器
|
||||||
UserManageController *adminController.UserManageController // 管理端用户管理控制器
|
UserManageController *adminController.UserManageController // 管理端用户管理控制器
|
||||||
OnlineController *adminController.OnlineController // 管理端在线监控控制器
|
OnlineController *adminController.OnlineController // 管理端在线监控控制器
|
||||||
ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器
|
ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器
|
||||||
|
GroupManageController *adminController.GroupManageController // 管理端群组管理控制器
|
||||||
WSHandler *wsApp.Handler // WebSocket 连接处理器
|
WSHandler *wsApp.Handler // WebSocket 连接处理器
|
||||||
Hub *ws.Hub // WebSocket Hub 连接管理
|
Hub *ws.Hub // WebSocket Hub 连接管理
|
||||||
PubSub *ws.PubSub // Redis Pub/Sub 消息路由
|
PubSub *ws.PubSub // Redis Pub/Sub 消息路由
|
||||||
@@ -37,6 +43,8 @@ type App struct {
|
|||||||
IMController *imController.IMController // IM 即时通讯控制器
|
IMController *imController.IMController // IM 即时通讯控制器
|
||||||
IMEventHandler *imHandler.EventHandler // IM WS 事件处理器
|
IMEventHandler *imHandler.EventHandler // IM WS 事件处理器
|
||||||
OfflinePusher *imHandler.OfflinePusher // 离线消息推送器
|
OfflinePusher *imHandler.OfflinePusher // 离线消息推送器
|
||||||
|
FileController *fileController.FileController // 文件上传控制器
|
||||||
|
GroupController *groupController.GroupController // 群聊管理控制器
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 创建应用实例
|
// NewApp 创建应用实例
|
||||||
@@ -44,12 +52,14 @@ func NewApp(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
gormDB *gorm.DB,
|
gormDB *gorm.DB,
|
||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
|
minioClient *minio.Client,
|
||||||
authService *service.AuthService,
|
authService *service.AuthService,
|
||||||
authCtrl *authController.AuthController,
|
authCtrl *authController.AuthController,
|
||||||
adminAuthCtrl *authController.AdminAuthController,
|
adminAuthCtrl *authController.AdminAuthController,
|
||||||
userManageCtrl *adminController.UserManageController,
|
userManageCtrl *adminController.UserManageController,
|
||||||
onlineCtrl *adminController.OnlineController,
|
onlineCtrl *adminController.OnlineController,
|
||||||
contactManageCtrl *adminController.ContactManageController,
|
contactManageCtrl *adminController.ContactManageController,
|
||||||
|
groupManageCtrl *adminController.GroupManageController,
|
||||||
wsHandler *wsApp.Handler,
|
wsHandler *wsApp.Handler,
|
||||||
hub *ws.Hub,
|
hub *ws.Hub,
|
||||||
pubsub *ws.PubSub,
|
pubsub *ws.PubSub,
|
||||||
@@ -58,6 +68,8 @@ func NewApp(
|
|||||||
imCtrl *imController.IMController,
|
imCtrl *imController.IMController,
|
||||||
imEventHandler *imHandler.EventHandler,
|
imEventHandler *imHandler.EventHandler,
|
||||||
offlinePusher *imHandler.OfflinePusher,
|
offlinePusher *imHandler.OfflinePusher,
|
||||||
|
fileCtrl *fileController.FileController,
|
||||||
|
groupCtrl *groupController.GroupController,
|
||||||
) *App {
|
) *App {
|
||||||
// 注入离线消息推送器到 WS Handler
|
// 注入离线消息推送器到 WS Handler
|
||||||
wsHandler.SetOfflinePusher(offlinePusher)
|
wsHandler.SetOfflinePusher(offlinePusher)
|
||||||
@@ -66,12 +78,14 @@ func NewApp(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
DB: gormDB,
|
DB: gormDB,
|
||||||
Redis: redisClient,
|
Redis: redisClient,
|
||||||
|
MinioClient: minioClient,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
AuthController: authCtrl,
|
AuthController: authCtrl,
|
||||||
AdminAuthController: adminAuthCtrl,
|
AdminAuthController: adminAuthCtrl,
|
||||||
UserManageController: userManageCtrl,
|
UserManageController: userManageCtrl,
|
||||||
OnlineController: onlineCtrl,
|
OnlineController: onlineCtrl,
|
||||||
ContactManageController: contactManageCtrl,
|
ContactManageController: contactManageCtrl,
|
||||||
|
GroupManageController: groupManageCtrl,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
PubSub: pubsub,
|
PubSub: pubsub,
|
||||||
@@ -80,6 +94,8 @@ func NewApp(
|
|||||||
IMController: imCtrl,
|
IMController: imCtrl,
|
||||||
IMEventHandler: imEventHandler,
|
IMEventHandler: imEventHandler,
|
||||||
OfflinePusher: offlinePusher,
|
OfflinePusher: offlinePusher,
|
||||||
|
FileController: fileCtrl,
|
||||||
|
GroupController: groupCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,12 +114,19 @@ func provideJWTConfig(cfg *config.Config) *config.JWTConfig {
|
|||||||
return &cfg.JWT
|
return &cfg.JWT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// provideMinioConfig 从全局 Config 中提取 MinioConfig
|
||||||
|
func provideMinioConfig(cfg *config.Config) *config.MinioConfig {
|
||||||
|
return &cfg.Minio
|
||||||
|
}
|
||||||
|
|
||||||
// InfraSet 基础设施层 Provider Set
|
// InfraSet 基础设施层 Provider Set
|
||||||
var InfraSet = wire.NewSet(
|
var InfraSet = wire.NewSet(
|
||||||
provideDBConfig,
|
provideDBConfig,
|
||||||
provideRedisConfig,
|
provideRedisConfig,
|
||||||
provideJWTConfig,
|
provideJWTConfig,
|
||||||
|
provideMinioConfig,
|
||||||
db.NewPostgres,
|
db.NewPostgres,
|
||||||
db.NewRedis,
|
db.NewRedis,
|
||||||
|
storage.NewMinioClient,
|
||||||
NewApp,
|
NewApp,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ package provider
|
|||||||
import (
|
import (
|
||||||
"github.com/echochat/backend/app/admin"
|
"github.com/echochat/backend/app/admin"
|
||||||
"github.com/echochat/backend/app/auth"
|
"github.com/echochat/backend/app/auth"
|
||||||
"github.com/echochat/backend/app/contact"
|
|
||||||
authService "github.com/echochat/backend/app/auth/service"
|
authService "github.com/echochat/backend/app/auth/service"
|
||||||
|
"github.com/echochat/backend/app/contact"
|
||||||
contactDAO "github.com/echochat/backend/app/contact/dao"
|
contactDAO "github.com/echochat/backend/app/contact/dao"
|
||||||
contactService "github.com/echochat/backend/app/contact/service"
|
contactService "github.com/echochat/backend/app/contact/service"
|
||||||
|
fileApp "github.com/echochat/backend/app/file"
|
||||||
|
groupApp "github.com/echochat/backend/app/group"
|
||||||
|
groupDAO "github.com/echochat/backend/app/group/dao"
|
||||||
|
groupService "github.com/echochat/backend/app/group/service"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
|
imDAO "github.com/echochat/backend/app/im/dao"
|
||||||
imService "github.com/echochat/backend/app/im/service"
|
imService "github.com/echochat/backend/app/im/service"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
@@ -26,11 +31,17 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
wsApp.WSSet,
|
wsApp.WSSet,
|
||||||
contact.ContactSet,
|
contact.ContactSet,
|
||||||
imApp.IMSet,
|
imApp.IMSet,
|
||||||
|
fileApp.FileSet,
|
||||||
|
groupApp.GroupSet,
|
||||||
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
|
wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)),
|
||||||
|
wire.Bind(new(imService.GroupInfoGetter), new(*groupDAO.GroupDAO)),
|
||||||
|
wire.Bind(new(imService.MessageReadRecorder), new(*groupDAO.MessageReadDAO)),
|
||||||
wire.Bind(new(wsApp.TokenValidator), new(*authService.AuthService)),
|
wire.Bind(new(wsApp.TokenValidator), new(*authService.AuthService)),
|
||||||
wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)),
|
wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)),
|
||||||
|
wire.Bind(new(groupService.MessageWriter), new(*imDAO.MessageDAO)),
|
||||||
)
|
)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import (
|
|||||||
controller3 "github.com/echochat/backend/app/contact/controller"
|
controller3 "github.com/echochat/backend/app/contact/controller"
|
||||||
dao3 "github.com/echochat/backend/app/contact/dao"
|
dao3 "github.com/echochat/backend/app/contact/dao"
|
||||||
service3 "github.com/echochat/backend/app/contact/service"
|
service3 "github.com/echochat/backend/app/contact/service"
|
||||||
|
controller4 "github.com/echochat/backend/app/file/controller"
|
||||||
|
service4 "github.com/echochat/backend/app/file/service"
|
||||||
|
controller5 "github.com/echochat/backend/app/group/controller"
|
||||||
|
dao4 "github.com/echochat/backend/app/group/dao"
|
||||||
|
service5 "github.com/echochat/backend/app/group/service"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
"github.com/echochat/backend/app/ws"
|
"github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/db"
|
"github.com/echochat/backend/pkg/db"
|
||||||
|
"github.com/echochat/backend/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injectors from wire.go:
|
// Injectors from wire.go:
|
||||||
@@ -36,6 +42,11 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
minioConfig := provideMinioConfig(cfg)
|
||||||
|
minioClient, err := storage.NewMinioClient(minioConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
userDAO := dao.NewUserDAO(gormDB)
|
userDAO := dao.NewUserDAO(gormDB)
|
||||||
roleDAO := dao.NewRoleDAO(gormDB)
|
roleDAO := dao.NewRoleDAO(gormDB)
|
||||||
jwtConfig := provideJWTConfig(cfg)
|
jwtConfig := provideJWTConfig(cfg)
|
||||||
@@ -62,11 +73,26 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
// IM 模块初始化
|
// IM 模块初始化
|
||||||
conversationDAO := imApp.ProvideConversationDAO(gormDB)
|
conversationDAO := imApp.ProvideConversationDAO(gormDB)
|
||||||
messageDAO := imApp.ProvideMessageDAO(gormDB)
|
messageDAO := imApp.ProvideMessageDAO(gormDB)
|
||||||
imService := imApp.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO)
|
groupDAO := dao4.NewGroupDAO(gormDB)
|
||||||
|
messageReadDAO := dao4.NewMessageReadDAO(gormDB)
|
||||||
|
imService := imApp.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO, groupDAO, messageReadDAO)
|
||||||
imEventHandler := imApp.ProvideIMEventHandler(imService, hub)
|
imEventHandler := imApp.ProvideIMEventHandler(imService, hub)
|
||||||
offlinePusher := imApp.ProvideOfflinePusher(imService, conversationDAO, pubSub)
|
offlinePusher := imApp.ProvideOfflinePusher(imService, conversationDAO, pubSub)
|
||||||
imController := imApp.ProvideIMController(imService)
|
imController := imApp.ProvideIMController(imService)
|
||||||
|
|
||||||
app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher)
|
// File 模块初始化
|
||||||
|
fileService := service4.NewFileService(minioClient, minioConfig)
|
||||||
|
fileController := controller4.NewFileController(fileService)
|
||||||
|
|
||||||
|
// Group 模块初始化
|
||||||
|
joinRequestDAO := dao4.NewJoinRequestDAO(gormDB)
|
||||||
|
groupService := service5.NewGroupService(groupDAO, joinRequestDAO, friendshipDAO, pubSub, messageDAO)
|
||||||
|
groupController := controller5.NewGroupController(groupService)
|
||||||
|
|
||||||
|
// Admin 群组管理初始化
|
||||||
|
groupManageService := service2.NewGroupManageService(gormDB, groupDAO)
|
||||||
|
groupManageController := controller2.NewGroupManageController(groupManageService)
|
||||||
|
|
||||||
|
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController)
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,11 @@ log:
|
|||||||
max_backups: 10 # 保留的旧日志归档文件最大数量,超出的自动删除
|
max_backups: 10 # 保留的旧日志归档文件最大数量,超出的自动删除
|
||||||
max_age: 30 # 旧日志归档文件保留天数,超过天数的自动删除
|
max_age: 30 # 旧日志归档文件保留天数,超过天数的自动删除
|
||||||
compress: false # 是否对归档的旧日志文件进行 gzip 压缩(生产环境建议 true)
|
compress: false # 是否对归档的旧日志文件进行 gzip 压缩(生产环境建议 true)
|
||||||
|
|
||||||
|
# MinIO 对象存储配置
|
||||||
|
minio:
|
||||||
|
endpoint: "localhost:9000" # MinIO 服务地址
|
||||||
|
access_key: "echochat" # 访问密钥(与 docker-compose 中的 MINIO_ROOT_USER 一致)
|
||||||
|
secret_key: "echochat123456" # 密钥(与 docker-compose 中的 MINIO_ROOT_PASSWORD 一致)
|
||||||
|
bucket: "echochat" # 存储桶名称
|
||||||
|
use_ssl: false # 开发环境不使用 HTTPS
|
||||||
|
|||||||
@@ -40,3 +40,10 @@ log:
|
|||||||
max_backups: 10
|
max_backups: 10
|
||||||
max_age: 30
|
max_age: 30
|
||||||
compress: false
|
compress: false
|
||||||
|
|
||||||
|
minio:
|
||||||
|
endpoint: "minio:9000"
|
||||||
|
access_key: "echochat"
|
||||||
|
secret_key: "echochat123456"
|
||||||
|
bucket: "echochat"
|
||||||
|
use_ssl: false
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ type Config struct {
|
|||||||
Redis RedisConfig `mapstructure:"redis"`
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
|
Minio MinioConfig `mapstructure:"minio"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinioConfig MinIO 对象存储配置
|
||||||
|
type MinioConfig struct {
|
||||||
|
Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址(host:port)
|
||||||
|
AccessKey string `mapstructure:"access_key"` // 访问密钥
|
||||||
|
SecretKey string `mapstructure:"secret_key"` // 密钥
|
||||||
|
Bucket string `mapstructure:"bucket"` // 存储桶名称
|
||||||
|
UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig HTTP 服务配置
|
// ServerConfig HTTP 服务配置
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/google/wire v0.7.0
|
github.com/google/wire v0.7.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/minio/minio-go/v7 v7.0.83
|
||||||
github.com/redis/go-redis/v9 v9.18.0
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
@@ -23,14 +24,16 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.4 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
@@ -39,14 +42,17 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
@@ -25,6 +27,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -35,8 +39,8 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
|
|||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
@@ -64,6 +68,9 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -74,6 +81,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
@@ -90,6 +101,8 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS
|
|||||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
|||||||
38
backend/go-service/pkg/storage/minio.go
Normal file
38
backend/go-service/pkg/storage/minio.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Package storage 提供对象存储基础设施
|
||||||
|
// 封装 MinIO 客户端初始化和存储桶自动创建逻辑
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/config"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewMinioClient 初始化 MinIO 客户端并确保存储桶存在
|
||||||
|
// 启动时自动创建配置中指定的 bucket(如不存在)
|
||||||
|
func NewMinioClient(cfg *config.MinioConfig) (*minio.Client, error) {
|
||||||
|
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||||
|
Secure: cfg.UseSSL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建 MinIO 客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("检查存储桶 %s 失败: %w", cfg.Bucket, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建存储桶 %s 失败: %w", cfg.Bucket, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
70
backend/go-service/pkg/utils/int64_array.go
Normal file
70
backend/go-service/pkg/utils/int64_array.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Package utils 提供通用工具类型和函数
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Int64Array 自定义 PostgreSQL BIGINT[] 类型
|
||||||
|
// 实现 sql.Scanner 和 driver.Valuer 接口,用于 GORM 与 PostgreSQL 数组字段交互
|
||||||
|
type Int64Array []int64
|
||||||
|
|
||||||
|
// Scan 从数据库读取 PostgreSQL BIGINT[] 格式:{1,2,3}
|
||||||
|
func (a *Int64Array) Scan(src interface{}) error {
|
||||||
|
if src == nil {
|
||||||
|
*a = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var s string
|
||||||
|
switch v := src.(type) {
|
||||||
|
case []byte:
|
||||||
|
s = string(v)
|
||||||
|
case string:
|
||||||
|
s = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Int64Array.Scan: 不支持的类型 %T", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "{}" || s == "" {
|
||||||
|
*a = Int64Array{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.Trim(s, "{}")
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
result := make(Int64Array, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(p, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Int64Array.Scan: 解析 '%s' 失败: %w", p, err)
|
||||||
|
}
|
||||||
|
result = append(result, n)
|
||||||
|
}
|
||||||
|
*a = result
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value 写入数据库时转换为 PostgreSQL BIGINT[] 格式:{1,2,3}
|
||||||
|
func (a Int64Array) Value() (driver.Value, error) {
|
||||||
|
if a == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(a) == 0 {
|
||||||
|
return "{}", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, len(a))
|
||||||
|
for i, v := range a {
|
||||||
|
parts[i] = strconv.FormatInt(v, 10)
|
||||||
|
}
|
||||||
|
return "{" + strings.Join(parts, ",") + "}", nil
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"github.com/echochat/backend/app/admin"
|
"github.com/echochat/backend/app/admin"
|
||||||
"github.com/echochat/backend/app/auth"
|
"github.com/echochat/backend/app/auth"
|
||||||
"github.com/echochat/backend/app/contact"
|
"github.com/echochat/backend/app/contact"
|
||||||
|
fileApp "github.com/echochat/backend/app/file"
|
||||||
|
groupApp "github.com/echochat/backend/app/group"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
"github.com/echochat/backend/app/provider"
|
"github.com/echochat/backend/app/provider"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
@@ -34,10 +36,12 @@ func Setup(engine *gin.Engine, app *provider.App) {
|
|||||||
|
|
||||||
// --- 各模块路由注册 ---
|
// --- 各模块路由注册 ---
|
||||||
auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth)
|
auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth)
|
||||||
admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, jwtAuth)
|
admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, app.GroupManageController, jwtAuth)
|
||||||
wsApp.RegisterRoutes(engine, app.WSHandler)
|
wsApp.RegisterRoutes(engine, app.WSHandler)
|
||||||
contact.RegisterRoutes(engine, app.ContactController, jwtAuth)
|
contact.RegisterRoutes(engine, app.ContactController, jwtAuth)
|
||||||
imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
|
imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
|
||||||
|
fileApp.RegisterRoutes(engine, app.FileController, jwtAuth)
|
||||||
|
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
|
||||||
|
|
||||||
// [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
// [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
||||||
// [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
// [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: echochat-minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: echochat
|
||||||
|
MINIO_ROOT_PASSWORD: echochat123456
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
go-service:
|
go-service:
|
||||||
build:
|
build:
|
||||||
context: ../backend/go-service
|
context: ../backend/go-service
|
||||||
@@ -46,8 +65,11 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
redisdata:
|
redisdata:
|
||||||
|
minio_data:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- EchoChat 数据库初始化脚本
|
-- EchoChat 数据库初始化脚本
|
||||||
-- 包含模块:auth(用户认证)、contact(联系人)、im(即时通讯)
|
-- 包含模块:auth(用户认证)、contact(联系人)、im(即时通讯)、group(群聊)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -188,6 +188,12 @@ CREATE TABLE im_conversation_members (
|
|||||||
unread_count INT DEFAULT 0,
|
unread_count INT DEFAULT 0,
|
||||||
last_read_msg_id BIGINT DEFAULT 0,
|
last_read_msg_id BIGINT DEFAULT 0,
|
||||||
clear_before_msg_id BIGINT DEFAULT 0,
|
clear_before_msg_id BIGINT DEFAULT 0,
|
||||||
|
role SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
nickname VARCHAR(50) DEFAULT '',
|
||||||
|
is_muted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_do_not_disturb BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
joined_at TIMESTAMP(0) DEFAULT NULL,
|
||||||
|
at_me_count INT DEFAULT 0,
|
||||||
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (conversation_id, user_id)
|
UNIQUE (conversation_id, user_id)
|
||||||
@@ -202,6 +208,12 @@ COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否删
|
|||||||
COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数';
|
COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数';
|
||||||
COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID';
|
COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID';
|
||||||
COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空聊天记录时的消息截止 ID(个人视图,不影响对方)';
|
COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空聊天记录时的消息截止 ID(个人视图,不影响对方)';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.role IS '成员角色:0=普通成员,1=管理员,2=群主';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.nickname IS '群内昵称(仅群聊有效)';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.is_muted IS '是否被禁言';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.is_do_not_disturb IS '是否消息免打扰(仍计未读但灰色显示,不推送通知)';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.joined_at IS '加入群聊时间';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.at_me_count IS '被@提醒未读计数,打开会话后清零';
|
||||||
COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间';
|
COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间';
|
||||||
COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间';
|
COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间';
|
||||||
|
|
||||||
@@ -222,6 +234,7 @@ CREATE TABLE im_messages (
|
|||||||
extra JSONB DEFAULT NULL,
|
extra JSONB DEFAULT NULL,
|
||||||
status SMALLINT NOT NULL DEFAULT 1,
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
client_msg_id VARCHAR(64) DEFAULT '',
|
client_msg_id VARCHAR(64) DEFAULT '',
|
||||||
|
at_user_ids BIGINT[] DEFAULT NULL,
|
||||||
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -234,8 +247,87 @@ COMMENT ON COLUMN im_messages.content IS '消息内容(文本消息
|
|||||||
COMMENT ON COLUMN im_messages.extra IS '扩展数据(JSON 格式),预留图片尺寸、语音时长等元信息';
|
COMMENT ON COLUMN im_messages.extra IS '扩展数据(JSON 格式),预留图片尺寸、语音时长等元信息';
|
||||||
COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回,3=已删除';
|
COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回,3=已删除';
|
||||||
COMMENT ON COLUMN im_messages.client_msg_id IS '客户端消息唯一 ID,用于幂等去重防止网络重试重复发送';
|
COMMENT ON COLUMN im_messages.client_msg_id IS '客户端消息唯一 ID,用于幂等去重防止网络重试重复发送';
|
||||||
|
COMMENT ON COLUMN im_messages.at_user_ids IS '@提醒用户 ID 列表,NULL=无@,包含 0 表示 @所有人';
|
||||||
COMMENT ON COLUMN im_messages.created_at IS '消息发送时间';
|
COMMENT ON COLUMN im_messages.created_at IS '消息发送时间';
|
||||||
|
|
||||||
CREATE INDEX idx_im_messages_conv_time ON im_messages (conversation_id, created_at DESC);
|
CREATE INDEX idx_im_messages_conv_time ON im_messages (conversation_id, created_at DESC);
|
||||||
CREATE INDEX idx_im_messages_conv_id ON im_messages (conversation_id, id DESC);
|
CREATE INDEX idx_im_messages_conv_id ON im_messages (conversation_id, id DESC);
|
||||||
CREATE INDEX idx_im_messages_content_search ON im_messages USING gin(to_tsvector('simple', content));
|
CREATE INDEX idx_im_messages_content_search ON im_messages USING gin(to_tsvector('simple', content));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_groups: 群聊信息表
|
||||||
|
-- 与 im_conversations (type=2) 一对一关联,存储群聊独有属性
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE im_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
conversation_id BIGINT NOT NULL UNIQUE REFERENCES im_conversations(id),
|
||||||
|
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
avatar VARCHAR(500) DEFAULT '',
|
||||||
|
owner_id BIGINT NOT NULL,
|
||||||
|
notice TEXT DEFAULT '',
|
||||||
|
max_members INT NOT NULL DEFAULT 200,
|
||||||
|
is_searchable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_all_muted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE im_groups IS '群聊信息表';
|
||||||
|
COMMENT ON COLUMN im_groups.id IS '群唯一标识';
|
||||||
|
COMMENT ON COLUMN im_groups.conversation_id IS '关联 im_conversations.id';
|
||||||
|
COMMENT ON COLUMN im_groups.name IS '群名称';
|
||||||
|
COMMENT ON COLUMN im_groups.avatar IS '群头像 URL(MinIO)';
|
||||||
|
COMMENT ON COLUMN im_groups.owner_id IS '群主用户 ID';
|
||||||
|
COMMENT ON COLUMN im_groups.notice IS '群公告内容';
|
||||||
|
COMMENT ON COLUMN im_groups.max_members IS '最大成员数,默认 200';
|
||||||
|
COMMENT ON COLUMN im_groups.is_searchable IS '是否可被搜索发现';
|
||||||
|
COMMENT ON COLUMN im_groups.is_all_muted IS '是否全体禁言';
|
||||||
|
COMMENT ON COLUMN im_groups.status IS '群状态:1=正常,2=已解散';
|
||||||
|
|
||||||
|
CREATE INDEX idx_im_groups_owner ON im_groups(owner_id);
|
||||||
|
CREATE INDEX idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_group_join_requests: 入群申请表
|
||||||
|
-- 记录用户主动申请加入群聊的审批流程
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE im_group_join_requests (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
group_id BIGINT NOT NULL REFERENCES im_groups(id),
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
message TEXT DEFAULT '',
|
||||||
|
reviewer_id BIGINT DEFAULT NULL,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE im_group_join_requests IS '入群申请表';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.id IS '申请唯一标识';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.group_id IS '目标群 ID';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.user_id IS '申请人用户 ID';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.message IS '申请附言';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.reviewer_id IS '审批人用户 ID';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.status IS '状态:0=待审批,1=通过,2=拒绝';
|
||||||
|
|
||||||
|
CREATE INDEX idx_group_join_req_group ON im_group_join_requests(group_id, status);
|
||||||
|
CREATE INDEX idx_group_join_req_user ON im_group_join_requests(user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_message_reads: 群聊消息已读记录表(消息级别)
|
||||||
|
-- 复合主键 (message_id, user_id),存储每条群消息的已读用户
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE im_message_reads (
|
||||||
|
message_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
read_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE im_message_reads IS '群聊消息已读记录表(消息级别)';
|
||||||
|
COMMENT ON COLUMN im_message_reads.message_id IS '消息 ID';
|
||||||
|
COMMENT ON COLUMN im_message_reads.user_id IS '已读用户 ID';
|
||||||
|
COMMENT ON COLUMN im_message_reads.read_at IS '已读时间';
|
||||||
|
|
||||||
|
CREATE INDEX idx_msg_reads_user ON im_message_reads(user_id, read_at);
|
||||||
|
|||||||
109
deploy/docker/postgres/phase2c_migration.sql
Normal file
109
deploy/docker/postgres/phase2c_migration.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Phase 2c 数据库迁移:群聊 + 已读回执
|
||||||
|
-- 执行环境:在已有 Phase 2b 数据库结构基础上增量升级
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_groups: 群聊信息表
|
||||||
|
-- 与 im_conversations (type=2) 一对一关联,存储群聊独有属性
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS im_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
conversation_id BIGINT NOT NULL UNIQUE REFERENCES im_conversations(id),
|
||||||
|
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
avatar VARCHAR(500) DEFAULT '',
|
||||||
|
owner_id BIGINT NOT NULL,
|
||||||
|
notice TEXT DEFAULT '',
|
||||||
|
max_members INT NOT NULL DEFAULT 200,
|
||||||
|
is_searchable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_all_muted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE im_groups IS '群聊信息表';
|
||||||
|
COMMENT ON COLUMN im_groups.id IS '群唯一标识';
|
||||||
|
COMMENT ON COLUMN im_groups.conversation_id IS '关联 im_conversations.id';
|
||||||
|
COMMENT ON COLUMN im_groups.name IS '群名称';
|
||||||
|
COMMENT ON COLUMN im_groups.avatar IS '群头像 URL(MinIO)';
|
||||||
|
COMMENT ON COLUMN im_groups.owner_id IS '群主用户 ID';
|
||||||
|
COMMENT ON COLUMN im_groups.notice IS '群公告内容';
|
||||||
|
COMMENT ON COLUMN im_groups.max_members IS '最大成员数,默认 200';
|
||||||
|
COMMENT ON COLUMN im_groups.is_searchable IS '是否可被搜索发现';
|
||||||
|
COMMENT ON COLUMN im_groups.is_all_muted IS '是否全体禁言';
|
||||||
|
COMMENT ON COLUMN im_groups.status IS '群状态:1=正常,2=已解散';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_im_groups_owner ON im_groups(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_group_join_requests: 入群申请表
|
||||||
|
-- 记录用户主动申请加入群聊的审批流程
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS im_group_join_requests (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
group_id BIGINT NOT NULL REFERENCES im_groups(id),
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
message TEXT DEFAULT '',
|
||||||
|
reviewer_id BIGINT DEFAULT NULL,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE im_group_join_requests IS '入群申请表';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.id IS '申请唯一标识';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.group_id IS '目标群 ID';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.user_id IS '申请人用户 ID';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.message IS '申请附言';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.reviewer_id IS '审批人用户 ID';
|
||||||
|
COMMENT ON COLUMN im_group_join_requests.status IS '状态:0=待审批,1=通过,2=拒绝';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_join_req_group ON im_group_join_requests(group_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_join_req_user ON im_group_join_requests(user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_message_reads: 群聊消息已读记录表(消息级别)
|
||||||
|
-- 复合主键 (message_id, user_id),存储每条群消息的已读用户
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS im_message_reads (
|
||||||
|
message_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
read_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE im_message_reads IS '群聊消息已读记录表(消息级别)';
|
||||||
|
COMMENT ON COLUMN im_message_reads.message_id IS '消息 ID';
|
||||||
|
COMMENT ON COLUMN im_message_reads.user_id IS '已读用户 ID';
|
||||||
|
COMMENT ON COLUMN im_message_reads.read_at IS '已读时间';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_msg_reads_user ON im_message_reads(user_id, read_at);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_conversation_members: 新增群聊相关字段
|
||||||
|
-- role/nickname/is_muted/is_do_not_disturb/joined_at/at_me_count
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE im_conversation_members
|
||||||
|
ADD COLUMN IF NOT EXISTS role SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS nickname VARCHAR(50) DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS is_muted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_do_not_disturb BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS joined_at TIMESTAMP(0) DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS at_me_count INT DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN im_conversation_members.role IS '成员角色:0=普通成员,1=管理员,2=群主';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.nickname IS '群内昵称(仅群聊有效)';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.is_muted IS '是否被禁言';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.is_do_not_disturb IS '是否消息免打扰(仍计未读但灰色显示,不推送通知)';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.joined_at IS '加入群聊时间';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.at_me_count IS '被@提醒未读计数,打开会话后清零';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- im_messages: 新增 @提醒用户列表字段
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE im_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS at_user_ids BIGINT[] DEFAULT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN im_messages.at_user_ids IS '@提醒用户 ID 列表,NULL=无@,包含 0 表示 @所有人';
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
| [frontend/contact.md](frontend/contact.md) | 联系人 | ✅ Phase 2a | 17 个 API:好友申请/管理、好友分组、黑名单、搜索/推荐、在线状态 |
|
| [frontend/contact.md](frontend/contact.md) | 联系人 | ✅ Phase 2a | 17 个 API:好友申请/管理、好友分组、黑名单、搜索/推荐、在线状态 |
|
||||||
| [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 |
|
| [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 |
|
||||||
| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 |
|
| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 |
|
||||||
| [frontend/group.md](frontend/group.md) | 群聊管理 | 🔜 Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 |
|
| [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 |
|
||||||
| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 |
|
| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 |
|
||||||
| [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 |
|
| [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 |
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
| [admin/user.md](admin/user.md) | 用户管理 | ✅ Phase 1 | 用户列表/详情、状态管理、角色分配、创建用户 |
|
| [admin/user.md](admin/user.md) | 用户管理 | ✅ Phase 1 | 用户列表/详情、状态管理、角色分配、创建用户 |
|
||||||
| [admin/online.md](admin/online.md) | 在线监控 | ✅ Phase 2a | 在线用户列表、在线用户计数 |
|
| [admin/online.md](admin/online.md) | 在线监控 | ✅ Phase 2a | 在线用户列表、在线用户计数 |
|
||||||
| [admin/contact.md](admin/contact.md) | 好友关系管理 | ✅ Phase 2a | 好友关系列表(分页)、管理员解除好友关系 |
|
| [admin/contact.md](admin/contact.md) | 好友关系管理 | ✅ Phase 2a | 好友关系列表(分页)、管理员解除好友关系 |
|
||||||
| [admin/group.md](admin/group.md) | 群聊管理 | 🔜 Phase 2c | 群列表/详情、管理员解散群/移除成员 |
|
| [admin/group.md](admin/group.md) | 群聊管理 | ✅ Phase 2c | 群列表/详情、管理员解散群聊 |
|
||||||
| [admin/meeting.md](admin/meeting.md) | 会议管理 | 📋 后续 | 会议列表/详情、强制结束、会议统计 |
|
| [admin/meeting.md](admin/meeting.md) | 会议管理 | 📋 后续 | 会议列表/详情、强制结束、会议统计 |
|
||||||
| [admin/system.md](admin/system.md) | 系统管理 | 📋 待定 | 仪表盘数据、操作日志、系统配置 |
|
| [admin/system.md](admin/system.md) | 系统管理 | 📋 待定 | 仪表盘数据、操作日志、系统配置 |
|
||||||
|
|
||||||
@@ -196,7 +196,8 @@ docs/api/
|
|||||||
│ ├── auth.md # 用户认证 ✅ Phase 1
|
│ ├── auth.md # 用户认证 ✅ Phase 1
|
||||||
│ ├── contact.md # 联系人管理(17 个 API) ✅ Phase 2a
|
│ ├── contact.md # 联系人管理(17 个 API) ✅ Phase 2a
|
||||||
│ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a
|
│ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a
|
||||||
│ ├── im.md # 即时通讯(7 个 API) ✅ Phase 2b
|
│ ├── im.md # 即时通讯(8 个 API) ✅ Phase 2b/2c
|
||||||
|
│ ├── group.md # 群聊管理(16 个 API) ✅ Phase 2c
|
||||||
│ ├── meeting.md # 会议 📋 后续
|
│ ├── meeting.md # 会议 📋 后续
|
||||||
│ └── notify.md # 通知 📋 后续
|
│ └── notify.md # 通知 📋 后续
|
||||||
├── admin/ # 后台管理端 API
|
├── admin/ # 后台管理端 API
|
||||||
@@ -204,7 +205,8 @@ docs/api/
|
|||||||
│ ├── user.md # 用户管理 ✅ Phase 1
|
│ ├── user.md # 用户管理 ✅ Phase 1
|
||||||
│ ├── online.md # 在线监控 ✅ Phase 2a
|
│ ├── online.md # 在线监控 ✅ Phase 2a
|
||||||
│ ├── contact.md # 好友关系管理 ✅ Phase 2a
|
│ ├── contact.md # 好友关系管理 ✅ Phase 2a
|
||||||
|
│ ├── group.md # 群聊管理(3 个 API) ✅ Phase 2c
|
||||||
│ ├── meeting.md # 会议管理 📋 后续
|
│ ├── meeting.md # 会议管理 📋 后续
|
||||||
│ └── system.md # 系统管理 📋 待定
|
│ └── system.md # 系统管理 📋 待定
|
||||||
└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a/2b
|
└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a/2b/2c
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -435,3 +435,214 @@
|
|||||||
"message": "我是你的同事"
|
"message": "我是你的同事"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 群聊事件(Phase 2c)
|
||||||
|
|
||||||
|
### im.group.read
|
||||||
|
|
||||||
|
**方向:** 客户端 → 服务端
|
||||||
|
|
||||||
|
**说明:** 标记群聊消息已读(消息级已读回执)
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversation_id": 100,
|
||||||
|
"message_ids": [501, 502, 503]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ACK 响应:** `{ "code": 0, "message": "ok" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.created
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 群聊创建成功,推送给所有初始成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"name": "项目讨论组",
|
||||||
|
"owner_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.info.update
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 群信息更新(名称、头像、公告等),推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"operator_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.dissolved
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 群聊已解散,推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"operator_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.member.join
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 新成员加入群聊(邀请加入或审批通过),推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"user_ids": [5, 6]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.member.kicked
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 成员被移出群聊,推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"user_id": 5,
|
||||||
|
"operator_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.member.leave
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 成员主动退出群聊,推送给其余群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"user_id": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.role.update
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 成员角色变更(设为/取消管理员),推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"user_id": 5,
|
||||||
|
"new_role": 1,
|
||||||
|
"operator_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `new_role`:0=普通成员,1=管理员,2=群主
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.mute.update
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 禁言状态变更(个人禁言或全体禁言),推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容(个人禁言):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"user_id": 5,
|
||||||
|
"is_muted": true,
|
||||||
|
"operator_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**data 内容(全体禁言):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"is_all_muted": true,
|
||||||
|
"operator_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.owner.transfer
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 群主转让,推送给所有群成员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"conversation_id": 100,
|
||||||
|
"old_owner_id": 1,
|
||||||
|
"new_owner_id": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### group.join.request
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端
|
||||||
|
|
||||||
|
**说明:** 新的入群申请,推送给群主和管理员
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_id": 10,
|
||||||
|
"request_id": 20,
|
||||||
|
"user_id": 8,
|
||||||
|
"user_nickname": "新用户",
|
||||||
|
"message": "请让我加入"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -92,11 +92,11 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想
|
|||||||
| ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a |
|
| ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a |
|
||||||
| contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a |
|
| contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a |
|
||||||
| im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b |
|
| im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b |
|
||||||
| group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批) | 🔜 Phase 2c |
|
| group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批) | ✅ Phase 2c |
|
||||||
| file | 文件上传(MinIO 对象存储 + 通用上传 API) | 🔜 Phase 2c |
|
| file | 文件上传(MinIO 对象存储 + 通用上传 API) | ✅ Phase 2c |
|
||||||
| meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 |
|
| meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 |
|
||||||
| notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 |
|
| notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 |
|
||||||
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a, 🔜 2c |
|
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a/2c |
|
||||||
|
|
||||||
**不负责的事情:** 不处理 RTP 媒体数据、不参与音视频转发、不做 WebRTC 协议协商。
|
**不负责的事情:** 不处理 RTP 媒体数据、不参与音视频转发、不做 WebRTC 协议协商。
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
> **适用范围**:EchoChat Go 后端(`backend/go-service/`)
|
> **适用范围**:EchoChat Go 后端(`backend/go-service/`)
|
||||||
> **创建日期**:2026-03-04
|
> **创建日期**:2026-03-04
|
||||||
> **最后更新**:2026-03-04(Phase 2c 设计阶段整理)
|
> **最后更新**:2026-03-04(基于全量代码审查,以实际代码为准)
|
||||||
> **关联文档**:`docs/conventions/frontend-backend-integration.md`
|
> **关联文档**:`docs/conventions/frontend-backend-integration.md`
|
||||||
|
|
||||||
|
**核心原则:本文档所有代码模板均直接摘自项目现有代码,编写新模块时必须严格遵循,禁止自创新模式。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、模块分层架构
|
## 一、模块分层架构
|
||||||
@@ -14,13 +16,13 @@
|
|||||||
```
|
```
|
||||||
app/{module_name}/
|
app/{module_name}/
|
||||||
├── controller/
|
├── controller/
|
||||||
│ └── {module}_controller.go # HTTP 请求处理(参数绑定/校验 → 调用 Service → 响应)
|
│ └── {module}_controller.go # HTTP 请求处理
|
||||||
├── service/
|
├── service/
|
||||||
│ └── {module}_service.go # 业务逻辑(事务协调、权限校验、多 DAO 编排)
|
│ └── {module}_service.go # 业务逻辑
|
||||||
├── dao/
|
├── dao/
|
||||||
│ └── {module}_dao.go # 数据访问(GORM 操作,不含业务逻辑)
|
│ └── {module}_dao.go # 数据访问
|
||||||
├── model/
|
├── model/
|
||||||
│ └── {module}.go # 数据模型(GORM 结构体,对应数据库表)
|
│ └── {module}.go # 数据模型
|
||||||
├── handler/
|
├── handler/
|
||||||
│ └── {module}_handler.go # [可选] WS 事件处理器
|
│ └── {module}_handler.go # [可选] WS 事件处理器
|
||||||
├── router.go # 路由注册
|
├── router.go # 路由注册
|
||||||
@@ -46,254 +48,417 @@ Controller → Service → DAO → 数据库
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、跨模块接口注入模式
|
## 三、日志 API(以实际代码为准,严禁使用不存在的 API)
|
||||||
|
|
||||||
|
项目 logs 包(`pkg/logs`)**只有以下 5 个公开日志方法**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
logs.Debug(ctx, funcName, message, ...zap.Field)
|
||||||
|
logs.Info(ctx, funcName, message, ...zap.Field)
|
||||||
|
logs.Warn(ctx, funcName, message, ...zap.Field)
|
||||||
|
logs.Error(ctx, funcName, message, ...zap.Field)
|
||||||
|
logs.Fatal(ctx, funcName, message, ...zap.Field)
|
||||||
|
```
|
||||||
|
|
||||||
|
辅助方法:`logs.Init()`, `logs.Sync()`, `logs.GetTraceID()`, `logs.MaskEmail()`
|
||||||
|
|
||||||
|
**不存在的 API(严禁调用)**:`LogFunctionEntry`、`LogFunctionExit`、`LogSuccess`、`LogFailure` 等均不存在。
|
||||||
|
|
||||||
|
**funcName 命名规则**:`"{层级}.{文件名}.{方法名}"`
|
||||||
|
- DAO 层:`"dao.conversation_dao.FindPrivateConversation"`
|
||||||
|
- Service 层:`"service.im_service.SendMessage"`
|
||||||
|
- Controller 层:`"controller.auth_controller.Register"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Controller 层代码风格
|
||||||
|
|
||||||
|
项目中存在两种 Controller 风格,新模块应根据所属类型选择对应风格:
|
||||||
|
|
||||||
|
### 4.1 前台业务模块 Controller(contact/im 模块风格)
|
||||||
|
|
||||||
|
**适用于**:contact、im、group、file 等前台用户端业务模块
|
||||||
|
|
||||||
|
**特征**:接收器 `ctl`,不记日志,方法级 `handleError`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/contact/controller/contact_controller.go(实际代码)
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"github.com/echochat/backend/app/contact/service"
|
||||||
|
"github.com/echochat/backend/app/dto"
|
||||||
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
|
"github.com/echochat/backend/pkg/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContactController struct {
|
||||||
|
contactService *service.ContactService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContactController(contactService *service.ContactService) *ContactController {
|
||||||
|
return &ContactController{contactService: contactService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFriendList 获取好友列表
|
||||||
|
// GET /api/v1/contacts?group_id=xx
|
||||||
|
func (ctl *ContactController) GetFriendList(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID, ok := middleware.GetCurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// ... 参数解析 ...
|
||||||
|
friends, err := ctl.contactService.GetFriendList(ctx, userID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
ctl.handleError(c, err, "获取好友列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, friends)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleError 统一业务错误映射
|
||||||
|
func (ctl *ContactController) handleError(c *gin.Context, err error, fallbackMsg ...string) {
|
||||||
|
switch err {
|
||||||
|
case service.ErrSelfRequest:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
case service.ErrAlreadyFriend:
|
||||||
|
utils.ResponseBadRequest(c, err.Error())
|
||||||
|
// ... 覆盖所有已知业务错误 ...
|
||||||
|
default:
|
||||||
|
msg := "服务器内部错误"
|
||||||
|
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
|
||||||
|
msg = fallbackMsg[0]
|
||||||
|
}
|
||||||
|
utils.ResponseError(c, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 接收器变量名:`ctl`
|
||||||
|
- **不导入** `logs` 和 `zap`,不记日志
|
||||||
|
- 没有 `funcName` 变量
|
||||||
|
- 用 `ctx := c.Request.Context()` 获取上下文
|
||||||
|
- 用 `middleware.GetCurrentUserID(c)` 获取当前用户
|
||||||
|
- `handleError` 是**方法**(不是包级函数),签名 `(c *gin.Context, err error, fallbackMsg ...string)`
|
||||||
|
- 响应统一用 `utils.ResponseOK/ResponseBadRequest/ResponseError` 等
|
||||||
|
- 参数绑定:JSON 用 `c.ShouldBindJSON(&req)`,Query 用 `c.ShouldBindQuery(&req)`
|
||||||
|
|
||||||
|
### 4.2 auth/admin 模块 Controller 风格
|
||||||
|
|
||||||
|
**适用于**:auth、admin 模块(涉及安全审计和管理操作,需要更详细的日志)
|
||||||
|
|
||||||
|
**特征**:接收器 `ctrl`/`ctl`,有 funcName+logs+zap,包级函数 `handleAuthError` 或内联错误处理
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/auth/controller/auth_controller.go(实际代码)
|
||||||
|
func (ctrl *AuthController) Register(c *gin.Context) {
|
||||||
|
funcName := "controller.auth_controller.Register"
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
var req dto.RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err))
|
||||||
|
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.Info(ctx, funcName, "注册请求",
|
||||||
|
zap.String("username", req.Username),
|
||||||
|
zap.String("email", logs.MaskEmail(req.Email)),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := ctrl.authService.Register(ctx, &req)
|
||||||
|
if err != nil {
|
||||||
|
handleAuthError(c, err, "注册失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.ResponseOK(c, resp)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Service 层代码风格(统一)
|
||||||
|
|
||||||
|
所有模块的 Service 层风格一致。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/im/service/im_service.go + app/contact/service/contact_service.go(实际代码)
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"github.com/echochat/backend/app/xxx/dao"
|
||||||
|
"github.com/echochat/backend/app/dto"
|
||||||
|
"github.com/echochat/backend/pkg/logs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 错误变量在包顶部定义
|
||||||
|
var (
|
||||||
|
ErrNotFriend = errors.New("对方不是你的好友")
|
||||||
|
ErrEmptyContent = errors.New("消息内容不能为空")
|
||||||
|
// ...
|
||||||
|
)
|
||||||
|
|
||||||
|
type IMService struct {
|
||||||
|
convDAO *dao.ConversationDAO
|
||||||
|
msgDAO *dao.MessageDAO
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIMService(...) *IMService {
|
||||||
|
return &IMService{...}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage 发送消息
|
||||||
|
func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
|
||||||
|
funcName := "service.im_service.SendMessage"
|
||||||
|
logs.Info(ctx, funcName, "发送消息",
|
||||||
|
zap.Int64("sender_id", senderID),
|
||||||
|
zap.Int64("conversation_id", req.ConversationID))
|
||||||
|
|
||||||
|
// 业务逻辑...
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "操作失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 接收器变量名:`s`
|
||||||
|
- 每个公开方法开头声明 `funcName` 并记一次 Info/Debug 日志
|
||||||
|
- 错误时记 `logs.Error` 日志
|
||||||
|
- 错误定义为包级 `var ErrXxx = errors.New("中文描述")`
|
||||||
|
- 跨模块依赖通过 interface 注入(定义在 Service 包内)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、DAO 层代码风格(统一)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/im/dao/conversation_dao.go(实际代码)
|
||||||
|
type ConversationDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConversationDAO(db *gorm.DB) *ConversationDAO {
|
||||||
|
return &ConversationDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ConversationDAO) FindPrivateConversation(ctx context.Context, userID, targetUserID int64) (*model.Conversation, error) {
|
||||||
|
funcName := "dao.conversation_dao.FindPrivateConversation"
|
||||||
|
logs.Debug(ctx, funcName, "查找单聊会话",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("target_user_id", targetUserID))
|
||||||
|
|
||||||
|
var conv model.Conversation
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Raw(`SELECT ...`, userID, targetUserID).
|
||||||
|
Scan(&conv).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "查找单聊会话失败", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if conv.ID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &conv, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 接收器变量名:`d`
|
||||||
|
- 所有 DB 操作使用 `d.db.WithContext(ctx)`
|
||||||
|
- 查询方法开头记 `logs.Debug`,写操作记 `logs.Info`
|
||||||
|
- 错误时记 `logs.Error`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、Router 代码风格
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/contact/router.go(实际代码)
|
||||||
|
package contact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/contact/controller"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(r *gin.Engine, ctrl *controller.ContactController, jwtAuth gin.HandlerFunc) {
|
||||||
|
authed := r.Group("/api/v1")
|
||||||
|
authed.Use(jwtAuth)
|
||||||
|
{
|
||||||
|
authed.GET("/contacts", ctrl.GetFriendList)
|
||||||
|
authed.POST("/contacts/request", ctrl.SendFriendRequest)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 函数签名:`RegisterRoutes(r *gin.Engine, ctrl *controller.XxxController, jwtAuth gin.HandlerFunc)`
|
||||||
|
- 参数名用 `r`(不是 `engine`)
|
||||||
|
- 路由组用 `r.Group(...)` + `.Use(jwtAuth)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Provider 代码风格
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/contact/provider.go(简洁风格)
|
||||||
|
package contact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/echochat/backend/app/contact/controller"
|
||||||
|
"github.com/echochat/backend/app/contact/dao"
|
||||||
|
"github.com/echochat/backend/app/contact/service"
|
||||||
|
"github.com/google/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ContactSet = wire.NewSet(
|
||||||
|
dao.NewFriendshipDAO,
|
||||||
|
dao.NewFriendGroupDAO,
|
||||||
|
service.NewContactService,
|
||||||
|
controller.NewContactController,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**ProviderSet 命名**:`{ModuleName}Set`(如 `AuthSet`、`ContactSet`、`IMSet`、`FileSet`、`GroupSet`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、常量定义风格
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/constants/im.go(实际代码)
|
||||||
|
package constants
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConversationTypePrivate = 1
|
||||||
|
ConversationTypeGroup = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var ConversationTypeMap = map[int]string{
|
||||||
|
ConversationTypePrivate: "单聊",
|
||||||
|
ConversationTypeGroup: "群聊",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 常量命名 camelCase:`GroupStatusNormal`(不是 `GROUP_STATUS_NORMAL`)
|
||||||
|
- 每组常量配套一个 `XxxMap` 中文映射
|
||||||
|
- 文件按模块拆分:`im.go`、`contact.go`、`group.go`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、DTO 定义风格
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/dto/im_dto.go(实际代码)
|
||||||
|
package dto
|
||||||
|
|
||||||
|
type SendMessageRequest struct {
|
||||||
|
ConversationID int64 `json:"conversation_id"`
|
||||||
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ClientMsgID string `json:"client_msg_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageDTO struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ConversationID int64 `json:"conversation_id"`
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 按模块分文件:`im_dto.go`、`contact_dto.go`、`admin_dto.go`、`group_dto.go`
|
||||||
|
- Request 用 `json` tag(POST body)或 `form` tag(GET query)
|
||||||
|
- Response 用 `json` tag + `omitempty` 可选
|
||||||
|
- 时间字段在 DTO 中用 `string` 类型(格式化后的 `"2006-01-02 15:04:05"`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、Model 定义风格
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 摘自 app/im/model/conversation.go(实际代码)
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Conversation struct {
|
||||||
|
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Type int `json:"type" gorm:"not null;default:1"`
|
||||||
|
CreatorID int64 `json:"creator_id" gorm:"not null"`
|
||||||
|
LastMessageID *int64 `json:"last_message_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Conversation) TableName() string {
|
||||||
|
return "im_conversations"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键要点**:
|
||||||
|
- 必须有 `TableName()` 方法
|
||||||
|
- 时间字段用 `time.Time`,GORM tag 含 `type:timestamp(0)`
|
||||||
|
- 可选字段用指针类型(`*int64`、`*time.Time`、`*string`)
|
||||||
|
- 每个字段必须有注释说明用途
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、跨模块接口注入模式
|
||||||
|
|
||||||
模块间通信通过 **interface injection** 实现,禁止直接 import 其他模块包。
|
模块间通信通过 **interface injection** 实现,禁止直接 import 其他模块包。
|
||||||
|
|
||||||
### 3.1 标准流程
|
### 标准流程
|
||||||
|
|
||||||
```
|
```
|
||||||
步骤1: 在消费方 Service 中定义 interface(如 im/service → GroupMemberChecker)
|
步骤1: 在消费方 Service 中定义 interface
|
||||||
步骤2: 在提供方 DAO 中实现该接口(如 group/dao.GroupDAO)
|
步骤2: 在提供方 DAO 中实现该接口
|
||||||
步骤3: 在 app/provider/wire.go 中用 wire.Bind 绑定接口和实现
|
步骤3: 在 app/provider/wire.go 中用 wire.Bind 绑定
|
||||||
步骤4: 重新生成 wire_gen.go
|
步骤4: 更新 wire_gen.go
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 接口命名约定
|
### 已有接口注入清单
|
||||||
|
|
||||||
| 接口类型 | 命名模式 | 示例 |
|
|
||||||
|---------|---------|------|
|
|
||||||
| 数据查询 | `{Entity}{Action}er` | `FriendIDsGetter`, `GroupInfoGetter` |
|
|
||||||
| 状态检查 | `{Entity}{State}Checker` | `GroupMemberChecker`, `OnlineChecker` |
|
|
||||||
| 操作执行 | `{Entity}{Action}er` | `OfflineMessagePusher` |
|
|
||||||
|
|
||||||
### 3.3 已有接口注入清单
|
|
||||||
|
|
||||||
| 接口 | 定义方 | 实现方 | 用途 |
|
| 接口 | 定义方 | 实现方 | 用途 |
|
||||||
|------|--------|--------|------|
|
|------|--------|--------|------|
|
||||||
| FriendIDsGetter | ws/handler | contact/dao | 获取用户好友 ID 列表 |
|
| FriendIDsGetter | ws/handler | contact/dao | 获取用户好友 ID 列表 |
|
||||||
| FriendChecker | im/service | contact/dao | 检查是否为好友 |
|
| FriendChecker | im/service | contact/dao | 检查是否为好友 |
|
||||||
| UserInfoGetter | im/service | auth/dao | 获取用户信息 |
|
| UserInfoGetter | im/service | contact/dao | 获取用户信息 |
|
||||||
| OnlineChecker | contact/service | ws/service | 检查在线状态 |
|
| OnlineChecker | contact/service | ws/service | 检查在线状态 |
|
||||||
| OfflineMessagePusher | im/service | ws/handler | 离线消息推送 |
|
| TokenValidator | ws/handler | auth/service | WS 连接 Token 校验 |
|
||||||
| GroupMemberChecker | im/service | group/dao | 检查群成员身份(Phase 2c) |
|
|
||||||
| GroupInfoGetter | im/service | group/dao | 获取群信息(Phase 2c) |
|
|
||||||
| GroupRoleChecker | im/service | group/dao | 检查用户群角色(Phase 2c) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、Wire 依赖注入规范
|
## 十三、批量查询优化
|
||||||
|
|
||||||
- 每个模块在 `provider.go` 中导出 `ProviderSet`(`wire.NewSet(...)`)
|
获取关联信息时,**必须使用批量查询 + Map 映射,严禁 N+1 查询**。
|
||||||
- 接口绑定统一在 `app/provider/wire.go` 中声明
|
|
||||||
- 修改 wire.go 后必须重新运行 `wire gen ./app/provider/` 生成 wire_gen.go
|
|
||||||
- Wire 有过手动 patch 历史(Phase 2b),修改后需检查 wire_gen.go 一致性
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、日志记录标准
|
## 十四、系统消息规范(Phase 2c+)
|
||||||
|
|
||||||
所有 DAO 和 Service 的公开方法必须记录入口和出口日志:
|
系统消息写入 `im_messages` 表,`type=10`(`MessageTypeSystem`),`sender_id=0`(表示系统),`content` 使用**纯文本格式**。前端居中显示、灰色小字体、无头像、无气泡。
|
||||||
|
|
||||||
```go
|
|
||||||
func (d *SomeDAO) SomeMethod(ctx context.Context, param int64) (result *Model, err error) {
|
|
||||||
funcName := "dao.some_dao.SomeMethod"
|
|
||||||
|
|
||||||
logs.LogFunctionEntry(ctx, funcName, map[string]interface{}{
|
|
||||||
"param": param,
|
|
||||||
})
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
logs.LogFunctionExit(ctx, funcName, result, err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 业务逻辑
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**funcName 命名规则:** `{层级}.{模块}_{文件}.{方法名}`
|
|
||||||
- DAO 层:`dao.group_dao.CreateGroup`
|
|
||||||
- Service 层:`service.group_service.CreateGroup`
|
|
||||||
- Controller 层:`controller.group_controller.CreateGroup`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、Controller 错误处理标准
|
## 十五、依赖管理
|
||||||
|
|
||||||
```go
|
- 添加新依赖前必须检查 `go.mod` 中的 Go 版本(当前 `go 1.23.12`)
|
||||||
func (ctrl *Controller) HandleAction(c *gin.Context) {
|
- 选择与当前 Go 版本兼容的包版本,禁止触发 Go 工具链自动升级
|
||||||
funcName := "controller.module.HandleAction"
|
- 优先复用 `go.mod` 中已有的间接依赖
|
||||||
|
|
||||||
// 参数绑定
|
|
||||||
var req dto.SomeRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Service
|
|
||||||
result, err := ctrl.service.DoAction(c.Request.Context(), &req)
|
|
||||||
if err != nil {
|
|
||||||
ctrl.handleError(c, funcName, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.ResponseOK(c, "操作成功", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleError 必须覆盖所有已知业务错误
|
|
||||||
func (ctrl *Controller) handleError(c *gin.Context, funcName string, err error) {
|
|
||||||
switch err {
|
|
||||||
case service.ErrNotFound:
|
|
||||||
utils.ResponseNotFound(c, err.Error())
|
|
||||||
case service.ErrPermission:
|
|
||||||
utils.ResponseForbidden(c, err.Error())
|
|
||||||
// ... 覆盖所有已知错误
|
|
||||||
default:
|
|
||||||
logs.Error(c.Request.Context(), funcName, "操作失败", zap.Error(err))
|
|
||||||
utils.ResponseError(c, "操作失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键要求:**
|
|
||||||
- `handleError` 必须覆盖所有已知业务错误,不能用 `default` 笼统处理
|
|
||||||
- 错误 message 使用中文,面向用户
|
|
||||||
- 未知错误必须记录日志
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、批量查询优化
|
|
||||||
|
|
||||||
获取关联信息时,**必须使用批量查询 + Map 映射,严禁 N+1 查询**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ✅ 正确:批量查询
|
|
||||||
userIDs := extractUserIDs(members)
|
|
||||||
users, _ := userDAO.GetByIDs(ctx, userIDs)
|
|
||||||
userMap := make(map[int64]*User)
|
|
||||||
for _, u := range users {
|
|
||||||
userMap[u.ID] = u
|
|
||||||
}
|
|
||||||
for _, m := range members {
|
|
||||||
m.Nickname = userMap[m.UserID].Nickname
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ 错误:N+1 查询
|
|
||||||
for _, m := range members {
|
|
||||||
user, _ := userDAO.GetByID(ctx, m.UserID)
|
|
||||||
m.Nickname = user.Nickname
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、系统消息规范(Phase 2c+)
|
|
||||||
|
|
||||||
### 8.1 系统消息定义
|
|
||||||
|
|
||||||
系统消息是由服务端自动生成的提示类消息,写入 `im_messages` 表,type=10(`MessageTypeSystem`)。
|
|
||||||
|
|
||||||
### 8.2 内容格式
|
|
||||||
|
|
||||||
系统消息的 `content` 字段使用**纯文本格式**,不使用 JSON 结构。
|
|
||||||
|
|
||||||
### 8.3 前端渲染
|
|
||||||
|
|
||||||
系统消息在聊天页面中**居中显示,灰色小字体,无头像,无气泡**。
|
|
||||||
|
|
||||||
### 8.4 sender_id
|
|
||||||
|
|
||||||
系统消息的 `sender_id` 设为 0(表示系统),前端根据 sender_id=0 和 type=10 判断为系统消息。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、WS 事件推送模式
|
|
||||||
|
|
||||||
### 9.1 S→C 推送(Service 层触发)
|
|
||||||
|
|
||||||
```go
|
|
||||||
pubsub.PublishToUser(ctx, targetUserID, &ws.Message{
|
|
||||||
Event: "group.member.join",
|
|
||||||
Data: map[string]interface{}{...},
|
|
||||||
})
|
|
||||||
|
|
||||||
pubsub.PublishToUsers(ctx, memberIDs, &ws.Message{
|
|
||||||
Event: "im.message.new",
|
|
||||||
Data: messageDTO,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 C→S 事件处理(Hub 事件路由表注册)
|
|
||||||
|
|
||||||
```go
|
|
||||||
hub.RegisterEvent("im.message.send", handler.HandleSendMessage)
|
|
||||||
hub.RegisterEvent("im.message.read", handler.HandleReadMessage)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、前端 Store 与 API 封装规范
|
|
||||||
|
|
||||||
### 10.1 API 封装标准
|
|
||||||
|
|
||||||
文件位置:`frontend/src/api/{module}.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import request from '@/utils/request'
|
|
||||||
|
|
||||||
// 获取群详情
|
|
||||||
export function getGroupDetail(groupId) {
|
|
||||||
return request.get(`/groups/${groupId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建群聊
|
|
||||||
export function createGroup(data) {
|
|
||||||
return request.post('/groups', data)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**命名规则:**
|
|
||||||
- GET 请求:`get{Entity}` / `get{Entity}List`
|
|
||||||
- POST 请求:`create{Entity}` / `{action}{Entity}`
|
|
||||||
- PUT 请求:`update{Entity}` / `set{Entity}{Field}`
|
|
||||||
- DELETE 请求:`delete{Entity}` / `remove{Entity}`
|
|
||||||
|
|
||||||
### 10.2 Pinia Store 标准结构
|
|
||||||
|
|
||||||
文件位置:`frontend/src/store/{module}.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useGroupStore = defineStore('group', {
|
|
||||||
state: () => ({
|
|
||||||
conversations: [], // 群会话列表
|
|
||||||
currentGroup: null, // 当前群详情
|
|
||||||
messages: {}, // {conversationId: Message[]}
|
|
||||||
members: {}, // {groupId: Member[]}
|
|
||||||
}),
|
|
||||||
|
|
||||||
getters: {
|
|
||||||
unreadTotal: (state) => { ... },
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
// 初始化 WS 事件监听(在 App.vue onLaunch 中调用)
|
|
||||||
initWsListeners() { ... },
|
|
||||||
|
|
||||||
// 加载群会话列表
|
|
||||||
async loadConversations() { ... },
|
|
||||||
|
|
||||||
// 发送群消息
|
|
||||||
async sendMessage(conversationId, content, atUserIds) { ... },
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Store 设计原则:**
|
|
||||||
1. **单一职责**:每个模块一个 Store,不混合不同模块数据
|
|
||||||
2. **WS 监听统一初始化**:所有 Store 的 WS 事件监听在 `App.vue` 的 `_initGlobalWS` 中统一调用
|
|
||||||
3. **消息缓存**:按 conversationId 键值对缓存,避免重复请求
|
|
||||||
4. **乐观更新**:发送消息时先本地插入,再等待服务端 ack 确认
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端)
|
> **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端)
|
||||||
> **创建日期**:2026-03-02
|
> **创建日期**:2026-03-02
|
||||||
> **最后更新**:2026-03-04(Phase 2c 设计完成:新增群聊模块、MinIO 文件存储、已读回执规划)
|
> **最后更新**:2026-03-04(Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -718,7 +718,7 @@ DELETE /api/v1/im/conversations/:id/messages 清空聊天记录(个人视
|
|||||||
GET /api/v1/im/messages/search 全局消息搜索(GIN 全文索引)
|
GET /api/v1/im/messages/search 全局消息搜索(GIN 全文索引)
|
||||||
GET /api/v1/im/unread 全局未读消息总数
|
GET /api/v1/im/unread 全局未读消息总数
|
||||||
|
|
||||||
# 群聊管理模块(Phase 2c 规划)
|
# 群聊管理模块(✅ Phase 2c 完成)
|
||||||
POST /api/v1/groups 创建群聊
|
POST /api/v1/groups 创建群聊
|
||||||
GET /api/v1/groups/:id 群详情
|
GET /api/v1/groups/:id 群详情
|
||||||
PUT /api/v1/groups/:id 更新群信息
|
PUT /api/v1/groups/:id 更新群信息
|
||||||
@@ -736,11 +736,11 @@ GET /api/v1/groups/:id/join-requests 入群申请列表
|
|||||||
PUT /api/v1/groups/:id/join-requests/:rid 审批入群申请
|
PUT /api/v1/groups/:id/join-requests/:rid 审批入群申请
|
||||||
GET /api/v1/groups/search 搜索公开群
|
GET /api/v1/groups/search 搜索公开群
|
||||||
|
|
||||||
# 已读回执(Phase 2c 规划)
|
# 已读回执(✅ Phase 2c 完成)
|
||||||
GET /api/v1/im/messages/:id/reads 消息已读详情
|
GET /api/v1/im/messages/:id/reads 消息已读详情
|
||||||
GET /api/v1/im/messages/:id/read-count 消息已读计数
|
GET /api/v1/im/messages/:id/read-count 消息已读计数
|
||||||
|
|
||||||
# 文件上传(Phase 2c 规划)
|
# 文件上传(✅ Phase 2c 完成)
|
||||||
POST /api/v1/upload 通用文件上传(MinIO)
|
POST /api/v1/upload 通用文件上传(MinIO)
|
||||||
|
|
||||||
# 会议模块
|
# 会议模块
|
||||||
@@ -782,7 +782,7 @@ GET /api/v1/admin/online/count 在线用户数
|
|||||||
GET /api/v1/admin/contacts 所有好友关系(分页)
|
GET /api/v1/admin/contacts 所有好友关系(分页)
|
||||||
DELETE /api/v1/admin/contacts/:id 管理员解除好友关系
|
DELETE /api/v1/admin/contacts/:id 管理员解除好友关系
|
||||||
|
|
||||||
# 群聊管理(Phase 2c 规划)
|
# 群聊管理(✅ Phase 2c 完成)
|
||||||
GET /api/v1/admin/groups 群列表(分页 + 筛选)
|
GET /api/v1/admin/groups 群列表(分页 + 筛选)
|
||||||
GET /api/v1/admin/groups/:id 群详情(含成员列表)
|
GET /api/v1/admin/groups/:id 群详情(含成员列表)
|
||||||
DELETE /api/v1/admin/groups/:id 管理员解散群
|
DELETE /api/v1/admin/groups/:id 管理员解散群
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 2c 设计文档:群聊与已读回执
|
# Phase 2c 设计文档:群聊与已读回执
|
||||||
|
|
||||||
> **状态:** 📋 设计完成,待实施
|
> **状态:** ✅ 已完成实施(含代码审查修复 14 项)
|
||||||
> **分支:** `feature/phase2c-group-read-receipt`
|
> **分支:** `feature/phase2c-group-read-receipt`
|
||||||
> **前置依赖:** Phase 2b 全部完成(单聊即时通讯)
|
> **前置依赖:** Phase 2b 全部完成(单聊即时通讯)
|
||||||
> **最后更新:** 2026-03-04
|
> **最后更新:** 2026-03-04
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Phase 2c 实施计划:群聊与已读回执
|
# Phase 2c 实施计划:群聊与已读回执
|
||||||
|
|
||||||
> **状态:** 📋 待执行
|
> **状态:** ✅ 已完成(含代码审查修复 14 项)
|
||||||
> **设计文档:** `docs/plans/2026-03-04-phase2c-design.md`
|
> **设计文档:** `docs/plans/2026-03-04-phase2c-design.md`
|
||||||
> **分支:** `feature/phase2c-group-read-receipt`
|
> **分支:** `feature/phase2c-group-read-receipt`
|
||||||
> **预计 Task 数:** 14 个
|
> **预计 Task 数:** 14 个(全部完成)
|
||||||
> **最后更新:** 2026-03-04
|
> **最后更新:** 2026-03-04
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,20 +12,20 @@
|
|||||||
|
|
||||||
| Task | 阶段 | 描述 | 依赖 | 状态 |
|
| Task | 阶段 | 描述 | 依赖 | 状态 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
| Task 0 | 基础设施 | MinIO Docker + SDK + 通用上传 API | 无 | 📋 |
|
| Task 0 | 基础设施 | MinIO Docker + SDK + 通用上传 API | 无 | ✅ |
|
||||||
| Task 1 | 基础设施 | 数据库迁移 + Model + 常量定义 | 无 | 📋 |
|
| Task 1 | 基础设施 | 数据库迁移 + Model + 常量定义 | 无 | ✅ |
|
||||||
| Task 2 | 群聊后端 | Group DAO 层 | Task 1 | 📋 |
|
| Task 2 | 群聊后端 | Group DAO 层 | Task 1 | ✅ |
|
||||||
| Task 3 | 群聊后端 | Group Service 业务逻辑 | Task 2 | 📋 |
|
| Task 3 | 群聊后端 | Group Service 业务逻辑 | Task 2 | ✅ |
|
||||||
| Task 4 | 群聊后端 | Group Controller + Router + Wire | Task 3 | 📋 |
|
| Task 4 | 群聊后端 | Group Controller + Router + Wire | Task 3 | ✅ |
|
||||||
| Task 5 | 群聊后端 | WS 群管理事件处理器 | Task 3 | 📋 |
|
| Task 5 | 群聊后端 | WS 群管理事件处理器 | Task 3 | ✅ |
|
||||||
| Task 6 | 群聊后端 | IM Service 扩展(群消息 + @提醒 + 管理员撤回) | Task 2 | 📋 |
|
| Task 6 | 群聊后端 | IM Service 扩展(群消息 + @提醒 + 管理员撤回) | Task 2 | ✅ |
|
||||||
| Task 7 | 已读回执 | 已读回执后端(ReadDAO + Service + API + WS 推送) | Task 1 | 📋 |
|
| Task 7 | 已读回执 | 已读回执后端(ReadDAO + Service + API + WS 推送) | Task 1 | ✅ |
|
||||||
| Task 8 | 已读回执 | 前端已读回执 UI(单聊标记 + 群聊计数 + 详情页) | Task 7 | 📋 |
|
| Task 8 | 已读回执 | 前端已读回执 UI(单聊标记 + 群聊计数 + 详情页) | Task 7 | ✅ |
|
||||||
| Task 9 | 前端 | 群聊 Store + API 封装 + WS 事件监听 | Task 4, 5 | 📋 |
|
| Task 9 | 前端 | 群聊 Store + API 封装 + WS 事件监听 | Task 4, 5 | ✅ |
|
||||||
| Task 10 | 前端 | 群聊核心页面(Tab 改造 + 群对话页 + 创建群页) | Task 9 | 📋 |
|
| Task 10 | 前端 | 群聊核心页面(Tab 改造 + 群对话页 + 创建群页) | Task 9 | ✅ |
|
||||||
| Task 11 | 前端 | 群聊管理页面(群设置 + 成员 + 邀请 + @选择器) | Task 10 | 📋 |
|
| Task 11 | 前端 | 群聊管理页面(群设置 + 成员 + 邀请 + @选择器) | Task 10 | ✅ |
|
||||||
| Task 12 | 前端 | 群聊辅助功能(审批 + 搜索 + 免打扰 + 公告 UI) | Task 11 | 📋 |
|
| Task 12 | 前端 | 群聊辅助功能(审批 + 搜索 + 免打扰 + 公告 UI) | Task 11 | ✅ |
|
||||||
| Task 13 | 管理端 | 管理端群聊管理 + 全量文档更新 + 代码审查 | Task 12 | 📋 |
|
| Task 13 | 管理端 | 管理端群聊管理 + 全量文档更新 + 代码审查 | Task 12 | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -553,10 +553,75 @@ Task 0 + Task 12 ── Task 13 (管理端+文档+审查)
|
|||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
1. **代码风格一致性**:严格遵循 Phase 2b 的代码风格(日志记录、错误处理、常量命名、DTO 设计)
|
### 代码风格全局一致(最高优先级)
|
||||||
2. **接口注入模式**:新模块间通信必须走 interface injection,禁止直接 import
|
|
||||||
3. **批量查询优化**:群成员信息获取使用批量查询 + Map 映射,避免 N+1
|
**编写任何新代码前,必须先阅读同层级现有模块的实际代码,严格复制其风格。** 详细规范见 `docs/conventions/backend-module-architecture.md`。
|
||||||
4. **前端设计规范**:所有前端页面使用 ui-ux-pro-max 技能包设计
|
|
||||||
5. **系统消息**:群管理操作产生的系统消息统一使用 type=10(MessageTypeSystem),内容格式化
|
**强制执行流程:**
|
||||||
6. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级
|
1. 写代码前 → 用 Read/Grep 读取同类型现有文件
|
||||||
7. **Wire 依赖**:Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性
|
2. 写代码时 → 逐行对照导入、结构体、方法签名、日志调用、错误处理
|
||||||
|
3. 写代码后 → 与参照文件做差异比对,确认风格完全一致
|
||||||
|
|
||||||
|
**各层参照文件(Phase 2c 新模块必读):**
|
||||||
|
|
||||||
|
| 新模块文件 | 参照现有文件 |
|
||||||
|
|-----------|------------|
|
||||||
|
| group/controller | `app/contact/controller/contact_controller.go`、`app/im/controller/im_controller.go` |
|
||||||
|
| group/service | `app/im/service/im_service.go`、`app/contact/service/contact_service.go` |
|
||||||
|
| group/dao | `app/im/dao/conversation_dao.go`、`app/contact/dao/friendship_dao.go` |
|
||||||
|
| group/model | `app/im/model/conversation.go`、`app/im/model/message.go` |
|
||||||
|
| group/router.go | `app/contact/router.go`、`app/im/router.go` |
|
||||||
|
| group/provider.go | `app/contact/provider.go` |
|
||||||
|
| constants/group.go | `app/constants/im.go`、`app/constants/contact.go` |
|
||||||
|
| dto/group_dto.go | `app/dto/im_dto.go`、`app/dto/contact_dto.go` |
|
||||||
|
|
||||||
|
**严格禁止事项:**
|
||||||
|
- 禁止调用不存在的 API(如 `logs.LogFunctionEntry`)
|
||||||
|
- 禁止引入与当前 Go 版本不兼容的依赖
|
||||||
|
- 禁止自创新封装模式(如自定义错误处理框架)
|
||||||
|
- 禁止在 Controller 层记日志(auth/admin 模块除外)
|
||||||
|
- 禁止使用未经项目验证的第三方库
|
||||||
|
|
||||||
|
### Code Review 遗留问题清单(Important/Minor)
|
||||||
|
|
||||||
|
以下问题在首轮 Code Review 中被标记为 Important 或 Minor,已在后续 Task 中逐步处理。
|
||||||
|
|
||||||
|
#### Important 级别
|
||||||
|
|
||||||
|
| # | 模块 | 问题 | 修复优先级 |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| I-1 | group/service | `UpdateGroup` 中 `_ = member` 无效赋值,应改为 `_, _, err :=` | 低(代码清洁) |
|
||||||
|
| I-2 | group/service | `MuteMember` 使用 `ErrCannotKickHigherRole` 语义不精确,应新增通用错误 | 中 |
|
||||||
|
| I-3 | group/dao | `SearchGroups` 的 `to_tsquery` 对特殊字符敏感,应改用 `plainto_tsquery` | 高(用户输入安全) |
|
||||||
|
| I-4 | group/service | `InviteMembers` 循环逐条插入缺少事务包裹和批量优化 | 中(性能) |
|
||||||
|
| I-5 | group/controller | `handleError` 缺少 `ErrAlreadyMuted/ErrUserMuted/ErrGroupAllMuted` 映射 | 高 |
|
||||||
|
| I-6 | group/model | `MessageRead` 放在 `group/model` 而非 `im/model`,领域归属可议 | 低(架构决策) |
|
||||||
|
| I-7 | im/service | `sendGroupMessage` 推送未检查成员免打扰设置 | 中(业务逻辑) |
|
||||||
|
| I-8 | im/service | 群消息推送逐用户查询 N+1 问题 | 中(性能) |
|
||||||
|
| I-9 | im/service | WS 群已读事件 `im.message.read` 处理器未注册 | 高(Task 5 实现) |
|
||||||
|
| I-10 | im/service | 消息推送数据缺少 `conv_type` 字段 | 高(前端适配) |
|
||||||
|
| I-11 | im/service | `GetMessageReadDetail` 无分页参数 | 中 |
|
||||||
|
| I-12 | file/service | 文件上传缺少 MIME 类型校验 | 中(安全性) |
|
||||||
|
| I-13 | file/service | 上传返回 URL 使用内部地址,应返回可配置前缀 | 中 |
|
||||||
|
| I-14 | pkg/storage | MinIO 初始化无超时控制 | 低 |
|
||||||
|
|
||||||
|
#### Minor 级别
|
||||||
|
|
||||||
|
| # | 模块 | 问题 |
|
||||||
|
|---|------|------|
|
||||||
|
| M-1 | group/dao | `GetMemberCount` / `GetMemberIDs` 缺少 funcName 和日志 |
|
||||||
|
| M-2 | group/dao | `HasRead` 缺少 funcName 和日志 |
|
||||||
|
| M-3 | group/provider | 与 IM 模块 Provider 风格略有差异(无 ProvideXxx 包装) |
|
||||||
|
| M-4 | group/service | `SearchGroups` 逐群查 `GetMemberCount`,建议批量 COUNT |
|
||||||
|
| M-5 | group/service | `imMember` 内部类型可直接使用 `imModel.ConversationMember` |
|
||||||
|
| M-6 | group/controller | `SetAllMuted` 使用匿名结构体,建议改用 DTO |
|
||||||
|
|
||||||
|
### 其他注意事项
|
||||||
|
|
||||||
|
1. **接口注入模式**:新模块间通信必须走 interface injection,禁止直接 import
|
||||||
|
2. **批量查询优化**:群成员信息获取使用批量查询 + Map 映射,避免 N+1
|
||||||
|
3. **前端设计规范**:所有前端页面使用 ui-ux-pro-max 技能包设计
|
||||||
|
4. **系统消息**:群管理操作产生的系统消息统一使用 type=10(MessageTypeSystem),内容格式化
|
||||||
|
5. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级
|
||||||
|
6. **Wire 依赖**:Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性
|
||||||
|
7. **依赖管理**:当前 Go 版本 1.23.12,添加新依赖必须选择兼容版本
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-03-04(Phase 2c 设计完成,待实施)
|
> **最后更新**:2026-03-04(Phase 2c 代码审查修复完成)
|
||||||
> **当前阶段**:Phase 2c 设计完成,准备开始实施
|
> **当前阶段**:Phase 2c 代码审查修复完成,待最终验证
|
||||||
> **当前分支**:`feature/phase2c-group-read-receipt`(已从 phase2b 拉出)
|
> **当前分支**:`feature/phase2c-group-read-receipt`
|
||||||
> **实施计划**:`docs/plans/2026-03-04-phase2c-implementation.plan.md`
|
> **实施计划**:`docs/plans/2026-03-04-phase2c-implementation.plan.md`
|
||||||
> **设计文档**:`docs/plans/2026-03-04-phase2c-design.md`
|
> **设计文档**:`docs/plans/2026-03-04-phase2c-design.md`
|
||||||
|
|
||||||
@@ -178,14 +178,24 @@ EchoChat/
|
|||||||
│ └── router/router.go
|
│ └── router/router.go
|
||||||
├── frontend/ # 前台(uni-app)
|
├── frontend/ # 前台(uni-app)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── api/{auth,contact,user,im}.js
|
│ ├── api/{auth,contact,user,im,group,file}.js
|
||||||
|
│ ├── constants/group.js # [Phase 2c] 群聊角色/状态常量
|
||||||
│ ├── services/websocket.js
|
│ ├── services/websocket.js
|
||||||
│ ├── store/{user,websocket,contact,chat}.js
|
│ ├── store/{user,websocket,contact,chat,group}.js
|
||||||
│ ├── pages/chat/ # [Phase 2b] 4 个页面
|
│ ├── pages/chat/ # [Phase 2b] 5 个页面
|
||||||
│ │ ├── index.vue # 会话列表
|
│ │ ├── index.vue # 会话列表(含群聊 Tab)
|
||||||
│ │ ├── conversation.vue # 聊天对话
|
│ │ ├── conversation.vue # 单聊对话
|
||||||
|
│ │ ├── read-detail.vue # [Phase 2c] 已读详情
|
||||||
│ │ ├── settings.vue # 聊天设置
|
│ │ ├── settings.vue # 聊天设置
|
||||||
│ │ └── search.vue # 消息搜索
|
│ │ └── search.vue # 消息搜索
|
||||||
|
│ ├── pages/group/ # [Phase 2c] 7 个页面
|
||||||
|
│ │ ├── conversation.vue # 群聊对话(含 @选择器 + 已读计数 + 禁言提示)
|
||||||
|
│ │ ├── create.vue # 创建群聊
|
||||||
|
│ │ ├── settings.vue # 群设置
|
||||||
|
│ │ ├── members.vue # 成员管理
|
||||||
|
│ │ ├── invite.vue # 邀请入群
|
||||||
|
│ │ ├── join-requests.vue # 入群审批
|
||||||
|
│ │ └── search.vue # 搜索群聊
|
||||||
│ ├── pages/contact/ # [Phase 2a] 6 个页面
|
│ ├── pages/contact/ # [Phase 2a] 6 个页面
|
||||||
│ └── components/CustomTabBar.vue(含 badge)
|
│ └── components/CustomTabBar.vue(含 badge)
|
||||||
├── admin/ # 管理端(Vue 3 + Element Plus)
|
├── admin/ # 管理端(Vue 3 + Element Plus)
|
||||||
@@ -237,12 +247,12 @@ cd frontend && npm run dev:h5
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、下一阶段:Phase 2c — 群聊与已读回执
|
## 八、Phase 2c — 群聊与已读回执
|
||||||
|
|
||||||
> **状态:** 设计完成,待实施
|
> **状态:** 实施中
|
||||||
> **设计文档:** `docs/plans/2026-03-04-phase2c-design.md`
|
> **设计文档:** `docs/plans/2026-03-04-phase2c-design.md`
|
||||||
> **实施计划:** `docs/plans/2026-03-04-phase2c-implementation.plan.md`
|
> **实施计划:** `docs/plans/2026-03-04-phase2c-implementation.plan.md`
|
||||||
> **分支:** `feature/phase2c-group-read-receipt`(已创建,从 phase2b 拉出)
|
> **分支:** `feature/phase2c-group-read-receipt`
|
||||||
|
|
||||||
### 功能范围
|
### 功能范围
|
||||||
|
|
||||||
@@ -252,29 +262,84 @@ cd frontend && npm run dev:h5
|
|||||||
| 群消息 | 复用 im.message.* 事件 + @某人/@所有人 + 管理员撤回(无时限)+ 系统消息 |
|
| 群消息 | 复用 im.message.* 事件 + @某人/@所有人 + 管理员撤回(无时限)+ 系统消息 |
|
||||||
| 已读回执 | 单聊会话级(last_read_msg_id)+ 群聊消息级(im_message_reads 表)+ 实时推送 |
|
| 已读回执 | 单聊会话级(last_read_msg_id)+ 群聊消息级(im_message_reads 表)+ 实时推送 |
|
||||||
| MinIO | Docker 容器 + Go SDK + 通用上传 API(群头像) |
|
| MinIO | Docker 容器 + Go SDK + 通用上传 API(群头像) |
|
||||||
| 管理端 | 群列表/群详情/解散群/移除成员 |
|
| 管理端 | 群列表/群详情/解散群 |
|
||||||
| 前端 | 9 个新页面 + 群聊 Store + 会话列表 Tab 改造 |
|
| 前端 | 9 个新页面 + 群聊 Store + 会话列表 Tab 改造 |
|
||||||
|
|
||||||
### Task 概览(14 个)
|
### Task 完成状态
|
||||||
|
|
||||||
| Task | 描述 | 状态 |
|
| Task | 描述 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Task 0 | MinIO Docker + SDK + 通用上传 API | 📋 |
|
| Task 0 | MinIO Docker + SDK + 通用上传 API | ✅ 完成 |
|
||||||
| Task 1 | 数据库迁移 + Model + 常量 | 📋 |
|
| Task 1 | 数据库迁移 + Model + 常量 | ✅ 完成 |
|
||||||
| Task 2 | Group DAO 层 | 📋 |
|
| Task 2 | Group DAO 层 | ✅ 完成 |
|
||||||
| Task 3 | Group Service 业务逻辑 | 📋 |
|
| Task 3 | Group Service 业务逻辑 + WS 推送 | ✅ 完成 |
|
||||||
| Task 4 | Group Controller + Router + Wire | 📋 |
|
| Task 4 | Group Controller + Router + Wire | ✅ 完成 |
|
||||||
| Task 5 | WS 群管理事件处理器 | 📋 |
|
| Task 5 | IM Service 扩展(群消息/@提醒/管理员撤回) | ✅ 完成 |
|
||||||
| Task 6 | IM Service 扩展(群消息/@提醒/管理员撤回) | 📋 |
|
| Task 6 | 已读回执后端(单聊 + 群聊) | ✅ 完成 |
|
||||||
| Task 7 | 已读回执后端 | 📋 |
|
| Task 7 | 代码审查修复(Critical 4 项 + Important 4 项) | ✅ 完成 |
|
||||||
| Task 8 | 前端已读回执 UI | 📋 |
|
| Task 8 | 前端已读回执 UI(单聊标记 + 群聊计数 + 详情页) | ✅ 完成 |
|
||||||
| Task 9 | 前端群聊 Store + API + WS 监听 | 📋 |
|
| Task 9 | 前端群聊 Store + API 封装 + WS 事件监听 | ✅ 完成 |
|
||||||
| Task 10 | 群聊核心页面(Tab + 对话 + 创建) | 📋 |
|
| Task 10 | 群聊核心页面(Tab 切换 + 群聊对话页 + 创建群聊页) | ✅ 完成 |
|
||||||
| Task 11 | 群聊管理页面(设置 + 成员 + 邀请 + @选择器) | 📋 |
|
| Task 11 | 群聊管理页面(群设置 + 成员管理 + 邀请入群) | ✅ 完成 |
|
||||||
| Task 12 | 群聊辅助功能(审批 + 搜索 + 免打扰 + 公告) | 📋 |
|
| Task 12 | 群聊辅助功能(入群审批 + 搜索群聊) | ✅ 完成 |
|
||||||
| Task 13 | 管理端 + 文档更新 + 代码审查 | 📋 |
|
| Task 13 | 管理端群组管理 + 文档更新 | ✅ 完成 |
|
||||||
|
| 代码审查修复 | 14 项修复(Critical×5 + Important×4 + Minor×2 + Suggestion×3) | ✅ 完成 |
|
||||||
|
|
||||||
|
### 代码审查修复详情(Phase 2c)
|
||||||
|
|
||||||
|
| # | 优先级 | 修复内容 |
|
||||||
|
|---|--------|----------|
|
||||||
|
| Fix C1 | Critical | conversation.vue 角色类型不匹配:字符串改为 GROUP_ROLE 数字常量 |
|
||||||
|
| Fix C2 | Critical | settings.vue 群公告字段名 announcement 改为 notice(对齐后端 DTO) |
|
||||||
|
| Fix C3 | Critical | chat.js sendMessage 未传递 at_user_ids 到 WS payload |
|
||||||
|
| Fix C4 | Critical | create.vue 创建成功后导航错误:改为 /pages/group/conversation + 正确参数 |
|
||||||
|
| Fix C5 | Critical | admin/provider.go Wire Set 未注册 GroupManageService/Controller |
|
||||||
|
| Fix I1 | Important | store/group.js searchGroups 添加 append 参数解决分页加载竞态 |
|
||||||
|
| Fix I2 | Important | settings.vue 改为 fetchMembers() 刷新数据,不直接修改 computed 引用 |
|
||||||
|
| Fix I3 | Important | 新增 constants/group.js 前端角色常量定义,消除魔数 |
|
||||||
|
| Fix I4 | Important | group_manage_service.go 列表查询 N+1 优化:批量查询用户名和成员数 |
|
||||||
|
| Fix M1 | Minor | file.js JSON.parse 添加 try/catch 异常保护 |
|
||||||
|
| Fix M2 | Minor | conversation.vue isSelf 移除冗余临时状态条件 |
|
||||||
|
| Fix S1 | Suggestion | 群聊对话页新增禁言状态检测和输入栏禁用提示 |
|
||||||
|
| Fix S2 | Suggestion | join-requests.vue 注册 WS group.join.request 事件实时刷新 |
|
||||||
|
| Fix S3 | Suggestion | read-detail.vue 获取失败时添加 uni.showToast 用户提示 |
|
||||||
|
|
||||||
|
### Phase 2c 新增内容
|
||||||
|
|
||||||
|
#### 后端新增模块
|
||||||
|
- **file/** — 文件上传(MinIO SDK + 通用上传 API)
|
||||||
|
- **group/** — 群聊管理(Controller + Service + DAO + Model + Router)
|
||||||
|
- 18 个群管理 REST API + 11 个 WS 群事件推送
|
||||||
|
- **im/ 扩展** — 群消息发送/撤回 + @提醒 + 单聊/群聊已读回执
|
||||||
|
- **admin/ 扩展** — 群组列表 + 群组详情 + 解散群聊
|
||||||
|
|
||||||
|
#### 前端新增页面(9 个)
|
||||||
|
| 页面 | 路径 | 功能 |
|
||||||
|
|------|------|------|
|
||||||
|
| 群聊对话页 | `pages/group/conversation.vue` | 群消息收发 + @选择器 + 已读计数 |
|
||||||
|
| 创建群聊页 | `pages/group/create.vue` | 好友多选 + 群名称输入 |
|
||||||
|
| 群设置页 | `pages/group/settings.vue` | 群信息修改 + 成员概览 + 退出/解散 |
|
||||||
|
| 群成员页 | `pages/group/members.vue` | 成员列表 + 角色管理 + 禁言操作 |
|
||||||
|
| 邀请入群页 | `pages/group/invite.vue` | 好友多选 + 排除已在群内成员 |
|
||||||
|
| 入群审批页 | `pages/group/join-requests.vue` | 申请列表 + 通过/拒绝操作 |
|
||||||
|
| 搜索群聊页 | `pages/group/search.vue` | 关键词搜索 + 申请加入 |
|
||||||
|
| 已读详情页 | `pages/chat/read-detail.vue` | 已读/未读成员列表(群聊消息级) |
|
||||||
|
| 会话列表改造 | `pages/chat/index.vue` | Tab 切换(全部/单聊/群聊)+ @标记 + 免打扰标识 |
|
||||||
|
|
||||||
|
#### 管理端新增
|
||||||
|
| 页面 | 路径 | 功能 |
|
||||||
|
|------|------|------|
|
||||||
|
| 群组列表 | `views/group/list.vue` | 搜索 + 分页 + 详情弹窗 + 解散群聊 |
|
||||||
|
|
||||||
|
#### 数据库新增/变更
|
||||||
|
- `im_groups` — 群信息表(新增)
|
||||||
|
- `im_group_join_requests` — 入群申请表(新增)
|
||||||
|
- `im_message_reads` — 群消息已读表(新增)
|
||||||
|
- `im_conversation_members` — 扩展字段(role, nickname, is_muted, is_do_not_disturb, joined_at, at_me_count)
|
||||||
|
- `im_messages` — 扩展字段(at_user_ids BIGINT[])
|
||||||
|
|
||||||
### 留待后续阶段
|
### 留待后续阶段
|
||||||
|
|
||||||
- 消息类型扩展(图片/语音/文件)
|
- 消息类型扩展(图片/语音/文件)
|
||||||
- 管理端消息管理功能
|
- 管理端消息管理功能
|
||||||
|
- 群头像上传 UI 完善
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useUserStore } from '@/store/user'
|
|||||||
import { useWebSocketStore } from '@/store/websocket'
|
import { useWebSocketStore } from '@/store/websocket'
|
||||||
import { useChatStore } from '@/store/chat'
|
import { useChatStore } from '@/store/chat'
|
||||||
import { useContactStore } from '@/store/contact'
|
import { useContactStore } from '@/store/contact'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
@@ -35,8 +36,10 @@ export default {
|
|||||||
wsStore.connect()
|
wsStore.connect()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const contactStore = useContactStore()
|
const contactStore = useContactStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
chatStore.initWsListeners()
|
chatStore.initWsListeners()
|
||||||
contactStore.initWsListeners()
|
contactStore.initWsListeners()
|
||||||
|
groupStore.initWsListeners()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/api/file.js
Normal file
55
frontend/src/api/file.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 文件上传模块 API
|
||||||
|
*
|
||||||
|
* 对应后端路由:POST /api/v1/upload
|
||||||
|
* 使用 uni.uploadFile 上传文件到 MinIO
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BASE_URL } from '@/utils/request'
|
||||||
|
import { getToken } from '@/utils/storage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
* @param {string} filePath - 本地文件路径(如从 uni.chooseImage 获取)
|
||||||
|
* @returns {Promise<Object>} 返回 { url: '...' }
|
||||||
|
*/
|
||||||
|
const uploadFile = (filePath) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const token = getToken()
|
||||||
|
uni.uploadFile({
|
||||||
|
url: `${BASE_URL}/api/v1/upload`,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
header: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(res.data)
|
||||||
|
if (data.code === 0) {
|
||||||
|
resolve(data)
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: data.message || '上传失败', icon: 'none' })
|
||||||
|
reject(data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '响应解析失败', icon: 'none' })
|
||||||
|
reject({ code: -1, message: '响应解析失败' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
reject({ code: res.statusCode, message: '上传失败' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
uni.showToast({ title: '网络异常', icon: 'none' })
|
||||||
|
reject({ code: -1, message: err.errMsg || '网络异常' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
uploadFile
|
||||||
|
}
|
||||||
119
frontend/src/api/group.js
Normal file
119
frontend/src/api/group.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 群聊模块 API
|
||||||
|
*
|
||||||
|
* 对应后端路由:/api/v1/groups/*
|
||||||
|
* 群消息发送/撤回通过 WebSocket 进行,此处仅包含 REST API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { get, post, put, del } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 创建群聊 */
|
||||||
|
const createGroup = (name, memberIds, avatar = '') => {
|
||||||
|
return post('/api/v1/groups', { name, member_ids: memberIds, avatar })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取群详情 */
|
||||||
|
const getGroupDetail = (groupId) => {
|
||||||
|
return get(`/api/v1/groups/${groupId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新群信息(名称/头像/公告/可搜索性) */
|
||||||
|
const updateGroup = (groupId, data) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解散群聊 */
|
||||||
|
const dissolveGroup = (groupId) => {
|
||||||
|
return del(`/api/v1/groups/${groupId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取群成员列表 */
|
||||||
|
const getMembers = (groupId) => {
|
||||||
|
return get(`/api/v1/groups/${groupId}/members`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邀请用户入群 */
|
||||||
|
const inviteMembers = (groupId, userIds) => {
|
||||||
|
return post(`/api/v1/groups/${groupId}/members`, { user_ids: userIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 踢出群成员 */
|
||||||
|
const kickMember = (groupId, userId) => {
|
||||||
|
return del(`/api/v1/groups/${groupId}/members/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出群聊 */
|
||||||
|
const leaveGroup = (groupId) => {
|
||||||
|
return del(`/api/v1/groups/${groupId}/members/me`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转让群主 */
|
||||||
|
const transferOwner = (groupId, newOwnerId) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}/transfer`, { new_owner_id: newOwnerId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/取消管理员 */
|
||||||
|
const setMemberRole = (groupId, userId, role) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}/members/${userId}/role`, { role })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁言/解除禁言成员 */
|
||||||
|
const muteMember = (groupId, userId, isMuted) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}/members/${userId}/mute`, { is_muted: isMuted })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/取消全体禁言 */
|
||||||
|
const setAllMuted = (groupId, isMuted) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}/mute`, { is_muted: isMuted })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改群内昵称 */
|
||||||
|
const updateNickname = (groupId, nickname) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}/members/me/nickname`, { nickname })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交入群申请 */
|
||||||
|
const submitJoinRequest = (groupId, message = '') => {
|
||||||
|
return post(`/api/v1/groups/${groupId}/join-requests`, { message })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取入群申请列表(群主/管理员) */
|
||||||
|
const getJoinRequests = (groupId) => {
|
||||||
|
return get(`/api/v1/groups/${groupId}/join-requests`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批入群申请 */
|
||||||
|
const reviewJoinRequest = (groupId, requestId, action) => {
|
||||||
|
return put(`/api/v1/groups/${groupId}/join-requests/${requestId}`, { action })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索公开群 */
|
||||||
|
const searchGroups = (keyword, page = 1, pageSize = 20) => {
|
||||||
|
return get('/api/v1/groups/search', { keyword, page, page_size: pageSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/取消免打扰 */
|
||||||
|
const setDoNotDisturb = (conversationId, isDoNotDisturb) => {
|
||||||
|
return put(`/api/v1/im/conversations/${conversationId}/dnd`, { is_do_not_disturb: isDoNotDisturb })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createGroup,
|
||||||
|
getGroupDetail,
|
||||||
|
updateGroup,
|
||||||
|
dissolveGroup,
|
||||||
|
getMembers,
|
||||||
|
inviteMembers,
|
||||||
|
kickMember,
|
||||||
|
leaveGroup,
|
||||||
|
transferOwner,
|
||||||
|
setMemberRole,
|
||||||
|
muteMember,
|
||||||
|
setAllMuted,
|
||||||
|
updateNickname,
|
||||||
|
submitJoinRequest,
|
||||||
|
getJoinRequests,
|
||||||
|
reviewJoinRequest,
|
||||||
|
searchGroups,
|
||||||
|
setDoNotDisturb
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ const getTotalUnread = () => {
|
|||||||
return get('/api/v1/im/unread')
|
return get('/api/v1/im/unread')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取消息已读详情(已读/未读用户列表) */
|
||||||
|
const getMessageReadDetail = (messageId, page = 1, pageSize = 50) => {
|
||||||
|
return get(`/api/v1/im/messages/${messageId}/reads`, { page, page_size: pageSize })
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getConversations,
|
getConversations,
|
||||||
getHistoryMessages,
|
getHistoryMessages,
|
||||||
@@ -53,5 +58,6 @@ export default {
|
|||||||
deleteConversation,
|
deleteConversation,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
searchMessages,
|
searchMessages,
|
||||||
getTotalUnread
|
getTotalUnread,
|
||||||
|
getMessageReadDetail
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/constants/group.js
Normal file
35
frontend/src/constants/group.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 群聊相关常量定义
|
||||||
|
*
|
||||||
|
* 与后端 constants/group.go 保持一致:
|
||||||
|
* - GroupRoleNormal = 0(普通成员)
|
||||||
|
* - GroupRoleAdmin = 1(管理员)
|
||||||
|
* - GroupRoleOwner = 2(群主)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 群成员角色(im_conversation_members.role) */
|
||||||
|
export const GROUP_ROLE = {
|
||||||
|
MEMBER: 0,
|
||||||
|
ADMIN: 1,
|
||||||
|
OWNER: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群成员角色中文映射 */
|
||||||
|
export const GROUP_ROLE_LABEL = {
|
||||||
|
[GROUP_ROLE.MEMBER]: '普通成员',
|
||||||
|
[GROUP_ROLE.ADMIN]: '管理员',
|
||||||
|
[GROUP_ROLE.OWNER]: '群主'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊状态 */
|
||||||
|
export const GROUP_STATUS = {
|
||||||
|
NORMAL: 1,
|
||||||
|
DISSOLVED: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入群申请状态 */
|
||||||
|
export const JOIN_REQUEST_STATUS = {
|
||||||
|
PENDING: 0,
|
||||||
|
APPROVED: 1,
|
||||||
|
REJECTED: 2
|
||||||
|
}
|
||||||
@@ -87,6 +87,55 @@
|
|||||||
"navigationBarTitleText": "搜索消息"
|
"navigationBarTitleText": "搜索消息"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/chat/read-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "已读详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/conversation",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "群聊",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/create",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "创建群聊"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/settings",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "群设置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/members",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "群成员"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/invite",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "邀请入群"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/join-requests",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "入群申请"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/group/search",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "搜索群聊"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/meeting/index",
|
"path": "pages/meeting/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -61,25 +61,32 @@
|
|||||||
|
|
||||||
<!-- ====== 自己消息(右侧):[状态] [气泡] [头像] ====== -->
|
<!-- ====== 自己消息(右侧):[状态] [气泡] [头像] ====== -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<view v-if="msg._sending" class="msg-status">
|
<view class="self-msg-col">
|
||||||
<uni-icons type="loop" size="16" color="#94A3B8" />
|
<view class="self-msg-row">
|
||||||
</view>
|
<view v-if="msg._sending" class="msg-status">
|
||||||
<view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
|
<uni-icons type="loop" size="16" color="#94A3B8" />
|
||||||
<uni-icons type="info-filled" size="18" color="#EF4444" />
|
</view>
|
||||||
</view>
|
<view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
|
||||||
<view
|
<uni-icons type="info-filled" size="18" color="#EF4444" />
|
||||||
class="bubble bubble-self"
|
</view>
|
||||||
:class="{ 'bubble-recalled': msg.status === 2 }"
|
<view
|
||||||
@longpress="onMsgLongPress(msg)"
|
class="bubble bubble-self"
|
||||||
>
|
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||||
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
@longpress="onMsgLongPress(msg)"
|
||||||
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
|
>
|
||||||
</view>
|
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||||
<view class="avatar-wrap">
|
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
|
||||||
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
|
</view>
|
||||||
<view v-else class="avatar-img avatar-placeholder avatar-self">
|
<view class="avatar-wrap">
|
||||||
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
|
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-img avatar-placeholder avatar-self">
|
||||||
|
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="getReadLabel(msg)" class="read-label" :class="isRead(msg) ? 'read-label-read' : 'read-label-unread'">
|
||||||
|
{{ getReadLabel(msg) }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
@@ -134,11 +141,29 @@ export default {
|
|||||||
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
|
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
|
||||||
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
|
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
|
||||||
|
|
||||||
|
const convType = ref(1)
|
||||||
|
|
||||||
const isSelf = (msg) => {
|
const isSelf = (msg) => {
|
||||||
const myId = Number(userStore.userInfo?.id) || 0
|
const myId = Number(userStore.userInfo?.id) || 0
|
||||||
return msg.sender_id === myId || msg._sending === true || msg._failed === true
|
return msg.sender_id === myId || msg._sending === true || msg._failed === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRead = (msg) => {
|
||||||
|
if (convType.value === 2) return false
|
||||||
|
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return false
|
||||||
|
const lastReadId = chatStore.readStatusMap[conversationId.value] || 0
|
||||||
|
return msg.id <= lastReadId
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadLabel = (msg) => {
|
||||||
|
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return ''
|
||||||
|
if (convType.value === 2) {
|
||||||
|
const count = chatStore.groupReadCountMap[msg.id]
|
||||||
|
return count > 0 ? `${count}人已读` : ''
|
||||||
|
}
|
||||||
|
return isRead(msg) ? '已读' : '未读'
|
||||||
|
}
|
||||||
|
|
||||||
const tryFindExistingConversation = async () => {
|
const tryFindExistingConversation = async () => {
|
||||||
try {
|
try {
|
||||||
await chatStore.fetchConversations()
|
await chatStore.fetchConversations()
|
||||||
@@ -158,6 +183,7 @@ export default {
|
|||||||
peerId.value = parseInt(query.peerId) || 0
|
peerId.value = parseInt(query.peerId) || 0
|
||||||
peerName.value = decodeURIComponent(query.peerName || '')
|
peerName.value = decodeURIComponent(query.peerName || '')
|
||||||
peerAvatar.value = decodeURIComponent(query.peerAvatar || '')
|
peerAvatar.value = decodeURIComponent(query.peerAvatar || '')
|
||||||
|
convType.value = parseInt(query.convType) || 1
|
||||||
|
|
||||||
chatStore.initWsListeners()
|
chatStore.initWsListeners()
|
||||||
|
|
||||||
@@ -265,9 +291,10 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
peerName, peerAvatar, selfAvatar, selfName,
|
peerName, peerAvatar, selfAvatar, selfName,
|
||||||
inputText, scrollToId, loadingMore,
|
inputText, scrollToId, loadingMore, convType,
|
||||||
messages, hasMore, isTyping,
|
messages, hasMore, isTyping,
|
||||||
isSelf, onSend, onInputChange, onLoadMore,
|
isSelf, isRead, getReadLabel,
|
||||||
|
onSend, onInputChange, onLoadMore,
|
||||||
onMsgLongPress, onResend, goBack, goToSettings
|
onMsgLongPress, onResend, goBack, goToSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,6 +403,25 @@ export default {
|
|||||||
.msg-text-self { color: #FFFFFF; }
|
.msg-text-self { color: #FFFFFF; }
|
||||||
.recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; }
|
.recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; }
|
||||||
|
|
||||||
|
/* ===== 自己消息布局(含已读标记) ===== */
|
||||||
|
.self-msg-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.self-msg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.read-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
margin-right: 88rpx;
|
||||||
|
}
|
||||||
|
.read-label-read { color: #2563EB; }
|
||||||
|
.read-label-unread { color: #94A3B8; }
|
||||||
|
|
||||||
/* ===== 发送状态 ===== */
|
/* ===== 发送状态 ===== */
|
||||||
.msg-status {
|
.msg-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,6 +15,22 @@
|
|||||||
<view class="action-btn" @tap="goToSearch">
|
<view class="action-btn" @tap="goToSearch">
|
||||||
<uni-icons type="search" size="20" color="#475569" />
|
<uni-icons type="search" size="20" color="#475569" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="action-btn" @tap="goToCreateGroup">
|
||||||
|
<uni-icons type="plusempty" size="20" color="#475569" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab 筛选栏 -->
|
||||||
|
<view class="tab-filter">
|
||||||
|
<view class="tab-item" :class="{ 'tab-active': activeTab === 'all' }" @tap="activeTab = 'all'">
|
||||||
|
<text class="tab-text">全部</text>
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ 'tab-active': activeTab === 'private' }" @tap="activeTab = 'private'">
|
||||||
|
<text class="tab-text">单聊</text>
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ 'tab-active': activeTab === 'group' }" @tap="activeTab = 'group'">
|
||||||
|
<text class="tab-text">群聊</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -47,7 +63,7 @@
|
|||||||
<view v-else class="conv-avatar conv-avatar-placeholder">
|
<view v-else class="conv-avatar conv-avatar-placeholder">
|
||||||
<text class="avatar-text">{{ (conv.peer_nickname || '?')[0] }}</text>
|
<text class="avatar-text">{{ (conv.peer_nickname || '?')[0] }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="conv.unread_count > 0" class="conv-badge">
|
<view v-if="conv.unread_count > 0" class="conv-badge" :class="{ 'conv-badge-mute': conv.is_do_not_disturb }">
|
||||||
<text class="conv-badge-text">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</text>
|
<text class="conv-badge-text">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -60,9 +76,13 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="conv-bottom">
|
<view class="conv-bottom">
|
||||||
<text v-if="isTyping(conv.id)" class="conv-typing">对方正在输入...</text>
|
<text v-if="isTyping(conv.id)" class="conv-typing">对方正在输入...</text>
|
||||||
<text v-else class="conv-preview" :class="{ 'conv-preview-unread': conv.unread_count > 0 }">
|
<view v-else class="conv-preview-row">
|
||||||
{{ conv.last_msg_content || ' ' }}
|
<text v-if="conv.at_me_count > 0" class="conv-at-tag">[{{ conv.at_me_count }}条] @了我</text>
|
||||||
</text>
|
<text v-if="conv.is_do_not_disturb" class="conv-dnd-icon">🔕</text>
|
||||||
|
<text class="conv-preview" :class="{ 'conv-preview-unread': conv.unread_count > 0 }">
|
||||||
|
{{ conv.last_msg_content || ' ' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
<view v-if="conv.is_pinned" class="conv-pin-tag">
|
<view v-if="conv.is_pinned" class="conv-pin-tag">
|
||||||
<uni-icons type="top" size="14" color="#94A3B8" />
|
<uni-icons type="top" size="14" color="#94A3B8" />
|
||||||
</view>
|
</view>
|
||||||
@@ -87,8 +107,14 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const activeTab = ref('all')
|
||||||
|
|
||||||
const conversations = computed(() => chatStore.sortedConversations)
|
const conversations = computed(() => {
|
||||||
|
const all = chatStore.sortedConversations
|
||||||
|
if (activeTab.value === 'private') return all.filter(c => c.type === 1)
|
||||||
|
if (activeTab.value === 'group') return all.filter(c => c.type === 2)
|
||||||
|
return all
|
||||||
|
})
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -105,15 +131,25 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openChat = (conv) => {
|
const openChat = (conv) => {
|
||||||
uni.navigateTo({
|
if (conv.type === 2) {
|
||||||
url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}`
|
uni.navigateTo({
|
||||||
})
|
url: `/pages/group/conversation?conversationId=${conv.id}&groupId=${conv.group_id || 0}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}&convType=1`
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToSearch = () => {
|
const goToSearch = () => {
|
||||||
uni.navigateTo({ url: '/pages/chat/search' })
|
uni.navigateTo({ url: '/pages/chat/search' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToCreateGroup = () => {
|
||||||
|
uni.navigateTo({ url: '/pages/group/create' })
|
||||||
|
}
|
||||||
|
|
||||||
const isTyping = (convId) => {
|
const isTyping = (convId) => {
|
||||||
return chatStore.typingMap[convId] || false
|
return chatStore.typingMap[convId] || false
|
||||||
}
|
}
|
||||||
@@ -160,9 +196,11 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
|
activeTab,
|
||||||
conversations,
|
conversations,
|
||||||
openChat,
|
openChat,
|
||||||
goToSearch,
|
goToSearch,
|
||||||
|
goToCreateGroup,
|
||||||
isTyping,
|
isTyping,
|
||||||
formatTime,
|
formatTime,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
@@ -215,9 +253,25 @@ export default {
|
|||||||
background-color: #E2E8F0;
|
background-color: #E2E8F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab 筛选栏 */
|
||||||
|
.tab-filter {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab-active { border-bottom: 4rpx solid #2563EB; }
|
||||||
|
.tab-text { font-size: 26rpx; color: #475569; }
|
||||||
|
.tab-active .tab-text { color: #2563EB; font-weight: 600; }
|
||||||
|
|
||||||
/* 会话列表 */
|
/* 会话列表 */
|
||||||
.conv-list {
|
.conv-list {
|
||||||
height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx);
|
height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx - 72rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conv-item {
|
.conv-item {
|
||||||
@@ -336,6 +390,31 @@ export default {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conv-preview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-at-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #EF4444;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-dnd-icon {
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-badge-mute {
|
||||||
|
background-color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
.conv-typing {
|
.conv-typing {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #2563EB;
|
color: #2563EB;
|
||||||
|
|||||||
173
frontend/src/pages/chat/read-detail.vue
Normal file
173
frontend/src/pages/chat/read-detail.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!--
|
||||||
|
已读详情页
|
||||||
|
|
||||||
|
展示群消息的已读/未读用户列表
|
||||||
|
Tab 切换:已读 / 未读
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<view class="tab-bar">
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'tab-active': activeTab === 'read' }"
|
||||||
|
@tap="activeTab = 'read'"
|
||||||
|
>
|
||||||
|
<text class="tab-text">已读 ({{ readCount }})</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'tab-active': activeTab === 'unread' }"
|
||||||
|
@tap="activeTab = 'unread'"
|
||||||
|
>
|
||||||
|
<text class="tab-text">未读 ({{ unreadCount }})</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 用户列表 -->
|
||||||
|
<scroll-view scroll-y class="user-list">
|
||||||
|
<view v-if="loading" class="empty-state">
|
||||||
|
<text class="empty-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="displayList.length === 0" class="empty-state">
|
||||||
|
<text class="empty-text">暂无数据</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="user in displayList"
|
||||||
|
:key="user.user_id"
|
||||||
|
class="user-item"
|
||||||
|
>
|
||||||
|
<view class="user-avatar-wrap">
|
||||||
|
<image v-if="user.user_avatar" class="user-avatar" :src="user.user_avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="user-avatar user-avatar-placeholder">
|
||||||
|
<text class="avatar-char">{{ (user.user_nickname || '?')[0] }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="user-name">{{ user.user_nickname || '未知用户' }}</text>
|
||||||
|
<text v-if="user.read_at && activeTab === 'read'" class="user-time">{{ user.read_at }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import imApi from '@/api/im'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ReadDetail',
|
||||||
|
setup() {
|
||||||
|
const messageId = ref(0)
|
||||||
|
const activeTab = ref('read')
|
||||||
|
const loading = ref(false)
|
||||||
|
const readList = ref([])
|
||||||
|
const unreadList = ref([])
|
||||||
|
const readCount = ref(0)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
|
||||||
|
const unreadCount = computed(() => totalCount.value - readCount.value)
|
||||||
|
|
||||||
|
const displayList = computed(() => {
|
||||||
|
return activeTab.value === 'read' ? readList.value : unreadList.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchReadDetail = async () => {
|
||||||
|
if (!messageId.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await imApi.getMessageReadDetail(messageId.value)
|
||||||
|
if (res.data) {
|
||||||
|
readList.value = res.data.read_list || []
|
||||||
|
unreadList.value = res.data.unread_list || []
|
||||||
|
readCount.value = res.data.read_count || 0
|
||||||
|
totalCount.value = res.data.total_count || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ReadDetail] 获取已读详情失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '获取已读详情失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
messageId.value = parseInt(query.messageId) || 0
|
||||||
|
fetchReadDetail()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab, loading, readCount, unreadCount,
|
||||||
|
displayList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab 切换 */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab-active {
|
||||||
|
border-bottom: 4rpx solid #2563EB;
|
||||||
|
}
|
||||||
|
.tab-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.tab-active .tab-text {
|
||||||
|
color: #2563EB;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户列表 */
|
||||||
|
.user-list {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
.user-avatar-wrap { flex-shrink: 0; margin-right: 20rpx; }
|
||||||
|
.user-avatar {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
.user-avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #2563EB;
|
||||||
|
}
|
||||||
|
.avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; }
|
||||||
|
.user-info { flex: 1; }
|
||||||
|
.user-name { font-size: 28rpx; color: #1E293B; }
|
||||||
|
.user-time { display: block; font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; }
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state { padding: 80rpx 0; text-align: center; }
|
||||||
|
.empty-text { font-size: 28rpx; color: #94A3B8; }
|
||||||
|
</style>
|
||||||
736
frontend/src/pages/group/conversation.vue
Normal file
736
frontend/src/pages/group/conversation.vue
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
<!--
|
||||||
|
群聊对话页
|
||||||
|
|
||||||
|
布局规则:
|
||||||
|
- 自己的消息:靠右对齐,顺序 [状态图标] [蓝色气泡] [自己头像]
|
||||||
|
- 他人的消息:靠左对齐,顺序 [头像] [昵称 + 白色气泡]
|
||||||
|
- 系统消息(type === 10):居中灰色文字,无头像无气泡
|
||||||
|
- 导航栏:返回 / 群名称 / 群设置入口
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-left" @tap="goBack">
|
||||||
|
<uni-icons type="back" size="20" color="#1E293B" />
|
||||||
|
</view>
|
||||||
|
<view class="nav-center">
|
||||||
|
<text class="nav-title">{{ groupName || '群聊' }}</text>
|
||||||
|
<text v-if="memberCount" class="nav-subtitle">({{ memberCount }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="nav-right" @tap="goToSettings">
|
||||||
|
<uni-icons type="more-filled" size="20" color="#475569" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
class="msg-list"
|
||||||
|
:scroll-into-view="scrollToId"
|
||||||
|
:scroll-with-animation="true"
|
||||||
|
@scrolltoupper="onLoadMore"
|
||||||
|
>
|
||||||
|
<view v-if="hasMore" class="load-more" @tap="onLoadMore">
|
||||||
|
<text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.client_msg_id || msg.id"
|
||||||
|
:id="'msg-' + (msg.id || msg.client_msg_id)"
|
||||||
|
>
|
||||||
|
<!-- ====== 系统消息(居中灰色文字) ====== -->
|
||||||
|
<view v-if="msg.type === 10 || msg.sender_id === 0" class="msg-row-system">
|
||||||
|
<text class="system-text">{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ====== 普通消息 ====== -->
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="msg-row"
|
||||||
|
:class="isSelf(msg) ? 'msg-row-self' : 'msg-row-other'"
|
||||||
|
>
|
||||||
|
<!-- 他人消息(左侧):[头像] [昵称 + 气泡] -->
|
||||||
|
<template v-if="!isSelf(msg)">
|
||||||
|
<view class="avatar-wrap">
|
||||||
|
<image
|
||||||
|
v-if="getMemberAvatar(msg.sender_id)"
|
||||||
|
class="avatar-img"
|
||||||
|
:src="getMemberAvatar(msg.sender_id)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="avatar-img avatar-placeholder avatar-peer">
|
||||||
|
<text class="avatar-char">{{ getMemberName(msg.sender_id)[0] || '?' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="bubble-col">
|
||||||
|
<text class="sender-name">{{ getMemberName(msg.sender_id) }}</text>
|
||||||
|
<view
|
||||||
|
class="bubble bubble-other"
|
||||||
|
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||||
|
@longpress="onMsgLongPress(msg)"
|
||||||
|
>
|
||||||
|
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||||
|
<text v-else class="msg-text">{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 自己消息(右侧):[状态] [气泡] [头像] -->
|
||||||
|
<template v-else>
|
||||||
|
<view class="self-msg-col">
|
||||||
|
<view class="self-msg-row">
|
||||||
|
<view v-if="msg._sending" class="msg-status">
|
||||||
|
<uni-icons type="loop" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
<view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
|
||||||
|
<uni-icons type="info-filled" size="18" color="#EF4444" />
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="bubble bubble-self"
|
||||||
|
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||||
|
@longpress="onMsgLongPress(msg)"
|
||||||
|
>
|
||||||
|
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||||
|
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="avatar-wrap">
|
||||||
|
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-img avatar-placeholder avatar-self">
|
||||||
|
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 群聊已读计数 -->
|
||||||
|
<text
|
||||||
|
v-if="getReadLabel(msg)"
|
||||||
|
class="read-label"
|
||||||
|
@tap="goToReadDetail(msg)"
|
||||||
|
>
|
||||||
|
{{ getReadLabel(msg) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view id="msg-bottom" style="height: 2rpx;" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- @成员选择器 -->
|
||||||
|
<view v-if="showAtPicker" class="at-overlay" @tap="showAtPicker = false">
|
||||||
|
<view class="at-panel" @tap.stop>
|
||||||
|
<view class="at-header">
|
||||||
|
<text class="at-header-text">选择成员</text>
|
||||||
|
<view class="at-close" @tap="showAtPicker = false">
|
||||||
|
<uni-icons type="close" size="18" color="#475569" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class="at-member-list">
|
||||||
|
<view class="at-member-item" @tap="onSelectAtMember(null)">
|
||||||
|
<view class="avatar-img avatar-placeholder avatar-all">
|
||||||
|
<text class="avatar-char">@</text>
|
||||||
|
</view>
|
||||||
|
<text class="at-member-name">所有人</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="member in atMemberList"
|
||||||
|
:key="member.user_id"
|
||||||
|
class="at-member-item"
|
||||||
|
@tap="onSelectAtMember(member)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="member.avatar"
|
||||||
|
class="avatar-img at-avatar"
|
||||||
|
:src="member.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="avatar-img avatar-placeholder avatar-peer at-avatar">
|
||||||
|
<text class="avatar-char">{{ (member.nickname || member.username || '?')[0] }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="at-member-name">{{ member.nickname || member.username }}</text>
|
||||||
|
<text v-if="member.role === GROUP_ROLE.OWNER" class="at-role-tag">群主</text>
|
||||||
|
<text v-else-if="member.role === GROUP_ROLE.ADMIN" class="at-role-tag">管理</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 输入栏 -->
|
||||||
|
<view v-if="isMuted" class="input-bar input-bar-muted">
|
||||||
|
<text class="muted-tip">{{ isAllMuted ? '当前群聊已开启全体禁言' : '你已被禁言,无法发送消息' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="input-bar">
|
||||||
|
<view class="input-wrap">
|
||||||
|
<input
|
||||||
|
class="msg-input"
|
||||||
|
v-model="inputText"
|
||||||
|
placeholder="输入消息..."
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
confirm-type="send"
|
||||||
|
@confirm="onSend"
|
||||||
|
@input="onInputChange"
|
||||||
|
:adjust-position="true"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() }" @tap="onSend">
|
||||||
|
<uni-icons type="paperplane" size="22" color="#FFFFFF" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
|
import { useChatStore } from '@/store/chat'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { GROUP_ROLE } from '@/constants/group'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupConversation',
|
||||||
|
setup() {
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const conversationId = ref(0)
|
||||||
|
const groupId = ref(0)
|
||||||
|
const groupName = ref('')
|
||||||
|
const groupAvatar = ref('')
|
||||||
|
const inputText = ref('')
|
||||||
|
const scrollToId = ref('')
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const showAtPicker = ref(false)
|
||||||
|
const atUserIds = ref([])
|
||||||
|
|
||||||
|
const messages = computed(() => chatStore.currentMessages)
|
||||||
|
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
|
||||||
|
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
|
||||||
|
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
|
||||||
|
const myId = computed(() => Number(userStore.userInfo?.id) || 0)
|
||||||
|
const memberCount = computed(() => groupStore.currentMembers.length || 0)
|
||||||
|
|
||||||
|
/** 构建成员 ID → 信息的映射,用于显示发送者昵称 / 头像 */
|
||||||
|
const memberMap = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const m of groupStore.currentMembers) {
|
||||||
|
map[m.user_id] = m
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @选择器中排除自己的成员列表 */
|
||||||
|
const atMemberList = computed(() => {
|
||||||
|
return groupStore.currentMembers.filter(m => m.user_id !== myId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前用户在群中的角色(用于判断撤回权限) */
|
||||||
|
const myRole = computed(() => {
|
||||||
|
const me = memberMap.value[myId.value]
|
||||||
|
return me ? me.role : GROUP_ROLE.MEMBER
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前群是否全体禁言 */
|
||||||
|
const isAllMuted = computed(() => {
|
||||||
|
return groupStore.currentGroup?.is_all_muted === true
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前用户是否被禁言(个人禁言或全体禁言且非管理员/群主) */
|
||||||
|
const isMuted = computed(() => {
|
||||||
|
const me = memberMap.value[myId.value]
|
||||||
|
if (!me) return false
|
||||||
|
if (me.is_muted) return true
|
||||||
|
if (isAllMuted.value && me.role === GROUP_ROLE.MEMBER) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelf = (msg) => {
|
||||||
|
return msg.sender_id === myId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMemberName = (userId) => {
|
||||||
|
const member = memberMap.value[userId]
|
||||||
|
if (member) return member.nickname || member.username || '未知'
|
||||||
|
return '未知成员'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMemberAvatar = (userId) => {
|
||||||
|
const member = memberMap.value[userId]
|
||||||
|
return member ? member.avatar : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊已读标签:仅自己的消息显示 "X人已读" */
|
||||||
|
const getReadLabel = (msg) => {
|
||||||
|
if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return ''
|
||||||
|
const count = chatStore.groupReadCountMap[msg.id]
|
||||||
|
return count > 0 ? `${count}人已读` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
conversationId.value = parseInt(query.conversationId) || 0
|
||||||
|
groupId.value = parseInt(query.groupId) || 0
|
||||||
|
groupName.value = decodeURIComponent(query.peerName || '')
|
||||||
|
groupAvatar.value = decodeURIComponent(query.peerAvatar || '')
|
||||||
|
|
||||||
|
chatStore.initWsListeners()
|
||||||
|
groupStore.initWsListeners()
|
||||||
|
|
||||||
|
if (conversationId.value) {
|
||||||
|
chatStore.setCurrentConversation(conversationId.value)
|
||||||
|
loadInitialMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId.value) {
|
||||||
|
groupStore.fetchMembers(groupId.value)
|
||||||
|
groupStore.fetchGroupDetail(groupId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
chatStore.setCurrentConversation(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => chatStore.currentConversationId, (newId) => {
|
||||||
|
if (newId && newId !== conversationId.value) {
|
||||||
|
conversationId.value = newId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 消息加载 ====================
|
||||||
|
|
||||||
|
const loadInitialMessages = async () => {
|
||||||
|
if (!chatStore.messagesMap[conversationId.value]?.length) {
|
||||||
|
await chatStore.loadHistoryMessages(conversationId.value)
|
||||||
|
}
|
||||||
|
scrollToBottom()
|
||||||
|
markVisibleMessagesRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToId.value = ''
|
||||||
|
nextTick(() => { scrollToId.value = 'msg-bottom' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记当前可见消息为已读 */
|
||||||
|
const markVisibleMessagesRead = () => {
|
||||||
|
const msgs = chatStore.messagesMap[conversationId.value] || []
|
||||||
|
const messageIds = msgs.filter(m => m.id && m.sender_id !== myId.value).map(m => m.id)
|
||||||
|
if (messageIds.length > 0) {
|
||||||
|
chatStore.markGroupRead(conversationId.value, messageIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 消息发送 ====================
|
||||||
|
|
||||||
|
const onSend = () => {
|
||||||
|
const content = inputText.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
chatStore.sendMessage({
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
content,
|
||||||
|
type: 1,
|
||||||
|
at_user_ids: atUserIds.value.length > 0 ? [...atUserIds.value] : undefined
|
||||||
|
})
|
||||||
|
inputText.value = ''
|
||||||
|
atUserIds.value = []
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入变化检测:输入 @ 时弹出成员选择器 */
|
||||||
|
const onInputChange = (e) => {
|
||||||
|
const val = e.detail.value || inputText.value
|
||||||
|
if (val.endsWith('@')) {
|
||||||
|
showAtPicker.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择 @成员(null 表示 @所有人) */
|
||||||
|
const onSelectAtMember = (member) => {
|
||||||
|
showAtPicker.value = false
|
||||||
|
if (member === null) {
|
||||||
|
atUserIds.value.push(0)
|
||||||
|
inputText.value = inputText.value + '所有人 '
|
||||||
|
} else {
|
||||||
|
atUserIds.value.push(member.user_id)
|
||||||
|
const name = member.nickname || member.username || ''
|
||||||
|
inputText.value = inputText.value + name + ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 加载更多 ====================
|
||||||
|
|
||||||
|
const onLoadMore = async () => {
|
||||||
|
if (loadingMore.value || !hasMore.value) return
|
||||||
|
loadingMore.value = true
|
||||||
|
try { await chatStore.loadHistoryMessages(conversationId.value) }
|
||||||
|
finally { loadingMore.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 消息撤回 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回权限判断
|
||||||
|
* - 管理员/群主:可撤回任何人消息,无时间限制
|
||||||
|
* - 普通成员:仅可撤回自己消息,2 分钟内
|
||||||
|
*/
|
||||||
|
const canRecall = (msg) => {
|
||||||
|
if (msg.status === 2 || !msg.id) return false
|
||||||
|
const isAdmin = myRole.value === GROUP_ROLE.OWNER || myRole.value === GROUP_ROLE.ADMIN
|
||||||
|
if (isAdmin) return true
|
||||||
|
if (!isSelf(msg)) return false
|
||||||
|
if (!msg.created_at) return false
|
||||||
|
return (Date.now() - new Date(msg.created_at).getTime()) < 2 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMsgLongPress = (msg) => {
|
||||||
|
if (msg.status === 2 || !canRecall(msg)) return
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: ['撤回'],
|
||||||
|
success: (res) => {
|
||||||
|
if (res.tapIndex === 0 && msg.id) chatStore.recallMessage(msg.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResend = (msg) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '是否重新发送?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
chatStore.sendMessage({
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
content: msg.content,
|
||||||
|
type: msg.type || 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 导航 ====================
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (getCurrentPages().length > 1) {
|
||||||
|
uni.navigateBack()
|
||||||
|
} else {
|
||||||
|
uni.switchTab({ url: '/pages/chat/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToSettings = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/group/settings?groupId=${groupId.value}&conversationId=${conversationId.value}&peerName=${encodeURIComponent(groupName.value)}&peerAvatar=${encodeURIComponent(groupAvatar.value)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToReadDetail = (msg) => {
|
||||||
|
if (!msg.id) return
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/chat/read-detail?messageId=${msg.id}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 监听新消息到达后标记已读 ====================
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => messages.value.length,
|
||||||
|
() => {
|
||||||
|
scrollToBottom()
|
||||||
|
markVisibleMessagesRead()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
GROUP_ROLE,
|
||||||
|
groupName, groupAvatar, selfAvatar, selfName,
|
||||||
|
inputText, scrollToId, loadingMore, memberCount,
|
||||||
|
messages, hasMore, showAtPicker, atMemberList, isMuted, isAllMuted,
|
||||||
|
isSelf, getMemberName, getMemberAvatar, getReadLabel,
|
||||||
|
onSend, onInputChange, onSelectAtMember,
|
||||||
|
onLoadMore, onMsgLongPress, onResend,
|
||||||
|
goBack, goToSettings, goToReadDetail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 导航栏 ===== */
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 88rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
padding-top: var(--status-bar-height, 44px);
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
.nav-left, .nav-right {
|
||||||
|
min-width: 88rpx;
|
||||||
|
min-height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
.nav-left:active, .nav-right:active { opacity: 0.6; }
|
||||||
|
.nav-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.nav-title { font-size: 32rpx; font-weight: 600; color: #1E293B; }
|
||||||
|
.nav-subtitle { font-size: 24rpx; color: #94A3B8; margin-left: 8rpx; }
|
||||||
|
|
||||||
|
/* ===== 消息列表 ===== */
|
||||||
|
.msg-list {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.load-more { text-align: center; padding: 16rpx 0; }
|
||||||
|
.load-more-text { font-size: 24rpx; color: #94A3B8; }
|
||||||
|
|
||||||
|
/* ===== 系统消息 ===== */
|
||||||
|
.msg-row-system {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16rpx 48rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.system-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
line-height: 36rpx;
|
||||||
|
background-color: rgba(148, 163, 184, 0.12);
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 消息行 ===== */
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.msg-row-other { justify-content: flex-start; }
|
||||||
|
.msg-row-self { justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* ===== 头像 ===== */
|
||||||
|
.avatar-wrap { flex-shrink: 0; }
|
||||||
|
.msg-row-other .avatar-wrap { margin-right: 16rpx; }
|
||||||
|
.msg-row-self .avatar-wrap { margin-left: 16rpx; }
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
.avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.avatar-peer { background-color: #2563EB; }
|
||||||
|
.avatar-self { background-color: #64748B; }
|
||||||
|
.avatar-all { background-color: #F59E0B; }
|
||||||
|
.avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ===== 发送者昵称 + 气泡列 ===== */
|
||||||
|
.bubble-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 65vw;
|
||||||
|
}
|
||||||
|
.sender-name {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 气泡 ===== */
|
||||||
|
.bubble {
|
||||||
|
max-width: 65vw;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.bubble-self {
|
||||||
|
background-color: #2563EB;
|
||||||
|
border-bottom-right-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.bubble-other {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom-left-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.bubble-recalled {
|
||||||
|
background-color: transparent !important;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-text { font-size: 30rpx; line-height: 42rpx; color: #1E293B; }
|
||||||
|
.msg-text-self { color: #FFFFFF; }
|
||||||
|
.recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; }
|
||||||
|
|
||||||
|
/* ===== 自己消息布局(含已读标记) ===== */
|
||||||
|
.self-msg-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.self-msg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.read-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
margin-right: 88rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 发送状态 ===== */
|
||||||
|
.msg-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 8rpx;
|
||||||
|
}
|
||||||
|
.msg-status-tap {
|
||||||
|
min-width: 44rpx;
|
||||||
|
min-height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== @成员选择器 ===== */
|
||||||
|
.at-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.at-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.at-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
.at-header-text { font-size: 30rpx; font-weight: 600; color: #1E293B; }
|
||||||
|
.at-close {
|
||||||
|
min-width: 44rpx;
|
||||||
|
min-height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.at-member-list {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 50vh;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
.at-member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
.at-member-item:active { background-color: #F8FAFC; }
|
||||||
|
.at-avatar { width: 64rpx; height: 64rpx; border-radius: 16rpx; }
|
||||||
|
.at-member-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
}
|
||||||
|
.at-role-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
background-color: rgba(37, 99, 235, 0.08);
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 输入栏 ===== */
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
padding-bottom: calc(16rpx + env(safe-area-inset-bottom, 0));
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-top: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
.input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.msg-input { flex: 1; font-size: 28rpx; color: #1E293B; }
|
||||||
|
.send-btn {
|
||||||
|
min-width: 72rpx;
|
||||||
|
min-height: 72rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #CBD5E1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
.send-btn-active { background-color: #2563EB; }
|
||||||
|
.send-btn:active { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ===== 禁言状态 ===== */
|
||||||
|
.input-bar-muted {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.muted-tip {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
576
frontend/src/pages/group/create.vue
Normal file
576
frontend/src/pages/group/create.vue
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
<!--
|
||||||
|
创建群聊页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
|
||||||
|
ui-ux-pro-max 规范:防重复提交 / loading 状态 / 触摸目标 >= 88rpx
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 群名称输入区 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">群名称</text>
|
||||||
|
</view>
|
||||||
|
<view class="name-input-wrap">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
v-model="groupName"
|
||||||
|
placeholder="请输入群聊名称"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
maxlength="30"
|
||||||
|
/>
|
||||||
|
<text class="name-count">{{ groupName.length }}/30</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 已选好友展示区 -->
|
||||||
|
<view v-if="selectedList.length > 0" class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">已选好友({{ selectedList.length }})</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="selected-scroll" scroll-x>
|
||||||
|
<view class="selected-list">
|
||||||
|
<view
|
||||||
|
v-for="friend in selectedList"
|
||||||
|
:key="friend.user_id"
|
||||||
|
class="selected-item"
|
||||||
|
@tap="toggleSelect(friend)"
|
||||||
|
>
|
||||||
|
<view class="selected-avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
|
||||||
|
<text class="selected-avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
|
||||||
|
<view class="selected-remove">
|
||||||
|
<text class="selected-remove-text">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="selected-name">{{ friend.remark || friend.nickname || friend.username }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索好友 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">选择好友</text>
|
||||||
|
</view>
|
||||||
|
<view class="search-bar">
|
||||||
|
<uni-icons type="search" size="18" color="#94A3B8" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索好友昵称"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
/>
|
||||||
|
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
|
||||||
|
<uni-icons type="clear" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 好友列表 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 5" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-check"></view>
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line skeleton-line--name"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="filteredFriends.length > 0" class="friend-list">
|
||||||
|
<view
|
||||||
|
v-for="friend in filteredFriends"
|
||||||
|
:key="friend.user_id"
|
||||||
|
class="friend-item"
|
||||||
|
@tap="toggleSelect(friend)"
|
||||||
|
>
|
||||||
|
<view class="checkbox" :class="{ 'checkbox--checked': isSelected(friend.user_id) }">
|
||||||
|
<text v-if="isSelected(friend.user_id)" class="checkbox-icon">✓</text>
|
||||||
|
</view>
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="friend-info">
|
||||||
|
<text class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</text>
|
||||||
|
<text v-if="friend.remark" class="friend-account">{{ friend.nickname || friend.username }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '暂无好友' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部占位 -->
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
|
||||||
|
<!-- 底部固定创建按钮 -->
|
||||||
|
<view class="bottom-bar">
|
||||||
|
<view
|
||||||
|
class="create-btn"
|
||||||
|
:class="{ 'create-btn--disabled': !canCreate || submitting }"
|
||||||
|
@tap="handleCreate"
|
||||||
|
>
|
||||||
|
<text class="create-btn-text">
|
||||||
|
{{ submitting ? '创建中...' : `创建群聊${selectedList.length > 0 ? '(' + selectedList.length + '人)' : ''}` }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { getAvatarColor, getInitial } from '@/utils/avatar'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupCreate',
|
||||||
|
setup() {
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const groupName = ref('')
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const selectedIds = ref({})
|
||||||
|
const loading = ref(true)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
/** 已选中的好友列表 */
|
||||||
|
const selectedList = computed(() => {
|
||||||
|
return contactStore.friendList.filter(f => selectedIds.value[f.user_id])
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 搜索过滤后的好友列表 */
|
||||||
|
const filteredFriends = computed(() => {
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return contactStore.friendList
|
||||||
|
return contactStore.friendList.filter(f => {
|
||||||
|
const nickname = (f.nickname || '').toLowerCase()
|
||||||
|
const username = (f.username || '').toLowerCase()
|
||||||
|
const remark = (f.remark || '').toLowerCase()
|
||||||
|
return nickname.includes(kw) || username.includes(kw) || remark.includes(kw)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 是否满足创建条件:群名非空 + 至少选2人 */
|
||||||
|
const canCreate = computed(() => {
|
||||||
|
return groupName.value.trim().length > 0 && selectedList.value.length >= 2
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 判断好友是否已选中 */
|
||||||
|
const isSelected = (userId) => {
|
||||||
|
return !!selectedIds.value[userId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换好友选中状态 */
|
||||||
|
const toggleSelect = (friend) => {
|
||||||
|
const newMap = { ...selectedIds.value }
|
||||||
|
if (newMap[friend.user_id]) {
|
||||||
|
delete newMap[friend.user_id]
|
||||||
|
} else {
|
||||||
|
newMap[friend.user_id] = true
|
||||||
|
}
|
||||||
|
selectedIds.value = newMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交创建群聊 */
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate.value || submitting.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const memberIds = selectedList.value.map(f => f.user_id)
|
||||||
|
const result = await groupStore.createGroup(groupName.value.trim(), memberIds)
|
||||||
|
|
||||||
|
uni.showToast({ title: '群聊创建成功', icon: 'success' })
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (result && result.conversation_id) {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: `/pages/group/conversation?conversationId=${result.conversation_id}&groupId=${result.group_id || 0}&peerName=${encodeURIComponent(groupName.value.trim())}`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('创建群聊失败', e)
|
||||||
|
uni.showToast({
|
||||||
|
title: e?.data?.message || '创建群聊失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(async () => {
|
||||||
|
try {
|
||||||
|
await contactStore.fetchFriends()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取好友列表失败', e)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupName,
|
||||||
|
searchKeyword,
|
||||||
|
selectedList,
|
||||||
|
filteredFriends,
|
||||||
|
canCreate,
|
||||||
|
loading,
|
||||||
|
submitting,
|
||||||
|
isSelected,
|
||||||
|
toggleSelect,
|
||||||
|
handleCreate,
|
||||||
|
getAvatarColor,
|
||||||
|
getInitial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块通用 */
|
||||||
|
.section {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 20rpx 32rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 群名称输入 */
|
||||||
|
.name-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
height: 96rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-count {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已选好友区 */
|
||||||
|
.selected-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -6rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #EF4444;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-remove-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-name {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
max-width: 100rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索栏 */
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
height: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-check {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--name {
|
||||||
|
width: 40%;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 好友列表 */
|
||||||
|
.friend-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-item:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 复选框 */
|
||||||
|
.checkbox {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
border: 2rpx solid #CBD5E1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox--checked {
|
||||||
|
background-color: #2563EB;
|
||||||
|
border-color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像 */
|
||||||
|
.avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 好友信息 */
|
||||||
|
.friend-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-account {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部占位 */
|
||||||
|
.bottom-spacer {
|
||||||
|
height: 140rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部固定按钮 */
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #2563EB;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn--disabled {
|
||||||
|
background-color: #CBD5E1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
541
frontend/src/pages/group/invite.vue
Normal file
541
frontend/src/pages/group/invite.vue
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
<!--
|
||||||
|
邀请入群页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
|
||||||
|
功能:好友列表多选(排除已在群内的成员)、搜索过滤、已选展示、确认邀请
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 已选好友展示区 -->
|
||||||
|
<view v-if="selectedList.length > 0" class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">已选好友({{ selectedList.length }})</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="selected-scroll" scroll-x>
|
||||||
|
<view class="selected-list">
|
||||||
|
<view
|
||||||
|
v-for="friend in selectedList"
|
||||||
|
:key="friend.user_id"
|
||||||
|
class="selected-item"
|
||||||
|
@tap="toggleSelect(friend)"
|
||||||
|
>
|
||||||
|
<view class="selected-avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
|
||||||
|
<text class="selected-avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
|
||||||
|
<view class="selected-remove">
|
||||||
|
<text class="selected-remove-text">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="selected-name">{{ friend.remark || friend.nickname || friend.username }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索好友 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">选择好友</text>
|
||||||
|
</view>
|
||||||
|
<view class="search-bar">
|
||||||
|
<uni-icons type="search" size="18" color="#94A3B8" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索好友昵称"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
/>
|
||||||
|
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
|
||||||
|
<uni-icons type="clear" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 好友列表 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 5" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-check"></view>
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line skeleton-line--name"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="availableFriends.length > 0" class="friend-list">
|
||||||
|
<view
|
||||||
|
v-for="friend in availableFriends"
|
||||||
|
:key="friend.user_id"
|
||||||
|
class="friend-item"
|
||||||
|
@tap="toggleSelect(friend)"
|
||||||
|
>
|
||||||
|
<view class="checkbox" :class="{ 'checkbox--checked': isSelected(friend.user_id) }">
|
||||||
|
<text v-if="isSelected(friend.user_id)" class="checkbox-icon">✓</text>
|
||||||
|
</view>
|
||||||
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(friend.nickname || friend.username) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(friend.nickname || friend.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="friend-info">
|
||||||
|
<text class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</text>
|
||||||
|
<text v-if="friend.remark" class="friend-account">{{ friend.nickname || friend.username }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-text">{{ searchKeyword ? '未找到匹配的好友' : '没有可邀请的好友' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部占位 -->
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
|
||||||
|
<!-- 底部固定邀请按钮 -->
|
||||||
|
<view class="bottom-bar">
|
||||||
|
<view
|
||||||
|
class="invite-btn"
|
||||||
|
:class="{ 'invite-btn--disabled': selectedList.length === 0 || submitting }"
|
||||||
|
@tap="handleInvite"
|
||||||
|
>
|
||||||
|
<text class="invite-btn-text">
|
||||||
|
{{ submitting ? '邀请中...' : `确认邀请${selectedList.length > 0 ? '(' + selectedList.length + '人)' : ''}` }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { getAvatarColor, getInitial } from '@/utils/avatar'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupInvite',
|
||||||
|
setup() {
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const groupId = ref(0)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const selectedIds = ref({})
|
||||||
|
const loading = ref(true)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
/** 已在群内的成员 ID 集合 */
|
||||||
|
const memberIdSet = computed(() => {
|
||||||
|
const set = new Set()
|
||||||
|
for (const m of groupStore.currentMembers) {
|
||||||
|
set.add(m.user_id)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 过滤掉已在群内的好友 + 搜索关键词过滤 */
|
||||||
|
const availableFriends = computed(() => {
|
||||||
|
const filtered = contactStore.friendList.filter(f => !memberIdSet.value.has(f.user_id))
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return filtered
|
||||||
|
return filtered.filter(f => {
|
||||||
|
const nickname = (f.nickname || '').toLowerCase()
|
||||||
|
const username = (f.username || '').toLowerCase()
|
||||||
|
const remark = (f.remark || '').toLowerCase()
|
||||||
|
return nickname.includes(kw) || username.includes(kw) || remark.includes(kw)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 已选中的好友列表 */
|
||||||
|
const selectedList = computed(() => {
|
||||||
|
return contactStore.friendList.filter(f => selectedIds.value[f.user_id])
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 判断好友是否已选中 */
|
||||||
|
const isSelected = (userId) => {
|
||||||
|
return !!selectedIds.value[userId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换好友选中状态 */
|
||||||
|
const toggleSelect = (friend) => {
|
||||||
|
const newMap = { ...selectedIds.value }
|
||||||
|
if (newMap[friend.user_id]) {
|
||||||
|
delete newMap[friend.user_id]
|
||||||
|
} else {
|
||||||
|
newMap[friend.user_id] = true
|
||||||
|
}
|
||||||
|
selectedIds.value = newMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交邀请 */
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (selectedList.value.length === 0 || submitting.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const userIds = selectedList.value.map(f => f.user_id)
|
||||||
|
await groupStore.inviteMembers(groupId.value, userIds)
|
||||||
|
|
||||||
|
uni.showToast({ title: '邀请成功', icon: 'success' })
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, 500)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('邀请入群失败', e)
|
||||||
|
uni.showToast({
|
||||||
|
title: e?.data?.message || '邀请失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
groupId.value = parseInt(query.groupId) || 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tasks = [contactStore.fetchFriends()]
|
||||||
|
if (groupId.value) {
|
||||||
|
tasks.push(groupStore.fetchMembers(groupId.value))
|
||||||
|
}
|
||||||
|
await Promise.all(tasks)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载数据失败', e)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchKeyword,
|
||||||
|
selectedList,
|
||||||
|
availableFriends,
|
||||||
|
loading,
|
||||||
|
submitting,
|
||||||
|
isSelected,
|
||||||
|
toggleSelect,
|
||||||
|
handleInvite,
|
||||||
|
getAvatarColor,
|
||||||
|
getInitial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块通用 */
|
||||||
|
.section {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 20rpx 32rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已选好友区 */
|
||||||
|
.selected-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -6rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #EF4444;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-remove-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-name {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
max-width: 100rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索栏 */
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
height: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-check {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--name {
|
||||||
|
width: 40%;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 好友列表 */
|
||||||
|
.friend-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-item:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 复选框 */
|
||||||
|
.checkbox {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
border: 2rpx solid #CBD5E1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox--checked {
|
||||||
|
background-color: #2563EB;
|
||||||
|
border-color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像 */
|
||||||
|
.avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 好友信息 */
|
||||||
|
.friend-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-account {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部占位 */
|
||||||
|
.bottom-spacer {
|
||||||
|
height: 140rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部固定按钮 */
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #2563EB;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-btn:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-btn--disabled {
|
||||||
|
background-color: #CBD5E1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-btn-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
515
frontend/src/pages/group/join-requests.vue
Normal file
515
frontend/src/pages/group/join-requests.vue
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
<!--
|
||||||
|
入群申请审批页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
|
||||||
|
功能:查看待审批/已处理的入群申请,通过或拒绝操作
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="loading" class="skeleton-list">
|
||||||
|
<view v-for="i in 4" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<view class="skeleton-line skeleton-line--name"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--msg"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-actions">
|
||||||
|
<view class="skeleton-btn"></view>
|
||||||
|
<view class="skeleton-btn"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<view v-else>
|
||||||
|
<!-- 待审批区域 -->
|
||||||
|
<view v-if="pendingList.length > 0" class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">待审批({{ pendingList.length }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="request-list">
|
||||||
|
<view
|
||||||
|
v-for="req in pendingList"
|
||||||
|
:key="req.id"
|
||||||
|
class="request-item"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="req.avatar"
|
||||||
|
class="request-avatar"
|
||||||
|
:src="req.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="request-avatar request-avatar-placeholder"
|
||||||
|
:style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }"
|
||||||
|
>
|
||||||
|
<text class="avatar-char">{{ getInitial(req.nickname || req.username) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="request-info">
|
||||||
|
<text class="request-name">{{ req.nickname || req.username }}</text>
|
||||||
|
<text class="request-msg" v-if="req.message">{{ req.message }}</text>
|
||||||
|
<text class="request-msg" v-else>申请加入群聊</text>
|
||||||
|
<text class="request-time">{{ formatTime(req.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="request-actions">
|
||||||
|
<view
|
||||||
|
class="btn btn--approve"
|
||||||
|
:class="{ 'btn--disabled': processingMap[req.id] }"
|
||||||
|
@tap="handleApprove(req)"
|
||||||
|
>
|
||||||
|
<text class="btn-text">通过</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="btn btn--reject"
|
||||||
|
:class="{ 'btn--disabled': processingMap[req.id] }"
|
||||||
|
@tap="handleReject(req)"
|
||||||
|
>
|
||||||
|
<text class="btn-text btn-text--reject">拒绝</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 已处理区域 -->
|
||||||
|
<view v-if="processedList.length > 0" class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">已处理({{ processedList.length }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="request-list">
|
||||||
|
<view
|
||||||
|
v-for="req in processedList"
|
||||||
|
:key="req.id"
|
||||||
|
class="request-item"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="req.avatar"
|
||||||
|
class="request-avatar"
|
||||||
|
:src="req.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="request-avatar request-avatar-placeholder"
|
||||||
|
:style="{ backgroundColor: getAvatarColor(req.nickname || req.username) }"
|
||||||
|
>
|
||||||
|
<text class="avatar-char">{{ getInitial(req.nickname || req.username) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="request-info">
|
||||||
|
<text class="request-name">{{ req.nickname || req.username }}</text>
|
||||||
|
<text class="request-msg" v-if="req.message">{{ req.message }}</text>
|
||||||
|
<text class="request-msg" v-else>申请加入群聊</text>
|
||||||
|
<text class="request-time">{{ formatTime(req.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="status-tag" :class="req.status === 1 ? 'status-tag--approved' : 'status-tag--rejected'">
|
||||||
|
<text class="status-tag-text" :class="req.status === 1 ? 'status-tag-text--approved' : 'status-tag-text--rejected'">
|
||||||
|
{{ req.status === 1 ? '已通过' : '已拒绝' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-if="pendingList.length === 0 && processedList.length === 0" class="empty-state">
|
||||||
|
<uni-icons type="person" size="48" color="#CBD5E1" />
|
||||||
|
<text class="empty-title">暂无入群申请</text>
|
||||||
|
<text class="empty-desc">当有人申请加入群聊时,会在这里显示</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed, reactive } from 'vue'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { getAvatarColor, getInitial } from '@/utils/avatar'
|
||||||
|
import wsService from '@/services/websocket'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupJoinRequests',
|
||||||
|
setup() {
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const groupId = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const processingMap = reactive({})
|
||||||
|
/** 本地维护的已处理申请(审批后从 store 移除但需要页面展示) */
|
||||||
|
const localProcessed = ref([])
|
||||||
|
|
||||||
|
/** 待审批列表:status === 0 */
|
||||||
|
const pendingList = computed(() => {
|
||||||
|
return groupStore.joinRequests.filter(r => r.status === 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已处理列表:合并后端返回的已处理申请 + 本地刚审批的
|
||||||
|
* 后端 fetch 可能已返回 status !== 0 的,加上本地审批后保存的
|
||||||
|
*/
|
||||||
|
const processedList = computed(() => {
|
||||||
|
const fromStore = groupStore.joinRequests.filter(r => r.status !== 0)
|
||||||
|
const processedIds = new Set(fromStore.map(r => r.id))
|
||||||
|
const fromLocal = localProcessed.value.filter(r => !processedIds.has(r.id))
|
||||||
|
return [...fromStore, ...fromLocal]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 格式化时间 */
|
||||||
|
const formatTime = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days} 天前`
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
/** 新入群申请到达时自动刷新列表 */
|
||||||
|
const _onNewJoinRequest = () => {
|
||||||
|
if (groupId.value) {
|
||||||
|
loadRequests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
groupId.value = parseInt(query.groupId) || 0
|
||||||
|
if (groupId.value) {
|
||||||
|
loadRequests()
|
||||||
|
} else {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
wsService.on('group.join.request', _onNewJoinRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
wsService.off('group.join.request', _onNewJoinRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 加载入群申请列表 */
|
||||||
|
const loadRequests = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await groupStore.fetchJoinRequests(groupId.value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取入群申请失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '获取申请列表失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 审批操作 ====================
|
||||||
|
|
||||||
|
/** 通过申请 */
|
||||||
|
const handleApprove = async (req) => {
|
||||||
|
if (processingMap[req.id]) return
|
||||||
|
processingMap[req.id] = true
|
||||||
|
try {
|
||||||
|
await groupStore.reviewJoinRequest(groupId.value, req.id, 'approve')
|
||||||
|
localProcessed.value.unshift({ ...req, status: 1 })
|
||||||
|
uni.showToast({ title: '已通过', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('审批通过失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
processingMap[req.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝申请 */
|
||||||
|
const handleReject = async (req) => {
|
||||||
|
if (processingMap[req.id]) return
|
||||||
|
processingMap[req.id] = true
|
||||||
|
try {
|
||||||
|
await groupStore.reviewJoinRequest(groupId.value, req.id, 'reject')
|
||||||
|
localProcessed.value.unshift({ ...req, status: 2 })
|
||||||
|
uni.showToast({ title: '已拒绝', icon: 'none' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('审批拒绝失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
processingMap[req.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
pendingList,
|
||||||
|
processedList,
|
||||||
|
processingMap,
|
||||||
|
formatTime,
|
||||||
|
handleApprove,
|
||||||
|
handleReject,
|
||||||
|
getAvatarColor,
|
||||||
|
getInitial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 骨架屏 ===== */
|
||||||
|
.skeleton-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--name {
|
||||||
|
width: 40%;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--msg {
|
||||||
|
width: 60%;
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 区块 ===== */
|
||||||
|
.section {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 20rpx 32rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 申请列表 ===== */
|
||||||
|
.request-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 头像 ===== */
|
||||||
|
.request-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-char {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 申请信息 ===== */
|
||||||
|
.request-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-msg {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748B;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 操作按钮 ===== */
|
||||||
|
.request-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--approve {
|
||||||
|
background-color: #22C55E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--reject {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text--reject {
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 状态标签 ===== */
|
||||||
|
.status-tag {
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--approved {
|
||||||
|
background-color: rgba(34, 197, 94, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag--rejected {
|
||||||
|
background-color: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag-text--approved {
|
||||||
|
color: #22C55E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag-text--rejected {
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 空状态 ===== */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #64748B;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 60rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
453
frontend/src/pages/group/members.vue
Normal file
453
frontend/src/pages/group/members.vue
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
<!--
|
||||||
|
群成员列表页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
|
||||||
|
功能:成员搜索、角色标识、禁言标识、管理操作(设管理员/禁言/踢人)
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view class="search-section">
|
||||||
|
<view class="search-bar">
|
||||||
|
<uni-icons type="search" size="18" color="#94A3B8" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索成员昵称"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
/>
|
||||||
|
<view v-if="searchKeyword" class="search-clear" @tap="searchKeyword = ''">
|
||||||
|
<uni-icons type="clear" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 成员数量标题 -->
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">全部成员({{ filteredMembers.length }})</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 成员列表 -->
|
||||||
|
<view v-if="filteredMembers.length > 0" class="member-list">
|
||||||
|
<view
|
||||||
|
v-for="member in filteredMembers"
|
||||||
|
:key="member.user_id"
|
||||||
|
class="member-item"
|
||||||
|
@longpress="onMemberLongPress(member)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="member.avatar"
|
||||||
|
class="member-avatar"
|
||||||
|
:src="member.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="member-avatar member-avatar-placeholder"
|
||||||
|
:style="{ backgroundColor: getAvatarColor(member.nickname || member.username) }"
|
||||||
|
>
|
||||||
|
<text class="avatar-char">{{ getInitial(member.nickname || member.username) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="member-info">
|
||||||
|
<view class="member-name-row">
|
||||||
|
<text class="member-name">{{ member.nickname || member.username }}</text>
|
||||||
|
<text v-if="member.role === GROUP_ROLE.OWNER" class="role-tag role-tag-owner">🔑 群主</text>
|
||||||
|
<text v-else-if="member.role === GROUP_ROLE.ADMIN" class="role-tag role-tag-admin">🛡 管理员</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="member.is_muted" class="muted-row">
|
||||||
|
<text class="muted-tag">已禁言</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<uni-icons
|
||||||
|
v-if="canManage(member)"
|
||||||
|
type="more-filled"
|
||||||
|
size="18"
|
||||||
|
color="#94A3B8"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-text">{{ searchKeyword ? '未找到匹配的成员' : '暂无成员' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右上角邀请按钮 -->
|
||||||
|
<view class="fab-btn" @tap="goToInvite">
|
||||||
|
<uni-icons type="plusempty" size="24" color="#FFFFFF" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { getAvatarColor, getInitial } from '@/utils/avatar'
|
||||||
|
import { GROUP_ROLE } from '@/constants/group'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupMembers',
|
||||||
|
setup() {
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const groupId = ref(0)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const currentMembers = computed(() => groupStore.currentMembers)
|
||||||
|
const myId = computed(() => Number(userStore.userInfo?.id) || 0)
|
||||||
|
|
||||||
|
/** 当前用户在群中的角色 */
|
||||||
|
const myRole = computed(() => {
|
||||||
|
const me = currentMembers.value.find(m => m.user_id === myId.value)
|
||||||
|
return me ? me.role : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 搜索过滤后的成员列表 */
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return currentMembers.value
|
||||||
|
return currentMembers.value.filter(m => {
|
||||||
|
const nickname = (m.nickname || '').toLowerCase()
|
||||||
|
const username = (m.username || '').toLowerCase()
|
||||||
|
return nickname.includes(kw) || username.includes(kw)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否可以管理某个成员
|
||||||
|
* - 群主可管理所有非群主成员
|
||||||
|
* - 管理员仅可管理普通成员
|
||||||
|
* - 普通成员无管理权限
|
||||||
|
* - 不能管理自己
|
||||||
|
*/
|
||||||
|
const canManage = (member) => {
|
||||||
|
if (member.user_id === myId.value) return false
|
||||||
|
if (myRole.value === GROUP_ROLE.OWNER) return member.role !== GROUP_ROLE.OWNER
|
||||||
|
if (myRole.value === GROUP_ROLE.ADMIN) return member.role === GROUP_ROLE.MEMBER
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
groupId.value = parseInt(query.groupId) || 0
|
||||||
|
if (groupId.value) {
|
||||||
|
groupStore.fetchMembers(groupId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 管理操作 ====================
|
||||||
|
|
||||||
|
const onMemberLongPress = (member) => {
|
||||||
|
if (!canManage(member)) return
|
||||||
|
|
||||||
|
const actions = []
|
||||||
|
|
||||||
|
if (myRole.value === GROUP_ROLE.OWNER) {
|
||||||
|
if (member.role === GROUP_ROLE.ADMIN) {
|
||||||
|
actions.push('取消管理员')
|
||||||
|
} else {
|
||||||
|
actions.push('设为管理员')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.is_muted) {
|
||||||
|
actions.push('解除禁言')
|
||||||
|
} else {
|
||||||
|
actions.push('禁言')
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push('踢出群聊')
|
||||||
|
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: actions,
|
||||||
|
success: (res) => {
|
||||||
|
const action = actions[res.tapIndex]
|
||||||
|
handleAction(action, member)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAction = (action, member) => {
|
||||||
|
switch (action) {
|
||||||
|
case '设为管理员':
|
||||||
|
confirmSetRole(member, GROUP_ROLE.ADMIN)
|
||||||
|
break
|
||||||
|
case '取消管理员':
|
||||||
|
confirmSetRole(member, GROUP_ROLE.MEMBER)
|
||||||
|
break
|
||||||
|
case '禁言':
|
||||||
|
confirmMute(member, true)
|
||||||
|
break
|
||||||
|
case '解除禁言':
|
||||||
|
confirmMute(member, false)
|
||||||
|
break
|
||||||
|
case '踢出群聊':
|
||||||
|
confirmKick(member)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSetRole = (member, role) => {
|
||||||
|
const roleName = role === GROUP_ROLE.ADMIN ? '管理员' : '普通成员'
|
||||||
|
const name = member.nickname || member.username
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认操作',
|
||||||
|
content: `确定将 ${name} 设为${roleName}吗?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.setMemberRole(groupId.value, member.user_id, role)
|
||||||
|
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMute = (member, isMuted) => {
|
||||||
|
const name = member.nickname || member.username
|
||||||
|
const tip = isMuted ? `确定禁言 ${name} 吗?` : `确定解除 ${name} 的禁言吗?`
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认操作',
|
||||||
|
content: tip,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.muteMember(groupId.value, member.user_id, isMuted)
|
||||||
|
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmKick = (member) => {
|
||||||
|
const name = member.nickname || member.username
|
||||||
|
uni.showModal({
|
||||||
|
title: '踢出群聊',
|
||||||
|
content: `确定将 ${name} 踢出群聊吗?`,
|
||||||
|
confirmColor: '#EF4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.kickMember(groupId.value, member.user_id)
|
||||||
|
uni.showToast({ title: '已踢出', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 导航 ====================
|
||||||
|
|
||||||
|
const goToInvite = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/group/invite?groupId=${groupId.value}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
GROUP_ROLE,
|
||||||
|
searchKeyword,
|
||||||
|
filteredMembers,
|
||||||
|
canManage,
|
||||||
|
onMemberLongPress,
|
||||||
|
goToInvite,
|
||||||
|
getAvatarColor,
|
||||||
|
getInitial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 搜索栏 ===== */
|
||||||
|
.search-section {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
height: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 区块标题 ===== */
|
||||||
|
.section-header {
|
||||||
|
padding: 20rpx 32rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 成员列表 ===== */
|
||||||
|
.member-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 头像 ===== */
|
||||||
|
.member-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-char {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 成员信息 ===== */
|
||||||
|
.member-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 角色标签 ===== */
|
||||||
|
.role-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag-owner {
|
||||||
|
color: #D97706;
|
||||||
|
background-color: rgba(217, 119, 6, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag-admin {
|
||||||
|
color: #2563EB;
|
||||||
|
background-color: rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 禁言标签 ===== */
|
||||||
|
.muted-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #EF4444;
|
||||||
|
background-color: rgba(239, 68, 68, 0.08);
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 空状态 ===== */
|
||||||
|
.empty-state {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 悬浮按钮 ===== */
|
||||||
|
.fab-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 32rpx;
|
||||||
|
bottom: calc(64rpx + env(safe-area-inset-bottom));
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #2563EB;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.3);
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-btn:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
466
frontend/src/pages/group/search.vue
Normal file
466
frontend/src/pages/group/search.vue
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
<!--
|
||||||
|
搜索群聊页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
|
||||||
|
功能:搜索公开群聊、查看群信息、申请加入群聊
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<view class="search-input-wrap">
|
||||||
|
<uni-icons type="search" size="18" color="#94A3B8" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="输入群名称搜索"
|
||||||
|
placeholder-style="color: #94A3B8"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="doSearch"
|
||||||
|
/>
|
||||||
|
<view v-if="keyword" class="search-clear" @tap="keyword = ''">
|
||||||
|
<uni-icons type="clear" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="search-btn" @tap="doSearch">
|
||||||
|
<text class="search-btn-text">搜索</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索结果 -->
|
||||||
|
<view v-if="searched" class="result-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">搜索结果</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view v-if="searchLoading && page === 1" class="loading-wrap">
|
||||||
|
<text class="loading-text">搜索中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 结果列表 -->
|
||||||
|
<scroll-view
|
||||||
|
v-else-if="resultList.length > 0"
|
||||||
|
class="result-scroll"
|
||||||
|
scroll-y
|
||||||
|
@scrolltolower="loadMore"
|
||||||
|
>
|
||||||
|
<view class="result-list">
|
||||||
|
<view
|
||||||
|
v-for="group in resultList"
|
||||||
|
:key="group.id"
|
||||||
|
class="group-item"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="group.avatar"
|
||||||
|
class="group-avatar"
|
||||||
|
:src="group.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="group-avatar group-avatar-placeholder"
|
||||||
|
:style="{ backgroundColor: getAvatarColor(group.name) }"
|
||||||
|
>
|
||||||
|
<text class="avatar-char">{{ getInitial(group.name) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="group-info">
|
||||||
|
<text class="group-name">{{ group.name }}</text>
|
||||||
|
<text class="group-meta">{{ group.member_count || 0 }} 名成员</text>
|
||||||
|
<text v-if="group.description" class="group-desc">{{ group.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="action-btn action-btn--apply"
|
||||||
|
:class="{ 'action-btn--disabled': appliedMap[group.id] }"
|
||||||
|
@tap="showApplyDialog(group)"
|
||||||
|
>
|
||||||
|
<text class="action-btn-text action-btn-text--apply">
|
||||||
|
{{ appliedMap[group.id] ? '已申请' : '申请加入' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部加载状态 -->
|
||||||
|
<view class="load-more-wrap">
|
||||||
|
<text v-if="loadingMore" class="load-more-text">加载中...</text>
|
||||||
|
<text v-else-if="noMore" class="load-more-text">没有更多了</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 无结果 -->
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<uni-icons type="search" size="48" color="#CBD5E1" />
|
||||||
|
<text class="empty-text">未找到相关群聊</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 未搜索提示 -->
|
||||||
|
<view v-else class="hint-section">
|
||||||
|
<uni-icons type="search" size="48" color="#CBD5E1" />
|
||||||
|
<text class="hint-text">输入关键词搜索群聊</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed, reactive } from 'vue'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { getAvatarColor, getInitial } from '@/utils/avatar'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupSearch',
|
||||||
|
setup() {
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const searched = ref(false)
|
||||||
|
const searchLoading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const appliedMap = reactive({})
|
||||||
|
|
||||||
|
/** 搜索结果列表 */
|
||||||
|
const resultList = computed(() => groupStore.searchResults)
|
||||||
|
|
||||||
|
/** 是否还有更多数据 */
|
||||||
|
const noMore = computed(() => {
|
||||||
|
return resultList.value.length >= groupStore.searchTotal && groupStore.searchTotal > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
groupStore.searchResults = []
|
||||||
|
groupStore.searchTotal = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 搜索逻辑 ====================
|
||||||
|
|
||||||
|
/** 执行搜索 */
|
||||||
|
const doSearch = async () => {
|
||||||
|
const kw = keyword.value.trim()
|
||||||
|
if (!kw) {
|
||||||
|
uni.showToast({ title: '请输入搜索关键词', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searched.value = true
|
||||||
|
searchLoading.value = true
|
||||||
|
page.value = 1
|
||||||
|
|
||||||
|
try {
|
||||||
|
await groupStore.searchGroups(kw, 1, pageSize)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('搜索群聊失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '搜索失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚动加载更多 */
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loadingMore.value || noMore.value || !searched.value) return
|
||||||
|
|
||||||
|
loadingMore.value = true
|
||||||
|
page.value += 1
|
||||||
|
|
||||||
|
try {
|
||||||
|
await groupStore.searchGroups(keyword.value.trim(), page.value, pageSize, true)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载更多群聊失败', e)
|
||||||
|
page.value -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 申请加入 ====================
|
||||||
|
|
||||||
|
/** 弹出申请附言输入框 */
|
||||||
|
const showApplyDialog = (group) => {
|
||||||
|
if (appliedMap[group.id]) return
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: `申请加入「${group.name}」`,
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入申请附言(可选)',
|
||||||
|
content: '',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.submitJoinRequest(group.id, res.content || '')
|
||||||
|
appliedMap[group.id] = true
|
||||||
|
uni.showToast({ title: '申请已发送', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('发送入群申请失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '申请失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
searched,
|
||||||
|
searchLoading,
|
||||||
|
loadingMore,
|
||||||
|
page,
|
||||||
|
resultList,
|
||||||
|
noMore,
|
||||||
|
appliedMap,
|
||||||
|
doSearch,
|
||||||
|
loadMore,
|
||||||
|
showApplyDialog,
|
||||||
|
getAvatarColor,
|
||||||
|
getInitial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 搜索栏 ===== */
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
gap: 16rpx;
|
||||||
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
height: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
background-color: #2563EB;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 区块标题 ===== */
|
||||||
|
.section-header {
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 加载状态 ===== */
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 搜索结果滚动区域 ===== */
|
||||||
|
.result-scroll {
|
||||||
|
height: calc(100vh - 200rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 结果列表 ===== */
|
||||||
|
.result-list {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 群头像 ===== */
|
||||||
|
.group-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-char {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 群信息 ===== */
|
||||||
|
.group-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-meta {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748B;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 操作按钮 ===== */
|
||||||
|
.action-btn {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--apply {
|
||||||
|
border: 2rpx solid #2563EB;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--disabled {
|
||||||
|
border-color: #CBD5E1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-text--apply {
|
||||||
|
color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--disabled .action-btn-text--apply {
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 加载更多 ===== */
|
||||||
|
.load-more-wrap {
|
||||||
|
padding: 24rpx 0 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 空状态 ===== */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 200rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 未搜索提示 ===== */
|
||||||
|
.hint-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
538
frontend/src/pages/group/settings.vue
Normal file
538
frontend/src/pages/group/settings.vue
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
<!--
|
||||||
|
群设置页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Surface #F1F5F9 / Text #1E293B
|
||||||
|
功能:群信息展示/编辑、成员概览、免打扰、解散/退出群聊
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 群头像 + 群名称 -->
|
||||||
|
<view class="group-card">
|
||||||
|
<view class="group-avatar-wrap" @tap="onChangeAvatar">
|
||||||
|
<image v-if="groupAvatar" class="group-avatar" :src="groupAvatar" mode="aspectFill" />
|
||||||
|
<view v-else class="group-avatar group-avatar-placeholder" :style="{ backgroundColor: getAvatarColor(groupName) }">
|
||||||
|
<text class="avatar-text">{{ getInitial(groupName) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="isOwnerOrAdmin" class="avatar-edit-badge">
|
||||||
|
<uni-icons type="camera-filled" size="14" color="#FFFFFF" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="group-name">{{ groupName || '群聊' }}</text>
|
||||||
|
<text class="group-id">群ID: {{ groupId }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 群公告 -->
|
||||||
|
<view class="settings-group">
|
||||||
|
<view class="settings-item" @tap="onEditAnnouncement">
|
||||||
|
<text class="settings-label">群公告</text>
|
||||||
|
<view class="settings-right">
|
||||||
|
<text class="settings-value settings-value-ellipsis">{{ currentGroup?.notice || '暂无公告' }}</text>
|
||||||
|
<uni-icons v-if="isOwnerOrAdmin" type="forward" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 成员概览 -->
|
||||||
|
<view class="settings-group">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">群成员({{ currentMembers.length }})</text>
|
||||||
|
<view class="section-action" @tap="goToMembers">
|
||||||
|
<text class="section-action-text">查看全部</text>
|
||||||
|
<uni-icons type="forward" size="14" color="#2563EB" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="member-preview-scroll" scroll-x>
|
||||||
|
<view class="member-preview-list">
|
||||||
|
<view
|
||||||
|
v-for="member in previewMembers"
|
||||||
|
:key="member.user_id"
|
||||||
|
class="member-preview-item"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="member.avatar"
|
||||||
|
class="member-preview-avatar"
|
||||||
|
:src="member.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="member-preview-avatar member-preview-avatar-placeholder"
|
||||||
|
:style="{ backgroundColor: getAvatarColor(member.nickname || member.username) }"
|
||||||
|
>
|
||||||
|
<text class="member-preview-char">{{ getInitial(member.nickname || member.username) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="member-preview-name">{{ member.nickname || member.username }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 功能区 -->
|
||||||
|
<view class="settings-group">
|
||||||
|
<view class="settings-item" @tap="onEditGroupName">
|
||||||
|
<text class="settings-label">群名称</text>
|
||||||
|
<view class="settings-right">
|
||||||
|
<text class="settings-value">{{ groupName }}</text>
|
||||||
|
<uni-icons v-if="isOwnerOrAdmin" type="forward" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="settings-item" @tap="onEditNickname">
|
||||||
|
<text class="settings-label">我在本群的昵称</text>
|
||||||
|
<view class="settings-right">
|
||||||
|
<text class="settings-value">{{ myNickname || '未设置' }}</text>
|
||||||
|
<uni-icons type="forward" size="16" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="settings-item">
|
||||||
|
<text class="settings-label">消息免打扰</text>
|
||||||
|
<switch
|
||||||
|
:checked="isDoNotDisturb"
|
||||||
|
color="#2563EB"
|
||||||
|
style="transform: scale(0.8);"
|
||||||
|
@change="onToggleDND"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 危险操作区 -->
|
||||||
|
<view class="settings-group">
|
||||||
|
<view v-if="isOwner" class="settings-item settings-danger" @tap="onDissolve">
|
||||||
|
<text class="settings-label-danger">解散群聊</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="settings-item settings-danger" @tap="onLeave">
|
||||||
|
<text class="settings-label-danger">退出群聊</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { getAvatarColor, getInitial } from '@/utils/avatar'
|
||||||
|
import { GROUP_ROLE } from '@/constants/group'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GroupSettings',
|
||||||
|
setup() {
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const groupId = ref(0)
|
||||||
|
const conversationId = ref(0)
|
||||||
|
const groupName = ref('')
|
||||||
|
const groupAvatar = ref('')
|
||||||
|
const isDoNotDisturb = ref(false)
|
||||||
|
|
||||||
|
const currentGroup = computed(() => groupStore.currentGroup)
|
||||||
|
const currentMembers = computed(() => groupStore.currentMembers)
|
||||||
|
const myId = computed(() => Number(userStore.userInfo?.id) || 0)
|
||||||
|
|
||||||
|
/** 成员概览:最多显示前 5 个 */
|
||||||
|
const previewMembers = computed(() => {
|
||||||
|
return currentMembers.value.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前用户在群中的成员信息 */
|
||||||
|
const myMember = computed(() => {
|
||||||
|
return currentMembers.value.find(m => m.user_id === myId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 我在本群的昵称 */
|
||||||
|
const myNickname = computed(() => {
|
||||||
|
return myMember.value?.nickname || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前用户是否为群主 */
|
||||||
|
const isOwner = computed(() => {
|
||||||
|
return myMember.value?.role === GROUP_ROLE.OWNER
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前用户是否为群主或管理员 */
|
||||||
|
const isOwnerOrAdmin = computed(() => {
|
||||||
|
const role = myMember.value?.role
|
||||||
|
return role === GROUP_ROLE.OWNER || role === GROUP_ROLE.ADMIN
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
groupId.value = parseInt(query.groupId) || 0
|
||||||
|
conversationId.value = parseInt(query.conversationId) || 0
|
||||||
|
groupName.value = decodeURIComponent(query.peerName || '')
|
||||||
|
groupAvatar.value = decodeURIComponent(query.peerAvatar || '')
|
||||||
|
|
||||||
|
if (groupId.value) {
|
||||||
|
groupStore.fetchGroupDetail(groupId.value).then((detail) => {
|
||||||
|
if (detail) {
|
||||||
|
groupName.value = detail.name || groupName.value
|
||||||
|
groupAvatar.value = detail.avatar || groupAvatar.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
groupStore.fetchMembers(groupId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 编辑群名称 ====================
|
||||||
|
|
||||||
|
const onEditGroupName = () => {
|
||||||
|
if (!isOwnerOrAdmin.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '修改群名称',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入新群名称',
|
||||||
|
content: groupName.value,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm && res.content && res.content.trim()) {
|
||||||
|
try {
|
||||||
|
await groupStore.updateGroup(groupId.value, { name: res.content.trim() })
|
||||||
|
groupName.value = res.content.trim()
|
||||||
|
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 编辑群公告 ====================
|
||||||
|
|
||||||
|
const onEditAnnouncement = () => {
|
||||||
|
if (!isOwnerOrAdmin.value) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '修改群公告',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入群公告',
|
||||||
|
content: currentGroup.value?.notice || '',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.updateGroup(groupId.value, { notice: res.content || '' })
|
||||||
|
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 修改群头像(占位,仅群主/管理员) ====================
|
||||||
|
|
||||||
|
const onChangeAvatar = () => {
|
||||||
|
if (!isOwnerOrAdmin.value) return
|
||||||
|
uni.showToast({ title: '头像修改功能即将开放', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 修改我的群昵称 ====================
|
||||||
|
|
||||||
|
const onEditNickname = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '修改群昵称',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入群昵称',
|
||||||
|
content: myNickname.value,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm && res.content !== undefined) {
|
||||||
|
try {
|
||||||
|
await groupStore.updateNickname(groupId.value, res.content.trim())
|
||||||
|
groupStore.fetchMembers(groupId.value)
|
||||||
|
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '修改失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 免打扰 ====================
|
||||||
|
|
||||||
|
const onToggleDND = async (e) => {
|
||||||
|
const newVal = e.detail.value
|
||||||
|
try {
|
||||||
|
await groupStore.setDoNotDisturb(conversationId.value, newVal)
|
||||||
|
isDoNotDisturb.value = newVal
|
||||||
|
} catch {
|
||||||
|
isDoNotDisturb.value = !newVal
|
||||||
|
uni.showToast({ title: '设置失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 解散群聊(仅群主) ====================
|
||||||
|
|
||||||
|
const onDissolve = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '解散群聊',
|
||||||
|
content: '确定要解散该群聊吗?此操作不可撤回,所有群成员将被移除。',
|
||||||
|
confirmColor: '#EF4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.dissolveGroup(groupId.value)
|
||||||
|
uni.showToast({ title: '群聊已解散', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.switchTab({ url: '/pages/chat/index' })
|
||||||
|
}, 500)
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '解散失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 退出群聊(非群主) ====================
|
||||||
|
|
||||||
|
const onLeave = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '退出群聊',
|
||||||
|
content: '确定要退出该群聊吗?退出后将不再接收该群消息。',
|
||||||
|
confirmColor: '#EF4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await groupStore.leaveGroup(groupId.value)
|
||||||
|
uni.showToast({ title: '已退出群聊', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.switchTab({ url: '/pages/chat/index' })
|
||||||
|
}, 500)
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.data?.message || '退出失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 导航 ====================
|
||||||
|
|
||||||
|
const goToMembers = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/group/members?groupId=${groupId.value}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
groupAvatar,
|
||||||
|
isDoNotDisturb,
|
||||||
|
currentGroup,
|
||||||
|
currentMembers,
|
||||||
|
previewMembers,
|
||||||
|
myNickname,
|
||||||
|
isOwner,
|
||||||
|
isOwnerOrAdmin,
|
||||||
|
onEditGroupName,
|
||||||
|
onEditAnnouncement,
|
||||||
|
onChangeAvatar,
|
||||||
|
onEditNickname,
|
||||||
|
onToggleDND,
|
||||||
|
onDissolve,
|
||||||
|
onLeave,
|
||||||
|
goToMembers,
|
||||||
|
getAvatarColor,
|
||||||
|
getInitial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 群信息卡片 ===== */
|
||||||
|
.group-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-avatar {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-edit-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4rpx;
|
||||||
|
right: -4rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #2563EB;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 3rpx solid #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1E293B;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-id {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 区块通用 ===== */
|
||||||
|
.settings-group {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx;
|
||||||
|
min-height: 88rpx;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-value-ellipsis {
|
||||||
|
max-width: 400rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label-danger {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 成员概览区 ===== */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 32rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-action-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 16rpx 32rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 96rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-avatar-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-char {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-preview-name {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
max-width: 96rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -40,6 +40,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
/** 是否还有更多历史消息 conversationId -> boolean */
|
/** 是否还有更多历史消息 conversationId -> boolean */
|
||||||
const hasMoreMap = ref({})
|
const hasMoreMap = ref({})
|
||||||
|
|
||||||
|
/** 单聊已读状态:conversationId -> lastReadMsgId(对方已读到的消息 ID) */
|
||||||
|
const readStatusMap = ref({})
|
||||||
|
|
||||||
|
/** 群聊已读计数:messageId -> readCount */
|
||||||
|
const groupReadCountMap = ref({})
|
||||||
|
|
||||||
// ==================== Computed ====================
|
// ==================== Computed ====================
|
||||||
|
|
||||||
/** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */
|
/** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */
|
||||||
@@ -85,7 +91,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
*/
|
*/
|
||||||
const sendMessage = (params) => {
|
const sendMessage = (params) => {
|
||||||
const clientMsgId = _generateClientMsgId()
|
const clientMsgId = _generateClientMsgId()
|
||||||
const { conversationId, targetUserId, content, type = 1 } = params
|
const { conversationId, targetUserId, content, type = 1, at_user_ids } = params
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const tempMsg = {
|
const tempMsg = {
|
||||||
@@ -105,13 +111,18 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg }
|
pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg }
|
||||||
|
|
||||||
const seq = wsService.send('im.message.send', {
|
const payload = {
|
||||||
conversation_id: conversationId || 0,
|
conversation_id: conversationId || 0,
|
||||||
target_user_id: targetUserId || 0,
|
target_user_id: targetUserId || 0,
|
||||||
type,
|
type,
|
||||||
content,
|
content,
|
||||||
client_msg_id: clientMsgId
|
client_msg_id: clientMsgId
|
||||||
})
|
}
|
||||||
|
if (at_user_ids && at_user_ids.length > 0) {
|
||||||
|
payload.at_user_ids = at_user_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = wsService.send('im.message.send', payload)
|
||||||
|
|
||||||
if (seq === -1) {
|
if (seq === -1) {
|
||||||
pendingMessages.value[clientMsgId].status = 'failed'
|
pendingMessages.value[clientMsgId].status = 'failed'
|
||||||
@@ -209,6 +220,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
wsService.on('im.message.send.ack', _onSendACK)
|
wsService.on('im.message.send.ack', _onSendACK)
|
||||||
wsService.on('im.offline.sync', _onOfflineSync)
|
wsService.on('im.offline.sync', _onOfflineSync)
|
||||||
wsService.on('im.typing', _onTyping)
|
wsService.on('im.typing', _onTyping)
|
||||||
|
wsService.on('im.message.read.ack', _onReadACK)
|
||||||
|
wsService.on('im.message.read.count', _onReadCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收到新消息推送 */
|
/** 收到新消息推送 */
|
||||||
@@ -311,6 +324,29 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
fetchConversations()
|
fetchConversations()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单聊已读确认(对方已读到某条消息) */
|
||||||
|
const _onReadACK = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { conversation_id, last_read_msg_id } = msg.data
|
||||||
|
readStatusMap.value[conversation_id] = last_read_msg_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊已读计数更新 */
|
||||||
|
const _onReadCount = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { message_id, read_count } = msg.data
|
||||||
|
groupReadCountMap.value[message_id] = read_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊标记已读(发送 WS 事件) */
|
||||||
|
const markGroupRead = (conversationId, messageIds) => {
|
||||||
|
if (!messageIds || messageIds.length === 0) return
|
||||||
|
wsService.send('im.group.read', {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
message_ids: messageIds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 正在输入通知 */
|
/** 正在输入通知 */
|
||||||
const _onTyping = (msg) => {
|
const _onTyping = (msg) => {
|
||||||
if (!msg || !msg.data) return
|
if (!msg || !msg.data) return
|
||||||
@@ -366,6 +402,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
totalUnread,
|
totalUnread,
|
||||||
typingMap,
|
typingMap,
|
||||||
hasMoreMap,
|
hasMoreMap,
|
||||||
|
readStatusMap,
|
||||||
|
groupReadCountMap,
|
||||||
currentMessages,
|
currentMessages,
|
||||||
sortedConversations,
|
sortedConversations,
|
||||||
fetchConversations,
|
fetchConversations,
|
||||||
@@ -373,6 +411,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
recallMessage,
|
recallMessage,
|
||||||
markRead,
|
markRead,
|
||||||
|
markGroupRead,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
loadHistoryMessages,
|
loadHistoryMessages,
|
||||||
pinConversation,
|
pinConversation,
|
||||||
|
|||||||
328
frontend/src/store/group.js
Normal file
328
frontend/src/store/group.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* 群聊状态 Store
|
||||||
|
*
|
||||||
|
* 管理群聊信息、群成员、入群申请,提供:
|
||||||
|
* - 当前群详情和成员列表
|
||||||
|
* - 群管理操作(邀请/踢人/禁言/设管理员/解散等)
|
||||||
|
* - WebSocket 群管理事件监听(group.* 系列)
|
||||||
|
*
|
||||||
|
* 对应后端 API:/api/v1/groups/*
|
||||||
|
* 对应 WS 事件:group.created / group.member.join / group.member.leave / group.member.kicked
|
||||||
|
* group.info.update / group.dissolved / group.mute.update / group.role.update
|
||||||
|
* group.owner.transfer / group.join.request / group.join.approved
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import groupApi from '@/api/group'
|
||||||
|
import wsService from '@/services/websocket'
|
||||||
|
import { useChatStore } from '@/store/chat'
|
||||||
|
|
||||||
|
export const useGroupStore = defineStore('group', () => {
|
||||||
|
/** 当前查看的群详情 */
|
||||||
|
const currentGroup = ref(null)
|
||||||
|
|
||||||
|
/** 当前群的成员列表 */
|
||||||
|
const currentMembers = ref([])
|
||||||
|
|
||||||
|
/** 当前群的入群申请列表 */
|
||||||
|
const joinRequests = ref([])
|
||||||
|
|
||||||
|
/** 群搜索结果 */
|
||||||
|
const searchResults = ref([])
|
||||||
|
const searchTotal = ref(0)
|
||||||
|
|
||||||
|
// ==================== Actions:群信息 ====================
|
||||||
|
|
||||||
|
/** 获取群详情 */
|
||||||
|
const fetchGroupDetail = async (groupId) => {
|
||||||
|
const res = await groupApi.getGroupDetail(groupId)
|
||||||
|
if (res.data) {
|
||||||
|
currentGroup.value = res.data
|
||||||
|
}
|
||||||
|
return currentGroup.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建群聊 */
|
||||||
|
const createGroup = async (name, memberIds, avatar = '') => {
|
||||||
|
const res = await groupApi.createGroup(name, memberIds, avatar)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新群信息 */
|
||||||
|
const updateGroup = async (groupId, data) => {
|
||||||
|
await groupApi.updateGroup(groupId, data)
|
||||||
|
if (currentGroup.value && currentGroup.value.id === groupId) {
|
||||||
|
Object.assign(currentGroup.value, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解散群聊 */
|
||||||
|
const dissolveGroup = async (groupId) => {
|
||||||
|
await groupApi.dissolveGroup(groupId)
|
||||||
|
currentGroup.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions:群成员管理 ====================
|
||||||
|
|
||||||
|
/** 获取群成员列表 */
|
||||||
|
const fetchMembers = async (groupId) => {
|
||||||
|
const res = await groupApi.getMembers(groupId)
|
||||||
|
if (res.data) {
|
||||||
|
currentMembers.value = res.data.list || res.data || []
|
||||||
|
}
|
||||||
|
return currentMembers.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邀请入群 */
|
||||||
|
const inviteMembers = async (groupId, userIds) => {
|
||||||
|
await groupApi.inviteMembers(groupId, userIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 踢出群成员 */
|
||||||
|
const kickMember = async (groupId, userId) => {
|
||||||
|
await groupApi.kickMember(groupId, userId)
|
||||||
|
currentMembers.value = currentMembers.value.filter(m => m.user_id !== userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出群聊 */
|
||||||
|
const leaveGroup = async (groupId) => {
|
||||||
|
await groupApi.leaveGroup(groupId)
|
||||||
|
currentGroup.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转让群主 */
|
||||||
|
const transferOwner = async (groupId, newOwnerId) => {
|
||||||
|
await groupApi.transferOwner(groupId, newOwnerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/取消管理员 */
|
||||||
|
const setMemberRole = async (groupId, userId, role) => {
|
||||||
|
await groupApi.setMemberRole(groupId, userId, role)
|
||||||
|
const member = currentMembers.value.find(m => m.user_id === userId)
|
||||||
|
if (member) member.role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁言/解除禁言 */
|
||||||
|
const muteMember = async (groupId, userId, isMuted) => {
|
||||||
|
await groupApi.muteMember(groupId, userId, isMuted)
|
||||||
|
const member = currentMembers.value.find(m => m.user_id === userId)
|
||||||
|
if (member) member.is_muted = isMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全体禁言 */
|
||||||
|
const setAllMuted = async (groupId, isMuted) => {
|
||||||
|
await groupApi.setAllMuted(groupId, isMuted)
|
||||||
|
if (currentGroup.value && currentGroup.value.id === groupId) {
|
||||||
|
currentGroup.value.is_all_muted = isMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改群昵称 */
|
||||||
|
const updateNickname = async (groupId, nickname) => {
|
||||||
|
await groupApi.updateNickname(groupId, nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/取消免打扰 */
|
||||||
|
const setDoNotDisturb = async (conversationId, isDoNotDisturb) => {
|
||||||
|
await groupApi.setDoNotDisturb(conversationId, isDoNotDisturb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions:入群申请 ====================
|
||||||
|
|
||||||
|
/** 提交入群申请 */
|
||||||
|
const submitJoinRequest = async (groupId, message = '') => {
|
||||||
|
await groupApi.submitJoinRequest(groupId, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取入群申请列表 */
|
||||||
|
const fetchJoinRequests = async (groupId) => {
|
||||||
|
const res = await groupApi.getJoinRequests(groupId)
|
||||||
|
if (res.data) {
|
||||||
|
joinRequests.value = res.data.list || res.data || []
|
||||||
|
}
|
||||||
|
return joinRequests.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批入群申请 */
|
||||||
|
const reviewJoinRequest = async (groupId, requestId, action) => {
|
||||||
|
await groupApi.reviewJoinRequest(groupId, requestId, action)
|
||||||
|
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions:搜索 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索公开群
|
||||||
|
* @param {string} keyword - 搜索关键词
|
||||||
|
* @param {number} page - 页码
|
||||||
|
* @param {number} pageSize - 每页条数
|
||||||
|
* @param {boolean} append - 是否追加到现有列表(分页加载更多时为 true)
|
||||||
|
*/
|
||||||
|
const searchGroups = async (keyword, page = 1, pageSize = 20, append = false) => {
|
||||||
|
const res = await groupApi.searchGroups(keyword, page, pageSize)
|
||||||
|
if (res.data) {
|
||||||
|
const newList = res.data.list || []
|
||||||
|
searchResults.value = append ? [...searchResults.value, ...newList] : newList
|
||||||
|
searchTotal.value = res.data.total || 0
|
||||||
|
}
|
||||||
|
return { list: searchResults.value, total: searchTotal.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WS 事件监听 ====================
|
||||||
|
|
||||||
|
let _wsInitialized = false
|
||||||
|
|
||||||
|
/** 初始化 WebSocket 群管理事件监听(幂等) */
|
||||||
|
const initWsListeners = () => {
|
||||||
|
if (_wsInitialized) return
|
||||||
|
_wsInitialized = true
|
||||||
|
|
||||||
|
wsService.on('group.created', _onGroupCreated)
|
||||||
|
wsService.on('group.member.join', _onMemberJoin)
|
||||||
|
wsService.on('group.member.leave', _onMemberLeave)
|
||||||
|
wsService.on('group.member.kicked', _onMemberKicked)
|
||||||
|
wsService.on('group.info.update', _onInfoUpdate)
|
||||||
|
wsService.on('group.dissolved', _onDissolved)
|
||||||
|
wsService.on('group.mute.update', _onMuteUpdate)
|
||||||
|
wsService.on('group.role.update', _onRoleUpdate)
|
||||||
|
wsService.on('group.owner.transfer', _onOwnerTransfer)
|
||||||
|
wsService.on('group.join.request', _onJoinRequest)
|
||||||
|
wsService.on('group.join.approved', _onJoinApproved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊创建通知 → 刷新会话列表 */
|
||||||
|
const _onGroupCreated = () => {
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新成员加入 → 刷新成员和会话 */
|
||||||
|
const _onMemberJoin = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
fetchMembers(group_id)
|
||||||
|
}
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员退出 → 刷新成员 */
|
||||||
|
const _onMemberLeave = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id, user_id } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
currentMembers.value = currentMembers.value.filter(m => m.user_id !== user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员被踢 → 刷新或跳转 */
|
||||||
|
const _onMemberKicked = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id, user_id } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
currentMembers.value = currentMembers.value.filter(m => m.user_id !== user_id)
|
||||||
|
}
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群信息变更 → 更新当前群详情 + 刷新会话 */
|
||||||
|
const _onInfoUpdate = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id, updates } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id && updates) {
|
||||||
|
Object.assign(currentGroup.value, updates)
|
||||||
|
}
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群解散 → 清空当前群 + 刷新会话 */
|
||||||
|
const _onDissolved = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
currentGroup.value = null
|
||||||
|
uni.showToast({ title: '该群已被解散', icon: 'none' })
|
||||||
|
}
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁言变更 → 更新成员 / 群信息 */
|
||||||
|
const _onMuteUpdate = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id, user_id, is_muted, is_all_muted } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
if (is_all_muted !== undefined) {
|
||||||
|
currentGroup.value.is_all_muted = is_all_muted
|
||||||
|
}
|
||||||
|
if (user_id) {
|
||||||
|
const member = currentMembers.value.find(m => m.user_id === user_id)
|
||||||
|
if (member) member.is_muted = is_muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色变更 → 更新成员角色 */
|
||||||
|
const _onRoleUpdate = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id, user_id, role } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
const member = currentMembers.value.find(m => m.user_id === user_id)
|
||||||
|
if (member) member.role = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群主转让 → 刷新群详情和成员 */
|
||||||
|
const _onOwnerTransfer = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const { group_id } = msg.data
|
||||||
|
if (currentGroup.value && currentGroup.value.id === group_id) {
|
||||||
|
fetchGroupDetail(group_id)
|
||||||
|
fetchMembers(group_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新入群申请 → 提示通知 */
|
||||||
|
const _onJoinRequest = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
uni.showToast({ title: '收到新的入群申请', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入群申请通过 → 刷新会话列表 */
|
||||||
|
const _onJoinApproved = () => {
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.fetchConversations()
|
||||||
|
uni.showToast({ title: '入群申请已通过', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentGroup,
|
||||||
|
currentMembers,
|
||||||
|
joinRequests,
|
||||||
|
searchResults,
|
||||||
|
searchTotal,
|
||||||
|
fetchGroupDetail,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
dissolveGroup,
|
||||||
|
fetchMembers,
|
||||||
|
inviteMembers,
|
||||||
|
kickMember,
|
||||||
|
leaveGroup,
|
||||||
|
transferOwner,
|
||||||
|
setMemberRole,
|
||||||
|
muteMember,
|
||||||
|
setAllMuted,
|
||||||
|
updateNickname,
|
||||||
|
setDoNotDisturb,
|
||||||
|
submitJoinRequest,
|
||||||
|
fetchJoinRequests,
|
||||||
|
reviewJoinRequest,
|
||||||
|
searchGroups,
|
||||||
|
initWsListeners
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user