首页 / 资讯中心 / 文章详情

基于SSM+MySQL的在线收银系统设计与实现:从架构到二次开发

基于SSM+MySQL的在线收银系统设计与实现:从架构到二次开发 ★ FEATURED ARTICLE
简介面向小型零售企业、超市、便利店及专卖店场景的在线收银系统毕业设计项目基于Java、SSM框架与MySQL 5.7实现为计算机相关专业毕业设计、课程设计提供可直接运行参考。系统围绕收银与销售管理闭环覆盖系统用户管理、员工管理、用户管理、商品类别管理、商品管理、入库管理、销售管理和销售统计功能层次清楚便于理解SSM框架下从请求处理、业务逻辑到数据持久化的完整过程。项目开发环境完整涉及Maven构建、Tomcat部署、MySQL数据库及Navicat工具适合希望系统学习Java Web整合开发的开发者。压缩包约23.97MB内含项目源码、说明文档与LW文档文件总数暂未单独统计源码注释清晰、包结构合理便于定位模块和二次开发。已有78人浏览学习适合作为毕业设计选题参考、SSM项目实战练习或小型零售收银场景的开发基线。1. 在线收银系统能做什么把手工记账变成全链路收银闭环一家小超市的晚高峰三个顾客同时拿着商品来结账店主一边在纸上记价格一边担心库存对不上月底对账更是要翻一整天的单据。这是很多小门店的真实写照也是这套基于 java ssm mysql 的在线收银系统毕业设计源码要解决的痛点。它把商品管理、购物车结算、订单生成、会员储值、销售统计全部收进同一个后台作为课程设计或毕业设计选题SSM 三层架构清晰、代码量适中很适合用来理解 Spring 容器管理、MyBatis 数据映射和前端页面交互的完整协作链路。不管你是想拿现成源码跑一个收银管理系统交作业还是想基于 SSM 练手改造这套项目都能作为起步模板。2. 技术选型与架构拆解SSM MySQL 的分层协作关系2.1 SSM 三件套的分工谁管对象、谁管路由、谁管数据库SSM 是 Spring Spring MVC MyBatis 的缩写在 Java Web 课程设计和毕业设计里出场率一直很高。它的核心思想是三层解耦Spring 负责管理所有对象的创建和依赖关系Spring MVC 负责接收 HTTP 请求并分发到对应的 ControllerMyBatis 负责把 SQL 语句和 Java 方法映射起来。层次技术职责表现层JSP Bootstrap jQuery页面渲染、表单交互、Ajax 请求控制层Spring MVC路由分发、参数绑定、返回视图或 JSON业务层Spring Service事务管理、业务规则校验、跨表操作编排持久层MyBatis MySQLSQL 映射、结果集封装、分页与条件查询构建工具Maven依赖管理、打包发布运行容器Tomcat 8Servlet 容器部署 war 包选择这套组合的好处是每一层都能独立替换。比如不想用 JSP可以把视图层换成 FreeMarker 或者前后端分离的 Vue Ajax不想用 MyBatis可以换成 Spring JDBC Template。但作为毕业设计SSM 的资料密度是最高的遇到问题搜索一下基本都有对应答案。我一般建议新手不要在第一版就引入 Spring Boot虽然 Spring Boot 配置更省事但 SSM 的手动配置反而能帮你把「过滤器、拦截器、事务、MyBatis 映射」这些底层机制看明白答辩被问到底层的概率也会小很多。2.2 项目目录逐层解剖从 controller 到 mapper 的依赖走向拿到源码后第一步不是急着启动而是先把目录结构过一遍。这套收银系统遵循标准的 Maven Web 项目布局源码包解压后你会看到典型的 ssm 分层结构。src/main/java ├── com.shop.controller │ ├── LoginController.java │ ├── GoodsController.java │ ├── OrderController.java │ ├── MemberController.java │ └── ReportController.java ├── com.shop.service │ ├── GoodsService.java │ ├── OrderService.java │ ├── MemberService.java │ └── ReportService.java ├── com.shop.dao │ ├── GoodsMapper.java │ ├── OrderMapper.java │ ├── MemberMapper.java │ └── ReportMapper.java ├── com.shop.entity │ ├── Goods.java │ ├── Order.java │ ├── OrderItem.java │ └── Member.java └── com.shop.interceptor └── LoginInterceptor.java src/main/resources ├── mappers │ ├── GoodsMapper.xml │ ├── OrderMapper.xml │ ├── MemberMapper.xml │ └── ReportMapper.xml ├── jdbc.properties └── spring-mybatis.xml src/main/webapp ├── WEB-INF │ ├── web.xml │ └── jsp │ ├── login.jsp │ ├── goods │ │ ├── list.jsp │ │ └── edit.jsp │ └── order │ ├── cashier.jsp │ └── list.jsp └── static ├── css ├── js └── images调用走向是 Controller → Service → Mapper 接口 → Mapper XML → MySQL。注意 Service 层接口和实现类是分开的实现类上标注 Service事务注解加在实现类的方法上。我在实际拆项目时一般先看 Controller 里有哪些请求映射就能快速判断这个系统提供了哪些功能入口再看 Service 实现类上的 Transactional就能知道哪些操作被当成了一个原子操作。2.3 数据库表设计六张核心表撑起收银全流程数据库是这套系统里信息密度最高的部分。收银系统涉及商品、订单、会员、员工四类核心业务对象表设计直接决定功能能做多深。常见的表结构是围绕 t_goods、t_order、t_order_item、t_member、t_user、t_category 这六张表展开的。CREATE TABLE t_goods ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL COMMENT 商品分类ID, name VARCHAR(64) NOT NULL COMMENT 商品名称, price DECIMAL(10, 2) NOT NULL COMMENT 销售单价, stock INT NOT NULL DEFAULT 0 COMMENT 库存数量, image VARCHAR(255) DEFAULT NULL COMMENT 商品图片路径, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架 ) COMMENT 商品表; CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE COMMENT 订单号, member_id INT DEFAULT NULL COMMENT 会员ID非会员为NULL, cashier_id INT NOT NULL COMMENT 收银员ID, total_amount DECIMAL(10, 2) NOT NULL COMMENT 订单总金额, pay_method TINYINT NOT NULL COMMENT 1现金 2微信 3支付宝 4会员余额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退款, create_time DATETIME NOT NULL COMMENT 下单时间 ) COMMENT 订单主表; CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, goods_id INT NOT NULL, goods_name VARCHAR(64) NOT NULL COMMENT 商品名称快照, price DECIMAL(10, 2) NOT NULL COMMENT 成交单价快照, count INT NOT NULL COMMENT 购买数量, subtotal DECIMAL(10, 2) NOT NULL COMMENT 小计金额 ) COMMENT 订单明细表;这里有几个设计细节值得学习订单号和商品名称都做了「快照」冗余。订单号用业务编号而非自增 ID 作为订单唯一标识是为了后续对账、退款时方便追踪明细表冗余 goods_name 和 price是为了防止商品后续改名、改价后历史订单对不上账。这是一个很多毕设容易忽略的细节但答辩时能讲出来会加分不少。3. 核心模块实现一个订单从加购到入库的完整链路3.1 登录与权限控制拦截器挡住未授权请求收银系统不是匿名系统收银员必须登录后才能操作。这套系统的权限控制用的是 Spring MVC 的 HandlerInterceptor在 web.xml 里注册了 Spring 的 DispatcherServlet 后拦截器配置放在 spring-mvc.xml 中。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从 Session 中取登录用户登录成功时放入 User loginUser (User) request.getSession().getAttribute(loginUser); if (loginUser null) { // 未登录时重定向到登录页 response.sendRedirect(request.getContextPath() /login); return false; } return true; } }mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/static/**/ bean classcom.shop.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorspreHandle 方法返回 true 才放行请求返回 false 就直接中断。这里要特别注意 exclude-mapping 的使用登录页本身、静态资源路径必须放行否则会出现「访问登录页也被重定向到登录页」的无限循环。密码的存储方式通常是 MD5 加盐后再入库登录时把输入密码做同样处理后比对避免明文密码直接落库。3.2 商品分页与条件查询PageHelper 怎么和 MyBatis 配合收银台要快速定位商品商品列表页要做分页展示。这套系统的分页方案是 MyBatis 的分页插件 PageHelper它通过拦截 SQL 自动拼接 LIMIT 语句使用方式是在查询前调用 PageHelper.startPage。Service public class GoodsServiceImpl implements GoodsService { Autowired private GoodsMapper goodsMapper; Override public PageInfoGoods page(Integer pageNum, Integer pageSize, String keyword) { // 注意startPage 只对其后第一条查询语句生效 PageHelper.startPage(pageNum, pageSize); ListGoods goodsList goodsMapper.selectByKeyword(keyword); return new PageInfo(goodsList); } }select idselectByKeyword resultTypecom.shop.entity.Goods SELECT * FROM t_goods WHERE status 1 if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR category_id (SELECT id FROM t_category WHERE name LIKE CONCAT(%, #{keyword}, %))) /if ORDER BY id DESC /selectpageNum 的起始值是 1 而不是 0pageSize 每页条数在 Controller 绑定默认值 10。PageInfo 对象里封装了 total、pages、list 等分页数据JSP 页面拿到后直接遍历 list 并渲染分页条。keyword 的模糊查询用 CONCAT 拼接百分号而不是直接在 Java 代码里拼好字符串传给 SQL这样可以避免注入风险同时保留 #{keyword} 的参数预编译特性。3.3 收银核心购物车结算与订单生成的事务边界收银是整套系统的核心也是最容易翻车的地方。一个完整的结算动作包含验库存、试算金额、扣库存、生成主单、生成明细、处理会员余额与积分这么多步必须在一个事务里完成否则就会出现「钱扣了订单没生成」或者「库存扣了商品没卖出去」的尴尬场面。Transactional(rollbackFor Exception.class) public Order createOrder(OrderVO vo) { // 1. 生成业务订单号时间戳 4位随机数 String orderNo OD System.currentTimeMillis() RandomUtil.randomNumbers(4); BigDecimal total BigDecimal.ZERO; ListOrderItem items new ArrayList(); // 2. 遍历购物车明细逐项校验库存并扣减 for (CartItem cart : vo.getCartItems()) { Goods goods goodsMapper.selectById(cart.getGoodsId()); if (goods null || goods.getStock() cart.getCount()) { throw new RuntimeException(商品库存不足请调整数量); } // 用带条件的 UPDATE 保证并发扣减安全 int affected goodsMapper.decreaseStock(goods.getId(), cart.getCount()); if (affected 0) { throw new RuntimeException(商品【 goods.getName() 】扣库存失败); } OrderItem item new OrderItem(null, null, goods.getId(), goods.getName(), goods.getPrice(), cart.getCount(), goods.getPrice().multiply(new BigDecimal(cart.getCount()))); items.add(item); total total.add(item.getSubtotal()); } // 3. 插入订单主表拿到自增主键 Order order new Order(null, orderNo, vo.getMemberId(), vo.getCashierId(), total, vo.getPayMethod(), 1, new Date()); orderMapper.insert(order); // 4. 批量插入订单明细 orderMapper.batchInsertItems(order.getId(), items); return order; }这段代码有几个关键点值得细看。首先是扣库存这一步decreaseStock 的 SQL 是UPDATE t_goods SET stock stock - #{count} WHERE id #{id} AND stock #{count}它把「判断库存是否够」和「扣减库存」合成了一条原子 SQL而不是先 SELECT 再 UPDATE这是避免高并发下超卖的基础写法。返回值 affected 为 0 表示条件不满足直接抛异常让事务回滚。其次是 Transactional 注解rollbackFor 指定任何异常都触发回滚。这里有个隐藏的坑Spring 默认只在 RuntimeException 时回滚如果在方法里 catch 住了异常不重新抛出事务边界就失效了。所以我在写这类方法时有个习惯——core 逻辑里不 catch 业务异常让事务拦截器统一处理。4. 报表与会员从记账工具到经营助手4.1 销售统计按天按月的聚合 SQL 怎么写收银系统不只是收款还要回答「今天卖了多少、哪类商品卖得好」。报表模块的数据来源是 t_order 表通过 SQL 的 GROUP BY 聚合就能得到日报和月报。MyBatis 的 mapper 里定义一个返回 Map 的查询方法比较灵活。select idcountDailySales resultTypejava.util.Map SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS orderCount, SUM(total_amount) AS totalAmount FROM t_order WHERE status 1 AND create_time gt; #{startDate} AND create_time lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY) GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day /select这里为什么要用create_time DATE_ADD(#{endDate}, INTERVAL 1 DAY)而不是create_time #{endDate}因为如果 endDate 传的是「2025-06-10」它代表的是当天的 0 点直接用 会漏掉 6 月 10 日全天的数据。用左闭右开区间配合 DATE_ADD 把截止时间推到次日零点才能完整覆盖。同样的写法在统计月度数据时也成立传参时把 startDate 和 endDate 在 Java 代码里算好即可。日报表通常还会带一个商品分类维度的子查询按 t_order_item 的 goods_name 分组统计销量 Top N配合这个日汇总一起展示。报表页面的查询条件是日期范围选择器Submit 后 Controller 接收两个 String 参数用 SimpleDateFormat 转成 Date 再传给 Service。4.2 会员储值与积分余额扣减的原子操作会员模块包含开户、充值、消费扣款、积分累计四条链路。会员消费时如果选择「会员余额支付」Service 层要同时做两件事扣减余额和生成订单。余额扣减的 SQL 和库存扣减是同一个套路必须用UPDATE ... WHERE balance amount保证余额不为负数。Transactional(rollbackFor Exception.class) public void recharge(Long memberId, BigDecimal amount) { // 入参校验充值金额必须大于0 if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new RuntimeException(充值金额必须大于0); } // SQL: UPDATE t_member SET balance balance #{amount} WHERE id #{memberId} memberMapper.addBalance(memberId, amount); // 插入充值流水便于对账 rechargeMapper.insert(new RechargeRecord(memberId, amount)); }BigDecimal 是金额计算必须用的类型如果这里用 double0.1 0.2 会出现精度误差对账时就会有几分钱的差额。会员消费时按等级折扣后积分按实付金额 1:1 累积在同一个事务里更新 t_member 的 balance 和 points 两个字段。// 会员折扣计算普通会员98折银卡95折金卡9折 BigDecimal realPay total; if (member ! null) { switch (member.getLevel()) { case 2: realPay total.multiply(new BigDecimal(0.95)); break; case 3: realPay total.multiply(new BigDecimal(0.90)); break; default: realPay total.multiply(new BigDecimal(0.98)); } // 积分满1000可抵10元一次订单限抵一次 if (member.getPoints() 1000) { BigDecimal afterPoints realPay.subtract(new BigDecimal(10)); if (afterPoints.compareTo(BigDecimal.ZERO) 0) { realPay afterPoints; } } }折扣计算这个分支里有个常见疏漏用 if-else 链处理等级时别忘了 default 分支。如果会员等级在数据库里多个空值或未知值不写 default 会导致空指针或折扣不生效。积分抵扣后的金额要做下限保护避免出现「抵扣后实际支付为负数」的笑话。4.3 日结单对账订单状态机与退款处理收银系统每天结束时要打一张日结单汇总当天的订单数和实收金额。这个汇总结果的准确性依赖订单状态字段的约束。t_order 的 status 字段定义了 0 待支付、1 已支付、2 已退款 三种状态日结单的 SQL 只统计 status 1 的订单。退款操作不是直接删订单而是把 status 改成 2同时把订单明细里的商品数量回补库存。回补库存 SQL 同样带条件更新只是方向反过来UPDATE t_goods SET stock stock #{count} WHERE id #{goodsId}。这里要注意退款后会员积分要扣回否则会出现退款后积分还能再用的漏洞。对账页面的设计是左侧列出当日订单列表右侧展示总单数、总金额、退款单数、退款金额四个统计卡片数据全部来自当日区间查询的结果集。5. 跑通源码的避坑指南环境、数据库、事务的踩坑记录5.1 项目启动就报 ClassNotFoundExceptionjar 没发布到 Tomcat现象Tomcat 启动时抛出 ClassNotFoundException提示找不到 org.springframework.web.context.ContextLoaderListener 或 org.apache.ibatis 相关类。原因Maven 依赖已经下载到了本地仓库但 Eclipse / IDEA 里项目部署到 Tomcat 时没有把 Maven 依赖同步到 WEB-INF/lib 目录。IDEA 里表现为「项目右键 → Run Configuration 看不到依赖」Eclipse 里是 Deployment Assembly 没有添加 Maven Dependencies。解决在 IDEA 里打开 Project Structure → Artifacts选中部署的 war exploded 包在 Output Layout 里点「」→ Library Files 把 Maven 依赖加进去或者直接检查 pom.xml 的packagingwar/packaging没有缺失。跑项目前养成先看 pom 是否干净、本地仓库依赖是否完整的习惯能省下大量排查时间。5.2 数据库连接失败驱动、时区、字符集的三连坑现象页面打开后报 500控制台提示 Communications link failure 或 Access denied for user rootlocalhost好一点的情况是连着 MySQL 但中文乱码。原因常见的有三种。一是 jdbc.properties 里密码写错或 MySQL 8 的驱动类名和连接 URL 不匹配——MySQL 5 用 com.mysql.jdbc.DriverMySQL 8 必须用 com.mysql.cj.jdbc.Driver二是连接 URL 少了 serverTimezoneAsia/Shanghai 参数MySQL 8 默认时区配置会导致连接报错三是 URL 里的 characterEncodingutf8 没写导致中文写入后变问号。解决统一把 URL 配置成jdbc:mysql://localhost:3306/shop_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse。导入 SQL 脚本用 source 命令执行时确认终端和 SQL 文件都是 UTF-8 编码。字符集这个问题我在本地跑通后放到服务器上遇到过好几次后来学乖了凡是数据库配置一律在 jdbc.properties 里显式写清楚编码参数不在默认值上赌运气。5.3 分页插件不生效版本冲突和 startPage 位置现象点击商品列表第二页返回的还是第一页数据或者分页总条数不对控制台打印的 SQL 里没有 LIMIT 语句。原因PageHelper 的版本和 MyBatis 版本不兼容项目里同时存在多个 mybatis-core 版本另一种情况是 startPage 之后在调用第一条 SQL 之前中间又执行了其他查询导致 PageHelper 的分页参数被其他查询消费掉后续真正要分页的查询就没分页了。解决在 pom.xml 里检查 mybatis-spring、mybatis、pagehelper 三个依赖的版本搭配我这边稳定使用的组合是 mybatis 3.4.6 pagehelper 5.1.11 mybatis-spring 1.3.2。启动时打开日志看 SQL 输出确认 SELECT 后面确实拼接了 LIMIT。习惯上把 PageHelper.startPage 写在 Service 方法的第一行紧随其后的第一条查询就是目标查询中间不要插任何其他数据库操作。5.4 还款不生效订单没入库但也没报错现象收银台提交订单后页面显示成功但数据库里找不到订单控制台也没有任何异常日志。原因Transactional 事务失效。常见两种一是 Service 实现类的方法上注解写错位置或者方法被同类内部调用绕过 Spring 代理导致事务不生效二是方法内部把异常 catch 住了比如在循环里对单个商品 try-catch事务拦截器感知不到异常于是默认提交。解决事务注解加在 public 方法上不在私有方法上加业务代码里不 catch 业务异常让异常抛到事务边界。如果实在要捕获单个商品的处理失败设计上应该先全部校验成功再统一写入而不是逐个 try-catch。这个坑我调试过很久最后发现删掉一层 try-catch 后事务立刻恢复正常。从那以后我写事务代码先看异常能不能顺利抛出去再考虑业务逻辑。5.5 登录成功后跳转死循环Session 丢失和路径问题现象登录页输入账号密码后成功进入首页但点击任意菜单又跳回登录页有时刷新一下也掉登录态。原因Session 作用域没设置对或浏览器禁用 Cookie 导致 Session ID 无法传递。另一个可能页面里的跳转路径带了多余的前缀访问的 URL 和拦截器 exclude 的路径不匹配。解决检查 web.xml 里的 session-config 是否设置了 session-timeout默认 30 分钟登录成功后把 User 对象放进 session 的 key 要和拦截器里取的一致这里我见过最乌龙的是把 key 写成 loginUser 一个地方写 user核对一遍就知道。路径方面建议用${pageContext.request.contextPath}生成绝对路径不要写相对路径。排查这类问题时直接看浏览器地址栏的 URL 和 Network 面板有没有 302 重定向能快速确认是哪一步被拦截。6. 二次开发实战给收银系统接入新的支付方式6.1 扩展点分析在哪加代码最合适这套收银系统目前的支付方式写死为现金、微信、支付宝、会员余额四种枚举值。要接入微信扫码支付核心改动只有三个点一是 t_order 表的 pay_method 加一个枚举值二是 OrderService 里新建订单后调用支付服务生成支付二维码三是新增一个回调 Controller 接收支付结果。不要改动订单生成的原有事务逻辑把它当作一个下游服务来对接。扩展的做法是定义 PayService 接口提供PayResult createPayUrl(Order order)和boolean handleNotify(String orderNo, BigDecimal amount, String sign)两个方法。OrderService 在创建订单成功后调用 createPayUrl 返回二维码字符串由前端渲染成二维码图片。回调接口与订单事务完全分离这样模拟支付失败不影响核心链路。6.2 模拟支付回调幂等、验签、金额校验真实支付回调最容易犯的错是重复通知和恶意伪造。微信的异步通知会多次投递回调接口必须保证重复请求不重复处理。RestController RequestMapping(/pay/notify) public class PayNotifyController { Autowired private OrderService orderService; /** * 模拟支付回调参数 orderNo 订单号、amount 实付金额、sign 签名 */ PostMapping(/wechat) public String wechatNotify(RequestParam String orderNo, RequestParam BigDecimal amount, RequestParam String sign) { // 1. 签名校验这里用固定值模拟真实项目要用商户密钥做 HMAC 校验 if (!SIMULATE_SIGN.equals(sign)) { return fail; } // 2. 幂等判断订单已支付直接返回 success避免重复入账 Order order orderService.findByOrderNo(orderNo); if (order null) { return fail; } if (order.getStatus() 1) { return success; } // 3. 金额校验回调金额必须与订单金额一致 if (amount.compareTo(order.getTotalAmount()) ! 0) { return fail; } // 4. 更新订单状态为已支付 orderService.markPaid(orderNo); return success; } }回调处理的三道防线是签名、幂等、金额校验缺一不可。签名校验保证了请求不是伪造的幂等判断保证了重复通知不会把同一笔订单改成两次支付成功金额校验保证了即使回调地址被模拟金额不一致也能被拦截。实际开发时回调接口通常还会加一层 IP 白名单校验只允许支付服务商的回调 IP 访问。改造完支付逻辑下一个可以动手的点是把购物车从 Session 搬到 Redis。现在这套系统的购物车数据存在 HttpSession 里换一台收银机登录购物车就没了。改成 Redis 后以 cashier_id 或会员 ID 为 key 存储购物车结构不仅大华门店多台收银机能共享购物车也为后续接入小程序点单留了扩展位。改造时原来的 CartService 接口不用动只替换实现类里存取数据的存储介质控制层完全感知不到变化。那次的教训是万事别急着动手改主流程。我每次拿到一套新的 SSM 项目先花半小时理清 Service 之间的依赖和事务边界再决定在哪个位置加扩展点改完一处就跑一遍回归最后再补接口文档。这个过程走顺了二次开发的效率比直接闷头写高很多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站