小登口算(进阶篇)

  • ~18.26K 字
  • 次阅读
  • 条评论
  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. 后端
    5. 2.5. 未完待续
  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
user_item = {
"uid": uid,
"email": email,
"nickname": nickname,
"total": 0,
"competition_total": 0,
"competition_win": 0,
"qid": [],
"mistake": [],
}
user_table.put_item(Item=user_item)

同时对登录时的逻辑也稍作修改,使后端 API 返回 nickname ,并在 LoginView.vue 组件中通过 localStoragenickname 的值保存在浏览器

1
localStorage.setItem('nickname', response.data.nickname);

这样,就可以在 App.vue 中获取到 nickname 了,并且该值不会随页面的刷新而丢失

1
const nickname = localStorage.getItem('nickname') || ''

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

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

后端

未完待续

未完待续

程序相关

Github

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

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

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

DEMO

https://arith.223322.best

其他说明

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