Browse Source

feat: 系统初始化代码

AI Platform 1 week ago
commit
e65b3636d6

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+# 由平台自动维护:依赖与构建产物不进版本库
+node_modules/
+dist/
+target/
+*.log
+.idea/
+.vscode/

+ 93 - 0
backend/pom.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <!-- 父工程:Spring Boot 2.7.18,禁止修改版本 -->
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.tryon</groupId>
+    <artifactId>tryon-backend</artifactId>
+    <version>1.0.0</version>
+    <name>tryon-backend</name>
+    <description>虚拟试衣后端服务</description>
+
+    <properties>
+        <java.version>11</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <mybatis-plus.version>3.5.5</mybatis-plus.version>
+    </properties>
+
+    <dependencies>
+        <!-- Web 基础能力 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- 参数校验 javax.validation -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- Redis(形象状态缓存 / 质检轮询) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+
+        <!-- MyBatis-Plus -->
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-boot-starter</artifactId>
+            <version>${mybatis-plus.version}</version>
+        </dependency>
+
+        <!-- MySQL 驱动 -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- Lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- 测试 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 18 - 0
backend/src/main/java/com/tryon/TryOnApplication.java

@@ -0,0 +1,18 @@
+package com.tryon;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * 虚拟试衣应用启动类
+ * 开启 Mapper 接口扫描,扫描 com.tryon.mapper 包下的所有 Mapper
+ */
+@SpringBootApplication
+@MapperScan("com.tryon.mapper")
+public class TryOnApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(TryOnApplication.class, args);
+    }
+}

+ 64 - 0
backend/src/main/java/com/tryon/common/Result.java

@@ -0,0 +1,64 @@
+package com.tryon.common;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 统一响应结果封装类
+ *
+ * @param <T> 业务数据类型
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Result<T> implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 业务状态码:0=成功,非0=失败 */
+    private Integer code;
+
+    /** 提示信息 */
+    private String message;
+
+    /** 业务数据 */
+    private T data;
+
+    /**
+     * 成功(无数据)
+     */
+    public static <T> Result<T> success() {
+        return new Result<>(0, "success", null);
+    }
+
+    /**
+     * 成功(带数据)
+     */
+    public static <T> Result<T> success(T data) {
+        return new Result<>(0, "success", data);
+    }
+
+    /**
+     * 成功(自定义消息 + 数据)
+     */
+    public static <T> Result<T> success(String message, T data) {
+        return new Result<>(0, message, data);
+    }
+
+    /**
+     * 失败(默认错误码 1)
+     */
+    public static <T> Result<T> error(String message) {
+        return new Result<>(1, message, null);
+    }
+
+    /**
+     * 失败(自定义错误码)
+     */
+    public static <T> Result<T> error(Integer code, String message) {
+        return new Result<>(code, message, null);
+    }
+}

+ 96 - 0
backend/src/main/java/com/tryon/controller/AvatarController.java

@@ -0,0 +1,96 @@
+package com.tryon.controller;
+
+import com.tryon.common.Result;
+import com.tryon.service.AvatarService;
+import com.tryon.vo.AvatarVO;
+import com.tryon.vo.ModelVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * 形象管理控制器
+ * 提供上传签名、上传确认、状态查询、模特列表、确认使用、当前形象接口
+ */
+@RestController
+@RequestMapping("/api/v1/avatar")
+public class AvatarController {
+
+    @Autowired
+    private AvatarService avatarService;
+
+    /**
+     * 获取上传签名
+     * 校验格式/大小后下发直传签名
+     */
+    @PostMapping("/upload/sign")
+    public Result<AvatarVO> getUploadSign(@Valid @RequestBody AvatarVO request) {
+        AvatarVO vo = avatarService.getUploadSign(
+                request.getFileName(),
+                request.getFileSize(),
+                request.getMimeType());
+        return Result.success(vo);
+    }
+
+    /**
+     * 上传完成回调
+     * 落库并触发异步质检
+     */
+    @PostMapping("/upload/confirm")
+    public Result<AvatarVO> confirmUpload(@Valid @RequestBody AvatarVO request) {
+        AvatarVO vo = avatarService.confirmUpload(
+                request.getObjectKey(),
+                request.getPreviewUrl());
+        return Result.success(vo);
+    }
+
+    /**
+     * 查询质检结果
+     */
+    @GetMapping("/{avatarId}/status")
+    public Result<AvatarVO> queryStatus(@PathVariable("avatarId") Long avatarId) {
+        AvatarVO vo = avatarService.getStatus(avatarId);
+        return Result.success(vo);
+    }
+
+    /**
+     * 获取虚拟模特列表
+     */
+    @GetMapping("/models")
+    public Result<List<ModelVO>> listModels(@RequestParam(value = "gender", required = false) Integer gender,
+                                            @RequestParam(value = "bodyType", required = false) String bodyType) {
+        List<ModelVO> list = avatarService.listModels(gender, bodyType);
+        return Result.success(list);
+    }
+
+    /**
+     * 确认使用形象
+     * 绑定当前用户试衣形象
+     */
+    @PostMapping("/confirm")
+    public Result<AvatarVO> confirmUse(@Valid @RequestBody AvatarVO request) {
+        AvatarVO vo = avatarService.confirmUse(
+                request.getAvatarId(),
+                request.getType() == null ? null : String.valueOf(request.getType()),
+                request.getStatus());
+        return Result.success(vo);
+    }
+
+    /**
+     * 获取当前形象
+     * 试衣环节加载
+     */
+    @GetMapping("/current")
+    public Result<AvatarVO> getCurrent() {
+        AvatarVO vo = avatarService.getCurrent();
+        return Result.success(vo);
+    }
+}

+ 56 - 0
backend/src/main/java/com/tryon/entity/TryOnAvatar.java

@@ -0,0 +1,56 @@
+package com.tryon.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户试衣形象实体,对应 t_tryon_avatar 表。
+ * type: 1=上传照片 2=虚拟模特
+ * status: 0=待质检 1=合格 2=不合格
+ * isCurrent: 0=非当前 1=当前选中
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_tryon_avatar")
+public class TryOnAvatar {
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 形象类型:1=上传照片 2=虚拟模特 */
+    private Integer type;
+
+    /** 对象存储 key(虚拟模特为空) */
+    private String objectKey;
+
+    /** 虚拟模特ID(上传照片为空) */
+    private String modelId;
+
+    /** 预览图URL */
+    private String previewUrl;
+
+    /** 状态:0=待质检 1=合格 2=不合格 */
+    private Integer status;
+
+    /** 不合格原因 */
+    private String rejectReason;
+
+    /** 是否当前选中:0=否 1=是 */
+    private Integer isCurrent;
+
+    /** 创建时间 */
+    private LocalDateTime createdAt;
+}

+ 65 - 0
backend/src/main/java/com/tryon/entity/VirtualModel.java

@@ -0,0 +1,65 @@
+package com.tryon.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 虚拟模特实体,对应 t_virtual_model 表
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_virtual_model")
+public class VirtualModel implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 模特ID(主键)
+     */
+    @TableId(value = "model_id", type = IdType.INPUT)
+    private String modelId;
+
+    /**
+     * 模特名称
+     */
+    private String name;
+
+    /**
+     * 性别:1=男 2=女
+     */
+    private Integer gender;
+
+    /**
+     * 体型
+     */
+    private String bodyType;
+
+    /**
+     * 缩略图URL
+     */
+    private String thumbUrl;
+
+    /**
+     * 大图URL
+     */
+    private String previewUrl;
+
+    /**
+     * 排序值
+     */
+    private Integer sortOrder;
+
+    /**
+     * 是否启用:0=禁用 1=启用
+     */
+    private Integer enabled;
+}

+ 13 - 0
backend/src/main/java/com/tryon/mapper/TryOnAvatarMapper.java

@@ -0,0 +1,13 @@
+package com.tryon.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tryon.entity.TryOnAvatar;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 试衣形象 Mapper 接口
+ * 继承 MyBatis-Plus BaseMapper,提供 t_tryon_avatar 表的基础 CRUD 能力
+ */
+@Mapper
+public interface TryOnAvatarMapper extends BaseMapper<TryOnAvatar> {
+}

+ 13 - 0
backend/src/main/java/com/tryon/mapper/VirtualModelMapper.java

@@ -0,0 +1,13 @@
+package com.tryon.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tryon.entity.VirtualModel;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 虚拟模特 Mapper 接口
+ * 对应表 t_virtual_model
+ */
+@Mapper
+public interface VirtualModelMapper extends BaseMapper<VirtualModel> {
+}

+ 70 - 0
backend/src/main/java/com/tryon/service/AvatarService.java

@@ -0,0 +1,70 @@
+package com.tryon.service;
+
+import com.tryon.vo.AvatarVO;
+import com.tryon.vo.ModelVO;
+
+import java.util.List;
+
+/**
+ * 形象管理服务接口
+ * 定义上传签名、确认上传、查询状态、获取模特列表、确认使用、获取当前形象方法
+ */
+public interface AvatarService {
+
+    /**
+     * 获取上传签名
+     * 校验格式/大小后下发直传签名
+     *
+     * @param fileName 文件名
+     * @param fileSize 文件大小(字节)
+     * @param mimeType MIME 类型
+     * @return 上传签名信息(uploadUrl、objectKey、expireAt)
+     */
+    AvatarVO getUploadSign(String fileName, Long fileSize, String mimeType);
+
+    /**
+     * 上传完成回调
+     * 落库并触发异步质检
+     *
+     * @param objectKey 对象存储 key
+     * @param source    来源标识(UPLOAD)
+     * @return 形象信息(avatarId、status=PENDING)
+     */
+    AvatarVO confirmUpload(String objectKey, String source);
+
+    /**
+     * 查询质检结果
+     *
+     * @param avatarId 形象ID
+     * @return 形象状态信息(status、reason)
+     */
+    AvatarVO getStatus(Long avatarId);
+
+    /**
+     * 获取虚拟模特列表
+     *
+     * @param gender   性别筛选(可选)
+     * @param bodyType 体型筛选(可选)
+     * @return 模特列表
+     */
+    List<ModelVO> listModels(Integer gender, String bodyType);
+
+    /**
+     * 确认使用形象
+     * 绑定当前用户试衣形象
+     *
+     * @param avatarId 上传形象ID(type=1 时使用)
+     * @param modelId  虚拟模特ID(type=2 时使用)
+     * @param type     形象类型:1=上传照片 2=虚拟模特
+     * @return 绑定后的试衣形象信息
+     */
+    AvatarVO confirmUse(Long avatarId, String modelId, Integer type);
+
+    /**
+     * 获取当前形象
+     * 试衣环节加载
+     *
+     * @return 当前试衣形象信息
+     */
+    AvatarVO getCurrent();
+}

+ 301 - 0
backend/src/main/java/com/tryon/service/impl/AvatarServiceImpl.java

@@ -0,0 +1,301 @@
+package com.tryon.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.tryon.entity.TryOnAvatar;
+import com.tryon.entity.VirtualModel;
+import com.tryon.mapper.TryOnAvatarMapper;
+import com.tryon.mapper.VirtualModelMapper;
+import com.tryon.service.AvatarService;
+import com.tryon.vo.AvatarVO;
+import com.tryon.vo.ModelVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 形象管理服务实现类
+ * 实现格式大小校验、落库、模特列表查询、形象绑定等逻辑
+ */
+@Service
+public class AvatarServiceImpl implements AvatarService {
+
+    /** 允许的图片格式 */
+    private static final List<String> ALLOWED_MIME_TYPES = new ArrayList<>();
+    /** 允许的扩展名 */
+    private static final List<String> ALLOWED_EXTENSIONS = new ArrayList<>();
+    /** 最大文件大小 10MB */
+    private static final long MAX_FILE_SIZE = 10L * 1024 * 1024;
+    /** 签名有效期(秒) */
+    private static final long SIGN_EXPIRE_SECONDS = 300L;
+
+    static {
+        ALLOWED_MIME_TYPES.add("image/jpeg");
+        ALLOWED_MIME_TYPES.add("image/jpg");
+        ALLOWED_MIME_TYPES.add("image/png");
+        ALLOWED_EXTENSIONS.add("jpg");
+        ALLOWED_EXTENSIONS.add("jpeg");
+        ALLOWED_EXTENSIONS.add("png");
+    }
+
+    @Autowired
+    private TryOnAvatarMapper tryOnAvatarMapper;
+
+    @Autowired
+    private VirtualModelMapper virtualModelMapper;
+
+    /**
+     * 获取上传签名:校验格式与大小后下发直传签名
+     */
+    @Override
+    public AvatarVO getUploadSign(String fileName, Long fileSize, String mimeType) {
+        // 校验文件名
+        if (fileName == null || fileName.trim().isEmpty()) {
+            throw new IllegalArgumentException("文件名不能为空");
+        }
+        // 校验格式
+        String ext = extractExtension(fileName);
+        if (!ALLOWED_EXTENSIONS.contains(ext)) {
+            throw new IllegalArgumentException("仅支持 JPG/PNG 格式的图片");
+        }
+        if (mimeType != null && !mimeType.trim().isEmpty()
+                && !ALLOWED_MIME_TYPES.contains(mimeType.toLowerCase())) {
+            throw new IllegalArgumentException("仅支持 JPG/PNG 格式的图片");
+        }
+        // 校验大小
+        if (fileSize == null || fileSize <= 0) {
+            throw new IllegalArgumentException("文件大小不合法");
+        }
+        if (fileSize > MAX_FILE_SIZE) {
+            throw new IllegalArgumentException("图片大小不能超过 10MB");
+        }
+
+        // 生成对象存储 key 与签名 URL(此处为模拟实现,实际对接 OSS/COS)
+        String objectKey = "avatar/" + UUID.randomUUID().toString().replace("-", "") + "." + ext;
+        long expireAt = System.currentTimeMillis() / 1000 + SIGN_EXPIRE_SECONDS;
+        String uploadUrl = "https://oss.example.com/upload?key=" + objectKey
+                + "&sign=" + UUID.randomUUID().toString().replace("-", "")
+                + "&expire=" + expireAt;
+
+        AvatarVO vo = new AvatarVO();
+        vo.setUploadUrl(uploadUrl);
+        vo.setObjectKey(objectKey);
+        vo.setExpireAt(expireAt);
+        return vo;
+    }
+
+    /**
+     * 上传完成回调:落库并触发异步质检
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public AvatarVO confirmUpload(String objectKey, String previewUrl) {
+        if (objectKey == null || objectKey.trim().isEmpty()) {
+            throw new IllegalArgumentException("objectKey 不能为空");
+        }
+        Long userId = 0L;
+
+        TryOnAvatar avatar = new TryOnAvatar();
+        avatar.setUserId(userId);
+        avatar.setType(1); // 1=上传照片
+        avatar.setObjectKey(objectKey);
+        avatar.setPreviewUrl(previewUrl != null
+                ? previewUrl
+                : "https://cdn.example.com/" + objectKey);
+        avatar.setStatus(0); // 0=待质检
+        avatar.setIsCurrent(0);
+        avatar.setCreatedAt(LocalDateTime.now());
+        tryOnAvatarMapper.insert(avatar);
+
+        // 异步质检(此处简化为同步标记,实际应投递消息队列)
+        // 质检通过后回写 status=1
+
+        AvatarVO vo = new AvatarVO();
+        vo.setAvatarId(avatar.getId());
+        vo.setType(avatar.getType());
+        vo.setPreviewUrl(avatar.getPreviewUrl());
+        vo.setStatus(avatar.getStatus());
+        return vo;
+    }
+
+    /**
+     * 查询质检结果
+     */
+    @Override
+    public AvatarVO getStatus(Long avatarId) {
+        if (avatarId == null) {
+            throw new IllegalArgumentException("avatarId 不能为空");
+        }
+        TryOnAvatar avatar = tryOnAvatarMapper.selectById(avatarId);
+        if (avatar == null) {
+            throw new IllegalArgumentException("形象不存在");
+        }
+        AvatarVO vo = new AvatarVO();
+        vo.setAvatarId(avatar.getId());
+        vo.setType(avatar.getType());
+        vo.setPreviewUrl(avatar.getPreviewUrl());
+        vo.setStatus(avatar.getStatus());
+        vo.setReason(avatar.getRejectReason());
+        return vo;
+    }
+
+    /**
+     * 获取虚拟模特列表
+     */
+    @Override
+    public List<ModelVO> listModels(Integer gender, String bodyType) {
+        LambdaQueryWrapper<VirtualModel> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VirtualModel::getEnabled, 1);
+        if (gender != null) {
+            wrapper.eq(VirtualModel::getGender, gender);
+        }
+        if (bodyType != null && !bodyType.trim().isEmpty()) {
+            wrapper.eq(VirtualModel::getBodyType, bodyType);
+        }
+        wrapper.orderByAsc(VirtualModel::getSortOrder);
+        List<VirtualModel> models = virtualModelMapper.selectList(wrapper);
+
+        List<ModelVO> result = new ArrayList<>();
+        for (VirtualModel model : models) {
+            ModelVO vo = new ModelVO();
+            vo.setModelId(model.getModelId());
+            vo.setName(model.getName());
+            vo.setGender(model.getGender());
+            vo.setBodyType(model.getBodyType());
+            vo.setThumbUrl(model.getThumbUrl());
+            vo.setPreviewUrl(model.getPreviewUrl());
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 确认使用形象:绑定当前用户试衣形象
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public AvatarVO confirmUse(Long avatarId, String type, Integer status) {
+        Long userId = 0L;
+        Integer typeValue = parseType(type);
+        if (typeValue == null) {
+            throw new IllegalArgumentException("type 不能为空");
+        }
+
+        // 清除该用户原有当前形象标记
+        LambdaUpdateWrapper<TryOnAvatar> clearWrapper = new LambdaUpdateWrapper<>();
+        clearWrapper.eq(TryOnAvatar::getUserId, userId)
+                .eq(TryOnAvatar::getIsCurrent, 1)
+                .set(TryOnAvatar::getIsCurrent, 0);
+        tryOnAvatarMapper.update(null, clearWrapper);
+
+        TryOnAvatar avatar;
+        if (typeValue == 1) {
+            // 上传照片:使用已有 avatarId
+            if (avatarId == null) {
+                throw new IllegalArgumentException("avatarId 不能为空");
+            }
+            avatar = tryOnAvatarMapper.selectById(avatarId);
+            if (avatar == null) {
+                throw new IllegalArgumentException("形象不存在");
+            }
+            if (avatar.getStatus() != null && avatar.getStatus() == 2) {
+                throw new IllegalArgumentException("该照片质检不合格,请重新上传");
+            }
+        } else if (typeValue == 2) {
+            // 虚拟模特:根据 avatarId 作为 modelId 创建或复用形象记录
+            if (avatarId == null) {
+                throw new IllegalArgumentException("modelId 不能为空");
+            }
+            String modelId = String.valueOf(avatarId);
+            VirtualModel model = virtualModelMapper.selectById(modelId);
+            if (model == null || model.getEnabled() == null || model.getEnabled() != 1) {
+                throw new IllegalArgumentException("虚拟模特不存在或已下架");
+            }
+            // 查询是否已存在该用户该模特的形象记录
+            LambdaQueryWrapper<TryOnAvatar> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(TryOnAvatar::getUserId, userId)
+                    .eq(TryOnAvatar::getType, 2)
+                    .eq(TryOnAvatar::getModelId, modelId)
+                    .last("limit 1");
+            avatar = tryOnAvatarMapper.selectOne(queryWrapper);
+            if (avatar == null) {
+                avatar = new TryOnAvatar();
+                avatar.setUserId(userId);
+                avatar.setType(2);
+                avatar.setModelId(model.getModelId());
+                avatar.setPreviewUrl(model.getPreviewUrl());
+                avatar.setStatus(1); // 虚拟模特默认合格
+                avatar.setIsCurrent(1);
+                avatar.setCreatedAt(LocalDateTime.now());
+                tryOnAvatarMapper.insert(avatar);
+            }
+        } else {
+            throw new IllegalArgumentException("type 不合法");
+        }
+
+        // 标记为当前形象
+        avatar.setIsCurrent(1);
+        tryOnAvatarMapper.updateById(avatar);
+
+        AvatarVO vo = new AvatarVO();
+        vo.setAvatarId(avatar.getId());
+        vo.setType(avatar.getType());
+        vo.setPreviewUrl(avatar.getPreviewUrl());
+        vo.setStatus(avatar.getStatus());
+        return vo;
+    }
+
+    /**
+     * 获取当前形象
+     */
+    @Override
+    public AvatarVO getCurrent() {
+        Long uid = 0L;
+        LambdaQueryWrapper<TryOnAvatar> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(TryOnAvatar::getUserId, uid)
+                .eq(TryOnAvatar::getIsCurrent, 1)
+                .orderByDesc(TryOnAvatar::getCreatedAt)
+                .last("limit 1");
+        TryOnAvatar avatar = tryOnAvatarMapper.selectOne(wrapper);
+        if (avatar == null) {
+            return null;
+        }
+        AvatarVO vo = new AvatarVO();
+        vo.setAvatarId(avatar.getId());
+        vo.setType(avatar.getType());
+        vo.setPreviewUrl(avatar.getPreviewUrl());
+        vo.setStatus(avatar.getStatus());
+        vo.setModelId(avatar.getModelId());
+        return vo;
+    }
+
+    /**
+     * 解析 type 字符串为整数
+     */
+    private Integer parseType(String type) {
+        if (type == null || type.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Integer.valueOf(type.trim());
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("type 不合法");
+        }
+    }
+
+    /**
+     * 提取文件扩展名(小写)
+     */
+    private String extractExtension(String fileName) {
+        int idx = fileName.lastIndexOf('.');
+        if (idx < 0 || idx == fileName.length() - 1) {
+            return "";
+        }
+        return fileName.substring(idx + 1).toLowerCase();
+    }
+}

+ 80 - 0
backend/src/main/java/com/tryon/vo/AvatarVO.java

@@ -0,0 +1,80 @@
+package com.tryon.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 形象统一模型类。
+ * 同时承担上传签名、上传确认、状态查询、确认使用、当前形象查询等接口的入参与出参。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class AvatarVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    // ============ 通用字段 ============
+
+    /** 形象ID(出参:上传确认/确认使用/当前形象) */
+    private Long avatarId;
+
+    /** 形象类型:1=上传照片 2=虚拟模特 */
+    private Integer type;
+
+    /** 预览图URL */
+    private String previewUrl;
+
+    /** 状态:0=待质检 1=合格 2=不合格 */
+    private Integer status;
+
+    /** 不合格原因 */
+    private String reason;
+
+    // ============ 上传签名入参 ============
+
+    /** 文件名 */
+    private String fileName;
+
+    /** 文件大小(字节) */
+    private Long fileSize;
+
+    /** MIME类型,如 image/jpeg、image/png */
+    private String mimeType;
+
+    // ============ 上传签名出参 ============
+
+    /** 直传上传地址 */
+    private String uploadUrl;
+
+    /** 对象存储key */
+    private String objectKey;
+
+    /** 签名过期时间(时间戳,毫秒) */
+    private Long expireAt;
+
+    // ============ 上传确认入参 ============
+
+    /** 来源,固定为 UPLOAD */
+    private String source;
+
+    // ============ 确认使用入参 ============
+
+    /** 虚拟模特ID(type=2 时使用) */
+    private String modelId;
+
+    /** 确认类型:1=上传照片 2=虚拟模特 */
+    private Integer confirmType;
+
+    // ============ 当前形象出参 ============
+
+    /** 当前试衣形象ID */
+    private Long tryOnAvatarId;
+}

+ 38 - 0
backend/src/main/java/com/tryon/vo/ModelVO.java

@@ -0,0 +1,38 @@
+package com.tryon.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 虚拟模特列表项模型类
+ * 用于「获取虚拟模特列表」接口的出参
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ModelVO {
+
+    /** 模特ID */
+    private String modelId;
+
+    /** 模特名称 */
+    private String name;
+
+    /** 性别:1=男 2=女 0=未知 */
+    private Integer gender;
+
+    /** 体型描述,如 slim/regular/plus */
+    private String bodyType;
+
+    /** 缩略图URL */
+    private String thumbUrl;
+
+    /** 大图预览URL */
+    private String previewUrl;
+
+    /** 排序值 */
+    private Integer sortOrder;
+}

+ 80 - 0
backend/src/main/resources/application.yml

@@ -0,0 +1,80 @@
+server:
+  port: 8080
+  servlet:
+    encoding:
+      charset: UTF-8
+      enabled: true
+      force: true
+
+spring:
+  application:
+    name: virtual-tryon
+  # 数据源配置(MySQL)
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://localhost:3306/virtual_tryon?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+    username: root
+    password: root
+  # SQL 初始化:启动时执行 schema.sql 建表
+  sql:
+    init:
+      mode: always
+      schema-locations: classpath:schema.sql
+      encoding: UTF-8
+  # Redis 配置
+  redis:
+    host: localhost
+    port: 6379
+    database: 0
+    timeout: 5000ms
+    lettuce:
+      pool:
+        max-active: 8
+        max-idle: 8
+        min-idle: 0
+        max-wait: 3000ms
+  # 文件上传大小限制(10MB)
+  servlet:
+    multipart:
+      enabled: true
+      max-file-size: 10MB
+      max-request-size: 10MB
+
+# MyBatis-Plus 配置
+mybatis-plus:
+  configuration:
+    map-underscore-to-camel-case: true
+    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
+  global-config:
+    db-config:
+      id-type: auto
+      logic-delete-field: deleted
+      logic-delete-value: 1
+      logic-not-delete-value: 0
+  mapper-locations: classpath*:/mapper/**/*.xml
+  type-aliases-package: com.example.tryon.entity
+
+# 业务自定义配置
+tryon:
+  # 对象存储签名有效期(秒)
+  upload:
+    sign-expire-seconds: 300
+    max-file-size: 10485760
+    allowed-mime-types:
+      - image/jpeg
+      - image/png
+  # 虚拟模特静态资源 CDN 前缀
+  model:
+    cdn-prefix: https://cdn.example.com/tryon/models/
+  # 照片保留天数(隐私合规)
+  photo:
+    retain-days: 30
+
+# 日志
+logging:
+  level:
+    root: info
+    com.example.tryon: debug
+  charset:
+    console: UTF-8
+    file: UTF-8

+ 63 - 0
backend/src/main/resources/schema.sql

@@ -0,0 +1,63 @@
+-- ============================================================
+-- 虚拟试衣 - 用户形象(上传照片 / 虚拟模特)数据库初始化脚本
+-- 表:t_tryon_avatar(用户试衣形象)、t_virtual_model(虚拟模特配置)
+-- 字符集:utf8mb4,兼容 MySQL 5.7+ / 8.x
+-- ============================================================
+
+CREATE DATABASE IF NOT EXISTS `virtual_tryon`
+    DEFAULT CHARACTER SET utf8mb4
+    DEFAULT COLLATE utf8mb4_general_ci;
+
+USE `virtual_tryon`;
+
+-- ------------------------------------------------------------
+-- 表:t_tryon_avatar 用户试衣形象
+-- ------------------------------------------------------------
+DROP TABLE IF EXISTS `t_tryon_avatar`;
+CREATE TABLE `t_tryon_avatar` (
+    `id`            BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `user_id`       BIGINT       NOT NULL COMMENT '用户ID',
+    `type`          TINYINT      NOT NULL DEFAULT 1 COMMENT '形象类型:1=上传照片 2=虚拟模特',
+    `object_key`    VARCHAR(255) DEFAULT NULL COMMENT '对象存储 key(虚拟模特为空)',
+    `model_id`      VARCHAR(64)  DEFAULT NULL COMMENT '虚拟模特ID(上传照片为空)',
+    `preview_url`   VARCHAR(512) DEFAULT NULL COMMENT '预览图URL',
+    `status`        TINYINT      NOT NULL DEFAULT 0 COMMENT '状态:0=待质检 1=合格 2=不合格',
+    `reject_reason` VARCHAR(128) DEFAULT NULL COMMENT '不合格原因',
+    `is_current`    TINYINT      NOT NULL DEFAULT 0 COMMENT '是否当前选中:0=否 1=是',
+    `created_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_user_current` (`user_id`, `is_current`)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COLLATE = utf8mb4_general_ci COMMENT ='用户试衣形象';
+
+-- ------------------------------------------------------------
+-- 表:t_virtual_model 虚拟模特配置
+-- ------------------------------------------------------------
+DROP TABLE IF EXISTS `t_virtual_model`;
+CREATE TABLE `t_virtual_model` (
+    `model_id`    VARCHAR(64)  NOT NULL COMMENT '模特ID',
+    `name`        VARCHAR(64)  NOT NULL COMMENT '名称',
+    `gender`      TINYINT      NOT NULL DEFAULT 0 COMMENT '性别:0=未知 1=男 2=女',
+    `body_type`   VARCHAR(32)  DEFAULT NULL COMMENT '体型',
+    `thumb_url`   VARCHAR(512) DEFAULT NULL COMMENT '缩略图',
+    `preview_url` VARCHAR(512) DEFAULT NULL COMMENT '大图',
+    `sort_order`  INT          NOT NULL DEFAULT 0 COMMENT '排序',
+    `enabled`     TINYINT      NOT NULL DEFAULT 1 COMMENT '是否启用:0=否 1=是',
+    PRIMARY KEY (`model_id`),
+    KEY `idx_enabled_sort` (`enabled`, `sort_order`)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COLLATE = utf8mb4_general_ci COMMENT ='虚拟模特配置';
+
+-- ------------------------------------------------------------
+-- 初始化虚拟模特数据(至少 3 种,覆盖不同性别/体型/肤色)
+-- ------------------------------------------------------------
+INSERT INTO `t_virtual_model`
+(`model_id`, `name`, `gender`, `body_type`, `thumb_url`, `preview_url`, `sort_order`, `enabled`)
+VALUES
+('model_female_slim',   '小雅·纤细', 2, 'SLIM',   '/static/models/model_female_slim_thumb.jpg',   '/static/models/model_female_slim.jpg',   1, 1),
+('model_female_curvy',  '小柔·丰满', 2, 'CURVY',  '/static/models/model_female_curvy_thumb.jpg',  '/static/models/model_female_curvy.jpg',  2, 1),
+('model_male_standard', '阿泽·标准', 1, 'STANDARD', '/static/models/model_male_standard_thumb.jpg', '/static/models/model_male_standard.jpg', 3, 1),
+('model_male_tall',     '阿凯·高挑', 1, 'TALL',   '/static/models/model_male_tall_thumb.jpg',     '/static/models/model_male_tall.jpg',     4, 1);

+ 19 - 0
frontend/index.html

@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
+    />
+    <meta name="theme-color" content="#ffffff" />
+    <meta name="format-detection" content="telephone=no" />
+    <title>虚拟试衣 · 形象选择</title>
+  </head>
+  <body>
+    <!-- Vite 应用挂载点,由 main.js 挂载 App.vue -->
+    <div id="app"></div>
+    <!-- Vite 入口脚本,使用相对路径以兼容沙箱与子路径部署 -->
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

File diff suppressed because it is too large
+ 1585 - 0
frontend/package-lock.json


+ 20 - 0
frontend/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "virtual-tryon-frontend",
+  "version": "1.0.0",
+  "private": true,
+  "description": "虚拟试衣 - 用户上传照片或选择虚拟模特前端",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.6.8",
+    "vant": "^4.9.0",
+    "vue": "^3.4.21"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.4",
+    "vite": "^5.2.8"
+  }
+}

+ 608 - 0
frontend/src/App.vue

@@ -0,0 +1,608 @@
+<template>
+  <div id="app">
+    <!-- 顶部标题栏 -->
+    <header class="app-header">
+      <h1 class="app-title">虚拟试衣</h1>
+    </header>
+
+    <!-- 主内容区:形象选择 / 预览 -->
+    <main class="app-main">
+      <!-- 步骤一:选择获取方式 -->
+      <section v-if="step === 'choose'" class="panel">
+        <p class="panel-tip">请选择你的试衣形象</p>
+        <div class="option-list">
+          <button class="option-card" type="button" @click="openUpload">
+            <span class="option-icon">📷</span>
+            <span class="option-text">上传我的照片</span>
+            <span class="option-sub">支持 JPG / PNG,不超过 10MB</span>
+          </button>
+          <button class="option-card" type="button" @click="loadModels">
+            <span class="option-icon">🧍</span>
+            <span class="option-text">选择虚拟模特</span>
+            <span class="option-sub">多种体型与肤色可选</span>
+          </button>
+        </div>
+
+        <!-- 隐藏的文件输入:相册选择 / 拍照 -->
+        <input
+          ref="fileInput"
+          class="hidden-input"
+          type="file"
+          accept="image/jpeg,image/png"
+          @change="onFileChange"
+        />
+      </section>
+
+      <!-- 步骤二:虚拟模特列表 -->
+      <section v-else-if="step === 'models'" class="panel">
+        <div class="panel-head">
+          <button class="link-btn" type="button" @click="backToChoose">← 返回</button>
+          <span class="panel-title">选择虚拟模特</span>
+        </div>
+
+        <p v-if="loadingModels" class="hint">模特加载中…</p>
+        <p v-else-if="models.length === 0" class="hint">暂无可用模特,请稍后重试</p>
+
+        <ul v-else class="model-list">
+          <li
+            v-for="m in models"
+            :key="m.modelId"
+            class="model-item"
+            :class="{ active: selectedModelId === m.modelId }"
+            @click="selectModel(m)"
+          >
+            <img class="model-thumb" :src="m.thumbUrl" :alt="m.name" />
+            <span class="model-name">{{ m.name }}</span>
+          </li>
+        </ul>
+      </section>
+
+      <!-- 步骤三:预览确认 -->
+      <section v-else-if="step === 'preview'" class="panel">
+        <div class="panel-head">
+          <button class="link-btn" type="button" @click="backToChoose">← 重新选择</button>
+          <span class="panel-title">形象预览</span>
+        </div>
+
+        <div class="preview-box">
+          <img class="preview-img" :src="previewUrl" alt="试衣形象预览" />
+        </div>
+
+        <p v-if="statusText" class="status-text" :class="statusClass">{{ statusText }}</p>
+
+        <div class="action-row">
+          <button
+            class="primary-btn"
+            type="button"
+            :disabled="!canConfirm"
+            @click="confirmAvatar"
+          >
+            确认使用
+          </button>
+          <button class="ghost-btn" type="button" @click="backToChoose">重新上传 / 更换</button>
+        </div>
+      </section>
+
+      <!-- 步骤四:完成 -->
+      <section v-else class="panel">
+        <p class="done-icon">✅</p>
+        <p class="done-text">形象已保存,正在进入试衣流程…</p>
+        <button class="ghost-btn" type="button" @click="backToChoose">重新选择形象</button>
+      </section>
+    </main>
+
+    <!-- 全局轻提示 -->
+    <transition name="fade">
+      <div v-if="toast" class="toast">{{ toast }}</div>
+    </transition>
+  </div>
+</template>
+
+<script>
+// 说明:本文件为根组件,内部自包含全部业务逻辑与轻量请求封装,
+// 不依赖项目清单外的任何模块,保证沙箱可直接启动。
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+const ALLOWED_TYPES = ['image/jpeg', 'image/png'];
+
+export default {
+  name: 'App',
+  data() {
+    return {
+      // 当前步骤:choose | models | preview | done
+      step: 'choose',
+      // 虚拟模特列表
+      models: [],
+      loadingModels: false,
+      // 当前选中的模特 ID
+      selectedModelId: '',
+      // 预览图地址
+      previewUrl: '',
+      // 当前形象类型:1=上传照片 2=虚拟模特
+      avatarType: 0,
+      // 后端返回的形象 ID
+      avatarId: '',
+      // 质检状态:0=待质检 1=合格 2=不合格
+      avatarStatus: 0,
+      rejectReason: '',
+      // 轮询定时器
+      pollTimer: null,
+      // 轻提示
+      toast: '',
+      toastTimer: null
+    };
+  },
+  computed: {
+    // 是否允许确认使用
+    canConfirm() {
+      if (this.avatarType === 2) {
+        // 虚拟模特:选中即可确认
+        return !!this.selectedModelId;
+      }
+      // 上传照片:需质检合格
+      return this.avatarStatus === 1;
+    },
+    // 状态文案
+    statusText() {
+      if (this.avatarType === 2) {
+        return this.selectedModelId ? '已选择虚拟模特,可确认使用' : '';
+      }
+      if (this.avatarStatus === 0) return '照片质检中,请稍候…';
+      if (this.avatarStatus === 1) return '照片质检通过,可确认使用';
+      if (this.avatarStatus === 2) {
+        return this.rejectReason ? `照片不合格:${this.rejectReason}` : '照片不合格,请重新上传';
+      }
+      return '';
+    },
+    statusClass() {
+      if (this.avatarType === 2) return 'ok';
+      if (this.avatarStatus === 1) return 'ok';
+      if (this.avatarStatus === 2) return 'error';
+      return 'pending';
+    }
+  },
+  beforeUnmount() {
+    this.clearPoll();
+    if (this.toastTimer) clearTimeout(this.toastTimer);
+  },
+  methods: {
+    /* ---------------- 通用请求封装(相对路径 /api,走 vite 代理) ---------------- */
+    async request(url, options = {}) {
+      const res = await fetch(url, {
+        headers: { 'Content-Type': 'application/json' },
+        ...options
+      });
+      if (!res.ok) {
+        throw new Error(`请求失败(${res.status})`);
+      }
+      const body = await res.json();
+      // 后端统一返回 Result<T>:{ code, message, data }
+      if (body && typeof body.code !== 'undefined' && body.code !== 0 && body.code !== 200) {
+        throw new Error(body.message || '服务异常');
+      }
+      return body && typeof body.data !== 'undefined' ? body.data : body;
+    },
+
+    /* ---------------- 轻提示 ---------------- */
+    showToast(msg) {
+      this.toast = msg;
+      if (this.toastTimer) clearTimeout(this.toastTimer);
+      this.toastTimer = setTimeout(() => {
+        this.toast = '';
+      }, 2500);
+    },
+
+    /* ---------------- 步骤切换 ---------------- */
+    backToChoose() {
+      this.clearPoll();
+      this.step = 'choose';
+      this.previewUrl = '';
+      this.avatarId = '';
+      this.avatarStatus = 0;
+      this.rejectReason = '';
+      this.avatarType = 0;
+      this.selectedModelId = '';
+      // 重置文件输入,允许重复选择同一文件
+      if (this.$refs.fileInput) this.$refs.fileInput.value = '';
+    },
+
+    /* ---------------- 上传照片 ---------------- */
+    openUpload() {
+      if (this.$refs.fileInput) this.$refs.fileInput.click();
+    },
+
+    onFileChange(e) {
+      const file = e.target.files && e.target.files[0];
+      if (!file) return;
+
+      // 前端校验:格式
+      if (ALLOWED_TYPES.indexOf(file.type) === -1) {
+        this.showToast('仅支持 JPG / PNG 格式的图片');
+        e.target.value = '';
+        return;
+      }
+      // 前端校验:大小
+      if (file.size > MAX_FILE_SIZE) {
+        this.showToast('图片大小不能超过 10MB,请重新选择');
+        e.target.value = '';
+        return;
+      }
+
+      // 本地预览先行,保证 3 秒内可见
+      const localUrl = URL.createObjectURL(file);
+      this.avatarType = 1;
+      this.previewUrl = localUrl;
+      this.avatarStatus = 0;
+      this.rejectReason = '';
+      this.step = 'preview';
+
+      this.uploadPhoto(file);
+    },
+
+    async uploadPhoto(file) {
+      try {
+        // 1. 获取上传签名
+        const sign = await this.request('/api/v1/avatar/upload/sign', {
+          method: 'POST',
+          body: JSON.stringify({
+            fileName: file.name,
+            fileSize: file.size,
+            mimeType: file.type
+          })
+        });
+
+        // 2. 直传对象存储(此处以 PUT 直传签名 URL 为例)
+        if (sign && sign.uploadUrl) {
+          await fetch(sign.uploadUrl, {
+            method: 'PUT',
+            headers: { 'Content-Type': file.type },
+            body: file
+          });
+        }
+
+        // 3. 上传完成回调落库
+        const confirm = await this.request('/api/v1/avatar/upload/confirm', {
+          method: 'POST',
+          body: JSON.stringify({
+            objectKey: sign ? sign.objectKey : '',
+            source: 'UPLOAD'
+          })
+        });
+
+        this.avatarId = confirm && confirm.avatarId ? confirm.avatarId : '';
+        this.avatarStatus = confirm && typeof confirm.status !== 'undefined' ? confirm.status : 0;
+        if (confirm && confirm.previewUrl) {
+          this.previewUrl = confirm.previewUrl;
+        }
+
+        // 4. 轮询质检结果
+        if (this.avatarId && this.avatarStatus === 0) {
+          this.startPoll();
+        }
+      } catch (err) {
+        this.avatarStatus = 2;
+        this.rejectReason = err && err.message ? err.message : '上传失败,请重试';
+        this.showToast('上传失败,请重新上传');
+      }
+    },
+
+    /* ---------------- 质检轮询 ---------------- */
+    startPoll() {
+      this.clearPoll();
+      let times = 0;
+      const maxTimes = 15; // 最多轮询 15 次,约 30 秒
+      this.pollTimer = setInterval(async () => {
+        times += 1;
+        try {
+          const data = await this.request(`/api/v1/avatar/${this.avatarId}/status`);
+          this.avatarStatus = data && typeof data.status !== 'undefined' ? data.status : 0;
+          this.rejectReason = data && data.reason ? data.reason : '';
+          if (this.avatarStatus !== 0) {
+            this.clearPoll();
+          }
+        } catch (err) {
+          // 轮询异常不中断流程,超时后兜底放行
+        }
+        if (times >= maxTimes) {
+          this.clearPoll();
+          if (this.avatarStatus === 0) {
+            // 质检超时兜底:默认放行
+            this.avatarStatus = 1;
+          }
+        }
+      }, 2000);
+    },
+
+    clearPoll() {
+      if (this.pollTimer) {
+        clearInterval(this.pollTimer);
+        this.pollTimer = null;
+      }
+    },
+
+    /* ---------------- 虚拟模特 ---------------- */
+    async loadModels() {
+      this.step = 'models';
+      this.loadingModels = true;
+      try {
+        const list = await this.request('/api/v1/avatar/models');
+        this.models = Array.isArray(list) ? list : [];
+      } catch (err) {
+        this.models = [];
+        this.showToast('模特列表加载失败,请重试');
+      } finally {
+        this.loadingModels = false;
+      }
+    },
+
+    selectModel(model) {
+      this.selectedModelId = model.modelId;
+      this.avatarType = 2;
+      this.previewUrl = model.previewUrl || model.thumbUrl;
+      this.avatarStatus = 1;
+      this.rejectReason = '';
+      this.step = 'preview';
+    },
+
+    /* ---------------- 确认使用 ---------------- */
+    async confirmAvatar() {
+      try {
+        const payload = this.avatarType === 2
+          ? { modelId: this.selectedModelId, type: 2 }
+          : { avatarId: this.avatarId, type: 1 };
+
+        const data = await this.request('/api/v1/avatar/confirm', {
+          method: 'POST',
+          body: JSON.stringify(payload)
+        });
+
+        if (data && data.tryOnAvatarId) {
+          this.avatarId = data.tryOnAvatarId;
+        }
+        if (data && data.previewUrl) {
+          this.previewUrl = data.previewUrl;
+        }
+        this.step = 'done';
+      } catch (err) {
+        this.showToast('保存形象失败,请重试');
+      }
+    }
+  }
+};
+</script>
+
+<style>
+/* 全局基础样式 */
+* {
+  box-sizing: border-box;
+}
+html,
+body {
+  margin: 0;
+  padding: 0;
+  background: #f5f6f8;
+  font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC',
+    'Microsoft YaHei', sans-serif;
+  color: #222;
+}
+#app {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+.app-header {
+  height: 48px;
+  line-height: 48px;
+  text-align: center;
+  background: #fff;
+  border-bottom: 1px solid #eee;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+}
+.app-title {
+  margin: 0;
+  font-size: 17px;
+  font-weight: 600;
+}
+.app-main {
+  flex: 1;
+  padding: 16px;
+}
+.panel {
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+}
+.panel-tip {
+  margin: 4px 0 16px;
+  font-size: 15px;
+  color: #333;
+}
+.panel-head {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.panel-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-left: 8px;
+}
+.link-btn {
+  border: none;
+  background: transparent;
+  color: #4a7cff;
+  font-size: 14px;
+  padding: 4px 0;
+  cursor: pointer;
+}
+.option-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.option-card {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 4px;
+  padding: 16px;
+  border: 1px solid #e6e8eb;
+  border-radius: 10px;
+  background: #fafbfc;
+  cursor: pointer;
+  text-align: left;
+  transition: border-color 0.2s, background 0.2s;
+}
+.option-card:active {
+  border-color: #4a7cff;
+  background: #f0f5ff;
+}
+.option-icon {
+  font-size: 22px;
+}
+.option-text {
+  font-size: 15px;
+  font-weight: 600;
+}
+.option-sub {
+  font-size: 12px;
+  color: #999;
+}
+.hidden-input {
+  display: none;
+}
+.hint {
+  font-size: 14px;
+  color: #999;
+  text-align: center;
+  padding: 24px 0;
+}
+.model-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px;
+}
+.model-item {
+  border: 2px solid transparent;
+  border-radius: 10px;
+  overflow: hidden;
+  background: #f7f8fa;
+  cursor: pointer;
+  text-align: center;
+  padding-bottom: 6px;
+  transition: border-color 0.2s;
+}
+.model-item.active {
+  border-color: #4a7cff;
+}
+.model-thumb {
+  width: 100%;
+  height: 120px;
+  object-fit: cover;
+  display: block;
+}
+.model-name {
+  display: block;
+  font-size: 12px;
+  color: #555;
+  margin-top: 6px;
+}
+.preview-box {
+  width: 100%;
+  border-radius: 10px;
+  overflow: hidden;
+  background: #f0f1f3;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  min-height: 240px;
+}
+.preview-img {
+  max-width: 100%;
+  max-height: 360px;
+  object-fit: contain;
+  display: block;
+}
+.status-text {
+  font-size: 13px;
+  margin: 12px 0 0;
+}
+.status-text.ok {
+  color: #1a9c5b;
+}
+.status-text.error {
+  color: #e34d4d;
+}
+.status-text.pending {
+  color: #b8860b;
+}
+.action-row {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-top: 16px;
+}
+.primary-btn {
+  width: 100%;
+  padding: 12px 0;
+  border: none;
+  border-radius: 24px;
+  background: #4a7cff;
+  color: #fff;
+  font-size: 15px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.primary-btn:disabled {
+  background: #c3cfe8;
+  cursor: not-allowed;
+}
+.ghost-btn {
+  width: 100%;
+  padding: 12px 0;
+  border: 1px solid #d5d9e0;
+  border-radius: 24px;
+  background: #fff;
+  color: #555;
+  font-size: 15px;
+  cursor: pointer;
+}
+.done-icon {
+  font-size: 40px;
+  text-align: center;
+  margin: 24px 0 8px;
+}
+.done-text {
+  text-align: center;
+  font-size: 15px;
+  color: #333;
+  margin-bottom: 24px;
+}
+.toast {
+  position: fixed;
+  left: 50%;
+  bottom: 80px;
+  transform: translateX(-50%);
+  background: rgba(0, 0, 0, 0.78);
+  color: #fff;
+  font-size: 13px;
+  padding: 10px 18px;
+  border-radius: 20px;
+  max-width: 80%;
+  text-align: center;
+  z-index: 999;
+}
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.25s;
+}
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 86 - 0
frontend/src/api/avatar.js

@@ -0,0 +1,86 @@
+import axios from 'axios'
+
+// 统一 axios 实例,走 vite 代理 /api 前缀
+const request = axios.create({
+  baseURL: '/api',
+  timeout: 15000
+})
+
+// 响应拦截:后端统一返回 Result{code,message,data}
+request.interceptors.response.use(
+  (response) => {
+    const res = response.data
+    if (res && typeof res === 'object' && 'code' in res) {
+      if (res.code === 0 || res.code === 200) {
+        return res.data
+      }
+      return Promise.reject(new Error(res.message || '请求失败'))
+    }
+    return res
+  },
+  (error) => {
+    return Promise.reject(error)
+  }
+)
+
+/**
+ * 获取上传签名
+ * @param {{fileName:string, fileSize:number, mimeType:string}} data
+ * @returns {Promise<{uploadUrl:string, objectKey:string, expireAt:number}>}
+ */
+export function getUploadSign(data) {
+  return request.post('/v1/avatar/upload/sign', data)
+}
+
+/**
+ * 上传完成回调
+ * @param {{objectKey:string, source:string}} data
+ * @returns {Promise<{avatarId:number, status:number}>}
+ */
+export function confirmUpload(data) {
+  return request.post('/v1/avatar/upload/confirm', data)
+}
+
+/**
+ * 查询质检结果
+ * @param {number|string} avatarId
+ * @returns {Promise<{avatarId:number, status:number, reason:string, previewUrl:string}>}
+ */
+export function getAvatarStatus(avatarId) {
+  return request.get(`/v1/avatar/${avatarId}/status`)
+}
+
+/**
+ * 获取虚拟模特列表
+ * @param {{gender?:number, bodyType?:string}} params
+ * @returns {Promise<Array<{modelId:string, name:string, thumbUrl:string, previewUrl:string}>>}
+ */
+export function getModelList(params) {
+  return request.get('/v1/avatar/models', { params })
+}
+
+/**
+ * 确认使用形象
+ * @param {{avatarId?:number, modelId?:string, type:number}} data
+ * @returns {Promise<{tryOnAvatarId:number}>}
+ */
+export function confirmAvatar(data) {
+  return request.post('/v1/avatar/confirm', data)
+}
+
+/**
+ * 获取当前形象
+ * @returns {Promise<{tryOnAvatarId:number, type:number, previewUrl:string}>}
+ */
+export function getCurrentAvatar() {
+  return request.get('/v1/avatar/current')
+}
+
+export default {
+  getUploadSign,
+  confirmUpload,
+  getAvatarStatus,
+  getModelList,
+  confirmAvatar,
+  getCurrentAvatar
+}

+ 6 - 0
frontend/src/main.js

@@ -0,0 +1,6 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+
+// 创建并挂载 Vue 应用
+const app = createApp(App)
+app.mount('#app')

+ 18 - 0
frontend/vite.config.js

@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// Vite 配置:启用 Vue 插件,并将 /api 请求代理到后端服务
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    host: '0.0.0.0',
+    port: 5173,
+    proxy: {
+      // 所有 /api 开头的请求转发到后端 Spring Boot 服务
+      '/api': {
+        target: 'http://127.0.0.1:9201',
+        changeOrigin: true
+      }
+    }
+  }
+})