Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
16302be
chore: 初始化论坛功能增强分支
Chaotze Aug 19, 2026
32cd71b
feat(forum): API 定义 - 添加图片上传接口
Chaotze Aug 19, 2026
8e590d6
chore(api): 从 openapi.yaml 自动生成 API 代码
Chaotze Aug 19, 2026
f30ce88
feat(forum): 后端实现 - 图片上传到 OSS
Chaotze Aug 19, 2026
e871a89
feat(forum): 前端实现 - Markdown 编辑器和渲染器
Chaotze Aug 19, 2026
2325c69
feat(forum): 无限嵌套回复
Chaotze Aug 19, 2026
9696c8f
fix(forum): 修复编辑框宽度和错误提示
Chaotze Aug 19, 2026
302cf6d
fix(frontend): 更新 package.json 及相应的 lock 文件
Chaotze Aug 19, 2026
2488b10
fix(forum): 修复 moderate 事件参数传递错误
Chaotze Aug 27, 2026
e50749d
fix(forum): 修复 moderate 事件参数传递错误
Chaotze Aug 27, 2026
b1dfe44
fix(forum): 修复回复嵌套层级错误导致无法形成无限嵌套树
Chaotze Aug 27, 2026
7553204
fix(forum): 修复 Markdown 编辑器图片插入定位错误
Chaotze Aug 27, 2026
d8e45d7
style(forum): 修正 ReplyItem.vue 缩进格式
Chaotze Aug 27, 2026
f84acf8
feat(forum): 支持任意深度嵌套回复及级联删除
Chaotze Aug 27, 2026
ed86a45
feat(forum): 支持任意深度回复树
Chaotze Aug 27, 2026
c066e4f
fix(forum): 区分图片上传失败类型并规范错误响应码
Chaotze Aug 27, 2026
3d91484
feat(forum): 改进上传错误处理和支持任意深度回复树
Chaotze Aug 27, 2026
36396af
fix(api): 统一测试和前端中的 API 路径为 /api/v1 版本
Chaotze Aug 27, 2026
3afd47a
chore(api): 从 openapi.yaml 自动生成 API 代码
Chaotze Aug 27, 2026
06e5aaf
perf(forum): 优化删除时获取帖子后代的 N+1 查询问题
Chaotze Aug 27, 2026
19532c1
fix(forum): 图片插入前校验内容长度避免超长正文被 API 拒绝
Chaotze Aug 27, 2026
68916b6
style(forum): 修正 ForumPostsController 代码缩进层级
Chaotze Aug 28, 2026
9b2f215
feat(forum): 改进上传错误处理和支持任意深度回复树
Chaotze Aug 28, 2026
c72b1df
fix(forum): 修复 OSS 图片 URL 构建时协议前缀处理错误
Chaotze Aug 28, 2026
3b144cf
test(membership): 补充账户状态与查询契约的边界测试
Chaotze Aug 28, 2026
0ff4978
style(membership): 移除 ProjectMembershipServiceTests 中未使用的 using 语句
Chaotze Aug 28, 2026
cf28b6b
fix(membership): 修正测试中 ProjectMember 实体属性名错误
Chaotze Aug 28, 2026
4552a49
refactor(forum): 使用生成的 API 模型解析响应并移除 any 类型
Chaotze Aug 28, 2026
067b362
refactor(forum): 修正 MarkdownEditor 中图片上传响应解析的类型转换
Chaotze Aug 28, 2026
32aefd4
style(forum): 修正 MarkdownEditor 中函数调用括号与类型断言缩进
Chaotze Aug 28, 2026
1b1879e
test(membership): 移除 AccountStatus 回退值以覆盖空状态分支
Chaotze Aug 28, 2026
cd7837f
feat(forum): 为图片上传接口增加专用超时时间
Chaotze Aug 28, 2026
47f9524
test(membership): 移除 AccountStatus 为 null 的测试用例因数据库约束不允许空值
Chaotze Aug 28, 2026
a42f097
refactor(forum): 恢复论坛列表分页契约实现
Chaotze Aug 29, 2026
00890b1
fix(forum): 修复话题列表查询中 IsPublished 方法调用错误
Chaotze Aug 29, 2026
c1cbbb3
fix(forum): 修复论坛列表查询中 IsPublished 方法调用错误
Chaotze Aug 29, 2026
658982c
refactor(forum): 改用手动分页方式避免 EF Core LINQ 转换问题
Chaotze Aug 29, 2026
cf07325
fix(forum): 修复论坛列表查询中的空引用异常
Chaotze Aug 29, 2026
2d1da85
fix(forum): 修复论坛列表查询中的空引用异常
Chaotze Aug 29, 2026
1a73bd1
refactor(forum): 恢复论坛列表简单实现
Chaotze Aug 29, 2026
89b0bc8
feat(forum): 添加图片上传请求体大小限制
Chaotze Aug 29, 2026
d57cce9
style(forum): 优化讨论区界面文案并替换 textarea 为 Element Plus Input 组件
Chaotze Sep 1, 2026
a3ea98d
style(forum): 修正 ForumCenter 中标题输入框的占位符换行格式
Chaotze Sep 1, 2026
146dbe0
refactor(forum): 使用 Element Plus InputInstance 类型优化 textarea 引用
Chaotze Sep 2, 2026
67246f7
fix(forum): 修正嵌套回复的权限绑定使用正确的俱乐部权限检查
Chaotze Sep 2, 2026
27f5c40
feat(api): 新增论坛图片删除接口并在上传响应中返回存储键
Chaotze Sep 2, 2026
e0026a8
chore(api): 从 openapi.yaml 自动生成 API 代码
Chaotze Sep 2, 2026
b09c2c8
fix(forum): 清理因内容超长而未插入的已上传图片
Chaotze Sep 2, 2026
84d631a
test(forum): 更新图片删除测试以适配无 OSS 配置的测试环境
Chaotze Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 147 additions & 2 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1571,7 +1571,7 @@ paths:

delete:
summary: 删除社团讨论区话题或回复
description: 删除话题(包括其所有回复)或删除回复。话题发布者或讨论区管理员可执行删除操作。删除操作会记录至审计日志。
description: 删除话题或回复。删除话题时级联删除其所有后代回复(包括嵌套回复)。删除回复时仅删除该回复及其所有子后代。话题发布者或讨论区管理员可执行删除操作。所有删除操作均记录至审计日志。
operationId: deleteClubForumPost
security:
- bearerAuth: []
Expand All @@ -1590,7 +1590,7 @@ paths:
minimum: 1
responses:
"204":
description: 删除成功
description: 删除成功(包括所有后代)
"400":
description: 请求参数不合法
content:
Expand All @@ -1616,6 +1616,128 @@ paths:
schema:
$ref: "#/components/schemas/ApiError"

/api/v1/clubs/{clubId}/forum-posts/upload-image:
post:
summary: 上传论坛图片到 OSS
operationId: uploadForumImage
security:
- bearerAuth: []
parameters:
- name: clubId
in: path
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
image:
type: string
format: binary
description: 图片文件,支持 jpg、png、gif、webp,文件大小 ≤ 5MB
required: [image]
responses:
"200":
description: 图片上传成功
content:
application/json:
schema:
$ref: "#/components/schemas/ForumImageUploadResponse"
"400":
description: 图片文件不合法(类型或大小超限)
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"401":
description: 登录状态缺失或无效
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"403":
description: 当前用户不是该社团的有效成员
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"404":
description: 社团不存在
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"413":
description: 文件过大,超出大小限制
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"500":
description: OSS 上传失败
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"

/api/v1/clubs/{clubId}/forum-posts/delete-image:
delete:
summary: 删除已上传的论坛图片
operationId: deleteForumImage
security:
- bearerAuth: []
parameters:
- name: clubId
in: path
required: true
schema:
type: integer
minimum: 1
- name: storageKey
in: query
required: true
schema:
type: string
description: 存储对象键,来自上传响应
responses:
"200":
description: 图片删除成功
"400":
description: storageKey 参数不能为空
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"401":
description: 登录状态缺失或无效
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"403":
description: 当前用户不是该社团的有效成员
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"404":
description: 社团不存在
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"500":
description: OSS 删除失败
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"

/api/v1/clubs/{clubId}/award-rule-documents:
get:
summary: 查询评奖评优评定细则
Expand Down Expand Up @@ -7149,6 +7271,29 @@ components:
example: published
required: [isTop, postStatus]

ForumImageUploadResponse:
type: object
description: 论坛图片上传响应
properties:
imageUrl:
type: string
description: 上传后的图片 OSS URL,可直接在 Markdown 中使用。
example: https://clubhub-oss.aliyuncs.com/forum/2026/08/19/abc123.png
fileName:
type: string
description: 图片文件名。
example: abc123.png
storageKey:
type: string
description: 存储对象键,用于后续删除操作。
example: clubs/1/forum/2026/08/19/abc123def456.png
uploadedAt:
type: string
format: date-time
description: 上传时间。
example: 2026-08-19T10:30:00Z
required: [imageUrl, fileName, storageKey, uploadedAt]

NoticeReadResult:
type: object
description: 通知已读记录结果;接口成功响应表示该用户已经完成已读标记。
Expand Down
91 changes: 89 additions & 2 deletions backend.Tests/ForumPostsAuthorizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ public async Task CreateTopic_NonMember_IsForbidden()
}

[Fact]
public async Task CreateReply_ToReply_IsRejected()
public async Task CreateReply_ToReply_IsAllowed()
{
var (client, clubId) = await SeedAsync(member: true, moderate: false);
var topic = await PostAndReadId(client, clubId, "{\"title\":\"topic\",\"content\":\"body\"}");
var reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{topic},\"content\":\"reply\"}}");
using var response = await client.PostAsync($"/api/v1/clubs/{clubId}/forum-posts", Json($"{{\"parentPostId\":{reply},\"content\":\"nested\"}}"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}

[Fact]
Expand Down Expand Up @@ -143,6 +143,44 @@ public async Task DeleteTopic_DeletesRepliesAlso()
Assert.Equal(0, afterDocument.RootElement.GetArrayLength());
}

[Fact]
public async Task NestedReplies_UpToThreeLevels_CreateQueryAndCascadeDelete()
{
var (client, clubId) = await SeedAsync(member: true, moderate: false);

// Create topic -> reply level 1 -> reply level 2 -> reply level 3
var topic = await PostAndReadId(client, clubId, "{\"title\":\"topic\",\"content\":\"body\"}");
var level1Reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{topic},\"content\":\"level1\"}}");
var level2Reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{level1Reply},\"content\":\"level2\"}}");
var level3Reply = await PostAndReadId(client, clubId, $"{{\"parentPostId\":{level2Reply},\"content\":\"level3\"}}");

// Verify all created
Assert.NotEqual(0, level1Reply);
Assert.NotEqual(0, level2Reply);
Assert.NotEqual(0, level3Reply);

// Verify structure in GET
using var getResponse = await client.GetAsync($"/api/v1/clubs/{clubId}/forum-posts");
using var getDocument = System.Text.Json.JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
var topicReply = getDocument.RootElement[0].GetProperty("replies")[0];
var level1Nested = topicReply.GetProperty("replies")[0];
var level2Nested = level1Nested.GetProperty("replies")[0];
Assert.Equal("level1", topicReply.GetProperty("content").GetString());
Assert.Equal("level2", level1Nested.GetProperty("content").GetString());
Assert.Equal("level3", level2Nested.GetProperty("content").GetString());

// Delete level 2 reply -> should cascade delete level 3
using var deleteLevel2 = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/{level2Reply}");
Assert.Equal(HttpStatusCode.NoContent, deleteLevel2.StatusCode);

// Verify level 2 and 3 deleted, but level 1 still exists
using var afterDeleteResponse = await client.GetAsync($"/api/v1/clubs/{clubId}/forum-posts");
using var afterDeleteDocument = System.Text.Json.JsonDocument.Parse(await afterDeleteResponse.Content.ReadAsStringAsync());
var topicReplyAfter = afterDeleteDocument.RootElement[0].GetProperty("replies")[0];
Assert.Equal("level1", topicReplyAfter.GetProperty("content").GetString());
Assert.Equal(0, topicReplyAfter.GetProperty("replies").GetArrayLength());
}

[Fact]
public async Task DeleteReply_ByOwner_Succeeds()
{
Expand Down Expand Up @@ -224,4 +262,53 @@ private static async Task<int> PostAndReadId(HttpClient client, int clubId, stri
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("id").GetInt32();
}

[Fact]
public async Task UploadImage_ExceedsRequestSizeLimit_Returns413()
{
var (client, clubId) = await SeedAsync(member: true, moderate: false);
Comment thread
Chaotze marked this conversation as resolved.

// Create a multipart request that exceeds 5 MB limit
using var content = new MultipartFormDataContent();
var oversizeData = new byte[6 * 1024 * 1024]; // 6 MB
content.Add(new ByteArrayContent(oversizeData), "image", "large.jpg");

using var response = await client.PostAsync($"/api/v1/clubs/{clubId}/forum-posts/upload-image", content);

// Should receive 413 Payload Too Large due to RequestSizeLimit
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
}

[Fact]
public async Task DeleteImage_WithValidStorageKey_ReturnsServerError()
{
var (client, clubId) = await SeedAsync(member: true, moderate: false);
const string testStorageKey = "clubs/1/forum/2026/08/19/test.png";

using var response = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/delete-image?storageKey={Uri.EscapeDataString(testStorageKey)}");

// OSS not configured in tests, so deletion fails with 500; verifies endpoint exists and accepts storageKey
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}

[Fact]
public async Task DeleteImage_WithoutStorageKey_ReturnsBadRequest()
{
var (client, clubId) = await SeedAsync(member: true, moderate: false);

using var response = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/delete-image");

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

[Fact]
public async Task DeleteImage_NonMember_IsForbidden()
{
var (client, clubId) = await SeedAsync(member: false, moderate: false);
const string testStorageKey = "clubs/1/forum/2026/08/19/test.png";

using var response = await client.DeleteAsync($"/api/v1/clubs/{clubId}/forum-posts/delete-image?storageKey={Uri.EscapeDataString(testStorageKey)}");

Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
Loading
Loading