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`
|
- 配置:`GET /api/v1/me/poster/config`
|
||||||
- Query: `activityId`, `userId`, `template`(`template` 可选)
|
- Query: `activityId`, `userId`, `template`(`template` 可选)
|
||||||
- 描述:图片/HTML 端点返回二进制或 HTML;配置端点返回 `ApiResponse<PosterConfigDto>`,`data` 包含 `template`、`imageUrl`、`htmlUrl`。
|
- 描述:图片/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