Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions backend/src/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ const authenticate: (req: Request, res: Response, next: NextFunction) => Respons
if (err || !decoded) {
return res.status(401).send("401 Unauthorized: Token expired or invalid");
}
if (typeof decoded === "string") {
return res.status(401).send("401 Unauthorized: Token payload is invalid");
}
const uuid = (decoded as { uuid?: string }).uuid;
if (!uuid) {
return res.status(401).send("401 Unauthorized: Token payload is missing uuid");
}
// 将 token 中的用户身份暴露给后续路由使用(如删除当前用户)
res.locals.uuid = uuid;
return next();
});
};
Expand Down
31 changes: 31 additions & 0 deletions backend/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,35 @@ router.get("/download", authenticate, (req, res) => {
}
});

// “痕迹抹除”:删除指定房间中的某个文件
router.post("/delete", authenticate, (req, res) => {
const room = req.body?.room;
const filename = req.body?.filename;
if (!room || typeof room !== "string" || !filename || typeof filename !== "string") {
return res.status(422).send("422 Unprocessable Entity: Missing room or filename");
}
const baseDirResolved = path.resolve(baseDir);
const roomDir = path.resolve(baseDirResolved, room);
const target = path.resolve(roomDir, filename);
try {
// 防止路径穿越:room 和 filename 解析后必须仍落在上传根目录之内
const roomRel = path.relative(baseDirResolved, roomDir);
const fileRel = path.relative(roomDir, target);
if (roomRel.startsWith("..") || fileRel.startsWith("..")) {
return res.status(400).send("400 Bad Request: room or filename escapes the upload directory");
}
if (!fs.existsSync(target)) {
return res.status(404).send("404 Not Found: File does not exist");
}
if (!fs.statSync(target).isFile()) {
return res.status(400).send("400 Bad Request: target is not a file");
}
fs.unlinkSync(target);
return res.send("File deleted successfully");
} catch (err) {
console.error(err);
return res.sendStatus(500);
}
});

export default router;
16 changes: 16 additions & 0 deletions backend/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,12 @@ export type GetUsersByUsernameQueryVariables = Exact<{

export type GetUsersByUsernameQuery = { __typename?: 'query_root', user: Array<{ __typename?: 'user', uuid: any, password: string }> };

export type DeleteUserByUuidMutationVariables = Exact<{
uuid: Scalars['uuid']['input'];
}>;


export type DeleteUserByUuidMutation = { __typename?: 'mutation_root', delete_user_by_pk?: { __typename?: 'user', uuid: any } | null };

export const AddMessageDocument = gql`
mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) {
Expand Down Expand Up @@ -1627,6 +1633,13 @@ export const GetUsersByUsernameDocument = gql`
}
}
`;
export const DeleteUserByUuidDocument = gql`
mutation deleteUserByUuid($uuid: uuid!) {
delete_user_by_pk(uuid: $uuid) {
uuid
}
}
`;

export type SdkFunctionWrapper = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string, operationType?: string, variables?: any) => Promise<T>;

Expand Down Expand Up @@ -1658,6 +1671,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
},
getUsersByUsername(variables: GetUsersByUsernameQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetUsersByUsernameQuery> {
return withWrapper((wrappedRequestHeaders) => client.request<GetUsersByUsernameQuery>(GetUsersByUsernameDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getUsersByUsername', 'query', variables);
},
deleteUserByUuid(variables: DeleteUserByUuidMutationVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<DeleteUserByUuidMutation> {
return withWrapper((wrappedRequestHeaders) => client.request<DeleteUserByUuidMutation>(DeleteUserByUuidDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'deleteUserByUuid', 'mutation', variables);
}
};
}
Expand Down
20 changes: 20 additions & 0 deletions backend/src/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express";
import jwt from "jsonwebtoken";
import authenticate from "./authenticate";
import { sdk as graphql } from "./index";

interface userJWTPayload {
Expand Down Expand Up @@ -71,4 +72,23 @@ router.post("/register", async (req, res) => {
}
});

// “痕迹抹除”:删除当前登录用户及其所有记录
// (user_room / message / note 等通过数据库外键 ON DELETE CASCADE 一并删除)
router.get("/delete", authenticate, async (req, res) => {
const uuid = res.locals.uuid as string | undefined;
if (!uuid) {
return res.status(401).send("401 Unauthorized: Missing or invalid token payload");
}
try {
const mutationResult = await graphql.deleteUserByUuid({ uuid: uuid });
if (!mutationResult.delete_user_by_pk) {
return res.status(404).send("404 Not Found: User does not exist");
}
return res.send("User and all related records deleted successfully");
} catch (err) {
console.error(err);
return res.sendStatus(500);
}
});

export default router;
85 changes: 85 additions & 0 deletions database/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,88 @@ _一般来说,一个实体对应一张表,多对多的关系也可对应一
| | created_at | timestamp | | |

注:由于使用的是 PostgreSQL,其`text`类型指长度可变的字符串,与其他数据库可能不同([PostgreSQL: Documentation: 16: Chapter 8. Data Types](https://www.postgresql.org/docs/current/datatype.html))

---

## 作业补充:会议便签纸(选项 C)

> 需求:用户可以为每个会议创建一张便签纸,便签纸仅创建者本人可见。

### 1. 分析需求

在原有「用户注册、登录 / 会议创建、加入 / 会议中的聊天室」基础上新增:

- 用户在某会议中拥有一张自己的便签纸,可在上面记录要点与待办
- 同一用户在同一会议只有一张便签纸;不同用户的便签互相不可见

### 2.1 标识实体

- 用户(用户名、密码)
- 会议(名称、介绍、邀请码、创建时间)
- **便签(内容、创建时间、修改时间)** ← 新增

### 2.2 标识关系

- 用户加入会议(用户——会议)
- 用户在会议中发出消息(用户——会议——消息)
- **用户在某会议拥有便签纸(用户——会议——便签)** ← 新增
- “每个会议一张”意味着约束 `(user_uuid, room_uuid)` 唯一

### 2.3 E-R 图

```mermaid
erDiagram
user{
String username
String password
}
room{
String name
String intro
String invite_code
Time created_at
}
message{
String content
Time created_at
}
note{
String content
Time created_at
Time updated_at
}
user }|--o{ room : join
user ||--o{ message : send
room ||--o{ message : contain
user ||--o{ note : own
room ||--o{ note : contain
```

### 3.1 新增表设计

| 表 | 字段 | 数据类型 | 主键 | 外键 | 约束 |
| ---- | ---------- | --------- | ---- | --------- | --------------------------------------------- |
| note | id | uuid | 是 | | 默认 `gen_random_uuid()` |
| | user_uuid | uuid | | user.uuid | 级联删除/更新;`(user_uuid, room_uuid)` 唯一 |
| | room_uuid | uuid | | room.uuid | 级联删除/更新 |
| | content | text | | | |
| | created_at | timestamp | | | 默认当前时间 |
| | updated_at | timestamp | | | 默认当前时间,更新时自动刷新 |

### 4. 仅自己可见的两种保障

1. 数据库层:便签行包含 `user_uuid`(属主),查询与修改都带上属主条件;
2. API 层(Hasura):`user` 角色的 `insert/select/update/delete` 权限均绑定会话变量
`X-Hasura-User-Id`,且 `insert` 使用 `set: { user_uuid: X-Hasura-User-Id }`,
客户端无法伪造属主。配置文件见
`database/hasura/metadata/databases/workshop/tables/public_note.yaml`。

### 5. 落地文件

| 文件 | 说明 |
| ---- | ---- |
| `database/sql/note.sql` | PostgreSQL 版建表 + 触发器 + 示例数据(Hasura/Neon 环境执行) |
| `database/sql/note_mysql.sql` | 同设计的 MySQL 9.x 版(本机实测通过) |
| `database/graphql/note.graphql` | 创建/查询/修改/删除便签的 GraphQL 操作 |
| `database/hasura/metadata/databases/workshop/tables/public_note.yaml` | note 表的 Hasura 元数据与权限 |

53 changes: 53 additions & 0 deletions database/graphql/note.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 便签纸由 Hasura 的 insert set 自动写入当前登录用户(见 public_note.yaml),
# 客户端无需也不能传 user_uuid,因此仅需要 room_uuid 与 content。

mutation createNote($room_uuid: uuid!, $content: String!) {
insert_note_one(object: {room_uuid: $room_uuid, content: $content}) {
id
room_uuid
content
created_at
updated_at
}
}

query getMyNoteInRoom($room_uuid: uuid!) {
note(where: {room_uuid: {_eq: $room_uuid}}) {
id
content
created_at
updated_at
room {
uuid
name
}
}
}

query getMyNotes {
note(order_by: {updated_at: desc}) {
id
room_uuid
content
created_at
updated_at
room {
uuid
name
}
}
}

mutation updateNote($id: uuid!, $content: String!) {
update_note_by_pk(pk_columns: {id: $id}, _set: {content: $content}) {
id
content
updated_at
}
}

mutation deleteNote($id: uuid!) {
delete_note_by_pk(id: $id) {
id
}
}
6 changes: 6 additions & 0 deletions database/graphql/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ mutation addUser($username: String!, $password: String!) {
}
}

mutation deleteUserByUuid($uuid: uuid!) {
delete_user_by_pk(uuid: $uuid) {
uuid
}
}

query getUsersByUsername($username: String!) {
user(where: {username: {_eq: $username}}) {
uuid
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
table:
name: note
schema: public
object_relationships:
- name: room
using:
foreign_key_constraint_on: room_uuid
- name: user
using:
foreign_key_constraint_on: user_uuid
insert_permissions:
- role: user
permission:
check: {}
set:
user_uuid: X-Hasura-User-Id
columns:
- content
- room_uuid
comment: 便签仅能作为当前登录用户创建(user_uuid 由会话变量写入)
select_permissions:
- role: user
permission:
columns:
- content
- created_at
- updated_at
- id
- room_uuid
- user_uuid
filter:
user_uuid:
_eq: X-Hasura-User-Id
comment: 只能看到自己创建的便签(仅自己可见)
update_permissions:
- role: user
permission:
columns:
- content
filter:
user_uuid:
_eq: X-Hasura-User-Id
check:
user_uuid:
_eq: X-Hasura-User-Id
comment: 只能修改自己便签的内容
delete_permissions:
- role: user
permission:
filter:
user_uuid:
_eq: X-Hasura-User-Id
comment: 只能删除自己的便签
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
- "!include public_message.yaml"
- "!include public_note.yaml"
- "!include public_room.yaml"
- "!include public_user.yaml"
- "!include public_user_room.yaml"
38 changes: 38 additions & 0 deletions database/sql/note.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- PostgreSQL
-- 第11讲作业(选项 C:会议便签纸)
-- 需求:用户可以为每个会议创建一张便签纸,便签纸仅自己可见。
-- * 每人每会议最多一张:由 UNIQUE(user_uuid, room_uuid) 保证
-- * 仅创建者本人可见:由 Hasura 权限层(session 变量 X-Hasura-User-Id)
-- 控制,见 database/hasura/metadata/databases/workshop/tables/public_note.yaml
-- 本文件依赖 user.sql、room.sql 已先执行(外键引用 user、room)。
create table if not exists public.note (
id uuid default gen_random_uuid() not null,
user_uuid uuid not null,
room_uuid uuid not null,
content text not null,
created_at timestamp default current_timestamp not null,
updated_at timestamp default current_timestamp not null,
primary key (id),
unique (user_uuid, room_uuid)
);
alter table public.note
add constraint note_user_uuid_fkey foreign key (user_uuid) references public.user (uuid) on update cascade on delete cascade;
alter table public.note
add constraint note_room_uuid_fkey foreign key (room_uuid) references public.room (uuid) on update cascade on delete cascade;

-- 触发器:修改便签内容时自动刷新 updated_at
create or replace function public.note_set_updated_at() returns trigger language plpgsql as $$
begin
new.updated_at := current_timestamp;
return new;
end;
$$;
create trigger trg_note_set_updated_at before update on public.note
for each row execute function public.note_set_updated_at();

insert into public.note (user_uuid, room_uuid, content) values
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000001', '大家好,我是张三!这是咱们房间的便签纸。待办:今天 15:00 讨论暑培答辩分组。'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-100000000001', '收到!我来准备演示用的投影和电脑。'),
('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-100000000001', '(王五)我负责记录会议纪要~'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000002', '(张三在聊天室1的私人便签)别忘了下周还书。'),
('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-100000000003', '(赵六)成语接龙战绩存档:心安理得→得过且过→过目不忘。');
Loading