Files
wenzi/docs/reports/review/CODE_REVIEW_REPORT.md

42 KiB
Raw Blame History

🦟 蚊子项目代码审查报告 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

@GetMapping("/r/{code}")
public ResponseEntity<Void> 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

修复方案:

@GetMapping("/r/{code}")
public ResponseEntity<Void> 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);
        })
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

public String generateApiKey(CreateApiKeyRequest request) {
    String rawApiKey = UUID.randomUUID().toString();
    // ... 保存hash
    return rawApiKey;  // 只返回一次!
}

风险等级: 🔴 HIGH
影响: 用户丢失密钥后只能重新创建,造成业务中断

业务影响:

  • 用户需要重新配置所有使用该密钥的系统
  • 旧密钥立即失效可能导致服务中断
  • 没有密钥轮换机制

修复方案:

// 方案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<String> revealApiKey(@PathVariable Long id) {
        // 需要额外验证(邮箱/密码)
        String encrypted = entity.getEncryptedKey();
        return decrypt(encrypted, ENCRYPTION_KEY);
    }
}

3. 速率限制可被绕过

位置: RateLimitInterceptor.java:17-44

private final ConcurrentHashMap<String, AtomicInteger> 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
影响: 多实例部署时无法正确限流

修复方案:

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

try {
    linkClickRepository.save(click);
} catch (Exception ignore) {}  // BAD!

风险等级: 🔴 HIGH
影响: 无法审计追踪,数据库问题不被发现

修复方案:

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 缺少外键约束

位置: 多个迁移文件

-- 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 无活动外键验证

修复方案:

-- 添加外键约束
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

public interface UserInviteRepository extends JpaRepository<UserInviteEntity, Long> {
    List<UserInviteEntity> findByActivityId(Long activityId);
    List<UserInviteEntity> findByActivityIdAndInviterUserId(Long activityId, Long inviterUserId);
}

问题: 没有 (activity_id, invitee_user_id) 的索引

迁移文件:

CREATE INDEX idx_user_invites_activity_invitee 
ON user_invites(activity_id, invitee_user_id);

6. N+1 查询问题

位置: ActivityService.java:287-304

@Cacheable(value = "leaderboards", key = "#activityId")
public List<LeaderboardEntry> getLeaderboard(Long activityId) {
    List<UserInviteEntity> invites = userInviteRepository.findByActivityId(activityId);
    // O(n) 次数据库查询? 不, 这是内存处理
    
    Map<Long, Integer> counts = new HashMap<>();
    for (UserInviteEntity inv : invites) {
        counts.merge(inv.getInviterUserId(), 1, Integer::sum);  // 内存聚合
    }
    // ...
}

当前状态: 已优化,在内存中聚合

建议: 如果数据量超过10万考虑使用SQL聚合:

@Query("SELECT u.inviterUserId, COUNT(u) FROM UserInviteEntity u " +
       "WHERE u.activityId = :activityId GROUP BY u.inviterUserId")
List<Object[]> getInviteCountsByActivityId(@Param("activityId") Long activityId);

7. 缓存策略问题

7.1 缓存没有失效机制

位置: ActivityService.java:287

@Cacheable(value = "leaderboards", key = "#activityId")
public List<LeaderboardEntry> getLeaderboard(Long activityId) {
    // 排行榜更新后,缓存不会失效!
}

修复方案:

@CacheEvict(value = "leaderboards", key = "#activityId")
public LeaderboardEntry recordInvite(...) {
    // 记录邀请后清除缓存
}

@Scheduled(fixedRate = 60000) // 或使用CachePut
public void refreshLeaderboardCache() {
    // 定时刷新
}

7.2 缓存配置缺少序列化安全

位置: CacheConfig.java:24-26

RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
    .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
        new GenericJackson2JsonRedisSerializer()
    ));

问题: GenericJackson2JsonRedisSerializer 使用JDK序列化存在反序列化漏洞

修复方案:

// 使用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

public DailyActivityStats aggregateStatsForActivity(Activity activity, LocalDate date) {
    Random random = new Random();  // 每次创建新Random
    stats.setViews(1000 + random.nextInt(500));
    // ...
}

当前状态: 无状态操作,安全

建议: 使用 ThreadLocalRandom 提高性能

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

private final Map<Long, Activity> 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

@GetMapping("/{id}/leaderboard")
public ResponseEntity<List<LeaderboardEntry>> getLeaderboard(...) {
    // 分页返回 List
}

@GetMapping("/{id}/leaderboard/export")
public ResponseEntity<byte[]> exportLeaderboard(...) {
    // 导出返回 CSV bytes
}

建议: 统一响应格式

public class ApiResponse<T> {
    private T data;
    private Meta meta;
    private Error error;
    
    public static <T> ApiResponse<T> success(T data) { ... }
    public static <T> ApiResponse<T> paginated(T data, PaginationMeta meta) { ... }
}

10. 未实现的业务逻辑

位置: ActivityService.java:264-271

public void createReward(Reward reward, boolean skipValidation) {
    if (reward.getRewardType() == RewardType.COUPON && !skipValidation) {
        boolean isValidCouponBatchId = false;  // 永远为false!
        if (!isValidCouponBatchId) {
            throw new InvalidActivityDataException("优惠券批次ID无效。");
        }
    }
}

问题: 验证逻辑被硬编码,功能未实现

建议:

// 方案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 类或使用配置

@Configuration
@ConfigurationProperties(prefix = "app")
public class AppConfig {
    private int defaultCodeLength = 8;
    private int rateLimitPerMinute = 100;
    private List<String> supportedImageTypes = List.of("image/jpeg", "image/png");
    
    // getters and setters
}

12. 重复代码

位置: ActivityService.java

// 重复的existsById检查
private void validateActivityExists(Long activityId) {
    if (!activityRepository.existsById(activityId)) {
        throw new ActivityNotFoundException("活动不存在。");
    }
}

// 在多个方法中使用
public List<LeaderboardEntry> getLeaderboard(Long activityId) {
    if (!activityRepository.existsById(activityId)) {  // 重复
        throw new ActivityNotFoundException("活动不存在。");
    }
    // ...
}

修复方案:

public List<LeaderboardEntry> getLeaderboard(Long activityId) {
    validateActivityExists(activityId);  // 使用私有方法
    // ...
}

private void validateActivityExists(Long activityId) {
    if (!activityRepository.existsById(activityId)) {
        throw new ActivityNotFoundException("活动不存在。");
    }
}

13. 缺少输入长度验证

位置: ShortenRequest.java

public class ShortenRequest {
    @NotBlank
    private String originalUrl;
    // 没有 @Size 验证!
}

修复方案:

public class ShortenRequest {
    @NotBlank
    @Size(min = 10, max = 2048, message = "URL长度必须在10-2048之间")
    private String originalUrl;
}

14. 缺少审计字段

问题: 部分表缺少 created_by, updated_by 字段

影响: 无法追踪数据变更责任人

建议:

ALTER TABLE activities ADD COLUMN created_by BIGINT;
ALTER TABLE activities ADD COLUMN updated_by BIGINT;

使用Spring Data Auditing:

@Entity
@EntityListeners(AuditingEntityListener.class)
public class ActivityEntity {
    @CreatedBy
    private Long createdBy;
    
    @LastModifiedBy
    private Long updatedBy;
}

15. 缺少软删除

当前: 使用 revoked_at 字段模拟软删除

问题:

  • API密钥有软删除
  • 其他数据没有统一处理

建议: 使用Spring Data JPA Soft Delete

@SoftDelete
public interface ActivityRepository extends JpaRepository<ActivityEntity, Long> {
}

🟢 低优先级改进建议

16. 日志格式不统一

位置: 多个文件

// 混杂的中英文日志
log.info("开始执行每日活动数据聚合任务");
log.info("为活动ID {} 聚合了数据", activity.getId());

建议: 统一使用英文或使用日志模板


17. 缺少健康检查端点

建议: 添加 actuator 端点

management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when_authorized

18. 缺少API文档

建议: 使用SpringDoc OpenAPI

@RestController
@RequestMapping("/api/v1/activities")
@Tag(name = "Activity Management", description = "活动管理API")
public class ActivityController {
    @Operation(summary = "创建活动", description = "创建一个新的推广活动")
    @PostMapping
    public ResponseEntity<Activity> createActivity(...) {
        // ...
    }
}

📈 性能优化建议

19. 数据库连接池

当前: application.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

public void enqueueReward(String trackingId, String externalUserId, String payloadJson) {
    RewardJobEntity job = new RewardJobEntity();
    repository.save(job);  // 单条插入
}

建议: 实现批量插入

@Override
public void enqueueRewards(List<RewardJob> jobs) {
    List<RewardJobEntity> entities = jobs.stream()
        .map(this::toEntity)
        .collect(Collectors.toList());
    repository.saveAll(entities);
}

🔒 安全加固清单

必须修复

  • URL白名单验证 (SSRF防护)
  • API密钥恢复机制
  • 异常日志记录
  • 速率限制强制Redis

建议修复

  • 添加数据库外键约束
  • 缓存序列化安全
  • 输入长度验证
  • 审计字段

可选改进

  • API版本控制
  • 统一响应格式
  • OpenAPI文档
  • 健康检查端点

📚 参考资源


📝 审查统计

类别 数量
🔴 严重安全问题 4
🟠 高优先级问题 6
🟡 中等优先级问题 5
🟢 低优先级改进 5
总计 20

报告生成时间: 2026-01-20 使用Skills: code-review, security, database, api-design


🆕 2026-03-18 补充审查(全面严格复核)

审查目标: 在既有 v2.0 报告基础上,对全仓代码进行再次严格复核,并补充新增高风险发现。
本轮使用 Skills: backendsecuritytestingfrontendverification-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_INCREMENTENGINE=InnoDBON DUPLICATE KEY UPDATE、列级 COMMENTON UPDATE CURRENT_TIMESTAMP)。
  2. 现有项目测试与历史脚本同时包含 H2/PostgreSQL 风格(如 GENERATED BY DEFAULT AS IDENTITYTIMESTAMP 语义)。
  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,100SUPER_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-82noFailedMigrations 恒为 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-188updateUser 未更新 passwordHash 字段)

问题细节:

  1. 代码意图是登录成功后把旧哈希升级为 BCrypt。
  2. 实际更新方法不写回 passwordHash,升级结果丢失。

影响:

  • 用户持续停留在旧哈希体系,无法完成渐进式安全升级。

修复建议:

  1. 为密码升级提供专用方法(只允许更新密码哈希)。
  2. 增加单元测试断言“升级后数据库哈希前缀为 $2”。

🟡 中优先级问题(新增)

28. 部分审计接口未写入 userName,审计可读性与检索能力下降

证据位置:

  • src/main/java/com/mosquito/project/permission/UserController.java:453,521,833auditService.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:88userName 取自 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: testingverification-before-completionbackendsecurity

新鲜验证证据

  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
    FlywayMigrationSmokeTestRolePermissionMigrationTest 在当前环境被跳过,不代表“容器环境一定通过”。建议在 CI 中提供可用 Docker/Podman并将这两类测试设为不可跳过。

  2. 配置误开风险(问题 25
    该风险已在后续修复中关闭:后端 demo 分支与硬编码凭据已移除,app.demo-auth.* 配置项已清理。

复审结论

  • 针对本轮工单范围21~286 项已完全关闭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. 问题 25demo 回退误开风险)

    • 先通过 prod 环境启动期阻断实现防误开。
    • 后续进一步移除后端 demo fallback 分支与硬编码凭据,并删除后端 app.demo-auth.* 配置项,从源头消除误开面。
  2. 问题 24迁移测试可被跳过

    • FlywayMigrationSmokeTestRolePermissionMigrationTest 增加“严格模式”:
      • -Dmigration.test.strict=trueSTRICT_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 Fixedprod 环境误开即阻断)

更新后结论: 21~28 问题已完成代码级闭环;当前仅剩“在具备容器能力的 CI/环境执行一次严格模式迁移全绿”这一运行环境前提事项。


🆕 2026-03-19 当前环境严格模式迁移复验(最终闭环)

复验目标: 在当前执行环境打通 JNA 临时目录 + Podman 运行时,验证严格模式迁移测试真实执行且不再跳过。
本轮使用 Skills: systematic-debuggingtesting-integrationverification-before-completion

本轮关键修复(迁移脚本)

  1. PostgreSQL 方言改造与幂等修复:
    • V27__Add_simplified_permission_codes.sqlMERGE/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.sqlUPDATE ... INNER JOIN -> UPDATE ... FROM
    • V53__Fix_approval_flow_table_and_backfill.sqlNOW(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
    • 结果:FlywayMigrationSmokeTestRolePermissionMigrationTest 全通过,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 项:CallbackControllerIntegrationTest1 个用例)
  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=1CallbackControllerIntegrationTest)。

根因定位

  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.ymlbuild_test 阶段改为统一调用 ./scripts/ci/backend-verify.sh
    • 新增并增强 scripts/ci/backend-verify.sh
      • 创建 tmp/jnatmp/java
      • 同命令拉起 podman system service
      • 导出 DOCKER_HOSTTESTCONTAINERS_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 不再直接对 userIdLong.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 容错”问题已完成最小修复并附带单测闭环,当前不再是待办项。