diff --git a/API.md b/API.md new file mode 100644 index 0000000..cd5717e --- /dev/null +++ b/API.md @@ -0,0 +1,947 @@ +# IM_API_NEW 接口文档 + +基于微服务架构的即时通讯系统,包含 6 个独立 WebApi 服务。 + +## 通用说明 + +### 统一响应格式 `Result` + +```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` + +请求示例: +```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` + +请求示例: +```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` + +请求示例: +```json +{ + "refreshToken": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" +} +``` + +响应示例:同 Login 的 `Result`。 + +**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` + +响应示例: +```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`(结构同上) + +### GET /api/User/FindByUname?username={string} — 按用户名查询 +响应:`Result`(结构同上) + +### POST /api/User/Update — 更新资料 +请求体(均可选):`nickName`、`region`、`avatar`、`description` +响应:`Result` + +请求示例: +```json +{ + "nickName": "张三丰", + "region": "湖北·武当山", + "avatar": "https://cdn.example.com/avatar/new.png", + "description": "太极宗师" +} +``` + +### POST /api/User/GetUsersByIds — 批量查询用户 +请求体:`["guid", "guid"]`(Guid 数组) +响应:`Result>` + +请求示例: +```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` + +请求示例(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`(结构同上;私有文件 `url` 为 `null`) + +**GET /api/File/{id}/content** — 鉴权下载文件内容(私有文件预览/下载) +响应:文件流 (`File` 结果,二进制;非 `Result` 包装) + +### 分片上传任务 (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`(结构同 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 分片号无效** | diff --git a/ConnectorService/Dockerfile b/ConnectorService/Dockerfile index 2437479..c0ce4c8 100644 --- a/ConnectorService/Dockerfile +++ b/ConnectorService/Dockerfile @@ -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 diff --git a/ConnectorService/ModuleInit.cs b/ConnectorService/ModuleInit.cs index 11b68e2..51b7205 100644 --- a/ConnectorService/ModuleInit.cs +++ b/ConnectorService/ModuleInit.cs @@ -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(); services.AddGrpcClient((sp ,o) => { var options = sp.GetRequiredService>(); diff --git a/ConnectorService/Services/ConversationIntegrationService.cs b/ConnectorService/Services/ConversationIntegrationService.cs index 0cd472b..2b81440 100644 --- a/ConnectorService/Services/ConversationIntegrationService.cs +++ b/ConnectorService/Services/ConversationIntegrationService.cs @@ -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> GetUserStreamKeysAsync(Guid userId) { var req = new GetUserStreamKeysRequest() diff --git a/ContactService.WebApi/Application/Dtos/FriendRequestResponse.cs b/ContactService.WebApi/Application/Dtos/FriendRequestResponse.cs index 196ff67..9a45650 100644 --- a/ContactService.WebApi/Application/Dtos/FriendRequestResponse.cs +++ b/ContactService.WebApi/Application/Dtos/FriendRequestResponse.cs @@ -6,23 +6,26 @@ namespace ContactService.WebApi.Application.Dtos { public Guid Id { get; private set; } /// - /// 申请人 + /// 申请人 /// public Guid OwnerId { get; private set; } + public string? OwnerNickName { get; set; } + public string? OwnerAvatar { get; set; } /// - /// 被申请人 + /// 被申请人 /// public Guid TargetId { get; private set; } - + public string? TargetNickName { get; set; } + public string? TargetAvatar { get; set; } /// - /// 申请附言 + /// 申请附言 /// public string Description { get; private set; } /// - /// 申请状态(0:待通过,1:拒绝,2:同意,3:拉黑) + /// 申请状态(0:待通过,1:拒绝,2:同意,3:拉黑) /// public FriendRequestStatus State { get; private set; } diff --git a/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs b/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs index 3c7cc5d..4ec6078 100644 --- a/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs +++ b/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs @@ -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> CreateAsync(CreateFriendRequestCommand command) @@ -25,7 +29,9 @@ namespace ContactService.WebApi.Application.FriendRequest { return Result.Fail(request); } - return Result.Success(mapper.Map(request.Data)); + var response = mapper.Map(request.Data); + await PopulateUserInfoAsync(response); + return Result.Success(response); } public async Task> UpdateStatusAsync(FriendRequestHandleCommand command) @@ -55,27 +61,81 @@ namespace ContactService.WebApi.Application.FriendRequest default: return Result.Fail(ResultCode.PARAMETER_ERROR); } - return Result.Success(mapper.Map(request)); + var response = mapper.Map(request); + await PopulateUserInfoAsync(response); + return Result.Success(response); } public async Task>> GetByOwnerIdAsync(Guid ownerId) { var requests = await reposity.FindByOwnerIdAsync(ownerId); - return Result>.Success(mapper.Map>(requests)); - + var responses = mapper.Map>(requests); + await PopulateUserInfoAsync(responses); + return Result>.Success(responses); } public async Task>> GetByTargetIdAsync(Guid targetId) { var requests = await reposity.FindByTargetIdAsync(targetId); - return Result>.Success(mapper.Map>(requests)); + var responses = mapper.Map>(requests); + await PopulateUserInfoAsync(responses); + return Result>.Success(responses); } public async Task>> GetByTargetIdOrOwnerIdAsync(Guid id) { var requests = await reposity.FindByTargetIdAsync(id); var requests2 = await reposity.FindByOwnerIdAsync(id); - return Result>.Success(mapper.Map>(requests.Concat(requests2))); + var responses = mapper.Map>(requests.Concat(requests2)); + await PopulateUserInfoAsync(responses); + return Result>.Success(responses); + } + + private async Task PopulateUserInfoAsync(List 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 { 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; + } } } } diff --git a/ContactService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs b/ContactService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs index 4c89f3e..64220d9 100644 --- a/ContactService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs +++ b/ContactService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs @@ -6,5 +6,6 @@ namespace ContactService.WebApi.Application.IntegrationServices public interface IIdentityIntegrationService { Task> FindUserByIdAsync(Guid id); + Task>> FindByIdsAsync(List ids); } } diff --git a/ContactService.WebApi/Application/IntegrationServices/IdentityIntegrationService.cs b/ContactService.WebApi/Application/IntegrationServices/IdentityIntegrationService.cs index ccf2d4b..ee5589f 100644 --- a/ContactService.WebApi/Application/IntegrationServices/IdentityIntegrationService.cs +++ b/ContactService.WebApi/Application/IntegrationServices/IdentityIntegrationService.cs @@ -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(ResultCode.USER_NOT_FOUND); } - + } + public async Task>> FindByIdsAsync(List ids) + { + if (ids == null || ids.Count == 0) + return Result.Success(new Dictionary()); + + 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>(ResultCode.USER_NOT_FOUND); + } } } } diff --git a/ContactService.WebApi/Dockerfile b/ContactService.WebApi/Dockerfile index 6bd9464..7acfbec 100644 --- a/ContactService.WebApi/Dockerfile +++ b/ContactService.WebApi/Dockerfile @@ -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 diff --git a/FileService.Application/Ports/IObjectStoragePort.cs b/FileService.Application/Ports/IObjectStoragePort.cs index e543344..b69b9af 100644 --- a/FileService.Application/Ports/IObjectStoragePort.cs +++ b/FileService.Application/Ports/IObjectStoragePort.cs @@ -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 InitUploadAsync(InitiateUploadCommand command,CancellationToken token); public Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token); public Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token); + + /// + /// 单次直传:把整个文件一次性写入存储(小文件,不分片)。 + /// + public Task PutObjectAsync(PutObjectCommand command, CancellationToken token); + + /// + /// 生成可直接访问的公开直链。 + /// 文件位于公开桶/目录返回直链;私有文件返回 null(需走鉴权下载接口)。 + /// + public string? GetPublicUrl(StorageLocation location); + + /// + /// 打开文件读取流(用于私有文件鉴权下载)。 + /// + public Task OpenReadAsync(StorageLocation location, CancellationToken token); } } diff --git a/FileService.Application/StorageContracts/StorageDto.cs b/FileService.Application/StorageContracts/StorageDto.cs index eb82bd1..66c112c 100644 --- a/FileService.Application/StorageContracts/StorageDto.cs +++ b/FileService.Application/StorageContracts/StorageDto.cs @@ -4,7 +4,24 @@ using FileService.Domain.ValueObjects; namespace FileService.Application.StorageContracts { /// - /// + /// 单次直传命令(小文件,一次性写入,不分片)。 + /// + /// 储存提供商编号 + /// 目标桶;传入公开桶名即落到公开位置 + /// 对象 key(含目录前缀) + /// 内容类型 + /// 文件流 + /// 文件大小 + public sealed record PutObjectCommand( + string ProviderCode, + string Bucket, + string ObjectKey, + string ContentType, + Stream Content, + long ContentLength); + + /// + /// /// /// 储存提供商编号 /// 储存桶名称 diff --git a/FileService.Application/UploadFile/FileDownload.cs b/FileService.Application/UploadFile/FileDownload.cs new file mode 100644 index 0000000..73121f4 --- /dev/null +++ b/FileService.Application/UploadFile/FileDownload.cs @@ -0,0 +1,10 @@ +namespace FileService.Application.UploadFile +{ + /// + /// 文件下载内容:流 + 内容类型 + 文件名。 + /// + public sealed record FileDownload( + Stream Content, + string ContentType, + string FileName); +} diff --git a/FileService.Application/UploadFile/FileResponse.cs b/FileService.Application/UploadFile/FileResponse.cs index bf8df21..98b2ec7 100644 --- a/FileService.Application/UploadFile/FileResponse.cs +++ b/FileService.Application/UploadFile/FileResponse.cs @@ -20,5 +20,10 @@ namespace FileService.Application.UploadFile public CheckSum CheckSum { get; set; } public DateTimeOffset Created { get; set; } public DateTimeOffset Updated { get; set; } + + /// + /// 公开直链;私有文件为 null,需走鉴权下载接口。 + /// + public string? Url { get; set; } } } diff --git a/FileService.Application/UploadFile/SimpleUploadCommand.cs b/FileService.Application/UploadFile/SimpleUploadCommand.cs new file mode 100644 index 0000000..c2a99ee --- /dev/null +++ b/FileService.Application/UploadFile/SimpleUploadCommand.cs @@ -0,0 +1,21 @@ +namespace FileService.Application.UploadFile +{ + /// + /// 单次直传命令(小文件,如头像/封面,一次性上传并同步落库)。 + /// + /// 上传者 + /// 原始文件名 + /// 内容类型 + /// 文件大小 + /// 文件流 + /// 是否落到公开桶(可生成直链) + /// 可选校验值(md5) + public sealed record SimpleUploadCommand( + Guid OwnerId, + string FileName, + string ContentType, + long FileSize, + Stream Content, + bool IsPublic, + string? CheckSum = null); +} diff --git a/FileService.Application/UploadFile/UploadFileService.cs b/FileService.Application/UploadFile/UploadFileService.cs index beccbda..9670aa1 100644 --- a/FileService.Application/UploadFile/UploadFileService.cs +++ b/FileService.Application/UploadFile/UploadFileService.cs @@ -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 options; - public UploadFileService(IUploadFileReposity reposity, IMapper mapper) + public UploadFileService(IUploadFileReposity reposity, IMapper mapper, + IObjectStorageRouter router, IOptions options) { this.reposity = reposity; this.mapper = mapper; + this.router = router; + this.options = options; } public async Task> GetFileInfoAsync(Guid id) @@ -23,7 +33,98 @@ namespace FileService.Application.UploadFile return Result.Fail(ResultCode.FILE_NOT_FOUND); } - return Result.Success(mapper.Map(file)); + var response = mapper.Map(file); + response.Url = router.Route(file.StorageLocation.StorageProvider) + .GetPublicUrl(file.StorageLocation); + return Result.Success(response); + } + + /// + /// 单次直传:一次性写入存储并同步落库(不分片、不走 MQ)。 + /// 上传前按 checksum 检查是否已存在(秒传)。 + /// + public async Task> 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(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(file); + response.Url = storage.GetPublicUrl(location); + return Result.Success(response); + } + + /// + /// 打开文件下载流(鉴权下载)。返回流、内容类型与原始文件名。 + /// + public async Task> OpenDownloadAsync(Guid id, Guid requesterId, CancellationToken token = default) + { + var file = await reposity.FindByIdAsync(id); + if (file == null) + { + return Result.Fail(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); } } } diff --git a/FileService.Application/UploadFileTask/UploadFileTaskService.cs b/FileService.Application/UploadFileTask/UploadFileTaskService.cs index cf0cf5a..d8b679c 100644 --- a/FileService.Application/UploadFileTask/UploadFileTaskService.cs +++ b/FileService.Application/UploadFileTask/UploadFileTaskService.cs @@ -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 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> 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(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(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> 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(ResultCode.CHUNK_NOT_FOUND); } - if(taskCache.TotalPartCount < partNum) + if (taskCache.TotalPartCount < partNum || partNum < 1) { - return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + return Result.Fail(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(ResultCode.CHUNK_NOT_FOUND); } - - if(taskCache.Parts.Count < taskCache.TotalPartCount) + // 校验分片数量必须匹配 + if (command.Parts.Count != taskCache.TotalPartCount) { - return Result.Fail(ResultCode.CHUNK_COMBINE_FAIL); + return Result.Fail(ResultCode.PART_COUNT_MISMATCH); + } + + // 校验所有分片都已在上传缓存中注册 + foreach (var part in command.Parts) + { + if (!taskCache.Parts.TryGetValue(part.PartNumber, out _)) + { + return Result.Fail(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> UploadPartAsync(UploadPartCommand command) { - var taskCache = await redis.GetAsync(command.SessionId); - if(taskCache is null) + if (taskCache is null) { return Result.Fail(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(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)); } + + /// + /// 查询分片上传进度。 + /// + public async Task> GetProgressAsync(string sessionId, Guid userId) + { + var taskCache = await redis.GetAsync(sessionId); + if (taskCache is null) + { + return Result.Fail(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); + } } } diff --git a/FileService.Application/UploadFileTask/UploadProgressResponse.cs b/FileService.Application/UploadFileTask/UploadProgressResponse.cs new file mode 100644 index 0000000..77d59dd --- /dev/null +++ b/FileService.Application/UploadFileTask/UploadProgressResponse.cs @@ -0,0 +1,16 @@ +namespace FileService.Application.UploadFileTask +{ + /// + /// 分片上传进度响应。 + /// + 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; } + } +} diff --git a/FileService.Domain/IReposities/IUploadFileReposity.cs b/FileService.Domain/IReposities/IUploadFileReposity.cs index 5ac907d..cea61d4 100644 --- a/FileService.Domain/IReposities/IUploadFileReposity.cs +++ b/FileService.Domain/IReposities/IUploadFileReposity.cs @@ -10,7 +10,14 @@ namespace FileService.Domain.IReposities public interface IUploadFileReposity { void Create(UploadFile file); - Task FindByIdAsync(Guid id); + Task FindByIdAsync(Guid id); + /// + /// 按上传者 + checksum 查询(用于同一用户秒传去重) + /// Task FindByCheckSumAsync(Guid uploaderId,string value); + /// + /// 全局按 checksum 查询(用于跨用户秒传去重) + /// + Task FindByCheckSumGlobalAsync(string algorithm, string value); } } diff --git a/FileService.Infrastructure/Reposites/UploadFileReposity.cs b/FileService.Infrastructure/Reposites/UploadFileReposity.cs index 9672093..c55c0d2 100644 --- a/FileService.Infrastructure/Reposites/UploadFileReposity.cs +++ b/FileService.Infrastructure/Reposites/UploadFileReposity.cs @@ -35,5 +35,12 @@ namespace FileService.Infrastructure.Reposites { return await db.Files.FirstOrDefaultAsync(x => x.Id == id); } + public async Task FindByCheckSumGlobalAsync(string algorithm, string value) + { + return await db.Files.FirstOrDefaultAsync( + x => x.CheckSum.Algorithm == algorithm && + x.CheckSum.Value == value + ); + } } } diff --git a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs index 78e7518..117ed54 100644 --- a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs +++ b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs @@ -16,6 +16,60 @@ namespace FileService.Infrastructure.Storage public string ProviderCode => "Local"; + /// + /// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。 + /// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。 + /// + public async Task 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); + } + + /// + /// 公开桶文件返回静态托管直链;私有文件返回 null。 + /// + 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}"; + } + + /// + /// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。 + /// + public Task 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 CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) { var res = await MergeAsync(command.UploadSessionId, command.ObjectKey, command.Parts); diff --git a/FileService.Infrastructure/Storage/StorageCacheService.cs b/FileService.Infrastructure/Storage/StorageCacheService.cs index f3b99d8..2182ee5 100644 --- a/FileService.Infrastructure/Storage/StorageCacheService.cs +++ b/FileService.Infrastructure/Storage/StorageCacheService.cs @@ -20,18 +20,40 @@ namespace FileService.Infrastructure.Storage public async Task SetAsync(UploadRuntimeCache upload) { string key = RedisHelper.GetUploadInfoKey(upload.UploadSessionId); - await redis.SetAsync(key, upload); + // 过期时间取 UploadRuntimeCache 的 ExpireAt 字段,兜底默认 24 小时 + var ttl = upload.ExpireAt > upload.CreatedAt + ? upload.ExpireAt - upload.CreatedAt + : TimeSpan.FromHours(24); + await redis.SetAsync(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(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(mapKey); + if (sessionId != null) + { + await DeleteAsync(sessionId); + } } public async Task GetAsync(string sessionId) diff --git a/FileService.WebApi/Controllers/File/FileController.cs b/FileService.WebApi/Controllers/File/FileController.cs index d3fc302..ae4bdb4 100644 --- a/FileService.WebApi/Controllers/File/FileController.cs +++ b/FileService.WebApi/Controllers/File/FileController.cs @@ -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; + } + + /// + /// 单次直传(小文件:头像/封面等)。isPublic=true 落公开桶,返回直链。 + /// 支持秒传:若相同 checksum 的文件已存在,直接返回已有记录,跳过上传。 + /// + [HttpPost("simple-upload")] + [UnitOfWork(typeof(FileDbContext))] + public async Task 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); + } + + /// + /// 获取文件信息(含直链 Url;私有文件 Url 为 null)。 + /// + [HttpGet("{id}")] + public async Task Get(Guid id) + { + var res = await service.GetFileInfoAsync(id); + return Ok(res); + } + + /// + /// 鉴权下载文件内容(私有文件预览/下载走这里)。 + /// + [HttpGet("{id}/content")] + public async Task 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); + } } } diff --git a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs index ff88e83..28f1342 100644 --- a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs +++ b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs @@ -36,6 +36,14 @@ namespace FileService.WebApi.Controllers.FileTask return Ok(res); } + [HttpGet("progress")] + public async Task 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 GetUploadUrl(string sessionId, int partNum) { diff --git a/FileService.WebApi/Dockerfile b/FileService.WebApi/Dockerfile new file mode 100644 index 0000000..8ea0e81 --- /dev/null +++ b/FileService.WebApi/Dockerfile @@ -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"] diff --git a/FileService.WebApi/Program.cs b/FileService.WebApi/Program.cs index e2c7f49..6ce7379 100644 --- a/FileService.WebApi/Program.cs +++ b/FileService.WebApi/Program.cs @@ -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(); + 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" + }); + } } } diff --git a/GroupService.Domain/Entities/Group.cs b/GroupService.Domain/Entities/Group.cs index 37c3cdb..ae4e380 100644 --- a/GroupService.Domain/Entities/Group.cs +++ b/GroupService.Domain/Entities/Group.cs @@ -41,7 +41,7 @@ namespace GroupService.Domain.Entities /// /// 群头像 /// - 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)); } diff --git a/GroupService.Domain/IReposities/IGroupInvitationReposity.cs b/GroupService.Domain/IReposities/IGroupInvitationReposity.cs index 0fafca8..d9fa3af 100644 --- a/GroupService.Domain/IReposities/IGroupInvitationReposity.cs +++ b/GroupService.Domain/IReposities/IGroupInvitationReposity.cs @@ -5,6 +5,8 @@ namespace GroupService.Domain.IReposities public interface IGroupInvitationReposity { void Create(GroupInvitation invitation); + void CreateRange(IEnumerable invitations); Task FindByIdAsync(Guid id); + Task FindByGroupIdAndUserIdAsync(Guid groupId, Guid userId); } } diff --git a/GroupService.Domain/IReposities/IGroupMemberReposity.cs b/GroupService.Domain/IReposities/IGroupMemberReposity.cs index 5d4bb2c..a1c91f7 100644 --- a/GroupService.Domain/IReposities/IGroupMemberReposity.cs +++ b/GroupService.Domain/IReposities/IGroupMemberReposity.cs @@ -29,6 +29,7 @@ namespace GroupService.Domain.IReposities /// /// GroupMember Create(GroupMember member); + void CreateRange(IEnumerable members); /// /// 检查成员是否存在 /// @@ -36,5 +37,7 @@ namespace GroupService.Domain.IReposities /// 存在返回true,否则返回false Task CheckMemberExistAsync(Guid groupId, Guid userId); Task FindByIdAsync(Guid id); + + Task CheckMemberAdminAsync(Guid groupId, Guid userId); } } diff --git a/GroupService.Domain/IReposities/IGroupRequestReposity.cs b/GroupService.Domain/IReposities/IGroupRequestReposity.cs index 6738301..758553d 100644 --- a/GroupService.Domain/IReposities/IGroupRequestReposity.cs +++ b/GroupService.Domain/IReposities/IGroupRequestReposity.cs @@ -7,5 +7,6 @@ namespace GroupService.Domain.IReposities void Create(GroupJoinRequest request); Task FindByIdAsync(Guid id); Task> FindByGroupIdAsync(Guid groupId); + Task> FindByUserIdAsync(Guid userId); } } diff --git a/GroupService.Infrastructure/Reposities/GroupInvitationReposity.cs b/GroupService.Infrastructure/Reposities/GroupInvitationReposity.cs index a764a7e..47959bf 100644 --- a/GroupService.Infrastructure/Reposities/GroupInvitationReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupInvitationReposity.cs @@ -17,9 +17,21 @@ namespace GroupService.Infrastructure.Reposities { db.GroupInvitations.Add(invitation); } + + public void CreateRange(IEnumerable invitations) + { + db.GroupInvitations.AddRange(invitations); + } + public async Task FindByIdAsync(Guid id) { return await db.GroupInvitations.FirstOrDefaultAsync(x => x.Id == id); } + + public async Task FindByGroupIdAndUserIdAsync(Guid groupId, Guid userId) + { + return await db.GroupInvitations.FirstOrDefaultAsync( + x => x.GroupId == groupId && x.UserId == userId); + } } } diff --git a/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs b/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs index c7d6dfa..d3dc850 100644 --- a/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs @@ -29,5 +29,12 @@ namespace GroupService.Infrastructure.Reposities { return await db.GroupJoinRequests.FirstOrDefaultAsync(x => x.Id == id); } + + public async Task> FindByUserIdAsync(Guid userId) + { + return await db.GroupJoinRequests.Where(x => + x.UserId == userId || x.OperatorId == userId + ).ToListAsync(); + } } } diff --git a/GroupService.Infrastructure/Reposities/GroupMemberReposity.cs b/GroupService.Infrastructure/Reposities/GroupMemberReposity.cs index ba6f58e..d37ead1 100644 --- a/GroupService.Infrastructure/Reposities/GroupMemberReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupMemberReposity.cs @@ -46,5 +46,19 @@ namespace GroupService.Infrastructure.Reposities { return await db.GroupMembers.FirstOrDefaultAsync(x => x.Id == id); } + + public async Task 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 members) + { + db.GroupMembers.AddRange(members); + } } } diff --git a/GroupService.WebApi/Application/EventHandler/GroupInvitationEventHandler.cs b/GroupService.WebApi/Application/EventHandler/GroupInvitationEventHandler.cs index 3ed63f7..607db91 100644 --- a/GroupService.WebApi/Application/EventHandler/GroupInvitationEventHandler.cs +++ b/GroupService.WebApi/Application/EventHandler/GroupInvitationEventHandler.cs @@ -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 { 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); } } diff --git a/GroupService.WebApi/Application/EventHandler/GroupMemberJoinedHandler.cs b/GroupService.WebApi/Application/EventHandler/GroupMemberJoinedHandler.cs index 03a7fd5..ff2023a 100644 --- a/GroupService.WebApi/Application/EventHandler/GroupMemberJoinedHandler.cs +++ b/GroupService.WebApi/Application/EventHandler/GroupMemberJoinedHandler.cs @@ -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 { 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().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); } } } diff --git a/GroupService.WebApi/Application/Group/GroupService.cs b/GroupService.WebApi/Application/Group/GroupService.cs index 3ff9d2e..aadea97 100644 --- a/GroupService.WebApi/Application/Group/GroupService.cs +++ b/GroupService.WebApi/Application/Group/GroupService.cs @@ -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.Success(mapper.Map(group)); } + + public async Task> UpdateAsync(GroupUpdateCommand command) + { + var group = await reposity.FindByIdAsync(command.GroupId); + if(group is null) + return Result.Fail(ResultCode.GROUP_NOT_FOUND); + + var isAdmin = await memberReposity.CheckMemberAdminAsync(group.Id ,command.UserId); + if (!isAdmin) + return Result.Fail(ResultCode.ADMIN_PERMISSION_DENIED); + + group.Update(command.GroupName, null, command.Description, command.Avatar); + return Result.Success(mapper.Map(group)); + } } } diff --git a/GroupService.WebApi/Application/Group/GroupUpdateCommand.cs b/GroupService.WebApi/Application/Group/GroupUpdateCommand.cs new file mode 100644 index 0000000..baa608e --- /dev/null +++ b/GroupService.WebApi/Application/Group/GroupUpdateCommand.cs @@ -0,0 +1,4 @@ +namespace GroupService.WebApi.Application.Group +{ + public record GroupUpdateCommand(Guid UserId, Guid GroupId, string? Avatar, string? GroupName, string? Description); +} diff --git a/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs b/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs index 1a287cf..dec78ee 100644 --- a/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs +++ b/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs @@ -39,6 +39,13 @@ namespace GroupService.WebApi.Application.GroupInvitation return Result.Fail(ResultCode.PERMISSION_DENIED); } + // 检查是否已存在对该用户的未处理邀请,避免 Duplicate entry 报错 + var existing = await reposity.FindByGroupIdAndUserIdAsync(groupId, userId); + if (existing != null) + { + return Result.Success(mapper.Map(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(invitation)); } + public async Task> CreateBatchAsync(Guid operatorId, List 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> HandleAsync(GroupInvitationHandleCommand command) { var invitation = await reposity.FindByIdAsync(command.InvitationId); diff --git a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs index cf3c42e..faedf95 100644 --- a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs +++ b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs @@ -96,6 +96,11 @@ namespace GroupService.WebApi.Application.GroupRequest return Result.Success(mapper.Map(request)); } + public async Task>> GetListAsync(Guid userId) + { + var list = await reposity.FindByUserIdAsync(userId); + return Result.Success(mapper.Map>(list.ToList())); + } } } diff --git a/GroupService.WebApi/Application/IntegrationServices/IDentityIntegrationService.cs b/GroupService.WebApi/Application/IntegrationServices/IDentityIntegrationService.cs index 31331d7..a18164a 100644 --- a/GroupService.WebApi/Application/IntegrationServices/IDentityIntegrationService.cs +++ b/GroupService.WebApi/Application/IntegrationServices/IDentityIntegrationService.cs @@ -14,6 +14,35 @@ namespace GroupService.WebApi.Application.IntegrationServices this.client = client; } + public async Task>> FindByIdsAsync(List 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>(ResultCode.USER_NOT_FOUND); + } + + } + public async Task> FindUserByIdAsync(Guid id) { try diff --git a/GroupService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs b/GroupService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs index c283d33..93258de 100644 --- a/GroupService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs +++ b/GroupService.WebApi/Application/IntegrationServices/IIdentityIntegrationService.cs @@ -6,5 +6,6 @@ namespace GroupService.WebApi.Application.IntegrationServices public interface IIdentityIntegrationService { Task> FindUserByIdAsync(Guid id); + Task>> FindByIdsAsync(List ids); } } diff --git a/GroupService.WebApi/Controllers/Group/GroupController.cs b/GroupService.WebApi/Controllers/Group/GroupController.cs index 1187bb2..bd89aa1 100644 --- a/GroupService.WebApi/Controllers/Group/GroupController.cs +++ b/GroupService.WebApi/Controllers/Group/GroupController.cs @@ -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))] - public async Task GetOne([FromRoute] Guid userId) + public async Task 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 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))); + } } } diff --git a/GroupService.WebApi/Controllers/Group/GroupUpdateRequest.cs b/GroupService.WebApi/Controllers/Group/GroupUpdateRequest.cs new file mode 100644 index 0000000..8028af3 --- /dev/null +++ b/GroupService.WebApi/Controllers/Group/GroupUpdateRequest.cs @@ -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 + { + public GroupUpDateRequestValidator() + { + RuleFor(r => r.GroupId) + .NotNull() + .NotEmpty(); + } + } +} diff --git a/GroupService.WebApi/Controllers/GroupRequest/GroupRequestController.cs b/GroupService.WebApi/Controllers/GroupRequest/GroupRequestController.cs index 93ffef2..d656657 100644 --- a/GroupService.WebApi/Controllers/GroupRequest/GroupRequestController.cs +++ b/GroupService.WebApi/Controllers/GroupRequest/GroupRequestController.cs @@ -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 List() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetListAsync(Guid.Parse(userId))); + } } } diff --git a/GroupService.WebApi/Dockerfile b/GroupService.WebApi/Dockerfile index e924c25..4b95ac5 100644 --- a/GroupService.WebApi/Dockerfile +++ b/GroupService.WebApi/Dockerfile @@ -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 diff --git a/IM.Commons/IntegrationEvents/GroupMemberJoinedEvent.cs b/IM.Commons/IntegrationEvents/GroupMemberJoinedEvent.cs index 083bec6..f349caf 100644 --- a/IM.Commons/IntegrationEvents/GroupMemberJoinedEvent.cs +++ b/IM.Commons/IntegrationEvents/GroupMemberJoinedEvent.cs @@ -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; } } } diff --git a/IM.Commons/RedisHelper.cs b/IM.Commons/RedisHelper.cs index 7ff02eb..5573b56 100644 --- a/IM.Commons/RedisHelper.cs +++ b/IM.Commons/RedisHelper.cs @@ -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"; + /// + /// taskId → sessionId 映射(用于按 taskId 快速定位 session 缓存) + /// + public static string GetUploadTaskSessionMapKey(string taskId) => $"upload:task:{taskId}:session"; } } diff --git a/IM.Commons/Result.cs b/IM.Commons/Result.cs index 30695b9..8013100 100644 --- a/IM.Commons/Result.cs +++ b/IM.Commons/Result.cs @@ -25,7 +25,7 @@ namespace IM.Commons public static Result Success(T? data = default) => new((int)ResultCode.SUCCESS, ResultCode.SUCCESS.GetDescription(), data); public static Result Success() => new((int)ResultCode.SUCCESS, ResultCode.SUCCESS.GetDescription(), null); - + public static Result Fail(ResultCode code) => new((int)code, code.GetDescription()); public static Result Fail(ResultCode code) => new((int)code, code.GetDescription()); public static Result Fail(ResultCode code, string errorMsg) => new((int)code, errorMsg); diff --git a/IM.Commons/ResultCode.cs b/IM.Commons/ResultCode.cs index 9a59822..d2eb0f8 100644 --- a/IM.Commons/ResultCode.cs +++ b/IM.Commons/ResultCode.cs @@ -154,6 +154,18 @@ namespace IM.Commons CHUNK_NOT_FOUND = 3201, /// 分片合并异常 [Description("分片合并失败")] - CHUNK_COMBINE_FAIL = 3202 + CHUNK_COMBINE_FAIL = 3202, + /// 分片小于最小限制 + [Description("分片大小过小")] + PART_TOO_SMALL = 3203, + /// 分片数量不匹配 + [Description("分片数量不匹配")] + PART_COUNT_MISMATCH = 3204, + /// 上传会话已过期 + [Description("上传会话已过期")] + UPLOAD_SESSION_EXPIRED = 3205, + /// 分片号超出范围 + [Description("分片号无效")] + INVALID_PART_NUMBER = 3206 } } diff --git a/IM.InitCommon/ConsulOption.cs b/IM.InitCommon/ConsulOption.cs index 48ab98c..878f021 100644 --- a/IM.InitCommon/ConsulOption.cs +++ b/IM.InitCommon/ConsulOption.cs @@ -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"; } } diff --git a/IM.InitCommon/StorageOptions.cs b/IM.InitCommon/StorageOptions.cs index 060c986..f07c293 100644 --- a/IM.InitCommon/StorageOptions.cs +++ b/IM.InitCommon/StorageOptions.cs @@ -22,6 +22,12 @@ namespace IM.InitCommon public string Bucket { get; init; } = default!; + /// + /// 公开读桶名;Local provider 下作为 public 目录名使用。 + /// 文件所在 Bucket == PublicBucket 即视为公开,可生成直链。 + /// + public string? PublicBucket { get; init; } + public string Region { get; init; } = default!; public string? Endpoint { get; init; } diff --git a/IM.InitCommon/WebApplicationBuilderExtensions.cs b/IM.InitCommon/WebApplicationBuilderExtensions.cs index fbe44df..9ce8912 100644 --- a/IM.InitCommon/WebApplicationBuilderExtensions.cs +++ b/IM.InitCommon/WebApplicationBuilderExtensions.cs @@ -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"; diff --git a/IM.Jwt/WebApplicationJwtExtension.cs b/IM.Jwt/WebApplicationJwtExtension.cs index 15d8a4f..91c6af4 100644 --- a/IM.Jwt/WebApplicationJwtExtension.cs +++ b/IM.Jwt/WebApplicationJwtExtension.cs @@ -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; } diff --git a/IM.Protocols/Protos/user.proto b/IM.Protocols/Protos/user.proto index c32f583..6e4b9e0 100644 --- a/IM.Protocols/Protos/user.proto +++ b/IM.Protocols/Protos/user.proto @@ -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; } \ No newline at end of file diff --git a/MessageService.Domain/Entities/Conversation.cs b/MessageService.Domain/Entities/Conversation.cs index 3a577f9..19dcc92 100644 --- a/MessageService.Domain/Entities/Conversation.cs +++ b/MessageService.Domain/Entities/Conversation.cs @@ -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(); } } + + /// + /// 标记会话已读(清零未读数,更新最后已读消息序列号) + /// + public void MarkAsRead(long? lastReadSequenceId) + { + UnreadCount = 0; + if (lastReadSequenceId.HasValue) + LastReadSequenceId = lastReadSequenceId.Value; + } + public void UpdateProfile(string name, string avatar) { TargetAvatar = avatar; diff --git a/MessageService.WebApi/Application/Conversation/ConversationService.cs b/MessageService.WebApi/Application/Conversation/ConversationService.cs index d2dc3b9..e92bc9c 100644 --- a/MessageService.WebApi/Application/Conversation/ConversationService.cs +++ b/MessageService.WebApi/Application/Conversation/ConversationService.cs @@ -39,5 +39,17 @@ namespace MessageService.WebApi.Application.Conversation var list = await reposity.FindAllStreamKeyAsync(userId); return Result.Success(list.ToList()); } + + public async Task> MarkAsReadAsync(Guid conversationId, Guid userId) + { + var conversation = await reposity.FindByIdAsync(conversationId); + if (conversation is null || conversation.UserId != userId) + { + return Result.Fail(ResultCode.CONVERSATION_NOT_FOUND); + } + + conversation.MarkAsRead(lastReadSequenceId: null); + return Result.Success(); + } } } diff --git a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs index 5c2ca76..a2a669f 100644 --- a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs @@ -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, diff --git a/MessageService.WebApi/Application/IntegrationServices/GroupMemberIntegrationService.cs b/MessageService.WebApi/Application/IntegrationServices/GroupMemberIntegrationService.cs index 2ff1284..f1fc357 100644 --- a/MessageService.WebApi/Application/IntegrationServices/GroupMemberIntegrationService.cs +++ b/MessageService.WebApi/Application/IntegrationServices/GroupMemberIntegrationService.cs @@ -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 CheckGroupMemberAsync(Guid userId, Guid groupId) { var result = await http.GetFromJsonAsync>( diff --git a/MessageService.WebApi/Application/Message/MessageService.cs b/MessageService.WebApi/Application/Message/MessageService.cs index 4ebdfbf..3f42c72 100644 --- a/MessageService.WebApi/Application/Message/MessageService.cs +++ b/MessageService.WebApi/Application/Message/MessageService.cs @@ -87,6 +87,7 @@ namespace MessageService.WebApi.Application.Message } message.WithQuote(quote); } + message.Send(); reposity.Create(message); return Result.Success(mapper.Map(message)); diff --git a/MessageService.WebApi/Controllers/Conversation/ConversationController.cs b/MessageService.WebApi/Controllers/Conversation/ConversationController.cs index d113eb4..d774d91 100644 --- a/MessageService.WebApi/Controllers/Conversation/ConversationController.cs +++ b/MessageService.WebApi/Controllers/Conversation/ConversationController.cs @@ -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 MarkRead([FromQuery] Guid conversationId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.MarkAsReadAsync(conversationId, Guid.Parse(userId))); + } + } } diff --git a/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs b/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs index f4f6265..039a3ec 100644 --- a/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs +++ b/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs @@ -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 字段不能为空"); + }); } } } diff --git a/MessageService.WebApi/Dockerfile b/MessageService.WebApi/Dockerfile index 9e18f48..6aeaa36 100644 --- a/MessageService.WebApi/Dockerfile +++ b/MessageService.WebApi/Dockerfile @@ -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 diff --git a/MessageService.WebApi/ModuleInit.cs b/MessageService.WebApi/ModuleInit.cs index 9005ac9..e8bb4a0 100644 --- a/MessageService.WebApi/ModuleInit.cs +++ b/MessageService.WebApi/ModuleInit.cs @@ -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(); + services.AddScoped>(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddHttpClient(c => + { + c.BaseAddress = new Uri("http://im-group-service:8080/"); + }); services.AddGrpcClient((sp, o) => { var options = sp.GetRequiredService>(); diff --git a/MessageService.WebApi/Program.cs b/MessageService.WebApi/Program.cs index 74741e0..40735d1 100644 --- a/MessageService.WebApi/Program.cs +++ b/MessageService.WebApi/Program.cs @@ -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(); } diff --git a/User.Domain/Entities/User.cs b/User.Domain/Entities/User.cs index 653a139..d5547cd 100644 --- a/User.Domain/Entities/User.cs +++ b/User.Domain/Entities/User.cs @@ -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) diff --git a/User.WebApi/Applications/User/UserResponseFindSpecification.cs b/User.WebApi/Applications/User/UserResponseFindSpecification.cs index 0e52185..9241235 100644 --- a/User.WebApi/Applications/User/UserResponseFindSpecification.cs +++ b/User.WebApi/Applications/User/UserResponseFindSpecification.cs @@ -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 diff --git a/User.WebApi/Applications/User/UserService.cs b/User.WebApi/Applications/User/UserService.cs index 7655006..7b9f3af 100644 --- a/User.WebApi/Applications/User/UserService.cs +++ b/User.WebApi/Applications/User/UserService.cs @@ -31,6 +31,16 @@ namespace IdentityService.WebApi.Applications.User return Result.Success(mapper.Map(user)); } + public async Task> GetUserInfoByUnameAsync(string username) + { + var user = await repository.FindByUserNameAsync(username); + if (user is null) + { + return Result.Fail(ResultCode.USER_NOT_FOUND); + } + + return Result.Success(mapper.Map(user)); + } public async Task> UpdateAsync(UserUpdateCommand command) { diff --git a/User.WebApi/Controllers/User/UserController.cs b/User.WebApi/Controllers/User/UserController.cs index 11027e4..e76d3b7 100644 --- a/User.WebApi/Controllers/User/UserController.cs +++ b/User.WebApi/Controllers/User/UserController.cs @@ -36,6 +36,12 @@ namespace IdentityService.WebApi.Controllers.User { return Ok(await userService.GetUserInfoAsync(userId)); } + [HttpGet] + [ProducesDefaultResponseType(typeof(Result))] + public async Task FindByUname(string username) + { + return Ok(await userService.GetUserInfoByUnameAsync(username)); + } [HttpPost] [ProducesDefaultResponseType(typeof(Result))] diff --git a/User.WebApi/Dockerfile b/User.WebApi/Dockerfile index ebccd37..5d92f71 100644 --- a/User.WebApi/Dockerfile +++ b/User.WebApi/Dockerfile @@ -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 diff --git a/User.WebApi/Services/UserService.cs b/User.WebApi/Services/UserService.cs index 0c65e96..e36bd20 100644 --- a/User.WebApi/Services/UserService.cs +++ b/User.WebApi/Services/UserService.cs @@ -35,5 +35,47 @@ namespace IdentityService.WebApi.Services UserName = res.Data.UserName }; } + public override async Task 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; + } } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..958017e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..b0cf69d --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} diff --git a/test_signalr.py b/test_signalr.py new file mode 100644 index 0000000..0dcf0c9 --- /dev/null +++ b/test_signalr.py @@ -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)}')