|
@@ -0,0 +1,1956 @@
|
|
|
|
|
+# 校园闲置物品交易平台 — 详细设计
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 第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 依赖
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<!-- 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),无需额外依赖 -->
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```yaml
|
|
|
|
|
+# 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
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 用户表
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+```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
|
|
|
|
|
+
|
|
|
|
|
+```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 注解)
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+// -------- 请求 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 注解)
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@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 — 注册
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 登录
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 发布物品
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 首页物品列表
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 订单状态流转
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+stateDiagram-v2
|
|
|
|
|
+ [*] --> PENDING: 买家点击"我想要"
|
|
|
|
|
+ PENDING --> CONFIRMED: 卖家确认接单
|
|
|
|
|
+ PENDING --> CANCELLED: 卖家拒绝接单
|
|
|
|
|
+ CONFIRMED --> COMPLETED: 卖家确认交货 + 买家确认收货
|
|
|
|
|
+ CONFIRMED --> CANCELLED: 任意方取消
|
|
|
|
|
+ COMPLETED --> [*]
|
|
|
|
|
+ CANCELLED --> [*]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 私信
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 实名审核
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<!-- 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 配置文件关键内容
|
|
|
|
|
+
|
|
|
|
|
+```yaml
|
|
|
|
|
+# 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 拦截器设计
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@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 统一响应与异常处理
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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 工作流
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+npm install orval --save-dev
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 2.2.3 orval.config.ts 配置
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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 脚本
|
|
|
|
|
+
|
|
|
|
|
+```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 代码示例
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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);
|
|
|
|
|
+};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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' },
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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 实例
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<!-- 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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<!-- 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 项目路由设计
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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 } },
|
|
|
|
|
+];
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 设计
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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 — 首页
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<!-- 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 — 订单详情(含流程操作)
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 — 双视角订单
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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 设置**
|
|
|
|
|
+ ```html
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+2. **ElementPlus 响应式栅格**
|
|
|
|
|
+ ```vue
|
|
|
|
|
+ <el-row :gutter="12">
|
|
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="6">
|
|
|
|
|
+ <!-- 移动端占满一列,桌面端分多列 -->
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+3. **CSS 媒体查询**
|
|
|
|
|
+ ```scss
|
|
|
|
|
+ .home-card {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ @media (min-width: 768px) { width: 50%; }
|
|
|
|
|
+ @media (min-width: 1200px) { width: 33%; }
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+4. **触摸友好**:按钮最小高度 44px,列表间距 >= 12px
|
|
|
|
|
+
|
|
|
|
|
+5. **底部 TabBar(C端)**
|
|
|
|
|
+ ```vue
|
|
|
|
|
+ <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 核心交互流程
|
|
|
|
|
+
|
|
|
|
|
+#### 买家下单流程
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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: 进入聊天界面
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 卖家处理订单流程
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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: 等待买家确认收货
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 管理员审核流程
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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章 核心业务状态枚举与常量定义
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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章 文件上传工具类
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@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章 订单状态机校验
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+// 使用示例
|
|
|
|
|
+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章 前端文件上传组件
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<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章 开发与协作流程
|
|
|
|
|
+
|
|
|
|
|
+```mermaid
|
|
|
|
|
+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` 即可同步
|