# 🦟 蚊子项目代码审查报告 v2.0 **项目**: Mosquito Propagation System **技术栈**: Spring Boot 3.1.5 + Java 17 + PostgreSQL + Redis **审查日期**: 2026-01-20 **审查工具**: code-review, security, testing skills --- ## 📊 审查摘要 | 维度 | 评分 | 说明 | |------|------|------| | **代码质量** | ⭐⭐⭐⭐☆ | 架构清晰,但存在重复代码 | | **安全性** | ⭐⭐⭐☆☆ | 存在SSRF、限流绕过风险 | | **性能** | ⭐⭐⭐⭐☆ | N+1查询、缓存策略需优化 | | **可维护性** | ⭐⭐⭐⭐☆ | 命名规范,分层合理 | | **测试覆盖** | ⭐⭐⭐⭐⭐ | JaCoCo强制80%覆盖 | --- ## 🔴 严重安全问题 (必须修复) ### 1. SSRF漏洞 - 短链接重定向 **位置**: `ShortLinkController.java:32-54` ```java @GetMapping("/r/{code}") public ResponseEntity redirect(@PathVariable String code, ...) { return shortLinkService.findByCode(code) .map(e -> { // 直接重定向到原始URL,无验证! headers.set(HttpHeaders.LOCATION, e.getOriginalUrl()); return new ResponseEntity<>(headers, HttpStatus.FOUND); }) ``` **风险等级**: 🔴 CRITICAL **影响**: 攻击者可利用短链接服务访问内部系统 **攻击场景**: ``` # 内部IP访问 POST /api/v1/internal/shorten {"originalUrl": "http://192.168.1.1/admin"} GET /r/abc123 → 重定向到内部IP # SSRF探测 http://169.254.169.254/latest/meta-data/ (AWS metadata) http://localhost:8080/admin ``` **修复方案**: ```java @GetMapping("/r/{code}") public ResponseEntity redirect(@PathVariable String code, HttpServletRequest request) { return shortLinkService.findByCode(code) .map(e -> { // 1. URL白名单验证 if (!isAllowedUrl(e.getOriginalUrl())) { log.warn("Blocked malicious redirect: {}", e.getOriginalUrl()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } // 2. 内部IP检查 if (isInternalUrl(e.getOriginalUrl())) { log.warn("Blocked internal redirect: {}", e.getOriginalUrl()); return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } HttpHeaders headers = new HttpHeaders(); headers.set(HttpHeaders.LOCATION, e.getOriginalUrl()); return new ResponseEntity<>(headers, HttpStatus.FOUND); }) ``` ```java private boolean isAllowedUrl(String url) { if (url == null) return false; try { URI uri = URI.create(url); // 只允许http/https if (!uri.isAbsolute() || (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme()))) { return false; } // 检查内部IP InetAddress addr = InetAddress.getByName(uri.getHost()); return !addr.isSiteLocalAddress() && !addr.isLoopbackAddress() && !addr.isAnyLocalAddress(); } catch (Exception e) { return false; } } ``` --- ### 2. API密钥一次性返回 - 无恢复机制 **位置**: `ActivityService.java:129-148` ```java public String generateApiKey(CreateApiKeyRequest request) { String rawApiKey = UUID.randomUUID().toString(); // ... 保存hash return rawApiKey; // 只返回一次! } ``` **风险等级**: 🔴 HIGH **影响**: 用户丢失密钥后只能重新创建,造成业务中断 **业务影响**: - 用户需要重新配置所有使用该密钥的系统 - 旧密钥立即失效可能导致服务中断 - 没有密钥轮换机制 **修复方案**: ```java // 方案1: 加密存储,支持重新显示 public class ApiKeyService { private static final String ENCRYPTION_KEY = "..."; // 从配置读取 public String generateApiKey(CreateApiKeyRequest request) { String rawApiKey = UUID.randomUUID().toString(); String encryptedKey = encrypt(rawApiKey, ENCRYPTION_KEY); ApiKeyEntity entity = new ApiKeyEntity(); entity.setEncryptedKey(encryptedKey); // 新增字段 // ... return rawApiKey; } @PostMapping("/{id}/reveal") public ResponseEntity revealApiKey(@PathVariable Long id) { // 需要额外验证(邮箱/密码) String encrypted = entity.getEncryptedKey(); return decrypt(encrypted, ENCRYPTION_KEY); } } ``` --- ### 3. 速率限制可被绕过 **位置**: `RateLimitInterceptor.java:17-44` ```java private final ConcurrentHashMap localCounters = new ConcurrentHashMap<>(); public boolean preHandle(HttpServletRequest request, ...) { if (redisTemplate != null) { // 使用Redis Long val = redisTemplate.opsForValue().increment(key); } else { // 回退到本地计数器 - 可被绕过! var counter = localCounters.computeIfAbsent(key, k -> new AtomicInteger(0)); count = counter.incrementAndGet(); } } ``` **风险等级**: 🔴 HIGH **影响**: 多实例部署时无法正确限流 **修复方案**: ```java public RateLimitInterceptor(Environment env) { this.perMinuteLimit = Integer.parseInt(env.getProperty("app.rate-limit.per-minute", "100")); this.redisTemplateOpt = redisTemplateOpt; // 生产环境强制使用Redis String profile = env.getProperty("spring.profiles.active"); if ("prod".equals(profile) && redisTemplateOpt.isEmpty()) { throw new IllegalStateException("Production requires Redis for rate limiting"); } } ``` --- ### 4. 异常被静默吞掉 **位置**: `ShortLinkController.java:48` ```java try { linkClickRepository.save(click); } catch (Exception ignore) {} // BAD! ``` **风险等级**: 🔴 HIGH **影响**: 无法审计追踪,数据库问题不被发现 **修复方案**: ```java try { linkClickRepository.save(click); } catch (Exception e) { log.error("Failed to record link click for code {}: {}", code, e.getMessage(), e); // 可选: 发送到Sentry/Datadog // metrics.increment("link_click.errors"); } ``` --- ## 🟠 高优先级问题 ### 5. 数据库设计问题 #### 5.1 缺少外键约束 **位置**: 多个迁移文件 ```sql -- V1__Create_activities_table.sql CREATE TABLE activities ( id BIGSERIAL PRIMARY KEY, ... ); -- V7__Add_activity_id_to_api_keys.sql ALTER TABLE api_keys ADD COLUMN activity_id BIGINT; -- 没有添加 FOREIGN KEY 约束! ``` **问题**: - `api_keys.activity_id` 无外键约束 - `short_links.activity_id` 无外键约束 - `user_invites` 无活动外键验证 **修复方案**: ```sql -- 添加外键约束 ALTER TABLE api_keys ADD CONSTRAINT fk_api_keys_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE; ALTER TABLE short_links ADD CONSTRAINT fk_short_links_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE SET NULL; ``` #### 5.2 缺少复合索引 **位置**: `UserInviteRepository.java` ```java public interface UserInviteRepository extends JpaRepository { List findByActivityId(Long activityId); List findByActivityIdAndInviterUserId(Long activityId, Long inviterUserId); } ``` **问题**: 没有 `(activity_id, invitee_user_id)` 的索引 **迁移文件**: ```sql CREATE INDEX idx_user_invites_activity_invitee ON user_invites(activity_id, invitee_user_id); ``` --- ### 6. N+1 查询问题 **位置**: `ActivityService.java:287-304` ```java @Cacheable(value = "leaderboards", key = "#activityId") public List getLeaderboard(Long activityId) { List invites = userInviteRepository.findByActivityId(activityId); // O(n) 次数据库查询? 不, 这是内存处理 Map counts = new HashMap<>(); for (UserInviteEntity inv : invites) { counts.merge(inv.getInviterUserId(), 1, Integer::sum); // 内存聚合 } // ... } ``` **当前状态**: ✅ 已优化,在内存中聚合 **建议**: 如果数据量超过10万,考虑使用SQL聚合: ```java @Query("SELECT u.inviterUserId, COUNT(u) FROM UserInviteEntity u " + "WHERE u.activityId = :activityId GROUP BY u.inviterUserId") List getInviteCountsByActivityId(@Param("activityId") Long activityId); ``` --- ### 7. 缓存策略问题 #### 7.1 缓存没有失效机制 **位置**: `ActivityService.java:287` ```java @Cacheable(value = "leaderboards", key = "#activityId") public List getLeaderboard(Long activityId) { // 排行榜更新后,缓存不会失效! } ``` **修复方案**: ```java @CacheEvict(value = "leaderboards", key = "#activityId") public LeaderboardEntry recordInvite(...) { // 记录邀请后清除缓存 } @Scheduled(fixedRate = 60000) // 或使用CachePut public void refreshLeaderboardCache() { // 定时刷新 } ``` #### 7.2 缓存配置缺少序列化安全 **位置**: `CacheConfig.java:24-26` ```java RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer() )); ``` **问题**: `GenericJackson2JsonRedisSerializer` 使用JDK序列化,存在反序列化漏洞 **修复方案**: ```java // 使用JSON序列化器 RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( new Jackson2JsonRedisSerializer<>(Object.class) )); // 或配置类型信息 ObjectMapper mapper = new ObjectMapper(); mapper.activateDefaultTyping( mapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL ); RedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( new Jackson2JsonRedisSerializer<>(mapper, Object.class) )); ``` --- ### 8. 并发安全问题 #### 8.1 内存中计数器 - StatisticsAggregationJob **位置**: `StatisticsAggregationJob.java:52-59` ```java public DailyActivityStats aggregateStatsForActivity(Activity activity, LocalDate date) { Random random = new Random(); // 每次创建新Random stats.setViews(1000 + random.nextInt(500)); // ... } ``` **当前状态**: ✅ 无状态操作,安全 **建议**: 使用 `ThreadLocalRandom` 提高性能 ```java import java.util.concurrent.ThreadLocalRandom; public DailyActivityStats aggregateStatsForActivity(Activity activity, LocalDate date) { int views = 1000 + ThreadLocalRandom.current().nextInt(500); // ... } ``` #### 8.2 ConcurrentHashMap 使用正确 **位置**: `ActivityService.java:41` ```java private final Map activities = new ConcurrentHashMap<>(); ``` **状态**: ✅ 正确使用并发集合 --- ### 9. API设计问题 #### 9.1 缺少版本控制 **当前**: `/api/v1/activities` **问题**: 未来API变更需要破坏性更新 **建议**: ``` # Header版本控制 Accept: application/vnd.mosquito.v1+json # 或URL版本控制 /api/v2/activities ``` #### 9.2 响应格式不一致 **位置**: `ActivityController.java:68-89` ```java @GetMapping("/{id}/leaderboard") public ResponseEntity> getLeaderboard(...) { // 分页返回 List } @GetMapping("/{id}/leaderboard/export") public ResponseEntity exportLeaderboard(...) { // 导出返回 CSV bytes } ``` **建议**: 统一响应格式 ```java public class ApiResponse { private T data; private Meta meta; private Error error; public static ApiResponse success(T data) { ... } public static ApiResponse paginated(T data, PaginationMeta meta) { ... } } ``` --- ### 10. 未实现的业务逻辑 **位置**: `ActivityService.java:264-271` ```java public void createReward(Reward reward, boolean skipValidation) { if (reward.getRewardType() == RewardType.COUPON && !skipValidation) { boolean isValidCouponBatchId = false; // 永远为false! if (!isValidCouponBatchId) { throw new InvalidActivityDataException("优惠券批次ID无效。"); } } } ``` **问题**: 验证逻辑被硬编码,功能未实现 **建议**: ```java // 方案1: 抛出明确的未实现异常 if (reward.getRewardType() == RewardType.COUPON && !skipValidation) { throw new UnsupportedOperationException("Coupon validation not yet implemented"); } // 方案2: 实现真正的验证 public void createReward(Reward reward, boolean skipValidation) { if (reward.getRewardType() == RewardType.COUPON && !skipValidation) { CouponBatch batch = couponService.getBatchById(reward.getCouponBatchId()); if (batch == null || !batch.isActive()) { throw new InvalidActivityDataException("优惠券批次ID无效或已禁用"); } } } ``` --- ## 🟡 中等优先级问题 ### 11. 硬编码值 | 位置 | 值 | 建议 | |------|-----|------| | `ActivityService.java:39` | `List.of("image/jpeg", "image/png")` | 提取到配置 | | `ShortLinkService.java:15` | `DEFAULT_CODE_LEN = 8` | 提取到配置 | | `RateLimitInterceptor.java:20` | `per-minute=100` | 提取到配置 | | `ActivityService.java:62` | `rewardCalculationMode = "delta"` | 使用枚举 | **建议**: 创建 `AppConstants` 类或使用配置 ```java @Configuration @ConfigurationProperties(prefix = "app") public class AppConfig { private int defaultCodeLength = 8; private int rateLimitPerMinute = 100; private List supportedImageTypes = List.of("image/jpeg", "image/png"); // getters and setters } ``` --- ### 12. 重复代码 **位置**: `ActivityService.java` ```java // 重复的existsById检查 private void validateActivityExists(Long activityId) { if (!activityRepository.existsById(activityId)) { throw new ActivityNotFoundException("活动不存在。"); } } // 在多个方法中使用 public List getLeaderboard(Long activityId) { if (!activityRepository.existsById(activityId)) { // 重复 throw new ActivityNotFoundException("活动不存在。"); } // ... } ``` **修复方案**: ```java public List getLeaderboard(Long activityId) { validateActivityExists(activityId); // 使用私有方法 // ... } private void validateActivityExists(Long activityId) { if (!activityRepository.existsById(activityId)) { throw new ActivityNotFoundException("活动不存在。"); } } ``` --- ### 13. 缺少输入长度验证 **位置**: `ShortenRequest.java` ```java public class ShortenRequest { @NotBlank private String originalUrl; // 没有 @Size 验证! } ``` **修复方案**: ```java public class ShortenRequest { @NotBlank @Size(min = 10, max = 2048, message = "URL长度必须在10-2048之间") private String originalUrl; } ``` --- ### 14. 缺少审计字段 **问题**: 部分表缺少 `created_by`, `updated_by` 字段 **影响**: 无法追踪数据变更责任人 **建议**: ```sql ALTER TABLE activities ADD COLUMN created_by BIGINT; ALTER TABLE activities ADD COLUMN updated_by BIGINT; ``` 使用Spring Data Auditing: ```java @Entity @EntityListeners(AuditingEntityListener.class) public class ActivityEntity { @CreatedBy private Long createdBy; @LastModifiedBy private Long updatedBy; } ``` --- ### 15. 缺少软删除 **当前**: 使用 `revoked_at` 字段模拟软删除 **问题**: - API密钥有软删除 - 其他数据没有统一处理 **建议**: 使用Spring Data JPA Soft Delete ```java @SoftDelete public interface ActivityRepository extends JpaRepository { } ``` --- ## 🟢 低优先级改进建议 ### 16. 日志格式不统一 **位置**: 多个文件 ```java // 混杂的中英文日志 log.info("开始执行每日活动数据聚合任务"); log.info("为活动ID {} 聚合了数据", activity.getId()); ``` **建议**: 统一使用英文或使用日志模板 --- ### 17. 缺少健康检查端点 **建议**: 添加 actuator 端点 ```properties management.endpoints.web.exposure.include=health,info,metrics management.endpoint.health.show-details=when_authorized ``` --- ### 18. 缺少API文档 **建议**: 使用SpringDoc OpenAPI ```java @RestController @RequestMapping("/api/v1/activities") @Tag(name = "Activity Management", description = "活动管理API") public class ActivityController { @Operation(summary = "创建活动", description = "创建一个新的推广活动") @PostMapping public ResponseEntity createActivity(...) { // ... } } ``` --- ## 📈 性能优化建议 ### 19. 数据库连接池 **当前**: `application.properties` 无数据库配置 **建议**: ```properties spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000 spring.datasource.hikari.max-lifetime=1800000 ``` --- ### 20. 批量操作优化 **位置**: `DbRewardQueue.java:13-24` ```java public void enqueueReward(String trackingId, String externalUserId, String payloadJson) { RewardJobEntity job = new RewardJobEntity(); repository.save(job); // 单条插入 } ``` **建议**: 实现批量插入 ```java @Override public void enqueueRewards(List jobs) { List entities = jobs.stream() .map(this::toEntity) .collect(Collectors.toList()); repository.saveAll(entities); } ``` --- ## 🔒 安全加固清单 ### 必须修复 - [ ] URL白名单验证 (SSRF防护) - [ ] API密钥恢复机制 - [ ] 异常日志记录 - [ ] 速率限制强制Redis ### 建议修复 - [ ] 添加数据库外键约束 - [ ] 缓存序列化安全 - [ ] 输入长度验证 - [ ] 审计字段 ### 可选改进 - [ ] API版本控制 - [ ] 统一响应格式 - [ ] OpenAPI文档 - [ ] 健康检查端点 --- ## 📚 参考资源 - [OWASP API Security Top 10](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/) - [Spring Security最佳实践](https://spring.io/projects/spring-security) - [Redis安全配置](https://redis.io/docs/management/security/) --- ## 📝 审查统计 | 类别 | 数量 | |------|------| | 🔴 严重安全问题 | 4 | | 🟠 高优先级问题 | 6 | | 🟡 中等优先级问题 | 5 | | 🟢 低优先级改进 | 5 | | **总计** | **20** | --- *报告生成时间: 2026-01-20* *使用Skills: code-review, security, database, api-design* --- ## 🆕 2026-03-18 补充审查(全面严格复核) **审查目标**: 在既有 v2.0 报告基础上,对全仓代码进行再次严格复核,并补充新增高风险发现。 **本轮使用 Skills**: `backend`、`security`、`testing`、`frontend`、`verification-before-completion` **复核范围**: 1. 后端核心认证与权限链路(`src/main/java`) 2. Flyway 全量迁移脚本(`src/main/resources/db/migration`) 3. 后端与前端测试基线(Maven + Vitest + vue-tsc) ### 🔴 严重问题(新增,必须优先修复) ### 21. Flyway 迁移脚本存在数据库方言混用,生产迁移存在直接失败风险 **证据位置**: - `src/main/resources/db/migration/V42__Create_system_config_table.sql:4-15` - `src/main/resources/db/migration/V65__Create_user_permission_table.sql:5-13` - `src/main/resources/db/migration/V74__Create_user_tag_table.sql:6-17` - `src/main/resources/db/migration/V44__Add_approval_timeout_reminder_table.sql:4-9` **问题细节**: 1. 在同一迁移链中混用 MySQL 方言(`AUTO_INCREMENT`、`ENGINE=InnoDB`、`ON DUPLICATE KEY UPDATE`、列级 `COMMENT`、`ON UPDATE CURRENT_TIMESTAMP`)。 2. 现有项目测试与历史脚本同时包含 H2/PostgreSQL 风格(如 `GENERATED BY DEFAULT AS IDENTITY`、`TIMESTAMP` 语义)。 3. 多个脚本未见方言隔离策略(按 profile/DB vendor 分目录),迁移执行路径不可预测。 **影响**: - 在非 MySQL 目标库(或 H2 兼容模式不完整场景)执行全量迁移时,启动阶段可直接失败。 - 数据库初始化不一致,造成环境间行为漂移(开发通过、预发/生产失败)。 **修复建议**: 1. 统一目标数据库方言并重写冲突脚本(建议优先 PostgreSQL 兼容 SQL)。 2. 对必须分库语法,使用 Flyway `locations` + profile 分离迁移目录。 3. 增加“全量迁移演练”CI任务(空库从 `V1` 到最新版本)。 --- ### 22. 权限表列名与迁移脚本不一致,V73 存在确定性 SQL 失败风险 **证据位置**: - `src/main/resources/db/migration/V21__Create_permission_tables.sql:26-29`(定义列:`module_code/resource_code/operation_code`) - `src/main/resources/db/migration/V73__Add_dashboard_monitor_and_risk_permissions.sql:5-6`(插入列:`module/resource/operation`) **问题细节**: 1. 表定义使用 `module_code/resource_code/operation_code`。 2. V73 插入语句使用不存在列 `module/resource/operation`。 3. 该问题不是风格差异,而是结构不匹配。 **影响**: - 迁移执行到 V73 时可能直接报“列不存在”并中断。 - 新权限点无法落库,后续角色授权链路连锁失效。 **修复建议**: 1. 统一 V73 列名到 `module_code/resource_code/operation_code`。 2. 对所有 `V6x+` 迁移执行一次列名一致性静态扫描,避免同类回归。 --- ### 🟠 高优先级问题(新增) ### 23. 角色编码大小写不一致,权限分配 SQL 可能“静默不生效” **证据位置**: - `src/main/resources/db/migration/V26__Seed_roles_permissions.sql:8-22`(基础角色:`super_admin/system_admin` 小写) - `src/main/resources/db/migration/V73__Add_dashboard_monitor_and_risk_permissions.sql:74,100`(`SUPER_ADMIN/SYSTEM_ADMIN` 大写) - `src/main/resources/db/migration/V74__Create_user_tag_table.sql:24,35,46`(大写角色码) **问题细节**: 1. 角色种子数据使用小写 `role_code`。 2. 后续授权迁移使用大写 role_code 做 `WHERE` 过滤。 3. 若数据库排序规则区分大小写,`INSERT ... SELECT` 结果集为空但不报错。 **影响**: - 新增权限未绑定到应有管理角色,表现为“功能存在但无权限可用”。 - 问题具备隐蔽性,常在联调/上线后才暴露。 **修复建议**: 1. 统一角色编码规范(建议全小写)并在迁移中强制使用同一规范。 2. 增加迁移后断言脚本:关键角色必须拥有关键权限(数量/集合校验)。 --- ### 24. Flyway 相关测试为“伪验证”,无法覆盖真实迁移链路 **证据位置**: - `src/test/java/com/mosquito/project/FlywayMigrationSmokeTest.java:24-30`(测试被 `@Disabled`,且 `spring.flyway.enabled=false`) - `src/test/java/com/mosquito/project/FlywayMigrationSmokeTest.java:79-82`(`noFailedMigrations` 恒为 `true`) - `src/test/java/com/mosquito/project/MigrationScriptSyntaxTest.java:41-47,68-86`(仅对临时测试表执行模拟 SQL) **问题细节**: 1. 冒烟测试被禁用且不跑 Flyway。 2. SQL 语法测试只验证“构造出来的测试 SQL”,不执行真实迁移文件。 3. 因此无法发现 V73/V74/V42 一类真实脚本问题。 **影响**: - CI 绿灯不代表迁移可执行,数据库变更风险转移到部署阶段。 **修复建议**: 1. 启用真实 Flyway integration test(空库全量迁移)。 2. 增加“目标数据库容器”验证(与生产同方言)。 3. 对每次新增迁移,强制跑全链路回放。 --- ### 25. 认证链路保留演示账号回退逻辑,存在绕过真实账户治理风险 **证据位置**: - `src/main/java/com/mosquito/project/service/AuthService.java:115-123` - `src/main/java/com/mosquito/project/service/AuthService.java:151-156` - `src/main/java/com/mosquito/project/service/AuthService.java:39-40` **问题细节**: 1. 当数据库用户不存在时,仍回退到硬编码演示账号校验。 2. 硬编码哈希长期驻留在业务代码中。 3. 与“数据库唯一事实源”认证模型冲突。 **影响**: - 认证路径不可控,运维与安全策略(禁用/口令轮换)无法完全闭环。 **修复建议**: 1. 生产构建禁用演示回退分支(profile 开关默认关闭)。 2. 移除硬编码凭据,若保留仅限本地开发并强约束环境变量控制。 --- ### 26. 冻结/黑名单用户在登录入口未被阻断,可重新签发新 Token **证据位置**: - `src/main/java/com/mosquito/project/permission/SysUserService.java:207-214`(冻结写入 `FROZEN` 并清 token) - `src/main/java/com/mosquito/project/permission/SysUserService.java:257-263`(黑名单写入 `isBlacklisted=true`) - `src/main/java/com/mosquito/project/service/AuthService.java:84-113,129-131`(登录流程未检查 `status/isBlacklisted`,仍创建 token) **问题细节**: 1. 冻结/黑名单仅清理“已有 token”。 2. 登录口未校验账户状态,用户可再次登录拿到新 token。 **影响**: - 账户治理策略失效,存在合规与风控穿透风险。 **修复建议**: 1. 在 `AuthService.login` 增加状态闸门(`ACTIVE && !isBlacklisted`)。 2. 将状态校验抽象为独立策略,覆盖登录与 token 续签两条链路。 --- ### 27. SHA-256 到 BCrypt 的“自动升级”未持久化,导致升级失效 **证据位置**: - `src/main/java/com/mosquito/project/service/AuthService.java:100-105`(尝试设置新 hash 并调用 `updateUser`) - `src/main/java/com/mosquito/project/permission/SysUserService.java:178-188`(`updateUser` 未更新 `passwordHash` 字段) **问题细节**: 1. 代码意图是登录成功后把旧哈希升级为 BCrypt。 2. 实际更新方法不写回 `passwordHash`,升级结果丢失。 **影响**: - 用户持续停留在旧哈希体系,无法完成渐进式安全升级。 **修复建议**: 1. 为密码升级提供专用方法(只允许更新密码哈希)。 2. 增加单元测试断言“升级后数据库哈希前缀为 `$2`”。 --- ### 🟡 中优先级问题(新增) ### 28. 部分审计接口未写入 `userName`,审计可读性与检索能力下降 **证据位置**: - `src/main/java/com/mosquito/project/permission/UserController.java:453,521,833`(`auditService.log(currentUserId.toString(), ...)`) - `src/main/java/com/mosquito/project/service/AuditService.java:403-411`(简化 `log` 仅写 `userId`,不写 `userName`) - `src/main/java/com/mosquito/project/service/AuditService.java:88`(`userName` 取自 `logData`,若未传即为空) **影响**: - 审计报表按用户名排查时信息不完整,事件追踪效率下降。 **修复建议**: 1. 统一审计上下文,强制同时写入 `userId + userName`。 2. 将 `AuditService.log` 参数改为强类型对象,避免字段遗漏。 --- ## ✅ 本轮验证结果(2026-03-18) ### 后端 1. 执行命令:`mvn -q test` 2. 结果:失败,`Tests run: 1504, Failures: 6, Errors: 0, Skipped: 1` 3. 失败集中: - `src/test/java/com/mosquito/project/web/UrlValidatorTest.java:84` - `src/test/java/com/mosquito/project/web/UrlValidatorTest.java:102` - `src/test/java/com/mosquito/project/web/UrlValidatorTest.java:118` - `src/test/java/com/mosquito/project/web/UrlValidatorTest.java:141` - `src/test/java/com/mosquito/project/web/UrlValidatorTest.java:149` - `src/test/java/com/mosquito/project/web/UrlValidatorTest.java:155` ### 前端(admin) 1. 执行命令:`npm --prefix frontend/admin run type-check` 2. 结果:通过 3. 执行命令:`npm --prefix frontend/admin run test -- --run` 4. 结果:通过(`9` files / `16` tests) --- ## 区分:已确认缺陷 vs 验证缺口 ### 已确认缺陷(有直接代码证据) 1. 迁移脚本方言混用(问题 21) 2. 权限列名不一致(问题 22) 3. 角色码大小写不一致导致授权静默失败(问题 23) 4. 演示账号回退(问题 25) 5. 冻结/黑名单登录闸门缺失(问题 26) 6. 密码升级不落库(问题 27) ### 验证缺口/高风险项(需要补测试进一步确认) 1. 真实目标数据库上的 Flyway 全链路回放(问题 24) 2. 迁移后关键角色权限集合自动断言(问题 23) 3. 审计用户名字段完备性回归测试(问题 28) --- ## 建议执行顺序(落地计划) 1. 先修复迁移脚本一致性(问题 21/22/23),并在目标数据库做全量迁移演练。 2. 再修复认证链路账户状态闸门与密码升级持久化(问题 26/27)。 3. 最后补齐 CI 验证策略(问题 24/28),避免同类问题再次进入主干。 *补充审查时间: 2026-03-18* *补充审查人: Codex(基于专业 Skills 复核)* --- ## 🆕 2026-03-18 工单执行后复审(含验证证据) **复审目标**: 对 21~28 号问题在工单执行后的实际落地情况做二次严格确认。 **本轮使用 Skills**: `testing`、`verification-before-completion`、`backend`、`security` ### ✅ 新鲜验证证据 1. 后端全量测试:`mvn -q test` 结果:`Tests run=1514, Failures=0, Errors=0, Skipped=4` 2. 前端 admin 类型检查:`npm --prefix frontend/admin run type-check` 结果:通过 3. 前端 admin 单测:`npm --prefix frontend/admin run test -- --run` 结果:`Test Files 9 passed, Tests 16 passed` ### 21~28 状态总览 | 编号 | 问题标题 | 当前状态 | 结论说明 | |------|----------|----------|----------| | 21 | Flyway 方言混用 | ✅ Fixed | `V42/V44/V65/V74` 已统一为 PostgreSQL 兼容写法(移除 `AUTO_INCREMENT/ENGINE/ON DUPLICATE KEY UPDATE` 等) | | 22 | V73 列名不一致 | ✅ Fixed | `V73` 已改为 `module_code/resource_code/operation_code` | | 23 | 角色码大小写不一致 | ✅ Fixed | `V73/V74` 已统一使用小写 `role_code`(如 `super_admin/system_admin`) | | 24 | Flyway 测试伪验证 | ⚠️ Partially Verified | 已补真实 PostgreSQL 迁移测试,但当前环境容器运行时不可用,测试被 `Assumption` 跳过 | | 25 | demo 回退绕过治理 | ✅ Fixed(后续已闭环) | 后续已移除后端 demo fallback 分支与硬编码凭据;后端不再依赖 `app.demo-auth.*` 配置项 | | 26 | 冻结/黑名单可重新登录 | ✅ Fixed | 登录口已增加 `ACTIVE && !isBlacklisted` 闸门 | | 27 | SHA->BCrypt 升级未落库 | ✅ Fixed | 已新增 `updatePasswordHash` 专用持久化方法,且有单测断言 | | 28 | 审计缺少 `userName` | ✅ Fixed | `AuditService` 增加带 `userName` 重载;`UserController` 紧急操作调用已补齐用户名 | ### 残余风险(复审后仍需跟进) 1. **运行时验证缺口(问题 24)** `FlywayMigrationSmokeTest` 与 `RolePermissionMigrationTest` 在当前环境被跳过,不代表“容器环境一定通过”。建议在 CI 中提供可用 Docker/Podman,并将这两类测试设为不可跳过。 2. **配置误开风险(问题 25)** 该风险已在后续修复中关闭:后端 demo 分支与硬编码凭据已移除,`app.demo-auth.*` 配置项已清理。 ### 复审结论 - 针对本轮工单范围(21~28):**6 项已完全关闭,2 项为“部分验证/部分修复”**。 - 项目当前不存在这批工单中的“确定性阻断缺陷”,但仍有上述两项残余风险需要在 CI/生产约束层完成最后闭环。 --- ## 🆕 2026-03-18 继续执行结果(残余项闭环) **执行目标**: 继续关闭上节中 24/25 两项残余风险。 **变更范围**: 1. `src/main/java/com/mosquito/project/service/AuthService.java` 2. `src/test/java/com/mosquito/project/service/AuthServiceTest.java` 3. `src/test/java/com/mosquito/project/FlywayMigrationSmokeTest.java` 4. `src/test/java/com/mosquito/project/RolePermissionMigrationTest.java` 5. `.woodpecker.yml` ### 已落地修复 1. **问题 25(demo 回退误开风险)** - 先通过 `prod` 环境启动期阻断实现防误开。 - 后续进一步移除后端 demo fallback 分支与硬编码凭据,并删除后端 `app.demo-auth.*` 配置项,从源头消除误开面。 2. **问题 24(迁移测试可被跳过)** - 在 `FlywayMigrationSmokeTest` 与 `RolePermissionMigrationTest` 增加“严格模式”: - `-Dmigration.test.strict=true` 或 `STRICT_MIGRATION_TESTS=true` 时,容器不可用不再 `skip`,而是 `fail`。 - CI 已启用严格模式:`.woodpecker.yml` 的后端校验命令追加 `-Dmigration.test.strict=true`。 ### 本轮验证证据 1. 关键回归: - `mvn -q -Dtest=AuthServiceTest,MigrationScriptSyntaxTest,FlywayMigrationSmokeTest,RolePermissionMigrationTest test` - 结果:通过(其中迁移容器测试在非严格模式下按预期 skip) 2. 严格模式验证: - `mvn -q -Dmigration.test.strict=true -Dtest=FlywayMigrationSmokeTest,RolePermissionMigrationTest test` - 结果:在当前环境 **按预期失败**(容器/JNA 不可用),证明“严格模式不再放行 skip”机制生效。 3. 后端全量: - `mvn -q test` - 结果:`Tests run=1515, Failures=0, Errors=0, Skipped=4` 4. 前端 admin: - `npm --prefix frontend/admin run type-check` 通过 - `npm --prefix frontend/admin run test -- --run` 通过(`9 files / 16 tests`) ### 状态更新(21~28) | 编号 | 旧状态 | 新状态 | |------|--------|--------| | 24 | ⚠️ Partially Verified | ✅ Fixed(机制闭环,严格模式下不可跳过;待具备容器环境时执行一次真实迁移) | | 25 | ⚠️ Partially Fixed | ✅ Fixed(prod 环境误开即阻断) | **更新后结论**: 21~28 问题已完成代码级闭环;当前仅剩“在具备容器能力的 CI/环境执行一次严格模式迁移全绿”这一运行环境前提事项。 --- ## 🆕 2026-03-19 当前环境严格模式迁移复验(最终闭环) **复验目标**: 在当前执行环境打通 JNA 临时目录 + Podman 运行时,验证严格模式迁移测试真实执行且不再跳过。 **本轮使用 Skills**: `systematic-debugging`、`testing-integration`、`verification-before-completion` ### 本轮关键修复(迁移脚本) 1. PostgreSQL 方言改造与幂等修复: - `V27__Add_simplified_permission_codes.sql`(`MERGE/FROM DUAL` -> `INSERT ... ON CONFLICT`) - `V30__Add_invite_notification_permissions.sql`(同上) - `V34__Add_missing_permission_codes.sql`(修复与 V27 冲突的权限 ID 区间,并补 `ON CONFLICT`) - `V51__Backfill_user_rewards_department_id.sql`(`UPDATE ... INNER JOIN` -> `UPDATE ... FROM`) - `V53__Fix_approval_flow_table_and_backfill.sql`(`NOW(3)` -> `CURRENT_TIMESTAMP`) - `V54__Add_batch_approval_and_audit_permissions.sql`(修复双 `WHERE` 语法错误) - `V56__Add_pending_value_to_system_config.sql`(移除 MySQL `AFTER` 子句,改 `ADD COLUMN IF NOT EXISTS`) 2. 审批流模板迁移与表结构一致性修复: - `V60__Fix_approval_templates.sql`(去除不存在列 `updated_at` 的更新) - `V61__Fix_role_code_in_approval_templates.sql`(同上) - `V69__Fix_activity_update_approval_nodes.sql`(同上) ### 验证证据(当前环境) 1. 严格模式迁移测试: - 命令:`mvn -q -Dmigration.test.strict=true -Djna.tmpdir=/home/long/project/蚊子/tmp/jna -Djava.io.tmpdir=/home/long/project/蚊子/tmp/java -Dtest=FlywayMigrationSmokeTest,RolePermissionMigrationTest test` - 结果:`FlywayMigrationSmokeTest` 与 `RolePermissionMigrationTest` 全通过,`Skipped=0` - surefire 报告: - `tests=2, errors=0, failures=0, skipped=0` - `tests=1, errors=0, failures=0, skipped=0` 2. 后端全量回归: - 命令:`mvn -q test` - 报告汇总:`suites=139, tests=1526, failures=0, errors=0, skipped=1` - 唯一 skipped 项:`CallbackControllerIntegrationTest`(1 个用例) 3. 迁移方言残留扫描(静态): - 在 `src/main/resources/db/migration` 中未检出有效残留 `MERGE/FROM DUAL/NOW(3)/AFTER/INNER JOIN`(仅注释中保留示例语句) ### 最终状态更新(21~28) | 编号 | 最终状态 | 说明 | |------|----------|------| | 21 | ✅ Fixed | PostgreSQL 迁移链路已实测全量通过到 `v76` | | 22 | ✅ Fixed | 列名不一致问题未再触发,链路通过 | | 23 | ✅ Fixed | 关键角色权限迁移通过严格模式验证 | | 24 | ✅ Fixed | 严格模式在当前环境已真实执行并 `Skipped=0` | | 25 | ✅ Fixed | 后端 demo fallback 与 `app.demo-auth.*` 配置均已移除,仅保留前端演示模式能力 | | 26 | ✅ Fixed | 登录闸门修复已保持通过 | | 27 | ✅ Fixed | 密码升级持久化修复已保持通过 | | 28 | ✅ Fixed | 审计用户名补齐修复已保持通过 | **最终结论(2026-03-19)**: 21~28 号问题已完成代码与运行验证双闭环;“当前环境严格模式迁移测试”已跑通且不再依赖跳过机制。 --- ## 🆕 2026-03-19(继续)Skipped 进一步压降到 0 **目标**: 在“严格模式迁移测试可跑通”的基础上,继续消除全量测试中剩余的 `Skipped=1`(`CallbackControllerIntegrationTest`)。 ### 根因定位 1. `CallbackControllerIntegrationTest` 被类级 `@Disabled` 直接跳过(历史“临时禁用”残留)。 2. 去掉 `@Disabled` 后暴露真实环境依赖问题: - Redis 连接在当前沙箱网络限制下不可用,导致 `RedisConnectionFailureException`。 - Spring Security 过滤链导致初始请求返回 `403`,与本用例关注点(回调幂等与限流)不一致。 ### 已落地修复 文件:`src/test/java/com/mosquito/project/controller/CallbackControllerIntegrationTest.java` 1. 移除类级 `@Disabled`,恢复用例实际执行。 2. 增加测试级 Redis Mock: - `@MockBean StringRedisTemplate` - 用 `ValueOperations.increment` 的内存计数模拟限流计数行为。 3. 增加 `spring.cache.type=simple`,避免测试命中 Redis CacheManager。 4. `@AutoConfigureMockMvc(addFilters = false)`,隔离安全过滤器噪声,让用例聚焦回调链路行为。 5. 保持并强化测试数据前置: - 显式创建活动/API Key - 显式写入 `tracking_id` 对应短链记录 ### 验证证据(本轮最新) 1. 单测定向: - `mvn -q -Dtest=CallbackControllerIntegrationTest test` - 结果:`tests=1, failures=0, errors=0, skipped=0` 2. 严格迁移测试(同命令内拉起 Podman service): - `set -euo pipefail; podman system service --time=0 "unix:///run/user/$(id -u)/podman/podman.sock" ... & DOCKER_HOST=... TESTCONTAINERS_RYUK_DISABLED=true mvn -q -Dmigration.test.strict=true -Djna.tmpdir=... -Djava.io.tmpdir=... -Dtest=FlywayMigrationSmokeTest,RolePermissionMigrationTest test` - 结果:通过,`Skipped=0` 3. 后端全量回归(同命令内拉起 Podman service): - `set -euo pipefail; podman system service --time=0 "unix:///run/user/$(id -u)/podman/podman.sock" ... & DOCKER_HOST=... TESTCONTAINERS_RYUK_DISABLED=true mvn -q -Djna.tmpdir=... -Djava.io.tmpdir=... test` - surefire 汇总:`files=139, tests=1526, failures=0, errors=0, skipped=0` ### 复审结论(更新) 1. 当前代码基线下,后端全量测试已达到 **零跳过(Skipped=0)**。 2. “严格模式迁移测试 + 容器运行时 + JNA 临时目录”链路在当前环境已实测可用。 3. 本轮无新增失败项;仍可见个别日志告警(如审计拦截器对匿名用户的异常日志),但未影响测试通过与验收目标。 --- ## 🆕 2026-03-19(继续)CI 固化 + 严格迁移复验补充 **执行目标**: 1. 将“同命令启动 Podman service + 严格迁移测试”固化到 CI,避免环境漂移导致假失败。 2. 在当前环境完成严格模式复验,并再次核查审查报告项是否仍有遗留问题。 ### 已落地变更 1. CI 脚本化固化: - `.woodpecker.yml` 的 `build_test` 阶段改为统一调用 `./scripts/ci/backend-verify.sh`。 - 新增并增强 `scripts/ci/backend-verify.sh`: - 创建 `tmp/jna`、`tmp/java` - 同命令拉起 `podman system service` - 导出 `DOCKER_HOST`、`TESTCONTAINERS_RYUK_DISABLED` - 执行 `mvn -B -DskipTests=false -Dmigration.test.strict=true ... clean verify` - 增加 socket 就绪等待、失败日志输出、退出清理(`trap`) 2. 严格迁移兼容修复: - `src/main/resources/db/migration/V76__Normalize_permission_codes_to_canonical.sql` - 修复 PostgreSQL 不兼容写法:`SET rp.permission_id` -> `SET permission_id` 3. 迁移断言对齐 canonical 权限码: - `src/test/java/com/mosquito/project/FlywayMigrationSmokeTest.java` - `src/test/java/com/mosquito/project/RolePermissionMigrationTest.java` - 断言从旧三段式权限码改为四段式(`.ALL`)。 ### 本轮验证证据(2026-03-19) 1. 完整脚本验证: - 命令:`./scripts/ci/backend-verify.sh` - 结果:`BUILD SUCCESS` - Maven 汇总:`Tests run: 1526, Failures: 0, Errors: 0, Skipped: 0` 2. surefire XML 汇总校验: - `files=139, tests=1526, failures=0, errors=0, skipped=0` 3. 严格迁移关键用例: - `FlywayMigrationSmokeTest`: `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0` - `RolePermissionMigrationTest`: `Tests run: 1, Failures: 0, Errors: 0, Skipped: 0` ### 再次复审结论(针对本报告问题清单) 1. **21~28 号问题保持关闭状态**,且在当前环境已通过“严格迁移 + 全量回归 + 零跳过”三重验证。 2. 原始高风险项中,`SSRF 防护`、`异常吞掉`、`生产限流强制 Redis` 已在代码层落实并维持通过。 3. 发现 1 个仍建议跟进的问题(非阻断): - **审计日志对匿名用户容错不足** - 位置:`src/main/java/com/mosquito/project/service/AuditService.java:87` - 现象:`userId` 直接 `Long.parseLong(...)`,当值为 `"anonymous"` 时触发 `NumberFormatException`,日志中可见错误堆栈(虽不影响主流程测试通过)。 - 建议:对非数字 `userId` 做降级处理(置空/保留原始字符串到扩展字段),避免噪声错误与审计记录丢失风险。 **补充结论(2026-03-19)**: 本轮目标“CI 固化 + 严格模式迁移实跑 + 全量验证”已完成并通过;项目在本报告主线风险上已基本收敛,当前仅剩审计容错的低风险改进项建议后续纳入工单。 --- ## 🆕 2026-03-19(继续)P2 审计 anonymous 容错修复完成 **目标**: 关闭“审计日志对匿名用户容错不足”的残余改进项。 **变更文件**: 1. `src/main/java/com/mosquito/project/service/AuditService.java` 2. `src/test/java/com/mosquito/project/service/AuditServiceTest.java` ### 修复内容 1. `AuditService.recordAuditLog` 不再直接对 `userId` 做 `Long.parseLong(...)`。 2. 新增安全解析方法 `parseNullableUserId`: - `null` / 空串 / 非数字(如 `anonymous`)统一降级为 `null` - 数字字符串(含空白)可正常解析 ### 验证证据 1. 定向测试命令:`mvn -q -Dtest=AuditServiceTest test` 2. surefire 报告: - `com.mosquito.project.service.AuditServiceTest` - `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0` ### 结论 “审计 anonymous 容错”问题已完成最小修复并附带单测闭环,当前不再是待办项。