Compare commits

...
Author SHA1 Message Date
nanxun f2ab2dc8a3 add(Adminweb):新增管理端 2026-09-15 14:14:35 +08:00
nanxun 53e6195938 fix: align backend APIs and upload flow 2026-09-15 14:10:55 +08:00
nanxun 898cf5dba0 fix: align backend APIs and upload flow 2026-09-11 18:18:59 +08:00
nanxunandClaude Fable 5 32177a7293 ci: 补充 FileService 构建阶段,去掉 sudo
- 新增 构建 FileService stage(file-service / FileService.WebApi/Dockerfile),
  使 6 个业务镜像与 docker-compose.yml 一致
- 移除所有 sudo docker 前缀:构建机 nanxunai 已在 docker 组,免提权运行

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:15:03 +08:00
nanxun d07051943e Merge branch 'master' of https://gitea.nxsir.cn/nanxun/IM_NEW 2026-08-31 14:42:26 +08:00
nanxun dc7a9a18c7 Merge branch 'master' of https://gitea.nxsir.cn/nanxun/IM_NEW 2026-08-31 14:42:22 +08:00
189 changed files with 7192 additions and 514 deletions
+5
View File
@@ -0,0 +1,5 @@
MYSQL_ROOT_PASSWORD=replace-with-a-strong-root-password
MYSQL_PASSWORD=replace-with-a-strong-application-password
RABBITMQ_DEFAULT_USER=replace-with-a-non-default-user
RABBITMQ_DEFAULT_PASS=replace-with-a-strong-password
IM_INTERNAL_API_KEY=replace-with-a-long-random-key
+9
View File
@@ -12,6 +12,9 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
.env
.env.*
!.env.example
# Mono auto generated files
mono_crash.*
@@ -361,3 +364,9 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
.tools/
deploy/.env.local
deploy/data/
artifacts/
**/keyring/
+962
View File
@@ -0,0 +1,962 @@
# IM_API_NEW 接口文档
基于微服务架构的即时通讯系统,包含 6 个独立 WebApi 服务。
## 通用说明
### 统一响应格式 `Result<T>`
```json
{
"code": 0,
"message": "成功",
"data": { }
}
```
- `code`:业务状态码,`0` 表示成功(见下方状态码表)
- `message`:状态描述
- `data`:业务数据,失败时为 `null`
### 认证
除特别说明外,所有接口需在请求头携带 JWT:
```
Authorization: Bearer {token}
```
当前用户 ID 从 Token 的 `NameIdentifier` 声明中获取,无需在请求体重复传递。
### 路由约定
控制器统一采用 `api/[controller]/[action]` 路由模板(FileService 例外,见对应章节)。
---
## 1. 认证服务 (User.WebApi - Auth)
> 以下接口**无需认证**。
### POST /api/Auth/Login — 登录
请求体:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| userName | string | 是 | 5-20 字符 |
| password | string | 是 | ≤50 字符 |
响应:`Result<LoginResponse>`
请求示例:
```json
{
"userName": "zhangsan",
"password": "P@ssw0rd"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"userId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"expired": "2026-06-27T10:30:00Z",
"userName": "zhangsan",
"nickName": "张三",
"avatar": "https://cdn.example.com/avatar/zhangsan.png",
"creationTime": "2026-01-15T08:00:00+00:00"
}
}
```
失败示例(密码错误,code 2002):
```json
{
"code": 2002,
"message": "密码错误",
"data": null
}
```
### POST /api/Auth/Register — 注册
请求体:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| userName | string | 是 | 5-20 字符 |
| password | string | 是 | 6-50 字符 |
| nickName | string | 是 | ≤50 字符 |
响应:`Result<UserResponse>`
请求示例:
```json
{
"userName": "zhangsan",
"password": "P@ssw0rd",
"nickName": "张三"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"userName": "zhangsan",
"nickName": "张三",
"email": null,
"phone": null,
"region": "",
"description": "",
"avatar": null,
"creationTime": "2026-06-26T08:00:00+00:00",
"deletion": null
}
}
```
### POST /api/Auth/Refresh — 刷新令牌
请求体:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| refreshToken | string | 是 | 刷新令牌 |
响应:`Result<LoginResponse>`
请求示例:
```json
{
"refreshToken": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
```
响应示例:同 Login 的 `Result<LoginResponse>`
**LoginResponse 结构**
```json
{
"userId": "guid", "token": "string", "refreshToken": "string",
"expired": "datetime", "userName": "string", "nickName": "string",
"avatar": "string|null", "creationTime": "datetime"
}
```
---
## 2. 用户服务 (User.WebApi - User)
> 需认证。
### GET /api/User/Me — 当前用户信息
响应:`Result<UserResponse>`
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"userName": "zhangsan",
"nickName": "张三",
"email": "zhangsan@example.com",
"phone": "13800000000",
"region": "广东·深圳",
"description": "这个人很懒,什么都没写",
"avatar": "https://cdn.example.com/avatar/zhangsan.png",
"creationTime": "2026-01-15T08:00:00+00:00",
"deletion": null
}
}
```
### GET /api/User/Find?userId={guid} — 查询指定用户
响应:`Result<UserResponse>`(结构同上)
### GET /api/User/FindByUname?username={string} — 按用户名查询
响应:`Result<UserResponse>`(结构同上)
### POST /api/User/Update — 更新资料
请求体(均可选):`nickName``region``avatar``description`
响应:`Result<UserResponse>`
请求示例:
```json
{
"nickName": "张三丰",
"region": "湖北·武当山",
"avatar": "https://cdn.example.com/avatar/new.png",
"description": "太极宗师"
}
```
### POST /api/User/GetUsersByIds — 批量查询用户
请求体:`["guid", "guid"]`Guid 数组)
响应:`Result<List<UserResponse>>`
请求示例:
```json
[
"8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
]
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{ "id": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b", "userName": "zhangsan", "nickName": "张三", "email": null, "phone": null, "region": "广东·深圳", "description": "", "avatar": null, "creationTime": "2026-01-15T08:00:00+00:00", "deletion": null }
]
}
```
**UserResponse 结构**
```json
{
"id": "guid", "userName": "string", "nickName": "string",
"email": "string|null", "phone": "string|null", "region": "string",
"description": "string", "avatar": "string|null",
"creationTime": "datetime", "deletion": "datetime|null"
}
```
---
## 3. 联系人服务 (ContactService.WebApi)
> 需认证。
### 好友 (Friend)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/Friend/List | 好友列表 | - |
| POST | /api/Friend/Delete | 删除好友 | `friendId` (query) |
| POST | /api/Friend/Block | 拉黑好友 | `friendId` (query) |
| GET | /api/Friend/CheckFriend | 检查好友关系 | `userId`, `targetId` (query) |
`GET /api/Friend/List` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "e5f6a7b8-c9d0-1e2f-3a4b-5c6d7e8f9a0b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"avatar": "https://cdn.example.com/avatar/lisi.png",
"nickName": "李四",
"remarkName": "老李",
"createTime": "2026-02-01T10:00:00",
"updateTime": null,
"status": "Pending"
}
]
}
```
`POST /api/Friend/Delete?friendId={guid}` 响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
`GET /api/Friend/CheckFriend?userId={guid}&targetId={guid}` 响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
### 好友请求 (FriendRequest)
**POST /api/FriendRequest/Add** — 发起好友申请
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| targetId | guid | 是 | 被申请人 |
| description | string | 否 | 附言 |
| remarkName | string | 否 | 备注名 |
请求示例:
```json
{
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"description": "我是张三,加个好友",
"remarkName": "老李"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"ownerNickName": "张三",
"ownerAvatar": "https://cdn.example.com/avatar/zhangsan.png",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"targetNickName": "李四",
"targetAvatar": "https://cdn.example.com/avatar/lisi.png",
"description": "我是张三,加个好友",
"state": "Pending",
"remarkName": "老李",
"creationTime": "2026-06-26T09:00:00+00:00",
"deletion": null,
"modificationTime": null
}
}
```
**POST /api/FriendRequest/Handle** — 处理申请
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| requestId | guid | 是 | 请求 ID |
| action | string | 是 | `"Accpet"` 同意, `"Reject"` 拒绝, `"Block"` 拉黑 |
| remarkName | string | 同意时必填 | 备注名 |
请求示例:
```json
{
"requestId": "c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f",
"action": "Accpet",
"remarkName": "张三"
}
```
响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
**GET /api/FriendRequest/List** — 我相关的申请列表
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"ownerNickName": "张三",
"ownerAvatar": "https://cdn.example.com/avatar/zhangsan.png",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"targetNickName": "李四",
"targetAvatar": "https://cdn.example.com/avatar/lisi.png",
"description": "我是张三,加个好友",
"state": "Passed",
"remarkName": "老李",
"creationTime": "2026-06-26T09:00:00+00:00",
"deletion": null,
"modificationTime": "2026-06-26T09:05:00+00:00"
}
]
}
```
**FriendRequestResponse 状态 (State)**`"Pending"` 待通过, `"Declined"` 已拒绝, `"Passed"` 已同意, `"Blocked"` 已拉黑
---
## 4. 群组服务 (GroupService.WebApi)
> 需认证(GroupMember 部分接口除外)。
### 群组 (Group)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/Group/GetAll | 我加入的群列表 | - |
| GET | /api/Group/GetOne | 群详情 | `groupId` (query) |
| POST | /api/Group/Create | 创建群 | body: `name` (≤20) |
| POST | /api/Group/Update | 更新群 | body: 见下 |
Update 请求体:
| 字段 | 类型 | 必填 |
|------|------|------|
| groupId | guid | 是 |
| groupName | string | 否 |
| avatar | string | 否 |
| description | string | 否 |
`POST /api/Group/Create` 请求示例:
```json
{ "name": "技术交流群" }
```
`POST /api/Group/Update` 请求示例:
```json
{
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"groupName": "技术交流群(2026)",
"avatar": "https://cdn.example.com/group/tech.png",
"description": "欢迎交流技术"
}
```
`GET /api/Group/GetOne?groupId={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"name": "技术交流群",
"groupMaster": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"authority": "REQUIRE_CONSENT",
"allMembersBanned": false,
"status": "Normal",
"announcement": "欢迎加入",
"avatar": "https://cdn.example.com/group/tech.png",
"maxSequenceId": 1024,
"lastMessage": "晚上好",
"lastSenderName": "张三",
"created": "2026-03-01T08:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00"
}
}
```
### 群成员 (GroupMember)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/GroupMember/CheckMember | 检查成员 | `userId`, `groupId` (query) |
| GET | /api/GroupMember/List | 成员列表 | `groupId` (query) |
| POST | /api/GroupMember/Delete | 移除成员 | `memberId` (query) |
`GET /api/GroupMember/List?groupId={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "f1e2d3c4-b5a6-9788-1a2b-3c4d5e6f7a8b",
"userId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"groupNickName": "群主张三",
"avatar": "https://cdn.example.com/avatar/zhangsan.png",
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"role": "Master",
"created": "2026-03-01T08:00:00+00:00"
}
]
}
```
**成员角色 (Role)**`"Normal"` 普通成员, `"Administrator"` 管理员, `"Master"` 群主
### 群邀请 (GroupInvitation)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| POST | /api/GroupInvitation/Send | 发送邀请 | body: `groupId`, `userId` |
| GET | /api/GroupInvitation/Get | 邀请详情 | `invitationId` (query) |
| POST | /api/GroupInvitation/Handle | 处理邀请 | `invitationId`, `action` (query) |
`POST /api/GroupInvitation/Send` 请求示例:
```json
{
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"userId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}
```
`GET /api/GroupInvitation/Get?invitationId={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"groupAvatar": "https://cdn.example.com/group/tech.png",
"groupName": "技术交流群",
"userId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"userAvatar": "https://cdn.example.com/avatar/lisi.png",
"userNickName": "李四",
"operatorId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"operatorName": "张三",
"operatorAvatar": "https://cdn.example.com/avatar/zhangsan.png",
"state": "Pending",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00"
}
}
```
**邀请处理 action**`"Accept"` 接受, `"Reject"` 拒绝
**邀请状态 (State)**`"Pending"` 待被邀请人同意, `"Passed"` 已同意, `"Reject"` 拒绝
### 入群请求 (GroupRequest)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| POST | /api/GroupRequest/Send | 申请入群 | body: `groupId`, `desc` (≤20) |
| POST | /api/GroupRequest/Handle | 处理申请 | `requestId`, `action` (query) |
| GET | /api/GroupRequest/Find | 申请详情 | `id` (query) |
| GET | /api/GroupRequest/List | 申请列表 | - |
`POST /api/GroupRequest/Send` 请求示例:
```json
{
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"desc": "我想加入学习"
}
```
`GET /api/GroupRequest/Find?id={guid}` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "c3d4e5f6-a7b8-9c0d-1e2f-3a4b5c6d7e8f",
"groupId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"groupAvatar": "https://cdn.example.com/group/tech.png",
"groupName": "技术交流群",
"userId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"userAvatar": "https://cdn.example.com/avatar/lisi.png",
"userNickName": "李四",
"operatorId": "00000000-0000-0000-0000-000000000000",
"operatorName": "",
"operatorAvatar": null,
"state": "Pending",
"description": "我想加入学习",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00"
}
}
```
**入群处理 action**`"Accept"` 接受, `"Reject"` 拒绝
**入群状态 (State)**`"Pending"` 待管理员同意, `"Declined"` 已拒绝, `"Passed"` 已通过
**群权限 (Authority)**`"REQUIRE_CONSENT"` 需管理员同意, `"ANYONE_CAN_JOIN"` 任意人可加, `"NOT_ALLOWED_TO_JOIN"` 不允许加入
**群状态 (Status)**`"Normal"` 正常, `"Blocked"` 封禁
---
## 5. 消息服务 (MessageService.WebApi)
> 需认证。
### 会话 (Conversation)
| 方法 | 路径 | 说明 | 参数 |
|------|------|------|------|
| GET | /api/Conversation/List | 会话列表 | - |
| GET | /api/Conversation/Get | 会话详情 | `id` (query) |
| POST | /api/Conversation/MarkRead | 清零未读 | `conversationId` (query) |
`GET /api/Conversation/List` 响应示例:
```json
{
"code": 0,
"message": "成功",
"data": [
{
"id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"userId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"targetAvatar": "https://cdn.example.com/avatar/lisi.png",
"targetName": "李四",
"lastReadSequenceId": 1020,
"unreadCount": 4,
"chatType": "PRIVATE",
"lastMessage": "晚上一起吃饭吗?",
"dateTime": "2026-06-26T18:30:00"
}
]
}
```
`POST /api/Conversation/MarkRead?conversationId={guid}` 响应示例:
```json
{ "code": 0, "message": "成功", "data": null }
```
### 消息 (Message)
**POST /api/Message/Send** — 发送消息
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| clientMsgId | guid | 是 | 客户端消息 ID(去重) |
| targetId | guid | 是 | 接收方(单聊=用户,群聊=群) |
| chatType | string | 是 | `"PRIVATE"` 单聊, `"GROUP"` 群聊 |
| msgType | string | 是 | 见消息类型表 |
| quoteMessageId | guid | 否 | 引用消息 ID |
| ext | object | 否 | 扩展字段 (键值对) |
| text | string | 否 | 文本内容 |
| url | string | 否 | 媒体 URL |
| width / height | int | 否 | 图片/视频尺寸 |
| thumb | string | 否 | 缩略图 |
| duration | int | 否 | 时长(语音/视频) |
文本消息请求示例:
```json
{
"clientMsgId": "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"chatType": "PRIVATE",
"msgType": "Text",
"text": "晚上一起吃饭吗?"
}
```
图片消息请求示例:
```json
{
"clientMsgId": "8f9a0b1c-2d3e-4f5a-6b7c-8d9e0f1a2b3c",
"targetId": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"chatType": "GROUP",
"msgType": "Image",
"url": "https://cdn.example.com/img/photo.jpg",
"width": 1920,
"height": 1080,
"thumb": "https://cdn.example.com/img/photo_thumb.jpg",
"quoteMessageId": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"ext": { "source": "album" }
}
```
发送响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"clientMsgId": "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"chatType": "PRIVATE",
"msgType": "Text",
"senderId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"state": "Sent",
"streamKey": "stream:private:xxx",
"sequenceId": 1021,
"creationTime": "2026-06-26T18:35:00+00:00",
"content": {
"fallback": "晚上一起吃饭吗?",
"body": { "text": "晚上一起吃饭吗?" },
"ext": {},
"quote": null
}
}
}
```
**POST /api/Message/WithDraw** — 撤回消息
参数:`msgId` (query)
响应示例:
```json
{ "code": 0, "message": "成功", "data": true }
```
**GET /api/Message/GetMessages** — 拉取消息
| 参数 | 类型 | 说明 |
|------|------|------|
| conversationId | guid | 会话 ID |
| cursor | long? | 游标 |
| direction | int | 方向 |
| limit | int | 数量 |
请求示例:
```
GET /api/Message/GetMessages?conversationId=d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a&cursor=1021&direction=0&limit=20
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"messages": [
{
"id": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"clientMsgId": "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"chatType": "PRIVATE",
"msgType": 0,
"senderId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"targetId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"state": "Sent",
"streamKey": "stream:private:xxx",
"sequenceId": 1021,
"creationTime": "2026-06-26T18:35:00+00:00",
"content": { "fallback": "晚上一起吃饭吗?", "body": { "text": "晚上一起吃饭吗?" }, "ext": {}, "quote": null }
}
],
"hasmore": true
}
}
```
**消息类型 (MsgType)**`"Text"` 文本, `"Image"` 图片, `"Voice"` 语音, `"Video"` 视频, `"File"` 文件, `"VoiceChat"` 语音通话, `"VideoChat"` 视频通话
**会话类型 (ChatType)**`"PRIVATE"` 单聊, `"GROUP"` 群聊
---
## 6. 文件服务 (FileService.WebApi)
> 需认证。路由为 `api/File` 与 `api/FileTask`。
### 文件 (File) — 小文件直传/下载
**POST /api/File/simple-upload** — 单次直传(头像/封面等),**支持秒传**
- Content-Type: `multipart/form-data`
- 表单字段:`file` (文件), `isPublic` (booltrue 落公开桶返回直链)
- 秒传:服务端计算文件 MD5 后,若已存在相同 checksum 的文件则直接返回已有记录,**跳过存储写入**
- 响应:`Result<FileResponse>`
请求示例(form-data):
```
file: (二进制文件)
isPublic: true
```
秒传命中响应(与正常上传返回一致,但实际未写入存储):
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"fileName": "avatar.png",
"fileSize": 20480,
"contentType": "image/png",
"state": "Uploaded",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-public",
"objectKey": "2026/06/26/abc123.png",
"region": "local"
},
"checkSum": "d41d8cd98f00b204e9800998ecf8427e",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00",
"url": "https://cdn.example.com/public/avatar.png"
}
}
```
正常上传响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"id": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
"fileName": "avatar.png",
"fileSize": 20480,
"contentType": "image/png",
"state": "Uploaded",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-public",
"objectKey": "2026/06/26/abc123.png",
"region": "local"
},
"checkSum": "d41d8cd98f00b204e9800998ecf8427e",
"created": "2026-06-26T09:00:00+00:00",
"updated": "2026-06-26T09:00:00+00:00",
"url": "https://cdn.example.com/public/avatar.png"
}
}
```
**GET /api/File/{id}** — 获取文件信息(含直链 Url,私有文件 Url 为 null
响应:`Result<FileResponse>`(结构同上;私有文件 `url``null`
**GET /api/File/{id}/content** — 鉴权下载文件内容(私有文件预览/下载)
响应:文件流 (`File` 结果,二进制;非 `Result<T>` 包装)
### 分片上传任务 (FileTask) — 大文件分片
> 完整流程:Init → GetUploadUrl → UploadPart → Complete(四步)
> 支持秒传、分片大小校验、上传进度查询。
**POST /api/FileTask/init** — 初始化上传任务(支持秒传)
| 字段 | 类型 | 说明 |
|------|------|------|
| conversationId | guid | 会话 ID |
| fileName | string | 文件名 |
| fileSize | long | 文件大小(≤ 1GB,由 `MaxObjectSizeBytes` 控制) |
| contentType | string | MIME 类型 |
| checkSum | string | 校验和(必须,用于秒传去重) |
请求示例:
```json
{
"conversationId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"fileName": "movie.mp4",
"fileSize": 104857600,
"contentType": "video/mp4",
"checkSum": "9e107d9d372bb6826bd81d3542a419d6"
}
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"taskId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
"uploadSessionId": "sess_abc123",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-local",
"objectKey": "data/im-files/2026/06/27/movie.mp4",
"region": "local"
}
}
}
```
秒传命中(checksum 匹配已完成的文件,跳过初始化):
```json
{
"code": 0,
"message": "成功",
"data": {
"taskId": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"uploadSessionId": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
"storageLocation": {
"storageProvider": "local",
"bucket": "im-local",
"objectKey": "2026/06/26/existing.png",
"region": "local"
}
}
}
```
此时 `taskId` 即为已存在文件的 ID,前端可直接跳到 Complete。
文件过大失败:
```json
{ "code": 2402, "message": "文件大小超限", "data": null }
```
**GET /api/FileTask/Getuploadurl** — 获取分片上传地址
参数:`sessionId`, `partNum` (query)
- `partNum` 必须满足 `1 ≤ partNum ≤ totalPartCount`,越界返回 `3206`(无效分片号)
请求示例:
```
GET /api/FileTask/Getuploadurl?sessionId=sess_abc123&partNum=1
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": "https://oss.example.com/upload?sessionId=sess_abc123&partNum=1&signature=xxx"
}
```
**GET /api/FileTask/progress****查询上传进度(新增)**
参数:`sessionId` (query)
请求示例:
```
GET /api/FileTask/progress?sessionId=sess_abc123
```
响应示例:
```json
{
"code": 0,
"message": "成功",
"data": {
"sessionId": "sess_abc123",
"taskId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
"fileSize": 104857600,
"totalPartCount": 20,
"completedPartCount": 7,
"uploadedBytes": 36700160,
"progressPercent": 35
}
}
```
**POST /api/FileTask/complete** — 完成上传(合并分片)
| 字段 | 类型 | 说明 |
|------|------|------|
| sessionId | string | 会话 ID |
| parts | UploadPart[] | 分片列表(**必须与 `totalPartCount` 数量一致** |
分片数量不匹配返回 `3204`(分片数量不匹配)。
请求示例:
```json
{
"sessionId": "sess_abc123",
"parts": [
{ "partNumber": 1, "eTag": "etag-part-1", "size": 5242880 },
{ "partNumber": 2, "eTag": "etag-part-2", "size": 5242880 }
]
}
```
响应示例:`Result<FileResponse>`(结构同 simple-upload
**POST /api/FileTask/local/parts/upload** — 本地分片上传(支持分片大小校验)
表单字段:`sessionId`, `partNumber`, `file`
- 非末片大小 ≥ `MinPartSizeBytes`(默认 5MB),否则返回 `3203`(分片过小)
- 末片豁免最小值校验
请求示例(form-data):
```
sessionId: sess_abc123
partNumber: 1
file: (二进制分片)
```
---
## 附录:业务状态码表
| 范围 | 分类 | 示例 |
|------|------|------|
| 0 | 成功 | 0 成功 |
| 1000-1999 | 系统级 | 1003 参数错误, 1005 权限不足, 1006 认证失败 |
| 2000-2099 | 用户 | 2000 用户不存在, 2001 用户已存在, 2002 密码错误 |
| 2100-2199 | 好友 | 2100 好友申请已存在, 2102 已经是好友 |
| 2200-2299 | 群聊 | 2200 群不存在, 2201 已在群中, 2202 群成员已满 |
| 2300-2399 | 消息 | 2300 发送失败, 2301 消息不存在, 2302 撤回失败 |
| 2400-2499 | 文件 | 2400 上传失败, 2401 文件不存在, 2402 大小超限 |
| 3000-3099 | 管理后台 | 3000 管理员不存在, 3003 权限不足 |
| 3100-3199 | 会话 | 3100 会话不存在 |
| 3200-3299 | 分片 | 3201 分片不存在, 3202 分片合并失败, **3203 分片过小**, **3204 分片数不匹配**, **3205 会话过期**, **3206 分片号无效** |
## 会话一致性说明
- `GET /api/Conversation/List` 的请求和响应结构不变,结果按 `ModificationTime ?? CreationTime` 倒序返回。
- 活动会话由 `(UserId, ChatType, TargetId)` 唯一确定;重复的好友或入群事件按幂等成功处理。
- `20260911000100_ConversationUniqueness` 会保留最新活动记录、合并最大未读数和最大已读序号,并软删除其余重复记录。
# 消息历史搜索
`GET /api/Message/Search` 在当前用户拥有的会话中搜索历史文本消息。
- 参数:`conversationId``keyword`(去除首尾空格后 1–50 字符)、可选独占游标 `cursor``limit`150,默认 30)。
- 只返回未撤回、未删除的文本消息,按 `sequenceId` 倒序排列。
- 响应继续使用统一 `Result`,数据结构为 `{ messages, hasmore }`。下一页以本页最后一条消息的 `sequenceId` 作为独占游标。
- `POST /api/Conversation/MarkRead` 新增可选 `lastReadSequenceId` 查询参数,旧客户端不传时仍兼容。
- 会话列表的 `dateTime` 表示最后消息活动时间;标记已读不会改变此时间或会话排序。
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.13" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.25" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
<ItemGroup><ProjectReference Include="../Admin.WebApi/Admin.WebApi.csproj"/><ProjectReference Include="../User.Infrastructure/IdentityService.Infrastructure.csproj"/><ProjectReference Include="../GroupService.Infrastructure/GroupService.Infrastructure.csproj"/><ProjectReference Include="../FileService.Infrastructure/FileService.Infrastructure.csproj"/></ItemGroup>
</Project>
+129
View File
@@ -0,0 +1,129 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Testcontainers.MySql;
using Xunit;
namespace IM.Admin.Tests;
public sealed class AdminFlowTests : IAsyncLifetime
{
readonly MySqlContainer mysql = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("admin_tests").WithUsername("test_admin").WithPassword(Convert.ToHexString(RandomNumberGenerator.GetBytes(24))).Build();
Factory factory = null!;
readonly DomainHandler domain = new();
const string Password = "Integration-password-456!";
readonly Guid superId = Guid.NewGuid(), reviewerId = Guid.NewGuid();
public async Task InitializeAsync()
{
await mysql.StartAsync(); factory = new Factory(mysql.GetConnectionString(),domain);
using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>(); await db.Database.MigrateAsync();
var hasher = new PasswordHasher<AdminAccount>();
foreach (var (id,name,role) in new[] {(superId,"root_test","super"),(reviewerId,"review_test","reviewer")}) {
var a = new AdminAccount {Id=id,Account=name,Name=name,Role=role}; a.PasswordHash=hasher.HashPassword(a,Password); db.Accounts.Add(a);
}
await db.SaveChangesAsync();
}
public async Task DisposeAsync() { await factory.DisposeAsync(); await mysql.DisposeAsync(); }
HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions {BaseAddress=new Uri("https://localhost"),AllowAutoRedirect=false,HandleCookies=true});
static async Task<JsonElement> Data(HttpResponseMessage response) { var json=await response.Content.ReadFromJsonAsync<JsonElement>(); return json.GetProperty("data").Clone(); }
static async Task<HttpResponseMessage> Mutate(HttpClient client,string path,object body,HttpMethod? method=null) {
var csrf=await Data(await client.GetAsync("/api/admin/auth/csrf"));
using var request=new HttpRequestMessage(method??HttpMethod.Post,"/api/admin"+path) {Content=JsonContent.Create(body)};
request.Headers.Add("X-CSRF-TOKEN",csrf.GetProperty("token").GetString()); return await client.SendAsync(request);
}
static async Task Login(HttpClient c,string name) => Assert.Equal(HttpStatusCode.OK,(await Mutate(c,"/auth/login",new {account=name,password=Password})).StatusCode);
async Task Process(Guid id) {
using (var scope=factory.Services.CreateScope()) { var db=scope.ServiceProvider.GetRequiredService<AdminDb>(); var op=await db.Operations.FindAsync(id); op!.NextAttemptAt=DateTime.UtcNow; await db.SaveChangesAsync(); }
await new OperationWorker(factory.Services.GetRequiredService<IServiceScopeFactory>(),NullLogger<OperationWorker>.Instance).Process(CancellationToken.None);
}
[Fact]
public async Task Cookie_csrf_permissions_versions_durable_failures_and_revocation_work_together()
{
using var root=Client(); using var reviewer=Client();
Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/settings")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,(await root.PostAsJsonAsync("/api/admin/auth/login",new {account="root_test",password=Password})).StatusCode);
await Login(root,"root_test"); await Login(reviewer,"review_test");
foreach(var route in new[]{"settings","admins","logs","health","storage"}) Assert.Equal(HttpStatusCode.Forbidden,(await reviewer.GetAsync("/api/admin/"+route)).StatusCode);
var me=await Data(await root.GetAsync("/api/admin/auth/me")); Assert.False(me.TryGetProperty("passwordHash",out _));
var settings=await Data(await root.GetAsync("/api/admin/settings")); var account=settings.EnumerateArray().Single(x=>x.GetProperty("id").GetString()=="account");
var input=new {version=account.GetProperty("version").GetInt64(),value=new {registrationEnabled=false,passwordMinLength=10},reason="测试暂停注册"};
Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode);
var platform=await Data(await root.GetAsync("/api/platform")); Assert.False(platform.GetProperty("registrationEnabled").GetBoolean());
using var internalRequest=new HttpRequestMessage(HttpMethod.Post,"/internal/management/reports") {Content=JsonContent.Create(new {reporterId=Guid.NewGuid(),type="user",targetId=domain.Target,reason="骚扰辱骂",description="服务端快照测试",messageIds=Array.Empty<Guid>()})};
internalRequest.Headers.Add("X-IM-Management-Key","integration-internal-key");
var submitted=await root.SendAsync(internalRequest); Assert.Equal(HttpStatusCode.OK,submitted.StatusCode);
var reportId=(await submitted.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetGuid();
Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/reports/{reportId}/claim",new{})).StatusCode);
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/claim",new{})).StatusCode);
var operationId=Guid.NewGuid(); var review=new {operationId,action="封禁",reason="核实违规后处置"};
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/review",review)).StatusCode);
domain.Fail=true;
Assert.Equal(HttpStatusCode.Accepted,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode);
for(var attempt=0;attempt<3;attempt++) await Process(operationId);
var pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString());
var failed=await Data(await reviewer.GetAsync($"/api/admin/operations/{operationId}")); Assert.Equal("failed",failed.GetProperty("status").GetString());
Assert.Equal(0,domain.Applied);
domain.Fail=false; domain.LoseAcknowledgement=true;
Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/operations/{operationId}/retry",new{})).StatusCode);
await Process(operationId); Assert.Equal(1,domain.Applied);
pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString());
await Process(operationId); Assert.Equal(1,domain.Applied);
var closed=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("已处理",closed.GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode);
using(var scope=factory.Services.CreateScope()) {
var db=scope.ServiceProvider.GetRequiredService<AdminDb>();
Assert.Equal(1,await db.Audit.CountAsync(x=>x.OperationId==operationId));
Assert.True(await db.Audit.AnyAsync(x=>x.Action=="查看举报证据"&&x.ReportId==reportId));
Assert.True(await db.Audit.AnyAsync(x=>x.Result.Contains("执行失败")));
}
var adminEdit=new {account="root_test",name="root_test",email="",password="",role="reviewer",enabled=false,reason="不允许停用最后超级管理员"};
Assert.Equal(HttpStatusCode.BadRequest,(await Mutate(root,$"/admins/{superId}",adminEdit,HttpMethod.Put)).StatusCode);
Assert.Equal(HttpStatusCode.OK,(await Mutate(root,$"/admins/{reviewerId}",new {account="review_test",name="review_test",email="",password="",role="reviewer",enabled=false,reason="停用测试账号"},HttpMethod.Put)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,(await reviewer.GetAsync("/api/admin/reports")).StatusCode);
Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/auth/password",new {currentPassword=Password,newPassword="New-integration-password!"})).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/auth/me")).StatusCode);
}
sealed class Factory(string connection,DomainHandler handler) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder) {
builder.UseEnvironment("Development");
builder.UseSetting("ConnectionStrings:Admin",connection);
builder.UseSetting("Management:KeyRingPath",Path.Combine(Path.GetTempPath(),"im-admin-test-keys",Guid.NewGuid().ToString("N")));
builder.UseSetting("Management:CredentialKey",Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
builder.UseSetting("Management:InternalKey","integration-internal-key");
foreach(var name in new[]{"user","group","message","contact","file","connector","admin"}) builder.UseSetting($"Management:Services:{name}",$"http://{name}.test");
builder.ConfigureServices(services=> { services.RemoveAll<IHostedService>(); services.AddHttpClient<InternalClient>().ConfigurePrimaryHttpMessageHandler(()=>handler); });
}
}
sealed class DomainHandler : HttpMessageHandler
{
public Guid Target {get;}=Guid.NewGuid(); public bool Fail; public bool LoseAcknowledgement; public int Applied;
readonly Dictionary<Guid,ActionReceipt> receipts=new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct) {
if(request.RequestUri!.AbsolutePath.EndsWith("/evidence")) return Ok(new SubjectEvidence("测试对象",[]));
if(request.RequestUri.AbsolutePath.EndsWith("/action")) {
if(Fail) return new(HttpStatusCode.ServiceUnavailable);
var command=await request.Content!.ReadFromJsonAsync<InternalAction>(cancellationToken:ct);
if(!receipts.TryGetValue(command!.Id,out var receipt)) { receipt=new("测试对象","正常","封禁"); receipts.Add(command.Id,receipt); Applied++; }
if(LoseAcknowledgement) { LoseAcknowledgement=false; throw new HttpRequestException("Injected response loss after commit"); }
return Ok(receipt);
}
return Ok(new {total=1});
}
static HttpResponseMessage Ok(object value)=>new(HttpStatusCode.OK) {Content=JsonContent.Create(value)};
}
}
+49
View File
@@ -0,0 +1,49 @@
using IM.Admin.Api;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using FileService.Infrastructure.Storage;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Nodes;
using Xunit;
namespace IM.Admin.Tests;
public class PolicyTests
{
static readonly IConfiguration Config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(new byte[32]) }).Build();
[Fact]
public void Defaults_preserve_unlimited_legacy_rules_and_explicit_report_limits()
{
foreach (var id in SettingsService.Fields.Keys) SettingsService.Validate(id, SettingsService.Defaults(id));
var p = new Policy(); Assert.True(p.RegistrationEnabled); Assert.Equal(0,p.FriendLimit); Assert.Equal(0,p.UploadMaxBytes); Assert.Equal(0,p.RecallMinutes); Assert.Equal(20,p.ReportsPerDay); Assert.Equal(10,p.ReportCooldownMinutes);
}
[Fact]
public void Validation_rejects_unknown_fields_negative_limits_and_malformed_extensions()
{
var p = SettingsService.Defaults("social"); p["friendLimit"] = -1; Assert.Throws<ApiError>(() => SettingsService.Validate("social",p));
p = SettingsService.Defaults("account"); p["password"] = "must not be persisted"; Assert.Throws<ApiError>(() => SettingsService.Validate("account",p));
p = SettingsService.Defaults("messaging"); p["allowedFileTypes"] = new JsonArray("*.exe"); Assert.Throws<ApiError>(() => SettingsService.Validate("messaging",p));
}
[Fact]
public void Credentials_are_authenticated_encrypted_and_require_the_original_deployment_key()
{
using var db = new AdminDb(new DbContextOptionsBuilder<AdminDb>().Options);
var service = new SettingsService(db,new EphemeralDataProtectionProvider(),Config);
var one = service.Protect("private-secret"); var two = service.Protect("private-secret");
Assert.NotEqual(one,two); Assert.DoesNotContain("private-secret",one); Assert.Equal("private-secret",service.Unprotect(one));
var wrong = new SettingsService(db,new EphemeralDataProtectionProvider(),new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(Enumerable.Repeat((byte)1,32).ToArray()) }).Build());
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(() => wrong.Unprotect(one));
}
[Fact]
public void Local_storage_paths_cannot_escape_the_managed_root()
{
var root = Path.Combine(Path.GetTempPath(),"im-storage-test");
Assert.StartsWith(Path.GetFullPath(root),LocalStorageAdapter.SafePath(root,"private","2026/file.txt"));
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,"..","outside.txt"));
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,Path.GetFullPath(Path.Combine(root,"..","outside.txt"))));
}
}
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
<ItemGroup>
<ProjectReference Include="../IM.InitCommon/IM.InitCommon.csproj" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using System.Security.Claims;
using IM.Admin.Data;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Api;
public sealed class ApiError(int status, string message) : Exception(message) { public int Status { get; } = status; }
public static class ApiSupport
{
public static Guid Actor(this HttpContext c) => Guid.Parse(c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public static string ActorName(this HttpContext c) => c.User.Identity?.Name ?? "";
public static bool IsSuper(this HttpContext c) => c.User.IsInRole("super");
public static void Reason(string? reason) { if (string.IsNullOrWhiteSpace(reason) || reason.Length > 500) throw new ApiError(400, "请填写 1500 字的操作原因"); }
public static void Audit(this AdminDb db, HttpContext c, string action, string target, string before, string after, string reason, Guid? reportId = null)
=> db.Audit.Add(new AuditRecord { ActorId = c.Actor(), ActorName = c.ActorName(), Action = action, TargetId = target, TargetName = target, Before = before, After = after, Reason = reason, ReportId = reportId });
public static async Task<PageResult<T>> Page<T>(IQueryable<T> query, int page, int size, CancellationToken ct)
{ page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); return new(await query.Skip((page - 1) * size).Take(size).ToListAsync(ct), await query.CountAsync(ct), page, size); }
}
+101
View File
@@ -0,0 +1,101 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Api;
public record LoginInput(string Account, string Password);
public record PasswordInput(string CurrentPassword, string NewPassword);
public record ResetRequest(string Account);
public record ResetInput(string Token, string Password);
public record AccountInput(string Account, string Name, string Email, string Password, string Role, bool Enabled, string Reason);
public static class AuthEndpoints
{
public static object Public(AdminAccount a) => new { a.Id, a.Account, a.Name, a.Email, a.Role, status = a.Enabled ? "启用" : "停用", a.CreatedAt };
public static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public static void CheckPassword(string p) { if (p.Length is < 12 or > 128) throw new ApiError(400, "管理员密码长度应为 12128 位"); }
public static void MapAuth(this WebApplication app)
{
var api = app.MapGroup("/api/admin/auth");
api.MapGet("/csrf", (HttpContext c, IAntiforgery af) => ManagementResult.Ok(new { token = af.GetAndStoreTokens(c).RequestToken }));
api.MapPost("/login", async (LoginInput input, AdminDb db, IPasswordHasher<AdminAccount> hasher, SettingsService settings, HttpContext c) => {
if (string.IsNullOrWhiteSpace(input.Account) || input.Account.Length > 100 || string.IsNullOrEmpty(input.Password) || input.Password.Length > 128) throw new ApiError(400, "账号或密码格式不正确");
var name = input.Account.Trim().ToLowerInvariant();
await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var a = await db.Accounts.FromSqlInterpolated($"SELECT * FROM admin_accounts WHERE Account = {name} FOR UPDATE").SingleOrDefaultAsync();
var policy = await settings.Policy();
if (a is null || !a.Enabled || a.LockedUntil > DateTime.UtcNow) throw new ApiError(401, "账号或密码错误,或账号暂不可用");
if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.Password) == PasswordVerificationResult.Failed) {
a.FailedAttempts++; if (a.FailedAttempts >= policy.AdminLockThreshold) a.LockedUntil = DateTime.UtcNow.AddMinutes(policy.AdminLockMinutes);
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Result = "失败", Reason = "密码验证未通过", After = a.LockedUntil.HasValue ? "临时锁定" : "登录失败" });
await db.SaveChangesAsync(); await tx.CommitAsync(); throw new ApiError(401, "账号或密码错误,或账号暂不可用");
}
a.FailedAttempts = 0; a.LockedUntil = null;
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Reason = "独立管理员登录", After = "登录成功" });
await db.SaveChangesAsync(); await tx.CommitAsync();
var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, a.Id.ToString()), new Claim(ClaimTypes.Name, a.Name), new Claim(ClaimTypes.Role, a.Role), new Claim("stamp", a.Stamp)], "Admin"));
await c.SignInAsync("Admin", principal, new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(policy.AdminSessionMinutes) });
return ManagementResult.Ok(Public(a));
}).RequireRateLimiting("login");
api.MapGet("/me", async (HttpContext c, AdminDb db) => ManagementResult.Ok(Public(await db.Accounts.SingleAsync(x => x.Id == c.Actor())))).RequireAuthorization();
api.MapPost("/logout", async (HttpContext c) => { await c.SignOutAsync("Admin"); return ManagementResult.Ok(); }).RequireAuthorization();
api.MapPost("/password", async (PasswordInput input, HttpContext c, AdminDb db, IPasswordHasher<AdminAccount> hasher) => {
CheckPassword(input.NewPassword); var a = await db.Accounts.SingleAsync(x => x.Id == c.Actor());
if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.CurrentPassword) == PasswordVerificationResult.Failed) throw new ApiError(400, "当前密码不正确");
a.PasswordHash = hasher.HashPassword(a, input.NewPassword); a.Stamp = Guid.NewGuid().ToString("N");
db.Audit(c, "修改密码", a.Id.ToString(), "", "已更新", "管理员修改本人密码"); await db.SaveChangesAsync(); await c.SignOutAsync("Admin"); return ManagementResult.Ok();
}).RequireAuthorization();
api.MapPost("/forgot", async (ResetRequest input, AdminDb db, InfrastructureService mail, IConfiguration config) => {
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == input.Account.Trim().ToLowerInvariant() && x.Enabled);
if (a is not null && !string.IsNullOrWhiteSpace(a.Email) && await mail.MailEnabled()) {
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
db.Resets.Add(new PasswordReset { Id = Hash(token), AccountId = a.Id, ExpiresAt = DateTime.UtcNow.AddMinutes(20) }); await db.SaveChangesAsync();
var origin = config["Management:AdminPublicUrl"] ?? throw new ApiError(503, "未配置后台访问地址");
await mail.Send(a.Email, "IM 后台密码重置", $"请在 20 分钟内打开以下地址重置密码:{origin.TrimEnd('/')}/#/reset-password?token={token}\n如果不是你发起的请求,请忽略此邮件。");
}
return ManagementResult.Ok(new { message = "如果账号可用且已配置邮件,将收到重置说明。" });
}).RequireRateLimiting("login");
api.MapPost("/reset-password", async (ResetInput input, AdminDb db, IPasswordHasher<AdminAccount> hasher) => {
CheckPassword(input.Password); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var reset = await db.Resets.SingleOrDefaultAsync(x => x.Id == Hash(input.Token) && x.ExpiresAt > DateTime.UtcNow);
if (reset is null) throw new ApiError(400, "链接无效或已过期");
var a = await db.Accounts.SingleAsync(x => x.Id == reset.AccountId); if (!a.Enabled) throw new ApiError(400, "账号已停用");
a.PasswordHash = hasher.HashPassword(a, input.Password); a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null;
db.Resets.RemoveRange(await db.Resets.Where(x => x.AccountId == a.Id).ToListAsync()); db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "重置密码", TargetId = a.Id.ToString(), After = "已更新", Reason = "通过邮件重置密码" });
await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok();
}).RequireRateLimiting("login");
var accounts = app.MapGroup("/api/admin/admins").RequireAuthorization("super");
accounts.MapGet("", async (AdminDb db, string? q, string? status, string? role, int? page, int? size, CancellationToken ct) => {
var query = db.Accounts.AsNoTracking().Where(x => q == null || x.Account.Contains(q) || x.Name.Contains(q));
if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Enabled == (status == "启用"));
if (!string.IsNullOrEmpty(role)) query = query.Where(x => x.Role == role);
var result = await ApiSupport.Page(query.OrderBy(x => x.CreatedAt).Select(x => new { x.Id, x.Name, x.Account, x.Email, x.Role, status = x.Enabled ? "启用" : "停用", x.CreatedAt }), page ?? 1, size ?? 8, ct);
return ManagementResult.Ok(result);
});
accounts.MapPost("", async (AccountInput input, AdminDb db, HttpContext c, IPasswordHasher<AdminAccount> hasher) => {
Validate(input); CheckPassword(input.Password); var account = input.Account.Trim().ToLowerInvariant();
if (await db.Accounts.AnyAsync(x => x.Account == account)) throw new ApiError(409, "管理员账号已存在");
var a = new AdminAccount { Account = account, Name = input.Name.Trim(), Email = input.Email.Trim(), Role = input.Role, Enabled = input.Enabled }; a.PasswordHash = hasher.HashPassword(a, input.Password);
db.Accounts.Add(a); db.Audit(c, "创建管理员", a.Id.ToString(), "", input.Role, input.Reason); await db.SaveChangesAsync(); return ManagementResult.Ok(Public(a));
});
accounts.MapPut("/{id:guid}", async (Guid id, AccountInput input, AdminDb db, HttpContext c) => {
Validate(input); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "管理员不存在");
if (a.Id == c.Actor() && (input.Role != a.Role || !input.Enabled)) throw new ApiError(400, "不能停用或变更自己的角色");
if (a.Role == "super" && a.Enabled && (input.Role != "super" || !input.Enabled) && await db.Accounts.CountAsync(x => x.Role == "super" && x.Enabled) <= 1) throw new ApiError(400, "必须保留一名启用的超级管理员");
var before = $"{a.Role}/{a.Enabled}"; a.Name = input.Name.Trim(); a.Email = input.Email.Trim(); a.Role = input.Role; a.Enabled = input.Enabled; a.Stamp = Guid.NewGuid().ToString("N");
db.Audit(c, "修改管理员", id.ToString(), before, $"{a.Role}/{a.Enabled}", input.Reason); await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok(Public(a));
});
}
static void Validate(AccountInput i) {
ApiSupport.Reason(i.Reason);
if (i.Account.Length is < 3 or > 100 || string.IsNullOrWhiteSpace(i.Name) || i.Name.Length > 50 || !new[] { "super", "operator", "reviewer" }.Contains(i.Role)) throw new ApiError(400, "管理员资料不正确");
if (!string.IsNullOrEmpty(i.Email) && !System.Net.Mail.MailAddress.TryCreate(i.Email, out _)) throw new ApiError(400, "邮箱格式不正确");
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Data;
using System.Text.Json;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Api;
public record SubmitReport(Guid ReporterId, string Type, Guid TargetId, string Reason, string Description, Guid[] MessageIds);
public record ReviewInput(Guid OperationId, string Action, string Reason);
public static class BusinessEndpoints
{
public static void MapAdminBusiness(this WebApplication app)
{
var api = app.MapGroup("/api/admin").RequireAuthorization();
api.MapGet("/dashboard", async (AdminDb db, InternalClient client) => {
var users = client.Send<JsonElement>("user", "/internal/management/summary");
var groups = client.Send<JsonElement>("group", "/internal/management/summary");
await Task.WhenAll(users, groups);
return ManagementResult.Ok(new { users = users.Result.GetProperty("total").GetInt32(), groups = groups.Result.GetProperty("total").GetInt32(), pending = await db.Reports.CountAsync(x => x.Status == "待处理"), disposals = await db.Operations.CountAsync(x => x.Status == "completed" && x.CompletedAt >= DateTime.UtcNow.Date) });
});
foreach (var resource in new[] { "users", "groups" }) {
var service = resource == "users" ? "user" : "group";
api.MapGet($"/{resource}", async (HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send<JsonElement>(service, "/internal/management/list" + c.Request.QueryString)));
api.MapGet($"/{resource}/{{id:guid}}", async (Guid id, HttpContext c, InternalClient client, AdminDb db) => {
var item = await client.Send<JsonElement>(service, $"/internal/management/detail/{id}");
var reports = await db.Reports.AsNoTracking().Where(x => x.TargetId == id).OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.Reason, x.Status, x.Result, x.CreatedAt }).Take(100).ToListAsync();
var logs = c.User.IsInRole("reviewer") ? [] : await db.Audit.AsNoTracking().Where(x => x.TargetId == id.ToString()).OrderByDescending(x => x.CreatedAt).Take(100).ToListAsync();
return ManagementResult.Ok(new { item, reports, logs });
});
api.MapPost($"/{resource}/{{id:guid}}/actions", async (Guid id, ReviewInput input, AdminDb db, HttpContext c) => {
if (input.Action is not "封禁" and not "解封") throw new ApiError(400, "操作不受支持");
return await Enqueue(db, c, service, id, null, input);
}).RequireAuthorization("operate");
}
api.MapGet("/reports", async (AdminDb db, string? q, string? status, string? type, int? page, int? size, CancellationToken ct) => {
var query = db.Reports.AsNoTracking().Where(x => q == null || x.TargetName.Contains(q) || x.Reason.Contains(q) || x.Id.ToString() == q);
if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Status == status);
if (!string.IsNullOrEmpty(type)) query = query.Where(x => x.Type == type);
return ManagementResult.Ok(await ApiSupport.Page(query.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.TargetId, x.TargetName, x.Type, x.Reason, x.Status, x.AssigneeId, x.CreatedAt, x.Result, x.Version }), page ?? 1, size ?? 8, ct));
});
api.MapGet("/reports/{id:guid}", async (Guid id, AdminDb db, HttpContext c) => {
var r = await db.Reports.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
db.Audit(c, "查看举报证据", id.ToString(), "", "已访问", "审核证据读取", id); await db.SaveChangesAsync();
var operations = await db.Operations.AsNoTracking().Where(x => x.ReportId == id).OrderByDescending(x => x.CreatedAt).ToListAsync();
var history = await db.Reports.AsNoTracking().Where(x => x.TargetId == r.TargetId && x.Id != id && x.ClosedAt != null).OrderByDescending(x => x.ClosedAt).Select(x => new { x.Id, x.Result, x.Status, x.ClosedAt }).Take(30).ToListAsync();
var name = r.AssigneeId is null ? null : await db.Accounts.Where(x => x.Id == r.AssigneeId).Select(x => x.Name).SingleOrDefaultAsync();
return ManagementResult.Ok(new { r.Id, r.Type, r.TargetId, r.TargetName, r.ReporterId, r.Reason, r.Description, r.Status, r.AssigneeId, assigneeName = name, r.CreatedAt, r.ClosedAt, r.Result, r.Version, evidence = JsonSerializer.Deserialize<JsonElement>(r.Evidence), operations, history });
});
api.MapPost("/reports/{id:guid}/claim", async (Guid id, AdminDb db, HttpContext c) => {
var r = await db.Reports.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
if (r.Status != "待处理") throw new ApiError(409, "举报已领取或已结案");
r.Status = "处理中"; r.AssigneeId = c.Actor(); r.Version++;
db.Audit(c, "领取举报", id.ToString(), "待处理", r.Status, "领取并核实举报", id); await db.SaveChangesAsync(); return ManagementResult.Ok();
});
api.MapPost("/reports/{id:guid}/review", async (Guid id, ReviewInput input, AdminDb db, HttpContext c) => {
if (input.Action is not "警告" and not "驳回" and not "封禁") throw new ApiError(400, "操作不受支持");
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var r = await db.Reports.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
if (r.Status != "处理中" || r.AssigneeId != c.Actor()) throw new ApiError(409, "仅能处置本人领取且尚未结案的举报");
if (await db.Operations.AnyAsync(x => x.ReportId == id && x.Status != "completed" && x.Id != input.OperationId)) throw new ApiError(409, "处置正在执行,请等待或重试原任务");
var result = await Enqueue(db, c, r.Type, r.TargetId, id, input); await tx.CommitAsync(); return result;
});
api.MapGet("/operations/{id:guid}", async (Guid id, AdminDb db, HttpContext c) => {
var op = await db.Operations.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "任务不存在");
if (c.User.IsInRole("reviewer") && op.ActorId != c.Actor()) throw new ApiError(403, "没有此任务权限"); return ManagementResult.Ok(op);
});
api.MapPost("/operations/{id:guid}/retry", async (Guid id, AdminDb db, HttpContext c) => {
var op = await db.Operations.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "任务不存在");
if (c.User.IsInRole("reviewer") && op.ActorId != c.Actor()) throw new ApiError(403, "没有此任务权限");
if (op.Status != "failed") throw new ApiError(409, "仅失败任务可重试");
op.Status = "pending"; op.Attempts = 0; op.Error = null; op.NextAttemptAt = DateTime.UtcNow; await db.SaveChangesAsync(); return ManagementResult.Ok(op);
});
api.MapGet("/logs", async (AdminDb db, string? q, Guid? actor, string? action, DateTime? from, DateTime? to, int? page, int? size, CancellationToken ct) => {
var query = db.Audit.AsNoTracking().Where(x => q == null || x.TargetId.Contains(q) || x.TargetName.Contains(q) || x.Reason.Contains(q));
if (actor.HasValue) query = query.Where(x => x.ActorId == actor);
if (!string.IsNullOrEmpty(action)) query = query.Where(x => x.Action == action);
if (from.HasValue) query = query.Where(x => x.CreatedAt >= from.Value);
if (to.HasValue) { var end = to.Value.Date.AddDays(1); query = query.Where(x => x.CreatedAt < end); }
return ManagementResult.Ok(await ApiSupport.Page(query.OrderByDescending(x => x.CreatedAt), page ?? 1, size ?? 8, ct));
}).RequireAuthorization("operate");
app.MapPost("/internal/management/reports", async (SubmitReport input, AdminDb db, SettingsService settings, InternalClient client) => {
if (input.Type is not "user" and not "group" || input.Description.Length > 1000 || input.MessageIds.Length > 20) throw new ApiError(400, "举报参数不正确");
var policy = await settings.Policy(); if (!policy.ReportCategories.Contains(input.Reason)) throw new ApiError(400, "请选择有效举报分类");
var verified = await client.Send<SubjectEvidence>("message", "/internal/management/evidence", new EvidenceRequest(input.ReporterId, input.Type, input.TargetId, input.MessageIds));
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
if (await db.Reports.CountAsync(x => x.ReporterId == input.ReporterId && x.CreatedAt >= DateTime.UtcNow.Date) >= policy.ReportsPerDay) throw new ApiError(429, "已达到今日举报上限");
var cutoff = DateTime.UtcNow.AddMinutes(-policy.ReportCooldownMinutes);
if (await db.Reports.AnyAsync(x => x.ReporterId == input.ReporterId && x.TargetId == input.TargetId && x.Type == input.Type && x.CreatedAt > cutoff)) throw new ApiError(429, "请勿重复举报同一对象");
var r = new Report { ReporterId = input.ReporterId, TargetId = input.TargetId, TargetName = verified.TargetName, Type = input.Type, Reason = input.Reason, Description = input.Description, Evidence = JsonSerializer.Serialize(verified.Evidence, SettingsService.Json) };
db.Reports.Add(r); await db.SaveChangesAsync(); await tx.CommitAsync(); return new { r.Id };
});
}
static async Task<IResult> Enqueue(AdminDb db, HttpContext c, string type, Guid target, Guid? report, ReviewInput input)
{
ApiSupport.Reason(input.Reason); if (input.OperationId == Guid.Empty) throw new ApiError(400, "缺少操作 ID");
var existing = await db.Operations.FindAsync(input.OperationId);
if (existing is not null) {
if (existing.ActorId != c.Actor() || existing.TargetId != target || existing.Action != input.Action || existing.ReportId != report || existing.Reason != input.Reason) throw new ApiError(409, "操作 ID 与已有请求冲突");
return Results.Json(ManagementResult.Ok(existing), statusCode: 202);
}
var op = new Operation { Id = input.OperationId, ActorId = c.Actor(), ActorName = c.ActorName(), TargetId = target, Type = type, Action = input.Action, Reason = input.Reason, ReportId = report };
db.Operations.Add(op); await db.SaveChangesAsync(); return Results.Json(ManagementResult.Ok(op), statusCode: 202);
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.Text.Json;
using IM.Admin.Services;
using IM.InitCommon.Management;
namespace IM.Admin.Api;
public static class MonitoringEndpoints
{
public static void MapMonitoring(this WebApplication app)
{
var api = app.MapGroup("/api/admin").RequireAuthorization("operate");
api.MapGet("/health", async (HealthSampler sampler, InternalClient client, CancellationToken ct) => {
var health = await sampler.Read(ct); JsonElement? connections = null;
try { connections = await client.Send<JsonElement>("connector", "/internal/management/connections", ct: ct); } catch { }
return ManagementResult.Ok(new { services = health, connections, sampledAt = DateTime.UtcNow });
});
api.MapGet("/storage", async (HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send<JsonElement>("file", "/internal/management/storage/summary" + c.Request.QueryString, ct: c.RequestAborted)));
app.MapGet("/api/admin/groups/{id:guid}/members", async (Guid id, HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send<JsonElement>("group", $"/internal/management/members/{id}" + c.Request.QueryString, ct: c.RequestAborted))).RequireAuthorization();
}
}
+28
View File
@@ -0,0 +1,28 @@
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Nodes;
namespace IM.Admin.Api;
public static class SettingsEndpoints
{
public static void MapSettings(this WebApplication app)
{
app.MapGet("/api/platform", async (SettingsService s) => { var p = await s.Policy(); return ManagementResult.Ok(new { p.PlatformName, p.Description, p.SupportEmail, p.RegistrationEnabled, p.PasswordMinLength, p.ReportCategories }); });
app.MapGet("/internal/management/policy", async (SettingsService s) => await s.Policy());
app.MapGet("/internal/management/infrastructure/{id}", async (string id, AdminDb db, SettingsService s) => {
if (id != "storage") throw new ApiError(404, "不存在");
var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id);
return new { version = row?.Version ?? 0, value = JsonNode.Parse(row?.Value ?? "{}"), secret = s.Unprotect(row?.Secret) };
});
var api = app.MapGroup("/api/admin/settings").RequireAuthorization("super");
api.MapGet("", async (SettingsService s) => ManagementResult.Ok(await s.List()));
api.MapPut("/{id}", async (string id, SettingInput input, SettingsService s, HttpContext c) => { await s.Save(id, input, c); return ManagementResult.Ok(await s.List()); });
api.MapPost("/{id}/defaults", async (string id, SettingInput input, SettingsService s, HttpContext c) => { await s.Save(id, input with { Value = SettingsService.Defaults(id) }, c); return ManagementResult.Ok(await s.List()); });
api.MapPost("/{id}/draft", async (string id, SettingInput input, InfrastructureService s, HttpContext c) => { await s.Draft(id, input, c); return ManagementResult.Ok(); });
api.MapPost("/{id}/test", async (string id, InfraTest input, InfrastructureService s, HttpContext c) => { await s.Test(id, input, c); return ManagementResult.Ok(); });
api.MapPost("/{id}/activate", async (string id, SettingInput input, InfrastructureService s, HttpContext c) => { await s.Activate(id, input, c); return ManagementResult.Ok(); });
}
}
public record InfraTest(long Version, string? Recipient);
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Data;
public sealed class AdminAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Account { get; set; } = "";
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string Role { get; set; } = "reviewer";
public bool Enabled { get; set; } = true;
public string Stamp { get; set; } = Guid.NewGuid().ToString("N");
public int FailedAttempts { get; set; }
public DateTime? LockedUntil { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class SystemSetting
{
public string Id { get; set; } = "";
public long Version { get; set; }
public string Value { get; set; } = "{}";
public string? Draft { get; set; }
public long? TestedVersion { get; set; }
public string Secret { get; set; } = "";
public string? DraftSecret { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class Report
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ReporterId { get; set; }
public Guid TargetId { get; set; }
public string TargetName { get; set; } = "";
public string Type { get; set; } = "user";
public string Reason { get; set; } = "";
public string Description { get; set; } = "";
public string Evidence { get; set; } = "[]";
public string Status { get; set; } = "待处理";
public Guid? AssigneeId { get; set; }
public string? Result { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public long Version { get; set; }
}
// A durable outbox command: both the request and the pending report update commit together.
public sealed class Operation
{
public Guid Id { get; set; }
public Guid ActorId { get; set; }
public string ActorName { get; set; } = "";
public Guid TargetId { get; set; }
public string Type { get; set; } = "";
public string Action { get; set; } = "";
public string Reason { get; set; } = "";
public Guid? ReportId { get; set; }
public string Status { get; set; } = "pending";
public int Attempts { get; set; }
public string? Error { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
public sealed class AuditRecord
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ActorId { get; set; }
public string ActorName { get; set; } = "";
public string Action { get; set; } = "";
public string TargetId { get; set; } = "";
public string TargetName { get; set; } = "";
public string Before { get; set; } = "";
public string After { get; set; } = "";
public string Reason { get; set; } = "";
public Guid? ReportId { get; set; }
public Guid? OperationId { get; set; }
public string Result { get; set; } = "成功";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class PasswordReset
{
public string Id { get; set; } = "";
public Guid AccountId { get; set; }
public DateTime ExpiresAt { get; set; }
}
public sealed class AdminDb(DbContextOptions<AdminDb> options) : DbContext(options)
{
public DbSet<AdminAccount> Accounts => Set<AdminAccount>();
public DbSet<SystemSetting> Settings => Set<SystemSetting>();
public DbSet<Report> Reports => Set<Report>();
public DbSet<Operation> Operations => Set<Operation>();
public DbSet<AuditRecord> Audit => Set<AuditRecord>();
public DbSet<PasswordReset> Resets => Set<PasswordReset>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<AdminAccount>().ToTable("admin_accounts").HasIndex(x => x.Account).IsUnique();
b.Entity<AdminAccount>().Property(x => x.Account).HasMaxLength(100);
b.Entity<AdminAccount>().Property(x => x.Stamp).IsConcurrencyToken();
b.Entity<SystemSetting>().ToTable("admin_settings").Property(x => x.Id).HasMaxLength(64);
b.Entity<SystemSetting>().Property(x => x.Version).IsConcurrencyToken();
b.Entity<Report>().ToTable("admin_reports").HasIndex(x => new { x.ReporterId, x.CreatedAt });
b.Entity<Report>().HasIndex(x => new { x.Status, x.CreatedAt });
b.Entity<Report>().Property(x => x.Status).HasMaxLength(30);
b.Entity<Report>().Property(x => x.Version).IsConcurrencyToken();
b.Entity<Operation>().ToTable("admin_operations").HasIndex(x => new { x.Status, x.NextAttemptAt });
b.Entity<Operation>().Property(x => x.Status).HasMaxLength(30);
b.Entity<AuditRecord>().ToTable("admin_audit").HasIndex(x => x.CreatedAt);
b.Entity<AuditRecord>().HasIndex(x => x.OperationId).IsUnique();
b.Entity<PasswordReset>().ToTable("admin_password_resets").Property(x => x.Id).HasMaxLength(64);
}
}
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace IM.Admin.Data;
public sealed class DesignTimeFactory : IDesignTimeDbContextFactory<AdminDb>
{
public AdminDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder<AdminDb>()
.UseMySql("Server=localhost;Database=im_admin;User=migration;Password=design-time-only", new MySqlServerVersion(new Version(8, 0, 0))).Options);
}
@@ -0,0 +1,314 @@
// <auto-generated />
using System;
using IM.Admin.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
[DbContext(typeof(AdminDb))]
[Migration("20260915004233_InitialAdmin")]
partial class InitialAdmin
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IM.Admin.Data.AdminAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<DateTime?>("LockedUntil")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Stamp")
.IsConcurrencyToken()
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Account")
.IsUnique();
b.ToTable("admin_accounts", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.AuditRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("After")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Before")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OperationId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetId")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OperationId")
.IsUnique();
b.ToTable("admin_audit", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Operation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Error")
.HasColumnType("longtext");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Status", "NextAttemptAt");
b.ToTable("admin_operations", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.PasswordReset", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("AccountId")
.HasColumnType("char(36)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("admin_password_resets", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Report", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AssigneeId")
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Evidence")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ReporterId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.HasColumnType("longtext");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ReporterId", "CreatedAt");
b.HasIndex("Status", "CreatedAt");
b.ToTable("admin_reports", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.SystemSetting", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Draft")
.HasColumnType("longtext");
b.Property<string>("DraftSecret")
.HasColumnType("longtext");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("longtext");
b.Property<long?>("TestedVersion")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("admin_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,234 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
/// <inheritdoc />
public partial class InitialAdmin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Account = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Email = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PasswordHash = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Role = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Enabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
Stamp = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FailedAttempts = table.Column<int>(type: "int", nullable: false),
LockedUntil = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_accounts", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_audit",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetId = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Before = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
After = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ReportId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
OperationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Result = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_audit", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_operations",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Type = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ReportId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Attempts = table.Column<int>(type: "int", nullable: false),
Error = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_operations", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_password_resets",
columns: table => new
{
Id = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AccountId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_password_resets", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_reports",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ReporterId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
TargetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
TargetName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Type = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Evidence = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AssigneeId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Result = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Version = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_reports", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_settings",
columns: table => new
{
Id = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Version = table.Column<long>(type: "bigint", nullable: false),
Value = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Draft = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
TestedVersion = table.Column<long>(type: "bigint", nullable: true),
Secret = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DraftSecret = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_settings", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_admin_accounts_Account",
table: "admin_accounts",
column: "Account",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_admin_audit_CreatedAt",
table: "admin_audit",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_admin_audit_OperationId",
table: "admin_audit",
column: "OperationId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_admin_operations_Status_NextAttemptAt",
table: "admin_operations",
columns: new[] { "Status", "NextAttemptAt" });
migrationBuilder.CreateIndex(
name: "IX_admin_reports_ReporterId_CreatedAt",
table: "admin_reports",
columns: new[] { "ReporterId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_admin_reports_Status_CreatedAt",
table: "admin_reports",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "admin_accounts");
migrationBuilder.DropTable(
name: "admin_audit");
migrationBuilder.DropTable(
name: "admin_operations");
migrationBuilder.DropTable(
name: "admin_password_resets");
migrationBuilder.DropTable(
name: "admin_reports");
migrationBuilder.DropTable(
name: "admin_settings");
}
}
}
@@ -0,0 +1,311 @@
// <auto-generated />
using System;
using IM.Admin.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
[DbContext(typeof(AdminDb))]
partial class AdminDbModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IM.Admin.Data.AdminAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<DateTime?>("LockedUntil")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Stamp")
.IsConcurrencyToken()
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Account")
.IsUnique();
b.ToTable("admin_accounts", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.AuditRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("After")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Before")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OperationId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetId")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OperationId")
.IsUnique();
b.ToTable("admin_audit", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Operation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Error")
.HasColumnType("longtext");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Status", "NextAttemptAt");
b.ToTable("admin_operations", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.PasswordReset", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("AccountId")
.HasColumnType("char(36)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("admin_password_resets", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Report", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AssigneeId")
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Evidence")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ReporterId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.HasColumnType("longtext");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ReporterId", "CreatedAt");
b.HasIndex("Status", "CreatedAt");
b.ToTable("admin_reports", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.SystemSetting", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Draft")
.HasColumnType("longtext");
b.Property<string>("DraftSecret")
.HasColumnType("longtext");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("longtext");
b.Property<long?>("TestedVersion")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("admin_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.Security.Claims;
using IM.Admin.Api;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AdminDb>(o => o.UseMySql(builder.Configuration.GetConnectionString("Admin") ?? throw new InvalidOperationException("ConnectionStrings:Admin is required"), new MySqlServerVersion(new Version(8, 0, 0))));
builder.Services.AddSingleton<IPasswordHasher<AdminAccount>, PasswordHasher<AdminAccount>>();
var keyPath = builder.Configuration["Management:KeyRingPath"] ?? throw new InvalidOperationException("Management:KeyRingPath is required");
builder.Services.AddDataProtection().SetApplicationName("IM.Admin").PersistKeysToFileSystem(new DirectoryInfo(keyPath));
builder.Services.AddHttpClient<InternalClient>(c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<InfrastructureService>();
builder.Services.AddSingleton<HealthSampler>();
builder.Services.AddHostedService<OperationWorker>();
builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.Name = "im.admin.csrf"; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; });
builder.Services.AddAuthentication("Admin").AddCookie("Admin", o => {
o.Cookie.Name = "im.admin.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict;
o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
o.SlidingExpiration = false;
o.Events.OnRedirectToLogin = c => { c.Response.StatusCode = 401; return c.Response.WriteAsJsonAsync(ManagementResult.Fail("请登录管理后台")); };
o.Events.OnRedirectToAccessDenied = c => { c.Response.StatusCode = 403; return c.Response.WriteAsJsonAsync(ManagementResult.Fail("没有此操作权限")); };
o.Events.OnValidatePrincipal = async c => {
if (!Guid.TryParse(c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id)) { c.RejectPrincipal(); return; }
var db = c.HttpContext.RequestServices.GetRequiredService<AdminDb>();
var a = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id);
if (a is null || !a.Enabled || a.Stamp != c.Principal!.FindFirstValue("stamp") || a.Role != c.Principal.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal();
};
});
builder.Services.AddAuthorization(o => { o.AddPolicy("super", p => p.RequireRole("super")); o.AddPolicy("operate", p => p.RequireRole("super", "operator")); });
builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = 429; o.AddPolicy("login", c => RateLimitPartition.GetFixedWindowLimiter(c.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); });
var app = builder.Build();
if (args.Contains("--migrate") || args.Contains("--init-admin") || args.Contains("--reset-admin")) { await AdminBootstrap.Run(app.Services, args); return; }
app.Use(async (c, next) => {
try { await next(); }
catch (ApiError e) { c.Response.StatusCode = e.Status; await c.Response.WriteAsJsonAsync(ManagementResult.Fail(e.Message)); }
catch (DbUpdateConcurrencyException) { c.Response.StatusCode = 409; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("内容已被其他管理员修改,请刷新后重试")); }
catch (InternalServiceException e) { c.Response.StatusCode = e.Status is >= 400 and < 500 ? e.Status : 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail(e.Status switch { 400 => "业务校验未通过,请检查对象、配置值和已有引用", 403 => "没有访问关联对象或消息的权限", 404 => "关联对象或资源不存在", 409 => "对象已变化,请刷新重试", _ => "业务服务暂不可用,请稍后重试" })); }
catch (Exception e) { app.Logger.LogError("Admin request failed: {Type}", e.GetType().Name); c.Response.StatusCode = 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("服务暂不可用,请稍后重试")); }
});
app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization();
app.Use(async (c, next) => {
if (c.Request.Path.StartsWithSegments("/internal") && !InternalClient.Authorized(c)) { c.Response.StatusCode = 403; return; }
if (c.Request.Path.StartsWithSegments("/api/admin") && !HttpMethods.IsGet(c.Request.Method)) {
try { await c.RequestServices.GetRequiredService<Microsoft.AspNetCore.Antiforgery.IAntiforgery>().ValidateRequestAsync(c); }
catch (Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException) { c.Response.StatusCode = 400; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("请求校验失败,请刷新页面")); return; }
}
await next();
});
app.MapAuth(); app.MapAdminBusiness(); app.MapSettings(); app.MapMonitoring();
app.MapGet("/internal/management/health", async (AdminDb db) => { if (!await db.Database.CanConnectAsync()) throw new ApiError(503, "数据库不可用"); return new { status = "healthy", service = "admin" }; });
app.Run();
public partial class Program { }
@@ -0,0 +1,12 @@
{
"profiles": {
"Admin.WebApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:60538;http://localhost:60539"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using IM.Admin.Api;
using IM.Admin.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Services;
public static class AdminBootstrap
{
public static async Task Run(IServiceProvider services, string[] args)
{
using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>();
if (args.Contains("--migrate")) { await db.Database.MigrateAsync(); Console.WriteLine("管理数据库迁移完成"); }
if (!args.Contains("--init-admin") && !args.Contains("--reset-admin")) return;
var name = Environment.GetEnvironmentVariable("IM_ADMIN_ACCOUNT")?.Trim().ToLowerInvariant() ?? throw new InvalidOperationException("IM_ADMIN_ACCOUNT is required");
var password = Environment.GetEnvironmentVariable("IM_ADMIN_PASSWORD") ?? throw new InvalidOperationException("IM_ADMIN_PASSWORD is required");
AuthEndpoints.CheckPassword(password);
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == name);
if (args.Contains("--init-admin")) {
if (await db.Accounts.AnyAsync()) throw new InvalidOperationException("已存在管理员,禁止重复初始化");
a = new AdminAccount { Account = name, Name = name, Role = "super", Email = Environment.GetEnvironmentVariable("IM_ADMIN_EMAIL") ?? "" }; db.Accounts.Add(a);
}
if (a is null) throw new InvalidOperationException("管理员不存在");
a.PasswordHash = scope.ServiceProvider.GetRequiredService<IPasswordHasher<AdminAccount>>().HashPassword(a, password);
a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null;
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = "部署初始化命令", Action = "初始化或重置管理员", TargetId = a.Id.ToString(), Reason = "部署命令操作", After = "凭据已更新" });
await db.SaveChangesAsync(); Console.WriteLine("管理员凭据已更新;请清除临时密码环境变量。");
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using IM.InitCommon.Management;
using MySqlConnector;
using StackExchange.Redis;
namespace IM.Admin.Services;
public sealed class HealthSampler(IConfiguration config, IServiceScopeFactory scopes)
{
private readonly ConcurrentDictionary<string, DateTime> successes = new();
public async Task<ServiceHealth[]> Read(CancellationToken ct)
{
using var scope = scopes.CreateScope(); var client = scope.ServiceProvider.GetRequiredService<InternalClient>();
var tasks = new[] { "admin", "user", "contact", "group", "message", "file", "connector" }.Select(name => Check(name, async token => {
var response = await client.Send<JsonElement>(name, "/internal/management/health", ct: token);
if (response.GetProperty("status").GetString() != "healthy") throw new InvalidOperationException();
return response.TryGetProperty("configVersion", out var version) ? version.GetInt64() : (long?)null;
}, ct)).ToList();
tasks.Add(Check("MySQL", async token => { await using var db = new MySqlConnection(config.GetConnectionString("Admin")); await db.OpenAsync(token); return null; }, ct));
tasks.Add(Check("Redis", async token => { var options = ConfigurationOptions.Parse(config.GetConnectionString("Redis") ?? throw new InvalidOperationException()); options.ConnectTimeout = 2000; options.AbortOnConnectFail = true; using var redis = await ConnectionMultiplexer.ConnectAsync(options).WaitAsync(token); await redis.GetDatabase().PingAsync().WaitAsync(token); return null; }, ct));
tasks.Add(Check("RabbitMQTCP", async token => { using var tcp = new TcpClient(); await tcp.ConnectAsync(config["Management:RabbitHost"] ?? "rabbitmq", config.GetValue("Management:RabbitPort", 5672), token); return null; }, ct));
tasks.Add(Check("Consul", async token => { using var http = new HttpClient(); using var response = await http.GetAsync((config["Management:ConsulUrl"] ?? throw new InvalidOperationException()).TrimEnd('/') + "/v1/status/leader", token); response.EnsureSuccessStatusCode(); return null; }, ct));
return await Task.WhenAll(tasks);
}
async Task<ServiceHealth> Check(string name, Func<CancellationToken, Task<long?>> check, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(3)); var sw = Stopwatch.StartNew();
try { var version = await check(timeout.Token); var now = DateTime.UtcNow; successes[name] = now; return new(name, "healthy", Math.Round(sw.Elapsed.TotalMilliseconds), now, now, null, version); }
catch { return new(name, "unavailable", null, DateTime.UtcNow, successes.TryGetValue(name, out var time) ? time : null, "连接或就绪检查失败,请检查服务日志"); }
}
}
@@ -0,0 +1,93 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using IM.Admin.Api;
using IM.Admin.Data;
using IM.InitCommon.Management;
using MailKit.Security;
using MimeKit;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Services;
public sealed class InfrastructureService(AdminDb db, SettingsService settings, InternalClient client, IConfiguration config, IHostEnvironment environment)
{
public async Task Draft(string id, SettingInput input, HttpContext c)
{
ApiSupport.Reason(input.Reason); Validate(id, input.Value);
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id);
if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新");
if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); }
row.Draft = input.Value.ToJsonString(); row.TestedVersion = null; row.Version++; row.UpdatedAt = DateTime.UtcNow;
var prior = settings.Unprotect(row.DraftSecret ?? row.Secret);
var secret = string.IsNullOrWhiteSpace(input.Secret) ? prior : id == "storage" ? MergeSecrets(prior, input.Secret) : input.Secret;
row.DraftSecret = settings.Protect(secret);
db.Audit(c, "保存基础设施草稿", id, "", "草稿已更新;凭据" + (string.IsNullOrEmpty(input.Secret) ? "保持" : "已更换"), input.Reason); await db.SaveChangesAsync();
}
static string MergeSecrets(string previous, string next)
{
var old = JsonNode.Parse(string.IsNullOrEmpty(previous) ? "{}" : previous)!.AsObject(); var update = JsonNode.Parse(next)!.AsObject();
foreach (var p in update) { var entry = old[p.Key]?.AsObject() ?? new JsonObject(); foreach (var value in p.Value!.AsObject()) if (!string.IsNullOrWhiteSpace(value.Value?.GetValue<string>())) entry[value.Key] = value.Value!.DeepClone(); old[p.Key] = entry.DeepClone(); }
return old.ToJsonString();
}
public async Task Test(string id, InfraTest input, HttpContext c)
{
var row = await Row(id, input.Version); var value = JsonNode.Parse(row.Draft!)!.AsObject();
Validate(id, value);
if (id == "smtp") {
using var smtp = await Connect(value, settings.Unprotect(row.DraftSecret));
if (!System.Net.Mail.MailAddress.TryCreate(input.Recipient, out _)) throw new ApiError(400, "请填写有效测试收件人");
await SendMessage(smtp, value, input.Recipient!, "IM SMTP 连接测试", "这是一封由后台管理员主动发起的连接测试邮件。");
} else await client.Send<JsonElement>("file", "/internal/management/storage/test", new InfrastructureEnvelope(row.Version, value, settings.Unprotect(row.DraftSecret)));
row.TestedVersion = row.Version; db.Audit(c, "测试基础设施连接", id, "", "测试成功", "管理员主动执行连通性测试"); await db.SaveChangesAsync();
}
public async Task Activate(string id, SettingInput input, HttpContext c)
{
ApiSupport.Reason(input.Reason); var row = await Row(id, input.Version);
if (row.TestedVersion != row.Version) throw new ApiError(409, "请先测试当前草稿的连接");
if (id == "storage") await client.Send<JsonElement>("file", "/internal/management/storage/validate", new InfrastructureEnvelope(row.Version, JsonNode.Parse(row.Draft!)!.AsObject(), settings.Unprotect(row.DraftSecret)));
var before = row.Value; row.Value = row.Draft!; row.Secret = row.DraftSecret!; row.Draft = null; row.DraftSecret = null; row.Version++; row.UpdatedAt = DateTime.UtcNow;
db.Audit(c, "启用基础设施配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync();
}
async Task<SystemSetting> Row(string id, long version)
{
if (id is not "smtp" and not "storage") throw new ApiError(404, "分组不存在");
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "请先保存草稿");
if (row.Draft is null || row.Version != version) throw new ApiError(409, "草稿不存在或版本已变化"); return row;
}
void Validate(string id, JsonObject value)
{
if (id == "smtp") {
var allowed = new[] { "host", "port", "username", "from", "tls", "enabled" };
if (value.Any(x => !allowed.Contains(x.Key))) throw new ApiError(400, "不支持的邮件字段");
ValidateHost(value["host"]?.GetValue<string>() ?? "", config);
if (value["port"]?.GetValue<int>() is not (>= 1 and <= 65535) || value["tls"]?.GetValue<string>() is not ("starttls" or "ssl") || !System.Net.Mail.MailAddress.TryCreate(value["from"]?.GetValue<string>(), out _)) throw new ApiError(400, "邮件端口、TLS 或发件人无效");
} else if (id == "storage") {
if (value["providers"] is not JsonObject providers || providers.Count == 0 || value["defaultProviderCode"] is null || !providers.ContainsKey(value["defaultProviderCode"]!.GetValue<string>())) throw new ApiError(400, "请配置有效的默认存储提供商");
var allowed = new[] { "providerCode", "providerType", "enabled", "bucket", "publicBucket", "region", "endpoint", "publicBaseUrl", "localRootPath", "localUploadApiBaseUrl", "uploadUrlExpiresIn", "downloadUrlExpiresIn", "maxObjectSizeBytes", "minPartSizeBytes", "defaultPartSizeBytes", "maxPartCount" };
if (value.Any(x => x.Key != "providers" && x.Key != "defaultProviderCode")) throw new ApiError(400, "未知存储字段");
foreach (var (code, node) in providers) {
var p = node!.AsObject(); if (p.Any(x => !allowed.Contains(x.Key)) || p["providerCode"]?.GetValue<string>() != code) throw new ApiError(400, "存储字段不正确,凭据需独立提交");
var type = p["providerType"]?.GetValue<int>(); if (type is not 1 and not 2 and not 5) throw new ApiError(400, "仅支持本地或 S3 兼容存储");
if (type != 1) { if (!Uri.TryCreate(p["endpoint"]?.GetValue<string>(), UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http") || !string.IsNullOrEmpty(uri.UserInfo)) throw new ApiError(400, "存储端点无效"); ValidateHost(uri.Host, config); }
}
} else throw new ApiError(404, "分组不存在");
}
public static void ValidateHost(string host, IConfiguration config)
{
var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(host) || !allowed.Contains(host, StringComparer.OrdinalIgnoreCase)) throw new ApiError(400, "该目标不在部署允许列表中");
}
async Task<MailKit.Net.Smtp.SmtpClient> Connect(JsonObject value, string secret)
{
Validate("smtp", value); var smtp = new MailKit.Net.Smtp.SmtpClient { Timeout = 10000 };
if (environment.IsDevelopment() && config["Management:DevelopmentSmtpCertificateSha256"] is { Length: > 0 } pin)
smtp.ServerCertificateValidationCallback = (_, certificate, _, errors) => errors == System.Net.Security.SslPolicyErrors.None || certificate is not null && string.Equals(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256), pin, StringComparison.OrdinalIgnoreCase);
try { await smtp.ConnectAsync(value["host"]!.GetValue<string>(), value["port"]!.GetValue<int>(), value["tls"]!.GetValue<string>() == "ssl" ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls);
if (!string.IsNullOrWhiteSpace(value["username"]?.GetValue<string>())) await smtp.AuthenticateAsync(value["username"]!.GetValue<string>(), secret);
return smtp;
} catch { smtp.Dispose(); throw new ApiError(503, "邮件连接失败,请检查允许列表、TLS 和凭据"); }
}
static async Task SendMessage(MailKit.Net.Smtp.SmtpClient smtp, JsonObject v, string recipient, string title, string text)
{ var msg = new MimeMessage(); msg.From.Add(MailboxAddress.Parse(v["from"]!.GetValue<string>())); msg.To.Add(MailboxAddress.Parse(recipient)); msg.Subject = title; msg.Body = new TextPart("plain") { Text = text }; await smtp.SendAsync(msg); await smtp.DisconnectAsync(true); }
public async Task<bool> MailEnabled() { var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == "smtp"); return row is not null && JsonNode.Parse(row.Value)?["enabled"]?.GetValue<bool>() == true; }
public async Task Send(string recipient, string title, string text) { var row = await db.Settings.AsNoTracking().SingleAsync(x => x.Id == "smtp"); var value = JsonNode.Parse(row.Value)!.AsObject(); using var smtp = await Connect(value, settings.Unprotect(row.Secret)); await SendMessage(smtp, value, recipient, title, text); }
}
+40
View File
@@ -0,0 +1,40 @@
using System.Data;
using IM.Admin.Data;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Services;
public sealed class OperationWorker(IServiceScopeFactory scopes, ILogger<OperationWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
while (await timer.WaitForNextTickAsync(ct)) {
try { await Process(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception e) { logger.LogWarning("Operation dispatcher unavailable: {Type}", e.GetType().Name); }
}
}
public async Task Process(CancellationToken ct)
{
using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>();
var id = await db.Operations.Where(x => x.Status == "pending" && x.NextAttemptAt <= DateTime.UtcNow).OrderBy(x => x.CreatedAt).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
if (id is null) return;
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
// A row lock serializes dispatchers. Receipt idempotency covers response loss after the domain commit.
var op = await db.Operations.FromSqlInterpolated($"SELECT * FROM admin_operations WHERE Id = {id.Value} FOR UPDATE").SingleAsync(ct);
if (op.Status != "pending" || op.NextAttemptAt > DateTime.UtcNow) return;
try {
var receipt = op.Action is "警告" or "驳回" ? new ActionReceipt(op.TargetId.ToString(), "处理中", op.Action == "驳回" ? "已驳回" : "已处理") : await scope.ServiceProvider.GetRequiredService<InternalClient>().Send<ActionReceipt>(op.Type, "/internal/management/action", new InternalAction(op.Id, op.ActorId, op.TargetId, op.Action, op.Reason), ct);
if (op.ReportId.HasValue) {
var r = await db.Reports.SingleAsync(x => x.Id == op.ReportId, ct);
r.Status = op.Action == "驳回" ? "已驳回" : "已处理"; r.Result = op.Action + "" + op.Reason; r.ClosedAt = DateTime.UtcNow; r.Version++;
}
op.Status = "completed"; op.CompletedAt = DateTime.UtcNow; op.Error = null;
db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = receipt.TargetName, Before = receipt.Before, After = receipt.After, Reason = op.Reason, ReportId = op.ReportId, OperationId = op.Id });
} catch (Exception e) when (e is not OperationCanceledException || !ct.IsCancellationRequested) {
op.Attempts++; op.Error = e is InternalServiceException se ? se.Message : "业务服务暂不可用,可重试原任务";
op.Status = op.Attempts >= 3 ? "failed" : "pending"; op.NextAttemptAt = DateTime.UtcNow.AddSeconds(10 * op.Attempts);
db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = op.TargetId.ToString(), Before = "待确认", After = "未确认", Reason = op.Reason, ReportId = op.ReportId, Result = $"第 {op.Attempts} 次执行失败;任务 {op.Id}" });
}
await db.SaveChangesAsync(ct); await tx.CommitAsync(ct);
}
}
+82
View File
@@ -0,0 +1,82 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using IM.Admin.Api;
using IM.Admin.Data;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace IM.Admin.Services;
public sealed class SettingsService(AdminDb db, IDataProtectionProvider protection, IConfiguration config)
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public static readonly Dictionary<string, string[]> Fields = new() {
["platform"] = ["platformName", "description", "supportEmail"], ["account"] = ["registrationEnabled", "passwordMinLength"],
["social"] = ["friendLimit", "createdGroupLimit", "groupMemberLimit", "defaultJoinAuthority"],
["messaging"] = ["textLimit", "recallMinutes", "uploadMaxBytes", "allowedFileTypes"],
["reports"] = ["reportCategories", "reportsPerDay", "reportCooldownMinutes"],
["security"] = ["clientAccessMinutes", "clientRefreshDays", "adminSessionMinutes", "adminLockThreshold", "adminLockMinutes"],
};
byte[] Key() { var key = Convert.FromBase64String(config["Management:CredentialKey"] ?? throw new InvalidOperationException("Management:CredentialKey is required")); if (key.Length != 32) throw new InvalidOperationException("CredentialKey must be 32 bytes"); return key; }
public string Protect(string secret) {
if (string.IsNullOrEmpty(secret)) return "";
var nonce = RandomNumberGenerator.GetBytes(12); var plain = Encoding.UTF8.GetBytes(secret); var cipher = new byte[plain.Length]; var tag = new byte[16];
using var aes = new AesGcm(Key(), 16); aes.Encrypt(nonce, plain, cipher, tag, Encoding.UTF8.GetBytes("IM.Admin.Config.v1"));
return "v1:" + Convert.ToBase64String(nonce.Concat(tag).Concat(cipher).ToArray());
}
public string Unprotect(string? secret) {
if (string.IsNullOrEmpty(secret)) return "";
if (!secret.StartsWith("v1:")) throw new InvalidOperationException("Unsupported credential encryption version");
var data = Convert.FromBase64String(secret[3..]); var plain = new byte[data.Length - 28];
using var aes = new AesGcm(Key(), 16); aes.Decrypt(data.AsSpan(0,12), data.AsSpan(28), data.AsSpan(12,16), plain, Encoding.UTF8.GetBytes("IM.Admin.Config.v1"));
return Encoding.UTF8.GetString(plain);
}
public static JsonObject Defaults(string id)
{
if (!Fields.TryGetValue(id, out var fields)) throw new ApiError(404, "配置分组不存在");
var all = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject();
return new JsonObject(fields.Select(k => new KeyValuePair<string, JsonNode?>(k, all[k]?.DeepClone())));
}
public async Task<Policy> Policy()
{
var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject();
var rows = await db.Settings.AsNoTracking().Where(x => x.Id != "storage" && x.Id != "smtp").ToListAsync();
foreach (var row in rows) foreach (var p in JsonNode.Parse(row.Value)!.AsObject()) merged[p.Key] = p.Value?.DeepClone();
var policy = merged.Deserialize<Policy>(Json)!; policy.Version = rows.Sum(x => x.Version); return policy;
}
public async Task<object> List()
{
var rows = await db.Settings.AsNoTracking().ToListAsync();
return Fields.Keys.Concat(["storage", "smtp"]).Select(id => {
var row = rows.SingleOrDefault(x => x.Id == id);
return new { id, version = row?.Version ?? 0, value = row is null ? (Fields.ContainsKey(id) ? Defaults(id) : new JsonObject()) : JsonNode.Parse(row.Value), draft = row?.Draft is null ? null : JsonNode.Parse(row.Draft), secretSet = !string.IsNullOrEmpty(row?.Secret), draftSecretSet = !string.IsNullOrEmpty(row?.DraftSecret), tested = row?.TestedVersion == row?.Version && row is not null, updatedAt = row?.UpdatedAt };
}).ToArray();
}
public async Task Save(string id, SettingInput input, HttpContext c)
{
ApiSupport.Reason(input.Reason);
if (!Fields.ContainsKey(id)) throw new ApiError(400, "基础设施配置需走草稿发布流程");
Validate(id, input.Value);
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id);
if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新后重试");
if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); }
var before = row.Value; row.Value = input.Value.ToJsonString(); row.Version++; row.UpdatedAt = DateTime.UtcNow;
db.Audit(c, "修改系统配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync();
}
public static void Validate(string id, JsonObject value)
{
if (!Fields.TryGetValue(id, out var fields) || value.Count != fields.Length || fields.Any(x => !value.ContainsKey(x))) throw new ApiError(400, "配置字段不完整或包含未知字段");
try {
var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); foreach (var p in value) merged[p.Key] = p.Value?.DeepClone();
var p1 = merged.Deserialize<Policy>(Json)!;
if (string.IsNullOrWhiteSpace(p1.PlatformName) || p1.PlatformName.Length > 60 || p1.Description.Length > 500 || p1.PasswordMinLength is < 6 or > 50 || p1.AdminSessionMinutes is < 5 or > 10080 || p1.AdminLockThreshold is < 1 or > 20 || p1.AdminLockMinutes is < 1 or > 1440 || p1.DefaultJoinAuthority is < 0 or > 2 || p1.ReportsPerDay is < 1 or > 1000 || p1.ReportCooldownMinutes is < 0 or > 1440) throw new Exception();
if (new[] { p1.FriendLimit, p1.CreatedGroupLimit, p1.GroupMemberLimit, p1.TextLimit, p1.RecallMinutes, p1.ClientAccessMinutes, p1.ClientRefreshDays }.Any(x => x < 0 || x > 1000000) || p1.UploadMaxBytes < 0 || p1.UploadMaxBytes > 1099511627776) throw new Exception();
if (p1.ReportCategories is null || p1.ReportCategories.Length is < 1 or > 30 || p1.ReportCategories.Any(x => string.IsNullOrWhiteSpace(x) || x.Length > 40) || p1.ReportCategories.Distinct().Count() != p1.ReportCategories.Length) throw new Exception();
if (p1.AllowedFileTypes is null || p1.AllowedFileTypes.Any(x => !System.Text.RegularExpressions.Regex.IsMatch(x, "^\\.[a-z0-9]{1,15}$"))) throw new Exception();
if (p1.SupportEmail.Length > 0 && !System.Net.Mail.MailAddress.TryCreate(p1.SupportEmail, out _)) throw new Exception();
} catch { throw new ApiError(400, "配置值无效,请检查类型、范围、邮箱和文件扩展名"); }
}
}
public record SettingInput(long Version, JsonObject Value, string Reason, string? Secret = null);
+3 -15
View File
@@ -2,25 +2,13 @@ FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 A
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY ConnectorService/ConnectorService.csproj ConnectorService/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
RUN dotnet restore ConnectorService/ConnectorService.csproj
COPY . .
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish ConnectorService/ConnectorService.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+5 -1
View File
@@ -10,11 +10,13 @@ namespace ConnectorService.Hubs
{
private readonly IConversationIntergrationService conService;
private readonly StackExchange.Redis.IDatabase redis;
private readonly ConnectionRegistry registry;
public ChatHub(IConversationIntergrationService conService, IConnectionMultiplexer multiplexer)
public ChatHub(IConversationIntergrationService conService, IConnectionMultiplexer multiplexer, ConnectionRegistry registry)
{
this.conService = conService;
this.redis = multiplexer.GetDatabase();
this.registry = registry;
}
public async override Task OnConnectedAsync()
@@ -34,6 +36,7 @@ namespace ConnectorService.Hubs
}
await redis.SetAddAsync(RedisHelper.GetConnectionIdKey(userId), Context.ConnectionId);
await registry.Add(Context);
await base.OnConnectedAsync();
@@ -41,6 +44,7 @@ namespace ConnectorService.Hubs
public async override Task OnDisconnectedAsync(Exception? exception)
{
await registry.Remove(Context);
if (Context.User.Identity.IsAuthenticated)
{
var userId = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
+3 -1
View File
@@ -1,4 +1,5 @@
using IM.Commons;
using ConnectorService.Services;
using IM.Commons;
using IM.Protocols.Grpc.Conversation;
using Microsoft.Extensions.Options;
@@ -9,6 +10,7 @@ namespace ConnectorService
public void Initialize(IServiceCollection services)
{
services.AddRedisCache();
services.AddScoped<IConversationIntergrationService, ConversationIntegrationService>();
services.AddGrpcClient<ConversationInternal.ConversationInternalClient>((sp ,o) =>
{
var options = sp.GetRequiredService<IOptionsMonitor<GrpcOptions>>();
+8
View File
@@ -1,5 +1,7 @@
using IM.InitCommon.Management;
using ConnectorService.Hubs;
using ConnectorService.Services;
using IM.InitCommon;
namespace ConnectorService
@@ -15,6 +17,8 @@ namespace ConnectorService
builder.ConfigureDbConfiguration();
builder.Services.AddSignalR();
builder.Services.AddSingleton<ConnectionRegistry>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ConnectionRegistry>());
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
@@ -23,6 +27,7 @@ namespace ConnectorService
builder.ConfigExtraServices();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -32,9 +37,12 @@ namespace ConnectorService
}
app.UseAppDefault();
app.MapManagementHealth();
app.MapHub<ChatHub>("/chat");
app.MapGet("/internal/management/connections", async (ConnectionRegistry registry) => await registry.Counts());
app.MapPost("/internal/management/disconnect/{id:guid}", async (Guid id, ConnectionRegistry registry) => { await registry.Disconnect(id); return new { disconnected = true }; });
app.Run();
}
@@ -0,0 +1,38 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using StackExchange.Redis;
namespace ConnectorService.Services;
public sealed class ConnectionRegistry(IConnectionMultiplexer redis, ILogger<ConnectionRegistry> logger) : BackgroundService
{
const string Active = "im:management:connections";
static readonly RedisChannel Revocations = RedisChannel.Literal("im:management:disconnect");
readonly ConcurrentDictionary<string, HubCallerContext> contexts = new();
public async Task Add(HubCallerContext c) { contexts[c.ConnectionId] = c; await Beat(c); }
public async Task Remove(HubCallerContext c) { contexts.TryRemove(c.ConnectionId, out _); await redis.GetDatabase().SortedSetRemoveAsync(Active, Key(c)); }
static string Key(HubCallerContext c) => c.User?.FindFirstValue(ClaimTypes.NameIdentifier) + ":" + c.ConnectionId;
Task Beat(HubCallerContext c) => redis.GetDatabase().SortedSetAddAsync(Active, Key(c), DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds());
public async Task Disconnect(Guid id) { Abort(id.ToString()); await redis.GetSubscriber().PublishAsync(Revocations, id.ToString()); }
void Abort(string userId) { foreach (var c in contexts.Values.Where(c => c.User!.FindFirstValue(ClaimTypes.NameIdentifier) == userId)) c.Abort(); }
public async Task<object> Counts() {
var db = redis.GetDatabase(); await db.SortedSetRemoveRangeByScoreAsync(Active, double.NegativeInfinity, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
var active = await db.SortedSetRangeByScoreAsync(Active, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), double.PositiveInfinity);
return new { connections = active.Length, users = active.Select(x => x.ToString().Split(':')[0]).Distinct().Count(), sampledAt = DateTime.UtcNow, expiresAfterSeconds = 90 };
}
protected override async Task ExecuteAsync(CancellationToken ct) {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
var subscribed = false;
try { do {
try {
if (!subscribed) { await redis.GetSubscriber().SubscribeAsync(Revocations, (_, value) => Abort(value.ToString())); subscribed = true; }
foreach (var c in contexts.Values) { if (c.ConnectionAborted.IsCancellationRequested) await Remove(c); else await Beat(c); }
} catch (Exception e) when (!ct.IsCancellationRequested) {
// Fail closed while the revocation channel is unavailable.
foreach (var c in contexts.Values) c.Abort();
logger.LogWarning("Connection heartbeat unavailable: {Type}",e.GetType().Name);
}
} while (await timer.WaitForNextTickAsync(ct)); }
finally { try { await redis.GetSubscriber().UnsubscribeAsync(Revocations); } catch { } }
}
}
@@ -6,6 +6,12 @@ namespace ConnectorService.Services
public class ConversationIntegrationService : IConversationIntergrationService
{
private readonly ConversationInternal.ConversationInternalClient client;
public ConversationIntegrationService(ConversationInternal.ConversationInternalClient client)
{
this.client = client;
}
public async Task<List<string>> GetUserStreamKeysAsync(Guid userId)
{
var req = new GetUserStreamKeysRequest()
@@ -9,12 +9,15 @@ namespace ContactService.WebApi.Application.Dtos
/// 申请人
/// </summary>
public Guid OwnerId { get; private set; }
public string? OwnerNickName { get; set; }
public string? OwnerAvatar { get; set; }
/// <summary>
/// 被申请人
/// </summary>
public Guid TargetId { get; private set; }
public string? TargetNickName { get; set; }
public string? TargetAvatar { get; set; }
/// <summary>
/// 申请附言
@@ -1,7 +1,9 @@
using AutoMapper;
using AutoMapper;
using ContactService.Domain;
using ContactService.WebApi.Application.Dtos;
using ContactService.WebApi.Application.IntegrationServices;
using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace ContactService.WebApi.Application.FriendRequest
{
@@ -10,12 +12,15 @@ namespace ContactService.WebApi.Application.FriendRequest
private readonly IFriendRequestReposity reposity;
private readonly FriendRequestDomainService service;
private readonly IMapper mapper;
private readonly IIdentityIntegrationService identityService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly ContactService.Infrastructure.ContactDbContext db;
public FriendRequestService(IFriendRequestReposity reposity, FriendRequestDomainService service, IMapper mapper)
public FriendRequestService(IFriendRequestReposity reposity, FriendRequestDomainService service,
IMapper mapper, IIdentityIntegrationService identityService, IM.InitCommon.Management.RuntimePolicy runtime, ContactService.Infrastructure.ContactDbContext db)
{
this.reposity = reposity;
this.service = service;
this.mapper = mapper;
this.identityService = identityService; this.runtime = runtime; this.db = db;
}
public async Task<Result<FriendRequestResponse>> CreateAsync(CreateFriendRequestCommand command)
@@ -25,7 +30,9 @@ namespace ContactService.WebApi.Application.FriendRequest
{
return Result<FriendRequestResponse>.Fail(request);
}
return Result<FriendRequestResponse>.Success(mapper.Map<FriendRequestResponse>(request.Data));
var response = mapper.Map<FriendRequestResponse>(request.Data);
await PopulateUserInfoAsync(response);
return Result<FriendRequestResponse>.Success(response);
}
public async Task<Result<FriendRequestResponse>> UpdateStatusAsync(FriendRequestHandleCommand command)
@@ -44,6 +51,9 @@ namespace ContactService.WebApi.Application.FriendRequest
switch (command.Action)
{
case FriendRequestAction.Accpet:
var limit = runtime.Current.FriendLimit;
if (limit > 0 && (await db.Friends.CountAsync(x => x.Owner.Id == request.OwnerId) >= limit || await db.Friends.CountAsync(x => x.Owner.Id == request.TargetId) >= limit))
return Result<FriendRequestResponse>.Fail(ResultCode.PERMISSION_DENIED, "好友数量已达到平台上限");
request.Accept(command.RemarkName);
break;
case FriendRequestAction.Block:
@@ -55,27 +65,81 @@ namespace ContactService.WebApi.Application.FriendRequest
default:
return Result<FriendRequestResponse>.Fail(ResultCode.PARAMETER_ERROR);
}
return Result<FriendRequestResponse>.Success(mapper.Map<FriendRequestResponse>(request));
var response = mapper.Map<FriendRequestResponse>(request);
await PopulateUserInfoAsync(response);
return Result<FriendRequestResponse>.Success(response);
}
public async Task<Result<List<FriendRequestResponse>>> GetByOwnerIdAsync(Guid ownerId)
{
var requests = await reposity.FindByOwnerIdAsync(ownerId);
return Result<List<FriendRequestResponse>>.Success(mapper.Map<List<FriendRequestResponse>>(requests));
var responses = mapper.Map<List<FriendRequestResponse>>(requests);
await PopulateUserInfoAsync(responses);
return Result<List<FriendRequestResponse>>.Success(responses);
}
public async Task<Result<List<FriendRequestResponse>>> GetByTargetIdAsync(Guid targetId)
{
var requests = await reposity.FindByTargetIdAsync(targetId);
return Result<List<FriendRequestResponse>>.Success(mapper.Map<List<FriendRequestResponse>>(requests));
var responses = mapper.Map<List<FriendRequestResponse>>(requests);
await PopulateUserInfoAsync(responses);
return Result<List<FriendRequestResponse>>.Success(responses);
}
public async Task<Result<List<FriendRequestResponse>>> GetByTargetIdOrOwnerIdAsync(Guid id)
{
var requests = await reposity.FindByTargetIdAsync(id);
var requests2 = await reposity.FindByOwnerIdAsync(id);
return Result<List<FriendRequestResponse>>.Success(mapper.Map<List<FriendRequestResponse>>(requests.Concat(requests2)));
var responses = mapper.Map<List<FriendRequestResponse>>(requests.Concat(requests2));
await PopulateUserInfoAsync(responses);
return Result<List<FriendRequestResponse>>.Success(responses);
}
private async Task PopulateUserInfoAsync(List<FriendRequestResponse> responses)
{
if (responses.Count == 0) return;
var allUserIds = responses.Select(r => r.OwnerId)
.Concat(responses.Select(r => r.TargetId))
.Distinct()
.ToList();
var userResult = await identityService.FindByIdsAsync(allUserIds);
if (!userResult.Succeeded || userResult.Data == null) return;
var userDict = userResult.Data;
foreach (var r in responses)
{
if (userDict.TryGetValue(r.OwnerId, out var owner))
{
r.OwnerNickName = owner.NickName;
r.OwnerAvatar = owner.Avatar;
}
if (userDict.TryGetValue(r.TargetId, out var target))
{
r.TargetNickName = target.NickName;
r.TargetAvatar = target.Avatar;
}
}
}
private async Task PopulateUserInfoAsync(FriendRequestResponse response)
{
var ids = new List<Guid> { response.OwnerId, response.TargetId }.Distinct().ToList();
var userResult = await identityService.FindByIdsAsync(ids);
if (!userResult.Succeeded || userResult.Data == null) return;
var userDict = userResult.Data;
if (userDict.TryGetValue(response.OwnerId, out var owner))
{
response.OwnerNickName = owner.NickName;
response.OwnerAvatar = owner.Avatar;
}
if (userDict.TryGetValue(response.TargetId, out var target))
{
response.TargetNickName = target.NickName;
response.TargetAvatar = target.Avatar;
}
}
}
}
@@ -6,5 +6,6 @@ namespace ContactService.WebApi.Application.IntegrationServices
public interface IIdentityIntegrationService
{
Task<Result<UserInfoDto>> FindUserByIdAsync(Guid id);
Task<Result<Dictionary<Guid, UserInfoDto>>> FindByIdsAsync(List<Guid> ids);
}
}
@@ -27,7 +27,7 @@ namespace ContactService.WebApi.Application.IntegrationServices
{
Avatar = res.Avatar,
CreationTime = res.CreationTime.ToDateTimeOffset(),
Deletion = res.Deletion.ToDateTimeOffset(),
Deletion = res.Deletion != null ? res.Deletion.ToDateTimeOffset() : null,
Description = res.Description,
Email = res.Email,
Id = Guid.Parse(res.Id),
@@ -40,8 +40,40 @@ namespace ContactService.WebApi.Application.IntegrationServices
{
return Result.Fail<UserInfoDto>(ResultCode.USER_NOT_FOUND);
}
}
public async Task<Result<Dictionary<Guid, UserInfoDto>>> FindByIdsAsync(List<Guid> ids)
{
if (ids == null || ids.Count == 0)
return Result.Success(new Dictionary<Guid, UserInfoDto>());
var req = new GetUserListRequest();
req.UserIds.AddRange(ids.Select(x => x.ToString()));
try
{
var res = await client.GetUserListAsyncAsync(req);
var dict = res.Users.ToDictionary(
u => Guid.Parse(u.Id),
u => new UserInfoDto
{
Id = Guid.Parse(u.Id),
UserName = u.UserName,
NickName = u.NickName,
Email = u.Email,
Phone = u.Phone,
Region = u.Region,
Description = u.Description,
Avatar = u.Avatar,
CreationTime = u.CreationTime.ToDateTimeOffset(),
Deletion = u.Deletion != null ? u.Deletion.ToDateTimeOffset() : null
});
return Result.Success(dict);
}
catch (RpcException)
{
return Result.Fail<Dictionary<Guid, UserInfoDto>>(ResultCode.USER_NOT_FOUND);
}
}
}
}
@@ -0,0 +1,11 @@
using ContactService.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ContactService.WebApi.Controllers;
[ApiController, Route("internal/management")]
public sealed class ManagementController(ContactDbContext db) : ControllerBase
{
[HttpGet("relation/{owner:guid}/{target:guid}")]
public async Task<object> Relation(Guid owner, Guid target) => new { related = await db.Friends.AnyAsync(x => x.Owner.Id == owner && x.Target.Id == target) };
}
+3 -17
View File
@@ -1,27 +1,13 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY IM_API_NEW.sln ./
COPY ContactService.WebApi/ContactService.WebApi.csproj ContactService.WebApi/
COPY ContactService.Domain/ContactService.Domain.csproj ContactService.Domain/
COPY ContactService.Infrastructure/ContactService.Infrastructure.csproj ContactService.Infrastructure/
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
COPY Infrastructure/IM.Infrastructure.csproj Infrastructure/
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
COPY IM.Commons/IM.Commons.csproj IM.Commons/
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
RUN dotnet restore ContactService.WebApi/ContactService.WebApi.csproj
COPY . .
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish ContactService.WebApi/ContactService.WebApi.csproj \
-c Release \
-o /app/publish \
--no-restore
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
+3
View File
@@ -1,3 +1,4 @@
using IM.InitCommon.Management;
using IM.InitCommon;
@@ -23,6 +24,7 @@ namespace ContactService.WebApi
builder.Services.AddAllGrpcServer();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -32,6 +34,7 @@ namespace ContactService.WebApi
}
app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers();
@@ -16,22 +16,33 @@ namespace FileService.Application.EventHandler
private readonly IObjectStorageRouter router;
private readonly IUploadFileReposity uploadFile;
private readonly IUnitOfWork uwork;
private readonly IStorageRedisCache storageCache;
private readonly IUploadTaskReposity uploadTask;
public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUnitOfWork uwork, IStorageRedisCache storageCache)
public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUploadTaskReposity uploadTask, IUnitOfWork uwork)
{
this.router = router;
this.uploadFile = uploadFile;
this.uwork = uwork;
this.storageCache = storageCache;
this.uploadTask = uploadTask;
}
public async Task Consume(ConsumeContext<UploadTaskCompleteEvent> context)
{
var @event = context.Message;
var existingFile = await uploadFile.FindBySourceTaskIdAsync(@event.TaskId);
if (existingFile != null)
{
return;
}
var task = await uploadTask.FindByIdAsync(@event.TaskId);
if (task is null)
{
throw new InvalidOperationException($"Upload task {@event.TaskId} has not been committed yet.");
}
var storage = router.Route(@event.ProviderCode);
var taskCache = await storageCache.GetAsync(@event.SessionId);
if(@event.ProviderCode == "Local")
try
{
await storage.CompleteUploadAsync(new StorageContracts.CompleteUploadCommand(
ProviderCode: @event.ProviderCode,
@@ -46,14 +57,27 @@ namespace FileService.Application.EventHandler
Checksum: s.Checksum
)).ToList()
), context.CancellationToken);
uploadFile.Create(new Domain.Entities.UploadFile(
var file = new Domain.Entities.UploadFile(
ownerId: @event.OperatorId,
fileName: @event.FileName,
fileSize: taskCache.FileSize,
fileSize: @event.FileSize,
contentType: @event.ContentType,
new Domain.ValueObjects.StorageLocation(taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region),
checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun)
));
new Domain.ValueObjects.StorageLocation(@event.ProviderCode, @event.Bucket, @event.ObjectKey, @event.Region),
checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun),
sourceTaskId: @event.TaskId,
chatType: @event.ChatType,
targetId: @event.TargetId,
isPublic: false
);
uploadFile.Create(file);
task.CompleteUpload(file.Id);
await uwork.SaveChangesAsync(context.CancellationToken);
}
catch (Exception ex)
{
task.Fail(ex.Message);
await uwork.SaveChangesAsync(context.CancellationToken);
throw;
}
}
}
@@ -1,12 +1,30 @@
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
namespace FileService.Application.Ports
{
public interface IObjectStoragePort
{
string ProviderCode { get; }
Task<UploadPart> WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) => throw new NotSupportedException();
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token);
/// <summary>
/// 单次直传:把整个文件一次性写入存储(小文件,不分片)。
/// </summary>
public Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token);
/// <summary>
/// 生成可直接访问的公开直链。
/// 文件位于公开桶/目录返回直链;私有文件返回 null(需走鉴权下载接口)。
/// </summary>
public string? GetPublicUrl(StorageLocation location);
/// <summary>
/// 打开文件读取流(用于私有文件鉴权下载)。
/// </summary>
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token);
}
}
@@ -3,6 +3,23 @@ using FileService.Domain.ValueObjects;
namespace FileService.Application.StorageContracts
{
/// <summary>
/// 单次直传命令(小文件,一次性写入,不分片)。
/// </summary>
/// <param name="ProviderCode">储存提供商编号</param>
/// <param name="Bucket">目标桶;传入公开桶名即落到公开位置</param>
/// <param name="ObjectKey">对象 key(含目录前缀)</param>
/// <param name="ContentType">内容类型</param>
/// <param name="Content">文件流</param>
/// <param name="ContentLength">文件大小</param>
public sealed record PutObjectCommand(
string ProviderCode,
string Bucket,
string ObjectKey,
string ContentType,
Stream Content,
long ContentLength);
/// <summary>
///
/// </summary>
@@ -43,6 +60,7 @@ namespace FileService.Application.StorageContracts
public sealed record PresignedUrl(
string Url,
string Method,
IReadOnlyDictionary<string, string> Headers,
DateTimeOffset ExpiresAt);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -46,7 +46,7 @@ namespace FileService.Application.StorageContracts
ObjectKey = objectKey;
FileSize = fileSize;
TotalPartCount = totalPartCount;
ExpireAt = expireAt ?? DateTime.MaxValue;
ExpireAt = expireAt ?? DateTimeOffset.UtcNow.AddHours(24);
CreatedAt = DateTime.Now;
}
@@ -0,0 +1,10 @@
namespace FileService.Application.UploadFile
{
/// <summary>
/// 文件下载内容:流 + 内容类型 + 文件名。
/// </summary>
public sealed record FileDownload(
Stream Content,
string ContentType,
string FileName);
}
@@ -12,13 +12,20 @@ namespace FileService.Application.UploadFile
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public FileName FileName { get; set; }
public string FileName { get; set; }
public long FileSize { get; set; }
public ContentType ContentType { get; set; }
public string ContentType { get; set; }
public FileState State { get; set; }
public StorageLocation StorageLocation { get; set; }
public CheckSum CheckSum { get; set; }
public string CheckSum { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
public bool IsPublic { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset Updated { get; set; }
/// <summary>
/// 公开直链;私有文件为 null,需走鉴权下载接口。
/// </summary>
public string? Url { get; set; }
}
}
@@ -0,0 +1,19 @@
using System.Net.Http.Json;
namespace FileService.Application.UploadFile
{
public interface IGroupAccessService
{
Task<bool> CheckMemberAsync(Guid userId, Guid groupId);
}
public class GroupAccessService(HttpClient httpClient) : IGroupAccessService
{
public async Task<bool> CheckMemberAsync(Guid userId, Guid groupId)
{
var result = await httpClient.GetFromJsonAsync<IM.Commons.Result<bool>>(
$"api/groupmember/checkmember?userId={userId}&groupId={groupId}");
return result?.Succeeded == true && result.Data;
}
}
}
@@ -0,0 +1,21 @@
namespace FileService.Application.UploadFile
{
/// <summary>
/// 单次直传命令(小文件,如头像/封面,一次性上传并同步落库)。
/// </summary>
/// <param name="OwnerId">上传者</param>
/// <param name="FileName">原始文件名</param>
/// <param name="ContentType">内容类型</param>
/// <param name="FileSize">文件大小</param>
/// <param name="Content">文件流</param>
/// <param name="IsPublic">是否落到公开桶(可生成直链)</param>
/// <param name="CheckSum">可选校验值(md5</param>
public sealed record SimpleUploadCommand(
Guid OwnerId,
string FileName,
string ContentType,
long FileSize,
Stream Content,
bool IsPublic,
string? CheckSum = null);
}
@@ -12,8 +12,11 @@ namespace FileService.Application.UploadFile
public UploadFileMapperConfig()
{
CreateMap<Domain.Entities.UploadFile, FileResponse>()
.ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName.Value))
.ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType.Value))
.ForMember(dest => dest.CheckSum, opt => opt.MapFrom(src => src.CheckSum.Value))
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.CreationTime))
.ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime))
.ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime))
;
}
}
@@ -1,6 +1,12 @@
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;
using System.Security.Cryptography;
namespace FileService.Application.UploadFile
{
@@ -8,22 +14,163 @@ namespace FileService.Application.UploadFile
{
private readonly IUploadFileReposity reposity;
private readonly IMapper mapper;
private readonly IObjectStorageRouter router;
private readonly IOptions<StorageOptions> options;
private readonly IGroupAccessService groupAccessService; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
public UploadFileService(IUploadFileReposity reposity, IMapper mapper)
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptionsSnapshot<StorageOptions> options,
IGroupAccessService groupAccessService, IM.InitCommon.Management.RuntimePolicy runtime)
{
this.reposity = reposity;
this.mapper = mapper;
this.router = router;
this.options = options;
this.groupAccessService = groupAccessService; this.runtime = runtime;
}
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id)
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId)
{
var file = await reposity.FindByIdAsync(id);
if (file == null)
{
return Result.Fail<FileResponse>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileResponse>(ResultCode.PERMISSION_DENIED);
}
return Result.Success(mapper.Map<FileResponse>(file));
var response = mapper.Map<FileResponse>(file);
response.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
response.IsPublic = response.IsPublic || response.Url != null;
return Result.Success(response);
}
/// <summary>
/// 单次直传:一次性写入存储并同步落库(不分片、不走 MQ)。
/// 上传前按 checksum 检查是否已存在(秒传)。
/// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
{
runtime.CheckFile(command.FileName, command.FileSize);
if (command.FileSize <= 0 || command.FileSize > options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes) return Result.Fail<FileResponse>(ResultCode.FILE_TOO_LARGE);
var checksum = command.CheckSum;
if (string.IsNullOrWhiteSpace(checksum))
{
var hash = await MD5.HashDataAsync(command.Content, token);
checksum = Convert.ToHexString(hash).ToLowerInvariant();
if (command.Content.CanSeek)
{
command.Content.Position = 0;
}
}
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
if (!string.IsNullOrEmpty(checksum))
{
var existing = await reposity.FindByCheckSumGlobalAsync("md5", checksum);
var existingPublicUrl = existing == null
? null
: router.Route(existing.StorageLocation.StorageProvider).GetPublicUrl(existing.StorageLocation);
if (existing != null && (existingPublicUrl != null ||
(!command.IsPublic && existing.OwnerId == command.OwnerId)))
{
var hit = mapper.Map<FileResponse>(existing);
hit.Url = existingPublicUrl;
hit.IsPublic = hit.IsPublic || hit.Url != null;
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", checksum),
isPublic: command.IsPublic);
reposity.Create(file);
var response = mapper.Map<FileResponse>(file);
response.Url = storage.GetPublicUrl(location);
return Result.Success(response);
}
/// <summary>
/// 打开文件下载流(鉴权下载)。返回流、内容类型与原始文件名。
/// </summary>
public async Task<Result<FileDownload>> OpenDownloadAsync(Guid id, Guid requesterId, CancellationToken token = default)
{
var file = await reposity.FindByIdAsync(id);
if (file == null)
{
return Result.Fail<FileDownload>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileDownload>(ResultCode.PERMISSION_DENIED);
}
var stream = await router.Route(file.StorageLocation.StorageProvider)
.OpenReadAsync(file.StorageLocation, token);
return Result.Success(new FileDownload(
stream,
file.ContentType.Value,
file.FileName.Value));
}
private async Task<bool> CanAccessAsync(Domain.Entities.UploadFile file, Guid requesterId)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
if (file.IsPublic || publicUrl != null || file.OwnerId == requesterId) return true;
if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase))
{
return file.TargetId == requesterId;
}
if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && file.TargetId.HasValue)
{
return await groupAccessService.CheckMemberAsync(requesterId, file.TargetId.Value);
}
return false;
}
// 文件名超长时安全截断(保留扩展名),真实存储键由 objectKey 保证唯一。
private static FileName SafeFileName(string fileName)
{
const int maxFileNameLength = 255;
if (fileName.Length <= maxFileNameLength)
{
return new FileName(fileName);
}
var ext = Path.GetExtension(fileName);
var stem = Path.GetFileNameWithoutExtension(fileName);
var keep = Math.Max(0, maxFileNameLength - ext.Length);
return new FileName(stem[..Math.Min(stem.Length, keep)] + ext);
}
}
}
@@ -13,5 +13,10 @@ namespace FileService.Application.UploadFileTask
public string UploadSessionId { get; init; }
public StorageLocation StorageLocation { get; init; }
public bool Instant { get; init; }
public string UploadMode { get; init; } = "LocalMultipart";
public int TotalPartCount { get; init; }
public long PartSizeBytes { get; init; }
public global::FileService.Application.UploadFile.FileResponse? File { get; init; }
}
}
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.IReposities;
@@ -12,8 +12,9 @@ namespace FileService.Application.UploadFileTask
{
public class UploadFileTaskService(IUploadTaskReposity reposity,
IMapper mapper, IObjectStorageRouter router,
IOptions<StorageOptions> options, IStorageRedisCache redis,
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage
IOptionsSnapshot<StorageOptions> options, IStorageRedisCache redis,
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
IUploadFileReposity uploadFileReposity, IM.InitCommon.Management.RuntimePolicy runtime, UploadFile.IGroupAccessService groupAccess
)
{
private readonly IUploadTaskReposity reposity = reposity;
@@ -23,11 +24,44 @@ namespace FileService.Application.UploadFileTask
private readonly IStorageRedisCache redis = redis;
private readonly IPublishEndpoint endpoint = endpoint;
private readonly ILocalChunkStorage localChunkStorage = localChunkStorage;
private readonly IUploadFileReposity uploadFileReposity = uploadFileReposity;
private readonly IObjectStoragePort storage = router.Route(options.Value.DefaultProviderCode);
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command)
{
runtime.CheckFile(command.FileName, command.FileSize);
if (command.FileSize <= 0) return Result.Fail<TaskInitResponse>(ResultCode.PARAMETER_ERROR);
await CheckGroup(command.ChatType, command.TargetId, command.UploaderId);
CancellationToken cancellationToken = CancellationToken.None;
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum);
if (existingFile != null && CanReuse(existingFile, command))
{
var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider);
var fileResponse = mapper.Map<UploadFile.FileResponse>(existingFile);
fileResponse.Url = storageForResponse.GetPublicUrl(existingFile.StorageLocation);
fileResponse.IsPublic = fileResponse.IsPublic || fileResponse.Url != null;
return Result.Success(new TaskInitResponse
{
TaskId = existingFile.Id,
UploadSessionId = existingFile.Id.ToString(),
StorageLocation = existingFile.StorageLocation,
Instant = true,
UploadMode = "Instant",
TotalPartCount = 0,
PartSizeBytes = 0,
File = fileResponse
});
}
// 文件大小上限校验
var maxSize = options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes;
if (command.FileSize > maxSize)
{
return Result.Fail<TaskInitResponse>(ResultCode.FILE_TOO_LARGE);
}
var task = command.ToUploadTask();
var date = DateTime.Now;
var storageOption = options.Value.Providers[options.Value.DefaultProviderCode];
@@ -35,16 +69,32 @@ namespace FileService.Application.UploadFileTask
var initUpdateCommand = new StorageContracts.InitiateUploadCommand(
ProviderCode: storageOption.ProviderCode,
Bucket: storageOption.Bucket,
ObjectKey: $"{storageOption.LocalRootPath}\\{date.Year}\\{date.Month}\\{date.Day}\\{command.FileName}",
ObjectKey: $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(command.FileName)}",
ContentType: task.ContentType.Value,
ContentLength: command.FileSize,
null);
var initRes = await storage.InitUploadAsync(initUpdateCommand, cancellationToken);
var totalPartCount = (int)(task.FileSize % storageOption.DefaultPartSizeBytes > 0 ?
(task.FileSize / storageOption.DefaultPartSizeBytes) + 1 :
task.FileSize / storageOption.DefaultPartSizeBytes);
var res = mapper.Map<TaskInitResponse>(initRes);
res.TaskId = task.Id;
res = new TaskInitResponse
{
TaskId = task.Id,
UploadSessionId = initRes.UploadSessionId,
StorageLocation = initRes.Location,
Instant = false,
UploadMode = runtime.Enabled || string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
? "ServerMultipart"
: "Presigned",
TotalPartCount = totalPartCount,
PartSizeBytes = storageOption.DefaultPartSizeBytes
};
task.StartUpload();
task.StartUpload(new Domain.ValueObjects.StorageLocation(storageOption.ProviderCode, storageOption.Bucket, initUpdateCommand.ObjectKey, storageOption.Region));
reposity.Create(task);
await redis.SetAsync(new StorageContracts.UploadRuntimeCache(
@@ -55,9 +105,7 @@ namespace FileService.Application.UploadFileTask
region: storageOption.Region,
objectKey: initUpdateCommand.ObjectKey,
fileSize: task.FileSize,
totalPartCount: (int)(task.FileSize % storageOption.DefaultPartSizeBytes > 0 ?
(task.FileSize / storageOption.DefaultPartSizeBytes) + 1 :
task.FileSize / storageOption.DefaultPartSizeBytes)
totalPartCount: totalPartCount
));
return Result.Success(res);
@@ -72,18 +120,27 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
}
if(taskCache.TotalPartCount < partNum)
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED);
}
var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
runtime.CheckFile(task.FileName.Value, task.FileSize);
await CheckGroup(task.ChatType, task.TargetId, userId);
if (taskCache.TotalPartCount < partNum || partNum < 1)
{
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
}
if (runtime.Enabled) return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED, "请通过鉴权分片接口上传,以保证封禁立即生效");
var presignUrl = await router.Route(taskCache.ProviderCode).GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
ProviderCode: taskCache.ProviderCode,
Bucket: taskCache.Bucket,
ObjectKey: taskCache.ObjectKey,
UploadSessionId: taskCache.UploadSessionId,
PartNumber: partNum,
ExpiresIn: options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn
ExpiresIn: options.Value.Providers[taskCache.ProviderCode].UploadUrlExpiresIn
), token);
return Result.Success(presignUrl);
@@ -98,23 +155,61 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if(taskCache.Parts.Count < taskCache.TotalPartCount)
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_COMBINE_FAIL);
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != command.userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
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);
runtime.CheckFile(task.FileName.Value, task.FileSize);
await CheckGroup(task.ChatType, task.TargetId, command.userId);
if (task.State == Domain.UploadTaskState.Merging) return Result.Success(mapper.Map<UploadTaskResponse>(task));
// 校验分片数量必须匹配
if (command.Parts.Count != taskCache.TotalPartCount)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PART_COUNT_MISMATCH);
}
task.CompleteUpload(new Domain.ValueObjects.StorageLocation(
var expectedPartNumbers = Enumerable.Range(1, taskCache.TotalPartCount).ToHashSet();
if (!expectedPartNumbers.SetEquals(command.Parts.Select(x => x.PartNumber)))
{
return Result.Fail<UploadTaskResponse>(ResultCode.INVALID_PART_NUMBER);
}
// 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。
if (runtime.Enabled || string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase))
{
foreach (var part in command.Parts)
{
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
}
}
if (task.State == Domain.UploadTaskState.Completed)
{
var completedResponse = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
completedResponse.File = mapper.Map<UploadFile.FileResponse>(file);
completedResponse.File.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
completedResponse.File.IsPublic = completedResponse.File.IsPublic || completedResponse.File.Url != null;
}
}
return Result.Success(completedResponse);
}
task.StartMerging(new Domain.ValueObjects.StorageLocation(
taskCache.ProviderCode, taskCache.Bucket,
taskCache.ObjectKey, taskCache.Region
));
@@ -136,27 +231,52 @@ namespace FileService.Application.UploadFileTask
FileSize = task.FileSize,
ContentType = task.ContentType.ToString(),
CheckSun = task.CheckSum.Value
,ChatType = task.ChatType
,TargetId = task.TargetId
}, cancellationToken);
return Result.Success(mapper.Map<UploadTaskResponse>(task));
}
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command)
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command, Guid userId)
{
var taskCache = await redis.GetAsync(command.SessionId);
if (taskCache is null)
{
return Result.Fail<CompleteUploadResult>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<CompleteUploadResult>(ResultCode.PERMISSION_DENIED);
}
runtime.CheckFile(task.FileName.Value, task.FileSize);
await CheckGroup(task.ChatType, task.TargetId, userId);
if (command.PartNum < 1 || command.PartNum > taskCache.TotalPartCount || command.ContentLength <= 0 || command.ContentLength > task.FileSize)
return Result.Fail<CompleteUploadResult>(ResultCode.PARAMETER_ERROR);
var minPartSize = options.Value.Providers[taskCache.ProviderCode].MinPartSizeBytes;
// 最后一个分片豁免最小值校验(仅校验非最后一片)
var isLastPart = command.PartNum == taskCache.TotalPartCount;
if (!isLastPart && command.ContentLength < minPartSize)
{
return Result.Fail<CompleteUploadResult>(ResultCode.PART_TOO_SMALL,
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
}
StorageContracts.UploadPart uploaded;
if (taskCache.ProviderCode == "Local") {
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
UploadSessionId: command.SessionId,
PartNumber: command.PartNum,
Stream: command.Stream,
ContentLength: command.ContentLength
));
taskCache.AddOrUpdatePart(new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength));
uploaded = new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength);
} else uploaded = await router.Route(taskCache.ProviderCode).WritePartAsync(taskCache, command.PartNum, command.Stream, command.ContentLength, CancellationToken.None);
taskCache.AddOrUpdatePart(uploaded);
await redis.SetAsync(taskCache);
var location = new Domain.ValueObjects.StorageLocation(
storageProvider: taskCache.ProviderCode,
@@ -164,7 +284,93 @@ namespace FileService.Application.UploadFileTask
objectKey: taskCache.ObjectKey,
region: taskCache.Region
);
return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength));
return Result.Success(new CompleteUploadResult(location, uploaded.ETag, command.ContentLength));
}
/// <summary>
/// 查询分片上传进度。
/// </summary>
public async Task<Result<UploadProgressResponse>> GetProgressAsync(string sessionId, Guid userId)
{
var taskCache = await redis.GetAsync(sessionId);
if (taskCache is null)
{
return Result.Fail<UploadProgressResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<UploadProgressResponse>(ResultCode.PERMISSION_DENIED);
}
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);
}
private async Task CheckGroup(string? chatType, Guid? targetId, Guid userId)
{
if (string.Equals(chatType, "GROUP", StringComparison.OrdinalIgnoreCase) && (!targetId.HasValue || !await groupAccess.CheckMemberAsync(userId, targetId.Value)))
throw new IM.DomainCommons.DomainException("群组已被封禁或没有群文件写入权限");
}
private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
if (file.IsPublic || publicUrl != null) return true;
if (file.OwnerId == command.UploaderId)
{
return string.Equals(file.ChatType, command.ChatType, StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.TargetId;
}
if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(command.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.TargetId;
}
if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(command.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.UploaderId && command.TargetId == file.OwnerId;
}
return false;
}
public async Task<Result<UploadTaskResponse>> GetStatusAsync(Guid taskId, Guid userId)
{
var task = await reposity.FindByIdAsync(taskId);
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
response.File = mapper.Map<UploadFile.FileResponse>(file);
response.File.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
response.File.IsPublic = response.File.IsPublic || response.File.Url != null;
}
}
return Result.Success(response);
}
}
}
@@ -0,0 +1,16 @@
namespace FileService.Application.UploadFileTask
{
/// <summary>
/// 分片上传进度响应。
/// </summary>
public class UploadProgressResponse
{
public string SessionId { get; set; }
public string TaskId { get; set; }
public long FileSize { get; set; }
public int TotalPartCount { get; set; }
public int CompletedPartCount { get; set; }
public long UploadedBytes { get; set; }
public int ProgressPercent { get; set; }
}
}
@@ -8,7 +8,7 @@ namespace FileService.Application.UploadFileTask
{
public record UploadTaskInitCommand(
Guid UploaderId,
Guid ConversationId, string FileName,
Guid ConversationId, string? ChatType, Guid? TargetId, string FileName,
long FileSize,string contentType,
string checkSum
)
@@ -17,7 +17,7 @@ namespace FileService.Application.UploadFileTask
{
return new Domain.Entities.UploadTask(
UploaderId,
ConversationId, FileName, FileSize, contentType,
ConversationId, ChatType, TargetId, FileName, FileSize, contentType,
null,new Domain.ValueObjects.CheckSum("md5", checkSum)
);
}
@@ -19,5 +19,8 @@ namespace FileService.Application.UploadFileTask
public StorageLocation StorageLocation { get; set; }
public string State { get; set; }
public string CheckSum { get; set; }
public Guid? ResultFileId { get; set; }
public string? FailureReason { get; set; }
public global::FileService.Application.UploadFile.FileResponse? File { get; set; }
}
}
+9 -1
View File
@@ -12,10 +12,14 @@ namespace FileService.Domain.Entities
public FileState State { get; private set; } = FileState.Uploaded;
public StorageLocation StorageLocation { get; private set; } = new StorageLocation();
public CheckSum CheckSum { get; private set; }
public Guid? SourceTaskId { get; private set; }
public string? ChatType { get; private set; }
public Guid? TargetId { get; private set; }
public bool IsPublic { get; private set; }
private UploadFile() { }
public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum, Guid? sourceTaskId = null, string? chatType = null, Guid? targetId = null, bool isPublic = false)
{
OwnerId = ownerId;
FileName = fileName;
@@ -23,6 +27,10 @@ namespace FileService.Domain.Entities
ContentType = contentType;
StorageLocation = storageLocation ?? new StorageLocation();
CheckSum = checkSum;
SourceTaskId = sourceTaskId;
ChatType = chatType?.ToUpperInvariant();
TargetId = targetId;
IsPublic = isPublic;
State = FileState.Uploaded;
}
+25 -7
View File
@@ -1,4 +1,4 @@
using FileService.Domain.Events;
using FileService.Domain.Events;
using FileService.Domain.ValueObjects;
using IM.DomainCommons;
@@ -8,19 +8,25 @@ namespace FileService.Domain.Entities
{
public Guid UploaderId { get; private set; }
public Guid ConversationId { get; private set; }
public string? ChatType { get; private set; }
public Guid? TargetId { get; private set; }
public FileName FileName { get; private set; }
public long FileSize { get; private set; }
public ContentType ContentType { get; private set; }
public StorageLocation StorageLocation { get; private set; }
public UploadTaskState State { get; private set; }
public CheckSum CheckSum { get; private set; }
public Guid? ResultFileId { get; private set; }
public string? FailureReason { get; private set; }
private UploadTask() { }
public UploadTask(Guid uploaderId, Guid conversationId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
public UploadTask(Guid uploaderId, Guid conversationId, string? chatType, Guid? targetId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
{
UploaderId = uploaderId;
ConversationId = conversationId;
ChatType = chatType?.ToUpperInvariant();
TargetId = targetId;
FileName = fileName;
FileSize = fileSize;
ContentType = contentType;
@@ -28,21 +34,33 @@ namespace FileService.Domain.Entities
CheckSum = checkSum;
}
public void StartUpload()
public void StartUpload(StorageLocation? location = null)
{
State = UploadTaskState.Uploading;
if (location != null) StorageLocation = location; State = UploadTaskState.Uploading;
}
public void CompleteUpload(StorageLocation location)
public void StartMerging(StorageLocation location)
{
StorageLocation = location;
State = UploadTaskState.Merging;
FailureReason = null;
NotifyModified();
}
public void CompleteUpload(Guid fileId)
{
ResultFileId = fileId;
State = UploadTaskState.Completed;
FailureReason = null;
NotifyModified();
AddDomainEvent(new UploadTaskCompletedDomainEvent(this));
}
public void Fail()
public void Fail(string reason)
{
State = UploadTaskState.Failed;
FailureReason = reason.Length > 500 ? reason[..500] : reason;
NotifyModified();
}
}
}
@@ -11,6 +11,14 @@ namespace FileService.Domain.IReposities
{
void Create(UploadFile file);
Task<UploadFile?> FindByIdAsync(Guid id);
/// <summary>
/// 按上传者 + checksum 查询(用于同一用户秒传去重)
/// </summary>
Task<UploadFile?> FindByCheckSumAsync(Guid uploaderId,string value);
/// <summary>
/// 全局按 checksum 查询(用于跨用户秒传去重)
/// </summary>
Task<UploadFile?> FindByCheckSumGlobalAsync(string algorithm, string value);
Task<UploadFile?> FindBySourceTaskIdAsync(Guid taskId);
}
}
+2 -2
View File
@@ -12,9 +12,9 @@ namespace FileService.Domain.ValueObjects
public FileName(string value)
{
if(value.Length > 20)
if (string.IsNullOrWhiteSpace(value) || value.Length > 255)
{
throw new ArgumentException("文件名超出长度");
throw new ArgumentException("文件名不能为空且不能超过 255 个字符");
}
Value = value;
@@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs
{
builder.ToTable("upload_files");
builder.Property(x => x.FileName)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.FileName(b)
);
builder.Property(x => x.ContentType)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.ContentType(b)
@@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs
builder.ComplexProperty(x => x.CheckSum, c =>
{
c.Property(p => p.Value)
.HasMaxLength(128)
.HasColumnName("checksum_value");
c.Property(p => p.Algorithm)
.HasMaxLength(16)
.HasColumnName("checksum_algorithm");
});
builder.HasIndex(x => x.SourceTaskId).IsUnique();
builder.Property(x => x.ChatType).HasMaxLength(16);
builder.ComplexProperty(x => x.StorageLocation, c =>
{
c.Property(p => p.StorageProvider)
.HasMaxLength(64)
.HasColumnName("storage_provider");
c.Property(p => p.ObjectKey)
.HasMaxLength(1024)
.HasColumnName("storage_key");
c.Property(p => p.Region)
.HasMaxLength(128)
.HasColumnName("storage_region");
c.Property(p => p.Bucket)
.HasMaxLength(255)
.HasColumnName("storage_bucket");
});
}
@@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs
{
builder.ToTable("upload_tasks");
builder.Property(x => x.FileName)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.FileName(b)
);
builder.Property(x => x.ContentType)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.ContentType(b)
@@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs
builder.ComplexProperty(x => x.CheckSum, c =>
{
c.Property(p => p.Value)
.HasMaxLength(128)
.HasColumnName("checksum_value");
c.Property(p => p.Algorithm)
.HasMaxLength(16)
.HasColumnName("checksum_algorithm");
});
builder.Property(x => x.FailureReason).HasMaxLength(500);
builder.Property(x => x.ChatType).HasMaxLength(16);
builder.ComplexProperty(x => x.StorageLocation, c =>
{
c.Property(p => p.StorageProvider)
.HasMaxLength(64)
.HasColumnName("storage_provider");
c.Property(p => p.ObjectKey)
.HasMaxLength(1024)
.HasColumnName("storage_key");
c.Property(p => p.Region)
.HasMaxLength(128)
.HasColumnName("storage_region");
c.Property(p => p.Bucket)
.HasMaxLength(255)
.HasColumnName("storage_bucket");
});
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.511.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace FileService.Infrastructure.Migrations
{
[DbContext(typeof(FileDbContext))]
[Migration("20260909000300_AsyncUploadResult")]
public partial class AsyncUploadResult : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
AlterStringColumns(migrationBuilder, narrowing: true);
migrationBuilder.AddColumn<string>(
name: "ChatType",
table: "upload_files",
type: "varchar(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsPublic",
table: "upload_files",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "SourceTaskId",
table: "upload_files",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TargetId",
table: "upload_files",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChatType",
table: "upload_tasks",
type: "varchar(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FailureReason",
table: "upload_tasks",
type: "varchar(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ResultFileId",
table: "upload_tasks",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TargetId",
table: "upload_tasks",
type: "char(36)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_upload_files_SourceTaskId",
table: "upload_files",
column: "SourceTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_upload_files_checksum",
table: "upload_files",
columns: new[] { "checksum_algorithm", "checksum_value", "IsDeleted" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_upload_files_checksum", "upload_files");
migrationBuilder.DropIndex("IX_upload_files_SourceTaskId", "upload_files");
migrationBuilder.DropColumn("ChatType", "upload_files");
migrationBuilder.DropColumn("IsPublic", "upload_files");
migrationBuilder.DropColumn("SourceTaskId", "upload_files");
migrationBuilder.DropColumn("TargetId", "upload_files");
migrationBuilder.DropColumn("ChatType", "upload_tasks");
migrationBuilder.DropColumn("FailureReason", "upload_tasks");
migrationBuilder.DropColumn("ResultFileId", "upload_tasks");
migrationBuilder.DropColumn("TargetId", "upload_tasks");
AlterStringColumns(migrationBuilder, narrowing: false);
}
private static void AlterStringColumns(MigrationBuilder migrationBuilder, bool narrowing)
{
var columns = new (string Table, string Column, int Length, bool Nullable)[]
{
("upload_files", "FileName", 255, false),
("upload_files", "ContentType", 255, false),
("upload_files", "checksum_algorithm", 16, false),
("upload_files", "checksum_value", 128, false),
("upload_files", "storage_provider", 64, false),
("upload_files", "storage_bucket", 255, false),
("upload_files", "storage_key", 1024, false),
("upload_files", "storage_region", 128, true),
("upload_tasks", "FileName", 255, false),
("upload_tasks", "ContentType", 255, false),
("upload_tasks", "checksum_algorithm", 16, false),
("upload_tasks", "checksum_value", 128, false),
("upload_tasks", "storage_provider", 64, false),
("upload_tasks", "storage_bucket", 255, false),
("upload_tasks", "storage_key", 1024, false),
("upload_tasks", "storage_region", 128, true)
};
foreach (var (table, column, length, nullable) in columns)
{
migrationBuilder.AlterColumn<string>(
name: column,
table: table,
type: narrowing ? $"varchar({length})" : "longtext",
maxLength: narrowing ? length : null,
nullable: nullable,
oldClrType: typeof(string),
oldType: narrowing ? "longtext" : $"varchar({length})",
oldMaxLength: narrowing ? null : length,
oldNullable: nullable);
}
}
}
}
@@ -25,13 +25,18 @@ namespace FileService.Infrastructure.Migrations
modelBuilder.Entity("FileService.Domain.Entities.UploadFile", b =>
{
b.Property<string>("ChatType")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTimeOffset>("CreationTime")
.HasColumnType("datetime(6)");
@@ -41,7 +46,8 @@ namespace FileService.Infrastructure.Migrations
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
@@ -49,27 +55,38 @@ namespace FileService.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublic")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("ModificationTime")
.HasColumnType("datetime(6)");
b.Property<Guid>("OwnerId")
.HasColumnType("char(36)");
b.Property<Guid?>("SourceTaskId")
.HasColumnType("char(36)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid?>("TargetId")
.HasColumnType("char(36)");
b.ComplexProperty<Dictionary<string, object>>("CheckSum", "FileService.Domain.Entities.UploadFile.CheckSum#CheckSum", b1 =>
{
b1.IsRequired();
b1.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(16)
.HasColumnType("varchar(16)")
.HasColumnName("checksum_algorithm");
b1.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("checksum_value");
});
@@ -79,38 +96,50 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Bucket")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(255)
.HasColumnType("varchar(255)")
.HasColumnName("storage_bucket");
b1.Property<string>("ObjectKey")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("storage_key");
b1.Property<string>("Region")
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("storage_region");
b1.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("storage_provider");
});
b.HasKey("Id");
b.HasIndex("SourceTaskId")
.IsUnique();
b.ToTable("upload_files", (string)null);
});
modelBuilder.Entity("FileService.Domain.Entities.UploadTask", b =>
{
b.Property<string>("ChatType")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<Guid>("ConversationId")
.HasColumnType("char(36)");
@@ -123,20 +152,31 @@ namespace FileService.Infrastructure.Migrations
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("ModificationTime")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ResultFileId")
.HasColumnType("char(36)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid?>("TargetId")
.HasColumnType("char(36)");
b.Property<Guid>("UploaderId")
.HasColumnType("char(36)");
@@ -146,12 +186,14 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(16)
.HasColumnType("varchar(16)")
.HasColumnName("checksum_algorithm");
b1.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("checksum_value");
});
@@ -161,21 +203,25 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Bucket")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(255)
.HasColumnType("varchar(255)")
.HasColumnName("storage_bucket");
b1.Property<string>("ObjectKey")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("storage_key");
b1.Property<string>("Region")
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("storage_region");
b1.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("storage_provider");
});
@@ -35,5 +35,17 @@ namespace FileService.Infrastructure.Reposites
{
return await db.Files.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<UploadFile?> FindByCheckSumGlobalAsync(string algorithm, string value)
{
return await db.Files.FirstOrDefaultAsync(
x => x.CheckSum.Algorithm == algorithm &&
x.CheckSum.Value == value
);
}
public Task<UploadFile?> FindBySourceTaskIdAsync(Guid taskId)
{
return db.Files.FirstOrDefaultAsync(x => x.SourceTaskId == taskId);
}
}
}
@@ -1,119 +1,70 @@
using FileService.Application.Ports;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
using IM.Commons;
using IM.InitCommon;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace FileService.Infrastructure.Storage
namespace FileService.Infrastructure.Storage;
public class LocalStorageAdapter(IStorageRedisCache redis, IOptionsSnapshot<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
{
public class LocalStorageAdapter(IStorageRedisCache redis, IOptions<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
{
private readonly IStorageRedisCache redis = redis;
private readonly IOptions<StorageOptions> options = options;
private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode];
private StorageProviderOptions Provider => options.Value.Providers["Local"];
public string ProviderCode => "Local";
public static string SafePath(string root, params string[] segments)
{
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var path = Path.GetFullPath(Path.Combine(new[] { fullRoot }.Concat(segments).ToArray()));
if (!path.StartsWith(fullRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) throw new InvalidOperationException("存储路径超出允许根目录");
for (var dir = Path.GetDirectoryName(path); dir != null && dir.Length >= fullRoot.Length; dir = Path.GetDirectoryName(dir))
if (Directory.Exists(dir) && File.GetAttributes(dir).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用存储目录链接");
if (File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用文件链接");
return path;
}
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token)
{
var path = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = new FileStream(path, FileMode.CreateNew);
await command.Content.CopyToAsync(stream, token);
if (stream.Length != command.ContentLength) throw new InvalidOperationException("文件实际大小与申报大小不符");
return new(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region);
}
public string? GetPublicUrl(StorageLocation location) => !string.IsNullOrEmpty(Provider.PublicBucket) && location.Bucket == Provider.PublicBucket
? $"{(Provider.PublicBaseUrl ?? Provider.LocalUploadApiBaseUrl ?? "").TrimEnd('/')}/static/{string.Join('/', location.ObjectKey.Replace('\\', '/').Split('/').Select(Uri.EscapeDataString))}" : null;
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token) => Task.FromResult<Stream>(File.OpenRead(SafePath(Provider.LocalRootPath!, location.Bucket, location.ObjectKey)));
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token) => Task.FromResult(new InitiateUploadResult(Guid.NewGuid().ToString(), new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region)));
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) => Task.FromResult(new PresignedUrl(
Provider.LocalUploadApiBaseUrl!.TrimEnd('/') + $"/local/parts/upload?sessionId={Uri.EscapeDataString(command.UploadSessionId)}&partNumber={command.PartNumber}", "POST", new Dictionary<string, string>(), DateTimeOffset.UtcNow.Add(command.ExpiresIn)));
public async Task SavePartAsync(SaveLocalPartCommand command)
{
if (!Guid.TryParse(command.UploadSessionId, out _) || command.PartNumber < 1) throw new InvalidOperationException("分片参数无效");
var path = SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{command.PartNumber}.part");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = File.Create(path);
await command.Stream.CopyToAsync(stream);
if (stream.Length != command.ContentLength) throw new InvalidOperationException("分片实际大小不符");
}
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token)
{
var res = await MergeAsync(command.UploadSessionId, command.ObjectKey, command.Parts);
return new CompleteUploadResult(new StorageLocation(
storageProvider: command.ProviderCode,
bucket: command.Bucket,
objectKey: command.ObjectKey,
region: command.Region
), null, command.Parts.Sum(x => x.Size).Value);
var cache = await redis.GetAsync(command.UploadSessionId) ?? throw new InvalidOperationException("上传任务已过期");
var final = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
Directory.CreateDirectory(Path.GetDirectoryName(final)!);
var temporary = final + ".merging";
await using (var output = File.Create(temporary)) {
foreach (var part in command.Parts.OrderBy(x => x.PartNumber)) {
await using var input = File.OpenRead(SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{part.PartNumber}.part"));
await input.CopyToAsync(output, token);
}
if (output.Length != cache.FileSize) throw new InvalidOperationException("合并文件大小不符");
}
File.Move(temporary, final, true);
// Keep parts available for idempotent retry after response loss.
return new(new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, command.Region), null, cache.FileSize);
}
public async Task<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> parts)
{
var rootPath = options.Value.Providers[options.Value.DefaultProviderCode].LocalRootPath;
var tempPath = Path.Combine(rootPath, sessionId, "parts"); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
var finalPath = Path.Combine(rootPath, objectKey);
var finalDir = Path.GetDirectoryName(finalPath);
Directory.CreateDirectory(finalDir);
var storageCache = await redis.GetAsync(sessionId);
var totalChunks = storageCache.TotalPartCount;
try
{
using (var finalStream = new FileStream(finalPath, FileMode.Create))
{
for (var i = 1; i <= totalChunks; i++)
{
var progress = (i * 100.0 / totalChunks);
if (i % 5 == 0 || i == totalChunks)
{
//await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
//{
// new("status", "processing"),
// new("progress", progress.ToString("F2"))
//});
}
var chunkPath = Path.Combine(tempPath, $"{i}.part");
if (!File.Exists(chunkPath))
return Result.Fail(ResultCode.CHUNK_NOT_FOUND);
using (var chunkStream = new FileStream(chunkPath, FileMode.Open))
{
await chunkStream.CopyToAsync(finalStream);
}
}
Directory.Delete(tempPath, true);
await redis.DeleteAsync(sessionId);
}
var cache = await redis.GetAsync(sessionId) ?? throw new InvalidOperationException("上传任务已过期");
await CompleteUploadAsync(new CompleteUploadCommand(ProviderCode: cache.ProviderCode, Bucket: cache.Bucket, Region: cache.Region, ObjectKey: objectKey, UploadSessionId: sessionId, Parts: parts), CancellationToken.None);
return Result.Success();
}
catch (Exception e)
{
//_logger.LogError(e, e.Message);
throw;
}
}
public async Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token)
{
var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl;
return new PresignedUrl(
baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}",
new Dictionary<string, string>(),
ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn)
);
}
public async Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token)
{
var sessionId = Guid.NewGuid();
var location = new StorageLocation();
return new InitiateUploadResult(sessionId.ToString(),location);
}
public async Task SavePartAsync(SaveLocalPartCommand command)
{
var path = BuildPartPath(
command.UploadSessionId,
command.PartNumber);
Directory.CreateDirectory(
Path.GetDirectoryName(path)!);
await using var fs = File.Create(path);
await command.Stream.CopyToAsync(fs);
await fs.FlushAsync();
}
private string BuildPartPath(
string uploadSessionId,
int partNumber)
{
return Path.Combine(
providerOptions.LocalRootPath,
uploadSessionId,
"parts",
$"{partNumber}.part");
}
}
}
@@ -1,22 +1,26 @@
using FileService.Application.Ports;
using FileService.Application.Ports;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using IM.InitCommon;
using Microsoft.Extensions.Options;
namespace FileService.Infrastructure.Storage
{
public class ObjectStorageRouter : IObjectStorageRouter
public class ObjectStorageRouter : IObjectStorageRouter, IDisposable
{
private IReadOnlyDictionary<string, IObjectStoragePort> adpters;
public ObjectStorageRouter(IEnumerable<IObjectStoragePort> storages)
public ObjectStorageRouter(IEnumerable<IObjectStoragePort> storages, IOptionsSnapshot<StorageOptions> options)
{
this.adpters = storages.ToDictionary(x => x.ProviderCode, StringComparer.OrdinalIgnoreCase);
var adapters = storages.ToDictionary(x => x.ProviderCode, StringComparer.OrdinalIgnoreCase);
foreach (var (code, provider) in options.Value.Providers.Where(x => x.Value.ProviderType is StorageProviderType.AwsS3 or StorageProviderType.Minio)) adapters[code] = new S3StorageAdapter(code, provider);
this.adpters = adapters;
}
public IObjectStoragePort Route(string providerCode)
public void Dispose() { foreach (var adapter in adpters.Values.OfType<S3StorageAdapter>()) adapter.Dispose(); } public IObjectStoragePort Route(string providerCode)
{
return this.adpters[providerCode];
}
@@ -0,0 +1,53 @@
using Amazon.S3;
using Amazon.S3.Model;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
using IM.InitCommon;
namespace FileService.Infrastructure.Storage;
public sealed class S3StorageAdapter(string code, StorageProviderOptions options) : IObjectStoragePort, IDisposable
{
private readonly AmazonS3Client client = new(options.AccessKeyId, options.AccessKeySecret, new AmazonS3Config { ServiceURL = options.Endpoint, AuthenticationRegion = string.IsNullOrWhiteSpace(options.Region) ? "us-east-1" : options.Region, ForcePathStyle = true });
public string ProviderCode => code;
public async Task<UploadPart> WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) {
var part = await client.UploadPartAsync(new UploadPartRequest { BucketName = task.Bucket, Key = task.ObjectKey, UploadId = task.UploadSessionId, PartNumber = partNumber, InputStream = content, PartSize = size }, ct);
return new(partNumber, part.ETag, size);
}
public async Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token) {
var r = await client.InitiateMultipartUploadAsync(new InitiateMultipartUploadRequest { BucketName = command.Bucket, Key = command.ObjectKey, ContentType = command.ContentType }, token);
return new(r.UploadId, new StorageLocation(code, command.Bucket, command.ObjectKey, options.Region));
}
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) {
var expires = DateTimeOffset.UtcNow.Add(command.ExpiresIn);
var request = new GetPreSignedUrlRequest { BucketName = command.Bucket, Key = command.ObjectKey, Verb = HttpVerb.PUT, Expires = expires.UtcDateTime, UploadId = command.UploadSessionId, PartNumber = command.PartNumber ?? 1 };
return Task.FromResult(new PresignedUrl(client.GetPreSignedURL(request), "PUT", new Dictionary<string, string>(), expires));
}
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) {
var response = await client.CompleteMultipartUploadAsync(new CompleteMultipartUploadRequest { BucketName = command.Bucket, Key = command.ObjectKey, UploadId = command.UploadSessionId, PartETags = command.Parts.OrderBy(x => x.PartNumber).Select(x => new PartETag(x.PartNumber, x.ETag)).ToList() }, token);
var meta = await client.GetObjectMetadataAsync(command.Bucket, command.ObjectKey, token);
return new(new StorageLocation(code, command.Bucket, command.ObjectKey, options.Region), response.ETag, meta.ContentLength, VersionId: response.VersionId);
}
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token) {
await client.PutObjectAsync(new PutObjectRequest { BucketName = command.Bucket, Key = command.ObjectKey, ContentType = command.ContentType, InputStream = command.Content, AutoCloseStream = false }, token);
return new(code, command.Bucket, command.ObjectKey, options.Region);
}
// Public rendering also passes through FileService. Private access is always authorized there.
public string? GetPublicUrl(StorageLocation location) => null;
public async Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token) { var response = await client.GetObjectAsync(location.Bucket, location.ObjectKey, token); return new ResponseStream(response); }
public async Task Test(CancellationToken ct) {
var key = "im-admin-connectivity/" + Guid.NewGuid().ToString("N");
try { await client.PutObjectAsync(new PutObjectRequest { BucketName = options.Bucket, Key = key, ContentBody = "IM connectivity test" }, ct); using var read = await client.GetObjectAsync(options.Bucket, key, ct); }
finally { await client.DeleteObjectAsync(options.Bucket, key, CancellationToken.None); }
}
public void Dispose() => client.Dispose();
sealed class ResponseStream(GetObjectResponse response) : Stream {
readonly Stream inner = response.ResponseStream;
public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; public override bool CanWrite => false;
public override long Length => response.ContentLength; public override long Position { get => inner.Position; set => inner.Position = value; }
public override void Flush() => inner.Flush(); public override int Read(byte[] b, int o, int c) => inner.Read(b, o, c);
public override ValueTask<int> ReadAsync(Memory<byte> b, CancellationToken ct = default) => inner.ReadAsync(b, ct);
public override long Seek(long o, SeekOrigin origin) => inner.Seek(o, origin); public override void SetLength(long v) => throw new NotSupportedException(); public override void Write(byte[] b, int o, int c) => throw new NotSupportedException();
protected override void Dispose(bool disposing) { if (disposing) response.Dispose(); base.Dispose(disposing); }
}
}
@@ -20,18 +20,40 @@ namespace FileService.Infrastructure.Storage
public async Task SetAsync(UploadRuntimeCache upload)
{
string key = RedisHelper.GetUploadInfoKey(upload.UploadSessionId);
await redis.SetAsync<UploadRuntimeCache>(key, upload);
// 过期时间取 UploadRuntimeCache 的 ExpireAt 字段,兜底默认 24 小时
var ttl = upload.ExpireAt > upload.CreatedAt
? upload.ExpireAt - upload.CreatedAt
: TimeSpan.FromHours(24);
await redis.SetAsync<UploadRuntimeCache>(key, upload, ttl);
// 维护 taskId → sessionId 映射,用于 DeleteByTaskId 快速定位
var mapKey = RedisHelper.GetUploadTaskSessionMapKey(upload.TaskId);
await redis.SetAsync(mapKey, upload.UploadSessionId, ttl);
}
public async Task DeleteAsync(string sessionId)
{
string key = RedisHelper.GetUploadInfoKey(sessionId);
// 先取出缓存以清理映射 key
var cache = await redis.GetAsync<UploadRuntimeCache>(key);
if (cache != null)
{
var mapKey = RedisHelper.GetUploadTaskSessionMapKey(cache.TaskId);
await redis.RemoveAsync(mapKey);
}
await redis.RemoveAsync(key);
}
public Task DeleteByTaskIdAsync(string taskId)
public async Task DeleteByTaskIdAsync(string taskId)
{
throw new NotImplementedException();
var mapKey = RedisHelper.GetUploadTaskSessionMapKey(taskId);
var sessionId = await redis.GetAsync<string>(mapKey);
if (sessionId != null)
{
await DeleteAsync(sessionId);
}
}
public async Task<UploadRuntimeCache?> GetAsync(string sessionId)
@@ -1,6 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using FileService.Application.UploadFile;
using FileService.Infrastructure;
using IM.ASPNETCore;
using IM.Commons;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace FileService.WebApi.Controllers.File
{
@@ -9,7 +14,68 @@ namespace FileService.WebApi.Controllers.File
[ApiController]
public class FileController : ControllerBase
{
//[HttpGet]
//public async
private readonly UploadFileService service;
public FileController(UploadFileService service)
{
this.service = service;
}
/// <summary>
/// 单次直传(小文件:头像/封面等)。isPublic=true 落公开桶,返回直链。
/// 支持秒传:若相同 checksum 的文件已存在,直接返回已有记录,跳过上传。
/// </summary>
[HttpPost("simple-upload")]
[UnitOfWork(typeof(FileDbContext))]
public async Task<IActionResult> SimpleUpload([FromForm] IFormFile file, [FromForm] bool isPublic)
{
if (file == null || file.Length == 0)
{
return BadRequest();
}
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await using var stream = file.OpenReadStream();
var res = await service.SimpleUploadAsync(new SimpleUploadCommand(
OwnerId: Guid.Parse(userId),
FileName: file.FileName,
ContentType: file.ContentType,
FileSize: file.Length,
Content: stream,
IsPublic: isPublic));
return Ok(res);
}
/// <summary>
/// 获取文件信息(含直链 Url;私有文件 Url 为 null)。
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.GetFileInfoAsync(id, Guid.Parse(userId));
return Ok(res);
}
/// <summary>
/// 鉴权下载文件内容(私有文件预览/下载走这里)。
/// </summary>
[HttpGet("{id}/content")]
public async Task<IActionResult> GetContent(Guid id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.OpenDownloadAsync(id, Guid.Parse(userId));
if (res.Data == null)
{
if (res.Code == (int)ResultCode.PERMISSION_DENIED)
{
return StatusCode(StatusCodes.Status403Forbidden, res);
}
return NotFound(res);
}
Response.Headers["Cache-Control"] = "private,max-age=86400";
return File(res.Data.Content, res.Data.ContentType, enableRangeProcessing: true);
}
}
}
@@ -5,8 +5,8 @@ namespace FileService.WebApi.Controllers.FileTask
{
public class CompleteTaskRequest
{
public string SessionId { get; set; }
public List<UploadPart> Parts { get; set; }
public string SessionId { get; set; } = string.Empty;
public List<UploadPart> Parts { get; set; } = [];
}
public class CompleteTaskRequestValidator : AbstractValidator<CompleteTaskRequest>
@@ -1,4 +1,4 @@
using FileService.Application.UploadFileTask;
using FileService.Application.UploadFileTask;
using FileService.Infrastructure;
using IM.ASPNETCore;
using Microsoft.AspNetCore.Authorization;
@@ -28,6 +28,8 @@ namespace FileService.WebApi.Controllers.FileTask
var res = await service.InitTaskAsync(new UploadTaskInitCommand(
UploaderId: Guid.Parse(userId),
ConversationId: request.ConversationId,
ChatType: request.ChatType,
TargetId: request.TargetId,
FileName: request.FileName,
FileSize: request.FileSize,
contentType: request.ContentType,
@@ -36,6 +38,21 @@ namespace FileService.WebApi.Controllers.FileTask
return Ok(res);
}
[HttpGet("progress")]
public async Task<IActionResult> Progress(string sessionId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.GetProgressAsync(sessionId, Guid.Parse(userId));
return Ok(res);
}
[HttpGet("status")]
public async Task<IActionResult> Status(Guid taskId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetStatusAsync(taskId, Guid.Parse(userId)));
}
[HttpGet("Getuploadurl")]
public async Task<IActionResult> GetUploadUrl(string sessionId, int partNum)
{
@@ -45,19 +62,20 @@ namespace FileService.WebApi.Controllers.FileTask
}
[HttpPost("complete")]
[UnitOfWork(typeof(FileDbContext))]
public async Task<IActionResult> Complete([FromBody] CompleteTaskRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.CompleteTaskAsync(new UploadTaskCompleteCommand(request.SessionId, Guid.Parse(userId), request.Parts));
return Ok(res);
return res.Succeeded ? Accepted(res) : Ok(res);
}
[HttpPost("local/parts/upload")]
public async Task<IActionResult> LocalUpload(string sessionId, int partNumber, IFormFile file)
public async Task<IActionResult> LocalUpload([FromForm] string sessionId, [FromForm] int partNumber, IFormFile file)
{
//var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var stream = file.OpenReadStream();
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length));
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await using var stream = file.OpenReadStream();
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId));
return Ok(res);
}
}
@@ -5,14 +5,30 @@ namespace FileService.WebApi.Controllers.FileTask
public class FileTaskInitRequest
{
public Guid ConversationId { get; set; }
public string FileName { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
public string FileName { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentType { get; set; }
public string CheckSum { get; set; }
public string ContentType { get; set; } = string.Empty;
public string CheckSum { get; set; } = string.Empty;
}
public class FileTaskInitRequestValidator: AbstractValidator<FileTaskInitRequest>
{
public FileTaskInitRequestValidator()
{
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
RuleFor(x => x.FileSize).GreaterThan(0);
RuleFor(x => x.ContentType).NotEmpty().MaximumLength(255);
RuleFor(x => x.CheckSum).NotEmpty().MaximumLength(128);
RuleFor(x => x.ChatType)
.Must(value => string.IsNullOrWhiteSpace(value) ||
value.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase) ||
value.Equals("GROUP", StringComparison.OrdinalIgnoreCase))
.WithMessage("chatType 必须为 PRIVATE 或 GROUP");
RuleFor(x => x.TargetId)
.NotEmpty()
.When(x => !string.IsNullOrWhiteSpace(x.ChatType));
}
}
}
@@ -0,0 +1,82 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FileService.Infrastructure;
using FileService.Infrastructure.Storage;
using IM.InitCommon;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FileService.WebApi.Controllers;
[ApiController, Route("internal/management/storage")]
public class ManagementController(FileDbContext db, IOptionsSnapshot<StorageOptions> current, IConfiguration config) : ControllerBase
{
[HttpGet("summary")]
public async Task<object> Summary(string? provider, string? type, DateTimeOffset? from, DateTimeOffset? to)
{
var files = db.Files.AsNoTracking().AsQueryable(); var tasks = db.Tasks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(provider)) { files = files.Where(x => x.StorageLocation.StorageProvider == provider); tasks = tasks.Where(x => x.StorageLocation.StorageProvider == provider); }
if (!string.IsNullOrWhiteSpace(type)) { files = files.Where(x => x.ContentType.Value == type); tasks = tasks.Where(x => x.ContentType.Value == type); }
if (from.HasValue) { files = files.Where(x => x.CreationTime >= from); tasks = tasks.Where(x => x.CreationTime >= from); }
if (to.HasValue) { files = files.Where(x => x.CreationTime < to); tasks = tasks.Where(x => x.CreationTime < to); }
var totals = await files.GroupBy(x => new { provider = x.StorageLocation.StorageProvider, type = x.ContentType.Value }).Select(g => new { g.Key.provider, g.Key.type, count = g.Count(), bytes = g.Sum(x => x.FileSize) }).ToListAsync();
var states = await tasks.GroupBy(x => x.State).Select(g => new { state = g.Key.ToString(), count = g.Count() }).ToListAsync();
var capacities = current.Value.Providers.Select(x => {
long? total = null, available = null; string status = "未提供";
if (x.Value.ProviderType == StorageProviderType.Local) try { var drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(x.Value.LocalRootPath!))!); total = drive.TotalSize; available = drive.AvailableFreeSpace; status = "可用"; } catch { status = "不可用"; }
return new { provider = x.Key, total, available, status };
}).ToArray();
return new { totals, tasks = states, capacities, checkedAt = DateTime.UtcNow };
}
[HttpPost("validate")]
public async Task<object> Validate(InfrastructureEnvelope input)
{
var next = Parse(input);
foreach (var (code, old) in current.Value.Providers) {
var referenced = await db.Files.IgnoreQueryFilters().AnyAsync(x => x.StorageLocation.StorageProvider == code) || await db.Tasks.IgnoreQueryFilters().AnyAsync(x => x.StorageLocation.StorageProvider == code);
if (!referenced) continue;
if (!next.Providers.TryGetValue(code, out var p) || !p.Enabled || p.ProviderType != old.ProviderType || p.Bucket != old.Bucket || p.PublicBucket != old.PublicBucket || p.Endpoint != old.Endpoint || p.Region != old.Region || p.LocalRootPath != old.LocalRootPath || p.PublicBaseUrl != old.PublicBaseUrl)
throw new IM.DomainCommons.DomainException("已有文件或上传任务引用该提供商,不能移除或更改定位参数");
}
return new { valid = true };
}
[HttpPost("test")]
public async Task<object> Test(InfrastructureEnvelope input, CancellationToken ct)
{
await Validate(input); var next = Parse(input);
foreach (var (code, provider) in next.Providers.Where(x => x.Value.Enabled)) {
if (provider.ProviderType == StorageProviderType.Local) {
var path = LocalStorageAdapter.SafePath(provider.LocalRootPath!, "im-admin-connectivity", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
try { await System.IO.File.WriteAllTextAsync(path, "IM connection test", ct); await System.IO.File.ReadAllTextAsync(path, ct); }
finally { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); }
} else { using var adapter = new S3StorageAdapter(code, provider); await adapter.Test(ct); }
}
return new { tested = true };
}
StorageOptions Parse(InfrastructureEnvelope input)
{
var next = input.Value.Deserialize<StorageOptions>(new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new IM.DomainCommons.DomainException("配置格式错误");
if (next.Providers is null || !next.Providers.TryGetValue(next.DefaultProviderCode, out var chosen) || !chosen.Enabled) throw new IM.DomainCommons.DomainException("默认提供商无效");
var secrets = JsonNode.Parse(string.IsNullOrEmpty(input.Secret) ? "{}" : input.Secret)!;
foreach (var (code, p) in next.Providers) {
if (p.ProviderCode != code || string.IsNullOrWhiteSpace(p.Bucket) || p.Bucket.IndexOfAny(['/', '\\']) >= 0 || p.PublicBucket?.IndexOfAny(['/', '\\']) >= 0 || p.Bucket is "." or ".." || p.PublicBucket is "." or "..") throw new IM.DomainCommons.DomainException("提供商或存储桶名称无效");
if (p.DefaultPartSizeBytes < p.MinPartSizeBytes || p.MinPartSizeBytes < 1 || p.MaxPartCount < 1 || p.MaxObjectSizeBytes < 1) throw new IM.DomainCommons.DomainException("分片或容量限制无效");
if (p.ProviderType == StorageProviderType.Local) {
if (code != "Local" || string.IsNullOrWhiteSpace(p.LocalRootPath)) throw new IM.DomainCommons.DomainException("本地提供商编码必须为 Local");
var root = Path.GetFullPath(p.LocalRootPath);
var allowed = config.GetSection("Management:AllowedStorageRoots").Get<string[]>() ?? [];
if (!allowed.Any(x => { var path = Path.GetFullPath(x).TrimEnd(Path.DirectorySeparatorChar); return root == path || root.StartsWith(path + Path.DirectorySeparatorChar, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); })) throw new IM.DomainCommons.DomainException("本地目录未被部署允许");
LocalStorageAdapter.SafePath(root, "im-admin-connectivity", "check");
} else {
if (p.ProviderType is not StorageProviderType.AwsS3 and not StorageProviderType.Minio || !Uri.TryCreate(p.Endpoint, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(uri.UserInfo)) throw new IM.DomainCommons.DomainException("不支持的存储端点");
var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get<string[]>() ?? [];
if (!allowed.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) throw new IM.DomainCommons.DomainException("存储主机未被部署允许");
p.AccessKeyId = secrets[code]?["accessKeyId"]?.GetValue<string>(); p.AccessKeySecret = secrets[code]?["accessKeySecret"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(p.AccessKeyId) || string.IsNullOrWhiteSpace(p.AccessKeySecret)) throw new IM.DomainCommons.DomainException("缺少存储凭据");
}
}
return next;
}
}
+23
View File
@@ -0,0 +1,23 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
# 清除本地 Windows obj 中的 NuGet fallback 路径引用,让 publish 在 Linux 下重新 restore
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
RUN dotnet publish FileService.WebApi/FileService.WebApi.csproj \
-c Release \
-o /app/publish
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FileService.WebApi.dll"]
+11
View File
@@ -9,6 +9,17 @@ namespace FileService.WebApi
public void Initialize(IServiceCollection services)
{
services.AddScoped<UploadFileService>();
services.AddHttpClient<IGroupAccessService, GroupAccessService>((sp, client) =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
client.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"]
?? "http://im-group-service:8080/");
var internalApiKey = configuration["InternalApiKey"];
if (!string.IsNullOrWhiteSpace(internalApiKey))
{
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey);
}
});
}
}
}
+32
View File
@@ -1,5 +1,7 @@
using IM.InitCommon.Management;
using IM.InitCommon;
using Microsoft.Extensions.FileProviders;
namespace FileService.WebApi
{
@@ -19,6 +21,7 @@ namespace FileService.WebApi
builder.ConfigExtraServices();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -28,11 +31,40 @@ namespace FileService.WebApi
}
app.UseAppDefault();
app.MapManagementHealth();
// 仅 FileService 暴露公开桶目录为静态直链,不动共享 UseAppDefault
UsePublicStaticFiles(app);
app.MapControllers();
app.Run();
}
private static void UsePublicStaticFiles(WebApplication app)
{
var storage = app.Configuration.GetSection("StorageOptions").Get<StorageOptions>();
if (storage?.Providers == null ||
!storage.Providers.TryGetValue(storage.DefaultProviderCode, out var provider))
{
return;
}
if (string.IsNullOrEmpty(provider.LocalRootPath) || string.IsNullOrEmpty(provider.PublicBucket))
{
return;
}
var publicPath = Path.Combine(provider.LocalRootPath, provider.PublicBucket);
Directory.CreateDirectory(publicPath);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(publicPath),
RequestPath = "/static",
OnPrepareResponse = ctx =>
ctx.Context.Response.Headers["Cache-Control"] = "public,max-age=2592000"
});
}
}
}
@@ -1,4 +1,8 @@
{
"InternalApiKey": "development-only-change-me",
"InternalServices": {
"GroupServiceBaseUrl": "http://localhost:5070/"
},
"Logging": {
"LogLevel": {
"Default": "Information",
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"InternalApiKey": "",
"InternalServices": {
"GroupServiceBaseUrl": "http://im-group-service:8080/"
}
}
+7 -4
View File
@@ -1,4 +1,4 @@
using GroupService.Domain.Enums;
using GroupService.Domain.Enums;
using GroupService.Domain.Events;
using IM.DomainCommons;
@@ -41,7 +41,7 @@ namespace GroupService.Domain.Entities
/// <summary>
/// 群头像
/// </summary>
public string? Avatar { get; private set; } = "https://ts1.tc.mm.bing.net/th/id/OIP-C.XiB9NHwI57WPmotCbXuwawAAAA?rs=1&pid=ImgDetMain&o=7&rm=3";
public string? Avatar { get; private set; }
public long MaxSequenceId { get; private set; } = 0;
public string LastMessage { get; private set; } = string.Empty;
public string LastSenderName { get; private set; } = string.Empty;
@@ -52,6 +52,7 @@ namespace GroupService.Domain.Entities
{
Name = name;
GroupMaster = groupMaster;
Avatar = $"https://api.dicebear.com/7.x/thumbs/svg?seed={Guid.NewGuid()}";
AddDomainEvent(new GroupCreateDomainEvent(this));
}
@@ -64,13 +65,15 @@ namespace GroupService.Domain.Entities
AddDomainEvent(new AllMembersBannedDomainEvent(this));
}
public void Ban()
public void Ban(bool notify = true)
{
Status = GroupState.Blocked;
ModificationTime = DateTime.Now;
AddDomainEvent(new GroupBlockedDomainEvent(this));
if (notify) AddDomainEvent(new GroupBlockedDomainEvent(this));
}
public void Unban() { Status = GroupState.Normal; ModificationTime = DateTime.Now; }
public void Update(string? name, GroupAuthorityType? groupAuthority, string? announcement, string? avatar)
{
bool isChanged = false;
@@ -49,5 +49,12 @@ namespace GroupService.Domain.Entities
{
GroupNickName = nickname;
}
public void Leave()
{
if (IsDeleted) return;
SoftDelete();
AddDomainEvent(new GroupMemberLeftDomainEvent(this));
}
}
}
@@ -0,0 +1,7 @@
using GroupService.Domain.Entities;
using MediatR;
namespace GroupService.Domain.Events
{
public record GroupMemberLeftDomainEvent(GroupMember Member) : INotification;
}
@@ -5,6 +5,8 @@ namespace GroupService.Domain.IReposities
public interface IGroupInvitationReposity
{
void Create(GroupInvitation invitation);
void CreateRange(IEnumerable<GroupInvitation> invitations);
Task<GroupInvitation?> FindByIdAsync(Guid id);
Task<GroupInvitation?> FindByGroupIdAndUserIdAsync(Guid groupId, Guid userId);
}
}
@@ -29,6 +29,7 @@ namespace GroupService.Domain.IReposities
/// <param name="member"></param>
/// <returns></returns>
GroupMember Create(GroupMember member);
void CreateRange(IEnumerable<GroupMember> members);
/// <summary>
/// 检查成员是否存在
/// </summary>
@@ -36,5 +37,7 @@ namespace GroupService.Domain.IReposities
/// <returns>存在返回true,否则返回false</returns>
Task<bool> CheckMemberExistAsync(Guid groupId, Guid userId);
Task<GroupMember?> FindByIdAsync(Guid id);
Task<bool> CheckMemberAdminAsync(Guid groupId, Guid userId);
}
}
@@ -22,6 +22,7 @@ namespace GroupService.Domain.IReposities
/// <param name="userId"></param>
/// <returns></returns>
Task<IEnumerable<Group>> FindByMasterIdAsync(Guid userId);
Task<IEnumerable<Group>> FindByMemberIdAsync(Guid userId);
/// <summary>
/// 创建群聊
/// </summary>
@@ -7,5 +7,7 @@ namespace GroupService.Domain.IReposities
void Create(GroupJoinRequest request);
Task<GroupJoinRequest> FindByIdAsync(Guid id);
Task<IEnumerable<GroupJoinRequest?>> FindByGroupIdAsync(Guid groupId);
Task<IEnumerable<GroupJoinRequest>> FindByUserIdAsync(Guid userId);
Task<IEnumerable<GroupJoinRequest>> FindVisibleToUserAsync(Guid userId);
}
}
@@ -11,6 +11,9 @@ namespace GroupService.Infrastructure.Configs
builder.ToTable("group_join_requests");
builder.HasKey(x => x.Id);
builder.HasKey(x => new { x.GroupId, x.UserId });
builder.HasIndex(x => x.Id).IsUnique();
builder.HasIndex(x => new { x.GroupId, x.State, x.CreationTime });
builder.HasIndex(x => new { x.UserId, x.CreationTime });
builder.ComplexProperty(x => x.UserProfile, u =>
{
@@ -9,6 +9,8 @@ namespace GroupService.Infrastructure.Configs
public void Configure(EntityTypeBuilder<GroupMember> builder)
{
builder.ToTable("group_members");
builder.HasIndex(x => new { x.UserId, x.IsDeleted, x.GroupId });
builder.HasIndex(x => new { x.GroupId, x.IsDeleted, x.Role });
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace GroupService.Infrastructure.Migrations
{
[DbContext(typeof(GroupDbContext))]
[Migration("20260909000200_ApiAlignmentFixes")]
public partial class ApiAlignmentFixes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_group_members_UserId_IsDeleted_GroupId",
table: "group_members",
columns: new[] { "UserId", "IsDeleted", "GroupId" });
migrationBuilder.CreateIndex(
name: "IX_group_members_GroupId_IsDeleted_Role",
table: "group_members",
columns: new[] { "GroupId", "IsDeleted", "Role" });
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_Id",
table: "group_join_requests",
column: "Id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_GroupId_State_CreationTime",
table: "group_join_requests",
columns: new[] { "GroupId", "State", "CreationTime" });
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_UserId_CreationTime",
table: "group_join_requests",
columns: new[] { "UserId", "CreationTime" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_group_members_UserId_IsDeleted_GroupId", "group_members");
migrationBuilder.DropIndex("IX_group_members_GroupId_IsDeleted_Role", "group_members");
migrationBuilder.DropIndex("IX_group_join_requests_Id", "group_join_requests");
migrationBuilder.DropIndex("IX_group_join_requests_GroupId_State_CreationTime", "group_join_requests");
migrationBuilder.DropIndex("IX_group_join_requests_UserId_CreationTime", "group_join_requests");
}
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace GroupService.Infrastructure.Migrations;
[DbContext(typeof(GroupDbContext))]
[Migration("20260915000100_ManagementReceipts")]
public class ManagementReceipts : Migration
{
protected override void Up(MigrationBuilder m) => m.Sql("""
CREATE TABLE IF NOT EXISTS management_receipts (
Owner varchar(64) NOT NULL,
Id char(36) NOT NULL,
Payload longtext NOT NULL,
CreatedAt datetime(6) NOT NULL,
PRIMARY KEY (Owner, Id)
) CHARACTER SET utf8mb4;
""");
// Receipts are retained on rollback: losing idempotency history can repeat a previously applied action.
protected override void Down(MigrationBuilder m) { }
}
@@ -227,6 +227,13 @@ namespace GroupService.Infrastructure.Migrations
b.HasKey("GroupId", "UserId");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("GroupId", "State", "CreationTime");
b.HasIndex("UserId", "CreationTime");
b.ToTable("group_join_requests", (string)null);
});
@@ -266,6 +273,10 @@ namespace GroupService.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("GroupId", "IsDeleted", "Role");
b.HasIndex("UserId", "IsDeleted", "GroupId");
b.ToTable("group_members", (string)null);
});
#pragma warning restore 612, 618
@@ -17,9 +17,21 @@ namespace GroupService.Infrastructure.Reposities
{
db.GroupInvitations.Add(invitation);
}
public void CreateRange(IEnumerable<GroupInvitation> invitations)
{
db.GroupInvitations.AddRange(invitations);
}
public async Task<GroupInvitation?> FindByIdAsync(Guid id)
{
return await db.GroupInvitations.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<GroupInvitation?> FindByGroupIdAndUserIdAsync(Guid groupId, Guid userId)
{
return await db.GroupInvitations.FirstOrDefaultAsync(
x => x.GroupId == groupId && x.UserId == userId);
}
}
}
@@ -29,5 +29,26 @@ namespace GroupService.Infrastructure.Reposities
{
return await db.GroupJoinRequests.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<IEnumerable<GroupJoinRequest>> FindByUserIdAsync(Guid userId)
{
return await db.GroupJoinRequests.Where(x =>
x.UserId == userId || x.OperatorId == userId
).ToListAsync();
}
public async Task<IEnumerable<GroupJoinRequest>> FindVisibleToUserAsync(Guid userId)
{
var managedGroupIds = db.GroupMembers
.Where(member => member.UserId == userId &&
(member.Role == Domain.Enums.GroupMemberRole.Administrator ||
member.Role == Domain.Enums.GroupMemberRole.Master))
.Select(member => member.GroupId);
return await db.GroupJoinRequests
.Where(request => request.UserId == userId || managedGroupIds.Contains(request.GroupId))
.OrderByDescending(request => request.CreationTime)
.ToListAsync();
}
}
}
@@ -46,5 +46,19 @@ namespace GroupService.Infrastructure.Reposities
{
return await db.GroupMembers.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<bool> CheckMemberAdminAsync(Guid groupId, Guid userId)
{
return await db.GroupMembers.AnyAsync(
x => x.GroupId == groupId && x.UserId == userId
&& (x.Role == Domain.Enums.GroupMemberRole.Administrator
|| x.Role == Domain.Enums.GroupMemberRole.Master)
);
}
public void CreateRange(IEnumerable<GroupMember> members)
{
db.GroupMembers.AddRange(members);
}
}
}
@@ -29,6 +29,18 @@ namespace GroupService.Infrastructure.Reposities
return await db.Groups.Where(x => x.GroupMaster == userId).ToListAsync();
}
public async Task<IEnumerable<Group>> FindByMemberIdAsync(Guid userId)
{
var groupIds = db.GroupMembers
.Where(member => member.UserId == userId)
.Select(member => member.GroupId);
return await db.Groups
.Where(group => groupIds.Contains(group.Id))
.OrderByDescending(group => group.ModificationTime ?? group.CreationTime)
.ToListAsync();
}
public async Task<IEnumerable<Group>> FindByNameAsync(string name)
{
return await db.Groups.Where(x => x.Name == name).ToListAsync();
@@ -1,5 +1,5 @@
using GroupService.Domain.Events;
using GroupService.WebApi.Application.GroupRequest;
using GroupService.WebApi.Application.GroupMember;
using IM.Commons;
using IM.Commons.IntegrationEvents;
using MassTransit;
@@ -12,20 +12,19 @@ namespace GroupService.WebApi.Application.EventHandler
, INotificationHandler<GroupInvitationCreateDomainEvent>
{
private readonly IPublishEndpoint endpoint;
private readonly GroupRequestService requestService;
private readonly GroupMemberService memberService;
public GroupInvitationEventHandler(IPublishEndpoint endpoint, GroupRequestService requestService)
public GroupInvitationEventHandler(IPublishEndpoint endpoint, GroupMemberService memberService)
{
this.endpoint = endpoint;
this.requestService = requestService;
this.memberService = memberService;
}
public async Task Handle(GroupInvitationAcceptDomainEvent notification, CancellationToken cancellationToken)
{
var invitation = notification.Invitation;
var res = await requestService.CreateAsync(invitation.GroupId, invitation.UserId,
$"邀请入群"
);
// 邀请接受 → 直接加入群聊,不走入群申请审批流程
var res = await memberService.CreateAsync(invitation.GroupId, invitation.UserId);
await endpoint.Publish(new GroupInvitationAcceptEvent(invitation.Id, invitation.UserId, invitation.UserProfile.NickName, invitation.UserProfile.Avatar
, invitation.GroupId, invitation.GroupProfile.GroupName, invitation.GroupProfile.Avatar, invitation.OperatorId
@@ -34,7 +33,6 @@ namespace GroupService.WebApi.Application.EventHandler
if (!res.Succeeded)
{
throw new EventHandlerException(res.Message);
}
}
@@ -1,4 +1,5 @@
using GroupService.Domain.Events;
using GroupService.Infrastructure;
using IM.Commons.IntegrationEvents;
using MassTransit;
using MediatR;
@@ -8,18 +9,25 @@ namespace GroupService.WebApi.Application.EventHandler
public class GroupMemberJoinedHandler : INotificationHandler<GroupMemberJoinedDomainEvent>
{
private readonly IPublishEndpoint endpoint;
private readonly GroupDbContext db;
public GroupMemberJoinedHandler(IPublishEndpoint endpoint)
public GroupMemberJoinedHandler(IPublishEndpoint endpoint, GroupDbContext db)
{
this.endpoint = endpoint;
this.db = db;
}
public async Task Handle(GroupMemberJoinedDomainEvent notification, CancellationToken cancellationToken)
{
var member = notification.Member;
// 用 FindAsync 而非 FirstOrDefaultAsync:群可能刚创建尚未提交到数据库,FindAsync 先查内存跟踪器
var group = await db.Set<Domain.Entities.Group>().FindAsync(member.GroupId);
var groupName = group?.Name ?? "群聊";
var groupAvatar = group?.Avatar;
await endpoint.Publish(new GroupMemberJoinedEvent(member.Id,
member.UserId, member.GroupId, member.GroupNickName, member.Avatar,
member.Role.ToString()), cancellationToken);
member.Role.ToString(), groupName, groupAvatar), cancellationToken);
}
}
}
@@ -0,0 +1,18 @@
using GroupService.Domain.Events;
using IM.Commons.IntegrationEvents;
using MassTransit;
using MediatR;
namespace GroupService.WebApi.Application.EventHandler
{
public class GroupMemberLeftHandler(IPublishEndpoint endpoint)
: INotificationHandler<GroupMemberLeftDomainEvent>
{
public Task Handle(GroupMemberLeftDomainEvent notification, CancellationToken cancellationToken)
{
return endpoint.Publish(
new GroupMemberLeftEvent(notification.Member.UserId, notification.Member.GroupId),
cancellationToken);
}
}
}
@@ -1,35 +1,42 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos;
using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace GroupService.WebApi.Application.Group
{
public class GroupService
{
private readonly IGroupReposity reposity;
private readonly IMapper mapper;
private readonly IGroupMemberReposity memberReposity;
private readonly IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly global::GroupService.Infrastructure.GroupDbContext db;
public GroupService(IGroupReposity reposity, IMapper mapper)
public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime, global::GroupService.Infrastructure.GroupDbContext db)
{
this.reposity = reposity;
this.mapper = mapper;
this.memberReposity = memberReposity;
this.mapper = mapper; this.runtime = runtime; this.db = db;
}
public async Task<Result<GroupResponse>> CreateAsync(GroupCreateCommand command)
{
var policy = runtime.Current;
if (policy.CreatedGroupLimit > 0 && await db.Groups.CountAsync(x => x.GroupMaster == command.GroupMasterId) >= policy.CreatedGroupLimit)
return Result.Fail<GroupResponse>(ResultCode.PERMISSION_DENIED, "创建群组数量已达到平台上限");
var group = new Domain.Entities.Group(command.GroupMasterId, command.Name);
group.Update(null, (Domain.Enums.GroupAuthorityType)policy.DefaultJoinAuthority, null, null);
reposity.Create(group);
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
public async Task<Result<List<GroupResponse>>> GetAllAsync(Guid userId)
{
var groups = await reposity.FindByMasterIdAsync(userId);
var groups = await reposity.FindByMemberIdAsync(userId);
return Result<List<GroupResponse>>.Success(mapper.Map<List<GroupResponse>>(groups));
}
public async Task<Result<GroupResponse>> GetByIdAsync(Guid groupId)
public async Task<Result<GroupResponse>> GetByIdAsync(Guid groupId, Guid userId)
{
var group = await reposity.FindByIdAsync(groupId);
if (group is null)
@@ -37,7 +44,48 @@ namespace GroupService.WebApi.Application.Group
return Result<GroupResponse>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (!await memberReposity.CheckMemberExistAsync(groupId, userId))
{
return Result<GroupResponse>.Fail(ResultCode.PERMISSION_DENIED);
}
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
public async Task<Result<object>> DissolveAsync(Guid groupId, Guid userId)
{
var group = await reposity.FindByIdAsync(groupId);
if (group is null)
{
return Result.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (group.GroupMaster != userId)
{
return Result.Fail(ResultCode.PERMISSION_DENIED);
}
var members = await memberReposity.FindByGroupIdAsync(groupId);
foreach (var member in members)
{
member.Leave();
}
group.SoftDelete();
return Result.Success();
}
public async Task<Result<GroupResponse>> UpdateAsync(GroupUpdateCommand command)
{
var group = await reposity.FindByIdAsync(command.GroupId);
if(group is null)
return Result.Fail<GroupResponse>(ResultCode.GROUP_NOT_FOUND);
var isAdmin = await memberReposity.CheckMemberAdminAsync(group.Id ,command.UserId);
if (!isAdmin)
return Result.Fail<GroupResponse>(ResultCode.ADMIN_PERMISSION_DENIED);
group.Update(command.GroupName, null, command.Description, command.Avatar);
return Result.Success(mapper.Map<GroupResponse>(group));
}
}
}
@@ -0,0 +1,4 @@
namespace GroupService.WebApi.Application.Group
{
public record GroupUpdateCommand(Guid UserId, Guid GroupId, string? Avatar, string? GroupName, string? Description);
}
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.IReposities;
using GroupService.Domain.ValueObjects;
using GroupService.WebApi.Application.Dtos;
@@ -39,7 +39,14 @@ namespace GroupService.WebApi.Application.GroupInvitation
return Result.Fail<GroupInvitationResponse>(ResultCode.PERMISSION_DENIED);
}
var group = await groupReposity.FindByIdAsync(groupId);
// 检查是否已存在对该用户的未处理邀请,避免 Duplicate entry 报错
var existing = await reposity.FindByGroupIdAndUserIdAsync(groupId, userId);
if (existing != null)
{
return Result.Success(mapper.Map<GroupInvitationResponse>(existing));
}
var group = await groupReposity.FindByIdAsync(groupId); if (group?.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组不可用或已被封禁");
var userProfile = new UserProfile()
{
@@ -66,6 +73,54 @@ namespace GroupService.WebApi.Application.GroupInvitation
return Result.Success(mapper.Map<GroupInvitationResponse>(invitation));
}
public async Task<Result<object>> CreateBatchAsync(Guid operatorId, List<Guid> userIds, Guid groupId)
{
var group = await groupReposity.FindByIdAsync(groupId); if (group?.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组不可用或已被封禁");
if (group is null)
return Result.Fail(ResultCode.GROUP_NOT_FOUND);
// 校验操作者是否有权限(群成员/管理员/群主)
var operatorMember = await memberReposity.FindOneByGroupIdAndUserIdAsync(groupId, operatorId);
if (operatorMember == null)
return Result.Fail(ResultCode.PERMISSION_DENIED);
var operatorInfo = await idService.FindUserByIdAsync(operatorId);
var operatorProfile = new UserProfile()
{
Avatar = operatorInfo.Data?.Avatar,
NickName = operatorInfo.Data?.NickName ?? string.Empty
};
var groupProfile = new GroupProfile()
{
Avatar = group.Avatar,
GroupName = group.Name
};
// 批量创建邀请,跳过已存在未处理邀请的用户
foreach (var userId in userIds)
{
var existing = await reposity.FindByGroupIdAndUserIdAsync(groupId, userId);
if (existing != null)
continue;
var userRes = await idService.FindUserByIdAsync(userId);
if (!userRes.Succeeded)
continue;
var userProfile = new UserProfile()
{
Avatar = userRes.Data?.Avatar,
NickName = userRes.Data?.NickName ?? string.Empty
};
var invitation = new Domain.Entities.GroupInvitation(
groupId, groupProfile, userId, userProfile, operatorId, operatorProfile);
reposity.Create(invitation);
}
return Result.Success();
}
public async Task<Result<object>> HandleAsync(GroupInvitationHandleCommand command)
{
var invitation = await reposity.FindByIdAsync(command.InvitationId);
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain;
using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos;
@@ -13,24 +13,28 @@ namespace GroupService.WebApi.Application.GroupMember
private readonly IGroupReposity groupReposity;
private readonly GroupMemberDomainService service;
private readonly IIdentityIntegrationService idService;
private IMapper mapper;
private IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper)
public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime)
{
this.reposity = reposity;
this.groupReposity = groupReposity;
this.service = service;
this.idService = idService;
this.mapper = mapper;
this.mapper = mapper; this.runtime = runtime;
}
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId)
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId, Guid userId)
{
var group = await groupReposity.FindByIdAsync(groupId);
if (group is null)
{
return Result<List<GroupMemberResponse>>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (!await reposity.CheckMemberExistAsync(groupId, userId))
{
return Result<List<GroupMemberResponse>>.Fail(ResultCode.PERMISSION_DENIED);
}
var members = await reposity.FindByGroupIdAsync(groupId);
return Result<List<GroupMemberResponse>>.Success(mapper.Map<List<GroupMemberResponse>>(members.ToList()));
@@ -45,6 +49,9 @@ namespace GroupService.WebApi.Application.GroupMember
return Result<GroupMemberResponse>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (group.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组已被封禁,不能加入");
var limit = runtime.Current.GroupMemberLimit;
if (limit > 0 && (await reposity.FindByGroupIdAsync(groupId)).Count() >= limit) throw new IM.DomainCommons.DomainException("群成员数量已达到平台上限");
var userRes = await idService.FindUserByIdAsync(userId);
if (!userRes.Succeeded)
{
@@ -64,7 +71,7 @@ namespace GroupService.WebApi.Application.GroupMember
public async Task<Result<bool>> CheckMemberAsync(Guid groupId, Guid userId)
{
var exist = await reposity.CheckMemberExistAsync(groupId, userId);
var group = await groupReposity.FindByIdAsync(groupId); var exist = group?.Status == Domain.Enums.GroupState.Normal && await reposity.CheckMemberExistAsync(groupId, userId);
return Result.Success(exist);
}
@@ -78,14 +85,32 @@ namespace GroupService.WebApi.Application.GroupMember
}
var operatorMember = await reposity.FindOneByGroupIdAndUserIdAsync(member.GroupId, operatorId);
if (operatorMember is null || operatorMember.Role == Domain.Enums.GroupMemberRole.Normal)
if (operatorMember is null || operatorMember.Id == member.Id ||
member.Role == Domain.Enums.GroupMemberRole.Master ||
operatorMember.Role <= member.Role)
{
return Result.Fail(ResultCode.PERMISSION_DENIED);
}
member.SoftDelete();
member.Leave();
return Result.Success();
}
public async Task<Result<object>> LeaveAsync(Guid groupId, Guid userId)
{
var member = await reposity.FindOneByGroupIdAndUserIdAsync(groupId, userId);
if (member is null)
{
return Result.Fail(ResultCode.GROUP_MEMBER_NOT_FOUNT);
}
if (member.Role == Domain.Enums.GroupMemberRole.Master)
{
return Result.Fail<object>(ResultCode.PERMISSION_DENIED, "群主不能直接退群,请使用解散群接口");
}
member.Leave();
return Result.Success();
}
}
}
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.Entities;
using GroupService.Domain.IReposities;
using GroupService.Domain.ValueObjects;
@@ -33,6 +33,8 @@ namespace GroupService.WebApi.Application.GroupRequest
return Result.Fail<GroupRequestResponse>(ResultCode.GROUP_NOT_FOUND);
}
if (group.Status != Domain.Enums.GroupState.Normal || group.Authority == Domain.Enums.GroupAuthorityType.NOT_ALLOWED_TO_JOIN)
return Result.Fail<GroupRequestResponse>(ResultCode.PERMISSION_DENIED, "群组当前不允许加入");
var user = await idService.FindUserByIdAsync(userId);
var groupProfile = new GroupProfile()
@@ -96,6 +98,11 @@ namespace GroupService.WebApi.Application.GroupRequest
return Result.Success(mapper.Map<GroupRequestResponse>(request));
}
public async Task<Result<List<GroupRequestResponse>>> GetListAsync(Guid userId)
{
var list = await reposity.FindVisibleToUserAsync(userId);
return Result.Success(mapper.Map<List<GroupRequestResponse>>(list.ToList()));
}
}
}

Some files were not shown because too many files have changed in this diff Show More