detailDesign.md 65 KB

校园闲置物品交易平台 — 详细设计


第1章 服务端详细设计

1.1 技术栈明细

层次 技术 版本 用途
框架 SpringBoot 2.7.x 应用容器
ORM MyBatis + MyBatis-Spring 2.x 数据访问
分页 PageHelper 5.x 分页插件
数据库 MySQL 8.x 关系型数据库
连接池 HikariCP 内置 数据库连接池
API 文档 springdoc-openapi 2.x OpenAPI 3 文档自动生成
密码加密 MD5 (Spring DigestUtils) 内置 密码哈希
JSON Jackson 内置 序列化/反序列化
文件上传 MultipartFile Spring 内置 文件上传
校验 Hibernate Validator 6.x 参数校验

1.2 关键 Maven 依赖

<!-- pom.xml 关键依赖 -->
<!-- SpringDoc OpenAPI 文档 -->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.8.16</version>
</dependency>

<!-- MyBatis 分页插件 -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>2.1.0</version>
</dependency>

<!-- 参数校验 @Valid @NotBlank -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- 密码加密使用 Spring 内置 DigestUtils(MD5),无需额外依赖 -->
# application.yml 增加 springdoc 配置
springdoc:
  api-docs:
    path: /v3/api-docs          # OpenAPI JSON 路径
  swagger-ui:
    path: /swagger-ui.html      # Swagger UI 路径
    enabled: true
  packages-to-scan: com.campus.trade.controller
  default-produces-media-type: application/json

访问路径:

  • OpenAPI JSON:http://localhost:8080/v3/api-docs
  • Swagger UI:http://localhost:8080/swagger-ui.html

1.3 数据库 DDL

-- 用户表
CREATE TABLE `user` (
  `id`            BIGINT       NOT NULL AUTO_INCREMENT,
  `student_id`    VARCHAR(20)  NOT NULL,
  `name`          VARCHAR(50)  NOT NULL,
  `password_hash` VARCHAR(255) NOT NULL,
  `nickname`      VARCHAR(50)  DEFAULT NULL,
  `avatar_url`    VARCHAR(255) DEFAULT NULL,
  `signature`     VARCHAR(200) DEFAULT NULL,
  `campus_card_url` VARCHAR(255) DEFAULT NULL,
  `auth_status`   VARCHAR(20)  NOT NULL DEFAULT 'UNAUTHENTICATED',
  `status`        VARCHAR(20)  NOT NULL DEFAULT 'NORMAL',
  `role`          VARCHAR(20)  NOT NULL DEFAULT 'USER',
  `created_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_student_id` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 分类表
CREATE TABLE `category` (
  `id`         BIGINT      NOT NULL AUTO_INCREMENT,
  `name`       VARCHAR(50) NOT NULL,
  `status`     VARCHAR(20) NOT NULL DEFAULT 'ENABLED',
  `sort_order` INT         NOT NULL DEFAULT 0,
  `created_at` DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 物品表
CREATE TABLE `item` (
  `id`           BIGINT        NOT NULL AUTO_INCREMENT,
  `seller_id`    BIGINT        NOT NULL,
  `category_id`  BIGINT        DEFAULT NULL,
  `title`        VARCHAR(100)  NOT NULL,
  `description`  TEXT,
  `price`        DECIMAL(10,2) NOT NULL,
  `images`       TEXT,
  `trade_method` VARCHAR(20)   NOT NULL,
  `status`       VARCHAR(20)   NOT NULL DEFAULT 'PENDING',
  `created_at`   DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`   DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_seller_id` (`seller_id`),
  KEY `idx_category_id` (`category_id`),
  KEY `idx_status` (`status`),
  KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 订单表
CREATE TABLE `order` (
  `id`           BIGINT      NOT NULL AUTO_INCREMENT,
  `item_id`      BIGINT      NOT NULL,
  `buyer_id`     BIGINT      NOT NULL,
  `seller_id`    BIGINT      NOT NULL,
  `trade_method` VARCHAR(20) NOT NULL,
  `status`       VARCHAR(20) NOT NULL DEFAULT 'PENDING',
  `created_at`   DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`   DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_item_id` (`item_id`),
  KEY `idx_buyer_id` (`buyer_id`),
  KEY `idx_seller_id` (`seller_id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 消息表
CREATE TABLE `message` (
  `id`           BIGINT       NOT NULL AUTO_INCREMENT,
  `from_user_id` BIGINT       NOT NULL,
  `to_user_id`   BIGINT       NOT NULL,
  `item_id`      BIGINT       DEFAULT NULL,
  `content`      TEXT,
  `image_url`    VARCHAR(255) DEFAULT NULL,
  `is_read`      TINYINT(1)   NOT NULL DEFAULT 0,
  `created_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_from_user_id` (`from_user_id`),
  KEY `idx_to_user_id` (`to_user_id`),
  KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 审核记录表
CREATE TABLE `review` (
  `id`           BIGINT       NOT NULL AUTO_INCREMENT,
  `admin_id`     BIGINT       NOT NULL,
  `review_type`  VARCHAR(20)  NOT NULL,
  `target_id`    BIGINT       NOT NULL,
  `result`       VARCHAR(20)  NOT NULL,
  `reason`       VARCHAR(255) DEFAULT NULL,
  `created_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_review_type_target` (`review_type`, `target_id`),
  KEY `idx_admin_id` (`admin_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

1.4 Entity 类设计

User.java

package com.campus.trade.entity;

import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;

@Schema(description = "用户实体")
public class User {
    private Long id;
    private String studentId;
    private String name;
    private String passwordHash;
    private String nickname;
    private String avatarUrl;
    private String signature;
    private String campusCardUrl;
    private String authStatus;    // UNAUTHENTICATED / PENDING / AUTHENTICATED
    private String status;        // NORMAL / BANNED
    private String role;          // USER / ADMIN
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    // getters & setters
}

Item.java

package com.campus.trade.entity;

import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Schema(description = "物品实体")
public class Item {
    private Long id;
    private Long sellerId;
    private Long categoryId;
    private String title;
    private String description;
    private BigDecimal price;
    private String images;
    private String tradeMethod;     // PICKUP / DELIVERY
    private String status;          // PENDING / ON_SALE / OFF_SHELF / SOLD
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    // getters & setters
}

Order.java / Message.java / Review.java / Category.java

结构与概要设计一致,所有 Entity 添加 @Schema(description = "...") 类注解。

1.5 DTO / VO 设计(含 SpringDoc Schema 注解)

// -------- 请求 DTO --------

@Schema(description = "登录请求")
public class LoginRequest {
    @Schema(description = "学号", example = "2021001001", required = true)
    @NotBlank private String studentId;

    @Schema(description = "密码", example = "123456", required = true)
    @NotBlank private String password;
}

@Schema(description = "注册请求(multipart/form-data)")
public class RegisterRequest {
    @Schema(description = "学号", example = "2021001001", required = true)
    @NotBlank private String studentId;

    @Schema(description = "姓名", example = "张三", required = true)
    @NotBlank private String name;

    @Schema(description = "密码", example = "123456", required = true)
    @NotBlank private String password;

    @Schema(description = "校园卡照片文件")
    private MultipartFile campusCardImage;
}

@Schema(description = "发布物品请求(multipart/form-data)")
public class ItemPublishRequest {
    @Schema(description = "标题", example = "全新高数课本", required = true)
    @NotBlank private String title;

    @Schema(description = "描述", example = "九成新,无涂写")
    private String description;

    @Schema(description = "价格", example = "25.00", required = true)
    @NotNull private BigDecimal price;

    @Schema(description = "分类ID", example = "1")
    private Long categoryId;

    @Schema(description = "交易方式", allowableValues = {"PICKUP", "DELIVERY"}, example = "PICKUP", required = true)
    @NotBlank private String tradeMethod;

    @Schema(description = "物品图片列表")
    private List<MultipartFile> images;
}

@Schema(description = "修改密码请求")
public class ChangePasswordRequest {
    @Schema(description = "旧密码", required = true) @NotBlank private String oldPassword;
    @Schema(description = "新密码", required = true) @NotBlank private String newPassword;
}

@Schema(description = "修改个人资料请求(multipart/form-data)")
public class UserProfileRequest {
    @Schema(description = "昵称", example = "小明") private String nickname;
    @Schema(description = "个性签名", example = "热爱生活") private String signature;
    @Schema(description = "头像文件") private MultipartFile avatar;
}

@Schema(description = "审核请求")
public class ReviewRequest {
    @Schema(description = "审核结果", allowableValues = {"APPROVED", "REJECTED"}, required = true)
    @NotBlank private String result;
    @Schema(description = "驳回原因(驳回时必填)", example = "照片不清晰")
    private String reason;
}

@Schema(description = "创建订单请求")
public class CreateOrderRequest {
    @Schema(description = "物品ID", required = true, example = "1")
    @NotNull private Long itemId;
}

@Schema(description = "发送消息请求")
public class SendMessageRequest {
    @Schema(description = "接收方用户ID", required = true, example = "2")
    @NotNull private Long toUserId;
    @Schema(description = "关联物品ID", example = "1")
    private Long itemId;
    @Schema(description = "文本内容")
    private String content;
    @Schema(description = "图片URL")
    private String imageUrl;
}

@Schema(description = "分类请求")
public class CategoryRequest {
    @Schema(description = "分类名称", example = "教材", required = true)
    @NotBlank private String name;
    @Schema(description = "排序", example = "1")
    private Integer sortOrder;
}

// -------- 响应 VO --------

@Schema(description = "用户信息")
public class UserProfileVO {
    @Schema(description = "用户ID", example = "1")       private Long id;
    @Schema(description = "学号", example = "2021001001") private String studentId;
    @Schema(description = "姓名")                          private String name;
    @Schema(description = "昵称")                          private String nickname;
    @Schema(description = "头像URL")                      private String avatarUrl;
    @Schema(description = "个性签名")                      private String signature;
    @Schema(description = "认证状态")                      private String authStatus;
    @Schema(description = "账号状态")                      private String status;
    @Schema(description = "角色")                          private String role;
    @Schema(description = "创建时间")                      private LocalDateTime createdAt;
}

@Schema(description = "物品信息")
public class ItemVO {
    @Schema(description = "物品ID", example = "1")        private Long id;
    @Schema(description = "卖家ID")                       private Long sellerId;
    @Schema(description = "卖家昵称")                     private String sellerNickname;
    @Schema(description = "卖家头像")                     private String sellerAvatar;
    @Schema(description = "分类名称")                     private String categoryName;
    @Schema(description = "标题")                         private String title;
    @Schema(description = "描述")                         private String description;
    @Schema(description = "价格")                         private BigDecimal price;
    @Schema(description = "图片列表")                     private List<String> images;
    @Schema(description = "交易方式")                     private String tradeMethod;
    @Schema(description = "状态")                         private String status;
    @Schema(description = "发布时间")                     private LocalDateTime createdAt;
}

@Schema(description = "订单信息")
public class OrderVO {
    @Schema(description = "订单ID")                       private Long id;
    @Schema(description = "物品ID")                       private Long itemId;
    @Schema(description = "物品标题")                     private String itemTitle;
    @Schema(description = "物品缩略图")                   private String itemImage;
    @Schema(description = "物品价格")                     private BigDecimal itemPrice;
    @Schema(description = "买家ID")                       private Long buyerId;
    @Schema(description = "买家昵称")                     private String buyerNickname;
    @Schema(description = "卖家ID")                       private Long sellerId;
    @Schema(description = "卖家昵称")                     private String sellerNickname;
    @Schema(description = "交易方式")                     private String tradeMethod;
    @Schema(description = "订单状态")                     private String status;
    @Schema(description = "创建时间")                     private LocalDateTime createdAt;
    @Schema(description = "更新时间")                     private LocalDateTime updatedAt;
}

@Schema(description = "消息信息")
public class MessageVO {
    @Schema(description = "消息ID")                       private Long id;
    @Schema(description = "发送方ID")                     private Long fromUserId;
    @Schema(description = "发送方昵称")                   private String fromUserNickname;
    @Schema(description = "发送方头像")                   private String fromUserAvatar;
    @Schema(description = "接收方ID")                     private Long toUserId;
    @Schema(description = "接收方昵称")                   private String toUserNickname;
    @Schema(description = "文本内容")                     private String content;
    @Schema(description = "图片URL")                      private String imageUrl;
    @Schema(description = "是否已读")                     private Boolean isRead;
    @Schema(description = "发送时间")                     private LocalDateTime createdAt;
}

@Schema(description = "会话信息")
public class ConversationVO {
    @Schema(description = "对方用户ID")                   private Long userId;
    @Schema(description = "对方昵称")                     private String nickname;
    @Schema(description = "对方头像")                     private String avatarUrl;
    @Schema(description = "最后一条消息内容")              private String lastMessage;
    @Schema(description = "最后一条消息图片")              private String lastMessageImage;
    @Schema(description = "最后消息时间")                  private LocalDateTime lastMessageTime;
    @Schema(description = "未读数")                       private Integer unreadCount;
}

// -------- 统一分页结果 --------

@Schema(description = "分页结果")
public class PageResult<T> {
    @Schema(description = "数据列表")   private List<T> list;
    @Schema(description = "当前页码")   private int pageNum;
    @Schema(description = "每页条数")   private int pageSize;
    @Schema(description = "总记录数")   private long total;
    @Schema(description = "总页数")     private int pages;
}

1.6 Controller 层设计(含 SpringDoc 注解)

@RestController
@RequestMapping("/api/auth")
@Tag(name = "认证模块", description = "注册 / 登录 / 登出 / 修改密码")
public class AuthController {

    @Operation(summary = "用户注册", description = "学号+姓名+密码+校园卡照片,提交后进入待审核状态")
    @ApiResponse(responseCode = "200", description = "注册成功")
    @ApiResponse(responseCode = "409", description = "学号已注册")
    @PostMapping(value = "/register", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Result register(@Valid RegisterRequest req) { }

    @Operation(summary = "用户登录", description = "学号+密码登录,创建 Session 会话")
    @PostMapping("/login")
    public Result login(@Valid @RequestBody LoginRequest req, HttpSession session) { }

    @Operation(summary = "用户登出", description = "销毁当前 Session")
    @PostMapping("/logout")
    public Result logout(HttpSession session) { }

    @Operation(summary = "修改密码", description = "需提供旧密码验证")
    @PostMapping("/change-password")
    public Result changePassword(@Valid @RequestBody ChangePasswordRequest req, HttpSession session) { }
}

@RestController
@RequestMapping("/api/user")
@Tag(name = "用户模块", description = "个人资料管理")
public class UserController {

    @Operation(summary = "获取个人资料")
    @GetMapping("/profile")
    public Result profile(HttpSession session) { }

    @Operation(summary = "修改个人资料", description = "支持修改昵称、个性签名、头像")
    @PutMapping(value = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Result updateProfile(@Valid UserProfileRequest req, HttpSession session) { }
}

@RestController
@RequestMapping("/api/items")
@Tag(name = "物品模块", description = "物品发布、浏览、搜索、管理")
public class ItemController {

    @Operation(summary = "首页物品列表", description = "按发布时间倒序,分页返回")
    @GetMapping
    public Result list(@RequestParam(defaultValue = "1") int pageNum,
                       @RequestParam(defaultValue = "10") int pageSize) { }

    @Operation(summary = "搜索物品", description = "按标题/描述模糊匹配")
    @GetMapping("/search")
    public Result search(@RequestParam String keyword,
                         @RequestParam(defaultValue = "1") int pageNum,
                         @RequestParam(defaultValue = "10") int pageSize) { }

    @Operation(summary = "按分类筛选")
    @GetMapping("/category/{id}")
    public Result byCategory(@PathVariable Long id,
                             @RequestParam(defaultValue = "1") int pageNum,
                             @RequestParam(defaultValue = "10") int pageSize) { }

    @Operation(summary = "物品详情")
    @GetMapping("/{id}")
    public Result detail(@PathVariable Long id) { }

    @Operation(summary = "发布物品", description = "需要实名认证,图片以 multipart 上传")
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Result publish(@Valid ItemPublishRequest req, HttpSession session) { }

    @Operation(summary = "我的发布列表")
    @GetMapping("/my")
    public Result myItems(HttpSession session) { }

    @Operation(summary = "下架物品", description = "卖家下架自己的物品")
    @PutMapping("/{id}/offline")
    public Result offline(@PathVariable Long id, HttpSession session) { }

    @Operation(summary = "删除物品", description = "卖家删除自己的物品")
    @DeleteMapping("/{id}")
    public Result delete(@PathVariable Long id, HttpSession session) { }
}

@RestController
@RequestMapping("/api/orders")
@Tag(name = "订单模块", description = "订单创建、状态流转、双视角查询")
public class OrderController {

    @Operation(summary = "生成订单", description = "买家点击『我想要』生成订单")
    @PostMapping
    public Result create(@RequestBody @Valid CreateOrderRequest req, HttpSession session) { }

    @Operation(summary = "我买到的订单")
    @GetMapping("/bought")
    public Result bought(@RequestParam(defaultValue = "1") int pageNum,
                         @RequestParam(defaultValue = "10") int pageSize,
                         HttpSession session) { }

    @Operation(summary = "我卖出的订单")
    @GetMapping("/sold")
    public Result sold(@RequestParam(defaultValue = "1") int pageNum,
                       @RequestParam(defaultValue = "10") int pageSize,
                       HttpSession session) { }

    @Operation(summary = "卖家接单")
    @PutMapping("/{id}/accept")
    public Result accept(@PathVariable Long id, HttpSession session) { }

    @Operation(summary = "卖家拒单")
    @PutMapping("/{id}/reject")
    public Result reject(@PathVariable Long id, HttpSession session) { }

    @Operation(summary = "卖家确认交货")
    @PutMapping("/{id}/deliver")
    public Result deliver(@PathVariable Long id, HttpSession session) { }

    @Operation(summary = "买家确认收货")
    @PutMapping("/{id}/confirm")
    public Result confirm(@PathVariable Long id, HttpSession session) { }
}

@RestController
@RequestMapping("/api/messages")
@Tag(name = "消息模块", description = "站内私信,文本+图片")
public class MessageController {

    @Operation(summary = "会话列表", description = "返回当前用户的所有会话及最后一条消息")
    @GetMapping("/conversations")
    public Result conversations(HttpSession session) { }

    @Operation(summary = "聊天记录", description = "与指定用户的聊天记录,分页")
    @GetMapping("/{userId}")
    public Result history(@PathVariable Long userId,
                          @RequestParam(defaultValue = "1") int pageNum,
                          @RequestParam(defaultValue = "20") int pageSize,
                          HttpSession session) { }

    @Operation(summary = "发送消息")
    @PostMapping
    public Result send(@Valid @RequestBody SendMessageRequest req, HttpSession session) { }
}

@RestController
@RequestMapping("/api/admin")
@Tag(name = "审核模块", description = "管理员实名审核、物品审核")
public class ReviewController {

    @Operation(summary = "待审核用户列表")
    @GetMapping("/review/users")
    public Result pendingUsers(@RequestParam(defaultValue = "1") int pageNum,
                               @RequestParam(defaultValue = "10") int pageSize,
                               HttpSession session) { }

    @Operation(summary = "审核用户实名认证")
    @PostMapping("/review/user/{id}")
    public Result reviewUser(@PathVariable Long id,
                             @Valid @RequestBody ReviewRequest req,
                             HttpSession session) { }

    @Operation(summary = "待审核物品列表")
    @GetMapping("/review/items")
    public Result pendingItems(@RequestParam(defaultValue = "1") int pageNum,
                               @RequestParam(defaultValue = "10") int pageSize,
                               HttpSession session) { }

    @Operation(summary = "审核物品上架")
    @PostMapping("/review/item/{id}")
    public Result reviewItem(@PathVariable Long id,
                             @Valid @RequestBody ReviewRequest req,
                             HttpSession session) { }
}

@RestController
@RequestMapping("/api/admin")
@Tag(name = "用户管理", description = "管理员管理用户账号")
public class UserManageController {

    @Operation(summary = "用户列表")
    @GetMapping("/users")
    public Result list(@RequestParam(defaultValue = "1") int pageNum,
                       @RequestParam(defaultValue = "10") int pageSize,
                       HttpSession session) { }

    @Operation(summary = "封禁用户")
    @PutMapping("/users/{id}/ban")
    public Result ban(@PathVariable Long id, HttpSession session) { }

    @Operation(summary = "解封用户")
    @PutMapping("/users/{id}/unban")
    public Result unban(@PathVariable Long id, HttpSession session) { }

    @Operation(summary = "重置用户密码")
    @PostMapping("/users/{id}/reset-password")
    public Result resetPassword(@PathVariable Long id, HttpSession session) { }
}

@RestController
@RequestMapping("/api/admin/categories")
@Tag(name = "分类管理", description = "物品分类的增删改查")
public class CategoryController {

    @Operation(summary = "分类列表")
    @GetMapping
    public Result list() { }

    @Operation(summary = "新增分类")
    @PostMapping
    public Result create(@Valid @RequestBody CategoryRequest req) { }

    @Operation(summary = "修改分类")
    @PutMapping("/{id}")
    public Result update(@PathVariable Long id, @Valid @RequestBody CategoryRequest req) { }

    @Operation(summary = "启用/禁用分类")
    @PutMapping("/{id}/status")
    public Result toggleStatus(@PathVariable Long id, @RequestBody StatusRequest req) { }
}

1.7 Service 层核心业务逻辑

AuthService — 注册

sequenceDiagram
    participant C as Client
    participant AC as AuthController
    participant AS as AuthService
    participant UM as UserMapper
    participant FU as FileUploadUtil

    C->>AC: POST /api/auth/register (multipart/form-data)
    AC->>AS: register(request)
    AS->>UM: selectByStudentId(studentId)
    UM-->>AS: 返回 User / null
    AS->>AS: 若已存在,抛异常"学号已注册"
    AS->>FU: upload(campusCardImage)
    FU-->>AS: 返回 "/upload/xxx.jpg"
    AS->>AS: passwordHash = MD5.encode(password)
    AS->>AS: 构建 User entity (authStatus=PENDING)
    AS->>UM: insert(user)
    UM-->>AS: 返回自增ID
    AS-->>AC: 返回 Result.success(userId)
    AC-->>C: { code:200, message:"注册成功,等待实名审核" }

AuthService — 登录

sequenceDiagram
    participant C as Client
    participant AC as AuthController
    participant AS as AuthService
    participant UM as UserMapper

    C->>AC: POST /api/auth/login (学号+密码)
    AC->>AS: login(studentId, password, session)
    AS->>UM: selectByStudentId(studentId)
    UM-->>AS: 返回 User / null
    AS->>AS: 若为 null,抛异常"学号或密码错误"
    AS->>AS: 若 user.status == BANNED,抛异常"账号已被封禁"
    AS->>AS: 验证 MD5(password).equals(user.passwordHash)
    AS->>AS: 若失败,抛异常"学号或密码错误"
    AS->>AS: session.setAttribute("userId", user.id)
    AS->>AS: session.setAttribute("role", user.role)
    AS-->>AC: 返回 Result.success(userProfileVO)
    AC-->>C: { code:200, data:{ id, nickname, avatar, role, authStatus } }

ItemService — 发布物品

sequenceDiagram
    participant C as Client
    participant IC as ItemController
    participant IS as ItemService
    participant UM as UserMapper
    participant FU as FileUploadUtil
    participant IM as ItemMapper

    C->>IC: POST /api/items (multipart/form-data)
    IC->>IS: publish(request, userId)
    IS->>UM: selectById(userId)
    UM-->>IS: 返回 User
    IS->>IS: 校验 user.authStatus != AUTHENTICATED → 抛异常
    IS->>FU: 遍历 images,逐一上传
    FU-->>IS: 返回 ["/upload/a.jpg", "/upload/b.jpg"]
    IS->>IS: 构建 Item entity (status=PENDING)
    IS->>IM: insert(item)
    IM-->>IS: 返回自增ID
    IS-->>IC: 返回 Result.success(itemId)
    IC-->>C: { code:200, message:"发布成功,等待管理员审核" }

ItemService — 首页物品列表

sequenceDiagram
    participant C as Client
    participant IC as ItemController
    participant IS as ItemService
    participant IM as ItemMapper

    C->>IC: GET /api/items?pageNum=1&pageSize=10
    IC->>IS: list(pageNum, pageSize)
    IS->>IM: PageHelper.startPage(pageNum, pageSize)
    IM->>IM: selectByStatus("ON_SALE") ORDER BY created_at DESC
    IM-->>IS: PageInfo<Item>
    IS->>IS: 补充 sellerNickname, categoryName 等
    IS-->>IC: 返回 PageResult<ItemVO>
    IC-->>C: { code:200, data:{ list:[...], total, pageNum, pageSize, pages } }

OrderService — 订单状态流转

stateDiagram-v2
    [*] --> PENDING: 买家点击"我想要"
    PENDING --> CONFIRMED: 卖家确认接单
    PENDING --> CANCELLED: 卖家拒绝接单
    CONFIRMED --> COMPLETED: 卖家确认交货 + 买家确认收货
    CONFIRMED --> CANCELLED: 任意方取消
    COMPLETED --> [*]
    CANCELLED --> [*]
sequenceDiagram
    participant B as 买家
    participant S as 卖家
    participant OC as OrderController
    participant OS as OrderService
    participant OM as OrderMapper
    participant IM as ItemMapper

    B->>OC: POST /api/orders { itemId }
    OC->>OS: create(itemId, buyerId)
    OS->>IM: selectById(itemId)
    IM-->>OS: 返回 Item
    OS->>OS: 校验 item.status == ON_SALE
    OS->>OS: 构建 Order (status=PENDING)
    OS->>OM: insert(order)
    OS->>IM: update item.status = SOLD
    OS-->>OC: 返回 orderId
    OC-->>B: 订单已创建,待卖家接单

    S->>OC: PUT /api/orders/{id}/accept
    OC->>OS: accept(orderId, sellerId)
    OS->>OM: selectById(orderId) → 校验 → updateStatus(CONFIRMED)
    OC-->>S: 接单成功,等待线下交易

    S->>OC: PUT /api/orders/{id}/deliver
    OC->>OS: deliver(orderId, sellerId)
    OS->>OM: updateStatus(id, DELIVERED)
    OC-->>S: 已交货,等待买家确认

    B->>OC: PUT /api/orders/{id}/confirm
    OC->>OS: confirm(orderId, buyerId)
    OS->>OM: updateStatus(id, COMPLETED)
    OC-->>B: 交易完成

MessageService — 私信

sequenceDiagram
    participant B as 买家
    participant MC as MessageController
    participant MS as MessageService
    participant MM as MessageMapper

    B->>MC: POST /api/messages { toUserId, itemId, content }
    MC->>MS: send(request, fromUserId)
    MS->>MS: 构建 Message entity (isRead=false)
    MS->>MM: insert(message)
    MM-->>MS: 返回 id
    MS-->>MC: 返回 messageId + createdAt
    MC-->>B: 消息发送成功

    B->>MC: GET /api/messages/conversations
    MC->>MS: conversations(userId)
    MS->>MM: selectConversations(userId)
    MM-->>MS: 返回会话列表
    MS-->>MC: 返回 List<ConversationVO>
    MC-->>B: 会话列表渲染

    B->>MC: GET /api/messages/{userId}?pageNum=1&pageSize=20
    MC->>MS: history(fromUserId, toUserId, pageNum, pageSize)
    MS->>MM: PageHelper.startPage → selectHistory
    MM-->>MS: PageInfo<Message>
    MS->>MM: markAsRead(fromUserId, toUserId)
    MS-->>MC: 返回分页消息列表
    MC-->>B: 聊天记录渲染

AdminService — 实名审核

sequenceDiagram
    participant A as 管理员
    participant RC as ReviewController
    participant RS as ReviewService
    participant UM as UserMapper
    participant RM as ReviewMapper

    A->>RC: GET /api/admin/review/users?pageNum=1
    RC->>RS: pendingUsers(pageNum, pageSize)
    RS->>UM: selectByAuthStatus(PENDING)
    UM-->>RS: 返回用户列表(含 campusCardUrl)
    RS-->>RC: 返回列表
    RC-->>A: 展示待审核用户

    A->>RC: POST /api/admin/review/user/{id} { result:APPROVED }
    RC->>RS: reviewUser(id, adminId, request)
    RS->>UM: selectById(id) → 校验 → updateAuthStatus(AUTHENTICATED)
    RS->>RM: insert review 记录
    RC-->>A: 审核通过

    A->>RC: POST /api/admin/review/user/{id} { result:REJECTED, reason:"..." }
    RC->>RS: reviewUser(id, adminId, request)
    RS->>UM: updateAuthStatus(UNAUTHENTICATED)
    RS->>RM: insert review(含 reason)
    RC-->>A: 已驳回

1.8 Mapper 层核心 SQL

<!-- UserMapper.xml -->
<mapper namespace="com.campus.trade.mapper.UserMapper">
    <select id="selectByStudentId" resultType="User">
        SELECT * FROM user WHERE student_id = #{studentId}
    </select>
    <select id="selectByAuthStatus" resultType="User">
        SELECT * FROM user WHERE auth_status = #{status} ORDER BY created_at ASC
    </select>
    <update id="updateAuthStatus">
        UPDATE user SET auth_status = #{authStatus} WHERE id = #{id}
    </update>
    <update id="updatePassword">
        UPDATE user SET password_hash = #{passwordHash} WHERE id = #{id}
    </update>
</mapper>

<!-- ItemMapper.xml -->
<mapper namespace="com.campus.trade.mapper.ItemMapper">
    <select id="selectByStatus" resultType="Item">
        SELECT * FROM item WHERE status = #{status} ORDER BY created_at DESC
    </select>
    <select id="searchByKeyword" resultType="Item">
        SELECT * FROM item WHERE status = 'ON_SALE'
          AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%'))
        ORDER BY created_at DESC
    </select>
    <select id="selectByCategoryId" resultType="Item">
        SELECT * FROM item WHERE status = 'ON_SALE' AND category_id = #{categoryId}
        ORDER BY created_at DESC
    </select>
    <select id="selectBySellerId" resultType="Item">
        SELECT * FROM item WHERE seller_id = #{sellerId} ORDER BY created_at DESC
    </select>
    <update id="updateStatus">
        UPDATE item SET status = #{status} WHERE id = #{id}
    </update>
</mapper>

<!-- OrderMapper.xml -->
<mapper namespace="com.campus.trade.mapper.OrderMapper">
    <select id="selectByBuyerId" resultType="Order">
        SELECT * FROM `order` WHERE buyer_id = #{buyerId} ORDER BY created_at DESC
    </select>
    <select id="selectBySellerId" resultType="Order">
        SELECT * FROM `order` WHERE seller_id = #{sellerId} ORDER BY created_at DESC
    </select>
    <update id="updateStatus">
        UPDATE `order` SET status = #{status} WHERE id = #{id}
    </update>
</mapper>

<!-- MessageMapper.xml -->
<mapper namespace="com.campus.trade.mapper.MessageMapper">
    <select id="selectConversations" resultType="ConversationVO">
        SELECT
            CASE WHEN m.from_user_id = #{userId} THEN m.to_user_id ELSE m.from_user_id END AS userId,
            u.nickname, u.avatar_url,
            m.content AS lastMessage, m.image_url AS lastMessageImage,
            m.created_at AS lastMessageTime,
            (SELECT COUNT(*) FROM message WHERE to_user_id = #{userId}
             AND from_user_id = CASE WHEN m.from_user_id = #{userId} THEN m.to_user_id ELSE m.from_user_id END
             AND is_read = 0) AS unreadCount
        FROM message m JOIN user u
          ON u.id = CASE WHEN m.from_user_id = #{userId} THEN m.to_user_id ELSE m.from_user_id END
        WHERE m.from_user_id = #{userId} OR m.to_user_id = #{userId}
        GROUP BY userId ORDER BY MAX(m.created_at) DESC
    </select>
    <select id="selectHistory" resultType="Message">
        SELECT * FROM message
        WHERE (from_user_id = #{userId1} AND to_user_id = #{userId2})
           OR (from_user_id = #{userId2} AND to_user_id = #{userId1})
        ORDER BY created_at DESC
    </select>
    <update id="markAsRead">
        UPDATE message SET is_read = 1
        WHERE to_user_id = #{toUserId} AND from_user_id = #{fromUserId}
    </update>
</mapper>

1.9 配置文件关键内容

# application.yml
server:
  port: 8080
  servlet:
    session:
      timeout: 3600s

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8mb4&useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 50MB
  mvc:
    static-path-pattern: /upload/**
    static-locations: file:./upload/

springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    enabled: true
  packages-to-scan: com.campus.trade.controller

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.campus.trade.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

pagehelper:
  helper-dialect: mysql
  reasonable: true
  support-methods-arguments: true

1.10 拦截器设计

@Component
public class AuthInterceptor implements HandlerInterceptor {

    private static final List<String> WHITE_LIST = Arrays.asList(
        "/api/auth/register", "/api/auth/login",
        "/api/items", "/api/items/search", "/api/items/category/", "/api/items/",
        "/upload/",
        "/api-docs", "/swagger-ui", "/v3/api-docs"   // SpringDoc 放行
    );

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        String uri = request.getRequestURI();
        if (isWhiteListed(uri)) return true;
        HttpSession session = request.getSession(false);
        if (session == null || session.getAttribute("userId") == null) {
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":401,\"message\":\"请先登录\"}");
            return false;
        }
        return true;
    }

    private boolean isWhiteListed(String uri) {
        return WHITE_LIST.stream().anyMatch(uri::startsWith);
    }
}

1.11 统一响应与异常处理

public class Result {
    private int code;
    private String message;
    private Object data;

    public static Result success() { return new Result(200, "success", null); }
    public static Result success(Object data) { return new Result(200, "success", data); }
    public static Result error(int code, String message) { return new Result(code, message, null); }
}

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result handleBusiness(BusinessException e) {
        return Result.error(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleValidation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors()
                       .stream().map(FieldError::getDefaultMessage)
                       .collect(Collectors.joining(", "));
        return Result.error(400, msg);
    }

    @ExceptionHandler(Exception.class)
    public Result handleUnknown(Exception e) {
        return Result.error(500, "服务器内部错误");
    }
}

1.12 后端项目结构(补充 SpringDoc)

src/main/java/com/campus/trade
├── config
│   ├── WebMvcConfig.java           # 静态资源配置
│   ├── SessionConfig.java          # Session 配置
│   └── MyBatisConfig.java          # MyBatis + PageHelper 配置
├── controller
│   ├── AuthController.java         # @Tag("认证模块")
│   ├── UserController.java         # @Tag("用户模块")
│   ├── ItemController.java         # @Tag("物品模块")
│   ├── OrderController.java        # @Tag("订单模块")
│   ├── MessageController.java      # @Tag("消息模块")
│   └── admin
│       ├── ReviewController.java   # @Tag("审核模块")
│       ├── UserManageController.java
│       └── CategoryController.java # @Tag("分类管理")
├── service
├── mapper
├── entity
├── dto                             # DTO/VO 含 @Schema 注解
├── interceptor
│   └── AuthInterceptor.java
└── common
    ├── Result.java
    ├── StatusCode.java
    └── FileUploadUtil.java

第2章 客户端详细设计

2.1 技术栈明细

技术 用途
Vue 3 (Composition API) 前端框架
ElementPlus UI 组件库
Vue Router 4 路由管理
Pinia 状态管理
Axios HTTP 请求
Vite 构建工具
Orval 基于 OpenAPI 3 自动生成 API 客户端代码

2.2 Orval 集成设计

2.2.1 工作流

flowchart LR
    A[SpringBoot 后端启动] --> B[SpringDoc 自动生成<br/>OpenAPI JSON]
    B --> C[访问 /api-docs<br/>获取 openapi.json]
    C --> D[Orval 读取 openapi.json<br/>执行 orval 命令]
    D --> E[自动生成 src/api/endpoints<br/>类型定义 + API 函数]
    E --> F[前端代码直接调用<br/>生成的 API 函数]

2.2.2 安装 Orval

npm install orval --save-dev

2.2.3 orval.config.ts 配置

// orval.config.ts
import { defineConfig } from 'orval';

export default defineConfig({
  campusTrade: {
    input: {
      // 方式一:开发时直接拉取运行中的后端 OpenAPI JSON
      target: 'http://localhost:8080/api-docs',
      // 方式二:或保存为本地文件
      // target: './openapi.json',
    },
    output: {
      // 生成的 API 代码输出目录
      target: './src/api/endpoints',
      // 生成的类型定义文件
      schemas: './src/api/models',
      // 客户端类型:axios
      client: 'axios',
      // 生成前清空目标目录
      clean: true,
      // 自定义 axios 实例路径
      override: {
        headerOptions: {
          // 使用自定义 Axios 实例(含 withCredentials)
          extraImport: `import { customAxios } from '@/utils/request'`,
        },
      },
    },
    hooks: {
      // 生成完成后输出提示
      afterAllFilesWrite: 'echo "API 代码生成完成"',
    },
  },
});

2.2.4 package.json 脚本

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "api:generate": "orval --config orval.config.ts"
  }
}

2.2.5 生成后的目录结构

src/api
├── endpoints
│   ├── auth.ts              # 认证 API(自动生成)
│   ├── user.ts              # 用户 API
│   ├── item.ts              # 物品 API
│   ├── order.ts             # 订单 API
│   ├── message.ts           # 消息 API
│   └── admin.ts             # 管理员 API
├── models                   # 类型定义(自动生成)
│   ├── loginRequest.ts
│   ├── registerRequest.ts
│   ├── itemVO.ts
│   ├── orderVO.ts
│   ├── pageResult.ts
│   └── ...

2.2.6 生成的 API 代码示例

// src/api/endpoints/auth.ts(由 Orval 自动生成)
import { customAxios } from '@/utils/request';
import type {
  LoginRequest,
  RegisterRequest,
  UserProfileVO,
  Result,
} from '@/api/models';

export const authRegister = (
  registerRequest: RegisterRequest,
) => {
  return customAxios.post<Result>('/api/auth/register', registerRequest, {
    headers: { 'Content-Type': 'multipart/form-data' },
  });
};

export const authLogin = (
  loginRequest: LoginRequest,
) => {
  return customAxios.post<Result & { data: UserProfileVO }>('/api/auth/login', loginRequest);
};

export const authLogout = () => {
  return customAxios.post<Result>('/api/auth/logout');
};

export const authChangePassword = (
  changePasswordRequest: ChangePasswordRequest,
) => {
  return customAxios.post<Result>('/api/auth/change-password', changePasswordRequest);
};
// src/api/endpoints/item.ts(由 Orval 自动生成)
import { customAxios } from '@/utils/request';
import type { ItemVO, PageResult, Result } from '@/api/models';

export const getItems = (pageNum?: number, pageSize?: number) => {
  return customAxios.get<Result & { data: PageResult<ItemVO> }>('/api/items', {
    params: { pageNum, pageSize },
  });
};

export const searchItems = (keyword: string, pageNum?: number, pageSize?: number) => {
  return customAxios.get<Result & { data: PageResult<ItemVO> }>('/api/items/search', {
    params: { keyword, pageNum, pageSize },
  });
};

export const getItemDetail = (id: number) => {
  return customAxios.get<Result & { data: ItemVO }>(`/api/items/${id}`);
};

export const publishItem = (formData: FormData) => {
  return customAxios.post<Result>('/api/items', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
  });
};
// src/api/models/itemVO.ts(由 Orval 自动生成)
export interface ItemVO {
  id?: number;
  sellerId?: number;
  sellerNickname?: string;
  sellerAvatar?: string;
  categoryName?: string;
  title?: string;
  description?: string;
  price?: number;
  images?: string[];
  tradeMethod?: string;
  status?: string;
  createdAt?: string;
}

// src/api/models/pageResult.ts
export interface PageResult<T> {
  list?: T[];
  pageNum?: number;
  pageSize?: number;
  total?: number;
  pages?: number;
}

2.3 自定义 Axios 实例

// utils/request.ts
import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';

export const customAxios = axios.create({
  baseURL: '/api',
  timeout: 15000,
  withCredentials: true,   // 携带 Session Cookie
  // 不设置全局 headers['Content-Type'],由 Orval 生成的代码按需指定
});

customAxios.interceptors.response.use(
  (response) => {
    const { code, message, data } = response.data;
    if (code === 200) return response.data;
    ElMessage.error(message);
    if (code === 401) router.push('/login');
    return Promise.reject(new Error(message));
  },
  (error) => {
    ElMessage.error('网络错误');
    return Promise.reject(error);
  },
);

2.4 在 Vue 组件中使用 Orval 生成的 API

<!-- Home.vue — 使用 Orval 生成的 API -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getItems } from '@/api/endpoints/item';
import type { ItemVO } from '@/api/models/itemVO';

const itemList = ref<ItemVO[]>([]);
const total = ref(0);
const pageNum = ref(1);

const loadItems = async () => {
  const res = await getItems(pageNum.value, 10);
  itemList.value = res.data.list ?? [];
  total.value = res.data.total ?? 0;
};

onMounted(loadItems);
</script>
<!-- Login.vue — 使用 Orval 生成的 API -->
<script setup lang="ts">
import { ref } from 'vue';
import { authLogin } from '@/api/endpoints/auth';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';

const studentId = ref('');
const password = ref('');
const store = useUserStore();
const router = useRouter();

const handleLogin = async () => {
  const res = await authLogin({ studentId: studentId.value, password: password.value });
  store.$patch({ ...res.data, isLoggedIn: true });
  await router.push('/home');
};
</script>

2.5 项目路由设计

// router/index.ts
const routes = [
  // ===== C端 =====
  { path: '/login',            component: () => import('@/views/Login.vue'), meta: { noAuth: true } },
  { path: '/register',         component: () => import('@/views/Register.vue'), meta: { noAuth: true } },
  { path: '/',                 redirect: '/home' },
  { path: '/home',             component: () => import('@/views/Home.vue') },
  { path: '/item/:id',         component: () => import('@/views/ItemDetail.vue') },
  { path: '/search',           component: () => import('@/views/Search.vue') },
  { path: '/publish',          component: () => import('@/views/Publish.vue'), meta: { requiresAuth: true, requiresVerified: true } },
  { path: '/my/items',         component: () => import('@/views/MyItems.vue'), meta: { requiresAuth: true } },
  { path: '/my/orders',        component: () => import('@/views/MyOrders.vue'), meta: { requiresAuth: true } },
  { path: '/order/:id',        component: () => import('@/views/OrderDetail.vue'), meta: { requiresAuth: true } },
  { path: '/messages',         component: () => import('@/views/Messages.vue'), meta: { requiresAuth: true } },
  { path: '/messages/:userId', component: () => import('@/views/Chat.vue'), meta: { requiresAuth: true } },
  { path: '/profile',          component: () => import('@/views/Profile.vue'), meta: { requiresAuth: true } },
  { path: '/profile/password', component: () => import('@/views/ChangePassword.vue'), meta: { requiresAuth: true } },

  // ===== B端 =====
  { path: '/admin/login',              component: () => import('@/views/admin/Login.vue'), meta: { noAuth: true } },
  { path: '/admin',                    redirect: '/admin/review/user' },
  { path: '/admin/review/user',        component: () => import('@/views/admin/ReviewUser.vue'), meta: { requiresAuth: true, requiresAdmin: true } },
  { path: '/admin/review/item',        component: () => import('@/views/admin/ReviewItem.vue'), meta: { requiresAuth: true, requiresAdmin: true } },
  { path: '/admin/users',              component: () => import('@/views/admin/UserManage.vue'), meta: { requiresAuth: true, requiresAdmin: true } },
  { path: '/admin/categories',         component: () => import('@/views/admin/CategoryManage.vue'), meta: { requiresAuth: true, requiresAdmin: true } },
];
graph TB
    subgraph 路由守卫
        direction LR
        R[路由跳转] --> G1{meta.noAuth?}
        G1 -->|是| Allow[放行]
        G1 -->|否| G2{已登录?}
        G2 -->|否| Login[跳转 /login]
        G2 -->|是| G3{requiresAdmin?}
        G3 -->|否| Allow
        G3 -->|是| G4{role==ADMIN?}
        G4 -->|否| Home[跳转 /home]
        G4 -->|是| Allow
    end

2.6 Pinia Store 设计

// stores/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    userId: null as number | null,
    nickname: '',
    avatarUrl: '',
    role: '',
    authStatus: '',
    isLoggedIn: false,
  }),
  getters: {
    isAuthenticated: (state) => state.authStatus === 'AUTHENTICATED',
    isAdmin: (state) => state.role === 'ADMIN',
  },
  actions: {
    async login(studentId: string, password: string) {
      const { authLogin } = await import('@/api/endpoints/auth');
      const res = await authLogin({ studentId, password });
      this.$patch({ ...res.data, isLoggedIn: true });
    },
    async logout() {
      const { authLogout } = await import('@/api/endpoints/auth');
      await authLogout();
      this.$reset();
    },
    async fetchProfile() {
      const { getUserProfile } = await import('@/api/endpoints/user');
      const res = await getUserProfile();
      this.$patch({ ...res.data, isLoggedIn: true });
    },
  },
});

2.7 核心页面组件结构

Home.vue — 首页

graph TB
    Home[Home.vue]
    SearchBar[搜索栏 el-input]
    CategoryTabs[分类切换 el-tabs]
    ItemGrid[物品网格 el-row/el-col]
    ItemCard[物品卡片 el-card]
    Pagination[分页 el-pagination]
    Loading[加载骨架屏 el-skeleton]

    Home --> SearchBar & CategoryTabs & ItemGrid & Pagination & Loading
    ItemGrid --> ItemCard

    subgraph ItemCard
        Img[商品图片 el-image]
        Title[标题]
        Price[价格 ¥]
        Seller[卖家昵称]
    end
<!-- Home.vue — 使用 Orval 生成的 API -->
<template>
  <div class="home">
    <el-input v-model="keyword" placeholder="搜索闲置物品" @keyup.enter="onSearch" />
    <el-tabs v-model="activeCategory" @tab-change="onCategoryChange">
      <el-tab-pane label="全部" name="" />
      <el-tab-pane v-for="cat in categories" :key="cat.id" :label="cat.name" :name="cat.id" />
    </el-tabs>
    <el-row :gutter="12">
      <el-col v-for="item in itemList" :key="item.id" :span="12">
        <el-card @click="goDetail(item.id)">
          <el-image :src="item.images?.[0]" fit="cover" />
          <h4>{{ item.title }}</h4>
          <span class="price">¥{{ item.price }}</span>
          <span class="seller">{{ item.sellerNickname }}</span>
        </el-card>
      </el-col>
    </el-row>
    <el-pagination v-model:current-page="pageNum" :total="total" :page-size="pageSize" @current-change="loadItems" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getItems, searchItems } from '@/api/endpoints/item';
import { getCategories } from '@/api/endpoints/admin';
import type { ItemVO } from '@/api/models/itemVO';
import type { Category } from '@/api/models/category';

const keyword = ref('');
const activeCategory = ref('');
const itemList = ref<ItemVO[]>([]);
const categories = ref<Category[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = 10;

const loadItems = async () => {
  const res = activeCategory.value
    ? await getItems(activeCategory.value, pageNum.value, pageSize)
    : await getItems(pageNum.value, pageSize);
  itemList.value = res.data.list ?? [];
  total.value = res.data.total ?? 0;
};

const onSearch = async () => {
  const res = await searchItems(keyword.value, 1, pageSize);
  itemList.value = res.data.list ?? [];
};

onMounted(async () => {
  await loadItems();
  const catRes = await getCategories();
  categories.value = catRes.data ?? [];
});
</script>

OrderDetail.vue — 订单详情(含流程操作)

graph TB
    OD[OrderDetail.vue]
    Info[订单信息卡片]
    StatusTag[状态标签 el-tag]
    Timeline[时间线 el-timeline]
    ActionBar[操作按钮区]
    ChatBtn[联系对方 el-button]

    OD --> Info & Timeline & ActionBar & ChatBtn
    Info --> StatusTag

    subgraph ActionBar
        direction LR
        PENDING[卖家: 接单/拒单]
        CONFIRMED[卖家: 确认交货<br/>买家: 取消]
        DELIVERED[买家: 确认收货]
    end
    StatusTag -->|待接单| PENDING
    StatusTag -->|待面交| CONFIRMED
    StatusTag -->|待收货| DELIVERED

MyOrders.vue — 双视角订单

graph TB
    MO[MyOrders.vue]
    Tab[Tabs: 我买到的 / 我卖出的]
    List[订单列表]
    OrderCard[订单卡片]
    Pagination[分页]

    MO --> Tab & List
    Tab --> List
    List --> OrderCard & Pagination

    subgraph OrderCard
        ItemImg[物品缩略图]
        Info[标题+价格+交易方式]
        Status[状态标签]
        Action[操作按钮]
    end

2.8 组件树总览

App.vue
├── C端布局 (el-container)
│   ├── 头部导航栏 (el-header)
│   │   ├── 站名/Logo
│   │   ├── 搜索入口
│   │   ├── 消息通知
│   │   └── 用户头像+下拉菜单
│   ├── 页面主体 (el-main)
│   │   ├── Login.vue
│   │   ├── Register.vue
│   │   ├── Home.vue
│   │   ├── ItemDetail.vue
│   │   ├── Search.vue
│   │   ├── Publish.vue
│   │   │   └── ImageUploader (el-upload)
│   │   ├── MyItems.vue
│   │   │   └── ItemCard
│   │   ├── MyOrders.vue
│   │   │   └── OrderCard
│   │   ├── OrderDetail.vue
│   │   │   └── ActionBar
│   │   ├── Messages.vue
│   │   │   └── ConversationItem
│   │   ├── Chat.vue
│   │   │   ├── MessageBubble
│   │   │   └── InputArea (文本+图片)
│   │   ├── Profile.vue
│   │   └── ChangePassword.vue
│   └── 底部导航栏 (el-footer 移动端适配)
│       ├── 首页 | 发布 | 消息 | 我的
│
├── B端布局 (el-container)
│   ├── 侧边栏 (el-aside)
│   │   ├── 实名审核 | 物品审核 | 用户管理 | 分类管理
│   └── 页面主体 (el-main)
│       ├── ReviewUser.vue | ReviewItem.vue | UserManage.vue | CategoryManage.vue

2.9 移动端适配方案

  1. Viewport 设置

    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    
  2. ElementPlus 响应式栅格

    <el-row :gutter="12">
     <el-col :xs="24" :sm="12" :md="8" :lg="6">
       <!-- 移动端占满一列,桌面端分多列 -->
     </el-col>
    </el-row>
    
  3. CSS 媒体查询

    .home-card {
     width: 100%;
     @media (min-width: 768px) { width: 50%; }
     @media (min-width: 1200px) { width: 33%; }
    }
    
  4. 触摸友好:按钮最小高度 44px,列表间距 >= 12px

  5. 底部 TabBar(C端)

    <el-footer v-if="!isAdminRoute">
     <el-row>
       <el-col :span="6" @click="router.push('/home')">
         <el-icon><HomeFilled /></el-icon><span>首页</span>
       </el-col>
       <el-col :span="6" @click="router.push('/publish')">
         <el-icon><Plus /></el-icon><span>发布</span>
       </el-col>
       <el-col :span="6" @click="router.push('/messages')">
         <el-icon><ChatDotSquare /></el-icon><span>消息</span>
       </el-col>
       <el-col :span="6" @click="router.push('/profile')">
         <el-icon><User /></el-icon><span>我的</span>
       </el-col>
     </el-row>
    </el-footer>
    

2.10 核心交互流程

买家下单流程

sequenceDiagram
    participant B as 买家
    participant FE as 前端
    participant API as 后端API

    B->>FE: 进入物品详情页 /item/:id
    FE->>API: GET /api/items/{id}
    API-->>FE: 返回 ItemVO
    FE-->>B: 渲染图文详情+卖家信息

    B->>FE: 点击"我想要"
    FE->>FE: 确认弹窗
    B->>FE: 确认
    FE->>API: POST /api/orders { itemId }
    API-->>FE: { code:200, data:{ orderId } }
    FE-->>B: 跳转订单详情页

    B->>FE: 点击"联系卖家"
    FE->>API: POST /api/messages { toUserId, itemId }
    FE-->>B: 进入聊天界面

卖家处理订单流程

sequenceDiagram
    participant S as 卖家
    participant FE as 前端
    participant API as 后端API

    S->>FE: 进入"我的订单" → 我卖出的
    FE->>API: GET /api/orders/sold?pageNum=1
    API-->>FE: 返回待接单订单列表
    FE-->>S: 展示订单卡片

    S->>FE: 点击"接单"
    FE->>API: PUT /api/orders/{id}/accept
    API-->>FE: 成功
    FE-->>S: 状态变为"待面交"

    S->>FE: 线下交易完成,点击"确认交货"
    FE->>API: PUT /api/orders/{id}/deliver
    API-->>FE: 成功
    FE-->>S: 等待买家确认收货

管理员审核流程

sequenceDiagram
    participant A as 管理员
    participant FE as 管理后台
    participant API as 后端API

    A->>FE: 进入"实名审核"页面
    FE->>API: GET /api/admin/review/users?pageNum=1
    API-->>FE: 返回待审核用户列表
    FE-->>A: 展示用户列表

    A->>FE: 点击"通过"
    FE->>API: POST /api/admin/review/user/{id} { result:"APPROVED" }
    API-->>FE: 成功
    FE-->>A: 用户移出待审核列表

    A->>FE: 点击"驳回" → 填写原因 → 提交
    FE->>API: POST /api/admin/review/user/{id} { result:"REJECTED", reason:"..." }
    API-->>FE: 成功
    FE-->>A: 用户移出待审核列表

第3章 核心业务状态枚举与常量定义

public class AuthStatus {
    public static final String UNAUTHENTICATED = "UNAUTHENTICATED";
    public static final String PENDING        = "PENDING";
    public static final String AUTHENTICATED  = "AUTHENTICATED";
}

public class UserStatus {
    public static final String NORMAL = "NORMAL";
    public static final String BANNED = "BANNED";
}

public class UserRole {
    public static final String USER  = "USER";
    public static final String ADMIN = "ADMIN";
}

public class ItemStatus {
    public static final String PENDING   = "PENDING";
    public static final String ON_SALE   = "ON_SALE";
    public static final String OFF_SHELF = "OFF_SHELF";
    public static final String SOLD      = "SOLD";
}

public class TradeMethod {
    public static final String PICKUP   = "PICKUP";
    public static final String DELIVERY = "DELIVERY";
}

public class OrderStatus {
    public static final String PENDING    = "PENDING";
    public static final String CONFIRMED  = "CONFIRMED";
    public static final String DELIVERED  = "DELIVERED";
    public static final String COMPLETED  = "COMPLETED";
    public static final String CANCELLED  = "CANCELLED";
}

public class ReviewType {
    public static final String AUTH = "AUTH";
    public static final String ITEM = "ITEM";
}

public class ReviewResult {
    public static final String APPROVED = "APPROVED";
    public static final String REJECTED = "REJECTED";
}

public class CategoryStatus {
    public static final String ENABLED  = "ENABLED";
    public static final String DISABLED = "DISABLED";
}

public class StatusCode {
    public static final int SUCCESS      = 200;
    public static final int BAD_REQUEST  = 400;
    public static final int UNAUTHORIZED = 401;
    public static final int FORBIDDEN    = 403;
    public static final int NOT_FOUND    = 404;
    public static final int CONFLICT     = 409;
    public static final int SERVER_ERROR = 500;
}

第4章 文件上传工具类

@Component
public class FileUploadUtil {

    @Value("${upload.dir:./upload}")
    private String uploadDir;

    public String upload(MultipartFile file) {
        if (file.isEmpty()) {
            throw new BusinessException(400, "文件不能为空");
        }
        String originalFilename = file.getOriginalFilename();
        String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
        List<String> allowed = Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".webp");
        if (!allowed.contains(ext.toLowerCase())) {
            throw new BusinessException(400, "不支持的图片格式");
        }
        String filename = UUID.randomUUID() + ext;
        File dest = new File(uploadDir, filename);
        if (!dest.getParentFile().exists()) {
            dest.getParentFile().mkdirs();
        }
        try {
            file.transferTo(dest);
        } catch (IOException e) {
            throw new BusinessException(500, "文件上传失败");
        }
        return "/upload/" + filename;
    }

    public List<String> uploadBatch(List<MultipartFile> files) {
        if (files == null) return Collections.emptyList();
        return files.stream().map(this::upload).collect(Collectors.toList());
    }
}

第5章 订单状态机校验

@Component
public class OrderStatusMachine {

    private static final Map<String, List<String>> ALLOWED_TRANSITIONS = new HashMap<>();

    static {
        ALLOWED_TRANSITIONS.put(OrderStatus.PENDING,   Arrays.asList(OrderStatus.CONFIRMED, OrderStatus.CANCELLED));
        ALLOWED_TRANSITIONS.put(OrderStatus.CONFIRMED, Arrays.asList(OrderStatus.DELIVERED, OrderStatus.CANCELLED));
        ALLOWED_TRANSITIONS.put(OrderStatus.DELIVERED, Arrays.asList(OrderStatus.COMPLETED, OrderStatus.CANCELLED));
        ALLOWED_TRANSITIONS.put(OrderStatus.COMPLETED, Collections.emptyList());
        ALLOWED_TRANSITIONS.put(OrderStatus.CANCELLED, Collections.emptyList());
    }

    public void validateTransition(String currentStatus, String targetStatus) {
        List<String> allowed = ALLOWED_TRANSITIONS.get(currentStatus);
        if (allowed == null || !allowed.contains(targetStatus)) {
            throw new BusinessException(400,
                "订单状态不允许从 " + currentStatus + " 转移到 " + targetStatus);
        }
    }
}
// 使用示例
public void accept(Long orderId, Long sellerId) {
    Order order = orderMapper.selectById(orderId);
    if (!order.getSellerId().equals(sellerId)) {
        throw new BusinessException(403, "无权操作该订单");
    }
    statusMachine.validateTransition(order.getStatus(), OrderStatus.CONFIRMED);
    orderMapper.updateStatus(orderId, OrderStatus.CONFIRMED);
}

第6章 前端文件上传组件

<template>
  <el-upload
    :action="uploadAction"
    :on-success="handleSuccess"
    :on-remove="handleRemove"
    :file-list="fileList"
    list-type="picture-card"
    :multiple="multiple"
    :limit="limit"
    :before-upload="beforeUpload"
  >
    <el-icon><Plus /></el-icon>
  </el-upload>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage } from 'element-plus';

const props = defineProps({
  multiple: { type: Boolean, default: false },
  limit: { type: Number, default: 1 },
  modelValue: { type: Array<string>, default: () => [] },
});

const emit = defineEmits(['update:modelValue']);
const uploadAction = '/api/upload';
const fileList = ref(props.modelValue.map(url => ({ url })));

const beforeUpload = (file: File) => {
  if (!file.type.startsWith('image/')) { ElMessage.error('只能上传图片'); return false; }
  if (file.size > 10 * 1024 * 1024) { ElMessage.error('图片大小不能超过 10MB'); return false; }
  return true;
};

const handleSuccess = (res: any) => {
  if (res.code === 200) {
    fileList.value.push({ url: res.data.url });
    emit('update:modelValue', fileList.value.map(f => f.url));
  }
};

const handleRemove = (file: any) => {
  fileList.value = fileList.value.filter(f => f.url !== file.url);
  emit('update:modelValue', fileList.value.map(f => f.url));
};
</script>

第7章 安全与校验补充

场景 措施
SQL 注入 MyBatis 参数绑定(#{}),无拼接 SQL
XSS Vue3 默认转义,后端输出编码
文件上传 白名单后缀 + 重命名 + 限制大小
越权操作 每个接口校验当前用户 ID 与资源所有者匹配
Session 安全 HttpOnly Cookie,设置 secure(HTTPS)
密码安全 MD5 加密
API 文档安全 生产环境关闭 springdoc 暴露

第8章 开发与协作流程

flowchart TB
    subgraph 服务端
        S1[编写 Controller / DTO<br/>添加 @Tag @Operation @Schema] --> S2[启动 SpringBoot]
        S2 --> S3[访问 /api-docs<br/>获取 OpenAPI JSON]
    end

    subgraph 客户端
        C1[运行 orval<br/>orval --config orval.config.ts] --> C2[自动生成<br/>src/api/endpoints/*.ts<br/>src/api/models/*.ts]
        C2 --> C3[组件中直接调用<br/>import { getItems } from '@/api/endpoints/item']
    end

    S3 -->|http://localhost:8080/api-docs| C1
    S3 -->|Swagger UI 调试| S4[开发者通过<br/>swagger-ui.html 调试]

8.1 典型开发流程

  1. 后端开发者在 Controller 中添加 @Tag、@Operation、@ApiResponse 注解,在 DTO/VO 中添加 @Schema 注解
  2. 启动 SpringBoot 后,访问 http://localhost:8080/api-docs 获取 OpenAPI JSON
  3. 前端开发者运行 npm run api:generate(即 orval),自动从 OpenAPI JSON 生成 TypeScript 类型定义和 API 函数
  4. 前端在 Vue 组件中直接 import 生成的 API 函数,无需手动编写任何 api/*.js 文件
  5. 后端 API 发生变化时,只需重新运行 npm run api:generate 即可同步