资讯动态

Java+Spring Boot电影在线订票系统源码:权限、锁座与订单状态机

发布时间:2026/11/6 2:25:53 来源:尧图企业网站定制
简介面向具备一定Java基础、希望学习或从事在线票务系统开发的技术人员这份文档围绕基于Java与Spring Boot的电影在线订票选座系统展开重点解决用户、会员与管理员三类角色的功能设计及实现思路涉及注册登录、影片浏览、订单管理、论坛交流、会员折扣和后台数据维护等业务。内容结合IDEA开发环境梳理JPA、Thymeleaf、MySQL等技术栈的协作方式并给出开发步骤、编译运行流程与核心代码模块示例便于读者理解数据模型、业务流程和系统落地路径。资源包为1个docx文档大小约34KB适合作为课程设计、毕业项目或企业票务平台原型开发的参考资料。已有315人学习读者可从中获取完整功能清单、后台管理模块、代码片段与项目总结等线索并据此扩展个性化推荐、性能优化和移动端适配等方向。1. 一套 Java Spring Boot 电影在线订票系统源码能省下的到底是哪几天多数人拿到一份 Java Spring Boot 写的电影在线订票系统源码第一步是双击压缩包第二步是找application.properties改密码第三步就卡在启动报错上。这套东西真正的价值不在“有登录有列表”而在于它把票务系统里最容易返工的三件事先摆好了三种角色的权限边界、场次与座位的一致性、订单和折扣的计算位置。技术栈是 Spring Boot 起服务、Spring Data JPA 管持久层、Thymeleaf 渲染页面、MySQL 存数据IDE 侧按 IntelliJ IDEA 的 Maven 工程组织拿来就能调试。适合两类人Java 语法还熟、想完整走一遍业务链路的以及手上有票务类需求、需要先做个可运行底座验证流程的。往下按“结构怎么读、环境怎么搭、核心逻辑在哪、坑埋在哪”的顺序拆每一步都能落到命令和文件上。2. 读懂项目骨架Spring Boot JPA Thymeleaf 的三层边界与五张核心表从压缩包解出来的那一刻很多人会先翻 controller看注册登录怎么写。这个顺序其实反了。先看pom.xml和目录分层能省掉后面一大半“这个类该放哪”的犹豫再看实体和表关系才能判断这份代码是能往上加业务还是一动就塌。下面按目录、数据模型、权限三块拆拆完之后你对这个项目的改动成本会有一个大致估计。2.1 Maven 目录与分层边界controller / service / repository 各管什么标准 Maven 结构下源码在src/main/java配置和模板在src/main/resources。国内项目普遍会在java下再按controller、service、repository、entity、config、common分包页面放在resources/templates静态资源放resources/static。分层不是形式主义它决定了你改一个功能要动几个文件。controller 只做三件事接参数、做基础校验、把结果包成统一响应。真正的业务判断放在 service比如“这个用户是不是会员”“这个座位还能不能锁”。repository 继承JpaRepository只负责查询不写业务条件拼接。常见做法是 service 层的方法加Transactionalcontroller 保持无状态这样后面接接口、换模板都不会互相牵连。// 统一响应体放在 common 包下避免每个 controller 各自拼 Map public class RT { private int code; // 0 成功非 0 为业务错误码 private String msg; private T data; public static T RT ok(T data) { RT r new R(); r.code 0; r.msg ok; r.data data; return r; } public static T RT fail(String msg) { RT r new R(); r.code 500; r.msg msg; return r; } // getter / setter 省略 }这个类看着简单作用是让前端和模板的取值方式统一后面加全局异常处理器时只改一处就能把错误码规范回来。参数说明上code建议按业务域分段比如 1xxx 用户、2xxx 影片、3xxx 订单排查日志时一眼能定位模块。pom.xml里值得先确认的有三项Spring Boot 的 parent 版本、MySQL 驱动依赖、Thymeleaf 依赖。Spring Boot 主版本决定了 JDK 下限2.7 及以下通常配 JDK 8 或 113.x 要求 JDK 17 起。如果你的机器上是 JDK 8而pom.xml的 parent 是 3.x编译期就会报 class 文件版本不匹配这类错误信息不明显容易误判成 IDEA 配置问题。2.2 数据模型五张核心表怎么连价格挂在哪一层订票系统的表设计翻车点几乎都集中在两处价格挂错层、座位没做唯一约束。影片有基础票价但同一部片子在普通厅和特效厅、工作日和周末的价格不一样所以价格应该挂在场次上不是影片上。座位也不是“订单表里存一个逗号分隔的字符串”就完事那样查空座和防重都很难做。表名关键字段设计要点userid、username、password、role、points、member_level三种角色共用一张表靠 role 区分避免多表联查movieid、title、type_id、duration、release_date、poster、status类型单独一张 movie_type方便后台增删scheduleid、movie_id、hall_id、start_time、price、total_seat价格和总座位数挂场次同一影片可有多场seat_lockid、schedule_id、seat_no、user_id、expire_at(schedule_id, seat_no)建唯一索引防重复占用ordersid、order_no、user_id、schedule_id、amount、pay_status、create_time金额落库快照不随场次改价而变订单和座位之间建议用明细表order_item(order_id, seat_no, price)而不是在订单表里塞字符串。这样退票时能精确释放某一个座位统计上座率也能直接按order_item聚合。seat_lock和orders分开的意义在于锁座是有时效的临时状态订单是终态两者生命周期不同混在一张表里过期释放逻辑会写得很别扭。movie和schedule是一对多schedule和seat_lock是一对多user和orders是一对多。JPA 里建议在“多”的一方持有外键字段比如schedule里存movieId不要急着用OneToMany双向关联双向关联在序列化 JSON 时特别容易循环引用还得靠JsonIgnore补丁属于自找麻烦。2.3 角色权限的落点USER、MEMBER、ADMIN 用拦截器怎么拦原文功能里区分了普通用户、会员、管理员落到代码上最容易走偏的做法是每个方法里if (role ADMIN)到处判断。可维护的写法是用一个HandlerInterceptor统一拦路径/admin/**只放管理员/api/order/**要求登录登录注册接口放行。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); User user (session null) ? null : (User) session.getAttribute(LOGIN_USER); String uri request.getRequestURI(); if (uri.startsWith(/admin)) { if (user null || user.getRole() ! Role.ADMIN) { response.sendRedirect(/login); // 后台页面走跳转 return false; } } if (uri.startsWith(/api/order) user null) { response.setStatus(401); // 接口走状态码 return false; } return true; } }注册拦截器时把放行名单写清楚别图省事用/**全拦再排除排除项一多就容易漏。Configuration public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebConfig(AuthInterceptor authInterceptor) { this.authInterceptor authInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/admin/**, /api/order/**) .excludePathPatterns(/api/user/login, /api/user/register, /static/**); } }会员等级不建议做成独立角色。会员本质是普通用户的一个属性它的差异体现在折扣和积分上而不是能访问哪些页面。如果把 MEMBER 也当角色用户升级会员时就要改 role 字段权限判断和历史数据全得跟着动。常见的做法是role只管 USER / ADMIN 两类会员信息放member_level和points两个字段折扣计算时读这两个值。提示拦截器拦的是路径不拦方法内部逻辑。涉及“只能操作自己的订单”这类数据级权限仍然要在 service 里比对当前登录用户 id别指望路径拦截能兜住。3. 把系统跑起来JDK、MySQL 到首页可访问的七步操作链启动失败是这类源码最劝退的环节而且九成问题集中在三处JDK 版本不匹配、数据库连不上、字符集不对。下面按顺序走每一步都有可验证的结果别跳步。跳过的代价是后面报错时你不知道是哪一层的问题。3.1 环境准备JDK、Maven、MySQL 的版本对齐先确认三件事的版本能对上再动手。命令行敲这三条输出比 IDEA 里点来点去更可靠。java -version mvn -v mysql --versionjava -version看主版本mvn -v会顺带打印它识别的 JAVA_HOME。如果 Maven 认到的 JDK 和你在 IDEA 里选的不是同一个就会出现“IDE 里编译通过、命令行打包失败”的玄学现象。这种情况在装了多个 JDK 的机器上很常见改系统环境变量JAVA_HOME指向目标版本再重开终端。MySQL 建议用 8.0 及以上驱动类是com.mysql.cj.jdbc.Driver5.x 的com.mysql.jdbc.Driver已经废弃配错会直接抛ClassNotFoundException。IDEA 里导入项目时选“Maven 项目”不要选“从已有源创建”后者经常把src/main/resources标成普通目录导致application.properties读不到。3.2 application.properties 里必须改对的六项配置配置文件在src/main/resources/application.properties这个文件是全项目最该逐行看的地方。下面这份是可用的最小集合注释标了每一项的作用和改法。# 1. 数据库连接库名、时区、字符集三件事一起配 spring.datasource.urljdbc:mysql://localhost:3306/movie_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue # 2. 账号密码换成你本机的 spring.datasource.usernameroot spring.datasource.passwordyour_password # 3. MySQL 8 必须用 cj 驱动 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver # 4. 开发期用 update 自动建表上线前改成 validate spring.jpa.hibernate.ddl-autoupdate spring.jpa.show-sqltrue # 5. 关掉 open-in-view避免懒加载在视图层才触发查询 spring.jpa.open-in-viewfalse # 6. 模板缓存关掉改 HTML 不用重启 spring.thymeleaf.cachefalse server.port8080allowPublicKeyRetrievaltrue这一项在 MySQL 8 默认认证插件下不加会报Public Key Retrieval is not allowed属于必踩的一步。serverTimezone不配或者配错插入的时间会整体偏 8 小时而且不报错只是数据不对排查起来特别费劲。ddl-autoupdate只适合开发期它不会删字段也不会改类型你以为改了实体字段就生效实际上表结构还停在旧版本这个坑后面单独说。3.3 建库、建表与第一条管理员数据建库时把字符集显式指定成utf8mb4别依赖默认值。有些环境默认是latin1插中文直接变问号而且改起来要动表、动字段、动连接三处。CREATE DATABASE IF NOT EXISTS movie_ticket DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE movie_ticket;如果项目带了初始化 SQL直接source导入没带就靠 JPA 的ddl-autoupdate先生成表再手工补初始数据。管理员账号的密码字段是加密存储的不要手工往表里插明文正确做法是先在测试里跑一次PasswordEncoder.encode(你的密码)把输出贴进 SQL。BCrypt 每次加密结果都不同所以你不能靠“看起来像不像”判断对错只能靠matches()验证。-- 角色用字符串枚举和实体里的 Enumerated(EnumType.STRING) 对应 INSERT INTO user (username, password, role, points, member_level) VALUES (admin, $2a$10$这里替换成encode后的密文, ADMIN, 0, 0);3.4 启动与接口自测先用命令行验一遍再打开浏览器启动有三种方式IDEA 里跑主类、mvn spring-boot:run、打包后java -jar。调试阶段用第一种最方便验证部署可行性用第三种最真实。# 打包跳过测试以免初始化数据干扰 mvn clean package -DskipTests # 运行打出来的 jar文件名以 target 目录实际为准 java -jar target/movie-ticket-system-0.0.1-SNAPSHOT.jar # 另开一个终端验接口 curl -X POST http://localhost:8080/api/user/register \ -H Content-Type: application/json \ -d {username:test01,password:123456} curl http://localhost:8080/api/movies/list?page0size5第一条返回{code:0,msg:注册成功}说明数据库连通、JPA 映射正常、事务生效。第二条能返回分页数据说明查询链路没问题。如果第一条就失败看日志最下面那几行 Caused by比看整段堆栈有用得多。浏览器访问http://localhost:8080能看到首页才说明 Thymeleaf 模板路径是对的接口通但页面 500问题一定在模板解析不在业务代码。4. 核心链路拆解注册登录、影片查询、选座下单的代码怎么写才不返工功能列表看着长真正决定这套代码能不能往上加业务的只有三条链路身份、内容、交易。其余论坛、公告、收藏都是标准增删改查照着抄就行。下面三段代码是改动频率最高的地方重点看参数怎么传、异常怎么抛、事务边界划在哪。4.1 注册登录密码加密与统一错误提示注册的核心不是save是“用户名查重 密码加密 默认角色赋值”这三步要么全成要么全败。登录的核心也不是查库是“用户不存在”和“密码错误”要给同一个提示避免被人拿来撞库探测账号是否存在。RestController RequestMapping(/api/user) public class UserController { private final UserService userService; // 构造器注入别用字段 Autowired单元测试时好替换 public UserController(UserService userService) { this.userService userService; } PostMapping(/register) public RVoid register(RequestBody Valid RegisterRequest req) { userService.register(req.getUsername(), req.getPassword()); return R.ok(null); } PostMapping(/login) public RMapString, Object login(RequestBody Valid LoginRequest req, HttpSession session) { User user userService.login(req.getUsername(), req.getPassword()); session.setAttribute(LOGIN_USER, user); MapString, Object data new HashMap(); data.put(username, user.getUsername()); data.put(role, user.getRole().name()); return R.ok(data); } }Valid配合请求体里的NotBlank、Size(min 6)能把空值和过短密码挡在 service 之前减少一半无效判断。session 里存的是整个 User 对象注意别把密码字段带出去简单做法是登录成功后把password置空再放进去。Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; } Transactional public void register(String username, String rawPassword) { if (userRepository.findByUsername(username).isPresent()) { throw new BizException(用户名已被占用); } User u new User(); u.setUsername(username); u.setPassword(passwordEncoder.encode(rawPassword)); // BCrypt每次结果不同 u.setRole(Role.USER); u.setPoints(0); userRepository.save(u); } public User login(String username, String rawPassword) { User u userRepository.findByUsername(username) .orElseThrow(() - new BizException(用户名或密码错误)); if (!passwordEncoder.matches(rawPassword, u.getPassword())) { throw new BizException(用户名或密码错误); // 提示与上面保持一致 } return u; } }passwordEncoder.matches()是唯一正确的比对方式自己写equals一定失败。Transactional加在 register 上而不是整个类上是因为登录是只读操作没必要开事务。异常统一抛自定义的BizException再由RestControllerAdvice转成R.fail(msg)这样前端拿到的结构始终一致。4.2 影片与场次查询分页参数和返回结构查询接口最容易出的问题是返回结构不统一。JPA 的Page对象直接当返回值不同版本序列化出来的字段名不一样前端解析经常崩。稳妥做法是转成自定义的PageResult只保留list、total、page、size四个字段。GetMapping(/list) public RPageResultMovie list( RequestParam(defaultValue 0) int page, RequestParam(defaultValue 12) int size, RequestParam(required false) Long typeId) { Pageable pageable PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, releaseDate)); PageMovie result (typeId null) ? movieRepository.findAll(pageable) : movieRepository.findByTypeId(typeId, pageable); PageResultMovie vo new PageResult(); vo.setList(result.getContent()); vo.setTotal(result.getTotalElements()); vo.setPage(page); vo.setSize(size); return R.ok(vo); }page从 0 开始这是PageRequest.of的约定前端传 1 的话要减一这个偏移量是最常见的“查出来少一条”的原因。size建议设上限比如超过 50 就强制改成 50否则有人传size100000就是一次全表扫描。排序字段releaseDate要和实体字段名严格一致写成数据库列名release_date会直接抛异常。场次查询按movieId过滤返回时带上剩余座位数。剩余座位不要实时count全表可以在schedule表里维护一个remain_seat字段锁座时减、释放时加查询时直接读。代价是要保证加减和座位表操作在同一个事务里否则会出现“显示有座但锁不上”的不一致。4.3 选座下单用唯一索引兜住并发别只靠先查后插这是整套系统里唯一真正涉及并发的地方。典型错误写法是先findByScheduleIdAndSeatNo查一下有没有没有再插入。两个请求同时进来都查到没有都插入成功一个座位卖两次。加synchronized只能单机有效多实例部署立刻失效。靠得住的方案是把约束下沉到数据库给seat_lock建唯一索引让数据库来判重。-- 一条 SQL 解决重复占用注意是联合唯一不是 seat_no 单列唯一 ALTER TABLE seat_lock ADD UNIQUE KEY uk_schedule_seat (schedule_id, seat_no);Transactional(rollbackFor Exception.class) public String lockSeats(Long scheduleId, ListString seats, Long userId) { for (String seat : seats) { SeatLock lock new SeatLock(); lock.setScheduleId(scheduleId); lock.setSeatNo(seat); lock.setUserId(userId); lock.setExpireAt(LocalDateTime.now().plusMinutes(15)); try { // saveAndFlush 立即发 SQL把唯一键冲突暴露在当前 try 里 seatLockRepository.saveAndFlush(lock); } catch (DataIntegrityViolationException e) { throw new BizException(座位 seat 已被占用请重新选座); } } return OK; }saveAndFlush和save的区别是关键save只是把对象放进持久化上下文真正发 SQL 可能拖到事务提交时那时候异常已经不在你的 try 里了捕获不到。Transactional(rollbackFor Exception.class)保证多人选座时只要有一个座位冲突整批全部回滚不会出现“锁了 3 个成功 1 个失败”的脏数据。锁座的过期时间是 15 分钟配合一个定时任务清理expire_at now()的记录。没有这个清理用户下单不付款座位会被永久占住越跑空座越多。4.4 会员折扣与积分算在 service不散落在 controller折扣和积分的规则会变所以一定要收在一个方法里。常见规则是会员按等级打 9 折到 8 折消费 1 元积 1 分。写在 service 里改规则只动一处散在 controller 里改的时候得全局搜。public BigDecimal calcAmount(BigDecimal origin, int memberLevel) { BigDecimal rate switch (memberLevel) { case 2 - new BigDecimal(0.90); case 3 - new BigDecimal(0.80); default - BigDecimal.ONE; // 普通用户无折扣 }; // 金额保留两位四舍五入避免 0.1 0.2 那类精度问题 return origin.multiply(rate).setScale(2, RoundingMode.HALF_UP); }金额一定用BigDecimal用double算出来的订单金额会出现99.99999999这种值落库和展示都对不上。折扣率从数据库配置表读别硬编码在代码里运营改活动时不用重新发版。5. 踩坑排查这套源码在我机器上翻车的五个地方下面五条都是启动和联调阶段真实会遇到的按“现象 → 原因 → 解决”写遇到同类问题直接对号入座比通读日志快。5.1 启动即退出日志最后一行是 Port 8080 was already in use现象是控制台刷了一屏 Spring 的 banner然后进程直接结束末尾一行Web server failed to start. Port 8080 was already in use。原因通常是上一次运行没停干净或者 IDEA 里开了多个运行实例也可能是本机装了别的东西占着 8080。解决分两步先确认占用者再决定是杀进程还是换端口。# macOS / Linux 查占用 lsof -i :8080 # Windows 用 netstat netstat -ano | findstr :8080急着调试就在application.properties里加server.port8081别去系统设置里翻。长期方案是养成习惯改完代码先点停止再点运行IDEA 的“停止”有时杀不掉子进程任务管理器里会残留一个java.exe。5.2 数据库连不上三种报错对应三种原因现象是启动时抛Cannot determine embedded database driver class或Communications link failure。第一种说明配置根本没读到检查application.properties是不是在src/main/resources根目录文件名有没有拼错。第二种说明读到了但连不上检查 MySQL 服务是否启动、端口是不是 3306、库名是否存在。Access denied for user rootlocalhost是密码错或者该用户没有远程/本地权限。Public Key Retrieval is not allowed是 MySQL 8 的认证插件问题在连接串后加allowPublicKeyRetrievaltrueuseSSLfalse即可。时间字段整体偏 8 小时则说明serverTimezone没配或配成了 UTC改成Asia/Shanghai。还有一种隐蔽情况IDEA 的 Database 面板能连上项目连不上。原因多半是连接串里用了localhost而系统解析到了 IPv6 地址换127.0.0.1试试。5.3 中文变问号或乱码字符集要三处一致现象是页面上的中文正常但存进数据库变成???或者反过来库里正常页面上是乱码。原因是库、表、连接三处的字符集不一致只改一处没用。排查顺序是先SHOW CREATE DATABASE movie_ticket;看库的字符集再SHOW CREATE TABLE movie;看表的最后看连接串里有没有characterEncodingutf8。三处都必须是utf8mb4utf8在 MySQL 里是utf8mb3的别名存 emoji 和部分生僻字会失败。已经建好的表要改字段字符集只改库不生效。ALTER TABLE movie CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;改完重启应用再测别在旧连接上验证连接池里的连接字符集是建立时定的。5.4 ddl-autoupdate 与手写 SQL 打架字段改了不生效现象是你给实体加了一个字段重启后数据库里没有或者你把varchar(50)改成varchar(200)插入长文本仍然报Data too long。原因是ddl-autoupdate只会新增表和新增列不会修改已有列的类型和长度更不会删除列。这条坑的血泪经验是开发期可以图省事用update但只要动过表结构就必须手工执行ALTER语句同步或者干脆关掉自动建表用一份schema.sql管理。上线前一定改成spring.jpa.hibernate.ddl-autovalidate让应用启动时校验实体与表是否一致不一致直接启动失败比运行到一半报错强。5.5 页面 500template might not exist 与静态资源 404现象是接口能通浏览器打开首页返回 500日志里是Error resolving template [index], template might not exist。原因有三种模板文件没放在src/main/resources/templates下controller 上用了RestController而不是Controller返回值被当成 JSON 序列化或者方法返回的字符串和文件名大小写不一致。静态资源 404 则是路径问题resources/static/css/app.css对应的访问路径是/css/app.css不需要带static前缀。如果用了拦截器拦截/**记得把/css/**、/js/**加进排除名单否则样式会被拦成 302 跳转页面上看着像“样式丢了”。6. 二次开发验证用并发请求和状态机把选座锁做稳改完代码最该验的不是页面好不好看是座位会不会被重复占用。单机手动点两下测不出来得用并发打。# 20 个请求同时抢 A1 座位统计返回的状态码分布 seq 1 20 | xargs -I{} -P 20 curl -s -o /dev/null -w %{http_code}\n \ -X POST http://localhost:8080/api/order/lock \ -H Content-Type: application/json \ -d {scheduleId:1,seats:[A1],userId:2} | sort | uniq -c # 再看库里到底有几条记录正确结果应该只有 1 mysql -uroot -p movie_ticket -e \ select count(*) from seat_lock where schedule_id1 and seat_noA1;预期结果是状态码里只有一个 200其余是 4xxcount等于 1。如果出现多个 200说明唯一索引没建上或者代码里用的是save而不是saveAndFlush加事务回滚。这一步验完选座逻辑才算真正立住。6.1 用状态机管订单别用布尔值订单状态用pay_status这种 0/1 布尔字段后面加退款、超时取消时一定会返工。改成枚举PENDING_PAY、PAID、CANCELLED、REFUNDED每次变更校验前置状态只允许合法流转。配一个每分钟跑一次的清理任务把过期未支付的锁座记录和订单一起置为取消。Scheduled(fixedDelay 60_000) public void releaseExpired() { // 先释放锁座再关单顺序反了会出现锁着座但单已关闭 int released seatLockRepository.deleteByExpireAtBefore(LocalDateTime.now()); orderRepository.closeExpiredPending(LocalDateTime.now().minusMinutes(15)); log.info(本轮释放过期锁座 {} 条, released); }记得在主类上加EnableScheduling否则这个方法永远不会执行而且不报错属于典型的黑匣子问题。6.2 验证清单验证项方法通过标准座位不重复并发脚本抢同一位只有 1 个成功库中 1 条记录锁座会过期锁定后等 15 分钟再查记录被清理座位可重新选折扣正确会员下单比对金额与手算一致保留两位小数权限隔离普通用户访问 /admin跳转登录或返回 401从那以后我每次接手这类票务系统改完代码的第一件事不是打开浏览器看首页而是先把并发脚本跑一遍、把唯一索引和状态字段确认一遍再谈界面和运营功能。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑