Compare commits
2
Commits
ffa4dab061
...
d07051943e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07051943e | ||
|
|
dc7a9a18c7 |
@@ -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` (bool,true 落公开桶返回直链)
|
||||
- 秒传:服务端计算文件 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 分片号无效** |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -6,23 +6,26 @@ namespace ContactService.WebApi.Application.Dtos
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
/// <summary>
|
||||
/// 申请人
|
||||
/// 申请人
|
||||
/// </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>
|
||||
/// 申请附言
|
||||
/// 申请附言
|
||||
/// </summary>
|
||||
public string Description { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请状态(0:待通过,1:拒绝,2:同意,3:拉黑)
|
||||
/// 申请状态(0:待通过,1:拒绝,2:同意,3:拉黑)
|
||||
/// </summary>
|
||||
public FriendRequestStatus State { get; private set; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,24 @@ 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>
|
||||
/// <param name="ProviderCode">储存提供商编号</param>
|
||||
/// <param name="Bucket">储存桶名称</param>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
public class UploadFileTaskService(IUploadTaskReposity reposity,
|
||||
IMapper mapper, IObjectStorageRouter router,
|
||||
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);
|
||||
@@ -67,14 +92,14 @@ namespace FileService.Application.UploadFileTask
|
||||
public async Task<Result<PresignedUrl>> GenerateUrlAsync(string sessionId, int partNum, Guid userId, CancellationToken token = default)
|
||||
{
|
||||
var taskCache = await redis.GetAsync(sessionId);
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
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(
|
||||
@@ -93,26 +118,27 @@ namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
var taskCache = await redis.GetAsync(command.UploadSessionId);
|
||||
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
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,
|
||||
@@ -125,7 +151,7 @@ namespace FileService.Application.UploadFileTask
|
||||
Bucket = taskCache.Bucket,
|
||||
FileName = task.FileName.ToString(),
|
||||
ObjectKey = taskCache.ObjectKey,
|
||||
Parts = command.Parts.Select(s =>
|
||||
Parts = command.Parts.Select(s =>
|
||||
new IM.Commons.IntegrationEvents.UploadPart(
|
||||
s.PartNumber, s.ETag, s.Size, s.Checksum)
|
||||
).ToList(),
|
||||
@@ -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)
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,14 @@ namespace FileService.Domain.IReposities
|
||||
public interface IUploadFileReposity
|
||||
{
|
||||
void Create(UploadFile file);
|
||||
Task<UploadFile?> FindByIdAsync(Guid id);
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace IM.Commons
|
||||
public static Result<T> Success<T>(T? data = default) => new((int)ResultCode.SUCCESS, ResultCode.SUCCESS.GetDescription(), data);
|
||||
|
||||
public static Result<object> Success() => new((int)ResultCode.SUCCESS, ResultCode.SUCCESS.GetDescription(), null);
|
||||
|
||||
|
||||
public static Result<T> Fail<T>(ResultCode code) => new((int)code, code.GetDescription());
|
||||
public static Result<object> Fail(ResultCode code) => new((int)code, code.GetDescription());
|
||||
public static Result<T> Fail<T>(ResultCode code, string errorMsg) => new((int)code, errorMsg);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
|
||||
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,
|
||||
|
||||
+7
-1
@@ -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 字段不能为空");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
# ── WebSocket(SignalR)─────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}')
|
||||
Reference in New Issue
Block a user