| 层次 | 技术 | 版本 | 用途 |
|---|---|---|---|
| 框架 | 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 | 参数校验 |
<!-- 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
访问路径:
http://localhost:8080/v3/api-docshttp://localhost:8080/swagger-ui.html-- 用户表
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;
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
}
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
}
结构与概要设计一致,所有 Entity 添加 @Schema(description = "...") 类注解。
// -------- 请求 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;
}
@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) { }
}
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:"注册成功,等待实名审核" }
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 } }
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:"发布成功,等待管理员审核" }
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 } }
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: 交易完成
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: 聊天记录渲染
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: 已驳回
<!-- 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>
# 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
@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);
}
}
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, "服务器内部错误");
}
}
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
| 技术 | 用途 |
|---|---|
| Vue 3 (Composition API) | 前端框架 |
| ElementPlus | UI 组件库 |
| Vue Router 4 | 路由管理 |
| Pinia | 状态管理 |
| Axios | HTTP 请求 |
| Vite | 构建工具 |
| Orval | 基于 OpenAPI 3 自动生成 API 客户端代码 |
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 函数]
npm install orval --save-dev
// 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 代码生成完成"',
},
},
});
{
"scripts": {
"dev": "vite",
"build": "vite build",
"api:generate": "orval --config orval.config.ts"
}
}
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
│ └── ...
// 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;
}
// 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);
},
);
<!-- 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>
// 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
// 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 });
},
},
});
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>
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
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
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
Viewport 设置
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
ElementPlus 响应式栅格
<el-row :gutter="12">
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<!-- 移动端占满一列,桌面端分多列 -->
</el-col>
</el-row>
CSS 媒体查询
.home-card {
width: 100%;
@media (min-width: 768px) { width: 50%; }
@media (min-width: 1200px) { width: 33%; }
}
触摸友好:按钮最小高度 44px,列表间距 >= 12px
底部 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>
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: 用户移出待审核列表
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;
}
@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());
}
}
@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);
}
<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>
| 场景 | 措施 |
|---|---|
| SQL 注入 | MyBatis 参数绑定(#{}),无拼接 SQL |
| XSS | Vue3 默认转义,后端输出编码 |
| 文件上传 | 白名单后缀 + 重命名 + 限制大小 |
| 越权操作 | 每个接口校验当前用户 ID 与资源所有者匹配 |
| Session 安全 | HttpOnly Cookie,设置 secure(HTTPS) |
| 密码安全 | MD5 加密 |
| API 文档安全 | 生产环境关闭 springdoc 暴露 |
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 调试]
@Tag、@Operation、@ApiResponse 注解,在 DTO/VO 中添加 @Schema 注解http://localhost:8080/api-docs 获取 OpenAPI JSONnpm run api:generate(即 orval),自动从 OpenAPI JSON 生成 TypeScript 类型定义和 API 函数api/*.js 文件npm run api:generate 即可同步