Scratch二次开发:登录功能与JWT认证实现
Scratch二次开发做了几期从环境搭建到界面定制再到积木扩展每一步都有不少朋友在问。这期聊一个绕不开的话题登录功能。很多做少儿编程平台的团队前期的痛点基本都一样——学生做完的作品只能存在本地老师没法统一收集作业家长也看不到孩子的成长记录。想解决这些问题第一步就是把匿名用户变成实名用户说白了就是做登录。这篇会从后端接口到前端界面完整拆解一遍我在Scratch里加登录功能的思路和实操过程。如果你是第一次接触Scratch二次开发或者正准备给自己的平台加用户体系这篇应该能帮你省不少弯路。1. 项目概述与需求拆解1.1 登录功能要解决的真实问题先说说我为什么要在Scratch编辑器里做登录而不是做一个独立的登录页。之前有个做在线编程教育的模拟项目X学生用Scratch做完作品后作品数据只能存在浏览器本地换台电脑就全没了。老师在后台也看不到学生作品评改只能靠学生手动提交截图管理成本非常高。后来梳理了一下需求发现其实有三层第一层是认出用户——让每个学生有独立的账号作品归属到账号下。第二层是存取作品——学生登录后可以把作品保存到云端下次在任何设备上都能打开继续做。第三层是权限管理——老师能看全班作品学生只能看自己的管理员能看全部数据。这三层需求核心都是登录功能。但如果你只是做一个用户名密码输入框那种极简登录后面第二层第三层根本撑不起来。所以我在设计的时候直接把登录做成了一套完整的用户认证体系后面接数据库、接项目存储一步到位。1.2 技术方案选型为什么选这套组合Scratch编辑器也就是scratch-gui本身是用React和Redux写的所以前端的技术栈基本被锁死了——你想在里面加界面绕不开React组件。后端的选择就灵活多了。我试过用Java写接口也试过Python写后端最后稳定用下来的是Node.js Express。原因很简单前后端都是JavaScript语法统一联调的时候沟通成本低。虽然你用Java也能做但碰到一些数据类型的小问题不用来回切换思维方式明显顺手。数据库用的是MySQL。这个选择倒不是性能上有多强而是很多教育类项目的老系统都是MySQL新项目适配起来省事。如果你没有历史包袱用MongoDB或者PostgreSQL也完全没问题反正用户表和项目表的结构都很简单。认证方式选的是JWTJSON Web Token不用传统的Session。原因后面专门讲这里先给个结论前后端分离的架构下JWT比Session舒服得多。2. 环境准备与工程结构说明2.1 开发环境搭建做Scratch二次开发第一步肯定要把scratch-gui跑起来。我用的方式是直接克隆源码然后npm install装依赖。这个步骤看起来简单但有个坑——scratch-gui的依赖很多网络不好容易中途失败。我一般建议先配一个镜像源再开始装不然反复重装很浪费时间。跑起来之后目录结构大概是这样的scratch-gui/ ├── src/ │ ├── components/ # 各种React组件包括菜单栏、编辑区 │ ├── lib/ # 核心逻辑、API封装 │ ├── redux/ # Redux store、reducer、中间件 │ ├── views/ # 页面级组件 │ └── index.js # 入口文件 └── test/ # 测试用例我后来没有直接改scratch-gui的源码而是在外层套了一个自定义的React应用把它当成一个子组件嵌进去。这样做的理由是升级scratch-gui版本的时候不需要把自定义的改动一个个重新移植只要重新编译再嵌一次就行。这个思路参考的是包一层壳的做法很多团队都是这么干的。2.2 后端认证服务的设计后端这边我单独建了一个服务不跟Scratch前端混在一起。项目结构大概是这样user-server/ ├── routes/ # 路由定义 ├── controllers/ # 业务逻辑 ├── models/ # 数据模型 ├── middlewares/ # 认证中间件 └── app.js # 入口先看数据表设计。用户表和项目表两张表就够用了用户表users字段类型说明idINT 自增主键usernameVARCHAR(50)用户名唯一passwordVARCHAR(255)密码哈希存储nicknameVARCHAR(50)昵称roleTINYINT0-学生 1-老师 2-管理员created_atDATETIME创建时间密码字段特别提醒一定不要明文存后面接口系统会讲加密方式。项目表projects字段类型说明idINT 自增主键user_idINT关联用户表titleVARCHAR(100)项目名称contentLONGTEXT项目数据JSON字符串updated_atDATETIME最后更新时间这个设计考虑了业务场景用户表只负责认证项目表负责作品数据。两者通过user_id关联。后面要加我的作品列表老师查看全班作品这些功能时一条SQL就查出来了不需要改表结构。3. 登录功能的核心实现3.1 后端接口开发注册、登录与令牌校验登录功能后端其实就三个核心接口注册、登录、校验登录状态再加上一个获取用户信息的接口。先写注册接口。// routes/user.js const express require(express); const router express.Router(); const bcrypt require(bcryptjs); const jwt require(jsonwebtoken); const db require(../db); // 注册 router.post(/register, async (req, res) { const { username, password, nickname } req.body; // 1. 检查用户名是否已存在 const existed await db.query( SELECT id FROM users WHERE username ?, [username] ); if (existed.length 0) { return res.status(400).json({ message: 用户名已存在 }); } // 2. 密码加密加盐哈希 const salt await bcrypt.genSalt(10); const hashedPassword await bcrypt.hash(password, salt); // 3. 写入数据库 const result await db.query( INSERT INTO users (username, password, nickname, role) VALUES (?, ?, ?, 0), [username, hashedPassword, nickname || username] ); res.json({ id: result.insertId, username, nickname: nickname || username, role: 0 }); });这里有个细节第2步的bcrypt.genSalt(10)后面的10代表加盐的轮数。轮数越多密码哈希计算越慢安全性越高。10是BCrypt的默认推荐值实测在普通服务器上注册一次大概需要50-100毫秒完全能接受。如果调成15以上性能会明显下降但没有必要——10轮对付常见攻击已经足够了。登录接口和注册类似但逻辑反过来了先查用户再比对密码。// 登录 router.post(/login, async (req, res) { const { username, password } req.body; // 1. 查询用户 const users await db.query( SELECT * FROM users WHERE username ?, [username] ); if (users.length 0) { return res.status(400).json({ message: 用户不存在 }); } const user users[0]; // 2. 比对密码 const valid await bcrypt.compare(password, user.password); if (!valid) { return res.status(400).json({ message: 密码错误 }); } // 3. 签发JWT const token jwt.sign( { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: 3d } ); res.json({ token, user: { id: user.id, username: user.username, nickname: user.nickname, role: user.role } }); });JWT的核心原理用一张图说清楚它是一串经过签名的JSON字符串客户端登录成功后拿到token之后每次请求都在Header里带上它。服务器收到token后用同样的密钥验证签名是否有效有效就直接信任token里写的用户ID、角色等信息不需要去数据库查Session表。token有效期我设的是3天理由是少儿编程项目一般是一周一节课学生3天内登录状态能保留下一节课重新打开网站还是登录状态体验好。如果你做的是成人学习平台可以设短一点比如1天再加刷新机制。校验登录状态的中间件也很关键// middlewares/auth.js module.exports function auth(req, res, next) { const token req.headers[authorization]; if (!token) { return res.status(401).json({ message: 未登录 }); } try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.user decoded; next(); } catch (err) { return res.status(401).json({ message: 登录已过期 }); } };这个中间件的逻辑非常朴素拿请求头里的Authorization字段用JWT密钥验证验证通过就把解码出来的用户信息挂到req上后面的接口就能直接用req.user.id了。如果验证失败就直接返回401前端收到这个数字就跳转登录界面。提示JWT密钥JWT_SECRET一定要存环境变量不要硬编码在代码里。密钥一旦泄露攻击者可以伪造任意用户的token。3.2 前端登录界面与状态管理后端接口准备好之后前端这边重点有两块登录界面的UI以及登录状态的Redux管理。UI我用了最简单的弹窗方式。在scratch-gui里顶部菜单栏右侧有一个位置可以放用户头像和用户名匿名状态就显示登录。我在这里放了一个按钮点击弹出登录弹窗。创建登录弹窗组件的伪代码如下// src/components/login-modal.jsx import React, { useState } from react; import { connect } from react-redux; import { login } from ../redux/login-slice; function LoginModal({ open, onClose, onLogin, loading }) { const [mode, setMode] useState(login); // login or register const [username, setUsername] useState(); const [password, setPassword] useState(); if (!open) return null; const handleSubmit async (e) { e.preventDefault(); await onLogin({ username, password }); onClose(); }; return ( div classNamelogin-modal-overlay div classNamelogin-modal button classNameclose-btn onClick{onClose}×/button h3{mode login ? 账号登录 : 注册新账号}/h3 form onSubmit{handleSubmit} input typetext placeholder用户名 value{username} onChange{(e) setUsername(e.target.value)} / input typepassword placeholder密码 value{password} onChange{(e) setPassword(e.target.value)} / button typesubmit disabled{loading} {loading ? 加载中... : mode login ? 登录 : 注册} /button /form p {mode login ? ( a onClick{() setMode(register)}没有账号去注册/a ) : ( a onClick{() setMode(login)}已有账号去登录/a )} /p /div /div ); } const mapDispatchToProps (dispatch) ({ onLogin: (payload) dispatch(login(payload)), }); export default connect(null, mapDispatchToProps)(LoginModal);这里的逻辑很简单mode字段切换登录和注册模式提交时调用Redux里的login异步action等请求成功后关闭弹窗刷新界面上的用户状态。Redux的状态管理部分我用了一个独立的slice来管理用户信息// src/redux/login-slice.js import { createSlice, createAsyncThunk } from reduxjs/toolkit; export const login createAsyncThunk( user/login, async ({ username, password }, { rejectWithValue }) { try { const response await fetch(/api/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username, password }), }); const data await response.json(); if (!response.ok) { return rejectWithValue(data.message); } // 保存token到localStorage刷新页面后还能保持登录状态 localStorage.setItem(scratch_token, data.token); return data.user; } catch (err) { return rejectWithValue(网络错误请稍后重试); } } ); const loginSlice createSlice({ name: user, initialState: { userInfo: null, token: localStorage.getItem(scratch_token) || null, loading: false, error: null, }, reducers: { logout: (state) { state.userInfo null; state.token null; localStorage.removeItem(scratch_token); }, }, extraReducers: (builder) { builder .addCase(login.pending, (state) { state.loading true; state.error null; }) .addCase(login.fulfilled, (state, action) { state.loading false; state.userInfo action.payload; state.token localStorage.getItem(scratch_token); }) .addCase(login.rejected, (state, action) { state.loading false; state.error action.payload; }); }, }); export const { logout } loginSlice.actions; export default loginSlice.reducer;这里有个细节值得专门说token存localStorage的原因。很多开发者习惯存cookie但前后端分离场景下cookie涉及跨域、CSRF等一堆问题。localStorage只要设置好token请求时手动加到Header里就行逻辑非常清晰。缺点是有XSS攻击风险但对少儿编程平台这种内部工具来说风险可以接受。前端还有一个关键点应用启动时如果发现localStorage里已经有token需要再调一个接口验证token是否有效并获取用户信息。这一步保证了用户刷新页面后仍然是登录状态。// 应用启动时调用 async function fetchUserInfo(token) { const response await fetch(/api/me, { headers: { Authorization: token }, }); if (response.ok) { const user await response.json(); // 更新Redux状态 } else { // 401说明token过期了清理掉 localStorage.removeItem(scratch_token); } }4. 实操过程从零到能用的完整链路4.1 后端部署与联调后端代码写完第一步不是马上接前端而是先用curl测接口。我习惯这样测# 注册 curl -X POST http://localhost:3000/api/register \ -H Content-Type: application/json \ -d {username:testuser,password:123456,nickname:测试同学} # 登录 curl -X POST http://localhost:3000/api/login \ -H Content-Type: application/json \ -d {username:testuser,password:123456} # 带token访问用户信息 curl http://localhost:3000/api/me \ -H Authorization: 这里粘贴登录返回的token这三个命令跑通后端逻辑基本就没问题了。然后再接前端。联调阶段遇到最多的坑就是跨域。Scratch前端跑在开发服务器比如localhost:8080后端跑在3000端口浏览器会做跨域校验。解决方法是给后端加上CORS支持const cors require(cors); app.use(cors({ origin: http://localhost:8080, credentials: true }));如果你把前端部署到线上origin那里要换成线上地址。另外如果前后端用同一个域名例如都挂在nginx下跨域问题就不存在了配置可以更简单。注意跨域配置里credentials: true要谨慎使用。如果你用JWT方式前端每次请求手动带Authorization头其实不需要credentials。开了credentials有时候反而会引起Preflight请求给排查增加难度。4.2 将登录状态接入Scratch的核心操作后端通了前端登录弹窗也有了接下来最关键的一步让登录功能真正跟Scratch作品的保存、加载联动起来。这一步如果只是做个登录界面但保存还是走本地那登录就没有实际意义。我的做法分三步第一步在Scratch的Redux store里注册用户模块。scratch-gui自己有一套Redux store你可以通过configureStore或combineReducers把自定义的reducer挂进去。这个操作需要找到scratch-gui的store配置文件把loginReducer加进reducers列表。第二步拦截Scratch的保存动作。scratch-gui的保存通常在菜单栏点击保存到电脑或执行导出逻辑。为了做保存到服务器我把这个动作拦截下来判断当前用户是否已登录。未登录就弹出登录弹窗已登录就调用后端项目保存接口。// 保存项目的封装 async function saveProjectToServer(user, projectData) { const response await fetch(/api/projects, { method: POST, headers: { Content-Type: application/json, Authorization: localStorage.getItem(scratch_token), }, body: JSON.stringify({ title: 我的项目, content: JSON.stringify(projectData), }), }); const result await response.json(); return result; }第三步加载作品时同理。用户登录后我的作品列表应该从后端拉而不是只显示本地文件。这里要注意性能每次拉列表不要拉全部作品内容可以先只拉标题和更新时间。用户点击某个作品后再单独调接口拿详情。另外还有一个体验细节如果用户项目保存到一半突然断网或者token过期了前端要有合理的错误提示而不是白屏或静默失败。我一般会在保存接口的catch里弹个提示框告诉用户保存失败请检查网络。如果错误是401就直接跳到登录弹窗。5. 常见问题与排查记录5.1 跨域和各种报错我在这个项目里踩过的跨域坑简单说三种第一种是开发环境跨域。通常前端跑8080后端跑3000浏览器会拦截请求。解决方法是加CORS中间件或者在前端package.json里配置proxy。// package.json proxy: http://localhost:3000第二种是请求头里的Authorization没带上。很多朋友写了前端代码但忘了在axios的默认配置里设置Authorization头结果后端一直返回401。解决方法是封装一个带token的请求工具函数async function authedFetch(url, options {}) { const token localStorage.getItem(scratch_token); return fetch(url, { ...options, headers: { ...(options.headers || {}), Authorization: token, }, }); }第三种是预检请求OPTIONS问题。如果你在请求里设置了非简单请求头浏览器会先发一个OPTIONS请求确认服务器允许。后端如果没处理OPTIONS请求会导致请求失败。5.2 密码加密与安全问题登录功能里绝对不能犯的错就是明文存密码。哪怕你的项目只是个内部Demo也一样。用bcrypt加盐哈希既不会增加太复杂的代码又能避免最要命的安全事故。还有一个容易被忽略的点JWT密钥JWT_SECRET里不要用弱密码。之前看到有项目直接把123456当密钥这种token基本上等于没加密。最起码要生成一个32位以上的随机字符串。比如用Node的crypto模块生成const crypto require(crypto); const secret crypto.randomBytes(32).toString(hex); console.log(secret);生成的字符串放到环境变量.env文件里别提交到代码仓库。如果用的是Git管理代码记得把.env加进.gitignore。5.3 登录状态同步与页面刷新登录之后界面上的用户名怎么刷新用Redux时登录成功的回调里dispatch一个action更新store里的userInfo。React组件通过选择器connect到userInfo字段上store变更后界面自动刷新。但有一个坑如果你在登录弹窗里登录成功但弹窗本身没有关闭或者弹窗关闭后界面没刷新用户会以为没登进去。这个问题我当时排查了很久最后发现是Redux的connect范围太小只有弹窗组件本身订阅了userInfo状态菜单栏组件没有订阅。解决方案是调整connect的使用范围让顶部菜单栏也能读到userInfo。还有一个细节是页面刷新后如何恢复登录状态。我的方案是应用启动时从localStorage读token然后调/api/me接口验证用户信息。如果返回成功就说明token有效重新把userInfo设进store如果返回401就直接清掉token让用户重新登录。这个静默恢复登录的逻辑写好后刷新页面的体验就很顺了。5.4 多角色与权限控制的展开做完基础登录后你会发现学生、老师、管理员这些角色天然给你留好了扩展点。我的users表里role字段存了0、1、2分别对应学生、老师、管理员。有了这个字段老师角色的用户登录后保存作品可能会变成查看全班作品。管理员登录后界面多了一个数据管理按钮。这些都可以根据role字段做条件渲染。后端这边的权限控制也很简单。写个中间件判断当前用户的role是否够权限// middlewares/role.js module.exports function requireRole(roles) { return (req, res, next) { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: 权不足 }); } next(); }; }; // 使用示例只有老师role1和管理员role2能访问 app.get(/api/admin/students, requireRole([1, 2]), handler);这套体系做完基本上从登录功能扩展成了用户体系和权限管理后面的功能叠加就变得很舒服了。6. 回顾与个人经验总结登录功能从设计到落地前前后后改了三版。最开始就是写个简单的表单加后端验证能用但用户体验很差后来加了JWT认证登录状态可以持久化了再后来加了角色权限和项目存储才算真正把这个功能嵌进Scratch的日常使用链路里。讲到这儿给你一个实用建议如果做Scratch二次开发登录功能千万不要等所有UI做完才开始。先用最小的后端接口把注册、登录、token这一条链路跑通马上接上Scratch的保存动作做个demo让用户能真实地把作品存到服务器、能打开。有了这个最小的闭环后面再优化界面、加权限控制都是顺手的事。另外一个小技巧给登录弹窗做表单校验时一定要做前端预校验。比如用户名长度、密码长度、两次密码是否一致这些在前端就能拦住别等到提交给后端才报错。这样用户体验和接口安全性都提升不少。最后说一句登录功能看着不起眼但它往往是整个平台从体验Demo走向实际可用的分水岭。把这一步走扎实后面做作品分享、班上排行榜、老师点评这些功能都是在同一套用户体系上搭积木而已。