Codex Assistant
昨天 afeeed281e60466b576fbe74d339634cc5d07b82
修复评审功能和用户认证问题

- 修复微信小程序评审页面switch语句数字字符串转换问题
- 修复GraphQL查询参数为空字符串的问题
- 添加用户认证API和相关功能
- 改进用户信息保存和验证逻辑
- 优化前端用户界面和交互体验
24个文件已修改
1个文件已添加
2081 ■■■■■ 已修改文件
backend/src/main/java/com/rongyichuang/auth/api/AuthGraphqlApi.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/auth/filter/JwtAuthenticationFilter.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/auth/service/AuthService.java 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/auth/util/JwtUtil.java 36 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/common/util/UserContextUtil.java 103 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeResponse.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/entity/Employee.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/employee/service/EmployeeService.java 47 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/judge/service/JudgeService.java 32 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/review/resolver/ReviewResolver.java 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/user/dto/response/UserProfile.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/user/resolver/UserResolver.java 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/user/service/UserService.java 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/graphql/auth.graphqls 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/graphql/user.graphqls 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/views/check-detail.vue 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
web/src/views/review-detail.vue 114 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/app.js 196 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/judge/review.js 520 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/message/message.js 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/profile/personal-info.js 94 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/project/detail.js 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/registration/registration.js 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
wx/pages/review/index.js 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/rongyichuang/auth/api/AuthGraphqlApi.java
New file
@@ -0,0 +1,73 @@
package com.rongyichuang.auth.api;
import com.rongyichuang.auth.dto.PhoneDecryptResponse;
import com.rongyichuang.auth.dto.WxLoginRequest;
import com.rongyichuang.auth.dto.WxLoginResponse;
import com.rongyichuang.auth.dto.LoginRequest;
import com.rongyichuang.auth.dto.LoginResponse;
import com.rongyichuang.auth.service.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;
/**
 * 认证GraphQL API控制器
 */
@Controller
public class AuthGraphqlApi {
    @Autowired
    private AuthService authService;
    /**
     * 微信登录
     */
    @MutationMapping
    public WxLoginResponse wxLogin(@Argument WxLoginRequest input) {
        try {
            return authService.wxLogin(input);
        } catch (Exception e) {
            throw new RuntimeException("微信登录失败: " + e.getMessage(), e);
        }
    }
    /**
     * Web端登录
     */
    @MutationMapping
    public LoginResponse webLogin(@Argument LoginRequest input) {
        try {
            return authService.login(input);
        } catch (Exception e) {
            throw new RuntimeException("登录失败: " + e.getMessage(), e);
        }
    }
    /**
     * 解密微信手机号(旧版API)
     */
    @MutationMapping
    public PhoneDecryptResponse decryptPhoneNumber(
            @Argument String encryptedData,
            @Argument String iv,
            @Argument String sessionKey) {
        try {
            return authService.decryptPhoneNumber(encryptedData, iv, sessionKey);
        } catch (Exception e) {
            throw new RuntimeException("手机号解密失败: " + e.getMessage(), e);
        }
    }
    /**
     * 获取微信手机号(新版API)
     */
    @MutationMapping
    public PhoneDecryptResponse getPhoneNumberByCode(@Argument String code) {
        try {
            return authService.getPhoneNumberByCode(code);
        } catch (Exception e) {
            throw new RuntimeException("获取手机号失败: " + e.getMessage(), e);
        }
    }
}
backend/src/main/java/com/rongyichuang/auth/filter/JwtAuthenticationFilter.java
@@ -169,7 +169,7 @@
     * 返回权限错误响应
     */
    private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"errors\":[{\"message\":\"没有权限访问,请先登录\",\"extensions\":{\"code\":\"UNAUTHORIZED\"}}]}");
    }
@@ -247,23 +247,37 @@
                    return;
                }
                
                // 查找用户信息并设置认证
                Optional<User> userOpt = userRepository.findById(userId);
                if (userOpt.isPresent()) {
                    User user = userOpt.get();
                // 检查是否为匿名用户(负数用户ID)
                if (userId < 0) {
                    // 匿名用户,设置特殊的认证信息
                    UsernamePasswordAuthenticationToken authToken = 
                        new UsernamePasswordAuthenticationToken(
                            user.getId().toString(),
                            "anonymous_" + userId,
                            null, 
                            new ArrayList<>()
                            Arrays.asList(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))
                        );
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                    logger.debug("GraphQL请求认证成功: userId={}", user.getId());
                    logger.debug("GraphQL请求匿名用户认证成功: userId={}", userId);
                } else {
                    logger.warn("GraphQL请求的用户不存在: userId={}", userId);
                    sendUnauthorizedResponse(response);
                    return;
                    // 正常用户,查找用户信息并设置认证
                    Optional<User> userOpt = userRepository.findById(userId);
                    if (userOpt.isPresent()) {
                        User user = userOpt.get();
                        UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                user.getId().toString(),
                                null,
                                new ArrayList<>()
                            );
                        authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authToken);
                        logger.debug("GraphQL请求认证成功: userId={}", user.getId());
                    } else {
                        logger.warn("GraphQL请求的用户不存在: userId={}", userId);
                        sendUnauthorizedResponse(response);
                        return;
                    }
                }
            } catch (Exception e) {
                logger.error("GraphQL请求JWT验证失败: {}", e.getMessage());
@@ -306,26 +320,40 @@
            if (jwtUtil.validateToken(token)) {
                logger.debug("Token验证成功,查找用户信息");
                
                // 查找用户信息
                Optional<User> userOpt = userRepository.findById(userId);
                if (userOpt.isPresent()) {
                    User user = userOpt.get();
                    logger.debug("找到用户: userId={}, phone={}", user.getId(), user.getPhone());
                    // 创建认证对象
                // 检查是否为匿名用户(负数用户ID)
                if (userId < 0) {
                    // 匿名用户,设置特殊的认证信息
                    UsernamePasswordAuthenticationToken authToken = 
                        new UsernamePasswordAuthenticationToken(
                            user.getId().toString(),
                            "anonymous_" + userId,
                            null, 
                            new ArrayList<>() // 暂时不设置权限,后续可以根据角色设置
                            Arrays.asList(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))
                        );
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                    logger.info("用户认证成功: userId={}, phone={}", user.getId(), user.getPhone());
                    logger.info("匿名用户认证成功: userId={}", userId);
                } else {
                    logger.warn("用户不存在: userId={}", userId);
                    // 正常用户,查找用户信息
                    Optional<User> userOpt = userRepository.findById(userId);
                    if (userOpt.isPresent()) {
                        User user = userOpt.get();
                        logger.debug("找到用户: userId={}, phone={}", user.getId(), user.getPhone());
                        // 创建认证对象
                        UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                user.getId().toString(),
                                null,
                                new ArrayList<>() // 暂时不设置权限,后续可以根据角色设置
                            );
                        authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authToken);
                        logger.info("用户认证成功: userId={}, phone={}", user.getId(), user.getPhone());
                    } else {
                        logger.warn("用户不存在: userId={}", userId);
                    }
                }
            } else {
                logger.warn("Token验证失败");
backend/src/main/java/com/rongyichuang/auth/service/AuthService.java
@@ -293,9 +293,9 @@
            logger.warn("⚠️ unionid为空或用户已找到,跳过unionid查找");
        }
        // 4. 如果都没找到用户,不创建新用户,只记录登录信息
        // 4. 如果都没找到用户,为小程序访问创建匿名用户对象(不保存到数据库)
        if (user == null) {
            logger.info("未找到现有用户,普通访问用户不创建user记录,只记录登录信息");
            logger.info("未找到现有用户,为小程序访问创建匿名用户对象");
            logger.info("访问用户信息:");
            logger.info("- openid: {}", wxLoginRequest.getWxOpenid());
            logger.info("- unionid: {}", wxLoginRequest.getWxUnionid());
@@ -321,17 +321,44 @@
                throw new RuntimeException("创建登录记录失败: " + e.getMessage(), e);
            }
            
            // 返回访问用户的响应(无用户信息,无token)
            // 6. 为匿名用户生成特殊的JWT token(使用专门的wxopenid字段)
            logger.info("步骤4: 为匿名用户生成JWT token");
            String token = null;
            try {
                // 使用特殊的用户ID(负数)来标识匿名用户,避免与真实用户ID冲突
                Long anonymousUserId = -Math.abs(wxLoginRequest.getWxOpenid().hashCode()) % 1000000L;
                // 使用新的三参数方法:userId, phone(null), wxopenid
                token = jwtUtil.generateToken(anonymousUserId, null, wxLoginRequest.getWxOpenid());
                logger.info("✅ 成功生成匿名用户JWT token,wxopenid: {}", wxLoginRequest.getWxOpenid());
            } catch (Exception e) {
                logger.error("❌ 生成匿名用户JWT token失败: {}", e.getMessage());
                logger.error("异常堆栈:", e);
                throw new RuntimeException("生成JWT token失败: " + e.getMessage(), e);
            }
            // 7. 构建匿名用户信息
            logger.info("步骤5: 构建匿名用户信息响应");
            LoginResponse.UserInfo userInfo = new LoginResponse.UserInfo(
                    null, // userId为null,表示匿名用户
                    "微信用户", // 默认名称
                    null, // 没有电话号码
                    "anonymous" // 用户类型为匿名
            );
            // 返回匿名用户的响应(包含用户信息和token)
            WxLoginResponse response = new WxLoginResponse();
            response.setSuccess(true);
            response.setMessage("访问成功");
            response.setMessage("匿名访问成功");
            response.setToken(token);
            response.setUserInfo(userInfo);
            response.setSessionKey(wxLoginRequest.getSessionKey());
            response.setIsNewUser(false);
            response.setHasEmployee(false);
            response.setHasJudge(false);
            response.setHasPlayer(false);
            response.setLoginRecordId(loginRecord.getId());
            
            logger.info("=== 微信访问流程完成(无用户创建) ===");
            logger.info("=== 微信匿名访问流程完成 ===");
            return response;
        }
@@ -408,12 +435,13 @@
        // 6. 生成JWT token
        logger.info("步骤5: 生成JWT token");
        String tokenIdentifier = user.getPhone() != null ? user.getPhone() : user.getWxOpenid();
        logger.info("Token标识符: {}", tokenIdentifier);
        logger.info("用户手机号: {}", user.getPhone());
        logger.info("用户wxopenid: {}", user.getWxOpenid());
        
        String token = null;
        try {
            token = jwtUtil.generateToken(user.getId(), tokenIdentifier);
            // 使用新的三参数方法:userId, phone, wxopenid
            token = jwtUtil.generateToken(user.getId(), user.getPhone(), user.getWxOpenid());
            logger.info("✅ 成功生成JWT token,长度: {}", token != null ? token.length() : 0);
        } catch (Exception e) {
            logger.error("❌ 生成JWT token失败: {}", e.getMessage());
backend/src/main/java/com/rongyichuang/auth/util/JwtUtil.java
@@ -25,21 +25,37 @@
    private long jwtExpiration;
    /**
     * 生成JWT token
     * 生成JWT token(旧版本,保持兼容性)
     */
    public String generateToken(Long userId, String phone) {
        return generateToken(userId, phone, null);
    }
    /**
     * 生成JWT token(新版本,支持wxopenid)
     */
    public String generateToken(Long userId, String phone, String wxopenid) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpiration);
        SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
        return Jwts.builder()
        JwtBuilder builder = Jwts.builder()
                .setSubject(userId.toString())
                .claim("phone", phone)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
                .setExpiration(expiryDate);
        // 只有当phone不为null时才添加phone claim
        if (phone != null) {
            builder.claim("phone", phone);
        }
        // 只有当wxopenid不为null时才添加wxopenid claim
        if (wxopenid != null) {
            builder.claim("wxopenid", wxopenid);
        }
        return builder.signWith(key, SignatureAlgorithm.HS256).compact();
    }
    /**
@@ -59,6 +75,14 @@
    }
    /**
     * 从token中获取微信openid
     */
    public String getWxOpenidFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        return claims.get("wxopenid", String.class);
    }
    /**
     * 验证token是否有效
     */
    public boolean validateToken(String token) {
backend/src/main/java/com/rongyichuang/common/util/UserContextUtil.java
@@ -36,19 +36,18 @@
    private JwtUtil jwtUtil;
    /**
     * 获取当前登录用户ID
     * 从JWT token中解析用户ID
     * 获取当前登录用户ID(包括匿名用户)
     * 从JWT token中解析用户ID,包括负数的匿名用户ID
     * 
     * @return 用户ID
     * @throws SecurityException 当没有有效认证时抛出
     * @return 用户ID,包括匿名用户的负数ID
     */
    public Long getCurrentUserId() {
    public Long getCurrentUserIdIncludingAnonymous() {
        try {
            // 首先尝试从HTTP请求头中获取JWT token
            String token = getTokenFromRequest();
            if (token != null && jwtUtil.validateToken(token)) {
                Long userId = jwtUtil.getUserIdFromToken(token);
                logger.debug("从JWT token中获取到用户ID: {}", userId);
                logger.debug("从JWT token中获取到用户ID(包括匿名用户): {}", userId);
                return userId;
            }
@@ -60,29 +59,103 @@
            // 如果没有有效的JWT token,尝试从Spring Security上下文获取
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null && authentication.isAuthenticated() &&
                !"anonymousUser".equals(authentication.getPrincipal())) {
                logger.debug("获取到认证用户: {}", authentication.getName());
            if (authentication != null && authentication.isAuthenticated()) {
                String principal = authentication.getName();
                logger.debug("获取到认证用户: {}", principal);
                // 检查是否为匿名用户
                if ("anonymousUser".equals(principal)) {
                    logger.debug("检测到Spring默认匿名用户,返回null");
                    return null;
                } else if (principal.startsWith("anonymous_")) {
                    // 从 "anonymous_-833488" 中提取用户ID
                    try {
                        String userIdStr = principal.substring("anonymous_".length());
                        Long userId = Long.parseLong(userIdStr);
                        logger.debug("从匿名认证中解析到用户ID: {}", userId);
                        return userId;
                    } catch (NumberFormatException e) {
                        logger.warn("无法从匿名认证信息中解析用户ID: {}", principal);
                    }
                }
                // 从Spring Security上下文中获取用户ID
                try {
                    return Long.parseLong(authentication.getName());
                    return Long.parseLong(principal);
                } catch (NumberFormatException e) {
                    logger.warn("无法从认证信息中解析用户ID: {}", authentication.getName());
                    logger.warn("无法从认证信息中解析用户ID: {}", principal);
                }
            }
        } catch (Exception e) {
            logger.warn("获取当前用户ID时发生异常: {}", e.getMessage());
        }
        
        // 如果没有有效的认证信息,抛出权限异常
        logger.warn("没有有效的认证信息,拒绝访问");
        throw new SecurityException("没有权限");
        // 如果没有有效的认证信息,返回null
        logger.debug("没有有效的认证信息,返回null");
        return null;
    }
    /**
     * 获取当前登录用户ID
     * 从JWT token中解析用户ID
     *
     * @return 用户ID,如果是匿名用户则返回null
     */
    public Long getCurrentUserId() {
        try {
            // 首先尝试从HTTP请求头中获取JWT token
            String token = getTokenFromRequest();
            if (token != null && jwtUtil.validateToken(token)) {
                Long userId = jwtUtil.getUserIdFromToken(token);
                logger.debug("从JWT token中获取到用户ID: {}", userId);
                // 检查是否为匿名用户(负数用户ID)
                if (userId != null && userId < 0) {
                    logger.debug("检测到匿名用户,返回null");
                    return null;
                }
                return userId;
            }
            if (token == null) {
                logger.debug("未能从请求头获取到JWT token");
            } else {
                logger.debug("从请求头获取到token但校验失败");
            }
            // 如果没有有效的JWT token,尝试从Spring Security上下文获取
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null && authentication.isAuthenticated()) {
                String principal = authentication.getName();
                logger.debug("获取到认证用户: {}", principal);
                // 检查是否为匿名用户
                if ("anonymousUser".equals(principal) || principal.startsWith("anonymous_")) {
                    logger.debug("检测到匿名用户认证,返回null");
                    return null;
                }
                // 从Spring Security上下文中获取用户ID
                try {
                    return Long.parseLong(principal);
                } catch (NumberFormatException e) {
                    logger.warn("无法从认证信息中解析用户ID: {}", principal);
                }
            }
        } catch (Exception e) {
            logger.warn("获取当前用户ID时发生异常: {}", e.getMessage());
        }
        // 如果没有有效的认证信息,返回null(支持匿名访问)
        logger.debug("没有有效的认证信息,返回null(匿名用户)");
        return null;
    }
    /**
     * 从HTTP请求中获取JWT token
     */
    private String getTokenFromRequest() {
    public String getTokenFromRequest() {
        try {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes == null) {
backend/src/main/java/com/rongyichuang/employee/dto/response/EmployeeResponse.java
@@ -1,6 +1,7 @@
package com.rongyichuang.employee.dto.response;
import com.rongyichuang.employee.entity.Employee;
import com.rongyichuang.user.entity.User;
/**
 * 员工响应DTO
@@ -11,18 +12,38 @@
    private String phone;
    private String roleId;
    private String description;
    private Integer state;
    private String createTime;
    private String updateTime;
    // 构造函数
    public EmployeeResponse() {}
    /**
     * @deprecated 此构造函数已废弃,请使用 EmployeeResponse(Employee, User) 构造函数
     */
    @Deprecated
    public EmployeeResponse(Employee employee) {
        this.id = employee.getId();
        this.name = employee.getName();
        this.phone = employee.getPhone();
        this.phone = employee.getPhone(); // 这里会返回null,因为phone字段已废弃
        this.roleId = employee.getRoleId();
        this.description = employee.getDescription();
        this.state = employee.getState();
        this.createTime = employee.getCreateTime() != null ? employee.getCreateTime().toString() : null;
        this.updateTime = employee.getUpdateTime() != null ? employee.getUpdateTime().toString() : null;
    }
    /**
     * 推荐使用的构造函数,从User对象获取phone信息
     */
    public EmployeeResponse(Employee employee, User user) {
        this.id = employee.getId();
        this.name = employee.getName();
        this.phone = user != null ? user.getPhone() : null; // 从User对象获取phone
        this.roleId = employee.getRoleId();
        this.description = employee.getDescription();
        this.state = employee.getState();
        this.createTime = employee.getCreateTime() != null ? employee.getCreateTime().toString() : null;
        this.updateTime = employee.getUpdateTime() != null ? employee.getUpdateTime().toString() : null;
    }
@@ -68,6 +89,14 @@
        this.description = description;
    }
    public Integer getState() {
        return state;
    }
    public void setState(Integer state) {
        this.state = state;
    }
    public String getCreateTime() {
        return createTime;
    }
backend/src/main/java/com/rongyichuang/employee/entity/Employee.java
@@ -20,8 +20,10 @@
    /**
     * 手机号码
     * @deprecated 此字段已废弃,电话号码统一保存在t_user表中,此字段将设置为null
     */
    @Column(name = "phone", length = 32, nullable = false)
    @Deprecated
    @Column(name = "phone", length = 32, nullable = true)
    private String phone;
@@ -68,10 +70,18 @@
        this.name = name;
    }
    /**
     * @deprecated 此方法已废弃,请通过关联的User对象获取电话号码
     */
    @Deprecated
    public String getPhone() {
        return phone;
    }
    /**
     * @deprecated 此方法已废弃,电话号码统一保存在User表中
     */
    @Deprecated
    public void setPhone(String phone) {
        this.phone = phone;
    }
backend/src/main/java/com/rongyichuang/employee/service/EmployeeService.java
@@ -43,12 +43,25 @@
    private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*?&]{6,}$");
    /**
     * 辅助方法:将Employee转换为EmployeeResponse
     */
    private EmployeeResponse convertToResponse(Employee employee) {
        Optional<User> userOpt = userService.findById(employee.getUserId());
        if (userOpt.isPresent()) {
            User user = userOpt.get();
            return new EmployeeResponse(employee, user);
        } else {
            throw new BusinessException("USER_NOT_FOUND", "员工对应的用户不存在,员工ID: " + employee.getId());
        }
    }
    /**
     * 获取所有员工列表
     */
    public List<EmployeeResponse> findAllEmployees() {
        List<Employee> employees = employeeRepository.findAll();
        return employees.stream()
                .map(EmployeeResponse::new)
                .map(this::convertToResponse)
                .collect(Collectors.toList());
    }
@@ -58,7 +71,7 @@
    public Page<EmployeeResponse> findEmployees(String name, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<Employee> employeePage = employeeRepository.findByNameContainingOrderByCreateTimeDesc(name, pageable);
        return employeePage.map(EmployeeResponse::new);
        return employeePage.map(this::convertToResponse);
    }
    /**
@@ -70,7 +83,7 @@
        }
        List<Employee> employees = employeeRepository.findByNameContaining(name.trim());
        return employees.stream()
                .map(EmployeeResponse::new)
                .map(this::convertToResponse)
                .collect(Collectors.toList());
    }
@@ -80,7 +93,14 @@
    public EmployeeResponse findById(Long id) {
        Optional<Employee> employee = employeeRepository.findById(id);
        if (employee.isPresent()) {
            return new EmployeeResponse(employee.get());
            Employee emp = employee.get();
            Optional<User> userOpt = userService.findById(emp.getUserId());
            if (userOpt.isPresent()) {
                User user = userOpt.get();
                return new EmployeeResponse(emp, user);
            } else {
                throw new BusinessException("USER_NOT_FOUND", "员工对应的用户不存在,员工ID: " + id);
            }
        }
        throw new BusinessException("EMPLOYEE_NOT_FOUND", "员工不存在");
    }
@@ -116,21 +136,24 @@
            employee = employeeRepository.findById(input.getId())
                    .orElseThrow(() -> new BusinessException("EMPLOYEE_NOT_FOUND", "员工不存在"));
            
            // 检查手机号是否被其他员工使用
            if (employeeRepository.existsByPhoneAndIdNot(input.getPhone(), input.getId())) {
                throw new BusinessException("PHONE_ALREADY_EXISTS", "手机号已被其他员工使用");
            // 检查用户ID是否被其他员工使用(排除当前员工)
            Optional<Employee> existingEmployee = employeeRepository.findByUserId(user.getId());
            if (existingEmployee.isPresent() && !existingEmployee.get().getId().equals(input.getId())) {
                throw new BusinessException("PHONE_ALREADY_EXISTS", "该手机号已被其他员工使用");
            }
        } else {
            // 新增员工
            if (employeeRepository.existsByPhone(input.getPhone())) {
                throw new BusinessException("PHONE_ALREADY_EXISTS", "手机号已存在");
            // 新增员工 - 检查该用户是否已经是员工
            Optional<Employee> existingEmployee = employeeRepository.findByUserId(user.getId());
            if (existingEmployee.isPresent()) {
                throw new BusinessException("PHONE_ALREADY_EXISTS", "该手机号已被其他员工使用");
            }
            employee = new Employee();
        }
        // 设置基本信息
        employee.setName(input.getName());
        employee.setPhone(input.getPhone());
        // 不再设置phone字段,保持为null
        employee.setPhone(null);
        employee.setRoleId(input.getRoleId());
        employee.setDescription(input.getDescription());
        employee.setUserId(user.getId()); // 设置关联的用户ID
@@ -138,7 +161,7 @@
        Employee savedEmployee = employeeRepository.save(employee);
        logger.info("员工保存成功: {}", savedEmployee.getName());
        
        return new EmployeeResponse(savedEmployee);
        return new EmployeeResponse(savedEmployee, user);
    }
    /**
backend/src/main/java/com/rongyichuang/judge/service/JudgeService.java
@@ -159,18 +159,28 @@
        }
        if (input.getId() != null) {
            // 更新现有评委
            judge = judgeRepository.findById(input.getId())
                    .orElseThrow(() -> new BusinessException("评委不存在"));
            // 检查用户ID是否被其他评委使用
            Optional<Judge> existingJudgeByUserId = judgeRepository.findByUserId(user.getId());
            if (existingJudgeByUserId.isPresent() && !existingJudgeByUserId.get().getId().equals(input.getId())) {
                throw new BusinessException("该手机号已被其他评委使用");
            }
        } else {
            // 新增评委
            judge = new Judge();
            // 新增评委时检查手机号是否已存在
            if (judgeRepository.existsByPhone(input.getPhone())) {
                throw new BusinessException("PHONE_EXISTS", "手机号码已存在,请使用其他手机号码");
            // 检查该用户是否已是评委
            Optional<Judge> existingJudge = judgeRepository.findByUserId(user.getId());
            if (existingJudge.isPresent()) {
                throw new BusinessException("该手机号已被其他评委使用");
            }
        }
        judge.setName(input.getName());
        judge.setPhone(input.getPhone());
        judge.setPhone(null); // 废弃judge.phone字段,设置为null
        judge.setGender(input.getGender());
        judge.setDescription(input.getDescription());
        judge.setTitle(input.getTitle());
@@ -204,7 +214,19 @@
        JudgeResponse response = new JudgeResponse();
        response.setId(judge.getId());
        response.setName(judge.getName());
        response.setPhone(judge.getPhone());
        // 通过关联的user获取phone
        if (judge.getUserId() != null) {
            Optional<User> userOpt = userService.findById(judge.getUserId());
            if (userOpt.isPresent()) {
                response.setPhone(userOpt.get().getPhone());
            } else {
                response.setPhone(null);
            }
        } else {
            response.setPhone(null);
        }
        response.setGender(judge.getGender());
        response.setDescription(judge.getDescription());
        response.setTitle(judge.getTitle());
backend/src/main/java/com/rongyichuang/review/resolver/ReviewResolver.java
@@ -31,10 +31,10 @@
     */
    @QueryMapping
    public ReviewProjectPageResponse unReviewedProjects(
            @Argument String searchKeyword,
            @Argument int page,
            @Argument int pageSize,
            @Argument String searchKeyword) {
        log.info("查询我未评审的项目列表,page: {}, pageSize: {}, searchKeyword: {}", page, pageSize, searchKeyword);
            @Argument int pageSize) {
        log.info("查询我未评审的项目列表,searchKeyword: {}, page: {}, pageSize: {}", searchKeyword, page, pageSize);
        
        Long currentJudgeId = userContextUtil.getCurrentJudgeId();
        if (currentJudgeId == null) {
@@ -49,10 +49,10 @@
     */
    @QueryMapping
    public ReviewProjectPageResponse reviewedProjects(
            @Argument String searchKeyword,
            @Argument int page,
            @Argument int pageSize,
            @Argument String searchKeyword) {
        log.info("查询我已评审的项目列表,page: {}, pageSize: {}, searchKeyword: {}", page, pageSize, searchKeyword);
            @Argument int pageSize) {
        log.info("查询我已评审的项目列表,searchKeyword: {}, page: {}, pageSize: {}", searchKeyword, page, pageSize);
        
        Long currentJudgeId = userContextUtil.getCurrentJudgeId();
        if (currentJudgeId == null) {
@@ -67,10 +67,10 @@
     */
    @QueryMapping
    public ReviewProjectPageResponse studentUnReviewedProjects(
            @Argument String searchKeyword,
            @Argument int page,
            @Argument int pageSize,
            @Argument String searchKeyword) {
        log.info("查询学员未评审的项目列表,page: {}, pageSize: {}, searchKeyword: {}", page, pageSize, searchKeyword);
            @Argument int pageSize) {
        log.info("查询学员未评审的项目列表,searchKeyword: {}, page: {}, pageSize: {}", searchKeyword, page, pageSize);
        
        Long currentJudgeId = userContextUtil.getCurrentJudgeId();
        if (currentJudgeId == null) {
backend/src/main/java/com/rongyichuang/user/dto/response/UserProfile.java
@@ -21,6 +21,7 @@
    private String gender;
    private String birthday;
    private List<String> roles;
    private String userType;
    private String createdAt;
    private EmployeeInfo employee;
    private JudgeInfo judge;
@@ -115,6 +116,14 @@
        this.roles = roles;
    }
    public String getUserType() {
        return userType;
    }
    public void setUserType(String userType) {
        this.userType = userType;
    }
    public String getCreatedAt() {
        return createdAt;
    }
backend/src/main/java/com/rongyichuang/user/resolver/UserResolver.java
@@ -4,6 +4,7 @@
import com.rongyichuang.auth.dto.LoginResponse.JudgeInfo;
import com.rongyichuang.auth.dto.LoginResponse.PlayerInfo;
import com.rongyichuang.common.util.UserContextUtil;
import com.rongyichuang.auth.util.JwtUtil;
import com.rongyichuang.employee.entity.Employee;
import com.rongyichuang.employee.service.EmployeeService;
import com.rongyichuang.judge.entity.Judge;
@@ -14,6 +15,9 @@
import com.rongyichuang.common.repository.MediaRepository;
import com.rongyichuang.common.entity.Media;
import com.rongyichuang.common.enums.MediaTargetType;
import com.rongyichuang.media.service.MediaV2Service;
import com.rongyichuang.media.dto.MediaSaveInput;
import com.rongyichuang.media.dto.MediaSaveResponse;
import com.rongyichuang.user.dto.response.UserProfile;
import com.rongyichuang.user.dto.response.UserStats;
import com.rongyichuang.user.dto.response.UserRegistration;
@@ -73,6 +77,12 @@
    @Autowired
    private UserContextUtil userContextUtil;
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private MediaV2Service mediaV2Service;
    @Value("${app.media-url}")
    private String mediaBaseUrl;
@@ -84,8 +94,17 @@
        try {
            Long userId = userContextUtil.getCurrentUserId();
            logger.debug("进入userProfile,解析到用户ID: {}", userId);
            // 如果是匿名用户,返回基本的用户档案
            if (userId == null) {
                throw new RuntimeException("用户未登录");
                logger.debug("匿名用户访问userProfile,返回基本档案");
                UserProfile profile = new UserProfile();
                profile.setId("0"); // 匿名用户ID设为0
                profile.setName("匿名用户");
                profile.setRoles(new ArrayList<>());
                profile.setUserType("user"); // 匿名用户类型为普通用户
                profile.setCreatedAt(java.time.LocalDateTime.now().toString());
                return profile;
            }
            UserProfile profile = new UserProfile();
@@ -96,6 +115,9 @@
            User user = null;
            if (userOpt.isPresent()) {
                user = userOpt.get();
                // 设置基本信息(姓名和电话号码)
                profile.setName(user.getName());
                profile.setPhone(user.getPhone());
                // 设置性别
                if (user.getGender() != null) {
                    profile.setGender(user.getGender() == 1 ? "MALE" : "FEMALE");
@@ -113,8 +135,13 @@
            Employee employee = employeeService.findByUserId(userId);
            logger.debug("员工查询结果: {}", employee != null ? ("id=" + employee.getId() + ", name=" + employee.getName()) : "无");
            if (employee != null) {
                profile.setName(employee.getName());
                profile.setPhone(employee.getPhone());
                // 如果员工信息存在,则覆盖基本信息
                if (employee.getName() != null) {
                    profile.setName(employee.getName());
                }
                if (employee.getPhone() != null) {
                    profile.setPhone(employee.getPhone());
                }
                roles.add("EMPLOYEE");
                
                EmployeeInfo employeeInfo = new EmployeeInfo(
@@ -131,8 +158,11 @@
            Judge judge = judgeService.findByUserId(userId);
            logger.debug("评委查询结果: {}", judge != null ? ("id=" + judge.getId() + ", name=" + judge.getName()) : "无");
            if (judge != null) {
                if (profile.getName() == null) {
                // 如果评委信息存在且基本信息为空,则设置评委信息
                if (profile.getName() == null && judge.getName() != null) {
                    profile.setName(judge.getName());
                }
                if (profile.getPhone() == null && judge.getPhone() != null) {
                    profile.setPhone(judge.getPhone());
                }
                roles.add("JUDGE");
@@ -152,8 +182,11 @@
            Player player = playerService.findByUserId(userId);
            logger.debug("学员查询结果: {}", player != null ? ("id=" + player.getId() + ", name=" + player.getName()) : "无");
            if (player != null) {
                if (profile.getName() == null) {
                // 如果学员信息存在且基本信息为空,则设置学员信息
                if (profile.getName() == null && player.getName() != null) {
                    profile.setName(player.getName());
                }
                if (profile.getPhone() == null && player.getPhone() != null) {
                    profile.setPhone(player.getPhone());
                }
                roles.add("PLAYER");
@@ -168,6 +201,20 @@
            }
            
            profile.setRoles(roles);
            // 确定主要角色类型(优先级:employee > judge > player)
            String userType;
            if (employee != null) {
                userType = "employee";
            } else if (judge != null) {
                userType = "judge";
            } else if (player != null) {
                userType = "player";
            } else {
                userType = "user"; // 普通用户
            }
            profile.setUserType(userType);
            logger.debug("设置用户类型: {}", userType);
            
            // 获取用户头像
            try {
@@ -429,30 +476,99 @@
    @MutationMapping
    public UserProfileInfo saveUserInfo(@Argument UserInput input) {
        try {
            Long userId = userContextUtil.getCurrentUserId();
            logger.debug("进入saveUserInfo,解析到用户ID: {}", userId);
            Long userId = userContextUtil.getCurrentUserIdIncludingAnonymous();
            logger.debug("进入saveUserInfo,解析到用户ID(包括匿名用户): {}", userId);
            if (userId == null) {
                throw new RuntimeException("用户未登录");
            }
            // 查找现有用户
            Optional<User> userOpt = userRepository.findById(userId);
            User user;
            boolean isNewUser = false;
            
            if (userOpt.isPresent()) {
                // 用户存在,更新信息
                user = userOpt.get();
                logger.debug("更新现有用户信息,用户ID: {}", userId);
            // 检查手机号是否存在
            if (StringUtils.hasText(input.getPhone())) {
                Optional<User> existingUserByPhone = userRepository.findByPhone(input.getPhone());
                if (existingUserByPhone.isPresent()) {
                    // 手机号已存在,更新现有用户
                    user = existingUserByPhone.get();
                    logger.debug("手机号{}已存在,更新现有用户,用户ID: {}", input.getPhone(), user.getId());
                    // 如果当前用户ID与手机号对应的用户ID不同,需要处理匿名用户转正的情况
                    if (!user.getId().equals(userId)) {
                        logger.debug("匿名用户{}转正为正式用户{}", userId, user.getId());
                        // 检查匿名用户是否有需要合并的信息
                        if (userId < 0) {
                            // 这是匿名用户转正,检查是否有临时信息需要合并
                            Optional<User> anonymousUserOpt = userRepository.findById(userId);
                            if (anonymousUserOpt.isPresent()) {
                                User anonymousUser = anonymousUserOpt.get();
                                logger.debug("发现匿名用户记录,准备合并信息");
                                // 合并匿名用户的信息到正式用户(如果正式用户没有这些信息)
                                if (!StringUtils.hasText(user.getName()) && StringUtils.hasText(anonymousUser.getName())) {
                                    user.setName(anonymousUser.getName());
                                    logger.debug("合并匿名用户的姓名: {}", anonymousUser.getName());
                                }
                                if (user.getGender() == null && anonymousUser.getGender() != null) {
                                    user.setGender(anonymousUser.getGender());
                                    logger.debug("合并匿名用户的性别: {}", anonymousUser.getGender());
                                }
                                if (user.getBirthday() == null && anonymousUser.getBirthday() != null) {
                                    user.setBirthday(anonymousUser.getBirthday());
                                    logger.debug("合并匿名用户的生日: {}", anonymousUser.getBirthday());
                                }
                                // 删除匿名用户记录(可选,避免数据冗余)
                                try {
                                    userRepository.delete(anonymousUser);
                                    logger.debug("删除匿名用户记录: {}", userId);
                                } catch (Exception e) {
                                    logger.warn("删除匿名用户记录失败: {}", e.getMessage());
                                }
                            }
                        }
                        userId = user.getId(); // 使用正式用户ID
                    }
                } else {
                    // 手机号不存在,检查当前用户ID是否存在
                    Optional<User> userOpt = userRepository.findById(userId);
                    if (userOpt.isPresent()) {
                        // 用户ID存在,更新信息
                        user = userOpt.get();
                        logger.debug("更新现有用户信息,用户ID: {}", userId);
                    } else {
                        // 用户ID不存在,创建新用户
                        user = new User();
                        user.setId(userId);
                        isNewUser = true;
                        logger.debug("创建新用户,用户ID: {}", userId);
                    }
                }
            } else {
                // 用户不存在,创建新用户
                user = new User();
                user.setId(userId);
                logger.debug("创建新用户,用户ID: {}", userId);
                // 没有提供手机号,直接根据用户ID查找或创建
                Optional<User> userOpt = userRepository.findById(userId);
                if (userOpt.isPresent()) {
                    user = userOpt.get();
                    logger.debug("更新现有用户信息,用户ID: {}", userId);
                } else {
                    user = new User();
                    user.setId(userId);
                    isNewUser = true;
                    logger.debug("创建新用户,用户ID: {}", userId);
                }
            }
            // 更新用户基本信息(不包含头像)
            user.setName(input.getName());
            user.setPhone(input.getPhone());
            if (StringUtils.hasText(input.getName())) {
                user.setName(input.getName());
            }
            if (StringUtils.hasText(input.getPhone())) {
                user.setPhone(input.getPhone());
            }
            
            // 处理性别转换:MALE -> 1, FEMALE -> 0
            if ("MALE".equals(input.getGender())) {
@@ -470,10 +586,75 @@
                    logger.warn("生日格式解析失败: {}", input.getBirthday(), e);
                }
            }
            // 处理wxopenid更新:从JWT token中获取当前用户的wxopenid
            try {
                String token = userContextUtil.getTokenFromRequest();
                if (token != null && jwtUtil.validateToken(token)) {
                    Long currentUserId = jwtUtil.getUserIdFromToken(token);
                    String wxopenidFromToken = jwtUtil.getWxOpenidFromToken(token);
                    // 如果token中包含wxopenid,则更新用户的wxopenid
                    if (StringUtils.hasText(wxopenidFromToken)) {
                        logger.debug("从token中获取到wxopenid: {}", wxopenidFromToken);
                        // 检查这个openid是否已经被其他用户使用
                        Optional<User> existingUserWithOpenid = userRepository.findByWxOpenid(wxopenidFromToken);
                        if (existingUserWithOpenid.isEmpty() || existingUserWithOpenid.get().getId().equals(user.getId())) {
                            user.setWxOpenid(wxopenidFromToken);
                            logger.debug("设置用户wxopenid: {}", wxopenidFromToken);
                        } else {
                            logger.warn("wxopenid {} 已被其他用户使用,用户ID: {}", wxopenidFromToken, existingUserWithOpenid.get().getId());
                        }
                    } else {
                        logger.debug("token中未包含wxopenid信息");
                    }
                }
            } catch (Exception e) {
                logger.warn("处理wxopenid更新时发生异常: {}", e.getMessage(), e);
            }
            // 保存用户基本信息
            user = userRepository.save(user);
            logger.debug("用户信息保存成功,用户ID: {}", user.getId());
            logger.debug("用户信息保存成功,用户ID: {}, 是否新用户: {}", user.getId(), isNewUser);
            // 处理头像保存
            if (StringUtils.hasText(input.getAvatar())) {
                try {
                    logger.debug("开始保存用户头像,路径: {}", input.getAvatar());
                    // 构建MediaSaveInput
                    MediaSaveInput mediaSaveInput = new MediaSaveInput();
                    mediaSaveInput.setTargetType("player"); // 使用"player"作为目标类型
                    mediaSaveInput.setTargetId(user.getId());
                    mediaSaveInput.setPath(input.getAvatar());
                    mediaSaveInput.setMediaType(1); // 1表示图片
                    mediaSaveInput.setFileSize(0L); // 设置默认文件大小为0,避免数据库约束错误
                    // 从路径中提取文件名和扩展名
                    String fileName = input.getAvatar();
                    if (fileName.contains("/")) {
                        fileName = fileName.substring(fileName.lastIndexOf("/") + 1);
                    }
                    mediaSaveInput.setFileName(fileName);
                    if (fileName.contains(".")) {
                        String fileExt = fileName.substring(fileName.lastIndexOf(".") + 1);
                        mediaSaveInput.setFileExt(fileExt);
                    }
                    // 保存头像媒体记录
                    MediaSaveResponse saveResponse = mediaV2Service.saveMedia(mediaSaveInput);
                    if (saveResponse.getSuccess()) {
                        logger.debug("头像保存成功,媒体ID: {}", saveResponse.getMediaId());
                    } else {
                        logger.warn("头像保存失败: {}", saveResponse.getMessage());
                    }
                } catch (Exception e) {
                    logger.error("保存头像时发生错误", e);
                    // 头像保存失败不影响用户信息保存
                }
            }
            // 构建返回结果
            UserProfileInfo result = new UserProfileInfo();
@@ -494,12 +675,13 @@
            try {
                List<Media> avatarMedias = mediaRepository.findByTargetTypeAndTargetIdAndState(
                    MediaTargetType.USER_AVATAR.getValue(), 
                    userId,
                    user.getId(),
                    1
                );
                if (!avatarMedias.isEmpty()) {
                    Media avatarMedia = avatarMedias.get(0);
                    result.setAvatar(buildFullMediaUrl(avatarMedia.getPath()));
                    logger.debug("设置头像URL: {}", result.getAvatar());
                }
            } catch (Exception e) {
                logger.warn("获取头像失败: {}", e.getMessage(), e);
backend/src/main/java/com/rongyichuang/user/service/UserService.java
@@ -75,14 +75,22 @@
                        boolean needUpdateWx = false;
                        if (currentWxOpenid != null && !currentWxOpenid.trim().isEmpty()) {
                            if (user.getWxOpenid() == null || !currentWxOpenid.equals(user.getWxOpenid())) {
                                user.setWxOpenid(currentWxOpenid);
                                needUpdateWx = true;
                                // 检查这个openid是否已经被其他用户使用
                                Optional<User> existingUserWithOpenid = userRepository.findByWxOpenid(currentWxOpenid);
                                if (existingUserWithOpenid.isEmpty() || existingUserWithOpenid.get().getId().equals(user.getId())) {
                                    user.setWxOpenid(currentWxOpenid);
                                    needUpdateWx = true;
                                }
                            }
                        }
                        if (currentWxUnionid != null && !currentWxUnionid.trim().isEmpty()) {
                            if (user.getWxUnionid() == null || !currentWxUnionid.equals(user.getWxUnionid())) {
                                user.setWxUnionid(currentWxUnionid);
                                needUpdateWx = true;
                                // 检查这个unionid是否已经被其他用户使用
                                Optional<User> existingUserWithUnionid = userRepository.findByWxUnionid(currentWxUnionid);
                                if (existingUserWithUnionid.isEmpty() || existingUserWithUnionid.get().getId().equals(user.getId())) {
                                    user.setWxUnionid(currentWxUnionid);
                                    needUpdateWx = true;
                                }
                            }
                        }
                        if (needUpdateWx) {
backend/src/main/resources/application.yml
@@ -86,7 +86,7 @@
  base-url: https://rych.9village.cn
  jwt:
    secret: ryc-jwt-secret-key-2024-secure-256bit-hmac-sha-algorithm-compatible
    expiration: 7200000 # 2小时
    expiration: 86400000 # 24小时
  media-url:  https://ryc-1379367838.cos.ap-chengdu.myqcloud.com
# 微信小程序配置
backend/src/main/resources/graphql/auth.graphqls
@@ -4,8 +4,17 @@
}
extend type Mutation {
    # 认证相关接口已迁移到RESTful API (/auth/*)
    _: Boolean
    # 微信登录
    wxLogin(input: WxLoginRequest!): WxLoginResponse
    # Web端登录
    webLogin(input: LoginRequest!): LoginResponse
    # 解密微信手机号(旧版API)
    decryptPhoneNumber(encryptedData: String!, iv: String!, sessionKey: String!): PhoneDecryptResponse
    # 获取微信手机号(新版API)
    getPhoneNumberByCode(code: String!): PhoneDecryptResponse
}
input LoginRequest {
@@ -35,6 +44,11 @@
    isNewUser: Boolean
    loginRecordId: Long
    sessionKey: String
    success: Boolean
    message: String
    hasEmployee: Boolean
    hasJudge: Boolean
    hasPlayer: Boolean
}
type UserInfo {
backend/src/main/resources/graphql/user.graphqls
@@ -13,6 +13,7 @@
    gender: String
    birthday: String
    roles: [String!]!
    userType: String
    createdAt: String
    # 角色相关信息
    employee: EmployeeInfo
web/src/views/check-detail.vue
@@ -115,6 +115,13 @@
              <el-button 
                type="primary" 
                size="small" 
                @click="previewAttachment(attachment)"
              >
                预览
              </el-button>
              <el-button
                type="primary"
                size="small"
                @click="downloadAttachment(attachment)"
              >
                下载
@@ -130,7 +137,24 @@
          <div class="card-header">
            <span>审核管理</span>
          </div>
        </template>
          <!-- 附件预览对话框 -->
  <el-dialog v-model="previewVisible" title="文件预览" width="80%" center>
    <div class="preview-content">
      <!-- 图片预览 -->
      <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" style="max-width: 100%; max-height: 70vh; object-fit: contain;" />
      <!-- 视频预览 -->
      <video v-else-if="previewType === 'video' && previewUrl" :src="previewUrl" controls style="width: 100%; max-height: 70vh;"></video>
      <!-- PDF 预览 -->
      <iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" style="width: 100%; height: 70vh; border: none;"></iframe>
      <!-- DOCX 预览 -->
      <div v-else-if="previewType === 'docx'" ref="docxContainer" class="docx-preview"></div>
      <!-- 其它不支持 -->
      <div v-else class="preview-error">
        <el-empty description="无法预览此文件类型,请下载查看" />
      </div>
    </div>
  </el-dialog>
</template>
        
        <div class="review-section">
          <div class="review-status">
@@ -223,6 +247,12 @@
const playerData = ref<any>(null)
const activityPlayerData = ref<any>(null)
const attachments = ref<any[]>([])
// 预览相关
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'' | 'image' | 'video' | 'pdf' | 'docx' | 'unknown'>('')
const docxContainer = ref<HTMLElement | null>(null)
// 审核相关数据
const feedbackText = ref('')
@@ -422,6 +452,113 @@
const downloadAttachment = (attachment: any) => {
  // TODO: 实现附件下载功能
  window.open(attachment.url, '_blank')
}
/**
 * 预览附件:按扩展名分发 图片/视频/PDF/DOCX
 */
const previewAttachment = async (attachment: any) => {
  const name: string = attachment.originalName || attachment.name || ''
  const url: string = attachment.url
  const ext = (name.split('.').pop() || '').toLowerCase()
  previewVisible.value = true
  previewUrl.value = ''
  previewType.value = ''
  const imageExts = ['jpg','jpeg','png','gif','bmp','webp']
  const videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','mkv']
  if (imageExts.includes(ext)) {
    previewType.value = 'image'
    previewUrl.value = url
    return
  }
  if (videoExts.includes(ext)) {
    previewType.value = 'video'
    previewUrl.value = url
    return
  }
  if (ext === 'pdf') {
    previewType.value = 'pdf'
    previewUrl.value = url
    return
  }
  if (ext === 'docx') {
    previewType.value = 'docx'
    try {
      await renderDocx(url)
    } catch (e: any) {
      console.error('DOCX 预览失败:', e)
      ElMessage.warning('DOCX 预览失败,建议下载查看')
      previewType.value = 'unknown'
    }
    return
  }
  if (ext === 'doc') {
    ElMessage.info('暂不支持 .doc 预览,请下载查看')
    previewType.value = 'unknown'
    return
  }
  ElMessage.warning('此文件类型不支持预览,请下载查看')
  previewType.value = 'unknown'
}
/**
 * 动态加载 docx-preview 并渲染 DOCX
 */
const renderDocx = async (url: string) => {
  // 动态加载 docx-preview
  const ensureScript = () => new Promise((resolve, reject) => {
    if ((window as any).docx && (window as any).docx.renderAsync) return resolve(true)
    const existed = document.querySelector('script[data-docx-preview]')
    if (existed) {
      existed.addEventListener('load', () => resolve(true))
      existed.addEventListener('error', reject)
      return
    }
    const s = document.createElement('script')
    s.src = 'https://unpkg.com/docx-preview/dist/docx-preview.min.js'
    s.async = true
    s.setAttribute('data-docx-preview', '1')
    s.onload = () => resolve(true)
    s.onerror = reject
    document.head.appendChild(s)
  })
  // 规范化 URL(支持相对路径)
  const buildUrl = (u: string) => {
    if (!u) return u
    if (u.startsWith('http://') || u.startsWith('https://')) return u
    if (u.startsWith('/')) return location.origin + u
    return u
  }
  // 携带鉴权头访问附件(很多文件服务不走 cookie,而是 JWT Header)
  const { getToken } = await import('@/utils/auth')
  const token = getToken ? getToken() : ''
  const requestUrl = buildUrl(url)
  let res: Response
  try {
    res = await fetch(requestUrl, {
      credentials: 'include',
      headers: token ? { Authorization: `Bearer ${token}` } : undefined,
      mode: 'cors'
    } as RequestInit)
  } catch (e) {
    throw new Error('获取 DOCX 失败: 网络不可达或被拦截')
  }
  if (!res.ok) throw new Error('获取 DOCX 失败: ' + res.status)
  const blob = await res.blob()
  await ensureScript()
  if (docxContainer.value) {
    docxContainer.value.innerHTML = ''
    await (window as any).docx.renderAsync(blob, docxContainer.value, null, { inWrapper: true })
  }
}
// 审核通过
@@ -770,4 +907,19 @@
    grid-template-columns: 1fr;
  }
}
  .preview-content {
    text-align: center;
  }
  .preview-error {
    padding: 40px 0;
  }
  .docx-preview {
    text-align: left;
    max-height: 70vh;
    overflow: auto;
    background: #fff;
    padding: 12px;
  }
</style>
web/src/views/review-detail.vue
@@ -168,13 +168,17 @@
    <!-- 文件预览对话框 -->
    <el-dialog v-model="previewVisible" title="文件预览" width="80%" center>
      <div class="preview-content">
        <iframe
          v-if="previewUrl"
          :src="previewUrl"
          style="width: 100%; height: 500px; border: none;"
        ></iframe>
        <!-- 图片预览 -->
        <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" style="max-width: 100%; max-height: 70vh; object-fit: contain;" />
        <!-- 视频预览 -->
        <video v-else-if="previewType === 'video' && previewUrl" :src="previewUrl" controls style="width: 100%; max-height: 70vh;"></video>
        <!-- PDF 预览 -->
        <iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" style="width: 100%; height: 70vh; border: none;"></iframe>
        <!-- DOCX 预览 -->
        <div v-else-if="previewType === 'docx'" ref="docxContainer" class="docx-preview"></div>
        <!-- 其它不支持 -->
        <div v-else class="preview-error">
          <el-empty description="无法预览此文件类型" />
          <el-empty description="无法预览此文件类型,请下载查看" />
        </div>
      </div>
    </el-dialog>
@@ -204,6 +208,8 @@
const ratingComment = ref('')
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref('') // image | video | pdf | docx | unknown
const docxContainer = ref(null)
// 权限验证相关
const currentJudge = ref(null)
@@ -446,17 +452,84 @@
  }
}
// 文件预览
const previewFile = (file) => {
  // 根据文件类型决定预览方式
  const fileExtension = file.name.split('.').pop().toLowerCase()
  const previewableTypes = ['pdf', 'txt', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm']
  if (previewableTypes.includes(fileExtension)) {
    // 在新窗口中打开预览
    window.open(file.url, '_blank')
  } else {
    ElMessage.warning('此文件类型不支持预览,请下载查看')
/**
 * 文件预览:按扩展名分发到图片/视频/PDF/DOCX
 */
const previewFile = async (file) => {
  const ext = (file.name?.split('.').pop() || '').toLowerCase()
  previewVisible.value = true
  previewUrl.value = ''
  previewType.value = ''
  // 统一取可用的完整 URL
  const url = file.url || file.fullUrl || file.full_path || file.path
  const imageExts = ['jpg','jpeg','png','gif','bmp','webp']
  const videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','mkv']
  if (imageExts.includes(ext)) {
    previewType.value = 'image'
    previewUrl.value = url
    return
  }
  if (videoExts.includes(ext)) {
    previewType.value = 'video'
    previewUrl.value = url
    return
  }
  if (ext === 'pdf') {
    previewType.value = 'pdf'
    previewUrl.value = url
    return
  }
  if (ext === 'docx') {
    previewType.value = 'docx'
    try {
      await renderDocx(url)
    } catch (e) {
      console.error('DOCX 预览失败:', e)
      ElMessage.warning('DOCX 预览失败,建议下载查看')
      previewType.value = 'unknown'
    }
    return
  }
  if (ext === 'doc') {
    ElMessage.info('暂不支持 .doc 预览,请下载查看')
    previewType.value = 'unknown'
    return
  }
  ElMessage.warning('此文件类型不支持预览,请下载查看')
  previewType.value = 'unknown'
}
/**
 * 动态加载 docx-preview 并渲染 DOCX
 */
const renderDocx = async (url) => {
  const ensureScript = () => new Promise((resolve, reject) => {
    if (window.docx && window.docx.renderAsync) return resolve(true)
    const existed = document.querySelector('script[data-docx-preview]')
    if (existed) {
      existed.addEventListener('load', () => resolve(true))
      existed.addEventListener('error', reject)
      return
    }
    const s = document.createElement('script')
    s.src = 'https://unpkg.com/docx-preview/dist/docx-preview.min.js'
    s.async = true
    s.setAttribute('data-docx-preview', '1')
    s.onload = () => resolve(true)
    s.onerror = reject
    document.head.appendChild(s)
  })
  await ensureScript()
  const res = await fetch(url, { credentials: 'include' })
  if (!res.ok) throw new Error('获取 DOCX 失败: ' + res.status)
  const blob = await res.blob()
  if (docxContainer.value) {
    docxContainer.value.innerHTML = ''
    await window.docx.renderAsync(blob, docxContainer.value, null, { inWrapper: true })
  }
}
@@ -699,6 +772,13 @@
.preview-content {
  text-align: center;
}
.docx-preview {
  text-align: left;
  max-height: 70vh;
  overflow: auto;
  background: #fff;
  padding: 12px;
}
.preview-error {
  padding: 40px 0;
wx/app.js
@@ -163,7 +163,7 @@
        }
        
        // 检查是否有错误信息(适配不同的错误响应格式)
        if (res.data.error || res.data.message || res.data.success === false) {
        if (res.data.error || res.data.success === false) {
          const errorMsg = res.data.error || res.data.message || '登录失败'
          console.error('❌ 登录失败:', errorMsg)
          wx.showToast({
@@ -276,174 +276,56 @@
  // GraphQL请求封装
  graphqlRequest(query, variables = {}) {
    return new Promise((resolve, reject) => {
      this._makeGraphQLRequest(query, variables, resolve, reject, false)
    })
  },
  // 内部GraphQL请求方法,支持重试机制
  _makeGraphQLRequest(query, variables, resolve, reject, isRetry = false) {
    // 确保token的一致性:优先使用globalData中的token,如果没有则从storage获取
    let token = this.globalData.token
    if (!token) {
      token = wx.getStorageSync('token')
      if (token) {
        this.globalData.token = token // 同步到globalData
      // 确保token的一致性:优先使用globalData中的token,如果没有则从storage获取
      let token = this.globalData.token
      if (!token) {
        token = wx.getStorageSync('token')
        if (token) {
          this.globalData.token = token // 同步到globalData
        }
      }
    }
    wx.request({
      url: this.globalData.baseUrl,
      method: 'POST',
      header: {
        'Content-Type': 'application/json',
        'Authorization': token ? `Bearer ${token}` : ''
      },
      data: {
        query: query,
        variables: variables
      },
      success: (res) => {
        console.log('GraphQL响应:', res.data)
        // 检查HTTP状态码
        if (res.statusCode !== 200) {
          // 对于401状态码,可能是认证错误,需要检查响应内容
          if (res.statusCode === 401 && res.data && res.data.errors) {
            console.log('收到401状态码,检查是否为认证错误')
            // 继续处理,让下面的GraphQL错误检查逻辑处理认证错误
          } else {
      wx.request({
        url: this.globalData.baseUrl,
        method: 'POST',
        header: {
          'Content-Type': 'application/json',
          'Authorization': token ? `Bearer ${token}` : ''
        },
        data: {
          query: query,
          variables: variables
        },
        success: (res) => {
          console.log('GraphQL响应:', res.data)
          // 检查HTTP状态码
          if (res.statusCode !== 200) {
            console.error('GraphQL HTTP错误:', res.statusCode)
            reject(new Error(`HTTP错误: ${res.statusCode}`))
            return
          }
        }
        // 检查GraphQL错误
        if (res.data && res.data.errors) {
          console.error('GraphQL错误:', res.data.errors)
          // 检查是否是认证错误(token过期或无效)
          const authErrors = res.data.errors.filter(error =>
            error.message && (
              error.message.includes('没有权限访问') ||
              error.message.includes('请先登录') ||
              error.message.includes('UNAUTHORIZED') ||
              error.extensions?.code === 'UNAUTHORIZED'
            )
          )
          if (authErrors.length > 0 && !isRetry) {
            console.log('🔄 检测到认证错误,尝试重新登录...')
            // 清除过期的认证信息
            this.globalData.token = null
            this.globalData.userInfo = null
            this.globalData.sessionKey = null
            wx.removeStorageSync('token')
            wx.removeStorageSync('userInfo')
            wx.removeStorageSync('sessionKey')
            // 重新登录
            wx.login({
              success: (loginRes) => {
                if (loginRes.code) {
                  console.log('🔄 重新获取微信code成功,调用后端登录...')
                  this._retryAfterLogin(loginRes.code, query, variables, resolve, reject)
                } else {
                  console.error('❌ 重新获取微信code失败')
                  reject(new Error('重新登录失败'))
                }
              },
              fail: (err) => {
                console.error('❌ 重新登录失败:', err)
                reject(new Error('重新登录失败'))
              }
            })
          // 检查GraphQL错误
          if (res.data.errors) {
            console.error('GraphQL错误:', res.data.errors)
            reject(new Error(res.data.errors[0]?.message || 'GraphQL请求错误'))
            return
          }
          reject(new Error(res.data.errors[0]?.message || 'GraphQL请求错误'))
          return
        }
        // 检查数据
        if (res.data.data !== undefined) {
          resolve(res.data.data)
        } else {
          console.error('GraphQL响应异常:', res.data)
          reject(new Error('GraphQL响应数据异常'))
        }
      },
      fail: (err) => {
        console.error('GraphQL网络请求失败:', err)
        reject(new Error('网络请求失败'))
      }
    })
  },
  // 重新登录后重试GraphQL请求
  _retryAfterLogin(code, query, variables, resolve, reject) {
    const that = this
    const deviceInfo = this.getDeviceInfo()
    const requestData = {
      code: code,
      loginIp: '127.0.0.1', // 小程序无法获取真实IP,使用默认值
      deviceInfo: deviceInfo
    }
    wx.request({
      url: 'http://localhost:8080/api/auth/wx-login',
      method: 'POST',
      header: {
        'Content-Type': 'application/json'
      },
      data: requestData,
      success: (res) => {
        console.log('🔄 重新登录响应:', res.data)
        if (res.statusCode !== 200 || res.data.error) {
          console.error('❌ 重新登录失败:', res.data.error || res.data.message)
          reject(new Error('重新登录失败'))
          return
        }
        // 检查响应数据格式
        let loginResult = null
        if (res.data.token && res.data.userInfo) {
          loginResult = res.data
        } else if (res.data.success && res.data.data) {
          loginResult = res.data.data
        }
        if (loginResult && loginResult.token) {
          console.log('✅ 重新登录成功,更新token')
          // 保存新的登录信息
          try {
            wx.setStorageSync('token', loginResult.token)
            wx.setStorageSync('userInfo', loginResult.userInfo)
            if (loginResult.sessionKey) {
              wx.setStorageSync('sessionKey', loginResult.sessionKey)
            }
          } catch (storageErr) {
            console.error('❌ 保存重新登录信息失败:', storageErr)
          // 检查数据
          if (res.data.data !== undefined) {
            resolve(res.data.data)
          } else {
            console.error('GraphQL响应异常:', res.data)
            reject(new Error('GraphQL响应数据异常'))
          }
          that.globalData.token = loginResult.token
          that.globalData.userInfo = loginResult.userInfo
          that.globalData.sessionKey = loginResult.sessionKey
          // 使用新token重试原始请求
          console.log('🔄 使用新token重试GraphQL请求...')
          that._makeGraphQLRequest(query, variables, resolve, reject, true)
        } else {
          console.error('❌ 重新登录响应格式错误')
          reject(new Error('重新登录响应格式错误'))
        },
        fail: (err) => {
          console.error('GraphQL网络请求失败:', err)
          reject(new Error('网络请求失败'))
        }
      },
      fail: (err) => {
        console.error('❌ 重新登录网络请求失败:', err)
        reject(new Error('重新登录网络请求失败'))
      }
      })
    })
  }
})
wx/pages/judge/review.js
@@ -1,6 +1,6 @@
// pages/judge/review.js
const app = getApp()
const { graphqlRequest, formatDate: formatDateUtil } = require('../../lib/utils')
const { graphqlRequest, formatDate } = require('../../lib/utils')
Page({
  data: {
@@ -10,15 +10,13 @@
    // 提交作品信息
    submission: null,
    activityPlayerId: '',
    stageId: null,
    submissionId: null,
    // 活动信息
    activity: null,
    // 评审标准
    criteria: [],
    // 评分数据
    scores: {},
    
@@ -33,7 +31,24 @@
    reviewStatus: 'PENDING', // PENDING, COMPLETED
    
    // 已有评审记录
    existingReview: null
    existingReview: null,
    // 媒体预览
    showMediaPreview: false,
    currentMedia: null,
    mediaType: 'image',
    // 文件下载
    downloadingFiles: [],
    // 评分等级
    scoreOptions: [
      { value: 1, label: '1分 - 很差' },
      { value: 2, label: '2分 - 较差' },
      { value: 3, label: '3分 - 一般' },
      { value: 4, label: '4分 - 良好' },
      { value: 5, label: '5分 - 优秀' }
    ]
  },
  onLoad(options) {
@@ -47,32 +62,6 @@
    // 页面显示时检查评审状态
    if (this.data.submissionId) {
      this.checkReviewStatus()
    }
  },
  transformMediaFile(file) {
    const url = file.fullUrl || file.url
    const thumbUrl = file.fullThumbUrl || url
    const ext = (file.fileExt || '').toLowerCase()
    let mediaType = 'file'
    if (file.mediaType === 1 || ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic'].includes(ext)) {
      mediaType = 'image'
    } else if (file.mediaType === 2 || ['mp4', 'mov', 'avi', 'wmv', 'mkv', 'webm', 'flv'].includes(ext)) {
      mediaType = 'video'
    } else if (ext === 'pdf') {
      mediaType = 'pdf'
    } else if (['doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'wps', 'txt', 'rtf'].includes(ext)) {
      mediaType = 'word'
    }
    return {
      id: file.id,
      name: file.name,
      url,
      thumbUrl,
      mediaType,
      size: file.fileSize || 0
    }
  },
@@ -113,11 +102,13 @@
            submissionFiles {
              id
              name
              url
              fullUrl
              fullThumbUrl
              fileExt
              fileSize
              mediaType
              thumbUrl
              fullThumbUrl
            }
            ratingForm {
              schemeId
@@ -126,8 +117,10 @@
              items {
                id
                name
                description
                maxScore
                orderNo
                weight
                sortOrder
              }
            }
          }
@@ -144,64 +137,62 @@
          id: detail.id,
          title: detail.projectName,
          description: detail.description,
          submittedAt: detail.submitTime || null,
          team: detail.team || null,
          files: detail.submissionFiles ? detail.submissionFiles.map(file => ({
            id: file.id,
            name: file.name,
            url: file.fullUrl || file.url,
            type: file.fileExt,
            size: file.fileSize,
            isDownloading: this.data.downloadingFiles.indexOf(file.id) > -1
          })) : [],
          images: detail.submissionFiles ? detail.submissionFiles
            .filter(file => file.mediaType === 1)
            .map(file => file.fullUrl || file.url) : [],
          videos: detail.submissionFiles ? detail.submissionFiles
            .filter(file => file.mediaType === 2)
            .map(file => file.fullUrl || file.url) : [],
          participant: {
            id: detail.playerInfo?.id,
            name: detail.playerInfo?.name,
            phone: detail.playerInfo?.phone || detail.playerInfo?.userInfo?.phone || '',
            avatar: detail.playerInfo?.userInfo?.avatarUrl || '/images/default-avatar.svg',
            gender: this.getGenderLabel(detail.playerInfo?.gender),
            birthday: this.getBirthdayText(detail.playerInfo?.birthday),
            region: detail.regionInfo?.fullPath || detail.regionInfo?.name || '',
            education: detail.playerInfo?.education || '',
            id: detail.playerInfo.id,
            name: detail.playerInfo.name,
            school: detail.regionInfo ? detail.regionInfo.name : '',
            major: detail.playerInfo?.education || ''
            major: detail.playerInfo.education || '',
            avatar: detail.playerInfo.userInfo?.avatarUrl || '/images/default-avatar.svg'
          },
          status: detail.state === 1 ? 'APPROVED' : detail.state === 2 ? 'REJECTED' : 'PENDING',
          mediaList: (detail.submissionFiles || []).map(file => this.transformMediaFile(file))
          status: detail.state === 1 ? 'APPROVED' : detail.state === 2 ? 'REJECTED' : 'PENDING'
        }
        const criteria = (detail.ratingForm?.items || []).map(item => {
          const maxScore = item.maxScore || 0
          return {
            id: item.id,
            name: item.name,
            maxScore,
            description: item.description || '暂无描述',
            step: maxScore > 20 ? 1 : 0.5,
            currentScore: 0
          }
        })
        // 构建activity对象
        const activity = {
          id: detail.stageId,
          title: detail.activityName,
          description: detail.description,
          judgeCriteria: detail.ratingForm ? detail.ratingForm.items || [] : []
        }
        // 初始化评分数据
        const scores = {}
        criteria.forEach(criterion => {
          scores[criterion.id] = 0
        })
        const maxScore = detail.ratingForm?.totalMaxScore || criteria.reduce((sum, item) => sum + (item.maxScore || 0), 0)
        let maxScore = 0
        if (activity.judgeCriteria) {
          activity.judgeCriteria.forEach(criterion => {
            scores[criterion.id] = 0 // 暂时设为0,后续需要查询已有评分
            maxScore += criterion.maxScore
          })
        }
        this.setData({
          submission,
          activity: {
            id: detail.id,
            stageId: detail.stageId,
            ratingSchemeId: detail.ratingForm?.schemeId || null,
            totalMaxScore: maxScore
          },
          stageId: detail.stageId || null,
          submissionId: detail.id,
          criteria,
          activity,
          criteria: activity.judgeCriteria || [],
          scores,
          maxScore,
          totalScore: 0,
          existingReview: null,
          existingReview: null, // 暂时设为null,后续需要查询已有评分
          reviewStatus: 'PENDING',
          comment: ''
        })
        this.calculateTotalScore()
        // 检查是否已有评分
        this.checkReviewStatus()
      }
@@ -224,7 +215,7 @@
          currentJudgeRating(activityPlayerId: $activityPlayerId) {
            id
            totalScore
            remark
            comment
            status
            ratedAt
            items {
@@ -248,98 +239,38 @@
        
        if (rating.items) {
          rating.items.forEach(item => {
            const numericScore = item.score !== undefined && item.score !== null ? Number(item.score) : 0
            scores[item.ratingItemId] = numericScore
            totalScore += numericScore
            scores[item.ratingItemId] = item.score
            totalScore += item.score
          })
        }
        const updatedCriteria = this.data.criteria.map(criterion => {
          const value = scores[criterion.id] !== undefined ? scores[criterion.id] : 0
          return {
            ...criterion,
            currentScore: value
          }
        })
        const normalizedTotal = Number(totalScore.toFixed(2))
        this.setData({
          scores,
          criteria: updatedCriteria,
          totalScore: normalizedTotal,
          comment: rating.remark || rating.comment || '',
          existingReview: {
            ...rating,
            totalScore: rating.totalScore ? Number(rating.totalScore) : normalizedTotal,
            reviewedAt: rating.ratedAt || rating.reviewedAt || rating.updateTime || null
          },
          reviewStatus: 'COMPLETED'
          totalScore,
          comment: rating.comment || '',
          existingReview: rating,
          reviewStatus: rating.status || 'COMPLETED'
        })
        
        console.log('已加载现有评分:', rating)
      } else {
        console.log('当前评委尚未评分')
      }
      this.calculateTotalScore()
    } catch (error) {
      console.error('检查评审状态失败:', error)
    }
  },
  normalizeScore(value, criterion) {
    const maxScore = Number(criterion.maxScore || 0)
    const step = Number(criterion.step || (maxScore > 20 ? 1 : 0.5))
    if (Number.isNaN(value)) {
      value = 0
    }
    let normalized = Math.round(value / step) * step
    if (normalized < 0) normalized = 0
    if (normalized > maxScore) normalized = maxScore
    return Number(normalized.toFixed(2))
  },
  updateCriterionScore(criterionId, index, value) {
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const normalized = this.normalizeScore(value, criterion)
    this.setData({
      [`scores.${criterionId}`]: normalized,
      [`criteria[${index}].currentScore`]: normalized
    })
    this.calculateTotalScore()
  },
  // 评分改变
  onScoreChange(e) {
    const { criterionId, index } = e.currentTarget.dataset
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const inputValue = Number(e.detail.value)
    const newScore = this.normalizeScore(inputValue, criterion)
    this.updateCriterionScore(criterionId, index, newScore)
  },
  increaseScore(e) {
    const { criterionId, index } = e.currentTarget.dataset
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const current = Number(this.data.scores[criterionId] || criterion.currentScore || 0)
    const step = criterion.step || (criterion.maxScore > 20 ? 1 : 0.5)
    this.updateCriterionScore(criterionId, index, current + step)
  },
  decreaseScore(e) {
    const { criterionId, index } = e.currentTarget.dataset
    const criterion = this.data.criteria[index]
    if (!criterion) return
    const current = Number(this.data.scores[criterionId] || criterion.currentScore || 0)
    const step = criterion.step || (criterion.maxScore > 20 ? 1 : 0.5)
    this.updateCriterionScore(criterionId, index, current - step)
    const { criterionId } = e.currentTarget.dataset
    const { value } = e.detail
    this.setData({
      [`scores.${criterionId}`]: parseInt(value)
    })
    this.calculateTotalScore()
  },
  // 计算总分
@@ -348,11 +279,11 @@
    let totalScore = 0
    
    criteria.forEach(criterion => {
      const score = Number(scores[criterion.id] || 0)
      totalScore += score
      const score = scores[criterion.id] || 0
      totalScore += score * (criterion.weight || 1)
    })
    
    this.setData({ totalScore: Number(totalScore.toFixed(2)) })
    this.setData({ totalScore })
  },
  // 评审意见输入
@@ -364,58 +295,106 @@
  // 媒体点击
  onMediaTap(e) {
    const index = Number(e.currentTarget.dataset.index)
    const mediaList = this.data.submission?.mediaList || []
    const media = mediaList[index]
    if (!media) return
    if (media.mediaType === 'image') {
      const imageUrls = mediaList
        .filter(item => item.mediaType === 'image')
        .map(item => item.url)
    const { url, type } = e.currentTarget.dataset
    if (type === 'image') {
      wx.previewImage({
        current: media.url,
        urls: imageUrls
        current: url,
        urls: this.data.submission.images || []
      })
    } else if (media.mediaType === 'video') {
      wx.navigateTo({
        url: `/pages/video/video?url=${encodeURIComponent(media.url)}&title=${encodeURIComponent(media.name)}`
    } else if (type === 'video') {
      this.setData({
        showMediaPreview: true,
        currentMedia: url,
        mediaType: 'video'
      })
    } else {
      this.openDocumentMedia(media)
    }
  },
  async openDocumentMedia(media) {
  // 关闭媒体预览
  onCloseMediaPreview() {
    this.setData({
      showMediaPreview: false,
      currentMedia: null
    })
  },
  // 下载文件
  async onDownloadFile(e) {
    const { fileId, fileName, fileUrl } = e.currentTarget.dataset
    try {
      wx.showLoading({ title: '打开中...' })
      const downloadRes = await new Promise((resolve, reject) => {
        wx.downloadFile({
          url: media.url,
          success: resolve,
          fail: reject
        })
      })
      if (downloadRes.statusCode !== 200) {
        throw new Error('文件下载失败')
      // 添加到下载中列表
      const downloadingFiles = [...this.data.downloadingFiles, fileId]
      // 同时更新文件的isDownloading字段
      const submission = { ...this.data.submission }
      if (submission.files) {
        submission.files = submission.files.map(file => ({
          ...file,
          isDownloading: file.id === fileId ? true : file.isDownloading
        }))
      }
      await new Promise((resolve, reject) => {
        wx.openDocument({
          filePath: downloadRes.tempFilePath,
          showMenu: true,
          success: resolve,
          fail: reject
        })
      this.setData({
        downloadingFiles,
        submission
      })
      wx.showLoading({ title: '下载中...' })
      const result = await wx.downloadFile({
        url: fileUrl,
        success: (res) => {
          if (res.statusCode === 200) {
            // 保存到相册或文件
            wx.saveFile({
              tempFilePath: res.tempFilePath,
              success: () => {
                wx.showToast({
                  title: '下载成功',
                  icon: 'success'
                })
              },
              fail: () => {
                wx.showToast({
                  title: '保存失败',
                  icon: 'error'
                })
              }
            })
          }
        },
        fail: () => {
          wx.showToast({
            title: '下载失败',
            icon: 'error'
          })
        }
      })
    } catch (error) {
      console.error('打开文件失败:', error)
      console.error('下载文件失败:', error)
      wx.showToast({
        title: '无法打开文件',
        title: '下载失败',
        icon: 'error'
      })
    } finally {
      // 从下载中列表移除
      const downloadingFiles = this.data.downloadingFiles.filter(id => id !== fileId)
      // 同时更新文件的isDownloading字段
      const submission = { ...this.data.submission }
      if (submission.files) {
        submission.files = submission.files.map(file => ({
          ...file,
          isDownloading: file.id === fileId ? false : file.isDownloading
        }))
      }
      this.setData({
        downloadingFiles,
        submission
      })
      wx.hideLoading()
    }
  },
@@ -423,7 +402,6 @@
  // 验证评审数据
  validateReview() {
    const { scores, criteria, comment } = this.data
    const commentText = (comment || '').trim()
    
    // 检查是否所有标准都已评分
    for (let criterion of criteria) {
@@ -437,7 +415,7 @@
    }
    
    // 检查评审意见
    if (!commentText) {
    if (!comment.trim()) {
      wx.showToast({
        title: '请填写评审意见',
        icon: 'error'
@@ -445,7 +423,7 @@
      return false
    }
    
    if (commentText.length < 10) {
    if (comment.trim().length < 10) {
      wx.showToast({
        title: '评审意见至少10个字符',
        icon: 'error'
@@ -454,6 +432,54 @@
    }
    
    return true
  },
  // 保存草稿
  async onSaveDraft() {
    try {
      wx.showLoading({ title: '保存中...' })
      const { activityPlayerId, scores, comment, criteria, activity } = this.data
      // 构建评分项数组
      const ratings = criteria.map(criterion => ({
        itemId: criterion.id,
        score: scores[criterion.id] || 0
      }))
      const mutation = `
        mutation SaveActivityPlayerRating($input: ActivityPlayerRatingInput!) {
          saveActivityPlayerRating(input: $input)
        }
      `
      const input = {
        activityPlayerId,
        stageId: activity.stageId,
        ratings,
        comment: comment.trim()
      }
      const result = await graphqlRequest(mutation, { input })
      if (result && result.saveActivityPlayerRating) {
        wx.showToast({
          title: '草稿已保存',
          icon: 'success'
        })
        // 重新加载评分状态
        await this.checkReviewStatus()
      }
    } catch (error) {
      console.error('保存草稿失败:', error)
      wx.showToast({
        title: '保存失败',
        icon: 'error'
      })
    } finally {
      wx.hideLoading()
    }
  },
  // 提交评审
@@ -479,23 +505,12 @@
      this.setData({ submitting: true })
      wx.showLoading({ title: '提交中...' })
      
      const { activityPlayerId, scores, comment, criteria, stageId } = this.data
      const commentText = (comment || '').trim()
      if (!stageId) {
        wx.showToast({
          title: '缺少阶段信息,无法提交',
          icon: 'none'
        })
        this.setData({ submitting: false })
        wx.hideLoading()
        return
      }
      const { activityPlayerId, scores, comment, criteria, activity } = this.data
      
      // 构建评分项数组
      const ratings = criteria.map(criterion => ({
        itemId: criterion.id,
        score: Number(scores[criterion.id] || 0)
        score: scores[criterion.id] || 0
      }))
      
      const mutation = `
@@ -506,9 +521,9 @@
      
      const input = {
        activityPlayerId,
        stageId,
        stageId: activity.stageId,
        ratings,
        comment: commentText
        comment: comment.trim()
      }
      
      const result = await graphqlRequest(mutation, { input })
@@ -554,17 +569,34 @@
  // 联系参赛者
  onContactParticipant() {
    const { submission } = this.data
    const phone = submission?.participant?.phone
    if (phone) {
      wx.makePhoneCall({
        phoneNumber: phone
      })
    } else {
      wx.showToast({
        title: '暂无联系方式',
        icon: 'none'
    if (submission.participant) {
      wx.showActionSheet({
        itemList: ['发送消息', '查看详情'],
        success: (res) => {
          switch (res.tapIndex) {
            case 0:
              // 发送消息功能
              wx.navigateTo({
                url: `/pages/chat/chat?userId=${submission.participant.id}`
              })
              break
            case 1:
              // 查看用户详情
              wx.navigateTo({
                url: `/pages/user/profile?userId=${submission.participant.id}`
              })
              break
          }
        }
      })
    }
  },
  // 获取评分等级文本
  getScoreLabel(score) {
    const option = this.data.scoreOptions.find(opt => opt.value === score)
    return option ? option.label : `${score}分`
  },
  // 获取文件大小文本
@@ -578,47 +610,9 @@
    }
  },
  // 统一处理性别显示文本
  getGenderLabel(gender) {
    if (gender === null || gender === undefined || gender === '') {
      return '未填写'
    }
    const normalized = String(gender).trim().toLowerCase()
    if (normalized === '') {
      return '未填写'
    }
    if (normalized === 'male' || normalized === 'm') {
      return '男'
    }
    if (normalized === 'female' || normalized === 'f') {
      return '女'
    }
    if (/^-?\d+$/.test(normalized)) {
      const numeric = Number(normalized)
      if (numeric === 1) return '男'
      if (numeric === 0) return '女'
      if (numeric === 2) return '女'
    }
    return gender === undefined || gender === null ? '未填写' : String(gender)
  },
  // 统一处理出生日期显示文本
  getBirthdayText(dateString) {
    if (!dateString) {
      return '未填写'
    }
    const formatted = formatDateUtil(dateString, 'YYYY-MM-DD')
    return formatted || '未填写'
  },
  // 格式化日期
  formatDate(dateString) {
    return formatDateUtil(dateString, 'YYYY-MM-DD HH:mm')
    return formatDate(dateString, 'YYYY-MM-DD HH:mm')
  },
  // 分享页面
wx/pages/message/message.js
@@ -21,22 +21,8 @@
  // 加载消息列表
  loadMessages() {
    // 检查用户是否已登录,如果globalData中没有,尝试从本地存储恢复
    let userInfo = app.globalData.userInfo
    if (!userInfo || !userInfo.userId) {
      console.log('globalData中没有userInfo,尝试从本地存储恢复')
      try {
        const storedUserInfo = wx.getStorageSync('userInfo')
        if (storedUserInfo && storedUserInfo.userId) {
          console.log('从本地存储恢复userInfo成功')
          app.globalData.userInfo = storedUserInfo
          userInfo = storedUserInfo
        }
      } catch (error) {
        console.error('从本地存储恢复userInfo失败:', error)
      }
    }
    // 检查用户是否已登录
    const userInfo = app.globalData.userInfo
    if (!userInfo || !userInfo.userId) {
      console.error('用户未登录或userId不存在')
      wx.showToast({
wx/pages/profile/personal-info.js
@@ -157,10 +157,17 @@
          title: '获取中...'
        })
        
        // TODO: 调用后端API解密手机号
        // 调用后端API解密手机号
        const app = getApp()
        const sessionKey = app.globalData.sessionKey
        if (!sessionKey) {
          throw new Error('SessionKey不存在,请重新登录')
        }
        const mutation = `
          mutation DecryptPhoneNumber($encryptedData: String!, $iv: String!) {
            decryptPhoneNumber(encryptedData: $encryptedData, iv: $iv) {
          mutation DecryptPhoneNumber($encryptedData: String!, $iv: String!, $sessionKey: String!) {
            decryptPhoneNumber(encryptedData: $encryptedData, iv: $iv, sessionKey: $sessionKey) {
              phoneNumber
            }
          }
@@ -168,7 +175,8 @@
        
        const variables = {
          encryptedData: e.detail.encryptedData,
          iv: e.detail.iv
          iv: e.detail.iv,
          sessionKey: sessionKey
        }
        
        const result = await graphqlRequest(mutation, variables)
@@ -229,10 +237,13 @@
      return
    }
    if (!userInfo.phone) {
      wx.showToast({
        title: '请获取手机号',
        icon: 'none'
    // 强制要求授权电话号码
    if (!userInfo.phone || userInfo.phone.trim() === '') {
      wx.showModal({
        title: '需要授权手机号',
        content: '根据平台规定,必须授权手机号码才能保存用户信息。请先获取手机号码授权。',
        showCancel: false,
        confirmText: '我知道了'
      })
      return
    }
@@ -284,15 +295,17 @@
        // 保存到本地存储
        wx.setStorageSync('userInfo', app.globalData.userInfo)
        wx.showToast({
        // 显示保存成功提示,并提醒需要重新登录
        wx.showModal({
          title: '保存成功',
          icon: 'success'
          content: '用户信息已保存成功。为了获取最新的关联信息(如参赛者、评委、员工身份),系统将重新登录。',
          showCancel: false,
          confirmText: '确定',
          success: () => {
            // 强制重新登录以获取最新的用户关联信息
            this.forceRelogin()
          }
        })
        // 延迟返回上一页
        setTimeout(() => {
          wx.navigateBack()
        }, 1500)
      } else {
        throw new Error('保存失败')
      }
@@ -385,5 +398,56 @@
  // 获取文件扩展名
  getFileExtension(fileName) {
    return fileName.split('.').pop().toLowerCase()
  },
  // 强制重新登录
  forceRelogin() {
    wx.showLoading({
      title: '重新登录中...'
    })
    try {
      // 清除本地存储的登录信息
      wx.removeStorageSync('token')
      wx.removeStorageSync('userInfo')
      wx.removeStorageSync('sessionKey')
      // 清除全局数据
      app.globalData.token = null
      app.globalData.userInfo = null
      app.globalData.sessionKey = null
      console.log('已清除本地登录信息,准备重新登录')
      // 重新调用登录流程
      app.login()
      // 延迟一段时间后隐藏loading并返回上一页
      setTimeout(() => {
        wx.hideLoading()
        wx.showToast({
          title: '重新登录完成',
          icon: 'success'
        })
        // 返回上一页
        setTimeout(() => {
          wx.navigateBack()
        }, 1000)
      }, 2000)
    } catch (error) {
      console.error('强制重新登录失败:', error)
      wx.hideLoading()
      wx.showToast({
        title: '重新登录失败',
        icon: 'none'
      })
      // 即使失败也返回上一页
      setTimeout(() => {
        wx.navigateBack()
      }, 1500)
    }
  }
})
wx/pages/project/detail.js
@@ -43,8 +43,6 @@
        if (projectDetail.submissionFiles) {
          projectDetail.submissionFiles.forEach(file => {
            file.fileSizeText = this.formatFileSize(file.fileSize)
            // 字段已经是正确的名称,无需映射
            // fullUrl, fullThumbUrl, fileSize, fileExt 都是正确的字段名
          })
        }
@@ -87,62 +85,42 @@
      query GetProjectDetail($id: ID!) {
        activityPlayerDetail(id: $id) {
          id
          playerInfo {
            id
            name
            phone
            gender
            birthday
            education
            introduction
            description
            avatarUrl
            avatar {
              id
              fullUrl
              fullThumbUrl
              name
              fileSize
              fileExt
            }
            userInfo {
              userId
              name
              phone
              avatarUrl
            }
          }
          regionInfo {
            id
            name
            fullPath
          }
          activityName
          activityId
          playerId
          playerName
          playerGender
          playerPhone
          playerEducation
          playerBirthDate
          playerIdCard
          playerAddress
          projectName
          description
          feedback
          state
          stageId
          submissionFiles {
          projectDescription
          projectCategory
          projectTags
          projectFiles {
            id
            fullUrl
            fullThumbUrl
            name
            fileName
            fileUrl
            fileSize
            fileExt
            mediaType
            fileType
            uploadTime
          }
          ratingForm {
            schemeId
            schemeName
            items {
              id
              name
              maxScore
              orderNo
            }
            totalMaxScore
          submitTime
          reviewTime
          reviewerId
          reviewerName
          score
          rating {
            id
            judgeId
            judgeName
            score
            feedback
            ratingTime
          }
          state
          feedback
        }
      }
    `
@@ -157,14 +135,22 @@
  // 获取评分统计
  async getRatingStatsFromAPI(projectId) {
    // 暂时返回空的评分数据,避免GraphQL查询错误
    // TODO: 需要后端提供合适的评分统计查询接口
    try {
      return {
        averageScore: null,
        ratingCount: 0,
        judgeRatings: []
    const query = `
      query GetRatingStats($activityPlayerId: ID!) {
        ratingStats(activityPlayerId: $activityPlayerId) {
          averageScore
          totalRatings
          scoreDistribution {
            score
            count
          }
        }
      }
    `
    try {
      const result = await app.graphqlRequest(query, { activityPlayerId: projectId })
      return result.ratingStats
    } catch (error) {
      throw error
    }
wx/pages/registration/registration.js
@@ -1094,8 +1094,8 @@
      errors.name = '请输入姓名';
    }
    if (!formData.phone.trim()) {
      errors.phone = '请输入手机号';
    if (!formData.phone || !formData.phone.trim()) {
      errors.phone = '请先授权获取手机号';
    } else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
      errors.phone = '请输入正确的手机号';
    }
@@ -1200,6 +1200,17 @@
      })
      return
    }
    // 额外检查:确保必须授权电话号码
    if (!this.data.formData.phone || !this.data.formData.phone.trim()) {
      wx.showModal({
        title: '需要授权手机号',
        content: '根据平台规定,必须授权手机号码才能报名参赛。请先获取手机号码授权。',
        showCancel: false,
        confirmText: '我知道了'
      })
      return
    }
    
    this.setData({ isSubmitting: true })
    
wx/pages/review/index.js
@@ -2,14 +2,6 @@
const app = getApp()
const { graphqlRequest, formatDate } = require('../../lib/utils')
const GET_RATING_STATS_QUERY = `
  query GetRatingStats($activityPlayerId: ID!) {
    judgeRatingsForPlayer(activityPlayerId: $activityPlayerId) {
      hasRated
    }
  }
`
Page({
  data: {
    loading: false,
@@ -63,7 +55,7 @@
  // 切换选项卡
  switchTab(e) {
    const index = parseInt(e.currentTarget.dataset.index) || 0
    const index = parseInt(e.currentTarget.dataset.index) // 将字符串转换为数字
    if (index === this.data.currentTab) return
    
    this.setData({
@@ -118,29 +110,10 @@
      ])
    } catch (error) {
      console.error('加载数据失败:', error)
      // 检查是否是认证相关错误
      if (error.message && (
        error.message.includes('没有权限访问') ||
        error.message.includes('请先登录') ||
        error.message.includes('重新登录')
      )) {
        wx.showToast({
          title: '正在重新登录...',
          icon: 'loading',
          duration: 2000
        })
        // 等待一段时间后重试
        setTimeout(() => {
          this.loadData()
        }, 2000)
      } else {
        wx.showToast({
          title: '加载失败,请重试',
          icon: 'none'
        })
      }
      wx.showToast({
        title: '加载失败',
        icon: 'none'
      })
    } finally {
      this.setData({ loading: false })
      wx.stopPullDownRefresh()
@@ -174,7 +147,7 @@
    }
    // 根据当前选项卡构建不同的查询
    switch (currentTab) {
    switch (parseInt(currentTab)) { // 确保currentTab是数字
      case 0: // 我未评审
        query = `
          query GetUnReviewedProjects($page: Int!, $pageSize: Int!, $searchKeyword: String) {
@@ -234,13 +207,39 @@
          }
        `
        break
      default:
        console.error('无效的选项卡索引:', currentTab)
        query = `
          query GetUnReviewedProjects($page: Int!, $pageSize: Int!, $searchKeyword: String) {
            unReviewedProjects(page: $page, pageSize: $pageSize, searchKeyword: $searchKeyword) {
              items {
                id
                projectName
                activityName
                stageName
                studentName
                submitTime
                status
              }
              total
              hasMore
            }
          }
        `
        break
    }
    // 检查query是否为空
    if (!query || query.trim() === '') {
      console.error('GraphQL查询为空,无法执行请求')
      return
    }
    const result = await graphqlRequest(query, variables)
    
    if (result) {
      const dataKey = currentTab === 0 ? 'unReviewedProjects' :
                     currentTab === 1 ? 'reviewedProjects' :
      const dataKey = parseInt(currentTab) === 0 ? 'unReviewedProjects' :
                     parseInt(currentTab) === 1 ? 'reviewedProjects' :
                     'studentUnReviewedProjects'
      
      const data = result[dataKey]
@@ -250,13 +249,13 @@
        const projects = data.items.map(item => ({
          ...item,
          submitTime: item.submitTime ? formatDate(item.submitTime) : '',
          reviewTime: item.reviewTime ? formatDate(item.reviewTime) : ''
          reviewTime: item.reviewTime ? formatDate(item.reviewTime) : '',
          statusText: this.getStatusText(item.status),
          statusType: this.getStatusType(item.status)
        }))
        const projectsWithRatingCount = await this.enrichProjectsWithRatingCounts(projects)
        this.setData({
          projectList: isLoadMore ? [...this.data.projectList, ...projectsWithRatingCount] : projectsWithRatingCount,
          projectList: isLoadMore ? [...this.data.projectList, ...projects] : projects,
          hasMore: data.hasMore || false,
          currentPage: variables.page
        })
@@ -301,39 +300,26 @@
    }
  },
  async enrichProjectsWithRatingCounts(projects) {
    if (!Array.isArray(projects) || projects.length === 0) {
      return projects || []
  // 获取状态文本
  getStatusText(status) {
    const statusMap = {
      'SUBMITTED': '已提交',
      'UNDER_REVIEW': '评审中',
      'REVIEWED': '已评审',
      'REJECTED': '已拒绝'
    }
    try {
      const counts = await Promise.all(projects.map(project => this.getProjectRatingCount(project.id)))
      return projects.map((project, index) => ({
        ...project,
        ratingCount: counts[index]
      }))
    } catch (error) {
      console.error('批量获取评审次数失败:', error)
      return projects.map(project => ({
        ...project,
        ratingCount: typeof project.ratingCount === 'number' ? project.ratingCount : 0
      }))
    }
    return statusMap[status] || status
  },
  async getProjectRatingCount(activityPlayerId) {
    if (!activityPlayerId) {
      return 0
  // 获取状态类型
  getStatusType(status) {
    const typeMap = {
      'SUBMITTED': 'info',
      'UNDER_REVIEW': 'warning',
      'REVIEWED': 'success',
      'REJECTED': 'danger'
    }
    try {
      const result = await graphqlRequest(GET_RATING_STATS_QUERY, { activityPlayerId })
      const ratings = result?.judgeRatingsForPlayer || []
      return ratings.filter(item => item?.hasRated).length
    } catch (error) {
      console.error(`获取项目 ${activityPlayerId} 的评审次数失败:`, error)
      return 0
    }
    return typeMap[status] || 'info'
  },
  // 获取空状态文本