小登口算(进阶篇)

  • ~33.52K 字
  • 次阅读
  • 条评论
  1. 1. 题目
    1. 1.1. 题目描述
    2. 1.2. 基本要求
    3. 1.3. 进阶要求
  2. 2. 实现过程【进阶篇】
    1. 2.1. 数据存储结构的设计
    2. 2.2. 云平台上的准备工作
      1. 2.2.1. DynamoDB
      2. 2.2.2. IAM
      3. 2.2.3. Lambda
      4. 2.2.4. API Gateway
      5. 2.2.5. Cloudfront
    3. 2.3. 用户注册与登录
      1. 2.3.1. 后端
      2. 2.3.2. 前端(注册)
      3. 2.3.3.
      4. 2.3.4. 前端(登录)
    4. 2.4. 用户信息的获取
      1. 2.4.1. 对先前代码的改动
      2. 2.4.2. 后端
      3. 2.4.3. 前端
    5. 2.5. 作答情况的上传
      1. 2.5.1. 前端
      2. 2.5.2. 后端
    6. 2.6. 错题本
      1. 2.6.1. 错题的收集、获取与移除
      2. 2.6.2. 错题展示页面
    7. 2.7. 未完待续
  3. 3. 程序相关
    1. 3.1. Github
    2. 3.2. DEMO
    3. 3.3. 其他说明

前端使用 Vue.jsVite 框架,使用 TypeScript 书写,
后端使用无服务器 Serverless 架构,基于 AWS Lambda ,使用 Python 书写,
数据库使用 Amazon DynamoDB
程序的完整代码在 Github 使用 MIT 协议开源,相关信息可以在文末找到,供一起学习的小伙伴们参考~
文中的代码还有很多很多的不足,如果屏幕前的你有更好的建议,欢迎在评论区提出,或是去 Github 提一个 pr ~

本文为进阶要求的实现过程,基本要求请转到【基础篇】

题目

题目描述

老王家里的小登玩口算被外挂虐爆,于是老王想自研一款口算软件,满足他家小登的好学心。请你帮帮老王,为他写一款口算程序。

基本要求

  1. 题目范围包括100以内的加减乘除,除法必须能整除,题目随机生成。
  2. 有两种模式,固定题目数量计算用时,以及固定时间计算做题数量。
  3. 固定题目数量计算用时为:固定30道题目,计算用时,超过15分钟强行中止作答,最后以正确率的百分数加剩余时间为得分。
  4. 固定时间计算做题数量为:固定3分钟,以做对的题目数量乘以正确率为得分。
  5. 需要保存每次测试的日期时间、模式、题目和分数。

进阶要求

  1. 新增多用户功能,程序可以通过用户名和密码注册及登录,每个用户的数据分开存储。
  2. 新增错题本功能,程序支持查看本人错题本中的题目。做错的题目自动加入错题本,在进行任何练习时,优先从错题本中抽取题目,如果答对则从错题本中移除。
  3. 新增热座PK功能,两种模式都支持两名用户的热座PK。热座PK指的是:出相同的题目,两个小登依次上机测试,然后对得分进行排名。需要保存用户的PK场数和胜率。
  4. 小登精通电脑,请你想办法让他没法乱改你保存的数据,特别是场数和胜率。

实现过程【进阶篇】

啊呀,要写后端了呢(→_→)

数据存储结构的设计

受 DynamoDB 无法通过其他属性对主键进行快速查询的特性影响,若通过一张表来存储全部的内容,在使用中反复的遍历会造成响应速度下降的问题,同时带来多余的费用开销。因此,设计多个表将会是更高效的一种方式,如下图

数据库

在图中已经包含了表名,主键(分区键)名称,其他属性名称,以及一些基本的处理逻辑

云平台上的准备工作

小登乱改?我选择直接上云(

进阶要求 4 达成!(真的假的?)

DynamoDB

在 DynamoDB 上分别创建名称为 Oral-Arithmetic-Auth, Oral-Arithmetic-Session, Oral-Arithmetic-User, Oral-Arithmetic-Quiz 的四张表,并按照上一步骤中的设计填入分区键,排序键这里用不到

其中,为便于用户使用和记忆,将 uid 设计为纯数字,因此Oral-Arithmetic-User 的分区键 uid 值类型应设为“数字”,其余为“字符串”

DynamoDB

在创建完成后,全部表如下图所示

DynamoDB

IAM

在 AWS 的 IAM 控制台创建一个角色,服务选择 Lambda

IAM

搜索并附加 AmazonDynamoDBFullAccessAWSLambdaBasicExecutionRole 两个托管策略

IAM

名称填写 Oral-Arithmetic ,随后创建角色

IAM

创建完成后应如图所示

IAM

Lambda

在 AWS Lambda 上,分别创建 Oral-Arithmetic-Auth, Oral-Arithmetic-User, Oral-Arithmetic-Quiz 3个函数,分别用于处理用户的注册与登录,用户信息的查询与管理,题目的记录和管理

由于后端使用 Python 编写,在这里选择 Python 3.13 运行时,为方便调试,架构选择 x86_64 ,执行角色选择 使用现有角色 并找到刚刚创建的 IAM 角色

Lambda

在附加选项中勾选 启用函数 URL ,别忘记将授权类型改为 NONE 并配置 CORS ,都设置好就可以创建啦

Lambda

在创建成功后可以点击函数 URL 进行测试,若返回 "Hello from Lambda!" 则创建成功

Lambda

API Gateway

在 API Gateway 中创建一个 HTTP API ,名称依然是 Oral-Arithmetic ,集成选择创建的三个 Lambda 函数

API Gateway

路由暂时全部设置为 ANY

API Gateway

其余不需要操作,直接创建

API Gateway

Cloudfront

项目总得有个域名吧~我们通过 Cloudfront CDN 将域名和 API 关联起来

在 Cloudfront 中,源站选择创建的 API Gateway ,会自动匹配分配域名。由于 API Gateway 的分配域名是支持 HTTPS 的,因此协议选择 仅 HTTPS 即可

Cloudfront

其他选项可以不用改,默认就好,另外注意启用 Web 应用程序防火墙(WAF)会单独收费,因此这里不启用

在最后的设置中填写备用域名为自己的域名①,然后点下方的请求证书②跳转到 Certificate Manager

Cloudfront

在 Certificate Manager 中,我们可以请求一个通配符证书

Certificate Manager

请求后按照页面提示进行 CNAME 解析验证,添加解析后稍等约2分钟即可完成证书签发,签发完成后回到 Cloudfront ,点击刷新按钮③,稍后便可在左侧找到刚刚创建的证书④

一切准备就绪,按下创建按钮吧~

创建成功后可以找到一个分配域名,将域名设置 CNAME 解析至分配域名就可以用啦

Cloudfront

用户注册与登录

后端

在 Python 文件中, 先对 DynamoDB 进行初始化

1
dynamodb = boto3.resource('dynamodb')

随后对表名进行统一定义,便于修改和使用

1
2
3
4
AUTH_TABLE = 'Oral-Arithmetic-Auth'
SESSION_TABLE = 'Oral-Arithmetic-Session'
USER_TABLE = 'Oral-Arithmetic-USER'
QUIZ_TABLE = 'Oral-Arithmetic-Quiz'

在 Lambda 的入口函数 lambda_handler 中,event 参数已经携带了每一次请求的各项内容,因此可以很方便的引用

这里将注册和登录的状态放在 URL 参数中,例如 https://arith-api.223322.best/auth?type=register ,可以通过以下方式读到

1
event_type = event["queryStringParameters"]["type"]

可以加一个错误处理

1
2
3
4
5
6
7
try:
event_type = event["queryStringParameters"]["type"]
except KeyError:
return {
"statusCode": 400,
"body": json.dumps({"message": "Bad Request: Missing parameter"}),
}

然后我们将用户的相关数据放在消息体中进行 POST 请求,比如注册时的消息体是这样的:

1
2
3
4
5
{
"email": "email",
"nickname": "nickname",
"password": "password"
}

登录时又是这样的:

1
2
3
4
{
"email": "email",
"password": "password"
}

在 AWS Lambda 中,可以通过以下方式获得消息体的内容

1
body = event["body"]

不过这里要注意一下,body 部分有时会被 Base64 加密,但好在 AWS Lambda 在 event 中给了一个 key 为 isBase64Encoded 的布尔值,所以可以这样写啦

1
2
3
4
5
6
7
8
9
body = (
(
json.loads(base64.b64decode(event["body"]).decode("utf-8"))
if event.get("isBase64Encoded")
else event["body"]
)
if "body" in event
else None
)

在得到了请求体之后,自然而然就可以解析需要的内容了

1
2
3
email = body.get("email", None) if body else None
nickname = body.get("nickname", None) if body else None
password = body.get("password", None) if body else None

嗯…真的很怕有人点炒饭( ̄▽ ̄)”

接下来,就是注册与登录相关逻辑的编写啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def register(email: str, nickname: str, password: str) -> None:
"""
注册

:param email: 邮箱
:param nickname: 昵称
:param password: 密码
:raise ValueError: 参数为空或邮箱已存在
"""
# 检查参数是否为空
if not email or not nickname or not password:
raise ValueError("Missing parameter")

# 定义数据表
auth_table = dynamodb.Table(AUTH_TABLE)
user_table = dynamodb.Table(USER_TABLE)

# 检查邮箱是否存在
if auth_table.get_item(Key={"email": email}).get("Item"):
raise ValueError("Email already exists")

# 对密码进行加密
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())

# 生成 UID
while True:
uid = int(str(uuid.uuid4().int)[:8])
print(uid)
data = user_table.get_item(Key={"uid": uid})
if "Item" in data:
continue
else:
break

# 存入 DynamoDB
auth_item = {
"email": email,
"password": hashed_password.decode("utf-8"),
"uid": uid,
}
auth_table.put_item(Item=auth_item)
user_item = {"uid": uid, "email": email, "nickname": nickname}
user_table.put_item(Item=user_item)

在这段代码中,对重复邮箱和 UID 进行了排除,同时使用 bcrypt 对密码进行了随机加盐加密,确保了密码储存的安全性(bcrypt 库需要安装)

因此,相应的,在登录时也要使用 bcrypt 对密码进行加密,然后将加密后的内容与数据库进行对比,来判断密码是否正确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def login(email: str, password: str) -> [str, int]:
"""
登录

:param email: 邮箱
:param password: 密码
:return: 用于 Cookie 的 session 值和有效期
:raise ValueError: 参数为空或邮箱密码错误。
"""
# 检查参数是否为空
if not email or not password:
raise ValueError("Missing parameter")

# 定义数据表
auth_table = dynamodb.Table(AUTH_TABLE)
session_table = dynamodb.Table(SESSION_TABLE)

# 获取用户数据
data = auth_table.get_item(Key={"email": email}).get("Item")

# 验证用户名和密码
if not data or not bcrypt.checkpw(
password.encode("utf-8"), data["password"].encode("utf-8")
):
raise ValueError("Invalid Email or Password")

uid = data.get("uid")

# 通过 UID 与时间戳和随机数生成初始 session
timestamp = int(time.time())
random_number = str(random.randint(1000, 9999))
session_raw = f"{uid}{str(timestamp)}{random_number}".encode("utf-8")

# 生成 session
session = hashlib.sha256(session_raw).hexdigest()
expiration = 604800 # 有效期一周

# 将 token 存储在 DynamoDB
session_table.put_item(
Item={"session": session, "uid": uid, "expiration": timestamp + expiration}
)

return session, expiration

在登录部分创建了一个名为 session 的 Cookie 作为登录凭据,并存入相应数据表,后续调用时可以通过 session 来确定用户的身份

接下来对 Lambda 入口函数进行完善,Auth 模块就基本完成啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def lambda_handler(event, context):
# 获取事件类型
try:
event_type = event["queryStringParameters"]["type"]
except KeyError:
return {
"statusCode": 400,
"body": json.dumps({"message": "Missing parameter"}),
}

# 解析请求体
body = (
(
json.loads(base64.b64decode(event["body"]).decode("utf-8"))
if event.get("isBase64Encoded")
else event["body"]
)
if "body" in event
else None
)
email = body.get("email", None) if body else None
nickname = body.get("nickname", None) if body else None
password = body.get("password", None) if body else None

# 注册
if event_type == "register":
try:
register(email, nickname, password)
return {"statusCode": 201, "body": "Success"}
except ValueError as e:
return {"statusCode": 400, "body": json.dumps({"message": str(e)})}

# 登录
if event_type == "login":
try:
session, expiration = login(email, password)
return {
"statusCode": 201,
"headers": {
"Set-Cookie": f"session={session}; Path=/; Max-Age={expiration}; Secure"
},
"body": "Cookie Set",
}
except ValueError as e:
return {"statusCode": 400, "body": json.dumps({"message": str(e)})}

return {"statusCode": 400, "body": ({"message": "Invalid parameter"})}

Request

前端(注册)

当然是先创建一个表单来承载用户的输入啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div>
<h2>注册</h2>
<input v-model="email" type="text" placeholder="邮箱" @blur="emailSelected = true" :class="{ 'invalid': emailSelected && ( !isEmailValid || !email ) }" required/><br />
<input v-model="nickname" type="text" placeholder="昵称" @blur="nicknameSelected = true" :class="{ 'invalid': nicknameSelected && !nickname }" required/><br />
<div class="password-container">
<input v-model="password" type="password" placeholder="密码" @blur="passwordSelected = true" :class="{ 'invalid': passwordSelected && ( !isPasswordValid || !password ) }" required/>
<div class="tooltip">
<p :class="{ valid: isPasswordLongEnough, invalid: !isPasswordLongEnough }">至少8个字符</p>
<p :class="{ valid: hasUpperCase, invalid: !hasUpperCase, unnecessary: !hasUpperCase && isPasswordValid }">包含大写字母</p>
<p :class="{ valid: hasLowerCase, invalid: !hasLowerCase, unnecessary: !hasLowerCase && isPasswordValid }">包含小写字母</p>
<p :class="{ valid: hasNumber, invalid: !hasNumber, unnecessary: !hasNumber && isPasswordValid }">包含数字</p>
</div>
</div><br />
<input v-model="confirmPassword" type="password" placeholder="再次输入密码" @blur="confirmPasswordSelected = true" :class="{ 'invalid': confirmPasswordSelected && ( !confirmPassword || confirmPassword !== password ) }" required/><br />
<button @click="register" :disabled="!email || !nickname || !password || !confirmPassword || registerStatus == 'clicked'" :class="{ 'disabled': !email || !nickname || !password || !confirmPassword }">注册</button>
<div v-if="registerStatus == 'clicked'" class="status-clicked">请稍后...</div>
<div v-if="registerStatus == 'success'" class="status-success">注册成功,3秒后跳转至登录页</div>
<div v-if="registerStatus == 'failed'" class="status-failed">注册失败</div>
</div>

在这里,通过 vuecomputed 组件可以实现判断用户的输入是否合法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const isEmailValid = computed(() => {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email.value);
});

const hasUpperCase = computed(() => /[A-Z]/.test(password.value));
const hasLowerCase = computed(() => /[a-z]/.test(password.value));
const hasNumber = computed(() => /[0-9]/.test(password.value));
const isPasswordLongEnough = computed(() => password.value.length >= 8);

const isPasswordValid = computed(() => {
const validCount = [hasUpperCase.value, hasLowerCase.value, hasNumber.value].filter(Boolean).length;
return validCount >= 2 && isPasswordLongEnough.value;
});

对于用户输入的密码,可以通过 crypto-js 进行一次 SHA256 加密来确保密码在数据传输过程的安全

毕竟判断用户密码是否正确并不依赖于原始密码,相同的原始密码经过 SHA256 加密后的值仍然相同

1
2
3
import CryptoJS from 'crypto-js';

const encryptedPassword = CryptoJS.SHA256(password.value).toString();

随后就是发起请求了,这里我引入了 axios

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const register = async () => {
registerStatus.value = "clicked";

if (!process.env.API_URL) {
console.error('API_URL is not defined');
alert('未找到环境变量 API_URL');
return;
}

try {
const encryptedPassword = CryptoJS.SHA256(password.value).toString(); // 对原始密码进行 SHA256 加密
const response = await axios.post(`${process.env.API_URL}/auth?type=register`, {
email: email.value,
nickname: nickname.value,
password: encryptedPassword
}, {
withCredentials: true
});
console.log(response.data);
registerStatus.value = "success";
setTimeout(async () => {
await router.push('/login');
}, 3000); // 等待 3 秒后跳转到登录页
} catch (error) {
console.error(error);
registerStatus.value = "failed";
}
};

看上去似乎大功告成了,就在前后端共同测试的时候,一个折磨了我一下午的坑出现了

Request

CORS 预检响应未能成功,跨源请求被拦截

起初我以为是 Cloudfront 的锅,把 Cloudfront 的响应标头策略改为了 SimpleCORS

但是…无济于事…

我又修改了 API Gateway 的 CORS

API Gateway

依然是那一行红色的 CORS Preflight Did Not Succeed

查遍了互联网的相关内容,与 API Gateway、Lambda、Cloudfront 相关的内容少之又少,况且我已尝试过修改 API Gateway 和 Cloudfront 的配置,直到我找到了一个说法:

Troy's 博客

“把请求类型OPTIONS做个简单的过滤就好啦!!!”

也是,毕竟预检请求属于 OPTIONS 方法,于是,我在 Lambda 的入口函数处写下了这样的处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def lambda_handler(event, context):
# 获取 HTTP 请求方法
http_method = event["requestContext"]["http"]["method"]

# 处理 OPTIONS 请求
if http_method == "OPTIONS":
return {
"statusCode": 200,
"headers": {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Credentials": True
},
"body": "",
}

Request

似乎有了效果,预检请求正常了,但是 POST 还有 CORS Missing Allow Origin 的错误

那么照猫画虎,把所有的响应都加上 CORS 标头吧~

Request

响应一切正常,注册成功!看到那一行绿色的字的时候我差点跳起来

你 AWS 不愧是 AWS ,强大到到处都是坑 (我菜)

前端(登录)

注册都写好了登录不是照猫画虎嘛.webp

同样的加密,发起请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const login = async () => {
loginStatus.value = "clicked";

if (!process.env.API_URL) {
console.error('API_URL is not defined');
alert('未找到环境变量 API_URL');
return;
}

try {
const encryptedPassword = CryptoJS.SHA256(password.value).toString(); // 对原始密码进行 SHA256 加密
const response = await axios.post(`${process.env.API_URL}/auth?type=login`, {
email: email.value,
password: encryptedPassword
}, {
withCredentials: true
});
console.log(response.data);
loginStatus.value = "success";
setTimeout(async () => {
await router.push('/');
}, 3000); // 等待 3 秒后跳转到主页
} catch (error) {
console.error(error);
loginStatus.value = "failed";
}
};

同样的模板

1
2
3
4
5
6
7
8
9
<div>
<h2>登录</h2>
<input v-model="email" type="text" placeholder="邮箱" required/><br />
<input v-model="password" type="password" placeholder="密码" required/><br />
<button @click="login" :disabled="!email || !password" :class="{ 'disabled': !email || !password || loginStatus == 'clicked' }">登录</button>
<div v-if="loginStatus == 'clicked'" class="status-clicked">请稍后...</div>
<div v-if="loginStatus == 'success'" class="status-success">登录成功,3秒后跳转至主页</div>
<div v-if="loginStatus == 'failed'" class="status-failed">登录失败</div>
</div>

Request

看上去没什么问题了呢,那么…

进阶要求 1 达成!

用户信息的获取

对先前代码的改动

其实在上一个部分留下了一个小坑,在用户注册的时候并没有将 Oral-Arithmetic-User 表的键一次写入,只写入了相关部分,此时强行读取不存在的键值可能会出现问题。因此为了简化读取过程,需要对注册过程写入数据库的部分稍作修改

1
2
3
4
5
6
7
8
9
10
11
12
user_item = {
"uid": uid,
"email": email,
"nickname": nickname,
"avatar": "",
"total": 0,
"competition_total": 0,
"competition_win": 0,
"qid": [],
"mistake": [],
}
user_table.put_item(Item=user_item)

同时对登录时的逻辑也稍作修改,使后端 API 返回 nickname session expiration

1
2
3
4
5
6
7
8
"body": json.dumps(
{
"message": "Cookie Set",
"session": session,
"expiration": expiration,
"nickname": nickname,
}
),

LoginView.vue 组件中通过 localStoragenickname 的值保存在浏览器,同时设置 Cookie

1
2
localStorage.setItem('nickname', response.data.nickname);
document.cookie = `session=${response.data.session}; Path=/; Max-Age=${response.data.expiration}; Secure; SameSite=None`;

再写一个获取 Cookie 的函数

1
2
3
4
5
6
export const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};

这样,就可以在其他组件中很方便地获取到 nickname 了,并且在 session 有效时,该值不会随页面的刷新而丢失

1
2
const session = getCookie('session') || '';
const nickname = session ? localStorage.getItem('nickname') : '';

同时也可以实现登录后隐藏注册和登录按钮,显示用户名并将其作为用户信息页面的入口

1
2
3
4
5
<div class="auth-links">
<RouterLink to="/login" class="nav-link" active-class="active" v-if="!session">登录</RouterLink>
<RouterLink to="/register" class="nav-link" active-class="active" v-if="!session">注册</RouterLink>
<RouterLink to="/user" class="nav-link" active-class="active" v-if="session">{{ nickname }}</RouterLink>
</div>

后端

在登录后有了 Cookie ,那么随后的请求便可以通过 Cookie 来快速判断用户以及登录状态

AWS Lambda 已经对 Cookie 进行了处理,并以列表形式储存,格式大概是这样的

1
['session=a2a85ccf1229199800259113fc4fc2c5774c0a6cc5ed7b657eb66a1a8b3be1c3']

因此要想读取 session 的值,需要再次进行处理,然后就可以查表来得到对应的 UID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def get_uid_from_cookie(cookie: dict) -> int:
"""
通过 Cookie 获取 UID

:param cookie: Cookie
:return: UID
"""
# 检查参数是否为空
if not cookie:
raise ValueError("Missing parameter")

# 解析 Cookie
cookie_dict = {i.split("=")[0].strip(): i.split("=")[1].strip() for i in cookie}
session = cookie_dict.get("session")

# 定义数据表
session_table = dynamodb.Table(SESSION_TABLE)

# 获取 UID
data = session_table.get_item(Key={"session": session})
if "Item" in data:
expiration = data["Item"].get("expiration")
if expiration < int(time.time()):
raise ValueError("Session expired")
return data["Item"].get("uid")
else:
raise ValueError("Missing parameter")

这样一来,就可以获取用户数据啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get(uid: int) -> dict:
"""
获取用户数据

:param uid: UID
"""
# 检查参数是否为空
if not uid:
raise ValueError("Missing parameter")

# 定义数据表
user_table = dynamodb.Table(USER_TABLE)

# 读取 DynamoDB
data = user_table.get_item(Key={"uid": uid})

if "Item" in data:
userdata = data["Item"]
userdata = json.loads(json.dumps(userdata, default=str))
return userdata
else:
raise ValueError("Missing parameter")

前端

先初始化 ref 便于模板使用

1
2
3
4
5
6
7
8
9
const uid = ref('');                                      // 用户 ID
const email = ref(''); // 邮箱
const nickname = ref<string | null>(null); // 昵称
const avatar = ref(''); // 头像
const total = ref(0); // 总场次
const competition_total = ref(0); // PK 总场次
const competition_win = ref(0); // PK 胜场次
const qid = ref<number[]>([]); // 测试 ID
const mistake = ref<[]>([]); // 错题

再创建一个 fetchUserData 函数,在函数内请求 API 并对相应 ref 值进行修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const fetchUserData = async () => {
if (!process.env.API_URL) {
console.error('API_URL is not defined');
alert('未找到环境变量 API_URL');
return;
}

try {
const response = await axios.get(`${process.env.API_URL}/user?type=get`, {
withCredentials: true
});
const userdata = response.data;
uid.value = userdata["uid"];
email.value = userdata["email"];
nickname.value = userdata["nickname"];
avatar.value = userdata["avatar"];
total.value = userdata["total"];
competition_total.value = userdata["competition_total"];
competition_win.value = userdata["competition_win"];
qid.value = userdata["qid"];
mistake.value = userdata["mistake"];
} catch (error) {
console.error(error);
}
};

最后通过一个事件钩子完成在登录状态下进入页面时的自动加载

1
2
3
4
5
onMounted(() => {
if (session) {
fetchUserData();
}
});

大致写个模板,用表格形式来呈现内容,搞定~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<div class="profile-container">
<img :src="`${avatar}`" class="profile-avatar" alt=""/>
<div class="profile-info">
<h2>欢迎,{{ nickname }}</h2>
<table class="profile-table">
<tr>
<td>UID:</td>
<td>{{ uid }}</td>
</tr>
<tr>
<td>邮箱:</td>
<td>{{ email }}</td>
</tr>
<tr>
<td>总场次:</td>
<td>{{ total }}</td>
</tr>
<tr>
<td>PK 总场次:</td>
<td>{{ competition_total }}</td>
</tr>
<tr>
<td>PK 胜场次:</td>
<td>{{ competition_win }}</td>
</tr>
</table>
</div>
</div>
</template>

在这里,图片使用了 Base64 格式编码以便于储存和使用

效果大概是这个样子啦~

User

作答情况的上传

前端

在先前基础篇的步骤中,已经实现了下载 JSON 来保存作答情况,那么上传对应的 JSON 即可

在上传前,先通过查找 Cookie 中 session 是否存在来判断用户是否登录,Cookie 过期会被浏览器自动清除,因此该方法对判断 session 是否有效同样适用

1
2
3
const session = getCookie('session') || '';

if (!session) return console.info('Not logged in');

为方便复用,将 axios 相关的请求部分剥离成一个单独的文件 request.ts 放入 /src/utils,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import axios from 'axios';
import { getCookie } from '@/utils/cookie';

const sendRequest = async (method: 'get' | 'post', url: string, data: unknown, errorMessage: { value: string }) => {
const session = getCookie('session') || '';
if (!session) return console.info('Not logged in');
if (!process.env.API_URL) return alert('未找到环境变量 API_URL');

try {
const response = await axios({ method, url: `${process.env.API_URL}${url}`, data, withCredentials: true });
return response.data;
} catch (error) {
console.error(error);
errorMessage.value = axios.isAxiosError(error) && error.response?.data?.message || "请求失败";
}
};

export const postData = async (url: string, data: unknown, errorMessage: { value: string }) => {
await sendRequest('post', url, data, errorMessage);
};

export const fetchData = async (url: string, errorMessage: { value: string }) => {
return await sendRequest('get', url, null, errorMessage);
};

然后在其他组件引用这个模块即可

1
import { postData, fetchData } from '@/utils/request';

那么可以创建一个 uploadSummary 函数并在 stopQuiz 函数调用,如果用户登录,则在答题终止时自动进行上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const getQuizSummary = () => {
const mode = selectedTimeLimit.value === timeLimit[0] ? 'quantity' : 'time';

return {
questions: questionsDetails.value,
correctCount: correctCount.value,
questionCount: questionCount.value,
startTime: startTime.value,
elapsedTime: elapsedTime.value,
score: score.value,
mode: mode
};
};

const uploadSummary = async () => {
const quizSummary = getQuizSummary();
await postData('/quiz?type=save_quiz', quizSummary, errorMessage);
};

后端

在后端首先通过解析 Cookie 得到 session ,然后查询 UID,与先前逻辑一致

在得到 UID 后,生成 qid 并将作答情况写入数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
while True:
qid = str(uuid.uuid4())
data = quiz_table.get_item(Key={"qid": qid})
if "Item" in data:
continue
else:
break

# 存入 DynamoDB
quiz_item = {
"qid": qid,
"mode": mode,
"quiz_time": quiz_time,
"questions": questions,
"question_count": question_count,
"correct_count": correct_count,
"used_time": used_time,
"is_competition": is_competition,
"allow_competition": allow_competition,
"p1_uid": uid,
"p2_uid": [],
}
quiz_table.put_item(Item=quiz_item)

再将 qid 存入 user 表中,实现作答情况与用户的对应,同时更新场次数据

1
2
3
4
5
6
7
# 更新用户数据,将 qid 添加到 qid 列表中,并使总场数 +1
user_table.update_item(
Key={"uid": uid},
UpdateExpression="SET qid = list_append(if_not_exists(qid, :empty_list), :qid), #total = #total + :increment",
ExpressionAttributeNames={"#total": "total"},
ExpressionAttributeValues={":qid": [qid], ":empty_list": [], ":increment": 1},
)

错题本

错题的收集、获取与移除

在基础篇中已经通过 QuestionDetail 这个接口来储存单个问题的题目信息,那么在判断正误的函数 checkAnswer 中,可以对错误的题目进行上传,来实现了错题的收集,同时对于再次做对的错题,也可以做移出处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (parseFloat(userAnswer.value) === correctAnswer.value) {
feedback.value = '做对啦!';
correctCount.value++;
if (isMistake.value) {
removeMistake({
question: question.value,
userAnswer: userAnswer.value,
correctAnswer: correctAnswer.value,
isCorrect: true
});
}
} else {
feedback.value = `再想想呢...正确答案是 ${correctAnswer.value} 哦`;
if (!isMistake.value) {
uploadMistake({
question: question.value,
userAnswer: userAnswer.value,
correctAnswer: correctAnswer.value,
isCorrect: false
});
}
}

上传部分也很简单

1
2
3
4
5
6
7
const uploadMistake = async (questionDetail: QuestionDetail) => {
await postData('/quiz?type=save_mistake', questionDetail, errorMessage);
};

const removeMistake = async (questionDetail: QuestionDetail) => {
await postData('/quiz?type=remove_mistake', questionDetail, errorMessage);
};

那怎样才能知道某一道题是错题呢?在做题前获取一下错题信息就可以啦

先创建一个 ref 用来存储是否为错题的状态

1
const isMistake = ref(false);                            // [状态]是否为错题

然后在 startQuiz 函数初始化状态后对后端发起请求,得到错题数据,之后再调用 generateQuestion 完成题目的生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface MistakeDetail {
question: string;
userAnswer: string;
correctAnswer: number | null;
}

const mistake = ref<MistakeDetail[]>([]); // 错题

const startQuiz = async () => {
started.value = true;
stopped.value = false;
startTime.value = Date.now();
elapsedTime.value = 0;
questionCount.value = 0;
correctCount.value = 0;
questionsDetails.value = [];
mistake.value = [];

try {
const data = await fetchData('/quiz?type=get_mistakes', errorMessage);
if (data) {
mistake.value = data;
}
} catch (error) {
console.error('Failed to fetch:', error);
}

generateQuestion();

...

}

generateQuestion 函数中加入对错题数据的处理,来实现错题的优先展示,若是错题,将 isMistake 的值改为 true 以便移除错题部分使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const generateQuestion = () => {
if (mistake.value.length > 0) {
const mistakeQuestion = mistake.value.pop();
if (mistakeQuestion) {
isMistake.value = true;
question.value = mistakeQuestion.question;
correctAnswer.value = Number(mistakeQuestion.correctAnswer);
userAnswer.value = '';
feedback.value = '';
answered.value = false;
return;
}
}

...

}

后端部分实现相应的数据库读取和编辑功能就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def save_mistake(
uid: int, question: str, user_answer: int, correct_answer: int
) -> None:
"""
保存错题

:param uid: 用户 ID
:param question: 题目
:param user_answer: 用户答案
:param correct_answer: 正确答案
"""
# 检查参数是否为空
if uid is None or not question or user_answer is None or correct_answer is None:
raise ValueError("Missing parameter")

# 定义数据表
user_table = dynamodb.Table(USER_TABLE)

# 错题记录
mistake = {
"question": question,
"userAnswer": user_answer,
"correctAnswer": correct_answer,
}

# 更新用户数据,将错题添加到 mistake 列表中
user_table.update_item(
Key={"uid": uid},
UpdateExpression="SET mistake = list_append(if_not_exists(mistake, :empty_list), :mistake)",
ExpressionAttributeValues={":mistake": [mistake], ":empty_list": []},
)


def remove_mistake(uid: int, question: str) -> None:
"""
移除错题

:param uid: 用户 ID
:param question: 题目
"""
# 检查参数是否为空
if uid is None or not question:
raise ValueError("Missing parameter")

# 定义数据表
user_table = dynamodb.Table(USER_TABLE)

# 获取用户数据
response = user_table.get_item(Key={"uid": uid})
if "Item" in response:
mistakes = response["Item"].get("mistake", [])
# 移除指定错题
mistakes = [mistake for mistake in mistakes if mistake["question"] != question]
# 更新用户数据
user_table.update_item(
Key={"uid": uid},
UpdateExpression="SET mistake = :mistakes",
ExpressionAttributeValues={":mistakes": mistakes},
)
else:
raise ValueError("错题不存在")


def get_mistakes(uid: int) -> list:
"""
获取错题

:param uid: 用户 ID
:return: 错题列表
"""
# 检查参数是否为空
if uid is None:
raise ValueError("Missing parameter")

# 定义数据表
user_table = dynamodb.Table(USER_TABLE)

# 获取用户数据
response = user_table.get_item(Key={"uid": uid})
if "Item" in response:
return response["Item"].get("mistake", [])
else:
raise ValueError("Missing parameter")

Mistake

错题展示页面

上面已经获取到错题列表了,用一个自动扩展的表格把内容存起来就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="mistake-table-wrapper">
<table class="mistake-table">
<thead>
<tr>
<th>题目</th>
<th>你的作答</th>
<th>正确答案</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in mistake" :key="index">
<td>{{ item.question }}</td>
<td>{{ item.userAnswer }}</td>
<td>{{ item.correctAnswer }}</td>
<td>
<button class="remove-button" @click="removeMistake(index)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>

Mistake

进阶要求 2 达成!

未完待续

未完待续

程序相关

Github

本程序源代码在 Github 平台完全开源

【链接 Link】: 【前端】Oral-Arithmetic 【后端】Oral-Arithmetic-Serverless
【协议 License】: MIT License

可以在 Github 点点右上角那颗小星星嘛?quq

DEMO

https://arith.223322.best

其他说明

后续的完善与更新将在 Github 平台进行,这里不再重复描述,可以前往前端后端对应的 Github Commits 页面查看具体内容