前端使用 Vue.js
和 Vite
框架,使用 TypeScript
书写, 后端使用无服务器 Serverless 架构,基于 AWS Lambda ,使用 Python
书写, 数据库使用 Amazon DynamoDB 程序的完整代码在 Github 使用 MIT
协议开源,相关信息可以在文末找到,供一起学习的小伙伴们参考~ 文中的代码还有很多很多的不足,如果屏幕前的你有更好的建议,欢迎在评论区提出,或是去 Github 提一个 pr ~
本文为进阶要求 的实现过程,基本要求 请转到【基础篇】
题目 题目描述 老王家里的小登玩口算被外挂虐爆,于是老王想自研一款口算软件,满足他家小登的好学心。请你帮帮老王,为他写一款口算程序。
基本要求
题目范围包括100以内的加减乘除,除法必须能整除,题目随机生成。
有两种模式,固定题目数量计算用时,以及固定时间计算做题数量。
固定题目数量计算用时为:固定30道题目,计算用时,超过15分钟强行中止作答,最后以正确率的百分数加剩余时间为得分。
固定时间计算做题数量为:固定3分钟,以做对的题目数量乘以正确率为得分。
需要保存每次测试的日期时间、模式、题目和分数。
进阶要求
新增多用户功能,程序可以通过用户名和密码注册及登录,每个用户的数据分开存储。
新增错题本功能,程序支持查看本人错题本中的题目。做错的题目自动加入错题本,在进行任何练习时,优先从错题本中抽取题目,如果答对则从错题本中移除。
新增热座PK功能,两种模式都支持两名用户的热座PK。热座PK指的是:出相同的题目,两个小登依次上机测试,然后对得分进行排名。需要保存用户的PK场数和胜率。
小登精通电脑,请你想办法让他没法乱改你保存的数据,特别是场数和胜率。
实现过程【进阶篇】 啊呀,要写后端了呢(→_→)
数据存储结构的设计 受 DynamoDB 无法通过其他属性对主键进行快速查询的特性影响,若通过一张表来存储全部的内容,在使用中反复的遍历会造成响应速度下降的问题,同时带来多余的费用开销。因此,设计多个表将会是更高效的一种方式,如下图
在图中已经包含了表名,主键(分区键)名称,其他属性名称,以及一些基本的处理逻辑
云平台上的准备工作 小登乱改?我选择直接上云(
DynamoDB 在 DynamoDB 上分别创建名称为 Oral-Arithmetic-Auth
, Oral-Arithmetic-Session
, Oral-Arithmetic-User
, Oral-Arithmetic-Quiz
的四张表,并按照上一步骤中的设计填入分区键,排序键这里用不到
其中,为便于用户使用和记忆,将 uid
设计为纯数字,因此Oral-Arithmetic-User
的分区键 uid
值类型应设为“数字”,其余为“字符串”
在创建完成后,全部表如下图所示
IAM 在 AWS 的 IAM 控制台创建一个角色,服务选择 Lambda
搜索并附加 AmazonDynamoDBFullAccess
和 AWSLambdaBasicExecutionRole
两个托管策略
名称填写 Oral-Arithmetic
,随后创建角色
创建完成后应如图所示
Lambda 在 AWS Lambda 上,分别创建 Oral-Arithmetic-Auth
, Oral-Arithmetic-User
, Oral-Arithmetic-Quiz
3个函数,分别用于处理用户的注册与登录,用户信息的查询与管理,题目的记录和管理
由于后端使用 Python
编写,在这里选择 Python 3.13
运行时,为方便调试,架构选择 x86_64
,执行角色选择 使用现有角色
并找到刚刚创建的 IAM 角色
在附加选项中勾选 启用函数 URL
,别忘记将授权类型改为 NONE
并配置 CORS
,都设置好就可以创建啦
在创建成功后可以点击函数 URL 进行测试,若返回 "Hello from Lambda!"
则创建成功
API Gateway 在 API Gateway 中创建一个 HTTP API
,名称依然是 Oral-Arithmetic
,集成选择创建的三个 Lambda 函数
路由暂时全部设置为 ANY
其余不需要操作,直接创建
Cloudfront 项目总得有个域名吧~我们通过 Cloudfront CDN 将域名和 API 关联起来
在 Cloudfront 中,源站选择创建的 API Gateway ,会自动匹配分配域名。由于 API Gateway 的分配域名是支持 HTTPS 的,因此协议选择 仅 HTTPS
即可
其他选项可以不用改,默认就好,另外注意启用 Web 应用程序防火墙(WAF)会单独收费,因此这里不启用
在最后的设置中填写备用域名为自己的域名①,然后点下方的请求证书②跳转到 Certificate Manager
在 Certificate Manager 中,我们可以请求一个通配符证书
请求后按照页面提示进行 CNAME 解析验证,添加解析后稍等约2分钟即可完成证书签发,签发完成后回到 Cloudfront ,点击刷新按钮③,稍后便可在左侧找到刚刚创建的证书④
一切准备就绪,按下创建按钮吧~
创建成功后可以找到一个分配域名,将域名设置 CNAME 解析至分配域名就可以用啦
用户注册与登录 后端 在 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 中,可以通过以下方式获得消息体的内容
不过这里要注意一下,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()) 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 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" ) timestamp = int (time.time()) random_number = str (random.randint(1000 , 9999 )) session_raw = f"{uid} {str (timestamp)} {random_number} " .encode("utf-8" ) session = hashlib.sha256(session_raw).hexdigest() expiration = 604800 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" })}
前端(注册) 当然是先创建一个表单来承载用户的输入啦
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>
在这里,通过 vue
的 computed
组件可以实现判断用户的输入是否合法
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 (); 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 ); } catch (error) { console .error (error); registerStatus.value = "failed" ; } };
看上去似乎大功告成了,就在前后端共同测试的时候,一个折磨了我一下午的坑出现了
坑
CORS 预检响应未能成功,跨源请求被拦截
起初我以为是 Cloudfront 的锅,把 Cloudfront 的响应标头策略改为了 SimpleCORS
但是…无济于事…
我又修改了 API Gateway 的 CORS
依然是那一行红色的 CORS Preflight Did Not Succeed
…
查遍了互联网的相关内容,与 API Gateway、Lambda、Cloudfront 相关的内容少之又少,况且我已尝试过修改 API Gateway 和 Cloudfront 的配置,直到我找到了一个说法:
“把请求类型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_method = event["requestContext" ]["http" ]["method" ] 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" : "" , }
似乎有了效果,预检请求正常了,但是 POST 还有 CORS Missing Allow Origin
的错误
那么照猫画虎,把所有的响应都加上 CORS 标头吧~
响应一切正常,注册成功!看到那一行绿色的字的时候我差点跳起来
你 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 (); 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 ); } 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>
看上去没什么问题了呢,那么…
用户信息的获取 对先前代码的一些改动 其实在上一个部分留下了一个小坑,在用户注册的时候并没有将 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
组件中通过 localStorage
将 nickname
的值保存在浏览器
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 页面查看具体内容