This commit is contained in:
2026-08-31 14:42:22 +08:00
72 changed files with 2479 additions and 168 deletions
+947
View File
@@ -0,0 +1,947 @@
# IM_API_NEW 接口文档
基于微服务架构的即时通讯系统,包含 6 个独立 WebApi 服务。
## 通用说明
### 统一响应格式 `Result<T>`
```json
{
"code": 0,
"message": "成功",
"data": { }
}
```
- `code`:业务状态码,`0` 表示成功(见下方状态码表)
- `message`:状态描述
- `data`:业务数据,失败时为 `null`
### 认证
除特别说明外,所有接口需在请求头携带 JWT:
```
Authorization: Bearer {token}
```
当前用户 ID 从 Token 的 `NameIdentifier` 声明中获取,无需在请求体重复传递。
### 路由约定
控制器统一采用 `api/[controller]/[action]` 路由模板(FileService 例外,见对应章节)。
---
## 1. 认证服务 (User.WebApi - Auth)
> 以下接口**无需认证**。
### POST /api/Auth/Login — 登录
请求体:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| userName | string | 是 | 5-20 字符 |
| password | string | 是 | ≤50 字符 |
响应:`Result<LoginResponse>`
请求示例:
```json
{
"userName": "zhangsan",
"password": "P@ssw0rd"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"userId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"expired": "2026-06-27T10:30:00Z",
"userName": "zhangsan",
"nickName": "张三",
"avatar": "https://cdn.example.com/avatar/zhangsan.png",
"creationTime": "2026-01-15T08:00:00+00:00"
}
}
```
失败示例(密码错误,code 2002):
```json
{
"code": 2002,
"message": "密码错误",
"data": null
}
```
### POST /api/Auth/Register — 注册
请求体:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| userName | string | 是 | 5-20 字符 |
| password | string | 是 | 6-50 字符 |
| nickName | string | 是 | ≤50 字符 |
响应:`Result<UserResponse>`
请求示例:
```json
{
"userName": "zhangsan",
"password": "P@ssw0rd",
"nickName": "张三"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"userName": "zhangsan",
"nickName": "张三",
"email": null,
"phone": null,
"region": "",
"description": "",
"avatar": null,
"creationTime": "2026-06-26T08:00:00+00:00",
"deletion": null
}
}
```
### POST /api/Auth/Refresh — 刷新令牌
请求体:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| refreshToken | string | 是 | 刷新令牌 |
响应:`Result<LoginResponse>`
请求示例:
```json
{
"refreshToken": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
```
响应示例:同 Login 的 `Result<LoginResponse>`
**LoginResponse 结构**
```json
{
"userId": "guid", "token": "string", "refreshToken": "string",
"expired": "datetime", "userName": "string", "nickName": "string",
"avatar": "string|null", "creationTime": "datetime"
}
```
---
## 2. 用户服务 (User.WebApi - User)
> 需认证。
### GET /api/User/Me — 当前用户信息
响应:`Result<UserResponse>`
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"userName": "zhangsan",
"nickName": "张三",
"email": "zhangsan@example.com",
"phone": "13800000000",
"region": "广东·深圳",
"description": "这个人很懒,什么都没写",
"avatar": "https://cdn.example.com/avatar/zhangsan.png",
"creationTime": "2026-01-15T08:00:00+00:00",
"deletion": null
}
}
```
### GET /api/User/Find?userId={guid} — 查询指定用户
响应:`Result<UserResponse>`(结构同上)
### GET /api/User/FindByUname?username={string} — 按用户名查询
响应:`Result<UserResponse>`(结构同上)
### POST /api/User/Update — 更新资料
请求体(均可选):`nickName``region``avatar``description`
响应:`Result<UserResponse>`
请求示例:
```json
{
"nickName": "张三丰",
"region": "湖北·武当山",
"avatar": "https://cdn.example.com/avatar/new.png",
"description": "太极宗师"
}
```
### POST /api/User/GetUsersByIds — 批量查询用户
请求体:`["guid", "guid"]`Guid 数组)
响应:`Result<List<UserResponse>>`
请求示例:
```json
[
"8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
]
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{ "id": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b", "userName": "zhangsan", "nickName": "张三", "email": null, "phone": null, "region": "广东·深圳", "description": "", "avatar": null, "creationTime": "2026-01-15T08:00:00+00:00", "deletion": null }
]
}
```
**UserResponse 结构**
```json
{
"id": "guid", "userName": "string", "nickName": "string",
"email": "string|null", "phone": "string|null", "region": "string",
"description": "string", "avatar": "string|null",
"creationTime": "datetime", "deletion": "datetime|null"
}
```
---
## 3. 联系人服务 (ContactService.WebApi)
> 需认证。
### 好友 (Friend)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/Friend/List | 好友列表 | - |
| POST | /api/Friend/Delete | 删除好友 | `friendId` (query) |
| POST | /api/Friend/Block | 拉黑好友 | `friendId` (query) |
| GET | /api/Friend/CheckFriend | 检查好友关系 | `userId`, `targetId` (query) |
`GET /api/Friend/List` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "e5f6a7b8-c9d0-1e2f-3a4b-5c6d7e8f9a0b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"avatar": "https://cdn.example.com/avatar/lisi.png",
"nickName": "李四",
"remarkName": "老李",
"createTime": "2026-02-01T10:00:00",
"updateTime": null,
"status": "Pending"
}
]
}
```
`POST /api/Friend/Delete?friendId={guid}` 响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
`GET /api/Friend/CheckFriend?userId={guid}&targetId={guid}` 响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
### 好友请求 (FriendRequest)
**POST /api/FriendRequest/Add** — 发起好友申请
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| targetId | guid | 是 | 被申请人 |
| description | string | 否 | 附言 |
| remarkName | string | 否 | 备注名 |
请求示例:
```json
{
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"description": "我是张三,加个好友",
"remarkName": "老李"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"ownerNickName": "张三",
"ownerAvatar": "https://cdn.example.com/avatar/zhangsan.png",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"targetNickName": "李四",
"targetAvatar": "https://cdn.example.com/avatar/lisi.png",
"description": "我是张三,加个好友",
"state": "Pending",
"remarkName": "老李",
"creationTime": "2026-06-26T09:00:00+00:00",
"deletion": null,
"modificationTime": null
}
}
```
**POST /api/FriendRequest/Handle** — 处理申请
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| requestId | guid | 是 | 请求 ID |
| action | string | 是 | `"Accpet"` 同意, `"Reject"` 拒绝, `"Block"` 拉黑 |
| remarkName | string | 同意时必填 | 备注名 |
请求示例:
```json
{
"requestId": "c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f",
"action": "Accpet",
"remarkName": "张三"
}
```
响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
**GET /api/FriendRequest/List** — 我相关的申请列表
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"ownerNickName": "张三",
"ownerAvatar": "https://cdn.example.com/avatar/zhangsan.png",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"targetNickName": "李四",
"targetAvatar": "https://cdn.example.com/avatar/lisi.png",
"description": "我是张三,加个好友",
"state": "Passed",
"remarkName": "老李",
"creationTime": "2026-06-26T09:00:00+00:00",
"deletion": null,
"modificationTime": "2026-06-26T09:05:00+00:00"
}
]
}
```
**FriendRequestResponse 状态 (State)**`"Pending"` 待通过, `"Declined"` 已拒绝, `"Passed"` 已同意, `"Blocked"` 已拉黑
---
## 4. 群组服务 (GroupService.WebApi)
> 需认证(GroupMember 部分接口除外)。
### 群组 (Group)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/Group/GetAll | 我加入的群列表 | - |
| GET | /api/Group/GetOne | 群详情 | `groupId` (query) |
| POST | /api/Group/Create | 创建群 | body: `name` (≤20) |
| POST | /api/Group/Update | 更新群 | body: 见下 |
Update 请求体:
| 字段 | 类型 | 必填 |
|------|------|------|
| groupId | guid | 是 |
| groupName | string | 否 |
| avatar | string | 否 |
| description | string | 否 |
`POST /api/Group/Create` 请求示例:
```json
{ "name": "技术交流群" }
```
`POST /api/Group/Update` 请求示例:
```json
{
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"groupName": "技术交流群(2026)",
"avatar": "https://cdn.example.com/group/tech.png",
"description": "欢迎交流技术"
}
```
`GET /api/Group/GetOne?groupId={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"name": "技术交流群",
"groupMaster": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"authority": "REQUIRE_CONSENT",
"allMembersBanned": false,
"status": "Normal",
"announcement": "欢迎加入",
"avatar": "https://cdn.example.com/group/tech.png",
"maxSequenceId": 1024,
"lastMessage": "晚上好",
"lastSenderName": "张三",
"created": "2026-03-01T08:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00"
}
}
```
### 群成员 (GroupMember)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/GroupMember/CheckMember | 检查成员 | `userId`, `groupId` (query) |
| GET | /api/GroupMember/List | 成员列表 | `groupId` (query) |
| POST | /api/GroupMember/Delete | 移除成员 | `memberId` (query) |
`GET /api/GroupMember/List?groupId={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "f1e2d3c4-b5a6-9788-1a2b-3c4d5e6f7a8b",
"userId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"groupNickName": "群主张三",
"avatar": "https://cdn.example.com/avatar/zhangsan.png",
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"role": "Master",
"created": "2026-03-01T08:00:00+00:00"
}
]
}
```
**成员角色 (Role)**`"Normal"` 普通成员, `"Administrator"` 管理员, `"Master"` 群主
### 群邀请 (GroupInvitation)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| POST | /api/GroupInvitation/Send | 发送邀请 | body: `groupId`, `userId` |
| GET | /api/GroupInvitation/Get | 邀请详情 | `invitationId` (query) |
| POST | /api/GroupInvitation/Handle | 处理邀请 | `invitationId`, `action` (query) |
`POST /api/GroupInvitation/Send` 请求示例:
```json
{
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"userId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}
```
`GET /api/GroupInvitation/Get?invitationId={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"groupAvatar": "https://cdn.example.com/group/tech.png",
"groupName": "技术交流群",
"userId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"userAvatar": "https://cdn.example.com/avatar/lisi.png",
"userNickName": "李四",
"operatorId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"operatorName": "张三",
"operatorAvatar": "https://cdn.example.com/avatar/zhangsan.png",
"state": "Pending",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00"
}
}
```
**邀请处理 action**`"Accept"` 接受, `"Reject"` 拒绝
**邀请状态 (State)**`"Pending"` 待被邀请人同意, `"Passed"` 已同意, `"Reject"` 拒绝
### 入群请求 (GroupRequest)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| POST | /api/GroupRequest/Send | 申请入群 | body: `groupId`, `desc` (≤20) |
| POST | /api/GroupRequest/Handle | 处理申请 | `requestId`, `action` (query) |
| GET | /api/GroupRequest/Find | 申请详情 | `id` (query) |
| GET | /api/GroupRequest/List | 申请列表 | - |
`POST /api/GroupRequest/Send` 请求示例:
```json
{
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"desc": "我想加入学习"
}
```
`GET /api/GroupRequest/Find?id={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "c3d4e5f6-a7b8-9c0d-1e2f-3a4b5c6d7e8f",
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"groupAvatar": "https://cdn.example.com/group/tech.png",
"groupName": "技术交流群",
"userId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"userAvatar": "https://cdn.example.com/avatar/lisi.png",
"userNickName": "李四",
"operatorId": "00000000-0000-0000-0000-000000000000",
"operatorName": "",
"operatorAvatar": null,
"state": "Pending",
"description": "我想加入学习",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00"
}
}
```
**入群处理 action**`"Accept"` 接受, `"Reject"` 拒绝
**入群状态 (State)**`"Pending"` 待管理员同意, `"Declined"` 已拒绝, `"Passed"` 已通过
**群权限 (Authority)**`"REQUIRE_CONSENT"` 需管理员同意, `"ANYONE_CAN_JOIN"` 任意人可加, `"NOT_ALLOWED_TO_JOIN"` 不允许加入
**群状态 (Status)**`"Normal"` 正常, `"Blocked"` 封禁
---
## 5. 消息服务 (MessageService.WebApi)
> 需认证。
### 会话 (Conversation)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/Conversation/List | 会话列表 | - |
| GET | /api/Conversation/Get | 会话详情 | `id` (query) |
| POST | /api/Conversation/MarkRead | 清零未读 | `conversationId` (query) |
`GET /api/Conversation/List` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"userId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"targetAvatar": "https://cdn.example.com/avatar/lisi.png",
"targetName": "李四",
"lastReadSequenceId": 1020,
"unreadCount": 4,
"chatType": "PRIVATE",
"lastMessage": "晚上一起吃饭吗?",
"dateTime": "2026-06-26T18:30:00"
}
]
}
```
`POST /api/Conversation/MarkRead?conversationId={guid}` 响应示例:
```json
{ "code": 0, "message": "成功", "data": null }
```
### 消息 (Message)
**POST /api/Message/Send** — 发送消息
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| clientMsgId | guid | 是 | 客户端消息 ID(去重) |
| targetId | guid | 是 | 接收方(单聊=用户,群聊=群) |
| chatType | string | 是 | `"PRIVATE"` 单聊, `"GROUP"` 群聊 |
| msgType | string | 是 | 见消息类型表 |
| quoteMessageId | guid | 否 | 引用消息 ID |
| ext | object | 否 | 扩展字段 (键值对) |
| text | string | 否 | 文本内容 |
| url | string | 否 | 媒体 URL |
| width / height | int | 否 | 图片/视频尺寸 |
| thumb | string | 否 | 缩略图 |
| duration | int | 否 | 时长(语音/视频) |
文本消息请求示例:
```json
{
"clientMsgId": "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"chatType": "PRIVATE",
"msgType": "Text",
"text": "晚上一起吃饭吗?"
}
```
图片消息请求示例:
```json
{
"clientMsgId": "8f9a0b1c-2d3e-4f5a-6b7c-8d9e0f1a2b3c",
"targetId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"chatType": "GROUP",
"msgType": "Image",
"url": "https://cdn.example.com/img/photo.jpg",
"width": 1920,
"height": 1080,
"thumb": "https://cdn.example.com/img/photo_thumb.jpg",
"quoteMessageId": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"ext": { "source": "album" }
}
```
发送响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"clientMsgId": "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"chatType": "PRIVATE",
"msgType": "Text",
"senderId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"state": "Sent",
"streamKey": "stream:private:xxx",
"sequenceId": 1021,
"creationTime": "2026-06-26T18:35:00+00:00",
"content": {
"fallback": "晚上一起吃饭吗?",
"body": { "text": "晚上一起吃饭吗?" },
"ext": {},
"quote": null
}
}
}
```
**POST /api/Message/WithDraw** — 撤回消息
参数:`msgId` (query)
响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
**GET /api/Message/GetMessages** — 拉取消息
| 参数 | 类型 | 说明 |
|------|------|------|
| conversationId | guid | 会话 ID |
| cursor | long? | 游标 |
| direction | int | 方向 |
| limit | int | 数量 |
请求示例:
```
GET /api/Message/GetMessages?conversationId=d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a&cursor=1021&direction=0&limit=20
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"messages": [
{
"id": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"clientMsgId": "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"chatType": "PRIVATE",
"msgType": 0,
"senderId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"state": "Sent",
"streamKey": "stream:private:xxx",
"sequenceId": 1021,
"creationTime": "2026-06-26T18:35:00+00:00",
"content": { "fallback": "晚上一起吃饭吗?", "body": { "text": "晚上一起吃饭吗?" }, "ext": {}, "quote": null }
}
],
"hasmore": true
}
}
```
**消息类型 (MsgType)**`"Text"` 文本, `"Image"` 图片, `"Voice"` 语音, `"Video"` 视频, `"File"` 文件, `"VoiceChat"` 语音通话, `"VideoChat"` 视频通话
**会话类型 (ChatType)**`"PRIVATE"` 单聊, `"GROUP"` 群聊
---
## 6. 文件服务 (FileService.WebApi)
> 需认证。路由为 `api/File` 与 `api/FileTask`。
### 文件 (File) — 小文件直传/下载
**POST /api/File/simple-upload** — 单次直传(头像/封面等),**支持秒传**
- Content-Type: `multipart/form-data`
- 表单字段:`file` (文件), `isPublic` (booltrue 落公开桶返回直链)
- 秒传:服务端计算文件 MD5 后,若已存在相同 checksum 的文件则直接返回已有记录,**跳过存储写入**
- 响应:`Result<FileResponse>`
请求示例(form-data):
```
file: (二进制文件)
isPublic: true
```
秒传命中响应(与正常上传返回一致,但实际未写入存储):
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"fileName": "avatar.png",
"fileSize": 20480,
"contentType": "image/png",
"state": "Uploaded",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-public",
"objectKey": "2026/06/26/abc123.png",
"region": "local"
},
"checkSum": "d41d8cd98f00b204e9800998ecf8427e",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00",
"url": "https://cdn.example.com/public/avatar.png"
}
}
```
正常上传响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"fileName": "avatar.png",
"fileSize": 20480,
"contentType": "image/png",
"state": "Uploaded",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-public",
"objectKey": "2026/06/26/abc123.png",
"region": "local"
},
"checkSum": "d41d8cd98f00b204e9800998ecf8427e",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00",
"url": "https://cdn.example.com/public/avatar.png"
}
}
```
**GET /api/File/{id}** — 获取文件信息(含直链 Url,私有文件 Url 为 null
响应:`Result<FileResponse>`(结构同上;私有文件 `url``null`
**GET /api/File/{id}/content** — 鉴权下载文件内容(私有文件预览/下载)
响应:文件流 (`File` 结果,二进制;非 `Result<T>` 包装)
### 分片上传任务 (FileTask) — 大文件分片
> 完整流程:Init → GetUploadUrl → UploadPart → Complete(四步)
> 支持秒传、分片大小校验、上传进度查询。
**POST /api/FileTask/init** — 初始化上传任务(支持秒传)
| 字段 | 类型 | 说明 |
|------|------|------|
| conversationId | guid | 会话 ID |
| fileName | string | 文件名 |
| fileSize | long | 文件大小(≤ 1GB,由 `MaxObjectSizeBytes` 控制) |
| contentType | string | MIME 类型 |
| checkSum | string | 校验和(必须,用于秒传去重) |
请求示例:
```json
{
"conversationId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"fileName": "movie.mp4",
"fileSize": 104857600,
"contentType": "video/mp4",
"checkSum": "9e107d9d372bb6826bd81d3542a419d6"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"taskId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
"uploadSessionId": "sess_abc123",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-local",
"objectKey": "data/im-files/2026/06/27/movie.mp4",
"region": "local"
}
}
}
```
秒传命中(checksum 匹配已完成的文件,跳过初始化):
```json
{
"code": 0,
"message": "成功",
"data": {
"taskId": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"uploadSessionId": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-local",
"objectKey": "2026/06/26/existing.png",
"region": "local"
}
}
}
```
此时 `taskId` 即为已存在文件的 ID,前端可直接跳到 Complete。
文件过大失败:
```json
{ "code": 2402, "message": "文件大小超限", "data": null }
```
**GET /api/FileTask/Getuploadurl** — 获取分片上传地址
参数:`sessionId`, `partNum` (query)
- `partNum` 必须满足 `1 ≤ partNum ≤ totalPartCount`,越界返回 `3206`(无效分片号)
请求示例:
```
GET /api/FileTask/Getuploadurl?sessionId=sess_abc123&partNum=1
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": "https://oss.example.com/upload?sessionId=sess_abc123&partNum=1&signature=xxx"
}
```
**GET /api/FileTask/progress****查询上传进度(新增)**
参数:`sessionId` (query)
请求示例:
```
GET /api/FileTask/progress?sessionId=sess_abc123
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"sessionId": "sess_abc123",
"taskId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
"fileSize": 104857600,
"totalPartCount": 20,
"completedPartCount": 7,
"uploadedBytes": 36700160,
"progressPercent": 35
}
}
```
**POST /api/FileTask/complete** — 完成上传(合并分片)
| 字段 | 类型 | 说明 |
|------|------|------|
| sessionId | string | 会话 ID |
| parts | UploadPart[] | 分片列表(**必须与 `totalPartCount` 数量一致** |
分片数量不匹配返回 `3204`(分片数量不匹配)。
请求示例:
```json
{
"sessionId": "sess_abc123",
"parts": [
{ "partNumber": 1, "eTag": "etag-part-1", "size": 5242880 },
{ "partNumber": 2, "eTag": "etag-part-2", "size": 5242880 }
]
}
```
响应示例:`Result<FileResponse>`(结构同 simple-upload
**POST /api/FileTask/local/parts/upload** — 本地分片上传(支持分片大小校验)
表单字段:`sessionId`, `partNumber`, `file`
- 非末片大小 ≥ `MinPartSizeBytes`(默认 5MB),否则返回 `3203`(分片过小)
- 末片豁免最小值校验
请求示例(form-data):
```
sessionId: sess_abc123
partNumber: 1
file: (二进制分片)
```
---
## 附录:业务状态码表
| 范围 | 分类 | 示例 |
|------|------|------|
| 0 | 成功 | 0 成功 |
| 1000-1999 | 系统级 | 1003 参数错误, 1005 权限不足, 1006 认证失败 |
| 2000-2099 | 用户 | 2000 用户不存在, 2001 用户已存在, 2002 密码错误 |
| 2100-2199 | 好友 | 2100 好友申请已存在, 2102 已经是好友 |
| 2200-2299 | 群聊 | 2200 群不存在, 2201 已在群中, 2202 群成员已满 |
| 2300-2399 | 消息 | 2300 发送失败, 2301 消息不存在, 2302 撤回失败 |
| 2400-2499 | 文件 | 2400 上传失败, 2401 文件不存在, 2402 大小超限 |
| 3000-3099 | 管理后台 | 3000 管理员不存在, 3003 权限不足 |
| 3100-3199 | 会话 | 3100 会话不存在 |
| 3200-3299 | 分片 | 3201 分片不存在, 3202 分片合并失败, **3203 分片过小**, **3204 分片数不匹配**, **3205 会话过期**, **3206 分片号无效** |
+3 -15
View File
@@ -2,25 +2,13 @@ FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 A
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY ConnectorService/ConnectorService.csproj ConnectorService/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
RUN dotnet restore ConnectorService/ConnectorService.csproj
COPY . .
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish ConnectorService/ConnectorService.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+3 -1
View File
@@ -1,4 +1,5 @@
using IM.Commons;
using ConnectorService.Services;
using IM.Commons;
using IM.Protocols.Grpc.Conversation;
using Microsoft.Extensions.Options;
@@ -9,6 +10,7 @@ namespace ConnectorService
public void Initialize(IServiceCollection services)
{
services.AddRedisCache();
services.AddScoped<IConversationIntergrationService, ConversationIntegrationService>();
services.AddGrpcClient<ConversationInternal.ConversationInternalClient>((sp ,o) =>
{
var options = sp.GetRequiredService<IOptionsMonitor<GrpcOptions>>();
@@ -6,6 +6,12 @@ namespace ConnectorService.Services
public class ConversationIntegrationService : IConversationIntergrationService
{
private readonly ConversationInternal.ConversationInternalClient client;
public ConversationIntegrationService(ConversationInternal.ConversationInternalClient client)
{
this.client = client;
}
public async Task<List<string>> GetUserStreamKeysAsync(Guid userId)
{
var req = new GetUserStreamKeysRequest()
@@ -9,12 +9,15 @@ namespace ContactService.WebApi.Application.Dtos
/// 申请人
/// </summary>
public Guid OwnerId { get; private set; }
public string? OwnerNickName { get; set; }
public string? OwnerAvatar { get; set; }
/// <summary>
/// 被申请人
/// </summary>
public Guid TargetId { get; private set; }
public string? TargetNickName { get; set; }
public string? TargetAvatar { get; set; }
/// <summary>
/// 申请附言
@@ -1,6 +1,7 @@
using AutoMapper;
using ContactService.Domain;
using ContactService.WebApi.Application.Dtos;
using ContactService.WebApi.Application.IntegrationServices;
using IM.Commons;
namespace ContactService.WebApi.Application.FriendRequest
@@ -10,12 +11,15 @@ namespace ContactService.WebApi.Application.FriendRequest
private readonly IFriendRequestReposity reposity;
private readonly FriendRequestDomainService service;
private readonly IMapper mapper;
private readonly IIdentityIntegrationService identityService;
public FriendRequestService(IFriendRequestReposity reposity, FriendRequestDomainService service, IMapper mapper)
public FriendRequestService(IFriendRequestReposity reposity, FriendRequestDomainService service,
IMapper mapper, IIdentityIntegrationService identityService)
{
this.reposity = reposity;
this.service = service;
this.mapper = mapper;
this.identityService = identityService;
}
public async Task<Result<FriendRequestResponse>> CreateAsync(CreateFriendRequestCommand command)
@@ -25,7 +29,9 @@ namespace ContactService.WebApi.Application.FriendRequest
{
return Result<FriendRequestResponse>.Fail(request);
}
return Result<FriendRequestResponse>.Success(mapper.Map<FriendRequestResponse>(request.Data));
var response = mapper.Map<FriendRequestResponse>(request.Data);
await PopulateUserInfoAsync(response);
return Result<FriendRequestResponse>.Success(response);
}
public async Task<Result<FriendRequestResponse>> UpdateStatusAsync(FriendRequestHandleCommand command)
@@ -55,27 +61,81 @@ namespace ContactService.WebApi.Application.FriendRequest
default:
return Result<FriendRequestResponse>.Fail(ResultCode.PARAMETER_ERROR);
}
return Result<FriendRequestResponse>.Success(mapper.Map<FriendRequestResponse>(request));
var response = mapper.Map<FriendRequestResponse>(request);
await PopulateUserInfoAsync(response);
return Result<FriendRequestResponse>.Success(response);
}
public async Task<Result<List<FriendRequestResponse>>> GetByOwnerIdAsync(Guid ownerId)
{
var requests = await reposity.FindByOwnerIdAsync(ownerId);
return Result<List<FriendRequestResponse>>.Success(mapper.Map<List<FriendRequestResponse>>(requests));
var responses = mapper.Map<List<FriendRequestResponse>>(requests);
await PopulateUserInfoAsync(responses);
return Result<List<FriendRequestResponse>>.Success(responses);
}
public async Task<Result<List<FriendRequestResponse>>> GetByTargetIdAsync(Guid targetId)
{
var requests = await reposity.FindByTargetIdAsync(targetId);
return Result<List<FriendRequestResponse>>.Success(mapper.Map<List<FriendRequestResponse>>(requests));
var responses = mapper.Map<List<FriendRequestResponse>>(requests);
await PopulateUserInfoAsync(responses);
return Result<List<FriendRequestResponse>>.Success(responses);
}
public async Task<Result<List<FriendRequestResponse>>> GetByTargetIdOrOwnerIdAsync(Guid id)
{
var requests = await reposity.FindByTargetIdAsync(id);
var requests2 = await reposity.FindByOwnerIdAsync(id);
return Result<List<FriendRequestResponse>>.Success(mapper.Map<List<FriendRequestResponse>>(requests.Concat(requests2)));
var responses = mapper.Map<List<FriendRequestResponse>>(requests.Concat(requests2));
await PopulateUserInfoAsync(responses);
return Result<List<FriendRequestResponse>>.Success(responses);
}
private async Task PopulateUserInfoAsync(List<FriendRequestResponse> responses)
{
if (responses.Count == 0) return;
var allUserIds = responses.Select(r => r.OwnerId)
.Concat(responses.Select(r => r.TargetId))
.Distinct()
.ToList();
var userResult = await identityService.FindByIdsAsync(allUserIds);
if (!userResult.Succeeded || userResult.Data == null) return;
var userDict = userResult.Data;
foreach (var r in responses)
{
if (userDict.TryGetValue(r.OwnerId, out var owner))
{
r.OwnerNickName = owner.NickName;
r.OwnerAvatar = owner.Avatar;
}
if (userDict.TryGetValue(r.TargetId, out var target))
{
r.TargetNickName = target.NickName;
r.TargetAvatar = target.Avatar;
}
}
}
private async Task PopulateUserInfoAsync(FriendRequestResponse response)
{
var ids = new List<Guid> { response.OwnerId, response.TargetId }.Distinct().ToList();
var userResult = await identityService.FindByIdsAsync(ids);
if (!userResult.Succeeded || userResult.Data == null) return;
var userDict = userResult.Data;
if (userDict.TryGetValue(response.OwnerId, out var owner))
{
response.OwnerNickName = owner.NickName;
response.OwnerAvatar = owner.Avatar;
}
if (userDict.TryGetValue(response.TargetId, out var target))
{
response.TargetNickName = target.NickName;
response.TargetAvatar = target.Avatar;
}
}
}
}
@@ -6,5 +6,6 @@ namespace ContactService.WebApi.Application.IntegrationServices
public interface IIdentityIntegrationService
{
Task<Result<UserInfoDto>> FindUserByIdAsync(Guid id);
Task<Result<Dictionary<Guid, UserInfoDto>>> FindByIdsAsync(List<Guid> ids);
}
}
@@ -27,7 +27,7 @@ namespace ContactService.WebApi.Application.IntegrationServices
{
Avatar = res.Avatar,
CreationTime = res.CreationTime.ToDateTimeOffset(),
Deletion = res.Deletion.ToDateTimeOffset(),
Deletion = res.Deletion != null ? res.Deletion.ToDateTimeOffset() : null,
Description = res.Description,
Email = res.Email,
Id = Guid.Parse(res.Id),
@@ -40,8 +40,40 @@ namespace ContactService.WebApi.Application.IntegrationServices
{
return Result.Fail<UserInfoDto>(ResultCode.USER_NOT_FOUND);
}
}
public async Task<Result<Dictionary<Guid, UserInfoDto>>> FindByIdsAsync(List<Guid> ids)
{
if (ids == null || ids.Count == 0)
return Result.Success(new Dictionary<Guid, UserInfoDto>());
var req = new GetUserListRequest();
req.UserIds.AddRange(ids.Select(x => x.ToString()));
try
{
var res = await client.GetUserListAsyncAsync(req);
var dict = res.Users.ToDictionary(
u => Guid.Parse(u.Id),
u => new UserInfoDto
{
Id = Guid.Parse(u.Id),
UserName = u.UserName,
NickName = u.NickName,
Email = u.Email,
Phone = u.Phone,
Region = u.Region,
Description = u.Description,
Avatar = u.Avatar,
CreationTime = u.CreationTime.ToDateTimeOffset(),
Deletion = u.Deletion != null ? u.Deletion.ToDateTimeOffset() : null
});
return Result.Success(dict);
}
catch (RpcException)
{
return Result.Fail<Dictionary<Guid, UserInfoDto>>(ResultCode.USER_NOT_FOUND);
}
}
}
}
+3 -17
View File
@@ -1,27 +1,13 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY ContactService.WebApi/ContactService.WebApi.csproj ContactService.WebApi/
COPY ContactService.Domain/ContactService.Domain.csproj ContactService.Domain/
COPY ContactService.Infrastructure/ContactService.Infrastructure.csproj ContactService.Infrastructure/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
COPY Infrastructure/IM.Infrastructure.csproj Infrastructure/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
RUN dotnet restore ContactService.WebApi/ContactService.WebApi.csproj
COPY . .
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish ContactService.WebApi/ContactService.WebApi.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
@@ -1,4 +1,5 @@
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
namespace FileService.Application.Ports
{
@@ -8,5 +9,21 @@ namespace FileService.Application.Ports
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token);
/// <summary>
/// 单次直传:把整个文件一次性写入存储(小文件,不分片)。
/// </summary>
public Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token);
/// <summary>
/// 生成可直接访问的公开直链。
/// 文件位于公开桶/目录返回直链;私有文件返回 null(需走鉴权下载接口)。
/// </summary>
public string? GetPublicUrl(StorageLocation location);
/// <summary>
/// 打开文件读取流(用于私有文件鉴权下载)。
/// </summary>
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token);
}
}
@@ -3,6 +3,23 @@ using FileService.Domain.ValueObjects;
namespace FileService.Application.StorageContracts
{
/// <summary>
/// 单次直传命令(小文件,一次性写入,不分片)。
/// </summary>
/// <param name="ProviderCode">储存提供商编号</param>
/// <param name="Bucket">目标桶;传入公开桶名即落到公开位置</param>
/// <param name="ObjectKey">对象 key(含目录前缀)</param>
/// <param name="ContentType">内容类型</param>
/// <param name="Content">文件流</param>
/// <param name="ContentLength">文件大小</param>
public sealed record PutObjectCommand(
string ProviderCode,
string Bucket,
string ObjectKey,
string ContentType,
Stream Content,
long ContentLength);
/// <summary>
///
/// </summary>
@@ -0,0 +1,10 @@
namespace FileService.Application.UploadFile
{
/// <summary>
/// 文件下载内容:流 + 内容类型 + 文件名。
/// </summary>
public sealed record FileDownload(
Stream Content,
string ContentType,
string FileName);
}
@@ -20,5 +20,10 @@ namespace FileService.Application.UploadFile
public CheckSum CheckSum { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset Updated { get; set; }
/// <summary>
/// 公开直链;私有文件为 null,需走鉴权下载接口。
/// </summary>
public string? Url { get; set; }
}
}
@@ -0,0 +1,21 @@
namespace FileService.Application.UploadFile
{
/// <summary>
/// 单次直传命令(小文件,如头像/封面,一次性上传并同步落库)。
/// </summary>
/// <param name="OwnerId">上传者</param>
/// <param name="FileName">原始文件名</param>
/// <param name="ContentType">内容类型</param>
/// <param name="FileSize">文件大小</param>
/// <param name="Content">文件流</param>
/// <param name="IsPublic">是否落到公开桶(可生成直链)</param>
/// <param name="CheckSum">可选校验值(md5</param>
public sealed record SimpleUploadCommand(
Guid OwnerId,
string FileName,
string ContentType,
long FileSize,
Stream Content,
bool IsPublic,
string? CheckSum = null);
}
@@ -1,6 +1,11 @@
using AutoMapper;
using AutoMapper;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.IReposities;
using FileService.Domain.ValueObjects;
using IM.Commons;
using IM.InitCommon;
using Microsoft.Extensions.Options;
namespace FileService.Application.UploadFile
{
@@ -8,11 +13,16 @@ namespace FileService.Application.UploadFile
{
private readonly IUploadFileReposity reposity;
private readonly IMapper mapper;
private readonly IObjectStorageRouter router;
private readonly IOptions<StorageOptions> options;
public UploadFileService(IUploadFileReposity reposity, IMapper mapper)
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptions<StorageOptions> options)
{
this.reposity = reposity;
this.mapper = mapper;
this.router = router;
this.options = options;
}
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id)
@@ -23,7 +33,98 @@ namespace FileService.Application.UploadFile
return Result.Fail<FileResponse>(ResultCode.FILE_NOT_FOUND);
}
return Result.Success(mapper.Map<FileResponse>(file));
var response = mapper.Map<FileResponse>(file);
response.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
return Result.Success(response);
}
/// <summary>
/// 单次直传:一次性写入存储并同步落库(不分片、不走 MQ)。
/// 上传前按 checksum 检查是否已存在(秒传)。
/// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
{
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
if (!string.IsNullOrEmpty(command.CheckSum))
{
var existing = await reposity.FindByCheckSumGlobalAsync("md5", command.CheckSum);
if (existing != null)
{
var hit = mapper.Map<FileResponse>(existing);
var hitStorage = router.Route(existing.StorageLocation.StorageProvider);
hit.Url = hitStorage.GetPublicUrl(existing.StorageLocation);
return Result.Success(hit);
}
}
var providerOption = options.Value.Providers[options.Value.DefaultProviderCode];
var storage = router.Route(providerOption.ProviderCode);
// 公开文件落公开桶/目录,否则落私有桶
var bucket = command.IsPublic
? (providerOption.PublicBucket ?? providerOption.Bucket)
: providerOption.Bucket;
var ext = Path.GetExtension(command.FileName);
var date = DateTime.Now;
var objectKey = $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{ext}";
var location = await storage.PutObjectAsync(new PutObjectCommand(
ProviderCode: providerOption.ProviderCode,
Bucket: bucket,
ObjectKey: objectKey,
ContentType: command.ContentType,
Content: command.Content,
ContentLength: command.FileSize), token);
var file = new Domain.Entities.UploadFile(
ownerId: command.OwnerId,
fileName: SafeFileName(command.FileName),
fileSize: command.FileSize,
contentType: command.ContentType,
storageLocation: location,
checkSum: new CheckSum("md5", command.CheckSum ?? string.Empty));
reposity.Create(file);
var response = mapper.Map<FileResponse>(file);
response.Url = storage.GetPublicUrl(location);
return Result.Success(response);
}
/// <summary>
/// 打开文件下载流(鉴权下载)。返回流、内容类型与原始文件名。
/// </summary>
public async Task<Result<FileDownload>> OpenDownloadAsync(Guid id, Guid requesterId, CancellationToken token = default)
{
var file = await reposity.FindByIdAsync(id);
if (file == null)
{
return Result.Fail<FileDownload>(ResultCode.FILE_NOT_FOUND);
}
var stream = await router.Route(file.StorageLocation.StorageProvider)
.OpenReadAsync(file.StorageLocation, token);
return Result.Success(new FileDownload(
stream,
file.ContentType.Value,
file.FileName.Value));
}
// FileName 值对象限制 20 字符;原始名超长时安全截断(保留扩展名),真实文件名由 objectKey 保证唯一。
private static FileName SafeFileName(string fileName)
{
if (fileName.Length <= 20)
{
return new FileName(fileName);
}
var ext = Path.GetExtension(fileName);
var stem = Path.GetFileNameWithoutExtension(fileName);
var keep = Math.Max(0, 20 - ext.Length);
return new FileName(stem[..Math.Min(stem.Length, keep)] + ext);
}
}
}
@@ -13,7 +13,8 @@ namespace FileService.Application.UploadFileTask
public class UploadFileTaskService(IUploadTaskReposity reposity,
IMapper mapper, IObjectStorageRouter router,
IOptions<StorageOptions> options, IStorageRedisCache redis,
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
IUploadFileReposity uploadFileReposity
)
{
private readonly IUploadTaskReposity reposity = reposity;
@@ -23,11 +24,33 @@ namespace FileService.Application.UploadFileTask
private readonly IStorageRedisCache redis = redis;
private readonly IPublishEndpoint endpoint = endpoint;
private readonly ILocalChunkStorage localChunkStorage = localChunkStorage;
private readonly IUploadFileReposity uploadFileReposity = uploadFileReposity;
private readonly IObjectStoragePort storage = router.Route(options.Value.DefaultProviderCode);
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command)
{
CancellationToken cancellationToken = CancellationToken.None;
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum);
if (existingFile != null)
{
var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider);
return Result.Success(new TaskInitResponse
{
TaskId = existingFile.Id,
UploadSessionId = existingFile.Id.ToString(),
StorageLocation = existingFile.StorageLocation
});
}
// 文件大小上限校验
var maxSize = options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes;
if (command.FileSize > maxSize)
{
return Result.Fail<TaskInitResponse>(ResultCode.FILE_TOO_LARGE);
}
var task = command.ToUploadTask();
var date = DateTime.Now;
var storageOption = options.Value.Providers[options.Value.DefaultProviderCode];
@@ -41,6 +64,10 @@ namespace FileService.Application.UploadFileTask
null);
var initRes = await storage.InitUploadAsync(initUpdateCommand, cancellationToken);
var totalPartCount = (int)(task.FileSize % storageOption.DefaultPartSizeBytes > 0 ?
(task.FileSize / storageOption.DefaultPartSizeBytes) + 1 :
task.FileSize / storageOption.DefaultPartSizeBytes);
var res = mapper.Map<TaskInitResponse>(initRes);
res.TaskId = task.Id;
@@ -55,9 +82,7 @@ namespace FileService.Application.UploadFileTask
region: storageOption.Region,
objectKey: initUpdateCommand.ObjectKey,
fileSize: task.FileSize,
totalPartCount: (int)(task.FileSize % storageOption.DefaultPartSizeBytes > 0 ?
(task.FileSize / storageOption.DefaultPartSizeBytes) + 1 :
task.FileSize / storageOption.DefaultPartSizeBytes)
totalPartCount: totalPartCount
));
return Result.Success(res);
@@ -72,9 +97,9 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
}
if(taskCache.TotalPartCount < partNum)
if (taskCache.TotalPartCount < partNum || partNum < 1)
{
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
}
var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
@@ -98,21 +123,22 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if(taskCache.Parts.Count < taskCache.TotalPartCount)
// 校验分片数量必须匹配
if (command.Parts.Count != taskCache.TotalPartCount)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_COMBINE_FAIL);
return Result.Fail<UploadTaskResponse>(ResultCode.PART_COUNT_MISMATCH);
}
// 校验所有分片都已在上传缓存中注册
foreach (var part in command.Parts)
{
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
//var res = await storage.CompleteUploadAsync(new CompleteUploadCommand(
// ProviderCode: taskCache.ProviderCode,
// Bucket: taskCache.Bucket,
// Region: taskCache.Region,
// ObjectKey: taskCache.ObjectKey,
// UploadSessionId: taskCache.UploadSessionId,
// Parts: command.Parts
// ), cancellationToken);
task.CompleteUpload(new Domain.ValueObjects.StorageLocation(
taskCache.ProviderCode, taskCache.Bucket,
@@ -144,12 +170,22 @@ namespace FileService.Application.UploadFileTask
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command)
{
var taskCache = await redis.GetAsync(command.SessionId);
if (taskCache is null)
{
return Result.Fail<CompleteUploadResult>(ResultCode.CHUNK_NOT_FOUND);
}
var minPartSize = options.Value.Providers[options.Value.DefaultProviderCode].MinPartSizeBytes;
// 最后一个分片豁免最小值校验(仅校验非最后一片)
var isLastPart = command.PartNum == taskCache.TotalPartCount;
if (!isLastPart && command.ContentLength < minPartSize)
{
return Result.Fail<CompleteUploadResult>(ResultCode.PART_TOO_SMALL,
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
}
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
UploadSessionId: command.SessionId,
PartNumber: command.PartNum,
@@ -166,5 +202,32 @@ namespace FileService.Application.UploadFileTask
);
return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength));
}
/// <summary>
/// 查询分片上传进度。
/// </summary>
public async Task<Result<UploadProgressResponse>> GetProgressAsync(string sessionId, Guid userId)
{
var taskCache = await redis.GetAsync(sessionId);
if (taskCache is null)
{
return Result.Fail<UploadProgressResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var response = new UploadProgressResponse
{
SessionId = sessionId,
TaskId = taskCache.TaskId,
FileSize = taskCache.FileSize,
TotalPartCount = taskCache.TotalPartCount,
CompletedPartCount = taskCache.Parts.Count,
UploadedBytes = taskCache.UploadedBytes,
ProgressPercent = taskCache.FileSize > 0
? (int)(taskCache.UploadedBytes * 100 / taskCache.FileSize)
: 0
};
return Result.Success(response);
}
}
}
@@ -0,0 +1,16 @@
namespace FileService.Application.UploadFileTask
{
/// <summary>
/// 分片上传进度响应。
/// </summary>
public class UploadProgressResponse
{
public string SessionId { get; set; }
public string TaskId { get; set; }
public long FileSize { get; set; }
public int TotalPartCount { get; set; }
public int CompletedPartCount { get; set; }
public long UploadedBytes { get; set; }
public int ProgressPercent { get; set; }
}
}
@@ -11,6 +11,13 @@ namespace FileService.Domain.IReposities
{
void Create(UploadFile file);
Task<UploadFile?> FindByIdAsync(Guid id);
/// <summary>
/// 按上传者 + checksum 查询(用于同一用户秒传去重)
/// </summary>
Task<UploadFile?> FindByCheckSumAsync(Guid uploaderId,string value);
/// <summary>
/// 全局按 checksum 查询(用于跨用户秒传去重)
/// </summary>
Task<UploadFile?> FindByCheckSumGlobalAsync(string algorithm, string value);
}
}
@@ -35,5 +35,12 @@ namespace FileService.Infrastructure.Reposites
{
return await db.Files.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<UploadFile?> FindByCheckSumGlobalAsync(string algorithm, string value)
{
return await db.Files.FirstOrDefaultAsync(
x => x.CheckSum.Algorithm == algorithm &&
x.CheckSum.Value == value
);
}
}
}
@@ -16,6 +16,60 @@ namespace FileService.Infrastructure.Storage
public string ProviderCode => "Local";
/// <summary>
/// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。
/// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。
/// </summary>
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token)
{
var fullPath = Path.Combine(providerOptions.LocalRootPath!, command.Bucket, command.ObjectKey);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await using (var fs = new FileStream(fullPath, FileMode.Create))
{
await command.Content.CopyToAsync(fs, token);
await fs.FlushAsync(token);
}
return new StorageLocation(
storageProvider: ProviderCode,
bucket: command.Bucket,
objectKey: command.ObjectKey,
region: providerOptions.Region);
}
/// <summary>
/// 公开桶文件返回静态托管直链;私有文件返回 null。
/// </summary>
public string? GetPublicUrl(StorageLocation location)
{
if (string.IsNullOrEmpty(providerOptions.PublicBucket) ||
!string.Equals(location.Bucket, providerOptions.PublicBucket, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var baseUrl = (providerOptions.PublicBaseUrl ?? providerOptions.LocalUploadApiBaseUrl ?? string.Empty)
.TrimEnd('/');
var key = location.ObjectKey.Replace('\\', '/').TrimStart('/');
return $"{baseUrl}/static/{key}";
}
/// <summary>
/// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。
/// </summary>
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token)
{
var fullPath = Path.Combine(providerOptions.LocalRootPath!, location.Bucket, location.ObjectKey);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException(fullPath);
}
Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return Task.FromResult(stream);
}
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token)
{
var res = await MergeAsync(command.UploadSessionId, command.ObjectKey, command.Parts);
@@ -20,18 +20,40 @@ namespace FileService.Infrastructure.Storage
public async Task SetAsync(UploadRuntimeCache upload)
{
string key = RedisHelper.GetUploadInfoKey(upload.UploadSessionId);
await redis.SetAsync<UploadRuntimeCache>(key, upload);
// 过期时间取 UploadRuntimeCache 的 ExpireAt 字段,兜底默认 24 小时
var ttl = upload.ExpireAt > upload.CreatedAt
? upload.ExpireAt - upload.CreatedAt
: TimeSpan.FromHours(24);
await redis.SetAsync<UploadRuntimeCache>(key, upload, ttl);
// 维护 taskId → sessionId 映射,用于 DeleteByTaskId 快速定位
var mapKey = RedisHelper.GetUploadTaskSessionMapKey(upload.TaskId);
await redis.SetAsync(mapKey, upload.UploadSessionId, ttl);
}
public async Task DeleteAsync(string sessionId)
{
string key = RedisHelper.GetUploadInfoKey(sessionId);
// 先取出缓存以清理映射 key
var cache = await redis.GetAsync<UploadRuntimeCache>(key);
if (cache != null)
{
var mapKey = RedisHelper.GetUploadTaskSessionMapKey(cache.TaskId);
await redis.RemoveAsync(mapKey);
}
await redis.RemoveAsync(key);
}
public Task DeleteByTaskIdAsync(string taskId)
public async Task DeleteByTaskIdAsync(string taskId)
{
throw new NotImplementedException();
var mapKey = RedisHelper.GetUploadTaskSessionMapKey(taskId);
var sessionId = await redis.GetAsync<string>(mapKey);
if (sessionId != null)
{
await DeleteAsync(sessionId);
}
}
public async Task<UploadRuntimeCache?> GetAsync(string sessionId)
@@ -1,6 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using FileService.Application.UploadFile;
using FileService.Infrastructure;
using IM.ASPNETCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace FileService.WebApi.Controllers.File
{
@@ -9,7 +13,63 @@ namespace FileService.WebApi.Controllers.File
[ApiController]
public class FileController : ControllerBase
{
//[HttpGet]
//public async
private readonly UploadFileService service;
public FileController(UploadFileService service)
{
this.service = service;
}
/// <summary>
/// 单次直传(小文件:头像/封面等)。isPublic=true 落公开桶,返回直链。
/// 支持秒传:若相同 checksum 的文件已存在,直接返回已有记录,跳过上传。
/// </summary>
[HttpPost("simple-upload")]
[UnitOfWork(typeof(FileDbContext))]
public async Task<IActionResult> SimpleUpload([FromForm] IFormFile file, [FromForm] bool isPublic)
{
if (file == null || file.Length == 0)
{
return BadRequest();
}
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await using var stream = file.OpenReadStream();
var res = await service.SimpleUploadAsync(new SimpleUploadCommand(
OwnerId: Guid.Parse(userId),
FileName: file.FileName,
ContentType: file.ContentType,
FileSize: file.Length,
Content: stream,
IsPublic: isPublic));
return Ok(res);
}
/// <summary>
/// 获取文件信息(含直链 Url;私有文件 Url 为 null)。
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id)
{
var res = await service.GetFileInfoAsync(id);
return Ok(res);
}
/// <summary>
/// 鉴权下载文件内容(私有文件预览/下载走这里)。
/// </summary>
[HttpGet("{id}/content")]
public async Task<IActionResult> GetContent(Guid id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.OpenDownloadAsync(id, Guid.Parse(userId));
if (res.Data == null)
{
return NotFound(res);
}
Response.Headers["Cache-Control"] = "private,max-age=86400";
return File(res.Data.Content, res.Data.ContentType);
}
}
}
@@ -36,6 +36,14 @@ namespace FileService.WebApi.Controllers.FileTask
return Ok(res);
}
[HttpGet("progress")]
public async Task<IActionResult> Progress(string sessionId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.GetProgressAsync(sessionId, Guid.Parse(userId));
return Ok(res);
}
[HttpGet("Getuploadurl")]
public async Task<IActionResult> GetUploadUrl(string sessionId, int partNum)
{
+23
View File
@@ -0,0 +1,23 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
# 清除本地 Windows obj 中的 NuGet fallback 路径引用,让 publish 在 Linux 下重新 restore
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish FileService.WebApi/FileService.WebApi.csproj \
-c Release \
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FileService.WebApi.dll"]
+29
View File
@@ -1,5 +1,6 @@
using IM.InitCommon;
using Microsoft.Extensions.FileProviders;
namespace FileService.WebApi
{
@@ -29,10 +30,38 @@ namespace FileService.WebApi
app.UseAppDefault();
// 仅 FileService 暴露公开桶目录为静态直链,不动共享 UseAppDefault
UsePublicStaticFiles(app);
app.MapControllers();
app.Run();
}
private static void UsePublicStaticFiles(WebApplication app)
{
var storage = app.Configuration.GetSection("StorageOptions").Get<StorageOptions>();
if (storage?.Providers == null ||
!storage.Providers.TryGetValue(storage.DefaultProviderCode, out var provider))
{
return;
}
if (string.IsNullOrEmpty(provider.LocalRootPath) || string.IsNullOrEmpty(provider.PublicBucket))
{
return;
}
var publicPath = Path.Combine(provider.LocalRootPath, provider.PublicBucket);
Directory.CreateDirectory(publicPath);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(publicPath),
RequestPath = "/static",
OnPrepareResponse = ctx =>
ctx.Context.Response.Headers["Cache-Control"] = "public,max-age=2592000"
});
}
}
}
+2 -1
View File
@@ -41,7 +41,7 @@ namespace GroupService.Domain.Entities
/// <summary>
/// 群头像
/// </summary>
public string? Avatar { get; private set; } = "https://ts1.tc.mm.bing.net/th/id/OIP-C.XiB9NHwI57WPmotCbXuwawAAAA?rs=1&pid=ImgDetMain&o=7&rm=3";
public string? Avatar { get; private set; }
public long MaxSequenceId { get; private set; } = 0;
public string LastMessage { get; private set; } = string.Empty;
public string LastSenderName { get; private set; } = string.Empty;
@@ -52,6 +52,7 @@ namespace GroupService.Domain.Entities
{
Name = name;
GroupMaster = groupMaster;
Avatar = $"https://api.dicebear.com/7.x/thumbs/svg?seed={Guid.NewGuid()}";
AddDomainEvent(new GroupCreateDomainEvent(this));
}
@@ -5,6 +5,8 @@ namespace GroupService.Domain.IReposities
public interface IGroupInvitationReposity
{
void Create(GroupInvitation invitation);
void CreateRange(IEnumerable<GroupInvitation> invitations);
Task<GroupInvitation?> FindByIdAsync(Guid id);
Task<GroupInvitation?> FindByGroupIdAndUserIdAsync(Guid groupId, Guid userId);
}
}
@@ -29,6 +29,7 @@ namespace GroupService.Domain.IReposities
/// <param name="member"></param>
/// <returns></returns>
GroupMember Create(GroupMember member);
void CreateRange(IEnumerable<GroupMember> members);
/// <summary>
/// 检查成员是否存在
/// </summary>
@@ -36,5 +37,7 @@ namespace GroupService.Domain.IReposities
/// <returns>存在返回true,否则返回false</returns>
Task<bool> CheckMemberExistAsync(Guid groupId, Guid userId);
Task<GroupMember?> FindByIdAsync(Guid id);
Task<bool> CheckMemberAdminAsync(Guid groupId, Guid userId);
}
}
@@ -7,5 +7,6 @@ namespace GroupService.Domain.IReposities
void Create(GroupJoinRequest request);
Task<GroupJoinRequest> FindByIdAsync(Guid id);
Task<IEnumerable<GroupJoinRequest?>> FindByGroupIdAsync(Guid groupId);
Task<IEnumerable<GroupJoinRequest>> FindByUserIdAsync(Guid userId);
}
}
@@ -17,9 +17,21 @@ namespace GroupService.Infrastructure.Reposities
{
db.GroupInvitations.Add(invitation);
}
public void CreateRange(IEnumerable<GroupInvitation> invitations)
{
db.GroupInvitations.AddRange(invitations);
}
public async Task<GroupInvitation?> FindByIdAsync(Guid id)
{
return await db.GroupInvitations.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<GroupInvitation?> FindByGroupIdAndUserIdAsync(Guid groupId, Guid userId)
{
return await db.GroupInvitations.FirstOrDefaultAsync(
x => x.GroupId == groupId && x.UserId == userId);
}
}
}
@@ -29,5 +29,12 @@ namespace GroupService.Infrastructure.Reposities
{
return await db.GroupJoinRequests.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<IEnumerable<GroupJoinRequest>> FindByUserIdAsync(Guid userId)
{
return await db.GroupJoinRequests.Where(x =>
x.UserId == userId || x.OperatorId == userId
).ToListAsync();
}
}
}
@@ -46,5 +46,19 @@ namespace GroupService.Infrastructure.Reposities
{
return await db.GroupMembers.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<bool> CheckMemberAdminAsync(Guid groupId, Guid userId)
{
return await db.GroupMembers.AnyAsync(
x => x.GroupId == groupId && x.UserId == userId
&& (x.Role == Domain.Enums.GroupMemberRole.Administrator
|| x.Role == Domain.Enums.GroupMemberRole.Master)
);
}
public void CreateRange(IEnumerable<GroupMember> members)
{
db.GroupMembers.AddRange(members);
}
}
}
@@ -1,5 +1,5 @@
using GroupService.Domain.Events;
using GroupService.WebApi.Application.GroupRequest;
using GroupService.WebApi.Application.GroupMember;
using IM.Commons;
using IM.Commons.IntegrationEvents;
using MassTransit;
@@ -12,20 +12,19 @@ namespace GroupService.WebApi.Application.EventHandler
, INotificationHandler<GroupInvitationCreateDomainEvent>
{
private readonly IPublishEndpoint endpoint;
private readonly GroupRequestService requestService;
private readonly GroupMemberService memberService;
public GroupInvitationEventHandler(IPublishEndpoint endpoint, GroupRequestService requestService)
public GroupInvitationEventHandler(IPublishEndpoint endpoint, GroupMemberService memberService)
{
this.endpoint = endpoint;
this.requestService = requestService;
this.memberService = memberService;
}
public async Task Handle(GroupInvitationAcceptDomainEvent notification, CancellationToken cancellationToken)
{
var invitation = notification.Invitation;
var res = await requestService.CreateAsync(invitation.GroupId, invitation.UserId,
$"邀请入群"
);
// 邀请接受 → 直接加入群聊,不走入群申请审批流程
var res = await memberService.CreateAsync(invitation.GroupId, invitation.UserId);
await endpoint.Publish(new GroupInvitationAcceptEvent(invitation.Id, invitation.UserId, invitation.UserProfile.NickName, invitation.UserProfile.Avatar
, invitation.GroupId, invitation.GroupProfile.GroupName, invitation.GroupProfile.Avatar, invitation.OperatorId
@@ -34,7 +33,6 @@ namespace GroupService.WebApi.Application.EventHandler
if (!res.Succeeded)
{
throw new EventHandlerException(res.Message);
}
}
@@ -1,4 +1,5 @@
using GroupService.Domain.Events;
using GroupService.Infrastructure;
using IM.Commons.IntegrationEvents;
using MassTransit;
using MediatR;
@@ -8,18 +9,25 @@ namespace GroupService.WebApi.Application.EventHandler
public class GroupMemberJoinedHandler : INotificationHandler<GroupMemberJoinedDomainEvent>
{
private readonly IPublishEndpoint endpoint;
private readonly GroupDbContext db;
public GroupMemberJoinedHandler(IPublishEndpoint endpoint)
public GroupMemberJoinedHandler(IPublishEndpoint endpoint, GroupDbContext db)
{
this.endpoint = endpoint;
this.db = db;
}
public async Task Handle(GroupMemberJoinedDomainEvent notification, CancellationToken cancellationToken)
{
var member = notification.Member;
// 用 FindAsync 而非 FirstOrDefaultAsync:群可能刚创建尚未提交到数据库,FindAsync 先查内存跟踪器
var group = await db.Set<Domain.Entities.Group>().FindAsync(member.GroupId);
var groupName = group?.Name ?? "群聊";
var groupAvatar = group?.Avatar;
await endpoint.Publish(new GroupMemberJoinedEvent(member.Id,
member.UserId, member.GroupId, member.GroupNickName, member.Avatar,
member.Role.ToString()), cancellationToken);
member.Role.ToString(), groupName, groupAvatar), cancellationToken);
}
}
}
@@ -8,11 +8,13 @@ namespace GroupService.WebApi.Application.Group
public class GroupService
{
private readonly IGroupReposity reposity;
private readonly IGroupMemberReposity memberReposity;
private readonly IMapper mapper;
public GroupService(IGroupReposity reposity, IMapper mapper)
public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper)
{
this.reposity = reposity;
this.memberReposity = memberReposity;
this.mapper = mapper;
}
@@ -39,5 +41,19 @@ namespace GroupService.WebApi.Application.Group
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
public async Task<Result<GroupResponse>> UpdateAsync(GroupUpdateCommand command)
{
var group = await reposity.FindByIdAsync(command.GroupId);
if(group is null)
return Result.Fail<GroupResponse>(ResultCode.GROUP_NOT_FOUND);
var isAdmin = await memberReposity.CheckMemberAdminAsync(group.Id ,command.UserId);
if (!isAdmin)
return Result.Fail<GroupResponse>(ResultCode.ADMIN_PERMISSION_DENIED);
group.Update(command.GroupName, null, command.Description, command.Avatar);
return Result.Success(mapper.Map<GroupResponse>(group));
}
}
}
@@ -0,0 +1,4 @@
namespace GroupService.WebApi.Application.Group
{
public record GroupUpdateCommand(Guid UserId, Guid GroupId, string? Avatar, string? GroupName, string? Description);
}
@@ -39,6 +39,13 @@ namespace GroupService.WebApi.Application.GroupInvitation
return Result.Fail<GroupInvitationResponse>(ResultCode.PERMISSION_DENIED);
}
// 检查是否已存在对该用户的未处理邀请,避免 Duplicate entry 报错
var existing = await reposity.FindByGroupIdAndUserIdAsync(groupId, userId);
if (existing != null)
{
return Result.Success(mapper.Map<GroupInvitationResponse>(existing));
}
var group = await groupReposity.FindByIdAsync(groupId);
var userProfile = new UserProfile()
@@ -66,6 +73,54 @@ namespace GroupService.WebApi.Application.GroupInvitation
return Result.Success(mapper.Map<GroupInvitationResponse>(invitation));
}
public async Task<Result<object>> CreateBatchAsync(Guid operatorId, List<Guid> userIds, Guid groupId)
{
var group = await groupReposity.FindByIdAsync(groupId);
if (group is null)
return Result.Fail(ResultCode.GROUP_NOT_FOUND);
// 校验操作者是否有权限(群成员/管理员/群主)
var operatorMember = await memberReposity.FindOneByGroupIdAndUserIdAsync(groupId, operatorId);
if (operatorMember == null)
return Result.Fail(ResultCode.PERMISSION_DENIED);
var operatorInfo = await idService.FindUserByIdAsync(operatorId);
var operatorProfile = new UserProfile()
{
Avatar = operatorInfo.Data?.Avatar,
NickName = operatorInfo.Data?.NickName ?? string.Empty
};
var groupProfile = new GroupProfile()
{
Avatar = group.Avatar,
GroupName = group.Name
};
// 批量创建邀请,跳过已存在未处理邀请的用户
foreach (var userId in userIds)
{
var existing = await reposity.FindByGroupIdAndUserIdAsync(groupId, userId);
if (existing != null)
continue;
var userRes = await idService.FindUserByIdAsync(userId);
if (!userRes.Succeeded)
continue;
var userProfile = new UserProfile()
{
Avatar = userRes.Data?.Avatar,
NickName = userRes.Data?.NickName ?? string.Empty
};
var invitation = new Domain.Entities.GroupInvitation(
groupId, groupProfile, userId, userProfile, operatorId, operatorProfile);
reposity.Create(invitation);
}
return Result.Success();
}
public async Task<Result<object>> HandleAsync(GroupInvitationHandleCommand command)
{
var invitation = await reposity.FindByIdAsync(command.InvitationId);
@@ -96,6 +96,11 @@ namespace GroupService.WebApi.Application.GroupRequest
return Result.Success(mapper.Map<GroupRequestResponse>(request));
}
public async Task<Result<List<GroupRequestResponse>>> GetListAsync(Guid userId)
{
var list = await reposity.FindByUserIdAsync(userId);
return Result.Success(mapper.Map<List<GroupRequestResponse>>(list.ToList()));
}
}
}
@@ -14,6 +14,35 @@ namespace GroupService.WebApi.Application.IntegrationServices
this.client = client;
}
public async Task<Result<List<UserInfoDto>>> FindByIdsAsync(List<Guid> ids)
{
try
{
var request = new GetUserListRequest();
request.UserIds.AddRange(ids.Select(x => x.ToString()));
var response = await client.GetUserListAsyncAsync(request);
var users = response.Users.Select(x => new UserInfoDto
{
Avatar = x.Avatar,
CreationTime = x.CreationTime.ToDateTimeOffset(),
Deletion = x.Deletion?.ToDateTimeOffset(),
Description = x.Description,
Email = x.Email,
Id = Guid.Parse(x.Id),
NickName = x.NickName,
Phone = x.Phone,
Region = x.Region,
UserName = x.UserName
}).ToList();
return Result.Success(users);
}catch(RpcException e)
{
return Result.Fail<List<UserInfoDto>>(ResultCode.USER_NOT_FOUND);
}
}
public async Task<Result<UserInfoDto>> FindUserByIdAsync(Guid id)
{
try
@@ -6,5 +6,6 @@ namespace GroupService.WebApi.Application.IntegrationServices
public interface IIdentityIntegrationService
{
Task<Result<UserInfoDto>> FindUserByIdAsync(Guid id);
Task<Result<List<UserInfoDto>>> FindByIdsAsync(List<Guid> ids);
}
}
@@ -28,11 +28,11 @@ namespace GroupService.WebApi.Controllers.Group
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetAllAsync(Guid.Parse(userId)));
}
[HttpGet("~/api/[controller]/{userId}")]
[HttpGet]
[ProducesDefaultResponseType(typeof(Result<GroupResponse>))]
public async Task<IActionResult> GetOne([FromRoute] Guid userId)
public async Task<IActionResult> GetOne(Guid groupId)
{
return Ok(await service.GetByIdAsync(userId));
return Ok(await service.GetByIdAsync(groupId));
}
[HttpPost]
@@ -42,5 +42,12 @@ namespace GroupService.WebApi.Controllers.Group
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.CreateAsync(new GroupCreateCommand(Guid.Parse(userId), request.Name)));
}
[HttpPost]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Update(GroupUpdateRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.UpdateAsync(new GroupUpdateCommand(Guid.Parse(userId), request.GroupId, request.Avatar, request.GroupName, request.Description)));
}
}
}
@@ -0,0 +1,22 @@
using FluentValidation;
namespace GroupService.WebApi.Controllers.Group
{
public class GroupUpdateRequest
{
public Guid GroupId { get; set; }
public string? GroupName { get; set; }
public string? Avatar { get; set; }
public string? Description { get; set; }
}
public class GroupUpDateRequestValidator : AbstractValidator<GroupUpdateRequest>
{
public GroupUpDateRequestValidator()
{
RuleFor(r => r.GroupId)
.NotNull()
.NotEmpty();
}
}
}
@@ -41,5 +41,12 @@ namespace GroupService.WebApi.Controllers.GroupRequest
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetByIdAsync(id, Guid.Parse(userId)));
}
[HttpGet]
public async Task<IActionResult> List()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetListAsync(Guid.Parse(userId)));
}
}
}
+3 -17
View File
@@ -1,27 +1,13 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY GroupService.WebApi/GroupService.WebApi.csproj GroupService.WebApi/
COPY GroupService.Domain/GroupService.Domain.csproj GroupService.Domain/
COPY GroupService.Infrastructure/GroupService.Infrastructure.csproj GroupService.Infrastructure/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
COPY Infrastructure/IM.Infrastructure.csproj Infrastructure/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
RUN dotnet restore GroupService.WebApi/GroupService.WebApi.csproj
COPY . .
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish GroupService.WebApi/GroupService.WebApi.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
@@ -8,8 +8,11 @@
public string GroupNickName { get; private set; }
public string? Avatar { get; private set; }
public string Role { get; private set; }
public string GroupName { get; private set; }
public string? GroupAvatar { get; private set; }
public GroupMemberJoinedEvent(Guid id, Guid userId, Guid groupId, string groupNickName, string? avatar, string role)
public GroupMemberJoinedEvent(Guid id, Guid userId, Guid groupId, string groupNickName, string? avatar, string role,
string groupName, string? groupAvatar)
{
Id = id;
UserId = userId;
@@ -17,6 +20,8 @@
GroupNickName = groupNickName;
Avatar = avatar;
Role = role;
GroupName = groupName;
GroupAvatar = groupAvatar;
}
}
}
+4
View File
@@ -12,5 +12,9 @@
public static string GetUploadPartKey(Guid taskId) => $"upload:task:{taskId}:parts";
public static string MergeStatus(Guid taskId) => $"upload:task:{taskId}:merge";
public static string GetUploadInfoKey(string sessionId) => $"upload:task:{sessionId}:info";
/// <summary>
/// taskId → sessionId 映射(用于按 taskId 快速定位 session 缓存)
/// </summary>
public static string GetUploadTaskSessionMapKey(string taskId) => $"upload:task:{taskId}:session";
}
}
+13 -1
View File
@@ -154,6 +154,18 @@ namespace IM.Commons
CHUNK_NOT_FOUND = 3201,
/// <summary>分片合并异常</summary>
[Description("分片合并失败")]
CHUNK_COMBINE_FAIL = 3202
CHUNK_COMBINE_FAIL = 3202,
/// <summary>分片小于最小限制</summary>
[Description("分片大小过小")]
PART_TOO_SMALL = 3203,
/// <summary>分片数量不匹配</summary>
[Description("分片数量不匹配")]
PART_COUNT_MISMATCH = 3204,
/// <summary>上传会话已过期</summary>
[Description("上传会话已过期")]
UPLOAD_SESSION_EXPIRED = 3205,
/// <summary>分片号超出范围</summary>
[Description("分片号无效")]
INVALID_PART_NUMBER = 3206
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
{
public class ConsulOption
{
public string Url { get; private set; } = "http://192.168.5.100:8500";
public string Url { get; private set; } = "http://192.168.5.100:8501";
}
}
+6
View File
@@ -22,6 +22,12 @@ namespace IM.InitCommon
public string Bucket { get; init; } = default!;
/// <summary>
/// 公开读桶名;Local provider 下作为 public 目录名使用。
/// 文件所在 Bucket == PublicBucket 即视为公开,可生成直链。
/// </summary>
public string? PublicBucket { get; init; }
public string Region { get; init; } = default!;
public string? Endpoint { get; init; }
@@ -30,7 +30,7 @@ namespace IM.InitCommon
// 优先从环境变量读取
string consulUrl =
Environment.GetEnvironmentVariable("CONSUL_URL")
?? "http://192.168.5.100:8500";
?? "http://192.168.5.100:8501";
string consulKey = $"IM/{env.EnvironmentName}/appsettings.json";
string serviceConsulKey = $"IM/{env.EnvironmentName}/{serviceName}/appsettings.json";
+3 -1
View File
@@ -40,7 +40,9 @@ namespace IM.Jwt
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) // 假设你的 SignalR 路径是 /hub
// SignalR WebSocket 握手时 token 通过 query string 传递(不是 Authorization 头)
// Hub 路径为 /chat(见 ConnectorService/Program.cs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/chat"))
{
context.Token = accessToken;
}
+21 -5
View File
@@ -9,26 +9,42 @@ option csharp_namespace = "IM.Protocols.Grpc.User";
package User;
service UserInternal {
// 获取用户信息
// 获取单个用户信息
rpc GetUserInfoAsync (GetUserInfoRequest) returns (UserResponse);
// 批量获取用户信息
rpc GetUserListAsync (GetUserListRequest) returns (GetUserListResponse);
}
// 请求参数:Guid 在 proto 中通常传 string 格式
// ======================== 请求 ========================
// 获取单个用户
message GetUserInfoRequest {
string userId = 1;
}
// 批量获取用户
message GetUserListRequest {
repeated string userIds = 1;
}
// ======================== 响应 ========================
message UserResponse {
string id = 1; // Guid 映射为 string
string id = 1;
string userName = 2;
string nickName = 3;
optional string email = 4; // 使用 optional 对应 string?
optional string email = 4;
optional string phone = 5;
string region = 6;
string description = 7;
optional string avatar = 8;
// 使用官方时间戳类型
google.protobuf.Timestamp creationTime = 9;
optional google.protobuf.Timestamp deletion = 10;
}
// 批量返回
message GetUserListResponse {
repeated UserResponse users = 1;
}
@@ -52,6 +52,7 @@ namespace MessageService.Domain.Entities
UnreadCount = unreadCount;
ChatType = chatType;
LastMessage = lastMessage;
ModificationTime = DateTime.Now;
StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(targetId) : StreamKeyBuilder.Private(userId, targetId);
AddDomainEvent(new ConversationCreatedDomainEvent(this));
}
@@ -61,18 +62,32 @@ namespace MessageService.Domain.Entities
if (LastReadSequenceId != null)
{
LastReadSequenceId = LastReadSequenceId.Value;
this.NotifyModified();
}
if (unreadCount != null)
{
UnreadCount += unreadCount.Value;
NotifyModified();
}
if (lastMsg != null)
{
LastMessage = lastMsg;
NotifyModified();
}
}
/// <summary>
/// 标记会话已读(清零未读数,更新最后已读消息序列号)
/// </summary>
public void MarkAsRead(long? lastReadSequenceId)
{
UnreadCount = 0;
if (lastReadSequenceId.HasValue)
LastReadSequenceId = lastReadSequenceId.Value;
}
public void UpdateProfile(string name, string avatar)
{
TargetAvatar = avatar;
@@ -39,5 +39,17 @@ namespace MessageService.WebApi.Application.Conversation
var list = await reposity.FindAllStreamKeyAsync(userId);
return Result.Success(list.ToList());
}
public async Task<Result<object>> MarkAsReadAsync(Guid conversationId, Guid userId)
{
var conversation = await reposity.FindByIdAsync(conversationId);
if (conversation is null || conversation.UserId != userId)
{
return Result.Fail<object>(ResultCode.CONVERSATION_NOT_FOUND);
}
conversation.MarkAsRead(lastReadSequenceId: null);
return Result.Success();
}
}
}
@@ -24,8 +24,8 @@ namespace MessageService.WebApi.Application.EventHandlers
reposity.Create(new Domain.Entities.Conversation(
userId: @event.UserId,
targetId: @event.GroupId,
targetAvatar: @event.Avatar,
targetName: @event.GroupNickName,
targetAvatar: @event.GroupAvatar ?? @event.Avatar,
targetName: @event.GroupName,
lastReadSequenceId: null,
unreadCount:0,
chatType: Domain.Enums.ChatType.GROUP,
@@ -1,4 +1,4 @@
using System.Net.Http.Json;
using IM.Commons;
namespace MessageService.WebApi.Application.IntegrationServices
@@ -6,6 +6,12 @@ namespace MessageService.WebApi.Application.IntegrationServices
public class GroupMemberIntegrationService : IGroupMemberIntegrationService
{
private readonly HttpClient http;
public GroupMemberIntegrationService(HttpClient http)
{
this.http = http;
}
public async Task<bool> CheckGroupMemberAsync(Guid userId, Guid groupId)
{
var result = await http.GetFromJsonAsync<Result<bool>>(
@@ -87,6 +87,7 @@ namespace MessageService.WebApi.Application.Message
}
message.WithQuote(quote);
}
message.Send();
reposity.Create(message);
return Result.Success(mapper.Map<MessageResponse>(message));
@@ -1,4 +1,6 @@
using MessageService.WebApi.Application.Conversation;
using IM.ASPNETCore;
using MessageService.Infrastructure;
using MessageService.WebApi.Application.Conversation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
@@ -30,5 +32,13 @@ namespace MessageService.WebApi.Controllers.Conversation
return Ok(await service.GetByIdAsync(id, Guid.Parse(userId)));
}
[HttpPost]
[UnitOfWork(typeof(MessageDbContext))]
public async Task<IActionResult> MarkRead([FromQuery] Guid conversationId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.MarkAsReadAsync(conversationId, Guid.Parse(userId)));
}
}
}
@@ -54,6 +54,19 @@ namespace MessageService.WebApi.Controllers.Message
RuleFor(r => r.TargetId)
.NotEmpty()
.NotNull();
// 文本消息:text 必填
When(r => r.MsgType == MessageType.Text, () =>
{
RuleFor(r => r.Text).NotEmpty().WithMessage("文本消息的 text 字段不能为空");
});
// 图片/视频/语音消息:url 必填
When(r => r.MsgType == MessageType.Image || r.MsgType == MessageType.Video
|| r.MsgType == MessageType.Voice || r.MsgType == MessageType.File, () =>
{
RuleFor(r => r.Url).NotEmpty().WithMessage("媒体消息的 url 字段不能为空");
});
}
}
}
+3 -17
View File
@@ -1,27 +1,13 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY MessageService.WebApi/MessageService.WebApi.csproj MessageService.WebApi/
COPY MessageService.Domain/MessageService.Domain.csproj MessageService.Domain/
COPY MessageService.Infrastructure/MessageService.Infrastructure.csproj MessageService.Infrastructure/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
COPY Infrastructure/IM.Infrastructure.csproj Infrastructure/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
RUN dotnet restore MessageService.WebApi/MessageService.WebApi.csproj
COPY . .
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish MessageService.WebApi/MessageService.WebApi.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
+9 -2
View File
@@ -1,6 +1,9 @@
using IM.Commons;
using IM.Application.Abstractions;
using IM.Commons;
using IM.Infrastructure.Efcore;
using IM.Protocols.Grpc.Contact;
using IM.Protocols.Grpc.User;
using MessageService.Infrastructure;
using MessageService.WebApi.Application;
using MessageService.WebApi.Application.Conversation;
using MessageService.WebApi.Application.IntegrationServices;
@@ -13,10 +16,14 @@ namespace MessageService.WebApi
public void Initialize(IServiceCollection services)
{
services.AddScoped<Application.Message.MessageService>();
services.AddScoped<IUnitOfWork, EfUnitOfWork<MessageDbContext>>();
services.AddScoped<ConversationService>();
services.AddScoped<SquenceService>();
services.AddScoped<IContactIntegrationService, ContactIntegrationService>();
services.AddScoped<IGroupMemberIntegrationService, GroupMemberIntegrationService>();
services.AddHttpClient<IGroupMemberIntegrationService, GroupMemberIntegrationService>(c =>
{
c.BaseAddress = new Uri("http://im-group-service:8080/");
});
services.AddGrpcClient<ContactInternal.ContactInternalClient>((sp, o) =>
{
var options = sp.GetRequiredService<IOptionsMonitor<GrpcOptions>>();
+2
View File
@@ -16,6 +16,7 @@ namespace MessageService.WebApi
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.ConfigExtraServices();
builder.Services.AddAllGrpcServer();
var app = builder.Build();
@@ -30,6 +31,7 @@ namespace MessageService.WebApi
app.MapControllers();
app.MapAllGrpcServer();
app.Run();
}
+1
View File
@@ -58,6 +58,7 @@ namespace IdentityService.Domain.Entities
Id = NewId.NextGuid();
UserName = username;
NickName = nickName;
Avatar = "https://api.dicebear.com/7.x/thumbs/svg?seed=" + UserName;
}
public void Ban(string reason)
@@ -20,6 +20,7 @@ namespace IdentityService.WebApi.Applications.User
Description = u.Description,
Email = u.Email,
Id = u.Id,
NickName = u.NickName,
Phone = u.PhoneNumber,
Region = u.Region,
UserName = u.UserName
@@ -31,6 +31,16 @@ namespace IdentityService.WebApi.Applications.User
return Result<UserResponse?>.Success(mapper.Map<UserResponse>(user));
}
public async Task<Result<UserResponse?>> GetUserInfoByUnameAsync(string username)
{
var user = await repository.FindByUserNameAsync(username);
if (user is null)
{
return Result<UserResponse?>.Fail(ResultCode.USER_NOT_FOUND);
}
return Result<UserResponse?>.Success(mapper.Map<UserResponse>(user));
}
public async Task<Result<UserResponse>> UpdateAsync(UserUpdateCommand command)
{
@@ -36,6 +36,12 @@ namespace IdentityService.WebApi.Controllers.User
{
return Ok(await userService.GetUserInfoAsync(userId));
}
[HttpGet]
[ProducesDefaultResponseType(typeof(Result<UserResponse?>))]
public async Task<IActionResult> FindByUname(string username)
{
return Ok(await userService.GetUserInfoByUnameAsync(username));
}
[HttpPost]
[ProducesDefaultResponseType(typeof(Result<UserResponse>))]
+4 -17
View File
@@ -1,27 +1,14 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY User.WebApi/IdentityService.WebApi.csproj User.WebApi/
COPY User.Domain/IdentityService.Domain.csproj User.Domain/
COPY User.Infrastructure/IdentityService.Infrastructure.csproj User.Infrastructure/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
COPY Infrastructure/IM.Infrastructure.csproj Infrastructure/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
RUN dotnet restore User.WebApi/IdentityService.WebApi.csproj
COPY . .
# 清除本机 obj 中的 NuGet fallback 路径引用
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish User.WebApi/IdentityService.WebApi.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
+42
View File
@@ -35,5 +35,47 @@ namespace IdentityService.WebApi.Services
UserName = res.Data.UserName
};
}
public override async Task<GetUserListResponse> GetUserListAsync(
GetUserListRequest request,
ServerCallContext context)
{
var ids = request.UserIds
.Select(Guid.Parse)
.ToList();
var res = await service.GetUsersByIdsAsync(ids);
if (!res.Succeeded)
{
throw new RpcException(new Status(StatusCode.NotFound, res.Message));
}
var response = new GetUserListResponse();
response.Users.AddRange(res.Data.Select(user =>
{
var item = new UserResponse
{
Avatar = user.Avatar ?? "",
CreationTime = user.CreationTime.ToUniversalTime().ToTimestamp(),
Description = user.Description,
Email = user.Email ?? "",
Id = user.Id.ToString(),
NickName = user.NickName,
Phone = user.Phone ?? "",
Region = user.Region,
UserName = user.UserName
};
if (user.Deletion.HasValue)
{
item.Deletion = user.Deletion.Value.ToUniversalTime().ToTimestamp();
}
return item;
}));
return response;
}
}
}
+230
View File
@@ -0,0 +1,230 @@
version: "3.8"
services:
nginx:
image: nginx:alpine
container_name: im-nginx
restart: always
ports:
- "8009:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- user-service
- contact-service
- group-service
- message-service
- file-service
- connector-service
networks:
- im-net
consul:
image: consul:1.15
container_name: im-consul
restart: always
volumes:
- ./consul/data:/consul/data
command: "agent -server -bootstrap-expect=1 -ui -client=0.0.0.0"
ports:
- "8501:8500"
networks:
- im-net
mysql:
image: mysql:8.0
container_name: im-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: im_db
MYSQL_USER: im
MYSQL_PASSWORD: im123456
ports:
- "3307:3306"
command:
--default-authentication-plugin=mysql_native_password
volumes:
- mysql-data:/var/lib/mysql
networks:
- im-net
redis:
image: redis:7
container_name: im-redis
restart: always
command: redis-server --appendonly yes
ports:
- "6380:6379"
volumes:
- redis-data:/data
networks:
- im-net
rabbitmq:
image: rabbitmq:3-management
container_name: im-rabbitmq
restart: always
environment:
RABBITMQ_DEFAULT_USER: im
RABBITMQ_DEFAULT_PASS: im123456
ports:
- "5673:5672"
- "15673:15672"
volumes:
- rabbitmq-data:/var/lib/rabbitmq
networks:
- im-net
# ── 业务服务 ──────────────────────────────────────
user-service:
image: reg.nxsir.cn/im/user-service:latest
container_name: im-user-service
restart: always
depends_on:
consul:
condition: service_started
mysql:
condition: service_started
redis:
condition: service_started
rabbitmq:
condition: service_started
ports:
- "5001:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
networks:
- im-net
contact-service:
image: reg.nxsir.cn/im/contact-service:latest
container_name: im-contact-service
restart: always
depends_on:
consul:
condition: service_started
mysql:
condition: service_started
redis:
condition: service_started
rabbitmq:
condition: service_started
user-service:
condition: service_started
ports:
- "5002:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
networks:
- im-net
group-service:
image: reg.nxsir.cn/im/group-service:latest
container_name: im-group-service
restart: always
depends_on:
consul:
condition: service_started
mysql:
condition: service_started
redis:
condition: service_started
rabbitmq:
condition: service_started
user-service:
condition: service_started
ports:
- "5003:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
networks:
- im-net
message-service:
image: reg.nxsir.cn/im/message-service:latest
container_name: im-message-service
restart: always
depends_on:
consul:
condition: service_started
mysql:
condition: service_started
redis:
condition: service_started
rabbitmq:
condition: service_started
user-service:
condition: service_started
contact-service:
condition: service_started
group-service:
condition: service_started
ports:
- "5004:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
networks:
- im-net
file-service:
image: reg.nxsir.cn/im/file-service:latest
container_name: im-file-service
restart: always
depends_on:
consul:
condition: service_started
mysql:
condition: service_started
redis:
condition: service_started
rabbitmq:
condition: service_started
ports:
- "5005:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
networks:
- im-net
connector-service:
image: reg.nxsir.cn/im/connector-service:latest
container_name: im-connector-service
restart: always
depends_on:
consul:
condition: service_started
redis:
condition: service_started
rabbitmq:
condition: service_started
message-service:
condition: service_started
ports:
- "5008:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
networks:
- im-net
networks:
im-net:
driver: bridge
volumes:
mysql-data:
redis-data:
rabbitmq-data:
+176
View File
@@ -0,0 +1,176 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_max_body_size 100m;
resolver 127.0.0.11 valid=10s ipv6=off;
server {
listen 80;
server_name localhost;
location /api/auth {
set $backend "im-user-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/user {
set $backend "im-user-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/friend {
set $backend "im-contact-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/friendrequest {
set $backend "im-contact-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/group {
set $backend "im-group-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/groupinvitation {
set $backend "im-group-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/groupmember {
set $backend "im-group-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/grouprequest {
set $backend "im-group-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/message {
set $backend "im-message-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/conversation {
set $backend "im-message-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── 文件服务 ─────────────────────────────
location /api/file {
set $backend "im-file-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/filetask {
set $backend "im-file-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
set $backend "im-file-service:8080";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── WebSocketSignalR)─────────────────
location /chat {
set $backend "im-connector-service:8080";
proxy_pass http://$backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /chatHub {
set $backend "im-connector-service:8080";
proxy_pass http://$backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import requests, json, uuid, time, threading, sys
sys.stdout.reconfigure(encoding='utf-8')
BASE = 'http://192.168.5.100:8009'
# Login
r = requests.post(f'{BASE}/api/auth/login', json={'userName':'nanxun','password':'123456'})
d = r.json()['data']
token, uid = d['token'], d['userId']
print(f'Logged in: {uid[:8]}...')
# Negotiate
nr = requests.post(f'{BASE}/chat/negotiate?access_token={token}&negotiateVersion=1',
headers={'X-Requested-With': 'XMLHttpRequest'})
neg = nr.json()
conn_id = neg['connectionId']
conn_token = neg['connectionToken']
print(f'Negotiate OK: connId={conn_id[:12]}... token={conn_token[:12]}...')
# WebSocket connect (proper SignalR protocol)
import websocket
ws_url = f'ws://192.168.5.100:8009/chat?id={conn_token}'
print(f'WS URL: {ws_url[:80]}...')
# Custom WebSocket with proper headers
class SignalRTracker:
def __init__(self):
self.open = False
self.msgs = []
self.errors = []
self.closed = False
def on_open(self, ws):
self.open = True
# Send the SignalR handshake
ws.send('{"protocol":"json","version":1}\x1e')
print('[SIGNALR] Handshake sent')
def on_message(self, ws, msg):
self.msgs.append(msg)
# SignalR messages are terminated with \x1e (record separator)
for m in msg.split('\x1e'):
m = m.strip()
if not m: continue
try:
data = json.loads(m)
t = data.get('type', 0)
if t == 1: print(f'[SIGNALR] Invocation: {str(data)[:200]}')
elif t == 6: print(f'[SIGNALR] Ping')
elif 'error' in data: print(f'[SIGNALR] Error: {data.get("error")}')
else: print(f'[SIGNALR] Msg type={t}: {str(data)[:200]}')
except:
print(f'[SIGNALR] Raw: {m[:200]}')
def on_error(self, ws, err):
self.errors.append(str(err))
print(f'[SIGNALR] Error: {str(err)[:150]}')
def on_close(self, ws, code, reason):
self.closed = True
print(f'[SIGNALR] Close: code={code} reason={str(reason)[:100]}')
tracker = SignalRTracker()
ws = websocket.WebSocketApp(ws_url,
on_open=tracker.on_open,
on_message=tracker.on_message,
on_error=tracker.on_error,
on_close=tracker.on_close,
header={'Authorization': f'Bearer {token}'})
t = threading.Thread(target=lambda: ws.run_forever(ping_interval=10), daemon=True)
t.start()
time.sleep(4)
if not tracker.open:
print('FAILED: WebSocket did not open')
print('Errors:', tracker.errors)
else:
print('SUCCESS: WebSocket connected!')
print(f'Received {len(tracker.msgs)} handshake responses')
# Now send a message
cid = str(uuid.uuid4())
mr = requests.post(f'{BASE}/api/message/send', json={
'ClientMsgId': cid, 'TargetId': '4eb50000-a3ad-9631-f5fe-08ded40a21f7',
'ChatType': 0, 'MsgType': 0, 'Text': f'signalr-push-test-{int(time.time())}'
}, headers={'Authorization': f'Bearer {token}'})
print(f'Send msg: code={mr.json()["code"]}')
time.sleep(5)
print(f'Total msgs received: {len(tracker.msgs)}')
for m in tracker.msgs:
print(f' MSG: {m[:300]}')
if tracker.closed:
print(f'\nConnection was closed')
print(f'\nSummary: connected={tracker.open} msgs={len(tracker.msgs)} errors={len(tracker.errors)}')