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:
File diff suppressed because one or more lines are too long
682
docs/API_INTEGRATION_GUIDE.md
Normal file
682
docs/API_INTEGRATION_GUIDE.md
Normal 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();
|
||||
}
|
||||
```
|
||||
|
||||
### 场景3:Webhook回调集成
|
||||
|
||||
```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
721
docs/CONFIGURATION_GUIDE.md
Normal 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
665
docs/DEPLOYMENT_GUIDE.md
Normal 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
770
docs/DEVELOPMENT_GUIDE.md
Normal 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
|
||||
# 安装PostgreSQL(Ubuntu/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
|
||||
377
docs/api.md
377
docs/api.md
@@ -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
|
||||
**维护者**: 技术团队
|
||||
|
||||
@@ -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:
|
||||
- [[1;34mINFO[m] Scanning for projects...
|
||||
[[1;33mWARNING[m]
|
||||
[[1;33mWARNING[m] Some problems were encountered while building the effective model for com.example:mosquito:jar:0.0.1-SNAPSHOT
|
||||
[[1;33mWARNING[m] '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
|
||||
[[1;33mWARNING[m]
|
||||
[[1;33mWARNING[m] It is highly recommended to fix these problems because they threaten the stability of your build.
|
||||
[[1;33mWARNING[m]
|
||||
[[1;33mWARNING[m] For this reason, future Maven versions might no longer support building such malformed projects.
|
||||
[[1;33mWARNING[m]
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m------------------------< [0;36mcom.example:mosquito[0;1m >------------------------[m
|
||||
[[1;34mINFO[m] [1mBuilding mosquito 0.0.1-SNAPSHOT[m
|
||||
[[1;34mINFO[m] [1m--------------------------------[ jar ]---------------------------------[m
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mjacoco-maven-plugin:0.8.10:prepare-agent[m [1m(default)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] 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
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-resources-plugin:3.3.1:resources[m [1m(default-resources)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Copying 4 resources from src/main/resources to target/classes
|
||||
[[1;34mINFO[m] Copying 20 resources from src/main/resources to target/classes
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-compiler-plugin:3.11.0:compile[m [1m(default-compile)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Nothing to compile - all classes are up to date
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-resources-plugin:3.3.1:testResources[m [1m(default-testResources)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Copying 4 resources from src/test/resources to target/test-classes
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-compiler-plugin:3.11.0:testCompile[m [1m(default-testCompile)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Nothing to compile - all classes are up to date
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-surefire-plugin:3.0.0:test[m [1m(default-test)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] -------------------------------------------------------
|
||||
[[1;34mINFO[m] T E S T S
|
||||
[[1;34mINFO[m] -------------------------------------------------------
|
||||
[[1;34mINFO[m] Running com.mosquito.project.service.[1mActivityServiceCoverageTest[m
|
||||
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.
|
||||
[[1;34mINFO[m] [1;32mTests run: [0;1;32m23[m, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.998 s - in com.mosquito.project.service.[1mActivityServiceCoverageTest[m
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] Results:
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1;32mTests run: 23, Failures: 0, Errors: 0, Skipped: 0[m
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m------------------------------------------------------------------------[m
|
||||
[[1;34mINFO[m] [1;32mBUILD SUCCESS[m
|
||||
[[1;34mINFO[m] [1m------------------------------------------------------------------------[m
|
||||
[[1;34mINFO[m] Total time: 13.953 s
|
||||
[[1;34mINFO[m] Finished at: 2026-01-28T08:05:38+08:00
|
||||
[[1;34mINFO[m] [1m------------------------------------------------------------------------[m
|
||||
- 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:
|
||||
- [[1;34mINFO[m] Scanning for projects...
|
||||
[[1;33mWARNING[m]
|
||||
[[1;33mWARNING[m] Some problems were encountered while building the effective model for com.example:mosquito:jar:0.0.1-SNAPSHOT
|
||||
[[1;33mWARNING[m] '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
|
||||
[[1;33mWARNING[m]
|
||||
[[1;33mWARNING[m] It is highly recommended to fix these problems because they threaten the stability of your build.
|
||||
[[1;33mWARNING[m]
|
||||
[[1;33mWARNING[m] For this reason, future Maven versions might no longer support building such malformed projects.
|
||||
[[1;33mWARNING[m]
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m------------------------< [0;36mcom.example:mosquito[0;1m >------------------------[m
|
||||
[[1;34mINFO[m] [1mBuilding mosquito 0.0.1-SNAPSHOT[m
|
||||
[[1;34mINFO[m] [1m--------------------------------[ jar ]---------------------------------[m
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mjacoco-maven-plugin:0.8.10:prepare-agent[m [1m(default)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] 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
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-resources-plugin:3.3.1:resources[m [1m(default-resources)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Copying 4 resources from src/main/resources to target/classes
|
||||
[[1;34mINFO[m] Copying 20 resources from src/main/resources to target/classes
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-compiler-plugin:3.11.0:compile[m [1m(default-compile)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Nothing to compile - all classes are up to date
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-resources-plugin:3.3.1:testResources[m [1m(default-testResources)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Copying 4 resources from src/test/resources to target/test-classes
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-compiler-plugin:3.11.0:testCompile[m [1m(default-testCompile)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Nothing to compile - all classes are up to date
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m--- [0;32mmaven-surefire-plugin:3.0.0:test[m [1m(default-test)[m @ [36mmosquito[0;1m ---[m
|
||||
[[1;34mINFO[m] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] -------------------------------------------------------
|
||||
[[1;34mINFO[m] T E S T S
|
||||
[[1;34mINFO[m] -------------------------------------------------------
|
||||
[[1;34mINFO[m] Running com.mosquito.project.service.[1mActivityServiceCoverageTest[m
|
||||
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.
|
||||
[[1;34mINFO[m] [1;32mTests run: [0;1;32m23[m, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.994 s - in com.mosquito.project.service.[1mActivityServiceCoverageTest[m
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] Results:
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1;32mTests run: 23, Failures: 0, Errors: 0, Skipped: 0[m
|
||||
[[1;34mINFO[m]
|
||||
[[1;34mINFO[m] [1m------------------------------------------------------------------------[m
|
||||
[[1;34mINFO[m] [1;32mBUILD SUCCESS[m
|
||||
[[1;34mINFO[m] [1m------------------------------------------------------------------------[m
|
||||
[[1;34mINFO[m] Total time: 13.874 s
|
||||
[[1;34mINFO[m] Finished at: 2026-01-28T08:06:26+08:00
|
||||
[[1;34mINFO[m] [1m------------------------------------------------------------------------[m
|
||||
- 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.
|
||||
@@ -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 运行,建议在非受限环境执行或调整权限策略。
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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; // 简化实现
|
||||
}
|
||||
}
|
||||
@@ -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("<script>"));
|
||||
}
|
||||
|
||||
@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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user