课程名称 | 程序设计 |
---|---|
项目名称 | FuzzyEnigma——一个简单的任务管理系统 |
项目代码量 | 7900 行代码左右 |
完成时间 | 2025 年 5 月 24 日 |
Github开源地址 | https://github.com/caiwen666/FuzzyEnigma |
演示地址 | http://fe.caiwen.work |
技术选型是一个项目开始时的主要问题之一,选择好的技术栈可以做到事半功倍的作用。
首先是编程语言,我考虑了自己比较熟悉的一些编程语言进行对比:
对于前端部分,目前主流的方向有两个。一个是 SPA,即单页应用,可以理解为网页首次加载时就把所有的页面都加载,后续页面跳转只不过是挂载不同的组件。一个是 SSR,即服务端渲染,可以理解为我们在 SPA 的基础上,让一些初始数据可以在服务端渲染。
SPA 是完全的前后端分离,但是根据先前开发经验会存在路由管理等问题,因此我们选用了 SSR。
于是前端部分的技术栈为:
后端部分的技术栈为
Salvo:HTTP 框架
anyhow、thiserror:提供更优雅的错误处理
lettre:邮件发送工具
moka:缓存工具。为了保持项目的简单性,且本项目并没有分布式架构,所以我们并没有选用 redis
sqlx:数据库操作的工具
validator:请求参数校验工具
以及 MySQL,提供数据的持久化存储
经过一段时间的摸索,本项目形成了这样的一个架构
前端中,每个页面的初始数据都由 page.tsx
在服务端中获取,并将初始数据通过 Props 注入到客户端组件中。服务端和客户端组件使用的 Request 实例(在本项目中为 Axios 实例)不同,但都经过统一的 api 函数来请求后端。
Router 层负责校验请求参数是否合法,是否符合权限。校验完毕之后,请求交给 Service 层处理。Service 层注重业务逻辑。Service 如果需要与持久化数据进行交互的话,则调用 Repository 层或者文件存储层。Repository 注重数据库操作。原本计划的文件存储层是打算接入 OSS (对象存储)服务的,但由于时间原因,本项目的资源选择在本地存储。Moka 作为缓存,放在 Repository 层比较好,但本项目为了简单期间并没有在后端和 MySQL 之间建立一个缓存层,Moka 在本项目中的用处是存储登录令牌,邮件验证码验证等会定时过期的数据,因此放在了 Service 层
一般而言用户鉴权有两种:
对于后者,难以控制用户的登录状态,所以本项目选择了第一种
登录时,后端从数据库中获取密码进行比对,比对成功后服务端生成一个 token,把 token 存储在缓存中,并下发给客户端
rust12345678910111213/// server/service/user.rs
/// 生成用户登录状态的会话 token
pub async fn generate_user_session(uid: u32) -> Result<String> {
let token = compute_str_md5(format!(
"user_session_{}_{}",
uid,
Utc::now().timestamp_millis()
));
CACHE
.insert(CacheType::UserSession(token.clone()), uid.to_string())
.await;
Ok(token)
}
客户端收到 token 后将其存储在 cookie 中
tsx12345678910/// web/src/app/user/login/LoginForm.tsx
try {
const token = await login(request, email, password);
setCookie("session", token, { maxAge: 60 * 60 * 12 });
if (callback) {
window.location.replace(callback);
} else {
window.location.replace("/");
}
} catch {}
不选择存储在 LocalStorage 的原因,一个是 LocalStorage 无法设置过期时间,另一个是,有一部分的后端请求是 Next.js 在服务端渲染的时候产生的,服务端组件的请求也需要 token,而 Next.js 的服务端组件不可能获取到 LocalStorage 的数据来得到 token,只能通过浏览器的请求来获得token,同时浏览器请求时无法自动携带 LocalStorage 的数据,但是可以自动携带 Cookie ,所以我们选择存放在 Cookie 中
而后端的接口鉴权的时候是通过 HTTP 请求头的 Authorization 字段获取 token 并进行检查,所以,对于客户端组件,请求前的准备如下:
ts12345678/// web/src/utils/request/client.ts
instance.interceptors.request.use(
(config) => {
config.headers["Authorization"] = getCookie("session");
return config;
},
...
);
客户端组件请求接口的一般做法是:
ts123import request from "@/utils/request/client";
// 这里拿登录接口举例
const token = await login(request, email, password);
对于服务端组件,在中间件中获取到 token
ts12345678910111213141516171819202122/// web/src/middleware.ts
if (request.cookies.has("session")) {
// 从 cookie 中获取 token
const session = request.cookies.get("session")?.value;
if (!session) {
return NextResponse.redirect(jump);
}
const r = await getServerRequest(session);
try {
const info = await getUserInfo(r);
request.headers.set("uid", info.basic_info.uid.toString());
request.headers.set("username", info.basic_info.username);
request.headers.set("email", info.basic_info.email);
request.headers.set("permission", JSON.stringify(info.permission));
// 注入到 header 中,因为在 Next.js 中,后续的组件无法获取 cookie 了,只能获取 header
request.headers.set("session", session);
} catch {
return NextResponse.redirect(jump);
}
} else {
return NextResponse.redirect(jump);
}
服务端组件请求接口的一般做法是:
ts12345678910111213141516171819202122232425/// web/src/app/task/detail/page.tsx
const header = await headers();
const session = header.get("session") as string;
const r = await getServerRequest(session);
// 以获取任务详情信息接口举例
const task = await getTaskDetail(r, Number(id));
/// web/src/utils/request/server.ts
export const getServerRequest = async (token: string) => {
const instance = axios.create({
baseURL: API_URL_LOCAL,
timeout: 10000,
withCredentials: true,
});
instance.interceptors.request.use((config) => {
config.headers["Authorization"] = token;
return config;
});
instance.interceptors.response.use((result) => {
const { code, msg, data } = result.data;
if (code === 200) return data;
return Promise.reject(msg);
});
return instance;
};
总体上说,我们希望仍保持前后端分离总体的思想,但是把部分本应客户端组件发送的请求转移到服务端组件中
同时,我们在前端设置 Cookie 中设置了过期时间,同时后端存储 token 时也设置了过期时间(见 server/src/cache.rs
,这使得经过一段时间,用户的登录状态会过期)
每个用户的权限是一个 string 数组,权限信息存储在 tb_user_permissions 表中,其定义为
sql123456789create table tb_user_permissions
(
uid int unsigned not null,
value varchar(255) not null,
primary key (uid, value),
constraint tb_user_permissions_ibfk_1
foreign key (uid) references tb_user (uid)
on delete cascade
);
权限的定义和解释如下
ts1234567891011121314151617181920212223/// web/src/config/index.ts
export const PERMISSIONS = [
{
value: "manage_all_task",
label: "可以管理所有任务",
},
{
value: "manage_user",
label: "可以进行用户管理",
},
{
value: "root",
label: "可以更改用户权限,更改用户角色,同时不能被删除",
},
{
value: "assign_task",
label: "可以将任务指派给其他人",
},
{
value: "ai",
label: "可以使用AI助手",
},
];
其中 root 权限需要手动执行 sql 语句赋予。其他的权限可以由拥有 root 权限的用户赋予
设置灵活分配的这些权限,可以实现不同的用户角色
例如,对于普通用户,可以不分配任何权限,普通用户可以创建任务并将任务分配给自己,进行自由学习。对于学生,可以赋予 ai
权限,允许学生使用 AI 功能。对于教师,可以分配 assign_task
权限,教师可以创建任务并指派给他人。对于管理员,可以分配 assign_task
、manage_all_task
,manage_user
权限,对所有的任务和用户进行管理。对于超级管理员则可分配所有权限。
在 router 层进行权限检查,如
rust123456789101112/// server/src/router/task.rs
#[handler]
pub async fn delete_task(req: &mut Request, depot: &mut Depot) -> RouterResult {
let id = req.query::<u32>("id").ok_or(AppError::ArgumentError)?;
let task = service::task::get(id).await?.ok_or(anyhow!("任务不存在"))?;
let context = depot.obtain::<AppContext>().unwrap();
// 如果不是任务的创建者,且没有 manage_all_task 权限,则拒绝
if context.user.uid != task.publisher && !context.permissions.manage_all_task() {
return Err(AppError::PermissionDenied.into());
}
...
}
根据作业文档的表述,我们可以梳理出一个任务应包含:任务标题,任务描述,任务的截止时间,任务的预计耗时,任务的优先级,任务的发布者。
作业中提到了很多任务类型,但除了小组任务之外,其他任务并无本质区别,因此我们将任务类型也作为任务的一个属性。
对于小组任务,我们可能要考虑将小组与任务相关联。
考虑到作业中“自由学习者”这个字眼,我们设计为允许用户自行创建任务,也可以创建一个任务,然后把这个任务指派给其他人。为了和权限系统打配合,我们设置只有拥有 assign_task
权限才可以把一个任务指派给其他人(拥有这个权限的人可以被视为教师角色),如果没有的话只能把这个任务指派给自己。
为了将任务指派和小组更好地结合,我们设计为:每个任务在创建后都有一个默认的分组,对于一般的任务,增删人员都视为给这个默认的分组里增删人员,一般的任务不允许再增加分组。对于小组任务,则可以增删分组,并把不同的用户添加到不同的小组中。
为了简单起见,我们暂时仅支持由任务的创建者将任务指派,而不支持用户主动请求加入一个任务。我们暂时只支持任务创建者来分组,决定哪些用户在同一个小组,而不支持用户自行挑选分组。
作业中还要求支持多级子任务,于是我们做出这样的设计:每个任务都可以设置前置任务,只有完成了前置任务才可以完成当前任务。简单起见,每个任务只能设置一个前置任务,同时一旦任务创建后,其依赖的任务就不能被修改了。这样的约定保证了任务的依赖关系一定是一个树或者说是森林结构,不会出现循环依赖关系。
我们建立 tb_group 表,表示每个任务底下的分组
sql123456789101112create table tb_group
(
id int unsigned auto_increment
primary key,
tid int unsigned not null,
constraint tb_group_ibfk_1
foreign key (tid) references tb_task (id)
on delete cascade
);
create index tid
on tb_group (tid);
然后建立 link_group_user 表,将分组和用户关联起来,其中 finish 字段记录用户是否完成这个任务
sql12345678910111213141516create table link_group_user
(
gid int unsigned not null,
uid int unsigned not null,
finish tinyint(1) default 0 not null,
primary key (gid, uid),
constraint link_group_user_ibfk_2
foreign key (uid) references tb_user (uid)
on delete cascade,
constraint link_group_user_ibfk_3
foreign key (gid) references tb_group (id)
on delete cascade
);
create index uid
on link_group_user (uid);
由于用户和任务之间的关系间隔了一个小组,所以很多的操作需要较为复杂的连表查询,如获取一个用户参加的所有任务:
rust12345678910111213141516/// server/src/repository/task.rs
pub async fn get_participated(uid: u32) -> Result<Vec<(Task, bool)>> {
let res = sqlx::query!(
r#"
SELECT task.id, task.title, task.type AS "typ!", task.priority, task.cost, task.deadline, task.publisher, task.prev, link_group_user.finish
FROM tb_task task
INNER JOIN tb_group ON task.id = tb_group.tid
INNER JOIN link_group_user ON tb_group.id = link_group_user.gid
WHERE link_group_user.uid = ?
"#,
uid
).fetch_all(db().await).await?.into_iter().map(|r| (
....
)).collect::<Vec<_>>();
Ok(res)
}
在前端添加小组成员的时候,我们需要根据关键词,对用户进行搜索。后端提供一个接口,可以列出所有用户名中包含给定关键词的用户。通过 sql 语句中的 LIKE 关键字实现:
rust123456789101112131415/// server/src/repository/user.rs
pub async fn search(keyword: &str) -> Result<Vec<UserBasicInfo>> {
let res = sqlx::query_as!(
UserBasicInfo,
r#"
SELECT uid, username, email
FROM tb_user
WHERE username LIKE ?
"#,
format!("%{}%", keyword)
)
.fetch_all(db().await)
.await?;
Ok(res)
}
前端基于 lodash,搜索时采用防抖技术
ts123const handleSearch = _.debounce(async (value: string) => {
...
}, 1000);
根据 2.4.1 内容,我们对任务的依赖关系做了很大的简化,但仍有很多地方需要考虑
由于任务的依赖关系是自身对自身的,所以无需再建立一个表,只需要给 tb_task 表设置一个 prev 字段,表示当前任务的前置任务id
sql1234567891011121314151617create table tb_task
(
id int unsigned auto_increment
primary key,
title varchar(255) not null,
description text not null,
type varchar(255) not null,
priority int unsigned not null,
cost int unsigned not null,
deadline bigint unsigned not null,
publisher int unsigned not null,
prev int unsigned null,
constraint fk_publisher
foreign key (publisher) references tb_user (uid)
on delete cascade
);
首先,一个任务(下文称为子任务)添加成员的时候,必须这个任务所依赖的任务(下文称之为父任务)已经添加了目标成员,才可以允许添加,否则,父任务没有指派给目标成员的话,那么目标成员永远无法完成子任务
同样,父任务在删除一个成员的时候,如果存在一个子任务指派给了目标成员,那么不应该允许这个删除操作,否则会破坏依赖关系的完整。由于一个任务的子任务可能有多个,所以仍然需要一个较为复杂的连表查询去检查这一点
rust123456789101112131415161718/// server/src/repository/task.rs
/// 检查某个用户是否也加入了依赖于某个任务的任务
pub async fn check_rely(uid: u32, task_id: u32) -> Result<u32> {
let res = sqlx::query!(
r#"
SELECT COUNT(DISTINCT tb_task.id) AS count
FROM tb_task
INNER JOIN tb_group ON tb_task.id = tb_group.tid
INNER JOIN link_group_user ON tb_group.id = link_group_user.gid
WHERE link_group_user.uid = ? AND tb_task.prev = ?
"#,
uid,
task_id
)
.fetch_one(db().await)
.await?;
Ok(res.count as u32)
}
同理,如果一个任务被其他任务依赖,那么这个任务是不被允许删除的。本项目可以在删除失败的时候给出当前任务被哪些任务依赖,做法是简单的:
rust1234567891011121314151617181920212223242526272829/// server/src/repository/task.rs
/// 被哪些任务作为依赖
pub async fn get_as_prev(id: u32) -> Result<Vec<Task>> {
let res = sqlx::query!(
r#"
SELECT id, title, type AS "typ!", priority, cost, deadline, publisher, prev
FROM tb_task
WHERE prev = ?
"#,
id
)
.fetch_all(db().await)
.await?
.into_iter()
.map(|r| Task {
id: r.id,
info: TaskInfo {
title: r.title,
typ: r.typ.into(),
priority: r.priority.into(),
cost: r.cost,
deadline: r.deadline,
},
publisher: r.publisher,
prev: r.prev,
})
.collect::<Vec<_>>();
Ok(res)
}
我们在前端,根据任务的优先级,剩余时间,来判断一个任务是否重要与是否紧急
考虑分类的数量较多(五个,四象限分类再加上全部未完成任务),我们考虑对分类也进行列表渲染
tsx12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576/// web/src/app/Home.tsx
const TaskCategories = [
{
name: "所有任务",
id: "all",
icon: <FormatLineSpacingOutlined />,
},
{
name: "重要且紧急",
id: "important_and_urgent",
icon: <WarningAmberOutlined />,
},
{
name: "重要但不紧急",
id: "important_but_not_urgent",
icon: <LightbulbOutlined />,
},
{
name: "紧急但不重要",
id: "urgent_but_not_important",
icon: <DirectionsBikeOutlined />,
},
{
name: "不重要且不紧急",
id: "not_important_and_not_urgent",
icon: <HotelOutlined />,
},
];
interface UncompletedTaskCategory {
all: Task[];
important_and_urgent: Task[];
important_but_not_urgent: Task[];
urgent_but_not_important: Task[];
not_important_and_not_urgent: Task[];
[index: string]: Task[];
}
....
const uncompleted_task: UncompletedTaskCategory = {
all: [],
important_and_urgent: [],
important_but_not_urgent: [],
urgent_but_not_important: [],
not_important_and_not_urgent: [],
};
....
....
list.forEach((item) => {
const status = getTaskStatus(item.task.info.deadline);
if (item.finish) {
completed_task.push(item.task);
} else if (status === "expired") {
expired_task.push(item.task);
} else {
uncompleted_task.all.push(item.task);
// 优先级为高就是重要,反之则不重要
const important = item.task.info.priority === "high";
// 根据颜色来判断是否紧急
const urgent = status === "red" || status === "orange";
if (important && urgent) {
uncompleted_task.important_and_urgent.push(item.task);
}
if (important && !urgent) {
uncompleted_task.important_but_not_urgent.push(item.task);
}
if (!important && urgent) {
uncompleted_task.urgent_but_not_important.push(item.task);
}
if (!important && !urgent) {
uncompleted_task.not_important_and_not_urgent.push(item.task);
}
}
});
....
我们设计了一个 getTaskStatus 函数,根据一个任务的剩余时间来返回不同的颜色,用于前端的显示。剩余时间较多则为绿色,较少则为红色
ts12345678910111213141516/// web/src/utils/task.ts
export const getTaskStatus = (deadline: number) => {
const now = new Date().getTime();
const remain_time = deadline - now;
if (remain_time <= 0) {
return "expired";
} else if (remain_time <= TASK_RED_LIMIT) {
return "red";
} else if (remain_time <= TASK_ORANGE_LIMIT) {
return "orange";
} else if (remain_time <= TASK_LIME_LIMIT) {
return "lime";
} else {
return "green";
}
};
每个颜色的阈值定义如下:
ts1234/// web/src/config/index.ts
export const TASK_RED_LIMIT = 1000 * 60 * 60 * 3; // 3 hours
export const TASK_ORANGE_LIMIT = 1000 * 60 * 60 * 24; // 1 day
export const TASK_LIME_LIMIT = 1000 * 60 * 60 * 24 * 3; // 3 day
颜色的具体定义参考 tailwind.config.js
。
见 4.2 和 4.3 节内容。
为了简单起见,我们把资源关联在任务上,作为每个任务的附件。
作业文件中提到的若干学习资源本质上可以分为两类:文件和链接。
为了简单起见,资源的评分设为点赞和点踩两类。
可以给资源添加标签,并且可以给资源进行评论。
我们不仅需要维护一个资源点赞和点踩的数量,还需要知道每个用户是点赞还是点踩了,所以需要开一个表 link_user_resource 进行维护,其定义如下:
sql1234567891011121314151617create table link_user_resource
(
rid int unsigned not null,
uid int unsigned not null,
attitude varchar(255) not null,
primary key (rid, uid),
constraint link_user_resource_ibfk_1
foreign key (rid) references tb_resource (id)
on delete cascade,
constraint link_user_resource_ibfk_2
foreign key (uid) references tb_user (uid)
on delete cascade
);
create index uid
on link_user_resource (uid);
attitude 表示用户对资源的态度,如果不存在或为 none 则为未作评价,为 up 则为点赞,为 down 则为点踩。
考虑到在前端,我们可能从即没点赞也没点踩到点赞或点踩,也可能取消点赞或点踩,也可能从点赞到点踩,情况比较多,分别提供点赞和点踩的接口并不明智。我们选择直接提供“设置对资源的态度”的接口,其对应于的 repository 层的核心代码如下:
rust1234567891011121314151617/// server/src/repository/resource.rs
pub async fn update_attitude(resource_id: u32, uid: u32, attitude: ResourceAttitude) -> Result<()> {
sqlx::query!(
r#"
INSERT INTO link_user_resource (uid, rid, attitude)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE attitude = ?
"#,
uid,
resource_id,
String::from(attitude),
String::from(attitude)
)
.execute(db().await)
.await?;
Ok(())
}
评论功能是简单的,我们定义了一个 tb_comment 表存储评论:
sql123456789101112131415161718192021create table tb_comment
(
id int unsigned auto_increment
primary key,
content text not null,
rid int unsigned not null,
time bigint unsigned not null,
uid int unsigned not null,
constraint tb_comment_ibfk_2
foreign key (uid) references tb_user (uid)
on delete cascade,
constraint tb_comment_ibfk_3
foreign key (rid) references tb_resource (id)
on delete cascade
);
create index rid
on tb_comment (rid);
create index uid
on tb_comment (uid);
其在 repository 层对应的增删查代码都很简单:
rust1234567891011121314151617181920212223242526272829303132333435363738394041424344/// server/src/repository/resource.rs
pub async fn add_comment(resource_id: u32, uid: u32, content: String) -> Result<u32> {
let res = sqlx::query!(
r#"
INSERT INTO tb_comment (rid, uid, content, time)
VALUES (?, ?, ?, ?)
"#,
resource_id,
uid,
content,
Utc::now().timestamp_millis()
)
.execute(db().await)
.await?;
Ok(res.last_insert_id() as u32)
}
pub async fn delete_comment(comment_id: u32) -> Result<()> {
sqlx::query!(
r#"
DELETE FROM tb_comment
WHERE id = ?
"#,
comment_id
)
.execute(db().await)
.await?;
Ok(())
}
pub async fn get_comments(id: u32) -> Result<Vec<Comment>> {
let res = sqlx::query_as!(
Comment,
r#"
SELECT id, content, rid, time, uid
FROM tb_comment
WHERE rid = ?
"#,
id
)
.fetch_all(db().await)
.await?;
Ok(res)
}
简单起见,我们只实现了一个基于资源标签的推荐策略。每个资源的详情页都有一个资源推荐列表。
对于给定资源 ,设其拥有的标签为 ,其对应的任务为 。
然后我们找出当前用户所有参加的任务对应的所有资源,在其中,如果一个资源 ,满足 那么资源 加入推荐列表。
实现代码如下:
rust123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778/// server/src/service/resource.rs
pub async fn get_recommend(uid: u32, task_id: u32) -> Result<Vec<Resource>> {
let tags = sqlx::query!(
r#"
SELECT DISTINCT value
FROM tb_resource_tag WHERE rid IN (
SELECT id FROM tb_resource WHERE tid = ?
)
"#,
task_id
)
.fetch_all(db().await)
.await?
.into_iter()
.map(|x| x.value)
.collect::<Vec<_>>();
if tags.is_empty() {
return Ok(vec![]);
}
let participated = repository::task::get_participated(uid)
.await?
.into_iter()
.map(|(task, _)| task.id)
.filter(|x| *x != task_id)
.collect::<Vec<_>>();
if participated.is_empty() {
return Ok(vec![]);
}
let sql = format!(
r#"
SELECT DISTINCT tb_resource.id as id
FROM tb_resource
INNER JOIN tb_resource_tag ON tb_resource.id = tb_resource_tag.rid
WHERE tb_resource_tag.value IN ({}) AND tb_resource.tid IN ({})
"#,
std::iter::repeat("?")
.take(tags.len())
.collect::<Vec<_>>()
.join(","),
std::iter::repeat("?")
.take(participated.len())
.collect::<Vec<_>>()
.join(",")
);
let mut query = sqlx::query(&sql);
for tag in tags {
query = query.bind(tag);
}
for task in participated {
query = query.bind(task);
}
let res = query
.fetch_all(db().await)
.await?
.into_iter()
.map(|x| x.get("id"))
.collect::<Vec<u32>>();
if res.is_empty() {
return Ok(vec![]);
}
let sql = format!(
r#"
SELECT id, type AS typ, content, name, tid
FROM tb_resource
WHERE id IN ({})
"#,
std::iter::repeat("?")
.take(res.len())
.collect::<Vec<_>>()
.join(",")
);
let mut query = sqlx::query_as::<_, Resource>(&sql);
for id in res {
query = query.bind(id);
}
let res = query.fetch_all(db().await).await?;
Ok(res)
}
考虑到这个推荐列表需要多条 sql 语句查询,并且目测会比较耗时,所以获取推荐资源列表单独作为一个接口,并且不由服务端组件进行数据获取。
对于文件资源,如果用户在下载资源时,直接给出其对应文件的下载地址,那么用户在获取到这个地址后可以把这个下载地址给没有参与该资源对应任务的用户,这样会容易造成资源的泄露,会不妥。为此,我们做如下设计:当需要下载一个资源的时候,先像后端发送请求,得到一个 ticket,这个 ticket 有效期很短,然后再根据这个 ticket 从后端获取文件。
ts12345678910111213141516/// web/src/app/resource/detail/page.tsx
const handleFetchResource = async () => {
setLoading("fetch");
try {
const res = await fetchResource(request, resource.info.id);
if (resource.info.type == "file") {
window.open(
API_URL_REMOTE + "/resource/download?ticket=" + res,
"_blank",
);
} else {
window.open(res, "_blank");
}
} catch {}
setLoading("");
};
rust1234567891011121314151617/// server/src/service/resource.rs
pub async fn generate_ticket(id: u32, file_name: String) -> Result<String> {
let ticket = compute_str_md5(format!("resource_{}_{}", id, Utc::now().timestamp_millis()));
CACHE
.insert(CacheType::ResourceTicket(ticket.clone()), file_name)
.await;
Ok(ticket)
}
pub async fn check_ticket(ticket: String) -> Result<Option<String>> {
if let Some(file_name) = CACHE.get(&CacheType::ResourceTicket(ticket.clone())).await {
CACHE.remove(&CacheType::ResourceTicket(ticket)).await;
Ok(Some(file_name))
} else {
Ok(None)
}
}
rust1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556/// server/src/router/resource.rs
#[handler]
pub async fn fetch(req: &mut Request, depot: &mut Depot) -> RouterResult {
let id = req
.query::<u32>("resource_id")
.ok_or(AppError::ArgumentError)?;
let context = depot.obtain::<AppContext>().unwrap();
let resource = service::resource::get(id).await?;
let task = service::task::get_with_resource(id)
.await?
.ok_or(anyhow!("资源不存在"))?;
if !service::task::is_participated(context.user.uid, task.id)
.await?
.is_some()
&& task.publisher != context.user.uid
&& !context.permissions.manage_all_task()
{
return Err(AppError::PermissionDenied);
}
// TODO 写到这里突然发现 Resource 结构体里的 typ 应为 ResourceType,但是已经懒得改了
let typ = resource.typ.as_str();
match typ {
"link" => {
return Ok(resource.content.into());
}
"file" => {
let ticket = service::resource::generate_ticket(id, resource.content).await?;
return Ok(ticket.into());
}
_ => {
return Err(anyhow!("未知资源类型").into());
}
};
}
#[handler]
pub async fn download(req: &mut Request, res: &mut Response) {
let app_result = async_func(async {
let ticket = req
.query::<String>("ticket")
.ok_or(AppError::ArgumentError)?;
let file_name = service::resource::check_ticket(ticket)
.await?
.ok_or(anyhow!("ticket 无效或已过期,请重试"))?;
let file_path = format!("data/files/{}", file_name);
res.send_file(&file_path, req.headers()).await;
Ok(().into())
})
.await;
match app_result {
Ok(_) => (),
Err(e) => {
res.render(e);
}
};
}
本项目宏观上使用了前后端分离的架构。
对于前端,使用了 React 作为主要的技术栈,使用 Material Design 风格进行 UI 界面的设计。使用 Next.js 来进行 SSR(服务端渲染)。并且前端界面是响应式的,能够在不同大小的设备上有良好呈现。并使用了防抖等技术。
对于后端,使用 Rust 语言进行编写,Salvo 作为主要的后端框架,采用类似 MVC 结构的 router-service-repository 分层,实现了较好的异常处理,日志管理,配置文件加载。使用 MySQL 进行数据的可持久化。
对于用户,支持用户登录,用户注册,注册时发送邮件验证码验证。支持灵活的用户权限管理。支持关键词用户搜索。
对于任务,支持任务增删改查,支持任务之间的依赖,支持任务分配小组,并做到根据任务依赖关系、优先级、预计耗时、截止时间,使用拓扑排序和优先队列安排任务执行顺序。接入了 DeepSeek 来实现任务的时间分配建议和休息建议。支持对任务进行自动紧急/重要四象限分类。
对于资源,支持增删改查,支持资源的点赞/点踩,支持评论,支持增删标签,支持基于标签的资源推荐。
在本次大作业之前已经很久没接触过前后端项目了,这次大作业又重新复习了一下前后端开发。并摸索出 router-service-repository 的后端分层架构和服务端组件请求数据,数据注入到客户端组件的前端 SSR 的架构。不过我仍发现目前的结构可能存在一些问题:
一个是 repository 的必要性,本项目中 service 层中很多代码都是直接转发 repository 的函数,似乎 repository 的分层有点多余。也可能是本项目业务逻辑比较简单,几乎都是简单的数据库操作的原因。
另一个是 SSR 的利用似乎不足。由于本项目为了清楚地分离服务端组件和客户端组件,直接让服务端组件只负责数据的获取,几乎所有的渲染都还是由客户端组件承担,这使得本项目严格意义上并不能算作一个 SSR。我们仍需要继续探索 SSR 的最佳实践。
通过本次大作业我强化了对 rust 语言的应用,在 6 个月前我还不能使用 rust 写一个简单的 crud 的后端程序。同时我也熟悉了 rust 的开发生态,对一些 rust 库有了一些了解。
Next.js 在国内的资料比较少,同时 Next.js 中间出现过较大的版本变化,这使得即使询问 ai 很多时候也无法得到满意的结果。本次大作业中我踩了 Next.js 的一些坑,如中间件的路由匹配,中间件获取的数据传递给后续服务端组件等。
同时这次大作业我一开始考虑使用 orm,但后续因为一些原因放弃,转而手写 sql 语句,也强化了 sql 语句的知识,特别是联表查询。
在本次大作业最后我突然想到可以接入 deepseek,并花了半天的时间简单的完成了接入。这是我首次尝试在一个项目中接入大模型,我感觉对于大模型的利用还有很多可以发挥的地方。
本项目在业务逻辑部分几乎没有用到面向对象,这也让我产生了关于面向对象的一些思考。一个类,和结构体+函数,函数传递结构体引用进去,似乎本质相同。那么面向对象的意义在哪里?我认为多态是面向对象的核心,没有利用到多态的话,面向对象就是假的。以及我认为要以业务逻辑优先,如果一个业务逻辑并不需要用到面向对象,那么强行套面向对象也是无意义的。以本项目举例,本项目至少在我的设计下,无论是用户、任务还是资源,都不需要涉及到多态。即使考虑到后面的拓展,那也应该视后续具体的业务需求而定。而且,对于一个前后端项目,数据的持久化是放在关系型数据库中的,本身不太可能搞成复杂的对象。以及后端的所有操作基本都是在一个请求里进行的,进行复杂的对象操作也不合适。除非是一些较为特殊的逻辑,不然难以遇到一定使用面向对象才好解决的场景。本次作业给出的需求我认为并不适合,或者说不是一定需要用面向对象。
我们实现了注册时需要通过邮箱验证才能完成注册。
邮箱使用阿里云的企业邮箱,绑定了自己的域名:gcteamo.com
。
使用 lettre 库来进行邮件的发送,具体细节见 server/src/mail.rs
。
后端还提供一个验证邮件的接口,客户端拿用户输入的验证码请求该接口,如果验证码正确,那么客户端会下发一个 ticket 。
rust123456789101112/// server/src/router/user.rs
#[handler]
pub async fn email_verify(req: &mut Request) -> RouterResult {
let arg: EmailVerifyForm = req.extract().await?;
if arg.validate().is_err() {
return Err(AppError::ArgumentError);
}
let ticket = service::user::verify_email_code(arg.email.as_str(), arg.code)
.await?
.ok_or(anyhow!("验证码错误"))?;
Ok(ticket.into())
}
拿到 ticket 就可以证明邮箱的所有权了。后续需要证明邮箱所有权的接口,如用户注册,找回密码,都需要提供 ticket。
为了防止邮件发送接口被滥用,我们设置了一个频率限制,即每个邮件地址每分钟只能发一个邮件。
rust123456789101112131415161718192021/// server/src/router/user.rs
#[handler]
pub async fn email_send(req: &mut Request) -> RouterResult {
let email: String = req.query("email").ok_or(AppError::ArgumentError)?;
if !email.validate_email() {
return Err(AppError::ArgumentError);
}
if CACHE
.get(&CacheType::EmailCodeLock(email.clone()))
.await
.is_some()
{
return Err(anyhow!("发送验证码频繁,请稍后再试").into());
}
service::user::send_email_code(email.as_str()).await?;
// CACHE 是 moka 实例,见 server/src/cache.rs
CACHE
.insert(CacheType::EmailCodeLock(email.clone()), "1".to_string())
.await;
Ok(().into())
}
验证码有效期也是类似的手段实现的。
我们希望首页显示的任务是按照一个较好的顺序给出的。具体来说,我们需要保证按照首页的排列顺序从上到下依次做任务,能解决任务的依赖关系(父任务在上,子任务在下),同时更紧急的任务先做(同等剩余时间,优先级越高越先做。同等优先级,剩余时间越小越先做)。为此,我们应用了拓扑排序算法来解决依赖关系。传统拓扑排序算法是一个普通队列,为了满足“更紧急的任务先做”这个要求,我们使用优先队列来维护。算法的代码如下:
rust123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263struct WeightWrapper<T> {
deadline: u64, // 截止时间小的先做
weight: u32, // 截止时间相同时,权重小的先做
inner: T,
}
impl<T> Ord for WeightWrapper<T> {
fn cmp(&self, other: &Self) -> Ordering {
if self.deadline == other.deadline {
other.weight.cmp(&self.weight)
} else {
other.deadline.cmp(&self.deadline)
}
}
}
impl WeightWrapper<(Task, bool)> {
fn new(task: Task, finish: bool) -> Self {
Self {
deadline: task.info.deadline,
// 权重并不是直接为优先级,而是 预计耗费时间 / 优先级
weight: task.info.cost / (u32::from(task.info.priority) + 1),
inner: (task, finish),
}
}
}
pub async fn arrange_task(list: Vec<(Task, bool)>) -> Vec<(Task, bool)> {
let mut deg = HashMap::new(); // 每个点的入度
let mut tasks = HashMap::new(); // 根据编号获得任务实体
let mut que = BinaryHeap::new(); // 优先队列
let mut g = HashMap::new(); // 邻接表
let mut res = Vec::new(); // 结果
for (task, finish) in list {
if let Some(prev) = task.prev {
g.entry(prev).or_insert(vec![]).push(task.id);
*deg.entry(task.id).or_insert(0) += 1;
} else {
que.push(WeightWrapper {
deadline: task.info.deadline,
weight: task.info.cost / (u32::from(task.info.priority) + 1),
inner: (task.clone(), finish),
});
}
tasks.insert(task.id, (task, finish));
}
while let Some(WeightWrapper {
deadline: _,
weight: _,
inner: (task, finish),
}) = que.pop()
{
if let Some(next_tasks) = g.get(&task.id) {
for next_id in next_tasks {
let next_deg = deg.get_mut(next_id).unwrap();
*next_deg -= 1;
if *next_deg == 0 {
let (next_task, next_task_finish) = tasks.remove(next_id).unwrap();
que.push(WeightWrapper::new(next_task, next_task_finish));
}
}
}
res.push((task, finish));
}
res
}
作业中的 “最优时间分配建议” 和 “学习疲劳预警” 这项功能,如果使用传统的算法不太好实现,即使实现效果也难以保证。因此,我们考虑直接接入大模型,借助大模型来实现这个功能。
在 deepseek 开放平台上购买相应的额度。
根据用户当前参与的,且没过期的任务,构造提示词:
rust12345678910111213141516171819202122232425262728293031323334353637383940let prompt = task
.into_iter()
.map(|(task, _)| {
let seconds = task.info.deadline / 1000;
let nanos = (task.info.deadline % 1000) * 1_000_000; // 转换为纳秒
let utc_time = DateTime::<Utc>::from_utc(
NaiveDateTime::from_timestamp_opt(seconds as i64, nanos as u32).unwrap(),
Utc,
);
// 转换为 UTC+8
let utc_plus_8 = FixedOffset::east_opt(8 * 3600).unwrap();
let beijing_time = utc_time.with_timezone(&utc_plus_8);
format!(
"<begin>{}\n{}\n{}\n{}<end>",
task.info.title,
beijing_time.format("%Y-%m-%d %H:%M"),
match task.info.priority {
crate::entity::task::TaskPriority::High => "高优先级",
crate::entity::task::TaskPriority::Medium => "中优先级",
crate::entity::task::TaskPriority::Low => "低优先级",
},
task.info.cost
)
})
.collect::<Vec<_>>()
.join("\n");
let data = json!({
"model": "deepseek-chat",
"messages": [
{
"role": "system",
"content": r#"你现在是一个时间规划大师,我将给你若干个任务,每个任务以<begin>开始<end>结束,每个任务包含多行信息,具体地,第一行为任务的名称,第二行为任务的截止时间,第三行为任务的优先级,第四行为任务的预计耗时。你需要为我安排一个时间规划方案。规划方案可以根据当前日期考虑其他外界因素。规划方案可以添加休息建议,注意劳逸结合。注意,你只需要给出规划方案和规划理由,其他的任何内容都不要回答。你的回答应简短"#
},
{
"role": "user",
"content": prompt
}
],
});
构造后提示词大概是这样:
Unknown
12345678910111213你现在是一个时间规划大师,我将给你若干个任务,每个任务以<begin>开始<end>结束,每个任务包含多行信息,具体地,第一行为任务的名称,第二行为任务的截止时间,第三行为任务的优先级,第四行为任务的预计耗时。你需要为我安排一个时间规划方案。规划方案可以根据当前日期考虑其他外界因素。规划方案可以添加休息建议,注意劳逸结合。注意,你只需要给出规划方案和规划理由,其他的任何内容都不要回答。你的回答应简短 <start> 测试任务aaaa 2025-05-25 12:30 中优先级 100分钟 <end> <start> 小组任务 2025-08-01 12:00 高优先级 100000000分钟 <end>
根据其 api 文档:https://api-docs.deepseek.com/zh-cn/api/create-chat-completion,发送请求并解析:
rust1234567891011121314151617181920212223242526272829303132333435363738394041424344#[derive(Deserialize)]
struct DeepSeekResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<DeepSeekChoice>,
}
#[derive(Deserialize)]
struct DeepSeekChoice {
pub index: u32,
pub message: DeepSeekMessage,
pub finish_reason: String,
}
#[derive(Deserialize)]
struct DeepSeekMessage {
pub role: String,
pub content: String,
}
let client = get_client().await;
let response = client
.post("https://api.deepseek.com/chat/completions")
.json(&data)
.send()
.await?;
if response.status() != 200 {
match response.status().as_u16() {
401 => return Ok("DeepSeek 密钥错误,请联系管理员解决".to_string()),
402 => return Ok("DeepSeek 额度不足,请联系管理员解决".to_string()),
429 => return Ok("DeepSeek 请求过于频繁,请稍后再试".to_string()),
500 => return Ok("DeepSeek 服务器错误,请稍后再试".to_string()),
503 => return Ok("DeepSeek 服务器繁忙,请稍后再试".to_string()),
_ => return Ok(format!("请求 DeepSeek 失败,错误码:{}", response.status())),
}
}
let response = response.json::<DeepSeekResponse>().await?;
Ok(response
.choices
.get(0)
.ok_or(anyhow!("DeepSeek 返回空响应!"))?
.message
.content
.clone())
由于大模型的生成比较慢,我们会先把大模型的响应放到数据库中缓存,每次获取时间安排方案的时候直接从数据库里获取。如果当前参加的任务有变动,则需要用户手动更新。
大模型生成的内容是 markdown 格式的,为了在前端很好地呈现,我们使用 markdown-it 来渲染 markdown,并采用 github 的 markdown 主题。
我们平常使用的大模型都采用了 SSE(服务端推送)的技术。这个技术可以做到大模型生成了什么就先返回什么,不必等到完全生成。由于大模型生成速度比较慢,目前的实现中,用户在手动更新后会有一个较长的等待时间,不太友好。理论上采用 SSE 的话会有更好的用户体验,但由于时间原因,我们没有做这一点。