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

**修复方案**:
```java
@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);
        })
```

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

---

### 3. 速率限制可被绕过

**位置**: `RateLimitInterceptor.java:17-44`

```java
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  
**影响**: 多实例部署时无法正确限流

**修复方案**:
```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<UserInviteEntity, Long> {
    List<UserInviteEntity> findByActivityId(Long activityId);
    List<UserInviteEntity> 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<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聚合:

```java
@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`

```java
@Cacheable(value = "leaderboards", key = "#activityId")
public List<LeaderboardEntry> 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<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`

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

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

**建议**: 统一响应格式

```java
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`

```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<String> 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<LeaderboardEntry> getLeaderboard(Long activityId) {
    if (!activityRepository.existsById(activityId)) {  // 重复
        throw new ActivityNotFoundException("活动不存在。");
    }
    // ...
}
```

**修复方案**:
```java
public List<LeaderboardEntry> 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<ActivityEntity, Long> {
}
```

---

## 🟢 低优先级改进建议

### 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<Activity> 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<RewardJob> jobs) {
    List<RewardJobEntity> 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 容错”问题已完成最小修复并附带单测闭环，当前不再是待办项。
