前端使用 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 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
组件中通过 localStorage
将 nickname
的值保存在浏览器,同时设置 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_dict = {i.split("=" )[0 ].strip(): i.split("=" )[1 ].strip() for i in cookie} session = cookie_dict.get("session" ) session_table = dynamodb.Table(SESSION_TABLE) 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) 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 ('' ); const email = ref ('' ); const nickname = ref<string | null >(null ); const avatar = ref ('' ); const total = ref (0 ); const competition_total = ref (0 ); const competition_win = ref (0 ); const qid = ref<number []>([]); 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
格式编码以便于储存和使用
效果大概是这个样子啦~
作答情况的上传 前端 在先前基础篇的步骤中,已经实现了下载 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 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 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, } 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" )
错题展示页面 上面已经获取到错题列表了,用一个自动扩展的表格把内容存起来就可以了
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>
未完待续 未完待续
程序相关 Github 本程序源代码在 Github 平台完全开源
【链接 Link】: 【前端】Oral-Arithmetic 【后端】Oral-Arithmetic-Serverless 【协议 License】: MIT License
可以在 Github 点点右上角那颗小星星嘛?quq
DEMO https://arith.223322.best
其他说明 后续的完善与更新将在 Github 平台进行,这里不再重复描述,可以前往前端 和后端 对应的 Github Commits 页面查看具体内容