Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ab2dc8a3 | ||
|
|
53e6195938 | ||
|
|
898cf5dba0 | ||
|
|
32177a7293 | ||
|
|
d07051943e | ||
|
|
dc7a9a18c7 |
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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` (bool,true 落公开桶返回直链)
|
||||
- 秒传:服务端计算文件 MD5 后,若已存在相同 checksum 的文件则直接返回已有记录,**跳过存储写入**
|
||||
- 响应:`Result<FileResponse>`
|
||||
|
||||
请求示例(form-data):
|
||||
```
|
||||
file: (二进制文件)
|
||||
isPublic: true
|
||||
```
|
||||
|
||||
秒传命中响应(与正常上传返回一致,但实际未写入存储):
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
|
||||
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
|
||||
"fileName": "avatar.png",
|
||||
"fileSize": 20480,
|
||||
"contentType": "image/png",
|
||||
"state": "Uploaded",
|
||||
"storageLocation": {
|
||||
"storageProvider": "local",
|
||||
"bucket": "im-public",
|
||||
"objectKey": "2026/06/26/abc123.png",
|
||||
"region": "local"
|
||||
},
|
||||
"checkSum": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"created": "2026-06-26T09:00:00+00:00",
|
||||
"updated": "2026-06-26T09:00:00+00:00",
|
||||
"url": "https://cdn.example.com/public/avatar.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
正常上传响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
|
||||
"ownerId": "8f3a2c10-1b2c-4d5e-9a8b-7c6d5e4f3a2b",
|
||||
"fileName": "avatar.png",
|
||||
"fileSize": 20480,
|
||||
"contentType": "image/png",
|
||||
"state": "Uploaded",
|
||||
"storageLocation": {
|
||||
"storageProvider": "local",
|
||||
"bucket": "im-public",
|
||||
"objectKey": "2026/06/26/abc123.png",
|
||||
"region": "local"
|
||||
},
|
||||
"checkSum": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"created": "2026-06-26T09:00:00+00:00",
|
||||
"updated": "2026-06-26T09:00:00+00:00",
|
||||
"url": "https://cdn.example.com/public/avatar.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GET /api/File/{id}** — 获取文件信息(含直链 Url,私有文件 Url 为 null)
|
||||
响应:`Result<FileResponse>`(结构同上;私有文件 `url` 为 `null`)
|
||||
|
||||
**GET /api/File/{id}/content** — 鉴权下载文件内容(私有文件预览/下载)
|
||||
响应:文件流 (`File` 结果,二进制;非 `Result<T>` 包装)
|
||||
|
||||
### 分片上传任务 (FileTask) — 大文件分片
|
||||
|
||||
> 完整流程:Init → GetUploadUrl → UploadPart → Complete(四步)
|
||||
> 支持秒传、分片大小校验、上传进度查询。
|
||||
|
||||
**POST /api/FileTask/init** — 初始化上传任务(支持秒传)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| conversationId | guid | 会话 ID |
|
||||
| fileName | string | 文件名 |
|
||||
| fileSize | long | 文件大小(≤ 1GB,由 `MaxObjectSizeBytes` 控制) |
|
||||
| contentType | string | MIME 类型 |
|
||||
| checkSum | string | 校验和(必须,用于秒传去重) |
|
||||
|
||||
请求示例:
|
||||
```json
|
||||
{
|
||||
"conversationId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
|
||||
"fileName": "movie.mp4",
|
||||
"fileSize": 104857600,
|
||||
"contentType": "video/mp4",
|
||||
"checkSum": "9e107d9d372bb6826bd81d3542a419d6"
|
||||
}
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"taskId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
|
||||
"uploadSessionId": "sess_abc123",
|
||||
"storageLocation": {
|
||||
"storageProvider": "local",
|
||||
"bucket": "im-local",
|
||||
"objectKey": "data/im-files/2026/06/27/movie.mp4",
|
||||
"region": "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
秒传命中(checksum 匹配已完成的文件,跳过初始化):
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"taskId": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
|
||||
"uploadSessionId": "a0b1c2d3-e4f5-6a7b-8c9d-0e1f2a3b4c5d",
|
||||
"storageLocation": {
|
||||
"storageProvider": "local",
|
||||
"bucket": "im-local",
|
||||
"objectKey": "2026/06/26/existing.png",
|
||||
"region": "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
此时 `taskId` 即为已存在文件的 ID,前端可直接跳到 Complete。
|
||||
|
||||
文件过大失败:
|
||||
```json
|
||||
{ "code": 2402, "message": "文件大小超限", "data": null }
|
||||
```
|
||||
|
||||
**GET /api/FileTask/Getuploadurl** — 获取分片上传地址
|
||||
参数:`sessionId`, `partNum` (query)
|
||||
- `partNum` 必须满足 `1 ≤ partNum ≤ totalPartCount`,越界返回 `3206`(无效分片号)
|
||||
|
||||
请求示例:
|
||||
```
|
||||
GET /api/FileTask/Getuploadurl?sessionId=sess_abc123&partNum=1
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功",
|
||||
"data": "https://oss.example.com/upload?sessionId=sess_abc123&partNum=1&signature=xxx"
|
||||
}
|
||||
```
|
||||
|
||||
**GET /api/FileTask/progress** — **查询上传进度(新增)**
|
||||
参数:`sessionId` (query)
|
||||
|
||||
请求示例:
|
||||
```
|
||||
GET /api/FileTask/progress?sessionId=sess_abc123
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"sessionId": "sess_abc123",
|
||||
"taskId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
|
||||
"fileSize": 104857600,
|
||||
"totalPartCount": 20,
|
||||
"completedPartCount": 7,
|
||||
"uploadedBytes": 36700160,
|
||||
"progressPercent": 35
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**POST /api/FileTask/complete** — 完成上传(合并分片)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| sessionId | string | 会话 ID |
|
||||
| parts | UploadPart[] | 分片列表(**必须与 `totalPartCount` 数量一致**) |
|
||||
|
||||
分片数量不匹配返回 `3204`(分片数量不匹配)。
|
||||
|
||||
请求示例:
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"parts": [
|
||||
{ "partNumber": 1, "eTag": "etag-part-1", "size": 5242880 },
|
||||
{ "partNumber": 2, "eTag": "etag-part-2", "size": 5242880 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
响应示例:`Result<FileResponse>`(结构同 simple-upload)
|
||||
|
||||
**POST /api/FileTask/local/parts/upload** — 本地分片上传(支持分片大小校验)
|
||||
表单字段:`sessionId`, `partNumber`, `file`
|
||||
- 非末片大小 ≥ `MinPartSizeBytes`(默认 5MB),否则返回 `3203`(分片过小)
|
||||
- 末片豁免最小值校验
|
||||
|
||||
请求示例(form-data):
|
||||
```
|
||||
sessionId: sess_abc123
|
||||
partNumber: 1
|
||||
file: (二进制分片)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:业务状态码表
|
||||
|
||||
| 范围 | 分类 | 示例 |
|
||||
|------|------|------|
|
||||
| 0 | 成功 | 0 成功 |
|
||||
| 1000-1999 | 系统级 | 1003 参数错误, 1005 权限不足, 1006 认证失败 |
|
||||
| 2000-2099 | 用户 | 2000 用户不存在, 2001 用户已存在, 2002 密码错误 |
|
||||
| 2100-2199 | 好友 | 2100 好友申请已存在, 2102 已经是好友 |
|
||||
| 2200-2299 | 群聊 | 2200 群不存在, 2201 已在群中, 2202 群成员已满 |
|
||||
| 2300-2399 | 消息 | 2300 发送失败, 2301 消息不存在, 2302 撤回失败 |
|
||||
| 2400-2499 | 文件 | 2400 上传失败, 2401 文件不存在, 2402 大小超限 |
|
||||
| 3000-3099 | 管理后台 | 3000 管理员不存在, 3003 权限不足 |
|
||||
| 3100-3199 | 会话 | 3100 会话不存在 |
|
||||
| 3200-3299 | 分片 | 3201 分片不存在, 3202 分片合并失败, **3203 分片过小**, **3204 分片数不匹配**, **3205 会话过期**, **3206 分片号无效** |
|
||||
|
||||
## 会话一致性说明
|
||||
|
||||
- `GET /api/Conversation/List` 的请求和响应结构不变,结果按 `ModificationTime ?? CreationTime` 倒序返回。
|
||||
- 活动会话由 `(UserId, ChatType, TargetId)` 唯一确定;重复的好友或入群事件按幂等成功处理。
|
||||
- `20260911000100_ConversationUniqueness` 会保留最新活动记录、合并最大未读数和最大已读序号,并软删除其余重复记录。
|
||||
# 消息历史搜索
|
||||
|
||||
`GET /api/Message/Search` 在当前用户拥有的会话中搜索历史文本消息。
|
||||
|
||||
- 参数:`conversationId`、`keyword`(去除首尾空格后 1–50 字符)、可选独占游标 `cursor`、`limit`(1–50,默认 30)。
|
||||
- 只返回未撤回、未删除的文本消息,按 `sequenceId` 倒序排列。
|
||||
- 响应继续使用统一 `Result`,数据结构为 `{ messages, hasmore }`。下一页以本页最后一条消息的 `sequenceId` 作为独占游标。
|
||||
- `POST /api/Conversation/MarkRead` 新增可选 `lastReadSequenceId` 查询参数,旧客户端不传时仍兼容。
|
||||
- 会话列表的 `dateTime` 表示最后消息活动时间;标记已读不会改变此时间或会话排序。
|
||||
@@ -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>
|
||||
@@ -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)};
|
||||
}
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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, "请填写 1–500 字的操作原因"); }
|
||||
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); }
|
||||
}
|
||||
@@ -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, "管理员密码长度应为 12–128 位"); }
|
||||
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, "邮箱格式不正确");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("管理员凭据已更新;请清除临时密码环境变量。");
|
||||
}
|
||||
}
|
||||
@@ -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("RabbitMQ(TCP)", 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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-1
@@ -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) };
|
||||
}
|
||||
@@ -1,27 +1,13 @@
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY IM_API_NEW.sln ./
|
||||
|
||||
COPY ContactService.WebApi/ContactService.WebApi.csproj ContactService.WebApi/
|
||||
COPY ContactService.Domain/ContactService.Domain.csproj ContactService.Domain/
|
||||
COPY ContactService.Infrastructure/ContactService.Infrastructure.csproj ContactService.Infrastructure/
|
||||
COPY DomainCommons/IM.DomainCommons.csproj DomainCommons/
|
||||
COPY Infrastructure/IM.Infrastructure.csproj Infrastructure/
|
||||
COPY IM.ASPNETCore/IM.ASPNETCore.csproj IM.ASPNETCore/
|
||||
COPY IM.Commons/IM.Commons.csproj IM.Commons/
|
||||
COPY IM.InitCommon/IM.InitCommon.csproj IM.InitCommon/
|
||||
COPY IM.Jwt/IM.Jwt.csproj IM.Jwt/
|
||||
COPY IM.Protocols/IM.Protocols.csproj IM.Protocols/
|
||||
|
||||
RUN dotnet restore ContactService.WebApi/ContactService.WebApi.csproj
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN find . -type d \( -name obj -o -name bin \) -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
RUN dotnet publish ContactService.WebApi/ContactService.WebApi.csproj \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore
|
||||
-o /app/publish
|
||||
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,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);
|
||||
@@ -67,23 +115,32 @@ namespace FileService.Application.UploadFileTask
|
||||
public async Task<Result<PresignedUrl>> GenerateUrlAsync(string sessionId, int partNum, Guid userId, CancellationToken token = default)
|
||||
{
|
||||
var taskCache = await redis.GetAsync(sessionId);
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
|
||||
if(taskCache.TotalPartCount < partNum)
|
||||
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);
|
||||
@@ -93,28 +150,66 @@ namespace FileService.Application.UploadFileTask
|
||||
{
|
||||
var taskCache = await redis.GetAsync(command.UploadSessionId);
|
||||
|
||||
if(taskCache is null)
|
||||
if (taskCache is null)
|
||||
{
|
||||
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
if(taskCache.Parts.Count < taskCache.TotalPartCount)
|
||||
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)
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"InternalApiKey": "",
|
||||
"InternalServices": {
|
||||
"GroupServiceBaseUrl": "http://im-group-service:8080/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user