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

1241 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🦟 蚊子项目代码审查报告 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. **问题 25demo 回退误开风险)**
- 先通过 `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 | ✅ Fixedprod 环境误开即阻断) |
**更新后结论**: 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 容错”问题已完成最小修复并附带单测闭环,当前不再是待办项。