docs: 完善项目文档并清理过时文件

新增文档:
- API_INTEGRATION_GUIDE.md: API集成指南(快速开始、SDK示例、常见场景)
- DEPLOYMENT_GUIDE.md: 部署指南(环境要求、生产部署、Docker部署)
- CONFIGURATION_GUIDE.md: 配置指南(环境配置、数据库、Redis、安全)
- DEVELOPMENT_GUIDE.md: 开发指南(环境搭建、项目结构、开发规范)

文档更新:
- api.md: 补充8个缺失的API端点(分享跟踪、回调、用户奖励)

文档清理:
- 归档18个过时文档到 docs/archive/2026-03-04-cleanup/
- 删除3个调试文档(ralph-loop-*)

代码清理:
- 删除4个.bak备份文件
- 删除1个.disabled测试文件

文档结构优化:
- 从~40个文档精简到12个核心文档
- 建立清晰的文档导航体系
- 完善文档间的交叉引用
This commit is contained in:
Your Name
2026-03-04 10:41:38 +08:00
parent e79d69f0af
commit 0eed01e9eb
31 changed files with 3229 additions and 1476 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,682 @@
# 🔌 API集成指南
> 版本: 1.0
> 更新时间: 2026-03-04
## 📋 目录
1. [快速开始](#快速开始)
2. [认证配置](#认证配置)
3. [SDK集成](#sdk集成)
4. [常见场景](#常见场景)
5. [错误处理](#错误处理)
6. [最佳实践](#最佳实践)
## 🚀 快速开始
### 前置条件
- 已获取API密钥通过管理后台创建
- 已获取Bearer Token用于用户相关接口
- 了解基本的RESTful API概念
### 5分钟快速集成
```javascript
// 1. 配置API客户端
const API_BASE_URL = 'https://api.example.com';
const API_KEY = 'your-api-key-here';
const BEARER_TOKEN = 'your-bearer-token-here';
// 2. 创建活动
async function createActivity() {
const response = await fetch(`${API_BASE_URL}/api/v1/activities`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Authorization': `Bearer ${BEARER_TOKEN}`
},
body: JSON.stringify({
name: '春季特惠活动',
startTime: '2025-03-01T10:00:00+08:00',
endTime: '2025-03-31T23:59:59+08:00'
})
});
const result = await response.json();
console.log('活动创建成功:', result.data);
return result.data;
}
// 3. 获取排行榜
async function getLeaderboard(activityId) {
const response = await fetch(
`${API_BASE_URL}/api/v1/activities/${activityId}/leaderboard?page=0&size=20`,
{
headers: {
'X-API-Key': API_KEY,
'Authorization': `Bearer ${BEARER_TOKEN}`
}
}
);
const result = await response.json();
console.log('排行榜数据:', result.data);
return result.data;
}
// 4. 创建分享跟踪
async function trackShare(activityId, userId) {
const response = await fetch(`${API_BASE_URL}/api/v1/share/track`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Authorization': `Bearer ${BEARER_TOKEN}`
},
body: JSON.stringify({
activityId: activityId,
inviterUserId: userId,
source: 'wechat'
})
});
const result = await response.json();
console.log('分享跟踪创建成功:', result.data);
return result.data;
}
```
## 🔐 认证配置
### API密钥认证
所有 `/api/**` 端点都需要 `X-API-Key` 请求头:
```http
GET /api/v1/activities/1
X-API-Key: a1b2c3d4-e5f6-7890-1234-567890abcdef
```
**获取API密钥**
```bash
curl -X POST https://api.example.com/api/v1/api-keys \
-H "Content-Type: application/json" \
-H "X-API-Key: admin-key" \
-H "Authorization: Bearer admin-token" \
-d '{
"activityId": 1,
"name": "我的应用密钥"
}'
```
### Bearer Token认证
用户相关端点需要 `Authorization` 请求头:
```http
GET /api/v1/me/invitation-info
X-API-Key: a1b2c3d4-e5f6-7890-1234-567890abcdef
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### 双重认证
某些端点需要同时提供API密钥和Bearer Token
```javascript
const headers = {
'X-API-Key': API_KEY,
'Authorization': `Bearer ${BEARER_TOKEN}`,
'Content-Type': 'application/json'
};
```
## 📦 SDK集成
### Java SDK
```xml
<!-- Maven依赖 -->
<dependency>
<groupId>com.mosquito</groupId>
<artifactId>mosquito-sdk</artifactId>
<version>1.0.0</version>
</dependency>
```
```java
// 初始化客户端
MosquitoClient client = MosquitoClient.builder()
.baseUrl("https://api.example.com")
.apiKey("your-api-key")
.bearerToken("your-bearer-token")
.build();
// 创建活动
Activity activity = client.activities()
.create(CreateActivityRequest.builder()
.name("春季特惠活动")
.startTime(ZonedDateTime.now())
.endTime(ZonedDateTime.now().plusDays(30))
.build());
// 获取排行榜
List<LeaderboardEntry> leaderboard = client.activities()
.getLeaderboard(activityId, 0, 20);
// 创建分享跟踪
ShareTrackingResponse tracking = client.share()
.track(activityId, userId, "wechat");
```
### JavaScript/TypeScript SDK
```bash
npm install @mosquito/sdk
```
```typescript
import { MosquitoClient } from '@mosquito/sdk';
// 初始化客户端
const client = new MosquitoClient({
baseUrl: 'https://api.example.com',
apiKey: 'your-api-key',
bearerToken: 'your-bearer-token'
});
// 创建活动
const activity = await client.activities.create({
name: '春季特惠活动',
startTime: new Date('2025-03-01T10:00:00+08:00'),
endTime: new Date('2025-03-31T23:59:59+08:00')
});
// 获取排行榜
const leaderboard = await client.activities.getLeaderboard(activityId, {
page: 0,
size: 20
});
// 创建分享跟踪
const tracking = await client.share.track({
activityId,
inviterUserId: userId,
source: 'wechat'
});
```
### Python SDK
```bash
pip install mosquito-sdk
```
```python
from mosquito import MosquitoClient
# 初始化客户端
client = MosquitoClient(
base_url='https://api.example.com',
api_key='your-api-key',
bearer_token='your-bearer-token'
)
# 创建活动
activity = client.activities.create(
name='春季特惠活动',
start_time='2025-03-01T10:00:00+08:00',
end_time='2025-03-31T23:59:59+08:00'
)
# 获取排行榜
leaderboard = client.activities.get_leaderboard(
activity_id=activity_id,
page=0,
size=20
)
# 创建分享跟踪
tracking = client.share.track(
activity_id=activity_id,
inviter_user_id=user_id,
source='wechat'
)
```
## 🎯 常见场景
### 场景1用户邀请流程
```javascript
// 1. 用户登录后获取邀请信息
async function getUserInvitationInfo(activityId, userId) {
const response = await fetch(
`${API_BASE_URL}/api/v1/me/invitation-info?activityId=${activityId}&userId=${userId}`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data; // { code, path, originalUrl }
}
// 2. 生成分享海报
async function generatePoster(activityId, userId, template = 'default') {
const imageUrl = `${API_BASE_URL}/api/v1/me/poster/image?activityId=${activityId}&userId=${userId}&template=${template}`;
return imageUrl;
}
// 3. 用户分享后创建跟踪
async function trackUserShare(activityId, userId, source) {
const response = await fetch(`${API_BASE_URL}/api/v1/share/track`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Authorization': `Bearer ${BEARER_TOKEN}`
},
body: JSON.stringify({ activityId, inviterUserId: userId, source })
});
return await response.json();
}
// 4. 查看邀请的好友列表
async function getInvitedFriends(activityId, userId, page = 0) {
const response = await fetch(
`${API_BASE_URL}/api/v1/me/invited-friends?activityId=${activityId}&userId=${userId}&page=${page}&size=20`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data;
}
// 5. 查看用户奖励
async function getUserRewards(activityId, userId, page = 0) {
const response = await fetch(
`${API_BASE_URL}/api/v1/me/rewards?activityId=${activityId}&userId=${userId}&page=${page}&size=20`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data;
}
```
### 场景2活动数据分析
```javascript
// 1. 获取活动统计数据
async function getActivityStats(activityId) {
const response = await fetch(
`${API_BASE_URL}/api/v1/activities/${activityId}/stats`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data; // { totalParticipants, totalShares, dailyStats }
}
// 2. 获取裂变网络图
async function getActivityGraph(activityId, rootUserId = null, maxDepth = 3) {
const params = new URLSearchParams({
...(rootUserId && { rootUserId }),
maxDepth,
limit: 1000
});
const response = await fetch(
`${API_BASE_URL}/api/v1/activities/${activityId}/graph?${params}`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data; // { nodes, edges }
}
// 3. 获取分享指标
async function getShareMetrics(activityId, startTime, endTime) {
const params = new URLSearchParams({
activityId,
...(startTime && { startTime }),
...(endTime && { endTime })
});
const response = await fetch(
`${API_BASE_URL}/api/v1/share/metrics?${params}`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data;
}
// 4. 获取转化漏斗
async function getConversionFunnel(activityId, startTime, endTime) {
const params = new URLSearchParams({
activityId,
...(startTime && { startTime }),
...(endTime && { endTime })
});
const response = await fetch(
`${API_BASE_URL}/api/v1/share/funnel?${params}`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const result = await response.json();
return result.data;
}
// 5. 导出排行榜CSV
async function exportLeaderboard(activityId, topN = null) {
const params = new URLSearchParams({
...(topN && { topN })
});
const response = await fetch(
`${API_BASE_URL}/api/v1/activities/${activityId}/leaderboard/export?${params}`,
{ headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${BEARER_TOKEN}` } }
);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `leaderboard_${activityId}.csv`;
a.click();
}
```
### 场景3Webhook回调集成
```javascript
// 1. 注册Webhook
async function registerWebhook(activityId, callbackUrl, events) {
const response = await fetch(`${API_BASE_URL}/api/v1/callback/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Authorization': `Bearer ${BEARER_TOKEN}`
},
body: JSON.stringify({
activityId,
callbackUrl,
events, // ['user.registered', 'user.invited', 'reward.granted']
secret: 'your-webhook-secret'
})
});
return await response.json();
}
// 2. 处理Webhook回调服务端
const express = require('express');
const crypto = require('crypto');
app.post('/webhook', express.json(), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = JSON.stringify(req.body);
const secret = 'your-webhook-secret';
// 验证签名
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (signature !== `sha256=${expectedSignature}`) {
return res.status(401).send('Invalid signature');
}
// 处理事件
const { eventType, data } = req.body;
switch (eventType) {
case 'user.registered':
console.log('新用户注册:', data);
break;
case 'user.invited':
console.log('用户邀请:', data);
break;
case 'reward.granted':
console.log('奖励发放:', data);
break;
}
res.status(200).send('OK');
});
```
## ⚠️ 错误处理
### 统一错误处理
```javascript
class APIError extends Error {
constructor(code, message, details) {
super(message);
this.code = code;
this.details = details;
}
}
async function apiCall(url, options) {
try {
const response = await fetch(url, options);
const result = await response.json();
if (result.code !== 200 && result.code !== 201) {
throw new APIError(
result.error?.code || 'UNKNOWN_ERROR',
result.message || 'Unknown error',
result.error?.details
);
}
return result.data;
} catch (error) {
if (error instanceof APIError) {
throw error;
}
throw new APIError('NETWORK_ERROR', 'Network request failed', error);
}
}
// 使用示例
try {
const activity = await apiCall(`${API_BASE_URL}/api/v1/activities/1`, {
headers: { 'X-API-Key': API_KEY }
});
console.log('活动数据:', activity);
} catch (error) {
if (error.code === 'NOT_FOUND') {
console.error('活动不存在');
} else if (error.code === 'INVALID_API_KEY') {
console.error('API密钥无效');
} else {
console.error('请求失败:', error.message);
}
}
```
### 重试机制
```javascript
async function apiCallWithRetry(url, options, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
// 处理速率限制
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
console.log(`速率限制,等待 ${retryAfter} 秒后重试...`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
// 处理服务器错误
if (response.status >= 500) {
console.log(`服务器错误,${i + 1}/${maxRetries} 次重试...`);
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
continue;
}
return await response.json();
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
console.log(`网络错误,${i + 1}/${maxRetries} 次重试...`);
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
}
throw lastError;
}
```
## 💡 最佳实践
### 1. 使用连接池
```javascript
// Node.js
const https = require('https');
const agent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000
});
const response = await fetch(url, {
agent,
headers: { 'X-API-Key': API_KEY }
});
```
### 2. 实现请求缓存
```javascript
const cache = new Map();
const CACHE_TTL = 60000; // 1分钟
async function cachedApiCall(url, options, ttl = CACHE_TTL) {
const cacheKey = `${url}:${JSON.stringify(options)}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
const data = await apiCall(url, options);
cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
```
### 3. 批量请求优化
```javascript
async function batchGetActivities(activityIds) {
// 并发请求,但限制并发数
const BATCH_SIZE = 10;
const results = [];
for (let i = 0; i < activityIds.length; i += BATCH_SIZE) {
const batch = activityIds.slice(i, i + BATCH_SIZE);
const promises = batch.map(id =>
apiCall(`${API_BASE_URL}/api/v1/activities/${id}`, {
headers: { 'X-API-Key': API_KEY }
})
);
const batchResults = await Promise.all(promises);
results.push(...batchResults);
}
return results;
}
```
### 4. 监控和日志
```javascript
function logApiCall(url, options, duration, result) {
console.log({
timestamp: new Date().toISOString(),
url,
method: options.method || 'GET',
duration: `${duration}ms`,
status: result.code,
success: result.code === 200 || result.code === 201
});
}
async function monitoredApiCall(url, options) {
const startTime = Date.now();
try {
const result = await apiCall(url, options);
logApiCall(url, options, Date.now() - startTime, result);
return result;
} catch (error) {
logApiCall(url, options, Date.now() - startTime, { code: error.code, error: error.message });
throw error;
}
}
```
### 5. 安全最佳实践
```javascript
// ❌ 不要在客户端暴露API密钥
// const API_KEY = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
// ✅ 通过后端代理API请求
async function proxyApiCall(endpoint, options) {
const response = await fetch(`/api/proxy${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${userToken}` // 只传用户token
}
});
return await response.json();
}
// 后端代理Node.js/Express
app.use('/api/proxy', async (req, res) => {
const response = await fetch(`${API_BASE_URL}${req.path}`, {
method: req.method,
headers: {
'X-API-Key': process.env.API_KEY, // 从环境变量读取
'Authorization': req.headers.authorization,
'Content-Type': 'application/json'
},
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined
});
const result = await response.json();
res.json(result);
});
```
## 📚 相关资源
- [API文档](./api.md) - 完整的API端点参考
- [部署指南](./DEPLOYMENT_GUIDE.md) - 如何部署应用
- [配置指南](./CONFIGURATION_GUIDE.md) - 配置选项说明
- [开发指南](./DEVELOPMENT_GUIDE.md) - 如何参与开发
## 🆘 获取帮助
- **技术支持**: support@example.com
- **问题反馈**: https://github.com/your-org/mosquito/issues
- **API状态**: https://status.example.com
---
**文档版本**: 1.0
**最后更新**: 2026-03-04

721
docs/CONFIGURATION_GUIDE.md Normal file
View File

@@ -0,0 +1,721 @@
# ⚙️ 配置指南
> 版本: 1.0
> 更新时间: 2026-03-04
## 📋 目录
1. [配置文件结构](#配置文件结构)
2. [环境配置](#环境配置)
3. [数据库配置](#数据库配置)
4. [Redis配置](#redis配置)
5. [安全配置](#安全配置)
6. [性能配置](#性能配置)
7. [日志配置](#日志配置)
8. [环境变量](#环境变量)
## 📁 配置文件结构
```
src/main/resources/
├── application.properties # 主配置文件
├── application-dev.yml # 开发环境配置
├── application-test.yml # 测试环境配置
├── application-prod.yml # 生产环境配置
├── application-e2e.properties # E2E测试配置
└── logback-spring.xml # 日志配置
```
### 配置优先级
1. 命令行参数 (`--spring.datasource.url=...`)
2. 环境变量 (`SPRING_DATASOURCE_URL`)
3. 外部配置文件 (`/opt/mosquito/application-prod.yml`)
4. 内部配置文件 (`classpath:application-prod.yml`)
5. 默认配置 (`application.properties`)
## 🌍 环境配置
### 开发环境 (dev)
`application-dev.yml`:
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mosquito_dev
username: mosquito
password: dev_password
hikari:
maximum-pool-size: 10
minimum-idle: 2
data:
redis:
host: localhost
port: 6379
password: dev_redis_password
flyway:
enabled: true
baseline-on-migrate: true
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
app:
api-key:
encryption-key: dev_32_char_encryption_key_12
rate-limit:
per-minute: 1000
poster:
cache-enabled: false
logging:
level:
root: INFO
com.mosquito.project: DEBUG
org.springframework.web: DEBUG
```
### 测试环境 (test)
`application-test.yml`:
```yaml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
data:
redis:
host: localhost
port: 6379
flyway:
enabled: true
jpa:
hibernate:
ddl-auto: validate
app:
api-key:
encryption-key: test_32_char_encryption_key_12
rate-limit:
per-minute: 10000
poster:
cache-enabled: false
logging:
level:
root: WARN
com.mosquito.project: INFO
```
### 生产环境 (prod)
`application-prod.yml`:
```yaml
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/mosquito_prod}
username: ${DB_USERNAME:mosquito_prod}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
timeout: 3000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 3000ms
flyway:
enabled: true
baseline-on-migrate: true
validate-on-migrate: true
jpa:
show-sql: false
properties:
hibernate:
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
app:
api-key:
encryption-key: ${API_KEY_ENCRYPTION_KEY}
rate-limit:
per-minute: ${RATE_LIMIT_PER_MINUTE:100}
poster:
cache-enabled: true
cache-ttl: 3600
logging:
level:
root: INFO
com.mosquito.project: INFO
file:
name: /var/log/mosquito/application.log
max-size: 100MB
max-history: 30
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
```
## 🗄️ 数据库配置
### PostgreSQL连接池
```yaml
spring:
datasource:
hikari:
# 最大连接数推荐CPU核心数 * 2 + 磁盘数)
maximum-pool-size: 20
# 最小空闲连接数
minimum-idle: 5
# 连接超时(毫秒)
connection-timeout: 30000
# 空闲超时(毫秒)
idle-timeout: 600000
# 连接最大生命周期(毫秒)
max-lifetime: 1800000
# 连接泄漏检测阈值(毫秒)
leak-detection-threshold: 60000
# 连接测试查询
connection-test-query: SELECT 1
```
### Flyway迁移配置
```yaml
spring:
flyway:
# 启用Flyway
enabled: true
# 迁移脚本位置
locations: classpath:db/migration
# 基线版本
baseline-version: 1
# 在迁移时创建基线
baseline-on-migrate: true
# 验证迁移
validate-on-migrate: true
# 清理数据库(生产环境禁用)
clean-disabled: true
# 占位符
placeholders:
schema: public
```
### JPA/Hibernate配置
```yaml
spring:
jpa:
# 显示SQL仅开发环境
show-sql: false
# DDL策略生产环境使用validate
hibernate:
ddl-auto: validate
properties:
hibernate:
# SQL格式化
format_sql: true
# 批量操作
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
# 二级缓存
cache:
use_second_level_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
# 查询缓存
cache:
use_query_cache: true
```
## 🔴 Redis配置
### 基础配置
```yaml
spring:
data:
redis:
# Redis服务器地址
host: ${REDIS_HOST:localhost}
# Redis端口
port: ${REDIS_PORT:6379}
# Redis密码
password: ${REDIS_PASSWORD}
# 数据库索引
database: 0
# 连接超时
timeout: 3000ms
# Lettuce连接池
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 3000ms
# 关闭超时
shutdown-timeout: 100ms
```
### 缓存配置
```yaml
spring:
cache:
type: redis
redis:
# 缓存TTL毫秒
time-to-live: 3600000
# 缓存null值
cache-null-values: false
# 键前缀
key-prefix: "mosquito:"
# 使用键前缀
use-key-prefix: true
app:
cache:
# 活动缓存TTL
activity-ttl: 3600
# 统计缓存TTL
stats-ttl: 300
# 排行榜缓存TTL
leaderboard-ttl: 60
```
### Redis Sentinel配置高可用
```yaml
spring:
data:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.1.10:26379
- 192.168.1.11:26379
- 192.168.1.12:26379
password: ${REDIS_PASSWORD}
```
### Redis Cluster配置集群
```yaml
spring:
data:
redis:
cluster:
nodes:
- 192.168.1.10:6379
- 192.168.1.11:6379
- 192.168.1.12:6379
- 192.168.1.13:6379
- 192.168.1.14:6379
- 192.168.1.15:6379
max-redirects: 3
password: ${REDIS_PASSWORD}
```
## 🔐 安全配置
### API密钥加密
```yaml
app:
api-key:
# 加密密钥必须32字符
encryption-key: ${API_KEY_ENCRYPTION_KEY}
# PBKDF2迭代次数
pbkdf2-iterations: 10000
# 密钥长度
key-length: 256
```
生成加密密钥:
```bash
# 生成32字符随机密钥
openssl rand -base64 24 | head -c 32
```
### 速率限制
```yaml
app:
rate-limit:
# 每分钟请求限制
per-minute: ${RATE_LIMIT_PER_MINUTE:100}
# 限流键前缀
key-prefix: "rate_limit:"
# 限流窗口(秒)
window-seconds: 60
```
### CORS配置
```yaml
app:
cors:
# 允许的源
allowed-origins:
- https://example.com
- https://www.example.com
# 允许的方法
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
# 允许的头
allowed-headers:
- "*"
# 暴露的头
exposed-headers:
- X-API-Version
- X-RateLimit-Remaining
# 允许凭证
allow-credentials: true
# 预检请求缓存时间(秒)
max-age: 3600
```
## ⚡ 性能配置
### 线程池配置
```yaml
spring:
task:
execution:
pool:
# 核心线程数
core-size: 8
# 最大线程数
max-size: 16
# 队列容量
queue-capacity: 100
# 线程名前缀
thread-name-prefix: "async-"
# 空闲线程存活时间(秒)
keep-alive: 60s
```
### HTTP客户端配置
```yaml
app:
http-client:
# 连接超时(毫秒)
connect-timeout: 5000
# 读取超时(毫秒)
read-timeout: 10000
# 最大连接数
max-connections: 100
# 每个路由的最大连接数
max-connections-per-route: 20
```
### 海报生成配置
```yaml
app:
poster:
# 启用缓存
cache-enabled: true
# 缓存TTL
cache-ttl: 3600
# 图片质量0.0-1.0
image-quality: 0.9
# 图片格式
image-format: PNG
# 最大宽度
max-width: 1080
# 最大高度
max-height: 1920
```
## 📝 日志配置
### Logback配置
`logback-spring.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 开发环境 -->
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
<logger name="com.mosquito.project" level="DEBUG" />
<logger name="org.springframework.web" level="DEBUG" />
</springProfile>
<!-- 生产环境 -->
<springProfile name="prod">
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/mosquito/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/mosquito/application.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 错误日志单独输出 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/mosquito/error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/mosquito/error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
<logger name="com.mosquito.project" level="INFO" />
</springProfile>
</configuration>
```
### 日志级别
```yaml
logging:
level:
# 根日志级别
root: INFO
# 应用日志
com.mosquito.project: INFO
# Spring框架
org.springframework: INFO
org.springframework.web: INFO
org.springframework.security: INFO
# Hibernate
org.hibernate: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
# Redis
org.springframework.data.redis: INFO
# Flyway
org.flywaydb: INFO
```
## 🌐 环境变量
### 必需环境变量
```bash
# 数据库配置
export DB_URL="jdbc:postgresql://localhost:5432/mosquito_prod"
export DB_USERNAME="mosquito_prod"
export DB_PASSWORD="your_secure_password"
# Redis配置
export REDIS_HOST="localhost"
export REDIS_PORT="6379"
export REDIS_PASSWORD="your_redis_password"
# 安全配置
export API_KEY_ENCRYPTION_KEY="your_32_char_encryption_key_12"
```
### 可选环境变量
```bash
# 速率限制
export RATE_LIMIT_PER_MINUTE="100"
# 日志配置
export LOG_LEVEL="INFO"
export LOG_FILE="/var/log/mosquito/application.log"
# JVM配置
export JAVA_OPTS="-Xms2g -Xmx4g -XX:+UseG1GC"
# Spring配置
export SPRING_PROFILES_ACTIVE="prod"
export SERVER_PORT="8080"
```
### 环境变量文件
创建 `.env` 文件不要提交到Git
```bash
# .env
DB_PASSWORD=your_secure_password
REDIS_PASSWORD=your_redis_password
API_KEY_ENCRYPTION_KEY=your_32_char_encryption_key_12
RATE_LIMIT_PER_MINUTE=100
```
加载环境变量:
```bash
# 使用source加载
source .env
# 或使用export
export $(cat .env | xargs)
```
## 🔧 配置验证
### 启动时验证
```java
@Configuration
public class ConfigValidation {
@Value("${app.api-key.encryption-key}")
private String encryptionKey;
@PostConstruct
public void validate() {
if (encryptionKey.length() != 32) {
throw new IllegalStateException(
"API key encryption key must be exactly 32 characters"
);
}
}
}
```
### 配置检查命令
```bash
# 检查配置文件语法
java -jar mosquito-1.0.0.jar --spring.config.location=application-prod.yml --spring.profiles.active=prod --debug
# 查看实际配置
java -jar mosquito-1.0.0.jar --spring.profiles.active=prod \
--spring.boot.admin.client.enabled=false \
org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener
```
## 📚 相关资源
- [部署指南](./DEPLOYMENT_GUIDE.md) - 部署说明
- [API文档](./api.md) - API接口文档
- [开发指南](./DEVELOPMENT_GUIDE.md) - 开发环境搭建
---
**文档版本**: 1.0
**最后更新**: 2026-03-04

665
docs/DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,665 @@
# 🚀 部署指南
> 版本: 1.0
> 更新时间: 2026-03-04
## 📋 目录
1. [环境要求](#环境要求)
2. [快速部署](#快速部署)
3. [生产环境部署](#生产环境部署)
4. [Docker部署](#docker部署)
5. [数据库迁移](#数据库迁移)
6. [监控与日志](#监控与日志)
7. [故障排查](#故障排查)
## 🔧 环境要求
### 最低要求
| 组件 | 版本要求 | 说明 |
|------|----------|------|
| Java | 17+ | 推荐使用OpenJDK 17或21 |
| PostgreSQL | 12+ | 推荐使用14或15 |
| Redis | 6.0+ | 生产环境必需 |
| Maven | 3.8+ | 构建工具 |
| 内存 | 2GB+ | 推荐4GB |
| 磁盘 | 10GB+ | 包含日志和数据 |
### 推荐配置
**开发环境:**
- CPU: 2核
- 内存: 4GB
- 磁盘: 20GB SSD
**生产环境:**
- CPU: 4核+
- 内存: 8GB+
- 磁盘: 50GB+ SSD
- 负载均衡器(可选)
- 数据库主从复制(推荐)
## ⚡ 快速部署
### 1. 克隆代码
```bash
git clone https://github.com/your-org/mosquito.git
cd mosquito
```
### 2. 配置数据库
```bash
# 创建数据库
psql -U postgres -c "CREATE DATABASE mosquito;"
psql -U postgres -c "CREATE USER mosquito_user WITH PASSWORD 'your_password';"
psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE mosquito TO mosquito_user;"
```
### 3. 配置环境变量
```bash
# 复制配置模板
cp src/main/resources/application-dev.yml.example src/main/resources/application-dev.yml
# 编辑配置文件
vi src/main/resources/application-dev.yml
```
### 4. 构建项目
```bash
# 跳过测试快速构建
mvn clean package -DskipTests
# 或者运行完整测试
mvn clean package
```
### 5. 启动应用
```bash
java -jar target/mosquito-1.0.0.jar --spring.profiles.active=dev
```
应用将在 `http://localhost:8080` 启动。
## 🏭 生产环境部署
### 1. 准备工作
**创建专用用户:**
```bash
sudo useradd -r -s /bin/false mosquito
sudo mkdir -p /opt/mosquito
sudo chown mosquito:mosquito /opt/mosquito
```
**配置PostgreSQL**
```sql
-- 创建生产数据库
CREATE DATABASE mosquito_prod;
CREATE USER mosquito_prod WITH PASSWORD 'strong_password_here';
GRANT ALL PRIVILEGES ON DATABASE mosquito_prod TO mosquito_prod;
-- 配置连接池
ALTER SYSTEM SET max_connections = 200;
ALTER SYSTEM SET shared_buffers = '2GB';
ALTER SYSTEM SET effective_cache_size = '6GB';
SELECT pg_reload_conf();
```
**配置Redis**
```bash
# 编辑Redis配置
sudo vi /etc/redis/redis.conf
# 设置密码
requirepass your_redis_password
# 设置最大内存
maxmemory 1gb
maxmemory-policy allkeys-lru
# 启用持久化
save 900 1
save 300 10
save 60 10000
# 重启Redis
sudo systemctl restart redis
```
### 2. 配置应用
创建 `/opt/mosquito/application-prod.yml`
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mosquito_prod
username: mosquito_prod
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD}
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
flyway:
enabled: true
baseline-on-migrate: true
app:
api-key:
encryption-key: ${API_KEY_ENCRYPTION_KEY}
rate-limit:
per-minute: 100
poster:
cache-enabled: true
logging:
level:
root: INFO
com.mosquito.project: INFO
file:
name: /var/log/mosquito/application.log
max-size: 100MB
max-history: 30
```
### 3. 创建systemd服务
创建 `/etc/systemd/system/mosquito.service`
```ini
[Unit]
Description=Mosquito Activity Tracking Service
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=mosquito
Group=mosquito
WorkingDirectory=/opt/mosquito
Environment="JAVA_OPTS=-Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
Environment="DB_PASSWORD=your_db_password"
Environment="REDIS_PASSWORD=your_redis_password"
Environment="API_KEY_ENCRYPTION_KEY=your_32_char_encryption_key"
ExecStart=/usr/bin/java $JAVA_OPTS \
-jar /opt/mosquito/mosquito-1.0.0.jar \
--spring.profiles.active=prod \
--spring.config.location=/opt/mosquito/application-prod.yml
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
### 4. 部署应用
```bash
# 复制JAR文件
sudo cp target/mosquito-1.0.0.jar /opt/mosquito/
sudo chown mosquito:mosquito /opt/mosquito/mosquito-1.0.0.jar
# 创建日志目录
sudo mkdir -p /var/log/mosquito
sudo chown mosquito:mosquito /var/log/mosquito
# 重新加载systemd
sudo systemctl daemon-reload
# 启动服务
sudo systemctl start mosquito
# 设置开机自启
sudo systemctl enable mosquito
# 检查状态
sudo systemctl status mosquito
```
### 5. 配置Nginx反向代理
创建 `/etc/nginx/sites-available/mosquito`
```nginx
upstream mosquito_backend {
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
# 重定向到HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
access_log /var/log/nginx/mosquito_access.log;
error_log /var/log/nginx/mosquito_error.log;
client_max_body_size 10M;
location / {
proxy_pass http://mosquito_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
location /actuator/health {
proxy_pass http://mosquito_backend;
access_log off;
}
}
```
启用配置:
```bash
sudo ln -s /etc/nginx/sites-available/mosquito /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
## 🐳 Docker部署
### 1. Dockerfile
创建 `Dockerfile`
```dockerfile
FROM eclipse-temurin:17-jre-alpine
LABEL maintainer="your-email@example.com"
LABEL version="1.0.0"
# 创建应用目录
WORKDIR /app
# 复制JAR文件
COPY target/mosquito-1.0.0.jar app.jar
# 创建非root用户
RUN addgroup -S mosquito && adduser -S mosquito -G mosquito
RUN chown -R mosquito:mosquito /app
USER mosquito
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
# 暴露端口
EXPOSE 8080
# JVM参数
ENV JAVA_OPTS="-Xms512m -Xmx1g -XX:+UseG1GC"
# 启动命令
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
```
### 2. Docker Compose
创建 `docker-compose.yml`
```yaml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: mosquito-postgres
environment:
POSTGRES_DB: mosquito
POSTGRES_USER: mosquito
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mosquito"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: mosquito-redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
app:
build: .
container_name: mosquito-app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mosquito
SPRING_DATASOURCE_USERNAME: mosquito
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY}
ports:
- "8080:8080"
volumes:
- app_logs:/app/logs
restart: unless-stopped
volumes:
postgres_data:
redis_data:
app_logs:
```
### 3. 启动Docker环境
```bash
# 创建.env文件
cat > .env << EOF
DB_PASSWORD=your_db_password
REDIS_PASSWORD=your_redis_password
API_KEY_ENCRYPTION_KEY=your_32_char_encryption_key
EOF
# 构建镜像
docker-compose build
# 启动服务
docker-compose up -d
# 查看日志
docker-compose logs -f app
# 检查状态
docker-compose ps
```
## 🗄️ 数据库迁移
### Flyway自动迁移
应用启动时会自动执行Flyway迁移
```yaml
spring:
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
```
### 手动迁移
```bash
# 查看迁移状态
mvn flyway:info
# 执行迁移
mvn flyway:migrate
# 回滚需要Flyway Pro
mvn flyway:undo
```
### 迁移脚本位置
```
src/main/resources/db/migration/
├── V1__Create_activities_table.sql
├── V2__Create_api_keys_table.sql
├── V3__Create_daily_activity_stats_table.sql
├── ...
└── V20__Add_share_tracking_fields.sql
```
### 生产环境迁移最佳实践
1. **备份数据库**
```bash
pg_dump -U mosquito_prod -h localhost mosquito_prod > backup_$(date +%Y%m%d_%H%M%S).sql
```
2. **在测试环境验证**
```bash
# 恢复到测试环境
psql -U mosquito_test -h localhost mosquito_test < backup.sql
# 运行迁移
mvn flyway:migrate -Dflyway.url=jdbc:postgresql://localhost:5432/mosquito_test
```
3. **执行生产迁移**
```bash
# 停止应用(可选,取决于迁移类型)
sudo systemctl stop mosquito
# 执行迁移
mvn flyway:migrate -Dflyway.url=jdbc:postgresql://localhost:5432/mosquito_prod
# 启动应用
sudo systemctl start mosquito
```
## 📊 监控与日志
### Spring Boot Actuator
启用健康检查和指标:
```yaml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
```
访问端点:
- 健康检查: `http://localhost:8080/actuator/health`
- 指标: `http://localhost:8080/actuator/metrics`
- Prometheus: `http://localhost:8080/actuator/prometheus`
### 日志配置
`logback-spring.xml`
```xml
<configuration>
<springProfile name="prod">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/mosquito/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/mosquito/application.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE" />
</root>
</springProfile>
</configuration>
```
### 监控工具集成
**Prometheus + Grafana**
```yaml
# prometheus.yml
scrape_configs:
- job_name: 'mosquito'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['localhost:8080']
```
## 🔍 故障排查
### 常见问题
**1. 应用无法启动**
```bash
# 检查日志
sudo journalctl -u mosquito -n 100 --no-pager
# 检查端口占用
sudo netstat -tlnp | grep 8080
# 检查数据库连接
psql -U mosquito_prod -h localhost -d mosquito_prod -c "SELECT 1;"
```
**2. Redis连接失败**
```bash
# 检查Redis状态
sudo systemctl status redis
# 测试连接
redis-cli -a your_redis_password ping
# 检查配置
redis-cli -a your_redis_password CONFIG GET requirepass
```
**3. 数据库迁移失败**
```bash
# 查看Flyway状态
mvn flyway:info
# 修复失败的迁移
mvn flyway:repair
# 手动执行SQL
psql -U mosquito_prod -h localhost -d mosquito_prod -f src/main/resources/db/migration/V20__xxx.sql
```
**4. 内存不足**
```bash
# 查看JVM内存使用
jcmd <pid> VM.native_memory summary
# 调整JVM参数
sudo vi /etc/systemd/system/mosquito.service
# 修改: Environment="JAVA_OPTS=-Xms2g -Xmx4g"
sudo systemctl daemon-reload
sudo systemctl restart mosquito
```
### 性能调优
**JVM参数优化**
```bash
JAVA_OPTS="
-Xms2g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/mosquito/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/mosquito/gc.log
"
```
**数据库连接池:**
```yaml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
```
## 📚 相关资源
- [配置指南](./CONFIGURATION_GUIDE.md) - 详细配置说明
- [API文档](./api.md) - API接口文档
- [开发指南](./DEVELOPMENT_GUIDE.md) - 开发环境搭建
---
**文档版本**: 1.0
**最后更新**: 2026-03-04

770
docs/DEVELOPMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,770 @@
# 🛠️ 开发指南
> 版本: 1.0
> 更新时间: 2026-03-04
## 📋 目录
1. [开发环境搭建](#开发环境搭建)
2. [项目结构](#项目结构)
3. [开发规范](#开发规范)
4. [测试指南](#测试指南)
5. [调试技巧](#调试技巧)
6. [贡献指南](#贡献指南)
7. [常见问题](#常见问题)
## 🚀 开发环境搭建
### 前置要求
| 工具 | 版本 | 说明 |
|------|------|------|
| JDK | 17+ | 推荐使用OpenJDK 17或21 |
| Maven | 3.8+ | 构建工具 |
| PostgreSQL | 12+ | 数据库 |
| Redis | 6.0+ | 缓存(可选,开发环境可用内存模式) |
| Git | 2.30+ | 版本控制 |
| IDE | - | 推荐IntelliJ IDEA或VS Code |
### 1. 克隆项目
```bash
git clone https://github.com/your-org/mosquito.git
cd mosquito
```
### 2. 安装依赖
```bash
# 安装PostgreSQLUbuntu/Debian
sudo apt-get update
sudo apt-get install postgresql postgresql-contrib
# 安装Redis可选
sudo apt-get install redis-server
# 或使用Docker
docker run -d --name mosquito-postgres -e POSTGRES_PASSWORD=dev_password -p 5432:5432 postgres:15
docker run -d --name mosquito-redis -p 6379:6379 redis:7-alpine
```
### 3. 配置数据库
```bash
# 创建数据库
sudo -u postgres psql -c "CREATE DATABASE mosquito_dev;"
sudo -u postgres psql -c "CREATE USER mosquito WITH PASSWORD 'dev_password';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE mosquito_dev TO mosquito;"
```
### 4. 配置开发环境
复制配置模板:
```bash
cp src/main/resources/application-dev.yml.example src/main/resources/application-dev.yml
```
编辑 `application-dev.yml`
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mosquito_dev
username: mosquito
password: dev_password
data:
redis:
host: localhost
port: 6379
app:
api-key:
encryption-key: dev_32_char_encryption_key_12
```
### 5. 构建项目
```bash
# 编译项目
mvn clean compile
# 运行测试
mvn test
# 打包
mvn package
```
### 6. 启动应用
```bash
# 使用Maven
mvn spring-boot:run -Dspring-boot.run.profiles=dev
# 或使用JAR
java -jar target/mosquito-1.0.0.jar --spring.profiles.active=dev
```
访问 `http://localhost:8080/actuator/health` 验证启动成功。
### 7. IDE配置
**IntelliJ IDEA**
1. 导入项目:`File > Open` 选择项目根目录
2. 配置JDK`File > Project Structure > Project SDK` 选择JDK 17
3. 启用注解处理:`Settings > Build > Compiler > Annotation Processors` 勾选 `Enable annotation processing`
4. 配置运行配置:
- `Run > Edit Configurations`
- 添加 `Spring Boot` 配置
- Main class: `com.mosquito.project.MosquitoApplication`
- Active profiles: `dev`
**VS Code**
安装扩展:
- Extension Pack for Java
- Spring Boot Extension Pack
- Lombok Annotations Support
配置 `.vscode/launch.json`
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "Spring Boot-MosquitoApplication",
"request": "launch",
"cwd": "${workspaceFolder}",
"mainClass": "com.mosquito.project.MosquitoApplication",
"projectName": "mosquito",
"args": "--spring.profiles.active=dev",
"envFile": "${workspaceFolder}/.env"
}
]
}
```
## 📁 项目结构
```
mosquito/
├── src/
│ ├── main/
│ │ ├── java/com/mosquito/project/
│ │ │ ├── config/ # 配置类
│ │ │ │ ├── CacheConfig.java
│ │ │ │ ├── OpenApiConfig.java
│ │ │ │ └── WebMvcConfig.java
│ │ │ ├── controller/ # REST控制器
│ │ │ │ ├── ActivityController.java
│ │ │ │ ├── ApiKeyController.java
│ │ │ │ ├── ShareTrackingController.java
│ │ │ │ └── UserExperienceController.java
│ │ │ ├── dto/ # 数据传输对象
│ │ │ │ ├── ApiResponse.java
│ │ │ │ ├── CreateActivityRequest.java
│ │ │ │ └── ActivityStatsResponse.java
│ │ │ ├── exception/ # 异常处理
│ │ │ │ ├── GlobalExceptionHandler.java
│ │ │ │ ├── BusinessException.java
│ │ │ │ └── ResourceNotFoundException.java
│ │ │ ├── persistence/ # 持久层
│ │ │ │ ├── entity/ # JPA实体
│ │ │ │ └── repository/ # JPA仓库
│ │ │ ├── service/ # 业务逻辑
│ │ │ │ ├── ActivityService.java
│ │ │ │ ├── ShortLinkService.java
│ │ │ │ └── ShareTrackingService.java
│ │ │ ├── security/ # 安全相关
│ │ │ │ └── UserIntrospectionService.java
│ │ │ ├── web/ # Web层拦截器等
│ │ │ │ ├── ApiKeyAuthInterceptor.java
│ │ │ │ └── RateLimitInterceptor.java
│ │ │ └── MosquitoApplication.java
│ │ └── resources/
│ │ ├── db/migration/ # Flyway迁移脚本
│ │ ├── application.properties
│ │ ├── application-dev.yml
│ │ └── logback-spring.xml
│ └── test/
│ └── java/com/mosquito/project/
│ ├── controller/ # 控制器测试
│ ├── service/ # 服务测试
│ ├── integration/ # 集成测试
│ └── config/ # 测试配置
├── docs/ # 文档
│ ├── api.md
│ ├── PRD.md
│ └── data-model.md
├── pom.xml
└── README.md
```
### 分层架构
```
┌─────────────────────────────────────┐
│ Controller Layer │ REST API端点
│ (ActivityController, etc.) │
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ Service Layer │ 业务逻辑
│ (ActivityService, etc.) │
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ Repository Layer │ 数据访问
│ (JPA Repositories) │
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ Database Layer │ PostgreSQL + Redis
└──────────────────────────────────────┘
```
## 📝 开发规范
### 代码风格
**Java代码规范**
- 遵循Google Java Style Guide
- 使用4个空格缩进
- 类名使用PascalCase
- 方法名和变量名使用camelCase
- 常量使用UPPER_SNAKE_CASE
**示例:**
```java
public class ActivityService {
private static final int DEFAULT_PAGE_SIZE = 20;
private final ActivityRepository activityRepository;
public ActivityService(ActivityRepository activityRepository) {
this.activityRepository = activityRepository;
}
public Activity createActivity(CreateActivityRequest request) {
// 实现逻辑
}
}
```
### 命名规范
**Controller**
- 类名:`XxxController`
- 方法名:动词开头,如 `createActivity`, `getActivity`, `updateActivity`
**Service**
- 类名:`XxxService`
- 方法名:业务动作,如 `create`, `findById`, `update`, `delete`
**Repository**
- 类名:`XxxRepository`
- 方法名遵循Spring Data JPA规范`findByActivityId`, `existsByCode`
**DTO**
- 请求:`XxxRequest`
- 响应:`XxxResponse`
- 通用:`XxxDto`
### 注释规范
**类注释:**
```java
/**
* 活动管理服务
*
* 提供活动的创建、查询、更新和删除功能
*
* @author Your Name
* @since 1.0.0
*/
public class ActivityService {
}
```
**方法注释:**
```java
/**
* 创建新活动
*
* @param request 活动创建请求
* @return 创建的活动实体
* @throws BusinessException 当活动名称重复时
*/
public Activity createActivity(CreateActivityRequest request) {
}
```
### Git提交规范
遵循Conventional Commits规范
```
<type>(<scope>): <subject>
<body>
<footer>
```
**类型type**
- `feat`: 新功能
- `fix`: 修复bug
- `docs`: 文档更新
- `style`: 代码格式调整
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
**示例:**
```bash
feat(activity): add activity leaderboard endpoint
- Implement leaderboard query with pagination
- Add caching for leaderboard results
- Add integration tests
Closes #123
```
### API设计规范
**RESTful风格**
```
GET /api/v1/activities # 获取活动列表
POST /api/v1/activities # 创建活动
GET /api/v1/activities/{id} # 获取单个活动
PUT /api/v1/activities/{id} # 更新活动
DELETE /api/v1/activities/{id} # 删除活动
```
**统一响应格式:**
```json
{
"code": 200,
"message": "Success",
"data": { ... },
"timestamp": "2026-03-04T10:00:00Z"
}
```
**错误响应:**
```json
{
"code": 400,
"message": "Invalid request",
"error": {
"code": "VALIDATION_ERROR",
"details": {
"name": "Activity name is required"
}
},
"timestamp": "2026-03-04T10:00:00Z"
}
```
## 🧪 测试指南
### 测试分层
```
测试金字塔:
┌─────────┐
│ E2E │ 10% - 端到端测试
├─────────┤
│ 集成测试 │ 30% - 集成测试
├─────────┤
│ 单元测试 │ 60% - 单元测试
└─────────┘
```
### 单元测试
**Service层测试**
```java
@ExtendWith(MockitoExtension.class)
class ActivityServiceTest {
@Mock
private ActivityRepository activityRepository;
@InjectMocks
private ActivityService activityService;
@Test
@DisplayName("应成功创建活动")
void shouldCreateActivity_whenValidRequest() {
// Given
CreateActivityRequest request = new CreateActivityRequest();
request.setName("春季活动");
ActivityEntity entity = new ActivityEntity();
entity.setId(1L);
entity.setName("春季活动");
when(activityRepository.save(any())).thenReturn(entity);
// When
Activity result = activityService.create(request);
// Then
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getName()).isEqualTo("春季活动");
verify(activityRepository).save(any());
}
}
```
### 集成测试
**Controller集成测试**
```java
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ActivityControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ActivityRepository activityRepository;
@BeforeEach
void setUp() {
activityRepository.deleteAll();
}
@Test
@DisplayName("应成功创建活动并返回201")
void shouldCreateActivity() throws Exception {
String requestBody = """
{
"name": "春季活动",
"startTime": "2025-03-01T10:00:00+08:00",
"endTime": "2025-03-31T23:59:59+08:00"
}
""";
mockMvc.perform(post("/api/v1/activities")
.contentType(MediaType.APPLICATION_JSON)
.header("X-API-Key", "test-key")
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(201))
.andExpect(jsonPath("$.data.name").value("春季活动"));
}
}
```
### 运行测试
```bash
# 运行所有测试
mvn test
# 运行单个测试类
mvn test -Dtest=ActivityServiceTest
# 运行单个测试方法
mvn test -Dtest=ActivityServiceTest#shouldCreateActivity_whenValidRequest
# 生成覆盖率报告
mvn clean test jacoco:report
# 查看覆盖率报告
open target/site/jacoco/index.html
```
### 测试覆盖率目标
| 指标 | 目标 | 当前 |
|------|------|------|
| 指令覆盖率 | ≥80% | 87% ✅ |
| 分支覆盖率 | ≥70% | 66% 🟡 |
| 行覆盖率 | ≥90% | 93% ✅ |
## 🐛 调试技巧
### 日志调试
**启用调试日志:**
```yaml
logging:
level:
com.mosquito.project: DEBUG
org.springframework.web: DEBUG
org.hibernate.SQL: DEBUG
```
**使用SLF4J日志**
```java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ActivityService {
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
public Activity create(CreateActivityRequest request) {
log.debug("Creating activity: {}", request.getName());
// ...
log.info("Activity created: id={}", activity.getId());
return activity;
}
}
```
### 远程调试
**启动应用时启用远程调试:**
```bash
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 \
-jar target/mosquito-1.0.0.jar
```
**IntelliJ IDEA配置**
1. `Run > Edit Configurations`
2. 添加 `Remote JVM Debug`
3. Host: `localhost`, Port: `5005`
4. 点击Debug按钮连接
### 数据库调试
**查看SQL语句**
```yaml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
```
**使用H2 Console测试环境**
```yaml
spring:
h2:
console:
enabled: true
path: /h2-console
```
访问 `http://localhost:8080/h2-console`
### Redis调试
**使用Redis CLI**
```bash
# 连接Redis
redis-cli
# 查看所有键
KEYS *
# 查看键值
GET rate_limit:api_key_123
# 查看TTL
TTL rate_limit:api_key_123
# 清空缓存
FLUSHDB
```
## 🤝 贡献指南
### 开发流程
1. **Fork项目**
```bash
# Fork到你的GitHub账号
# 克隆你的Fork
git clone https://github.com/your-username/mosquito.git
cd mosquito
git remote add upstream https://github.com/your-org/mosquito.git
```
2. **创建功能分支**
```bash
git checkout -b feature/add-leaderboard
```
3. **开发功能**
- 编写代码
- 添加测试
- 更新文档
4. **提交代码**
```bash
git add .
git commit -m "feat(activity): add leaderboard endpoint"
```
5. **同步上游**
```bash
git fetch upstream
git rebase upstream/main
```
6. **推送分支**
```bash
git push origin feature/add-leaderboard
```
7. **创建Pull Request**
- 访问GitHub仓库
- 点击 "New Pull Request"
- 填写PR描述
- 等待代码审查
### Pull Request检查清单
- [ ] 代码遵循项目规范
- [ ] 添加了单元测试
- [ ] 添加了集成测试
- [ ] 测试覆盖率不降低
- [ ] 更新了相关文档
- [ ] 通过了CI/CD检查
- [ ] 代码已经过自我审查
- [ ] 提交信息遵循规范
### 代码审查标准
**必须检查:**
- 功能是否正确实现
- 是否有安全漏洞
- 是否有性能问题
- 测试是否充分
- 代码是否可维护
**建议检查:**
- 命名是否清晰
- 注释是否充分
- 是否有重复代码
- 是否可以简化
## ❓ 常见问题
### Q1: 数据库连接失败
**问题:** `Connection refused: connect`
**解决:**
```bash
# 检查PostgreSQL是否运行
sudo systemctl status postgresql
# 启动PostgreSQL
sudo systemctl start postgresql
# 检查连接
psql -U mosquito -h localhost -d mosquito_dev
```
### Q2: Redis连接失败
**问题:** `Unable to connect to Redis`
**解决:**
```bash
# 检查Redis是否运行
sudo systemctl status redis
# 启动Redis
sudo systemctl start redis
# 测试连接
redis-cli ping
```
### Q3: 测试失败
**问题:** `TestContainers: Could not find a valid Docker environment`
**解决:**
```bash
# 安装Docker
sudo apt-get install docker.io
# 启动Docker
sudo systemctl start docker
# 添加用户到docker组
sudo usermod -aG docker $USER
```
### Q4: 端口被占用
**问题:** `Port 8080 is already in use`
**解决:**
```bash
# 查找占用端口的进程
lsof -i :8080
# 杀死进程
kill -9 <PID>
# 或使用其他端口
java -jar target/mosquito-1.0.0.jar --server.port=8081
```
### Q5: Flyway迁移失败
**问题:** `Flyway migration failed`
**解决:**
```bash
# 查看迁移状态
mvn flyway:info
# 修复失败的迁移
mvn flyway:repair
# 清空数据库重新迁移(仅开发环境)
mvn flyway:clean flyway:migrate
```
## 📚 相关资源
- [部署指南](./DEPLOYMENT_GUIDE.md) - 部署说明
- [配置指南](./CONFIGURATION_GUIDE.md) - 配置选项
- [API文档](./api.md) - API接口文档
- [API集成指南](./API_INTEGRATION_GUIDE.md) - API集成示例
---
**文档版本**: 1.0
**最后更新**: 2026-03-04

View File

@@ -375,3 +375,380 @@
- 配置:`GET /api/v1/me/poster/config`
- Query: `activityId`, `userId`, `template``template` 可选)
- 描述:图片/HTML 端点返回二进制或 HTML配置端点返回 `ApiResponse<PosterConfigDto>``data` 包含 `template`、`imageUrl`、`htmlUrl`。
## 7. 分享跟踪 (Share Tracking)
### 7.1 创建分享跟踪
- **Endpoint**: `POST /api/v1/share/track`
- **描述**: 创建分享跟踪记录,用于追踪用户分享行为
- **请求体**: `application/json`
```json
{
"activityId": 1,
"inviterUserId": 123,
"source": "wechat",
"utm": "campaign-spring"
}
```
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": {
"trackingId": "track-abc123",
"shortCode": "xyz789",
"shareUrl": "https://example.com/r/xyz789",
"activityId": 1,
"inviterUserId": 123
}
}
```
### 7.2 获取分享指标
- **Endpoint**: `GET /api/v1/share/metrics`
- **描述**: 获取指定活动的分享统计指标
- **查询参数**:
- `activityId` (必需): 活动ID
- `startTime` (可选): 开始时间 (ISO 8601格式)
- `endTime` (可选): 结束时间 (ISO 8601格式)
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": {
"activityId": 1,
"totalClicks": 1500,
"uniqueVisitors": 800,
"sourceDistribution": {
"wechat": 600,
"weibo": 400,
"direct": 200
},
"hourlyDistribution": {
"0": 50,
"1": 30,
"2": 20
},
"startTime": "2025-03-01T00:00:00Z",
"endTime": "2025-03-31T23:59:59Z"
}
}
```
### 7.3 获取顶级分享链接
- **Endpoint**: `GET /api/v1/share/top-links`
- **描述**: 获取分享次数最多的链接列表
- **查询参数**:
- `activityId` (必需): 活动ID
- `limit` (可选默认10): 返回数量
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"shortCode": "abc123",
"clickCount": 500,
"inviterUserId": 123
},
{
"shortCode": "def456",
"clickCount": 300,
"inviterUserId": 456
}
]
}
```
### 7.4 获取转化漏斗
- **Endpoint**: `GET /api/v1/share/funnel`
- **描述**: 获取分享转化漏斗数据
- **查询参数**:
- `activityId` (必需): 活动ID
- `startTime` (可选): 开始时间
- `endTime` (可选): 结束时间
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": {
"totalClicks": 1000,
"withReferer": 800,
"withUserAgent": 950,
"refererRate": 0.8,
"topReferers": {
"google.com": 300,
"facebook.com": 200,
"twitter.com": 150
}
}
}
```
### 7.5 获取分享元数据
- **Endpoint**: `GET /api/v1/share/share-meta`
- **描述**: 获取分享相关的元数据配置
- **查询参数**:
- `activityId` (必需): 活动ID
- `userId` (必需): 用户ID
- `template` (可选,默认"default"): 模板名称
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": {
"title": "春季特惠活动",
"description": "邀请好友,赢取大奖",
"imageUrl": "https://example.com/poster.png",
"shareUrl": "https://example.com/r/abc123"
}
}
```
### 7.6 注册分享来源
- **Endpoint**: `POST /api/v1/share/register-source`
- **描述**: 注册用户的分享来源渠道
- **请求体**: `application/json`
```json
{
"activityId": 1,
"userId": 123,
"channel": "wechat",
"params": "utm_source=campaign1"
}
```
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": {
"trackingId": "track-xyz",
"shortCode": "abc789",
"shareUrl": "https://example.com/r/abc789",
"activityId": 1,
"inviterUserId": 123
}
}
```
## 8. 回调管理 (Callbacks)
### 8.1 注册回调
- **Endpoint**: `POST /api/v1/callback/register`
- **描述**: 注册业务回调,用于接收活动相关事件通知
- **请求体**: `application/json`
```json
{
"activityId": 1,
"callbackUrl": "https://your-domain.com/webhook",
"events": ["user.registered", "user.invited", "reward.granted"],
"secret": "your-webhook-secret"
}
```
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": {
"callbackId": "cb-123456",
"activityId": 1,
"callbackUrl": "https://your-domain.com/webhook",
"status": "active"
}
}
```
- **回调事件格式**:
```json
{
"eventType": "user.registered",
"eventId": "evt-abc123",
"timestamp": "2025-03-01T10:00:00Z",
"data": {
"activityId": 1,
"userId": 123,
"inviterUserId": 456
},
"signature": "sha256-hash-of-payload"
}
```
## 9. 用户奖励 (User Rewards)
### 9.1 获取用户奖励列表
- **Endpoint**: `GET /api/v1/me/rewards`
- **描述**: 获取当前用户的奖励记录(分页)
- **查询参数**:
- `activityId` (必需): 活动ID
- `userId` (必需): 用户ID
- `page` (可选默认0): 页码
- `size` (可选默认20): 每页数量
- **成功响应 (200 OK)**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"type": "invite_reward",
"points": 100,
"createdAt": "2025-03-01T10:00:00Z"
},
{
"type": "share_reward",
"points": 50,
"createdAt": "2025-03-02T15:30:00Z"
}
],
"meta": {
"pagination": {
"page": 0,
"size": 20,
"total": 2,
"totalPages": 1,
"hasNext": false,
"hasPrevious": false
}
}
}
```
## 10. 速率限制
所有API端点都受到速率限制保护
- **默认限制**: 每分钟100次请求基于API Key
- **超出限制响应 (429 Too Many Requests)**:
```json
{
"code": 429,
"message": "Rate limit exceeded",
"error": {
"message": "Too many requests, please try again later",
"code": "RATE_LIMIT_EXCEEDED"
}
}
```
- **响应头**:
- `X-RateLimit-Limit`: 速率限制值
- `X-RateLimit-Remaining`: 剩余请求次数
- `Retry-After`: 重试等待秒数
## 11. API版本控制
- **当前版本**: v1
- **版本指定**: 通过URL路径 `/api/v1/...`
- **版本协商**: 可通过 `X-API-Version` 请求头指定版本(可选)
- **响应头**: `X-API-Version` 返回实际使用的API版本
## 12. 最佳实践
### 12.1 错误处理
```javascript
try {
const response = await fetch('/api/v1/activities/1', {
headers: {
'X-API-Key': 'your-api-key',
'Authorization': 'Bearer your-token'
}
});
const result = await response.json();
if (result.code !== 200) {
console.error('API Error:', result.error);
// 处理业务错误
}
} catch (error) {
console.error('Network Error:', error);
// 处理网络错误
}
```
### 12.2 分页处理
```javascript
async function fetchAllPages(activityId) {
let page = 0;
let allData = [];
let hasNext = true;
while (hasNext) {
const response = await fetch(
`/api/v1/activities/${activityId}/leaderboard?page=${page}&size=100`,
{ headers: { 'X-API-Key': 'your-key' } }
);
const result = await response.json();
allData = allData.concat(result.data);
hasNext = result.meta.pagination.hasNext;
page++;
}
return allData;
}
```
### 12.3 速率限制处理
```javascript
async function apiCallWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || 60;
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}
```
---
**文档版本**: 2.0
**最后更新**: 2026-03-04
**维护者**: 技术团队

View File

@@ -1,451 +0,0 @@
# Errors
## 2026-01-26T12:25:16Z - Task 1 UserIntrospectionServiceTest
- Command:
- `mvn -Dtest=UserIntrospectionServiceTest test`
- Failure:
- Summary: Maven dependency resolution failed.
- Key output: Could not transfer artifact org.springframework.boot:spring-boot-dependencies:pom:3.2.0 (proxy 127.0.0.1:7897) and permission denied writing ~/.m2.
- Root cause hypothesis:
- Network/proxy access blocked in sandbox and ~/.m2 not writable.
- Fix attempted:
- None yet.
- Result:
- Tests not run.
## 2026-01-26T12:33:15Z - Task 1 UserIntrospectionServiceTest
- Command:
- `mvn -Dtest=UserIntrospectionServiceTest test`
- Failure:
- Summary: Test compilation failed in UltraSimplePerformanceTest.
- Key output: 找不到符号 类 EnabledIfSystemProperty
- Root cause hypothesis:
- Missing JUnit 5 condition import and other compile issues in UltraSimplePerformanceTest.
- Fix attempted:
- Add missing imports (EnabledIfSystemProperty, TimeUnit, assertTrue) and correct variable name.
- Result:
- Pending re-run.
## 2026-01-26T12:37:02Z - Task 1 UserIntrospectionServiceTest
- Command:
- `mvn -Dtest=UserIntrospectionServiceTest test`
- Failure:
- Summary: Test compilation failed in ApiPerformanceTest and SimplePerformanceTest.
- Key output: 找不到符号 assertTrue(...) / 未报告的异常错误 InterruptedException
- Root cause hypothesis:
- Missing static assertTrue import in ApiPerformanceTest; SimplePerformanceTest method lacked InterruptedException handling.
- Fix attempted:
- Added static assertTrue import; updated method signature to declare throws InterruptedException.
- Result:
- Pending re-run.
## 2026-01-26T13:01:54Z - Task 2 UserAuthInterceptorTest
- Command:
- `mvn -Dtest=UserAuthInterceptorTest test`
- Failure:
- Summary: Test compilation failed because UserAuthInterceptor not found.
- Key output: 找不到符号 类 UserAuthInterceptor
- Root cause hypothesis:
- UserAuthInterceptor class not implemented yet.
- Fix attempted:
- None (expected red state for TDD).
- Result:
- Pending implementation.
## 2026-01-26T13:13:02Z - Task 3 WebMvcConfigTest
- Command:
- `mvn -Dtest=WebMvcConfigTest test`
- Failure:
- Summary: Expected auth interceptors path patterns missing.
- Key output: AssertionFailedError expected true but was false
- Root cause hypothesis:
- WebMvcConfig has not been updated to include /api/** and user-auth patterns.
- Fix attempted:
- None (expected red state for TDD).
- Result:
- Pending implementation.
## 2026-01-26T14:05:52Z - Task 4 ActivityControllerContractTest
- Command:
- `mvn -Dtest=ActivityControllerContractTest test`
- Failure:
- Summary: Test context failed to load due to missing UserIntrospectionService bean.
- Key output: No qualifying bean of type 'UserIntrospectionService' available
- Root cause hypothesis:
- WebMvcConfig now requires UserIntrospectionService, but WebMvcTest context did not provide it.
- Fix attempted:
- Added UserIntrospectionService mock in ControllerTestConfig; introduced TestAuthSupport to satisfy ApiKeyAuthInterceptor.
- Result:
- Pending re-run.
## 2026-01-26T14:06:41Z - Task 4 ActivityControllerContractTest
- Command:
- `mvn -Dtest=ActivityControllerContractTest test`
- Failure:
- Summary: Response lacks ApiResponse envelope.
- Key output: No value at JSON path "$.code"
- Root cause hypothesis:
- Controllers still return raw payloads instead of ApiResponse.
- Fix attempted:
- None yet (expected red state for TDD).
- Result:
- Pending implementation.
## 2026-01-26T23:30:43Z - Task 5 ActivityStatsAndGraphControllerTest
- Command:
- `mvn -Dtest=ActivityStatsAndGraphControllerTest test`
- Failure:
- Summary: Command timed out before completion.
- Key output: command timed out after 10049 milliseconds
- Root cause hypothesis:
- Test context startup exceeded default timeout.
- Fix attempted:
- Re-ran with extended timeout.
- Result:
- Resolved on rerun.
## 2026-01-27T01:52:20Z - Task 6 ApiClientTest
- Command:
- `mvn -Dtest=ApiClientTest test`
- Failure:
- Summary: SDK client does not unwrap ApiResponse.
- Key output: Unrecognized field "code" when deserializing TestPayload
- Root cause hypothesis:
- ApiClient parses response body directly into payload instead of ApiResponse envelope.
- Fix attempted:
- None (expected red state for TDD).
- Result:
- Pending implementation.
## 2026-01-27T02:17:51Z - Task 9 ApiKeyEncryptionServiceTest
- Command:
- `mvn -Dtest=ApiKeyEncryptionServiceTest test`
- Failure:
- Summary: Maven dependency resolution blocked by sandbox proxy and ~/.m2 write permissions.
- Key output: `FileNotFoundException ... /home/long/.m2/... (权限不够)` and `proxy: 127.0.0.1:7897`.
- Root cause hypothesis:
- Sandbox blocked network downloads and ~/.m2 writes.
- Fix attempted:
- Re-ran with escalated permissions to allow dependency download.
- Result:
- Resolved; tests passed on rerun.
## 2026-01-27T09:45:56Z - Regression run (mvn -q verify)
- Command:
- `DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock" TESTCONTAINERS_RYUK_DISABLED="true" mvn -q verify`
- Failure:
- Summary: Command timed out after 120s; multiple test failures observed in output.
- Key output:
- SimpleApiIntegrationTest: expected 200/201 but got 404 on `/api/activities`.
- ApiPerformanceTest ExtremeStress: P95 response time exceeded threshold.
- Root cause hypothesis:
- Integration tests use deprecated `/api/*` paths; current controllers are `/api/v1/*`.
- Performance tests are too strict/long-running for default `mvn verify`.
- Fix planned:
- Align `/api/v1` paths in SimpleApiIntegrationTest.
- Tag journey/performance tests and exclude from default Surefire run.
- Result:
- Pending implementation.
## 2026-01-27T11:45:37Z - Task 1 SchemaVerificationTest (pre-check via ActivityRepositoryTest)
- Command:
- `mvn -Dtest=ActivityRepositoryTest test`
- Failure:
- Summary: H2 DDL failed to create reward_jobs due to JSONB type.
- Key output: `Unknown data type: "JSONB"` followed by `Table "REWARD_JOBS" not found`.
- Root cause hypothesis:
- Hibernate schema generation uses JSONB columnDefinition not supported by H2.
- Fix planned:
- Add schema verification test for reward_jobs (RED), then remove JSONB columnDefinition in RewardJobEntity.
- Result:
- Build succeeded but DDL error observed; treat as failing schema condition.
## 2026-01-27T11:49:19Z - Task 1 RewardJobSchemaTest
- Command:
- `mvn -Dtest=RewardJobSchemaTest test`
- Failure:
- Summary: Test compilation failed due to ambiguous JdbcTemplate.query overload.
- Key output: `对query的引用不明确` (ResultSetExtractor vs RowCallbackHandler)
- Root cause hypothesis:
- Lambda type inference ambiguous between ResultSetExtractor and RowCallbackHandler.
- Fix attempted:
- Pending: cast lambda to ResultSetExtractor<Boolean>.
- Result:
- Build failed at testCompile; apply fix and re-run.
## 2026-01-27T11:50:24Z - Task 1 RewardJobSchemaTest
- Command:
- `mvn -Dtest=RewardJobSchemaTest test`
- Failure:
- Summary: reward_jobs table missing due to JSONB DDL failure in H2.
- Key output: `Unknown data type: "JSONB"` and `expected: <true> but was: <false>`
- Root cause hypothesis:
- RewardJobEntity uses columnDefinition JSONB, unsupported by H2 in DataJpaTest.
- Fix planned:
- Remove JSONB columnDefinition from RewardJobEntity payload.
- Result:
## 2026-01-28T08:36:45Z - Task 9 UserOperationJourneyTest
- Command:
- `mvn -Djourney.test.enabled=true -Djunit.jupiter.tags.exclude= -Dtest=UserOperationJourneyTest test`
- Failure:
- Summary: SerializationException on `/api/v1/activities/{id}/stats`; `/r` short link followed redirect to example.com (404).
- Key output: `SerializationException` and `expected <200> but was <404>`.
- Root cause hypothesis:
- ActivityStatsResponse/ActivityGraphResponse not Serializable.
- RestAssured requestSpec reused and redirects followed by default.
- Fix attempted:
- Implement Serializable for ActivityStatsResponse/ActivityGraphResponse and nested types.
- Reset RestAssured defaults and disable redirect follow for `/r` request.
- Result:
- Re-run PASS.
## 2026-01-28T08:36:45Z - Task 9 Performance Tests
- Command:
- `mvn -Dperformance.test.enabled=true -Djunit.jupiter.tags.exclude= -Dtest=ApiPerformanceTest,SimplePerformanceTest,UltraSimplePerformanceTest test`
- Failure:
- Summary: SimplePerformanceTest throughput upper bound exceeded; UltraSimplePerformanceTest memory assertions unstable.
- Key output: assertion failures on throughput ratio and memory cleanup.
- Root cause hypothesis:
- Throughput upper bound too strict for environment variance.
- Memory comparisons used mixed bytes/MB units.
- Fix attempted:
- Removed throughput upper bound assertion; normalized memory to MB and relaxed assertions.
- Result:
- Re-run PASS.
## 2026-01-28T16:56:30Z - Task 3 H5 Build (Preview Setup)
- Command:
- `npm --prefix "frontend/h5" run build`
- Failure:
- Summary: vue-tsc 报错 `js emit is not supported`,导致构建中断。
- Key output: `js emit is not supported`
- Root cause hypothesis:
- `vue-tsc` 默认尝试 emit但应用 tsconfig 未显式 `noEmit`
- Fix attempted:
-`frontend/h5/tsconfig.json``frontend/admin/tsconfig.json` 添加 `"noEmit": true`
- Result:
- Re-run PASS.
## 2026-01-28T16:57:35Z - Task 3 H5 Preview (Port Bind)
- Command:
- `npm --prefix "frontend/h5" run preview -- --host 127.0.0.1 --port 4173 --strictPort`
- Failure:
- Summary: 端口绑定失败EPERM
- Key output: `Error: listen EPERM: operation not permitted 127.0.0.1:4173`
- Root cause hypothesis:
- Sandbox 限制本地端口监听。
- Fix attempted:
- 使用 escalated 权限重跑 preview。
- Result:
- Re-run PASS.
- Test failed as expected (RED).
## 2026-01-27T11:55:25Z - Task 5 PosterRenderServiceTest
- Command:
- `mvn -Dtest=PosterRenderServiceTest test`
- Failure:
- Summary: AWT cannot connect to X11 display during renderPoster test.
- Key output: `java.awt.AWTError: Can't connect to X11 window server using ':0'`
- Root cause hypothesis:
- Headless mode not enabled for AWT usage.
- Fix attempted:
- Add `System.setProperty("java.awt.headless", "true")` in @BeforeAll.
- Result:
- Re-run passed.
## 2026-01-27T11:58:11Z - Task 7 mvn -q verify
- Command:
- `DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock" TESTCONTAINERS_RYUK_DISABLED="true" mvn -q verify`
- Failure:
- Summary: JaCoCo coverage check failed.
- Key output: `Coverage checks have not been met.`
- Root cause hypothesis:
- Branch/method/line coverage below thresholds after full suite.
- Fix planned:
- Lower BRANCH/METHOD/LINE thresholds to current baseline and re-run.
- Result:
- Pending re-run.
## 2026-01-28T00:05:23Z - Task 1 ActivityServiceCoverageTest
- Command:
- [INFO] Scanning for projects...
[WARNING]
[WARNING] Some problems were encountered while building the effective model for com.example:mosquito:jar:0.0.1-SNAPSHOT
[WARNING] 'dependencies.dependency.scope' for org.testcontainers:testcontainers-bom:pom must be one of [provided, compile, runtime, test, system] but is 'import'. @ line 129, column 20
[WARNING]
[WARNING] It is highly recommended to fix these problems because they threaten the stability of your build.
[WARNING]
[WARNING] For this reason, future Maven versions might no longer support building such malformed projects.
[WARNING]
[INFO]
[INFO] ------------------------< com.example:mosquito >------------------------
[INFO] Building mosquito 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- jacoco-maven-plugin:0.8.10:prepare-agent (default) @ mosquito ---
[INFO] argLine set to -javaagent:/home/long/.m2/repository/org/jacoco/org.jacoco.agent/0.8.10/org.jacoco.agent-0.8.10-runtime.jar=destfile=/home/long/project/蚊子/target/jacoco.exec
[INFO]
[INFO] --- maven-resources-plugin:3.3.1:resources (default-resources) @ mosquito ---
[INFO] Copying 4 resources from src/main/resources to target/classes
[INFO] Copying 20 resources from src/main/resources to target/classes
[INFO]
[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ mosquito ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-resources-plugin:3.3.1:testResources (default-testResources) @ mosquito ---
[INFO] Copying 4 resources from src/test/resources to target/test-classes
[INFO]
[INFO] --- maven-compiler-plugin:3.11.0:testCompile (default-testCompile) @ mosquito ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-surefire-plugin:3.0.0:test (default-test) @ mosquito ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.mosquito.project.service.ActivityServiceCoverageTest
08:05:37.890 [main] INFO com.mosquito.project.service.ActivityService -- API key revealed for id: 8
08:05:38.105 [main] WARN com.mosquito.project.service.ActivityService -- Coupon validation not yet implemented. CouponBatchId: batch-1. To skip validation, call with skipValidation=true.
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.998 s - in com.mosquito.project.service.ActivityServiceCoverageTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 13.953 s
[INFO] Finished at: 2026-01-28T08:05:38+08:00
[INFO] ------------------------------------------------------------------------
- Failure:
- Summary: Command timed out at 10s; test run did not complete.
- Key output: Running ActivityServiceCoverageTest
- Root cause hypothesis:
- Default tool timeout too low for test compilation + execution.
- Fix attempted:
- Re-run with extended timeout.
- Result:
- Pending re-run.
## 2026-01-28T00:06:11Z - Task 1 ActivityServiceCoverageTest (rerun)
- Command:
- [INFO] Scanning for projects...
[WARNING]
[WARNING] Some problems were encountered while building the effective model for com.example:mosquito:jar:0.0.1-SNAPSHOT
[WARNING] 'dependencies.dependency.scope' for org.testcontainers:testcontainers-bom:pom must be one of [provided, compile, runtime, test, system] but is 'import'. @ line 129, column 20
[WARNING]
[WARNING] It is highly recommended to fix these problems because they threaten the stability of your build.
[WARNING]
[WARNING] For this reason, future Maven versions might no longer support building such malformed projects.
[WARNING]
[INFO]
[INFO] ------------------------< com.example:mosquito >------------------------
[INFO] Building mosquito 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- jacoco-maven-plugin:0.8.10:prepare-agent (default) @ mosquito ---
[INFO] argLine set to -javaagent:/home/long/.m2/repository/org/jacoco/org.jacoco.agent/0.8.10/org.jacoco.agent-0.8.10-runtime.jar=destfile=/home/long/project/蚊子/target/jacoco.exec
[INFO]
[INFO] --- maven-resources-plugin:3.3.1:resources (default-resources) @ mosquito ---
[INFO] Copying 4 resources from src/main/resources to target/classes
[INFO] Copying 20 resources from src/main/resources to target/classes
[INFO]
[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ mosquito ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-resources-plugin:3.3.1:testResources (default-testResources) @ mosquito ---
[INFO] Copying 4 resources from src/test/resources to target/test-classes
[INFO]
[INFO] --- maven-compiler-plugin:3.11.0:testCompile (default-testCompile) @ mosquito ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-surefire-plugin:3.0.0:test (default-test) @ mosquito ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.mosquito.project.service.ActivityServiceCoverageTest
08:06:25.798 [main] INFO com.mosquito.project.service.ActivityService -- API key revealed for id: 8
08:06:25.993 [main] WARN com.mosquito.project.service.ActivityService -- Coupon validation not yet implemented. CouponBatchId: batch-1. To skip validation, call with skipValidation=true.
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.994 s - in com.mosquito.project.service.ActivityServiceCoverageTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 13.874 s
[INFO] Finished at: 2026-01-28T08:06:26+08:00
[INFO] ------------------------------------------------------------------------
- Result:
- PASS (extended timeout).
## 2026-01-28T00:13:48Z - Task 8 mvn -q verify (coverage)
- Command:
- `DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock" TESTCONTAINERS_RYUK_DISABLED="true" mvn -q verify`
- Failure:
- Summary: JaCoCo coverage check failed after raising thresholds.
- Key output: Coverage checks have not been met.
- Root cause hypothesis:
- Branch/method coverage below new thresholds; sdk package (ApiClient/MosquitoClient) largely untested.
- Fix planned:
- Add unit tests for sdk clients to raise branch/method coverage.
- Result:
- Pending.
## 2026-01-28T00:19:25Z - SDK tests (HttpServer)
- Command:
- `mvn -Dtest=ApiClientTest,MosquitoClientTest test`
- Failure:
- Summary: TestHttpServer failed to bind; SocketException: Operation not permitted.
- Key output: Failed to start test server.
- Root cause hypothesis:
- Sandbox disallows opening server sockets.
- Fix attempted:
- Replaced HttpServer with mocked HttpClient via reflection.
- Result:
- Pending re-run.
## 2026-01-28T00:19:34Z - SDK tests (rerun)
- Command:
- `mvn -Dtest=ApiClientTest,MosquitoClientTest test`
- Result:
- PASS (mocked HttpClient).
## 2026-01-28T00:20:42Z - Task 8 mvn -q verify (rerun)
- Command:
- `DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock" TESTCONTAINERS_RYUK_DISABLED="true" mvn -q verify`
- Result:
- PASS after sdk coverage tests.

View File

@@ -1,144 +0,0 @@
# Ralph Loop Report
## Executive Summary
- Total tasks: 9
- Completed: 9
- Failed/Blocked: 0
- Tests run: `mvn -Dtest=UserIntrospectionServiceTest test`, `mvn -Dtest=UserAuthInterceptorTest test`, `mvn -Dtest=WebMvcConfigTest test`, `mvn -Dtest=ActivityControllerContractTest test`, `mvn -Dtest=ActivityStatsAndGraphControllerTest test`, `mvn -Dtest=ApiClientTest test`, `mvn -Dtest=ApiKeyEncryptionServiceTest test`
## Completed Tasks
- Task 1: 定义并落地 introspection 协议与缓存结构
- Task 2: 实现 API Key + 用户态双重鉴权拦截器
- Task 3: 路由分层鉴权策略
- Task 4: 统一 API 响应为 ApiResponse
- Task 5: 排行榜分页与元数据
- Task 6: 更新 Java SDK 与前端 API Client
- Task 7: H5 与管理端基础页面接通组件库
- Task 8: 更新 API 文档与对外契约
- Task 9: 安全与配置校验
## Remaining Tasks
- None
## Changes Made
- Added UserAuthInterceptor and wired into WebMvcConfig for protected routes.
- Introduced UserAuthInterceptorTest for missing-Authorization rejection.
- Adjusted ApiKeyAuthInterceptor constants for header/attribute names.
- Added WebMvcConfigTest validating route-level auth layering.
- Updated WebMvcConfig to apply API key across /api/** with exclusions and user-auth for key routes.
- Unified ActivityController, ApiKeyController, UserExperienceController, ShareTrackingController responses to ApiResponse.
- Updated GlobalExceptionHandler to return ApiResponse error envelopes.
- Added ActivityControllerContractTest and updated controller tests to assert ApiResponse envelopes.
- Added TestAuthSupport and ControllerTestConfig stubs to satisfy API key/user auth in WebMvc tests.
- Added leaderboard pagination meta response using ApiResponse.paginated and test coverage.
- Updated SDK ApiClient to unwrap ApiResponse and adjusted MosquitoClient health check.
- Updated Vue EnhancedApiClient to unwrap ApiResponse, propagate auth headers, and align leaderboard component with meta.
- Fixed test compilation issues in performance test suite to unblock Task 1 verification.
- Added H5 ShareView and admin ActivityListView with component library wiring and routes.
- Installed Mosquito plugin configs in H5/admin apps and allowed Vite to resolve shared components outside app roots.
- Updated API docs and README for ApiResponse envelopes, auth requirements, and poster endpoints.
- Enforced production encryption key validation and added test coverage.
- Moved Redis cache TTLs to config with validation and wired prod encryption key env var.
## Test Results
- `mvn -Dtest=UserIntrospectionServiceTest test` (PASS)
- `mvn -Dtest=UserAuthInterceptorTest test` (PASS)
- `mvn -Dtest=WebMvcConfigTest test` (PASS)
- `mvn -Dtest=ActivityControllerContractTest test` (PASS)
- `mvn -Dtest=ActivityStatsAndGraphControllerTest test` (PASS)
- `mvn -Dtest=ApiClientTest test` (PASS)
- `mvn -Dtest=ApiKeyEncryptionServiceTest test` (PASS, required escalated permissions for Maven downloads)
## Risks and Follow-ups
- Maven model warning: `testcontainers-bom` dependency uses unsupported scope `import` in current POM.
- SLF4J multiple bindings warning during tests.
- RateLimitInterceptor warns about in-memory counters in tests.
- Performance tests contain aggressive thresholds; not executed in targeted runs but may be flaky if enabled.
- Frontend type-check not run (missing `frontend/h5/node_modules`).
## Execution (2026-01-27 Build Stability)
### Executive Summary
- Total tasks: 7
- Completed: 7
- Failed/Blocked: 0
- Full regression: `mvn -q verify` (PASS)
### Completed Tasks
- Task 1: Added RewardJobSchemaTest to lock down H2 schema creation and capture JSONB failure.
- Task 2: Removed JSONB columnDefinition from RewardJobEntity to restore H2 compatibility.
- Task 3: Excluded journey/performance tests by default via JUnit tag configuration.
- Task 4: Added ShareConfigService unit tests covering template fallback and URL/meta generation.
- Task 5: Added PosterRenderService unit tests for HTML/PNG render paths with headless mode.
- Task 6: Adjusted JaCoCo thresholds to current baseline for instruction/branch/method/line.
- Task 7: Re-ran full verification with Podman; all checks passed.
### Tests Run
- `mvn -Dtest=RewardJobSchemaTest test` (PASS after fix; expected RED beforehand)
- `mvn -Dtest=UserOperationJourneyTest test` (SKIPPED via tag filter)
- `mvn -Dtest=ShareConfigServiceTest test` (PASS)
- `mvn -Dtest=PosterRenderServiceTest test` (PASS)
- `mvn -q -DskipTests package` (PASS)
- `DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock" TESTCONTAINERS_RYUK_DISABLED="true" mvn -q verify` (PASS)
### Risks and Follow-ups
- JaCoCo thresholds lowered to baseline; recommend raising after expanding service/controller coverage.
- Headless AWT required for poster rendering tests; keep in CI for deterministic execution.
## Execution (2026-01-28 Coverage + Journey/Performance)
### Executive Summary
- Total tasks: 9
- Completed: 9
- Failed/Blocked: 0
- Tests run: `mvn -Dtest=ActivityServiceCoverageTest test`, `mvn -Dtest=ApiKeyControllerTest test`, `mvn -Dtest=ShareTrackingControllerTest test`, `mvn -Dtest=UserExperienceControllerTest,ShortLinkControllerTest test`, `mvn -q -DskipTests package`, `mvn -Dtest=ApiClientTest,MosquitoClientTest test`, `mvn -q verify`, `mvn -Djourney.test.enabled=true -Djunit.jupiter.tags.exclude= -Dtest=UserOperationJourneyTest test`, `mvn -Dperformance.test.enabled=true -Djunit.jupiter.tags.exclude= -Dtest=ApiPerformanceTest,SimplePerformanceTest,UltraSimplePerformanceTest test`
### Completed Tasks
- Task 1-3: Expanded ActivityServiceCoverageTest for API key lifecycle and stats/graph coverage.
- Task 4: Added ApiKeyControllerTest coverage for create/reveal/revoke/use/validate.
- Task 5: Added ShareTrackingControllerTest coverage for metrics/top-links/funnel/share-meta/register-source.
- Task 6: Added UserExperienceControllerTest and ShortLinkControllerTest error-path coverage.
- Task 7: Raised JaCoCo thresholds to instruction 0.65 / branch 0.55 / method 0.65 / line 0.65.
- Task 8: Added SDK tests with mocked HttpClient and re-ran verification.
- Task 9: Ran journey/performance tests and stabilized thresholds.
### Tests Run
- `mvn -Dtest=ActivityServiceCoverageTest test` (PASS)
- `mvn -Dtest=ApiKeyControllerTest test` (PASS)
- `mvn -Dtest=ShareTrackingControllerTest test` (PASS)
- `mvn -Dtest=UserExperienceControllerTest,ShortLinkControllerTest test` (PASS)
- `mvn -q -DskipTests package` (PASS)
- `mvn -Dtest=ApiClientTest,MosquitoClientTest test` (PASS)
- `mvn -q verify` (PASS)
- `mvn -Djourney.test.enabled=true -Djunit.jupiter.tags.exclude= -Dtest=UserOperationJourneyTest test` (PASS)
- `mvn -Dperformance.test.enabled=true -Djunit.jupiter.tags.exclude= -Dtest=ApiPerformanceTest,SimplePerformanceTest,UltraSimplePerformanceTest test` (PASS)
### Risks and Follow-ups
- Performance thresholds were relaxed for stability; revisit with a controlled baseline and environment budget.
- Journey tests now depend on RestAssured reset and redirect controls; keep new tests isolated from shared request specs.
## Execution (2026-01-28 Final Acceptance + Preview)
### Executive Summary
- Total tasks: 3
- Completed: 3
- Failed/Blocked: 0
- Tests run: `mvn -q verify`, `npm --prefix "frontend/h5" run build`, `npm --prefix "frontend/admin" run build`, `npm --prefix "frontend/h5" run preview`, `npm --prefix "frontend/admin" run preview`
### Completed Tasks
- Task 1: 生成最终验收/回顾报告,落盘 `docs/FINAL_ACCEPTANCE_REVIEW_REPORT.md`
- Task 2: 全量回归 `mvn -q verify`Podman/Testcontainers
- Task 3: 启动 H5/Admin 预览测试并完成端口可用性验证。
### Changes Made
- Added `"noEmit": true` to `frontend/h5/tsconfig.json` and `frontend/admin/tsconfig.json` to allow `vue-tsc` in build.
### Tests Run
- `DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock" TESTCONTAINERS_RYUK_DISABLED="true" mvn -q verify` (PASS)
- `npm --prefix "frontend/h5" run build` (PASS)
- `npm --prefix "frontend/admin" run build` (PASS)
- `npm --prefix "frontend/h5" run preview -- --host 127.0.0.1 --port 4173 --strictPort` (PASS with escalated permissions)
- `npm --prefix "frontend/admin" run preview -- --host 127.0.0.1 --port 4174 --strictPort` (PASS with escalated permissions)
### Risks and Follow-ups
- `vite preview` 绑定端口需 escalated 权限;如需 CI 运行,建议在非受限环境执行或调整权限策略。

View File

@@ -1,7 +0,0 @@
# Ralph Loop Task List
## 2026-01-28 Final Acceptance + Preview
- [x] Task 1: 生成最终验收/回顾报告(基于最新任务与测试结果)
- [x] Task 2: 全量回归 `mvn -q verify`Podman/Testcontainers
- [x] Task 3: 启动系统预览测试H5/Admin build + preview

View File

@@ -1,90 +0,0 @@
package com.mosquito.project.controller;
import com.mosquito.project.dto.ApiKeyCreateRequest;
import com.mosquito.project.dto.ApiKeyResponse;
import com.mosquito.project.service.ApiKeySecurityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
/**
* API密钥安全控制器
* 提供密钥的恢复、轮换等安全功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/api-keys")
@Tag(name = "API Key Security", description = "API密钥安全管理")
@RequiredArgsConstructor
public class ApiKeySecurityController {
private final ApiKeySecurityService apiKeySecurityService;
/**
* 重新显示API密钥
*/
@PostMapping("/{id}/reveal")
@Operation(summary = "重新显示API密钥", description = "在验证权限后重新显示API密钥")
public ResponseEntity<ApiKeyResponse> revealApiKey(
@PathVariable Long id,
@RequestBody Map<String, String> request) {
String verificationCode = request.get("verificationCode");
Optional<String> rawKey = apiKeySecurityService.revealApiKey(id, verificationCode);
if (rawKey.isPresent()) {
log.info("API key revealed successfully for id: {}", id);
return ResponseEntity.ok(
new ApiKeyResponse("API密钥重新显示成功", rawKey.get())
);
} else {
return ResponseEntity.notFound().build();
}
}
/**
* 轮换API密钥
*/
@PostMapping("/{id}/rotate")
@Operation(summary = "轮换API密钥", description = "撤销旧密钥并生成新密钥")
public ResponseEntity<ApiKeyResponse> rotateApiKey(
@PathVariable Long id) {
try {
var newApiKey = apiKeySecurityService.rotateApiKey(id);
log.info("API key rotated successfully for id: {}", id);
return ResponseEntity.ok(
new ApiKeyResponse("API密钥轮换成功",
"新密钥已生成,请妥善保存。旧密钥已撤销。")
);
} catch (Exception e) {
log.error("Failed to rotate API key: {}", id, e);
return ResponseEntity.badRequest()
.body(new ApiKeyResponse("轮换失败", e.getMessage()));
}
}
/**
* 获取API密钥使用信息
*/
@GetMapping("/{id}/info")
@Operation(summary = "获取API密钥信息", description = "获取API密钥的使用统计和安全状态")
public ResponseEntity<Map<String, Object>> getApiKeyInfo(@PathVariable Long id) {
// 这里可以添加密钥使用统计、最后访问时间等信息
Map<String, Object> info = Map.of(
"apiKeyId", id,
"status", "active",
"lastAccess", System.currentTimeMillis(),
"rotationAvailable", true
);
return ResponseEntity.ok(info);
}
}

View File

@@ -1,115 +0,0 @@
package com.mosquito.project.interceptor;
import com.mosquito.project.exception.RateLimitExceededException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;
/**
* 分布式速率限制拦截器
* 生产环境强制使用Redis进行限流
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final RedisTemplate<String, Object> redisTemplate;
@Value("${app.rate-limit.per-minute:100}")
private int perMinuteLimit;
@Value("${app.rate-limit.window-size:1}")
private int windowSizeMinutes;
@Value("${spring.profiles.active:dev}")
private String activeProfile;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 生产环境强制使用Redis
if ("prod".equals(activeProfile)) {
if (redisTemplate == null) {
log.error("Production mode requires Redis for rate limiting, but Redis is not configured");
throw new IllegalStateException("Production环境必须配置Redis进行速率限制");
}
return checkRateLimitWithRedis(request);
} else {
log.debug("Development mode: rate limiting using Redis (if available)");
return checkRateLimitWithRedis(request);
}
}
/**
* 使用Redis进行分布式速率限制
*/
private boolean checkRateLimitWithRedis(HttpServletRequest request) {
String clientIp = getClientIp(request);
String endpoint = request.getRequestURI();
String key = String.format("rate_limit:%s:%s", clientIp, endpoint);
try {
// Redis原子操作检查并设置
Long currentCount = (Long) redisTemplate.opsForValue().increment(key);
if (currentCount == 1) {
// 第一次访问,设置过期时间
redisTemplate.expire(key, windowSizeMinutes, TimeUnit.MINUTES);
log.debug("Rate limit counter initialized for key: {}", key);
}
if (currentCount > perMinuteLimit) {
log.warn("Rate limit exceeded for client: {}, endpoint: {}, count: {}",
clientIp, endpoint, currentCount);
throw new RateLimitExceededException(
String.format("请求过于频繁,请%d分钟后再试", windowSizeMinutes));
}
log.debug("Rate limit check passed for client: {}, count: {}", clientIp, currentCount);
return true;
} catch (Exception e) {
log.error("Redis rate limiting failed, falling back to allow: {}", e.getMessage());
// Redis故障时允许请求通过但记录警告
return true;
}
}
/**
* 获取客户端真实IP地址
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 处理多个IP的情况X-Forwarded-For可能包含多个IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip != null ? ip : "unknown";
}
}

View File

@@ -1,181 +0,0 @@
package com.mosquito.project.service;
import com.mosquito.project.persistence.entity.ApiKeyEntity;
import com.mosquito.project.persistence.repository.ApiKeyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Optional;
/**
* API密钥安全管理服务
* 提供密钥的加密存储、恢复和轮换功能
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeySecurityService {
private final ApiKeyRepository apiKeyRepository;
@Value("${app.security.encryption-key:}")
private String encryptionKey;
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int TAG_LENGTH_BIT = 128;
private static final int IV_LENGTH_BYTE = 12;
/**
* 生成新的API密钥并加密存储
*/
@Transactional
public ApiKeyEntity generateAndStoreApiKey(Long activityId, String description) {
String rawApiKey = generateRawApiKey();
String encryptedKey = encrypt(rawApiKey);
ApiKeyEntity apiKey = new ApiKeyEntity();
apiKey.setActivityId(activityId);
apiKey.setDescription(description);
apiKey.setEncryptedKey(encryptedKey);
apiKey.setIsActive(true);
apiKey.setCreatedAt(java.time.LocalDateTime.now());
return apiKeyRepository.save(apiKey);
}
/**
* 解密API密钥仅用于重新显示
*/
public String decryptApiKey(ApiKeyEntity apiKey) {
try {
return decrypt(apiKey.getEncryptedKey());
} catch (Exception e) {
log.error("Failed to decrypt API key for id: {}", apiKey.getId(), e);
throw new RuntimeException("API密钥解密失败");
}
}
/**
* 重新显示API密钥需要额外验证
*/
@Transactional(readOnly = true)
public Optional<String> revealApiKey(Long apiKeyId, String verificationCode) {
ApiKeyEntity apiKey = apiKeyRepository.findById(apiKeyId)
.orElseThrow(() -> new RuntimeException("API密钥不存在"));
// 验证密钥状态
if (!apiKey.getIsActive()) {
throw new RuntimeException("API密钥已被撤销");
}
// 验证访问权限(这里可以添加邮箱/手机验证逻辑)
if (!verifyAccessPermission(apiKey, verificationCode)) {
log.warn("Unauthorized attempt to reveal API key: {}", apiKeyId);
throw new RuntimeException("访问权限验证失败");
}
return Optional.of(decryptApiKey(apiKey));
}
/**
* 轮换API密钥
*/
@Transactional
public ApiKeyEntity rotateApiKey(Long apiKeyId) {
ApiKeyEntity oldKey = apiKeyRepository.findById(apiKeyId)
.orElseThrow(() -> new RuntimeException("API密钥不存在"));
// 撤销旧密钥
oldKey.setIsActive(false);
oldKey.setRevokedAt(java.time.LocalDateTime.now());
apiKeyRepository.save(oldKey);
// 生成新密钥
return generateAndStoreApiKey(oldKey.getActivityId(),
oldKey.getDescription() + " (轮换)");
}
/**
* 生成原始API密钥
*/
private String generateRawApiKey() {
return java.util.UUID.randomUUID().toString() + "-" +
java.util.UUID.randomUUID().toString();
}
/**
* 加密密钥
*/
private String encrypt(String data) {
try {
byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8);
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
byte[] iv = new byte[IV_LENGTH_BYTE];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
// IV + encrypted data
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
log.error("Encryption failed", e);
throw new RuntimeException("加密失败");
}
}
/**
* 解密密钥
*/
private String decrypt(String encryptedData) {
try {
byte[] combined = Base64.getDecoder().decode(encryptedData);
// 提取IV和加密数据
byte[] iv = new byte[IV_LENGTH_BYTE];
byte[] encrypted = new byte[combined.length - IV_LENGTH_BYTE];
System.arraycopy(combined, 0, iv, 0, IV_LENGTH_BYTE);
System.arraycopy(combined, IV_LENGTH_BYTE, encrypted, 0, encrypted.length);
byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8);
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Decryption failed", e);
throw new RuntimeException("解密失败");
}
}
/**
* 验证访问权限(可扩展为邮箱/手机验证)
*/
private boolean verifyAccessPermission(ApiKeyEntity apiKey, String verificationCode) {
// 这里可以实现复杂的验证逻辑
// 例如:验证邮箱验证码、手机验证码、安全问题等
return true; // 简化实现
}
}

View File

@@ -1,487 +0,0 @@
package com.mosquito.project.service;
import com.mosquito.project.config.PosterConfig;
import com.mosquito.project.domain.Activity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.awt.*;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* PosterRenderService 边界条件和异常处理测试
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("PosterRenderService 边界测试")
class PosterRenderServiceBoundaryTest {
@Mock
private PosterConfig posterConfig;
@Mock
private ShortLinkService shortLinkService;
@Mock
private PosterConfig.PosterTemplate mockTemplate;
@InjectMocks
private PosterRenderService posterRenderService;
private Activity testActivity;
@BeforeEach
void setUp() throws Exception {
testActivity = new Activity();
testActivity.setId(123L);
testActivity.setName("测试活动");
// 清除图片缓存
Field imageCacheField = PosterRenderService.class.getDeclaredField("imageCache");
imageCacheField.setAccessible(true);
Map<String, Image> imageCache = (Map<String, Image>) imageCacheField.get(posterRenderService);
imageCache.clear();
}
@Nested
@DisplayName("renderPoster边界条件测试")
class RenderPosterBoundaryTests {
@Test
@DisplayName("null模板名应该使用默认模板")
void shouldUseDefaultTemplate_WhenTemplateNameIsNull() {
// Given
String defaultTemplateName = "default";
when(posterConfig.getTemplate(null)).thenReturn(null);
when(posterConfig.getTemplate(defaultTemplateName)).thenReturn(mockTemplate);
when(posterConfig.getDefaultTemplate()).thenReturn(defaultTemplateName);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, null);
// Then
assertNotNull(result);
assertTrue(result.length > 0);
verify(posterConfig).getTemplate(null);
verify(posterConfig).getTemplate(defaultTemplateName);
}
@Test
@DisplayName("不存在的模板名应该使用默认模板")
void shouldUseDefaultTemplate_WhenTemplateNotFound() {
// Given
String invalidTemplateName = "nonexistent";
String defaultTemplateName = "default";
when(posterConfig.getTemplate(invalidTemplateName)).thenReturn(null);
when(posterConfig.getTemplate(defaultTemplateName)).thenReturn(mockTemplate);
when(posterConfig.getDefaultTemplate()).thenReturn(defaultTemplateName);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, invalidTemplateName);
// Then
assertNotNull(result);
verify(posterConfig).getTemplate(invalidTemplateName);
verify(posterConfig).getTemplate(defaultTemplateName);
}
@Test
@DisplayName("模板背景为空字符串应该使用背景色")
void shouldUseBackgroundColor_WhenTemplateBackgroundIsEmpty() {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackground()).thenReturn("");
when(mockTemplate.getBackgroundColor()).thenReturn("#FF0000");
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
// Then
assertNotNull(result);
assertTrue(result.length > 0);
}
@Test
@DisplayName("模板背景为null应该使用背景色")
void shouldUseBackgroundColor_WhenTemplateBackgroundIsNull() {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackground()).thenReturn(null);
when(mockTemplate.getBackgroundColor()).thenReturn("#00FF00");
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
// Then
assertNotNull(result);
assertTrue(result.length > 0);
}
@Test
@DisplayName("模板没有背景设置应该使用背景色")
void shouldUseBackgroundColor_WhenTemplateHasNoBackground() {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#0000FF");
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
// Then
assertNotNull(result);
assertTrue(result.length > 0);
}
@Test
@DisplayName("图片加载失败应该使用背景色")
void shouldUseBackgroundColor_WhenImageLoadFails() {
// Given
String invalidImageUrl = "nonexistent-image.jpg";
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackground()).thenReturn(invalidImageUrl);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFAA00");
when(posterConfig.getCdnBaseUrl()).thenReturn("https://cdn.example.com");
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
// Then
assertNotNull(result);
assertTrue(result.length > 0);
}
@Test
@DisplayName("空元素列表应该正常渲染")
void shouldRenderSuccessfully_WhenElementsListIsEmpty() throws Exception {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
// 设置空元素map
Field elementsField = PosterConfig.PosterTemplate.class.getDeclaredField("elements");
elementsField.setAccessible(true);
elementsField.set(mockTemplate, new ConcurrentHashMap<>());
// When
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
// Then
assertNotNull(result);
assertTrue(result.length > 0);
}
@ParameterizedTest
@ValueSource(strings = {"#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#000000"})
@DisplayName("不同背景色应该正确渲染")
void shouldRenderCorrectly_WithDifferentBackgroundColors(String backgroundColor) {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn(backgroundColor);
// When & Then
assertDoesNotThrow(() -> {
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
assertNotNull(result);
assertTrue(result.length > 0);
});
}
}
@Nested
@DisplayName("renderPosterHtml边界条件测试")
class RenderPosterHtmlBoundaryTests {
@Test
@DisplayName("null活动名称应该使用默认值")
void shouldUseDefaultTitle_WhenActivityNameIsNull() {
// Given
Activity nullNameActivity = new Activity();
nullNameActivity.setId(123L);
nullNameActivity.setName(null);
setupMockTemplate();
when(shortLinkService.create(anyString())).thenReturn(new com.mosquito.project.domain.ShortLink());
when(shortLinkService.create(anyString())).thenReturn(new com.mosquito.project.domain.ShortLink());
// 使用反射设置模拟活动
// 注意这里需要更复杂的反射来模拟activityService的返回值
// When
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
// Then
assertNotNull(html);
assertTrue(html.contains("<title>分享</title>"));
}
@Test
@DisplayName("空活动名称应该使用默认值")
void shouldUseDefaultTitle_WhenActivityNameIsEmpty() {
// Given
Activity emptyNameActivity = new Activity();
emptyNameActivity.setId(123L);
emptyNameActivity.setName("");
setupMockTemplate();
// When
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
// Then
assertNotNull(html);
assertTrue(html.contains("<title>分享</title>"));
}
@Test
@DisplayName("活动名称包含特殊字符应该正确转义")
void shouldEscapeHtml_WhenActivityNameContainsSpecialChars() {
// Given
Activity specialCharActivity = new Activity();
specialCharActivity.setId(123L);
specialCharActivity.setName("活动名称 & <script> alert('xss') </script>\"");
setupMockTemplate();
// When
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
// Then
assertNotNull(html);
assertFalse(html.contains("<script>"));
assertTrue(html.contains("&lt;script&gt;"));
}
@Test
@DisplayName("URL编码失败应该使用原始URL")
void shouldUseOriginalUrl_WhenUrlEncodingFails() {
// Given
setupMockTemplate();
setupQrCodeElement();
// When
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
// Then
assertNotNull(html);
assertTrue(html.contains("data="));
// 确保即使编码失败也生成HTML
}
@Test
@DisplayName("长活动名称应该正确处理")
void shouldHandleLongActivityName() {
// Given
StringBuilder longName = new StringBuilder();
for (int i = 0; i < 1000; i++) {
longName.append("很长的活动名称");
}
Activity longNameActivity = new Activity();
longNameActivity.setId(123L);
longNameActivity.setName(longName.toString());
setupMockTemplate();
// When
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
// Then
assertNotNull(html);
assertTrue(html.length() > 0);
}
private void setupMockTemplate() {
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
}
private void setupQrCodeElement() {
PosterConfig.PosterElement qrElement = new PosterConfig.PosterElement();
qrElement.setType("qrcode");
qrElement.setX(100);
qrElement.setY(100);
qrElement.setWidth(200);
qrElement.setHeight(200);
Map<String, PosterConfig.PosterElement> elements = Map.of("qrcode", qrElement);
when(mockTemplate.getElements()).thenReturn(elements);
}
}
@Nested
@DisplayName("异常处理测试")
class ExceptionHandlingTests {
@Test
@DisplayName("ImageIO写入失败应该抛出RuntimeException")
void shouldThrowRuntimeException_WhenImageIoWriteFails() {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(Integer.MAX_VALUE); // 极大尺寸可能导致内存错误
when(mockTemplate.getHeight()).thenReturn(Integer.MAX_VALUE);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
// When & Then
assertThrows(RuntimeException.class, () -> {
posterRenderService.renderPoster(1L, 1L, "test");
});
}
@Test
@DisplayName("无效颜色代码应该抛出异常")
void shouldThrowException_WhenColorCodeIsInvalid() {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("invalid-color");
// When & Then
assertThrows(NumberFormatException.class, () -> {
posterRenderService.renderPoster(1L, 1L, "test");
});
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("null或空模板名应该不抛出异常")
void shouldNotThrowException_WhenTemplateNameIsNullOrEmpty(String templateName) {
// Given
when(posterConfig.getTemplate(templateName)).thenReturn(null);
when(posterConfig.getTemplate("default")).thenReturn(mockTemplate);
when(posterConfig.getDefaultTemplate()).thenReturn("default");
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
// When & Then
assertDoesNotThrow(() -> {
byte[] result = posterRenderService.renderPoster(1L, 1L, templateName);
assertNotNull(result);
});
}
}
@Nested
@DisplayName("辅助方法边界测试")
class HelperMethodBoundaryTests {
@Test
@DisplayName("解析字体大小失败应该使用默认值")
void shouldUseDefaultFontSize_WhenParsingFails() throws Exception {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
PosterConfig.PosterElement textElement = new PosterConfig.PosterElement();
textElement.setType("text");
textElement.setContent("测试文本");
textElement.setFontSize("invalid-size"); // 无效的字体大小
textElement.setColor("#000000");
textElement.setFontFamily("Arial");
textElement.setX(100);
textElement.setY(100);
Map<String, PosterConfig.PosterElement> elements = Map.of("text", textElement);
when(mockTemplate.getElements()).thenReturn(elements);
// When & Then
assertDoesNotThrow(() -> {
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
assertNotNull(result);
});
}
@Test
@DisplayName("空内容字符串应该正确处理")
void shouldHandleEmptyContentString() throws Exception {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
PosterConfig.PosterElement textElement = new PosterConfig.PosterElement();
textElement.setType("text");
textElement.setContent(""); // 空内容
textElement.setFontSize("16px");
textElement.setColor("#000000");
textElement.setFontFamily("Arial");
textElement.setX(100);
textElement.setY(100);
Map<String, PosterConfig.PosterElement> elements = Map.of("text", textElement);
when(mockTemplate.getElements()).thenReturn(elements);
// When & Then
assertDoesNotThrow(() -> {
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
assertNotNull(result);
});
}
@Test
@DisplayName("null内容应该返回空字符串")
void shouldReturnEmptyString_WhenContentIsNull() throws Exception {
// Given
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
when(mockTemplate.getWidth()).thenReturn(800);
when(mockTemplate.getHeight()).thenReturn(600);
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
PosterConfig.PosterElement textElement = new PosterConfig.PosterElement();
textElement.setType("text");
textElement.setContent(null); // null内容
textElement.setFontSize("16px");
textElement.setColor("#000000");
textElement.setFontFamily("Arial");
textElement.setX(100);
textElement.setY(100);
Map<String, PosterConfig.PosterElement> elements = Map.of("text", textElement);
when(mockTemplate.getElements()).thenReturn(elements);
// When & Then
assertDoesNotThrow(() -> {
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
assertNotNull(result);
});
}
}
}