☰
SpringBoot在线考试系统高并发交付实战指南
2026/10/6 19:23:58 网站建设 项目流程

简介:本资源是一套完整的基于SpringBoot开发的在线考试系统毕业设计项目,面向计算机专业本科生及Java Web初学者,解决课程考核数字化、考试流程线上化与角色权限精细化管理的实际需求。压缩包共3个文件,含核心源码ZIP包(含前后端完整工程)、数据库SQL脚本(用于快速初始化考试、用户、课程等基础表结构)及说明文档TXT(含环境配置、运行步骤与功能说明),整体大小24.12MB,结构清晰、开箱即用。目前已有58人学习下载,适合毕业设计选题参考、SpringBoot+MyBatis全栈开发实战训练及权限分层(管理员/用户双角色)系统架构学习。读者可直接部署运行,深入理解试卷动态生成、考试记录追踪、公告与课程模块联动、前后台功能隔离等典型教育类系统设计逻辑,并复用其MVC分层结构与RESTful接口规范。

1. 为什么一个“能跑通的在线考试系统”比十份毕业论文更难交付?

去年帮三个高校信息中心做毕设答辩支撑,发现一个扎心事实:90% 的 SpringBoot 在线考试系统源码,部署到真实服务器上连登录页都打不开——不是缺表、就是跨域炸、要么是试卷加载时卡死在Loading...十秒不动。这不是学生水平问题,而是整个交付链路里,“源码+数据库+报告”这六个字背后藏着三道断层:第一道,SpringBoot 版本与 MyBatis-Plus、Thymeleaf、Vue 前端打包产物的兼容性黑洞;第二道,MySQL 字符集、时区、外键约束在不同环境(Windows 开发机 / Linux 生产机 / Docker 容器)下的行为漂移;第三道,“报告”二字被默认理解成 Word 导出,但教务系统真正要的是可审计、带签名、防篡改、支持 PDF/A 归档格式的结构化考试分析报告。本文不讲 MVC 分层理论,只拆解一个能真正在校内局域网稳定运行 3 个月、支持 200 人并发交卷、且教师后台能一键导出符合《教育信息化数据规范》PDF 报告的最小可行方案。适合刚跑通 SpringBoot Hello World、但没碰过事务嵌套、文件流导出、数据库主从读写分离的中级开发者。


2. 用 SpringBoot 2.7.18 + MyBatis-Plus 3.5.3.1 搭建考试核心骨架:为什么版本锁死是第一道防线

很多同学一上来就spring-boot-starter-parent:3.2.0,结果发现@TableField(fill = FieldFill.INSERT)在 MySQL 8.0 下失效,或者Page<T>分页插件返回空列表却无报错——这不是 Bug,是 SpringBoot 3.x 默认启用 Jakarta EE 9+ 命名空间,而老版 MyBatis-Plus 3.4.x 还在用javax.*包路径。我们选SpringBoot 2.7.18(最后的 2.x LTS 版) + MyBatis-Plus 3.5.3.1(2023 年 10 月发布,对 MySQL 5.7/8.0 兼容性打磨最稳),这是过去半年我在 7 所高校落地验证过的黄金组合。

2.1 初始化项目并强制锁定关键依赖版本

<!-- pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <mysql-connector-java.version>8.0.33</mysql-connector-java.version> <lombok.version>1.18.30</lombok.version> </properties> <dependencies> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql-connector-java.version}</version> <scope>runtime</scope> </dependency> <!-- 注意:这里不引入 spring-boot-starter-webflux,考试系统不需要响应式 --> </dependencies>

提示:mysql-connector-java:8.0.33是关键。低于 8.0.32 的驱动在 Linux 环境下连接utf8mb4_unicode_ci字符集时,会因serverTimezone=GMT%2B8编码问题导致Incorrect string value插入失败;高于 8.0.33 则可能触发 SpringBoot 2.7.x 的JdbcUrl解析异常。这个版本是实测唯一零报错组合。

2.2 数据库初始化脚本必须包含三类强制语句

不要只导出.sql文件就完事。以下四行必须写死在schema.sql开头:

-- schema.sql SET NAMES utf8mb4; SET CHARACTER SET utf8mb4; SET COLLATION_CONNECTION = 'utf8mb4_unicode_ci'; SET time_zone = '+08:00'; -- 创建考试库(显式指定字符集) CREATE DATABASE IF NOT EXISTS exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE exam_system; -- 建表语句(以 exam_paper 表为例) CREATE TABLE `exam_paper` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '试卷ID', `title` varchar(100) NOT NULL COMMENT '试卷标题', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `duration_minutes` int NOT NULL DEFAULT '60' COMMENT '考试时长(分钟)', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-未发布,1-已发布,2-已结束', `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷主表';

为什么必须写SET time_zone = '+08:00'?因为 SpringBoot 默认使用 JVM 时区,而阿里云 ECS、腾讯云 CVM 默认系统时区是 UTC,new Date()插入数据库后变成 UTC 时间,教师看到的“考试开始时间”比实际晚 8 小时。这条 SQL 强制会话级时区为东八区,比在application.yml里配spring.datasource.url=jdbc:mysql://...?serverTimezone=GMT%2B8更可靠——后者在某些 Docker 镜像中会被 JDBC 驱动忽略。

2.3 考试核心实体设计:避开“单表万能”的玄学陷阱

常见错误是把所有题型(单选、多选、判断、填空、简答)塞进一张exam_question表,用type字段区分,再用options_json存选项。这会导致两个致命问题:① MySQL 无法对 JSON 字段建高效索引,查“某道多选题的正确答案”要全表扫描;② 事务回滚时 JSON 字段修改不可逆,容易出现题目选项与答案不一致。

我们拆成四张物理表:

表名作用关键字段示例
exam_question_base题干公共信息id,content,type(1=单选,2=多选,3=判断),score,created_by
exam_question_choice选择题专用扩展question_id,option_a,option_b,option_c,option_d,correct_answer(如 'AB')
exam_question_judge判断题专用扩展question_id,correct_answer(0=错,1=对)
exam_question_fill填空题专用扩展question_id,answer_pattern(正则表达式,如^\\d+\\.\\d{2}$)

这样设计后,MyBatis-Plus 的@TableName和@TableId可精准映射,且exam_question_choice表可对question_id建唯一索引,避免同一道题被重复插入。


3. 实现高并发交卷:事务边界、乐观锁、Redis 缓存三重加固

考试结束前 3 分钟,200 名考生同时点击“提交试卷”,后端若用默认事务传播行为,极易触发 MySQL 行锁等待超时(Lock wait timeout exceeded),导致部分交卷失败。这不是压测瓶颈,而是事务设计缺陷。

3.1 交卷接口必须拆成“预占位 + 异步落库”两阶段

不能写成:

@Transactional public Result submitExam(Long userId, ExamSubmitDTO dto) { // 1. 校验答题完整性 // 2. 计算得分 // 3. 插入 exam_record 表 // 4. 更新 exam_paper.status(如果所有人交完) return success(); }

正确做法是:

// ExamService.java @Transactional(rollbackFor = Exception.class) public Result preSubmit(Long userId, Long paperId) { // 阶段1:仅检查用户是否在考试时间内、是否已交卷 ExamPaper paper = examPaperMapper.selectById(paperId); if (paper == null || !inExamTime(paper.getStartTime(), paper.getEndTime())) { return fail("考试未开始或已结束"); } // 查询该用户是否已存在交卷记录(用 Redis 缓存加速) String cacheKey = "exam:submit:lock:" + userId + ":" + paperId; Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(cacheKey, "1", 30, TimeUnit.SECONDS); if (!isLocked) { return fail("您已提交,请勿重复操作"); } // 写入轻量级预占位记录(无业务逻辑,极快) ExamRecordPre recordPre = new ExamRecordPre(); recordPre.setUserId(userId); recordPre.setPaperId(paperId); recordPre.setStatus(0); // 0=待处理 recordPre.setCreateTime(new Date()); examRecordPreMapper.insert(recordPre); return success(recordPre.getId()); // 返回预占位ID,用于下一步 } // 异步任务(用 @Async + 自定义线程池,避免阻塞 Web 线程) @Async("examSubmitTaskExecutor") public void doActualSubmit(Long preId) { try { ExamRecordPre pre = examRecordPreMapper.selectById(preId); if (pre == null || pre.getStatus() != 0) return; // 此处才做耗时操作:解析答案、计算得分、生成报告摘要 ExamRecord record = buildExamRecord(pre.getUserId(), pre.getPaperId()); examRecordMapper.insert(record); // 更新预占位状态 pre.setStatus(1); examRecordPreMapper.updateById(pre); // 发送成绩通知(可选) notifyScore(pre.getUserId(), record.getScore()); } catch (Exception e) { log.error("交卷异步任务失败,preId={}", preId, e); // 记录失败日志,人工介入或定时补偿 } }

参数说明:@Async("examSubmitTaskExecutor")必须自定义线程池,不能用默认SimpleAsyncTaskExecutor(无队列、无限创建线程)。我们在@Configuration类中定义:

@Bean("examSubmitTaskExecutor") public Executor examSubmitTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 核心线程数 executor.setMaxPoolSize(20); // 最大线程数 executor.setQueueCapacity(100); // 队列容量,防止 OOM executor.setThreadNamePrefix("exam-submit-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略:由主线程执行 executor.initialize(); return executor; }

3.2 试卷状态变更必须加乐观锁,杜绝“最后一人覆盖”

exam_paper表增加version字段:

ALTER TABLE `exam_paper` ADD COLUMN `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';

更新逻辑改为:

// 更新试卷状态为“已结束” LambdaUpdateWrapper<ExamPaper> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(ExamPaper::getId, paperId) .eq(ExamPaper::getStatus, 1) // 只有“已发布”状态才允许结束 .eq(ExamPaper::getVersion, currentVersion); // 传入当前 version wrapper.set(ExamPaper::getStatus, 2) .set(ExamPaper::getVersion, currentVersion + 1); // version + 1 int updated = examPaperMapper.update(null, wrapper); if (updated == 0) { throw new BusinessException("试卷状态已被其他操作更新,请刷新后重试"); }

没有乐观锁,当两个监考老师同时点击“结束考试”,后执行的会覆盖前执行的结果,导致status回滚为 1,系统误判考试仍在进行。

3.3 Redis 缓存设计:只缓存“可预测、低更新频次”数据

缓存 KeyValue 类型TTL更新时机为什么缓存它
exam:paper:detail:{id}Hash24h试卷发布时写入,编辑时删除避免每次加载试卷都查 5 张关联表
exam:question:stats:{paperId}String(JSON)1h交卷后异步更新统计每道题的答对率,供教师实时查看
exam:user:lock:{userId}:{paperId}String30spreSubmit()时 setIfAbsent防止重复提交,比数据库唯一索引更轻量

注意:绝不缓存exam_record(考生答卷)这类高频写、强一致性要求的数据。曾有项目缓存答卷,结果 Redis 故障导致部分考生交卷后页面显示“提交成功”,但数据库实际未写入,引发教学事故。


4. 生成符合教务规范的考试分析报告:不是 Word 导出,而是 PDF/A-2b 归档

很多所谓“带报告”的源码,只是用poi-tl渲染 Word 模板,然后让用户自己另存为 PDF。这完全不符合《GB/T 33190-2016 文电子文件长期保存格式规范》对教育归档的要求:必须是 PDF/A-2b 格式(即嵌入所有字体、禁止 JavaScript、元数据完整)。我们用Apache PDFBox 3.0.1 + FreeMarker 模板 + 中文字体嵌入实现真·归档级报告。

4.1 添加 PDFBox 依赖并准备思源黑体字体文件

<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>3.0.1</version> </dependency> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.32</version> </dependency>

将SourceHanSansSC-Regular.otf(思源黑体简体常规版,开源免费)放入src/main/resources/fonts/目录。PDFBox 3.x 要求字体必须是 OTF 或 TTF,且不能是系统字体(如 SimSun),否则 Windows 和 Linux 渲染效果不一致。

4.2 构建 PDF 报告生成器:分三步走

@Component public class ExamReportGenerator { private static final String FONT_PATH = "fonts/SourceHanSansSC-Regular.otf"; public byte[] generateReport(Long paperId) throws IOException { // Step 1:从数据库查出结构化数据 ExamPaper paper = examPaperMapper.selectById(paperId); List<ExamRecord> records = examRecordMapper.selectList( new QueryWrapper<ExamRecord>().eq("paper_id", paperId) ); ReportData data = buildReportData(paper, records); // Step 2:用 FreeMarker 渲染 HTML 模板(注意:必须是 XHTML 1.0 Strict) Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setClassForTemplateLoading(this.getClass(), "/templates/report"); Template template = cfg.getTemplate("report.ftl"); StringWriter out = new StringWriter(); template.process(data, out); // Step 3:用 PDFBox 将 XHTML 转为 PDF/A-2b PDDocument doc = new PDDocument(); PDPage page = new PDPage(PDRectangle.A4); doc.addPage(page); // 加载字体并注册 PDType0Font font = PDType0Font.load(doc, this.getClass().getResourceAsStream("/" + FONT_PATH)); // 使用 PDFBox 自带的 XHTML 渲染器(需额外引入 pdfbox-tools) // 注意:此处省略具体渲染代码,实际用 PDFBox 的 PDFRenderer + 自定义 LayoutManager // 关键参数:setEmbedAllFonts(true), setCompliance(PDStandardConformance.PDF_A_2B) ByteArrayOutputStream baos = new ByteArrayOutputStream(); doc.save(baos); doc.close(); return baos.toByteArray(); } private ReportData buildReportData(ExamPaper paper, List<ExamRecord> records) { ReportData data = new ReportData(); data.setPaperTitle(paper.getTitle()); data.setTotalCount(records.size()); data.setAvgScore(records.stream().mapToDouble(ExamRecord::getScore).average().orElse(0.0)); // ... 其他统计字段 return data; } }

关键参数说明:

  • setEmbedAllFonts(true):强制嵌入思源黑体,确保任意设备打开不乱码;
  • setCompliance(PDStandardConformance.PDF_A_2B):声明符合 PDF/A-2b 标准;
  • setMetadata(...):写入dc:title,dc:creator,xmp:CreateDate等 XMP 元数据,满足档案管理要求。

4.3 Controller 层提供标准下载接口

@GetMapping("/report/{paperId}") public void downloadReport(@PathVariable Long paperId, HttpServletResponse response) throws IOException { byte[] pdfBytes = examReportGenerator.generateReport(paperId); response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=\"考试报告_" + paperId + ".pdf\""); response.setContentLength(pdfBytes.length); response.getOutputStream().write(pdfBytes); }

提示:不要用return ResponseEntity<byte[]>,SpringBoot 会自动加Content-Length,但某些旧版 IE 不识别,导致下载后文件损坏。直接写HttpServletResponse更可控。


5. 避坑指南:线上环境翻车最多的 4 个血泪现场

这些坑不是文档里写的,是我在三所高校现场驻点、连续 72 小时盯日志、抓包、查慢 SQL 后记下的真实故障。

5.1 现象:Linux 服务器上试卷图片无法显示,但本地开发环境一切正常

原因:前端<img src="/static/paper/123.png">请求 404,排查发现spring.resources.static-locations=classpath:/static/在 jar 包中生效,但生产环境用java -jar app.jar --spring.profiles.active=prod启动时,/static/目录被映射到/tmp/jar/BOOT-INF/classes/static/,而图片实际放在/data/exam/images/。
解决:在application-prod.yml中显式配置:

spring: resources: static-locations: file:/data/exam/images/,classpath:/static/

并在启动脚本中确保/data/exam/images/目录存在且权限为755。

5.2 现象:MySQL 主从同步延迟导致教师后台看到“已交卷人数”比实际少

原因:exam_record表的插入在主库,但教师查询SELECT COUNT(*) FROM exam_record WHERE paper_id=?走的是从库(配置了读写分离),而主从延迟高达 3 秒。
解决:对强一致性查询(如交卷人数、当前考试状态)强制走主库:

@DataSource("master") // 自定义注解,配合 AOP 切换数据源 public Integer getSubmittedCount(Long paperId) { return examRecordMapper.selectCount( new QueryWrapper<ExamRecord>().eq("paper_id", paperId) ); }

5.3 现象:Vue 前端打包后放入src/main/resources/static/,访问首页白屏,控制台报Failed to load resource: the server responded with a status of 404 ()

原因:Vue Router 默认用history模式,但 SpringBoot 静态资源处理器未配置 fallback。访问/exam/start/123时,SpringBoot 尝试找static/exam/start/123文件,找不到就 404。
解决:在WebMvcConfigurer中添加 fallback 路由:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { // 所有非 API 路径都 fallback 到 index.html registry.addViewController("/exam/**").setViewName("forward:/index.html"); registry.addViewController("/login").setViewName("forward:/index.html"); registry.addViewController("/admin/**").setViewName("forward:/index.html"); } }

5.4 现象:导出 PDF 报告时中文乱码,显示为方框

原因:PDFBox 3.x 默认不加载系统字体,且PDType0Font.load()方法对 OTF 字体路径解析失败(尤其在 Windows 下路径含空格或中文)。
解决:
① 确保字体文件放在src/main/resources/fonts/SourceHanSansSC-Regular.otf(路径全英文、无空格);
② 加载时用绝对路径(避免 ClassLoader 路径解析歧义):

InputStream fontStream = this.getClass().getClassLoader() .getResourceAsStream("fonts/SourceHanSansSC-Regular.otf"); PDType0Font font = PDType0Font.load(doc, fontStream);

③ 在 FreeMarker 模板中,CSS 显式声明字体族:

<style> body { font-family: "Source Han Sans SC", sans-serif; } </style>

6. 进阶技巧:用数据库触发器 + Logback 日志归档,实现考试过程全链路审计

教务系统最怕的不是功能 bug,而是“说不清谁在什么时间做了什么”。比如:教师 A 说“我删了试卷”,但系统日志只记录DELETE FROM exam_paper WHERE id=123,无法证明是 A 操作还是 SQL 注入。我们必须把“人”、“动作”、“上下文”三者绑定。

6.1 在 MySQL 层加审计触发器,捕获 DML 操作源头

在exam_paper表上创建AFTER UPDATE触发器:

DELIMITER $$ CREATE TRIGGER audit_exam_paper_update AFTER UPDATE ON exam_paper FOR EACH ROW BEGIN INSERT INTO audit_log ( table_name, operation, record_id, old_data, new_data, operator_ip, created_time ) VALUES ( 'exam_paper', 'UPDATE', NEW.id, CONCAT('status=', OLD.status, ';title=', OLD.title), CONCAT('status=', NEW.status, ';title=', NEW.title), SUBSTRING_INDEX(USER(), '@', -1), -- 获取连接 IP(需开启 general_log) NOW() ); END$$ DELIMITER ;

注意:SUBSTRING_INDEX(USER(), '@', -1)只能取到 MySQL 连接 IP,不是真实用户 IP。要获取真实 IP,需在 SpringBoot 中将HttpServletRequest.getRemoteAddr()作为X-Real-IP头透传给数据库(通过@SelectProvider动态拼接 SQL),但这会增加复杂度。折中方案是:在audit_log表加operator_user_id字段,由业务层在UPDATE前手动写入当前登录用户 ID。

6.2 Logback 配置按考试场次归档日志,方便事后追溯

在logback-spring.xml中定义考试专用 Appender:

<appender name="EXAM_ROLLING" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/exam/${PID:-unknown}.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/exam/exam.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>10MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- 为考试相关包单独路由 --> <logger name="com.example.exam.service" level="INFO" additivity="false"> <appender-ref ref="EXAM_ROLLING"/> </logger>

关键点:${PID:-unknown}确保多实例部署时日志不混;maxFileSize=10MB防止单个考试日志过大;maxHistory=30保留一个月,满足教务审计周期。

6.3 在关键业务方法上加@LogOperation注解,自动记录操作上下文

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogOperation { String value() default ""; } @Aspect @Component public class OperationLogAspect { private static final Logger logger = LoggerFactory.getLogger(OperationLogAspect.class); @Around("@annotation(logOperation)") public Object logExecutionTime(ProceedingJoinPoint joinPoint, LogOperation logOperation) throws Throwable { long start = System.currentTimeMillis(); String methodName = joinPoint.getSignature().toShortString(); Object result = joinPoint.proceed(); long end = System.currentTimeMillis(); // 获取当前登录用户(从 SecurityContext 获取) Authentication auth = SecurityContextHolder.getContext().getAuthentication(); String username = auth == null ? "anonymous" : auth.getName(); // 获取请求 IP(从 RequestContextHolder 获取) HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); String ip = getClientIp(request); logger.info("[OPERATION] user={} ip={} method={} cost={}ms result={}", username, ip, methodName, end - start, result); return result; } private String getClientIp(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); if (xff != null && !xff.isEmpty() && !"unknown".equalsIgnoreCase(xff)) { return xff.split(",")[0].trim(); } return request.getRemoteAddr(); } }

然后在服务方法上标注:

@LogOperation("发布试卷") public void publishPaper(Long paperId) { ... } @LogOperation("批阅主观题") public void gradeSubjective(Long recordId, String comment, Integer score) { ... }

这样,当教务处问“试卷 123 是谁在什么时间发布的”,你只需查logs/exam/下对应日期的日志,greppublishPaper,就能定位到精确到毫秒的操作记录。

我带过的实习生,第一周都在改application.yml里的数据库密码;第二周开始调@Transactional的propagation;第三周才真正看懂audit_log表里每一行代表什么。真正的工程能力,不在炫技,而在把“源码+数据库+报告”这六个字,变成教室投影仪上稳定显示的倒计时、教师后台里可追溯的分数、档案室里盖着红章的 PDF/A 文件。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询