Skip to content

Commit 71bea8a

Browse files
authored
feat: 新增特关作者发作业站内信通知 (#239)
- 为用户关注关系增加特别关注状态和查询/设置接口 - 新增站内信表、实体、仓库、服务、响应模型和接口 - 作者上传公开作业后向特关粉丝生成站内信 - 补充站内信建表索引和已有数据库手工迁移 SQL 尝试实现() ## Summary by Sourcery 为用户关系添加特别关注支持,并实现站内信机制,当作者发布新的公开 copilot 作业时通知其特别关注者。 New Features: - 通过关注相关 API 支持标记和查询用户之间的特别关注关系。 - 引入带持久化的站内信系统,并提供 REST API 用于列出消息、查询未读数量以及标记消息为已读。 Enhancements: - 在用户资料以及关注/粉丝列表响应中暴露特别关注状态。 - 当作者发布公开 copilot 作业时,向其特别关注者发送站内信通知。 <details> <summary>Original summary in English</summary> ## Summary by Sourcery Add special-follow support to user relations and implement an in-app site messaging mechanism to notify special followers when authors publish new public copilot assignments. New Features: - Support marking and querying special follow relationships between users via follow APIs. - Introduce a site message system with persistence and REST APIs for listing messages, querying unread counts, and marking messages as read. Enhancements: - Expose special follow status in user profile and follow/fan list responses. - Send site messages to special followers when an author publishes a public copilot assignment. </details>
2 parents dc6d4dc + 9bc9441 commit 71bea8a

17 files changed

Lines changed: 569 additions & 6 deletions

docker/init.sql

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,39 @@ create unique index if not exists idx_user_user_email on "user" (email);
1717
create table if not exists "user_follow"
1818
(
1919
user_id bigint,
20-
follow_user_id bigint not null,
21-
updated_at timestamp(3) not null,
20+
follow_user_id bigint not null,
21+
special_follow boolean default false not null,
22+
updated_at timestamp(3) not null,
2223
primary key (user_id, follow_user_id)
2324
);
25+
create index if not exists idx_user_follow_special_author on user_follow (follow_user_id, special_follow);
26+
27+
-- 站内信表
28+
create table if not exists site_message
29+
(
30+
id bigserial primary key,
31+
receiver_id bigint not null,
32+
sender_id bigint not null,
33+
sender_name text not null,
34+
type text not null,
35+
title text not null,
36+
content text not null,
37+
copilot_id bigint,
38+
read_at timestamp(3),
39+
created_at timestamp(3) not null
40+
);
41+
comment on table site_message is '站内信表';
42+
comment on column site_message.receiver_id is '接收用户ID';
43+
comment on column site_message.sender_id is '发送/触发用户ID';
44+
comment on column site_message.sender_name is '发送/触发用户名称快照';
45+
comment on column site_message.type is '站内信类型';
46+
comment on column site_message.title is '站内信标题';
47+
comment on column site_message.content is '站内信内容';
48+
comment on column site_message.copilot_id is '关联作业ID';
49+
comment on column site_message.read_at is '已读时间';
50+
comment on column site_message.created_at is '创建时间';
51+
create index if not exists idx_site_message_receiver_created on site_message (receiver_id, created_at desc);
52+
create index if not exists idx_site_message_receiver_read on site_message (receiver_id, read_at);
2453

2554
-- 作业表
2655
create table if not exists copilot
@@ -205,3 +234,5 @@ ALTER SEQUENCE IF EXISTS comments_area_id_seq RESTART WITH 1;
205234
ALTER SEQUENCE IF EXISTS copilot_set_id_seq RESTART WITH 1;
206235
-- ark_level 表的 id 从 1 开始
207236
ALTER SEQUENCE IF EXISTS ark_level_id_seq RESTART WITH 1;
237+
-- site_message 表的 id 从 1 开始
238+
ALTER SEQUENCE IF EXISTS site_message_id_seq RESTART WITH 1;

docker/migration_site_message.sql

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
alter table user_follow
2+
add column if not exists special_follow boolean default false not null;
3+
4+
create index if not exists idx_user_follow_special_author on user_follow (follow_user_id, special_follow);
5+
6+
create table if not exists site_message
7+
(
8+
id bigserial primary key,
9+
receiver_id bigint not null,
10+
sender_id bigint not null,
11+
sender_name text not null,
12+
type text not null,
13+
title text not null,
14+
content text not null,
15+
copilot_id bigint,
16+
read_at timestamp(3),
17+
created_at timestamp(3) not null
18+
);
19+
20+
comment on table site_message is '站内信表';
21+
comment on column site_message.receiver_id is '接收用户ID';
22+
comment on column site_message.sender_id is '发送/触发用户ID';
23+
comment on column site_message.sender_name is '发送/触发用户名称快照';
24+
comment on column site_message.type is '站内信类型';
25+
comment on column site_message.title is '站内信标题';
26+
comment on column site_message.content is '站内信内容';
27+
comment on column site_message.copilot_id is '关联作业ID';
28+
comment on column site_message.read_at is '已读时间';
29+
comment on column site_message.created_at is '创建时间';
30+
31+
create index if not exists idx_site_message_receiver_created on site_message (receiver_id, created_at desc);
32+
create index if not exists idx_site_message_receiver_read on site_message (receiver_id, read_at);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package plus.maa.backend.controller
2+
3+
import io.swagger.v3.oas.annotations.Operation
4+
import io.swagger.v3.oas.annotations.responses.ApiResponse
5+
import io.swagger.v3.oas.annotations.tags.Tag
6+
import org.springframework.web.bind.annotation.GetMapping
7+
import org.springframework.web.bind.annotation.PathVariable
8+
import org.springframework.web.bind.annotation.PostMapping
9+
import org.springframework.web.bind.annotation.RequestMapping
10+
import org.springframework.web.bind.annotation.RequestParam
11+
import org.springframework.web.bind.annotation.RestController
12+
import plus.maa.backend.common.controller.PagedDTO
13+
import plus.maa.backend.config.doc.RequireJwt
14+
import plus.maa.backend.config.security.AuthenticationHelper
15+
import plus.maa.backend.controller.response.MaaResult
16+
import plus.maa.backend.controller.response.MaaResult.Companion.success
17+
import plus.maa.backend.controller.response.message.SiteMessageInfo
18+
import plus.maa.backend.controller.response.message.UnreadMessageCountInfo
19+
import plus.maa.backend.service.SiteMessageService
20+
21+
@RestController
22+
@RequestMapping("/message")
23+
@Tag(name = "SiteMessage", description = "站内信接口")
24+
class MessageController(
25+
private val siteMessageService: SiteMessageService,
26+
private val helper: AuthenticationHelper,
27+
) {
28+
@Operation(summary = "分页查询站内信")
29+
@ApiResponse(description = "站内信列表")
30+
@RequireJwt
31+
@GetMapping("/list")
32+
fun list(
33+
@RequestParam page: Int = 1,
34+
@RequestParam size: Int = 10,
35+
@RequestParam("unread_only") unreadOnly: Boolean = false,
36+
): MaaResult<PagedDTO<SiteMessageInfo>> {
37+
return success(siteMessageService.list(helper.userId, page, size, unreadOnly))
38+
}
39+
40+
@Operation(summary = "查询未读站内信数量")
41+
@ApiResponse(description = "未读站内信数量")
42+
@RequireJwt
43+
@GetMapping("/unreadCount")
44+
fun unreadCount(): MaaResult<UnreadMessageCountInfo> {
45+
return success(siteMessageService.unreadCount(helper.userId))
46+
}
47+
48+
@Operation(summary = "标记单条站内信已读")
49+
@ApiResponse(description = "标记结果,true=确实标记成功,false=该消息不存在/已读/不属于当前用户")
50+
@RequireJwt
51+
@PostMapping("/read/{id}")
52+
fun read(@PathVariable id: Long): MaaResult<Boolean> {
53+
return success(siteMessageService.markRead(helper.userId, id))
54+
}
55+
56+
@Operation(summary = "标记全部站内信已读")
57+
@ApiResponse(description = "本次实际标记已读的条数")
58+
@RequireJwt
59+
@PostMapping("/readAll")
60+
fun readAll(): MaaResult<Int> {
61+
return success(siteMessageService.markAllRead(helper.userId))
62+
}
63+
}

src/main/kotlin/plus/maa/backend/controller/UserFollowController.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import plus.maa.backend.config.doc.RequireJwt
1616
import plus.maa.backend.config.security.AuthenticationHelper
1717
import plus.maa.backend.controller.response.MaaResult
1818
import plus.maa.backend.controller.response.MaaResult.Companion.success
19+
import plus.maa.backend.controller.response.user.FollowStatusInfo
1920
import plus.maa.backend.controller.response.user.MaaUserInfo
2021
import plus.maa.backend.service.follow.UserFollowService
2122

@@ -43,6 +44,21 @@ class UserFollowController(
4344
userFollowService.unfollow(helper.userId, followUserId),
4445
)
4546

47+
@Operation(summary = "设置特别关注")
48+
@ApiResponse(description = "设置特别关注结果")
49+
@RequireJwt
50+
@PostMapping("/special/{followUserId}")
51+
fun specialFollow(@PathVariable followUserId: Long, @RequestParam status: Boolean): MaaResult<Unit> = success(
52+
userFollowService.setSpecialFollow(helper.userId, followUserId, status),
53+
)
54+
55+
@Operation(summary = "查询关注状态")
56+
@ApiResponse(description = "关注状态")
57+
@RequireJwt
58+
@GetMapping("/status/{followUserId}")
59+
fun getStatus(@PathVariable followUserId: Long): MaaResult<FollowStatusInfo> =
60+
success(userFollowService.getStatus(helper.userId, followUserId))
61+
4662
@Operation(summary = "获取关注列表")
4763
@ApiResponse(description = "关注列表")
4864
@RequireJwt
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package plus.maa.backend.controller.response.message
2+
3+
import kotlinx.serialization.Contextual
4+
import kotlinx.serialization.Serializable
5+
import plus.maa.backend.service.model.SiteMessageType
6+
import java.time.LocalDateTime
7+
8+
@Serializable
9+
data class SiteMessageInfo(
10+
val id: Long,
11+
val type: SiteMessageType,
12+
val title: String,
13+
val content: String,
14+
val senderId: Long,
15+
val senderName: String,
16+
val copilotId: Long?,
17+
@Contextual
18+
val createdAt: LocalDateTime,
19+
@Contextual
20+
val readAt: LocalDateTime?,
21+
val isRead: Boolean,
22+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package plus.maa.backend.controller.response.message
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class UnreadMessageCountInfo(
7+
val unreadCount: Long,
8+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package plus.maa.backend.controller.response.user
2+
3+
import kotlinx.serialization.Contextual
4+
import kotlinx.serialization.Serializable
5+
import java.time.Instant
6+
7+
@Serializable
8+
data class FollowStatusInfo(
9+
val following: Boolean,
10+
val specialFollow: Boolean,
11+
val relation: RelationType,
12+
@Contextual
13+
val followedAt: Instant? = null,
14+
)

src/main/kotlin/plus/maa/backend/controller/response/user/MaaUserInfo.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ data class MaaUserInfo(
1919
val followingCount: Int = 0,
2020
val fansCount: Int = 0,
2121
val relation: RelationType? = null,
22+
val specialFollow: Boolean = false,
2223
@Contextual val followedAt: Instant? = null,
2324
) {
2425
constructor(user: MaaUser) : this(
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package plus.maa.backend.repository.entity
2+
3+
import org.ktorm.database.Database
4+
import org.ktorm.entity.Entity
5+
import org.ktorm.entity.sequenceOf
6+
import org.ktorm.schema.Table
7+
import org.ktorm.schema.datetime
8+
import org.ktorm.schema.enum
9+
import org.ktorm.schema.long
10+
import org.ktorm.schema.text
11+
import plus.maa.backend.service.model.SiteMessageType
12+
import java.time.LocalDateTime
13+
14+
interface SiteMessageEntity : Entity<SiteMessageEntity> {
15+
var id: Long
16+
var receiverId: Long
17+
var senderId: Long
18+
var senderName: String
19+
var type: SiteMessageType
20+
var title: String
21+
var content: String
22+
var copilotId: Long?
23+
var readAt: LocalDateTime?
24+
var createdAt: LocalDateTime
25+
26+
companion object : Entity.Factory<SiteMessageEntity>()
27+
}
28+
29+
object SiteMessages : Table<SiteMessageEntity>("site_message") {
30+
val id = long("id").primaryKey().bindTo { it.id }
31+
val receiverId = long("receiver_id").bindTo { it.receiverId }
32+
val senderId = long("sender_id").bindTo { it.senderId }
33+
val senderName = text("sender_name").bindTo { it.senderName }
34+
val type = enum<SiteMessageType>("type").bindTo { it.type }
35+
val title = text("title").bindTo { it.title }
36+
val content = text("content").bindTo { it.content }
37+
val copilotId = long("copilot_id").bindTo { it.copilotId }
38+
val readAt = datetime("read_at").bindTo { it.readAt }
39+
val createdAt = datetime("created_at").bindTo { it.createdAt }
40+
}
41+
42+
val Database.siteMessages get() = sequenceOf(SiteMessages)

src/main/kotlin/plus/maa/backend/repository/entity/UserFollowEntity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package plus.maa.backend.repository.entity
22

33
import org.ktorm.entity.Entity
44
import org.ktorm.schema.Table
5+
import org.ktorm.schema.boolean
56
import org.ktorm.schema.datetime
67
import org.ktorm.schema.long
78
import java.time.LocalDateTime
89

910
interface UserFollowEntity : Entity<UserFollowEntity> {
1011
var userId: Long
1112
val followUserId: Long
13+
var specialFollow: Boolean
1214
val updatedAt: LocalDateTime
1315

1416
companion object : Entity.Factory<UserFollowEntity>()
@@ -17,5 +19,6 @@ interface UserFollowEntity : Entity<UserFollowEntity> {
1719
object UserFollows : Table<UserFollowEntity>("user_follow") {
1820
val userId = long("user_id").primaryKey().bindTo { it.userId }
1921
val followUserId = long("follow_user_id").primaryKey().bindTo { it.followUserId }
22+
val specialFollow = boolean("special_follow").bindTo { it.specialFollow }
2023
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
2124
}

0 commit comments

Comments
 (0)