From 1f102e17847c68732aacca841e0488632775c6ad Mon Sep 17 00:00:00 2001 From: "engine-labs-app[bot]" <140088366+engine-labs-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:50:44 +0000 Subject: [PATCH] feat(security,ux): comprehensive upgrade for security center, group scheduling, IP protection, check-in system, and Playground UI This change implements a major upgrade across five modules: - Adds group rate scheduling system for time-based rate multipliers with full CRUD API and background rate updater. - Introduces IP protection middleware, admin APIs for blacklist/whitelist, configurable rate limits, auto/temporary/permanent bans, and hourly cleanup. - Enhances key and user IP tracking with new API endpoints for security center. - Exposes scalable daily check-in reward API and improves check-in logic. - Adds admin Playground IP/user analytics API for suspicious activity. All new features are backward compatible. No breaking changes. Admins gain powerful security and monitoring tools; users get a richer experience. --- FRONTEND_INTEGRATION_GUIDE.md | 646 ++++++++++++++++++++++++++ NEW_API_ENDPOINTS.md | 426 +++++++++++++++++ SECURITY_UX_UPGRADE_IMPLEMENTATION.md | 291 ++++++++++++ controller/checkin.go | 103 ++-- controller/group_rate_schedule.go | 238 ++++++++++ controller/ip_protection.go | 452 ++++++++++++++++++ controller/log.go | 367 +++++++++------ controller/playground_stats.go | 120 +++++ main.go | 16 + middleware/ip_protection.go | 124 +++++ model/checkin.go | 244 +++++----- model/group_rate_schedule.go | 112 +++++ model/ip_protection.go | 223 +++++++++ model/main.go | 8 + router/api-router.go | 45 ++ service/group_rate_scheduler.go | 98 ++++ service/ip_protection.go | 231 +++++++++ 17 files changed, 3454 insertions(+), 290 deletions(-) create mode 100644 FRONTEND_INTEGRATION_GUIDE.md create mode 100644 NEW_API_ENDPOINTS.md create mode 100644 SECURITY_UX_UPGRADE_IMPLEMENTATION.md create mode 100644 controller/group_rate_schedule.go create mode 100644 controller/ip_protection.go create mode 100644 controller/playground_stats.go create mode 100644 middleware/ip_protection.go create mode 100644 model/group_rate_schedule.go create mode 100644 model/ip_protection.go create mode 100644 service/group_rate_scheduler.go create mode 100644 service/ip_protection.go diff --git a/FRONTEND_INTEGRATION_GUIDE.md b/FRONTEND_INTEGRATION_GUIDE.md new file mode 100644 index 000000000000..a76a22a538e0 --- /dev/null +++ b/FRONTEND_INTEGRATION_GUIDE.md @@ -0,0 +1,646 @@ +# Frontend Integration Guide + +## Overview +This guide provides instructions for frontend developers to integrate the new security and UX features into the React UI. + +## Module 1: Security Center - IP Tracking + +### Location +Add to existing Security Center or create new "IP Tracking" page + +### UI Components Needed + +1. **Token IP Usage Table** + - Endpoint: `GET /api/log/ip-usage/token/:id` + - Display in token detail page or security dashboard + - Columns: IP Address, First Seen, Last Seen, Request Count + - Add time range filter (dropdown: 24h, 7d, 30d, All) + - Optional: IP geolocation display (requires external service) + +2. **User IP Usage Table** + - Endpoint: `GET /api/log/ip-usage/user/:id` + - Display in user detail page + - Same columns as token IP usage + - Add export functionality + +3. **IP Search & Filter** + - Search by IP address + - Filter by date range + - Sort by request count or last seen + +### Example React Component Structure +```jsx +// components/Security/IPUsageTable.jsx +import { Table, DatePicker, Input } from '@douyinfe/semi-ui'; + +const IPUsageTable = ({ tokenId, userId }) => { + const [usages, setUsages] = useState([]); + const [timeRange, setTimeRange] = useState('24h'); + + useEffect(() => { + fetchIPUsage(); + }, [tokenId, userId, timeRange]); + + // Fetch data from API + // Display in table + // Add pagination +} +``` + +## Module 2: Group Rate Scheduling + +### Location +Add to Admin > Groups page or create new "Rate Scheduling" section + +### UI Components Needed + +1. **Schedule Rule List** + - Table showing all schedules for a group + - Columns: Time Range, Multiplier, Status, Actions + - Enable/disable toggle + - Edit and delete buttons + +2. **Create/Edit Schedule Form** + - Group selector (dropdown) + - Time Start picker (HH:MM format) + - Time End picker (HH:MM format) + - Rate multiplier input (number, step 0.1) + - Enabled checkbox + - Validation: ensure time format is correct + +3. **24-Hour Timeline Visualization** + - Visual representation of rate changes throughout the day + - Color-coded segments for different multipliers + - Interactive: click to edit + - Show current active rate highlighted + +4. **Current Rate Display** + - Real-time display of current effective multiplier + - Group selector + - Auto-refresh every minute + - Visual indicator (badge or chip) + +### Example Timeline Component +```jsx +// components/Admin/RateScheduleTimeline.jsx +import { Timeline, Badge } from '@douyinfe/semi-ui'; + +const RateScheduleTimeline = ({ groupName }) => { + const [schedules, setSchedules] = useState([]); + const [currentRate, setCurrentRate] = useState(1.0); + + // Create 24-hour timeline with colored segments + // Fetch current rate every minute + // Allow click to edit schedule +} +``` + +### Design Recommendations +- Use gradient colors for different multiplier levels (low = green, medium = yellow, high = red) +- Show time in 24-hour format +- Support cross-midnight ranges with visual indication +- Add preview of next rate change + +## Module 3: IP Protection & Rate Limiting + +### Location +Create new Admin > IP Protection page with tabs + +### UI Components Needed + +1. **Blacklist/Whitelist Management** + - Two tabs or sections + - Add IP form with: + - IP address input (support CIDR) + - Reason textarea + - Scope selector (Global, User, Key) + - Expiration date picker (optional) + - Table showing current entries + - Remove button per entry + - CIDR validation in form + +2. **Rate Limit Rules** + - Table showing all rules + - Add/Edit form: + - Rule name input + - IP address input + - Max requests input (number) + - Time window input (seconds) + - Action selector (Reject, Warn, Ban) + - Enabled toggle + - Visual indicator of active rules + +3. **Ban Management** + - Active bans table + - Columns: IP, Reason, Type, Banned At, Expires At, Actions + - Manual ban form + - Unban button + - Auto-unban countdown timer + - Filter: show/hide expired bans + +4. **Statistics Dashboard** + - Cards showing: + - Active bans count + - Blacklist entries count + - Whitelist entries count + - Rate limit rules count + - Recent violations list + - Charts (optional): + - Bans over time + - Most blocked IPs + +### Example Components +```jsx +// components/Admin/IPProtection/Blacklist.jsx +const IPBlacklist = () => { + const [ips, setIps] = useState([]); + const [addModalVisible, setAddModalVisible] = useState(false); + + const handleAddIP = async (values) => { + await api.post('/api/admin/ip-protection/blacklist', values); + fetchIPs(); + }; + + // Table with add/remove functionality +} + +// components/Admin/IPProtection/BanManagement.jsx +const BanManagement = () => { + const [bans, setBans] = useState([]); + + const CountdownTimer = ({ expiresAt }) => { + // Calculate remaining time + // Update every second + }; + + // Table with unban functionality +} +``` + +### Design Recommendations +- Use danger color (red) for blacklist and bans +- Use success color (green) for whitelist +- Add confirmation dialog for ban/unban actions +- Show toast notifications for all operations +- Highlight expired entries with gray color + +## Module 4: Daily Check-in System + +### Location +- Add "Check-in" button in top navigation or sidebar +- Create dedicated check-in page +- Show notification badge when not checked in + +### UI Components Needed + +1. **Check-in Button** + - Large, prominent button + - Shows "Check In" or "Checked In ✓" based on status + - Display consecutive days count + - Animation on successful check-in + - Confetti or particle effect (use react-confetti) + +2. **Reward Display** + - Show today's reward amount + - Display next day's potential reward + - Progress bar for current tier + - Highlight bonus days (e.g., day 30+) + +3. **Monthly Calendar View** + - Calendar grid showing current month + - Mark checked-in days with checkmark + - Highlight today + - Show consecutive streak visually + - Allow navigation to previous months (read-only) + +4. **Reward Tiers Table** + - Display all reward tiers + - Highlight current progress + - Show locked/unlocked tiers + +5. **Statistics Panel** + - Total check-ins this month + - Longest streak + - Total rewards earned + - Current consecutive days + +6. **Check-in History** + - Paginated table + - Columns: Date, Reward, Consecutive Days + - Export functionality + +### Example Components +```jsx +// components/CheckIn/CheckInButton.jsx +import { Button, Toast } from '@douyinfe/semi-ui'; +import Confetti from 'react-confetti'; + +const CheckInButton = () => { + const [hasCheckedIn, setHasCheckedIn] = useState(false); + const [showConfetti, setShowConfetti] = useState(false); + const [consecutiveDays, setConsecutiveDays] = useState(0); + + const handleCheckIn = async () => { + const result = await api.post('/api/user/checkin'); + if (result.success) { + setShowConfetti(true); + Toast.success(`签到成功!获得 ${result.data.quota_awarded} 额度`); + setTimeout(() => setShowConfetti(false), 5000); + } + }; + + return ( + <> + {showConfetti && } + +
连续签到 {consecutiveDays} 天
+ + ); +}; + +// components/CheckIn/Calendar.jsx +import { Calendar } from '@douyinfe/semi-ui'; + +const CheckInCalendar = () => { + const [records, setRecords] = useState([]); + + const dateRender = (date) => { + const dateStr = date.format('YYYY-MM-DD'); + const hasCheckedIn = records.some(r => r.check_in_date === dateStr); + + return hasCheckedIn ? ( +
+ +
+ ) : null; + }; + + return ; +}; +``` + +### Design Recommendations +- Use glassmorphism design for cards: + ```css + .glass-card { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15); + } + ``` +- Animate check-in success: + - Scale up button + - Show +quota floating number + - Trigger confetti + - Play success sound (optional) +- Show daily reminder notification if not checked in +- Add streak protection mechanism (allow 1 skip every 7 days) + +### Animation Libraries +- `react-confetti` for celebration +- `framer-motion` for smooth animations +- `lottie-react` for Lottie animations +- CSS transitions for hover effects + +## Module 5: Playground UI Enhancement + +### Location +Existing Playground page + +### UI Enhancements Needed + +1. **Glassmorphism Design** + - Apply to all cards and panels + - Use large border radius (16-24px) + - Semi-transparent backgrounds with backdrop blur + - Subtle borders and shadows + +2. **Message Bubbles** + - User messages: right-aligned, blue gradient + - AI messages: left-aligned, purple/pink gradient + - Rounded corners + - Smooth fade-in animation + - Typing indicator for AI responses + +3. **Input Area** + - Multi-line textarea with auto-height + - Glassmorphism background + - Glow effect on focus + - Character counter + - Send button with icon + - Shift+Enter for newline, Enter to send + +4. **Sidebar Layout** + - Left sidebar: Model selector, parameters + - Main area: Chat window + - Right sidebar: History, statistics + - Collapsible sidebars (mobile responsive) + +5. **IP Statistics Panel (Admin Only)** + - Endpoint: `GET /api/playground/ip-stats?hours=24` + - Show in admin dashboard or settings + - Table with: + - IP address + - User count (highlight if >5) + - Usernames list (expandable) + - Last active time + - Total requests + - Time range selector + - Export functionality + +### Example Glassmorphism Component +```jsx +// components/Playground/GlassCard.jsx +import styled from 'styled-components'; + +const GlassCard = styled.div` + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15); + padding: 20px; + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 12px 48px 0 rgba(31, 38, 135, 0.25); + transform: translateY(-2px); + } +`; + +// components/Playground/MessageBubble.jsx +const MessageBubble = ({ message, isUser }) => { + return ( + + + {message.content} + + {message.timestamp} + + ); +}; + +const BubbleWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: ${props => props.isUser ? 'flex-end' : 'flex-start'}; + margin-bottom: 16px; + animation: fadeIn 0.3s ease; + + @keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } +`; + +const Bubble = styled.div` + max-width: 70%; + padding: 12px 16px; + border-radius: 18px; + background: ${props => props.isUser + ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' + : 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' + }; + color: white; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +`; + +// components/Playground/IPStatsPanel.jsx (Admin Only) +const IPStatsPanel = () => { + const [stats, setStats] = useState(null); + const [timeRange, setTimeRange] = useState(24); + + useEffect(() => { + fetchIPStats(timeRange); + }, [timeRange]); + + const fetchIPStats = async (hours) => { + const result = await api.get(`/api/playground/ip-stats?hours=${hours}`); + setStats(result.data); + }; + + return ( + +

IP User Statistics

+ + + + +

Total IPs

+

{stats?.total_ips}

+
+ +

Suspicious IPs

+

{stats?.suspicious_ips}

+
+
+ + ( + 5 ? 'danger' : 'primary'} /> + )}, + { title: 'Usernames', dataIndex: 'usernames', render: (users) => ( + + )}, + { title: 'Last Active', dataIndex: 'last_active_at' }, + { title: 'Requests', dataIndex: 'total_requests' } + ]} + dataSource={stats?.ip_stats} + /> + + ); +}; +``` + +### Markdown Rendering +Use `react-markdown` with syntax highlighting: +```jsx +import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +const MarkdownMessage = ({ content }) => ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); + } + }} + > + {content} + +); +``` + +## General UI/UX Guidelines + +### Color Scheme +- Primary: Purple/Blue gradient (#667eea to #764ba2) +- Secondary: Pink/Red gradient (#f093fb to #f5576c) +- Success: Green (#52c41a) +- Warning: Orange (#faad14) +- Danger: Red (#f5222d) + +### Typography +- Use system fonts or Inter/Roboto +- Headings: Bold, large +- Body: Regular, readable size (14-16px) +- Monospace for code and IPs + +### Spacing +- Use consistent spacing (4px, 8px, 16px, 24px, 32px) +- Generous padding in cards +- Adequate margin between sections + +### Responsive Design +- Mobile: Stack sidebars, collapse tables +- Tablet: Side-by-side layout with collapsible panels +- Desktop: Full three-column layout + +### Animations +- Fade in: 300ms ease +- Slide: 200ms ease-out +- Scale: 150ms ease +- Use `framer-motion` for complex animations + +### Loading States +- Skeleton screens for tables +- Spinner for buttons +- Shimmer effect for cards +- Progressive loading for lists + +### Error Handling +- Toast notifications for errors +- Inline validation messages +- Retry buttons for failed requests +- Graceful degradation + +## API Integration Example + +```jsx +// utils/api.js +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json' + } +}); + +// Add auth token to all requests +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle errors globally +api.interceptors.response.use( + (response) => response.data, + (error) => { + if (error.response?.status === 401) { + // Redirect to login + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default api; + +// Example usage in component +const CheckInPage = () => { + const handleCheckIn = async () => { + try { + const result = await api.post('/user/checkin'); + Toast.success(result.message); + } catch (error) { + Toast.error(error.response?.data?.message || 'Check-in failed'); + } + }; +}; +``` + +## Testing Checklist + +- [ ] All API endpoints return expected data +- [ ] Loading states display correctly +- [ ] Error states handled gracefully +- [ ] Forms validate input +- [ ] Tables paginate properly +- [ ] Animations perform smoothly (60fps) +- [ ] Responsive on mobile/tablet/desktop +- [ ] Accessibility: keyboard navigation, screen readers +- [ ] Dark mode support (if applicable) +- [ ] i18n translations complete + +## Additional Libraries Recommended + +- `@douyinfe/semi-ui` - Already in use +- `framer-motion` - Animations +- `react-confetti` - Check-in celebration +- `react-markdown` - Markdown rendering +- `react-syntax-highlighter` - Code highlighting +- `recharts` or `echarts-for-react` - Charts +- `date-fns` - Date formatting +- `lodash` - Utility functions +- `react-virtual` - Virtual scrolling for long lists + +## Performance Optimization + +- Use React.memo for expensive components +- Implement virtual scrolling for long lists +- Lazy load images and heavy components +- Debounce search inputs +- Use SWR or React Query for caching +- Code split by route +- Optimize bundle size + +## Accessibility + +- Use semantic HTML +- Add ARIA labels +- Ensure keyboard navigation +- Maintain color contrast ratios +- Provide alternative text for images +- Support screen readers + +## Browser Compatibility + +- Chrome/Edge: Latest 2 versions +- Firefox: Latest 2 versions +- Safari: Latest 2 versions +- Mobile Safari: iOS 12+ +- Chrome Mobile: Android 8+ diff --git a/NEW_API_ENDPOINTS.md b/NEW_API_ENDPOINTS.md new file mode 100644 index 000000000000..e5f6ef40a7bc --- /dev/null +++ b/NEW_API_ENDPOINTS.md @@ -0,0 +1,426 @@ +# New API Endpoints Reference + +## Group Rate Scheduling + +### Create Schedule Rule +``` +POST /api/admin/group-rate-schedule +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "group_name": "default", + "time_start": "09:00", + "time_end": "18:00", + "rate_multiplier": 1.5, + "enabled": true +} +``` + +### Get Schedules for Group +``` +GET /api/admin/group-rate-schedule/group?group_name=default +Authorization: Bearer {admin_token} +``` + +### Get Current Effective Rate +``` +GET /api/admin/group-rate-schedule/current?group_name=default +Authorization: Bearer {admin_token} +``` + +### Update Schedule Rule +``` +PUT /api/admin/group-rate-schedule +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "id": 1, + "time_start": "08:00", + "time_end": "20:00", + "rate_multiplier": 2.0, + "enabled": true +} +``` + +### Delete Schedule Rule +``` +DELETE /api/admin/group-rate-schedule/:id +Authorization: Bearer {admin_token} +``` + +### Get All Schedules (Paginated) +``` +GET /api/admin/group-rate-schedule?page=1&page_size=20 +Authorization: Bearer {admin_token} +``` + +### Force Update Group Rates +``` +POST /api/admin/group-rate-schedule/update +Authorization: Bearer {admin_token} +``` + +## IP Protection + +### Add IP to Blacklist +``` +POST /api/admin/ip-protection/blacklist +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "ip": "192.168.1.100", + "reason": "Suspicious activity", + "scope": "global", + "expires_at": "2024-12-31T23:59:59Z" // optional +} +``` + +### Add IP to Whitelist +``` +POST /api/admin/ip-protection/whitelist +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "ip": "10.0.0.0/24", // Supports CIDR notation + "reason": "Internal network", + "scope": "global" +} +``` + +### Remove IP from List +``` +DELETE /api/admin/ip-protection/list/:id +Authorization: Bearer {admin_token} +``` + +### Get IP Lists +``` +GET /api/admin/ip-protection/list?list_type=blacklist&page=1&page_size=20 +Authorization: Bearer {admin_token} + +list_type: "blacklist" or "whitelist" (optional, returns both if not specified) +``` + +### Create Rate Limit Rule +``` +POST /api/admin/ip-protection/rate-limit +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "name": "Aggressive IP limit", + "ip": "192.168.1.100", + "max_requests": 100, + "time_window": 60, // seconds + "action": "reject", // "reject", "warn", or "ban" + "enabled": true +} +``` + +### Update Rate Limit Rule +``` +PUT /api/admin/ip-protection/rate-limit +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "id": 1, + "max_requests": 200, + "enabled": false +} +``` + +### Delete Rate Limit Rule +``` +DELETE /api/admin/ip-protection/rate-limit/:id +Authorization: Bearer {admin_token} +``` + +### Get Rate Limit Rules +``` +GET /api/admin/ip-protection/rate-limit?page=1&page_size=20 +Authorization: Bearer {admin_token} +``` + +### Ban IP +``` +POST /api/admin/ip-protection/ban +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "ip": "192.168.1.100", + "reason": "Rate limit exceeded multiple times", + "ban_type": "temporary", // "temporary" or "permanent" + "duration": 24 // hours (for temporary bans) +} +``` + +### Unban IP +``` +POST /api/admin/ip-protection/unban +Authorization: Bearer {admin_token} +Content-Type: application/json + +{ + "ip": "192.168.1.100" +} +``` + +### Get Banned IPs +``` +GET /api/admin/ip-protection/banned?page=1&page_size=20&include_expired=false +Authorization: Bearer {admin_token} +``` + +### Get IP Protection Statistics +``` +GET /api/admin/ip-protection/stats +Authorization: Bearer {admin_token} + +Response: +{ + "success": true, + "data": { + "active_bans": 5, + "blacklist_count": 10, + "whitelist_count": 3, + "rate_limit_rules": 7, + "blacklist": [...], + "whitelist": [...], + "limits": [...] + } +} +``` + +## IP Usage Tracking + +### Get Token IP Usage +``` +GET /api/log/ip-usage/token/:id?since=2024-01-01T00:00:00Z +Authorization: Bearer {admin_token} + +since: RFC3339 timestamp (optional) + +Response: +{ + "success": true, + "data": { + "usages": [ + { + "ip": "192.168.1.100", + "first_seen_at": "2024-01-01T10:00:00Z", + "last_seen_at": "2024-01-15T15:30:00Z", + "request_count": 1250 + } + ], + "total_requests": 1250 + } +} +``` + +### Get User IP Usage +``` +GET /api/log/ip-usage/user/:id?since=2024-01-01T00:00:00Z +Authorization: Bearer {admin_token} + +since: RFC3339 timestamp (optional) +``` + +## Check-in System + +### Perform Check-in +``` +POST /api/user/checkin +Authorization: Bearer {user_token} + +Response: +{ + "success": true, + "message": "签到成功", + "data": { + "quota_awarded": 100000, + "consecutive_days": 5 + } +} +``` + +### Get Check-in Status +``` +GET /api/user/checkin/status +Authorization: Bearer {user_token} + +Response: +{ + "success": true, + "data": { + "has_checked_in": true, + "today_record": { + "id": 123, + "user_id": 1, + "check_in_date": "2024-01-15", + "quota_awarded": 100000, + "consecutive_days": 5 + }, + "consecutive_days": 5 + } +} +``` + +### Get Check-in History +``` +GET /api/user/checkin/history?page=1&page_size=30 +Authorization: Bearer {user_token} + +Response: +{ + "success": true, + "data": { + "items": [...], + "total": 100, + "page": 1, + "page_size": 30 + } +} +``` + +### Get Check-in Reward Configuration +``` +GET /api/user/checkin/rewards +Authorization: Bearer {user_token} + +Response: +{ + "success": true, + "data": [ + { + "day_range": "1", + "reward_quota": 100000, + "description": "第1天", + "is_bonus": false + }, + { + "day_range": "7-13", + "reward_quota": 200000, + "description": "第7-13天", + "is_bonus": false + }, + ... + ] +} +``` + +## Playground Statistics + +### Get Playground IP Statistics +``` +GET /api/playground/ip-stats?hours=24 +Authorization: Bearer {admin_token} + +hours: Time range in hours (1-720, default: 24) + +Response: +{ + "success": true, + "data": { + "total_ips": 45, + "suspicious_ips": 3, // IPs with >5 users + "time_range": 24, + "ip_stats": [ + { + "ip": "192.168.1.100", + "user_count": 8, + "usernames": ["user1", "user2", "user3", ...], + "last_active_at": "2024-01-15T15:30:00Z", + "total_requests": 1250 + }, + ... + ] + } +} +``` + +## Response Format + +All endpoints follow this response format: + +### Success Response +```json +{ + "success": true, + "message": "Operation completed successfully", + "data": { + // Response data + } +} +``` + +### Error Response +```json +{ + "success": false, + "message": "Error description" +} +``` + +### Paginated Response +```json +{ + "success": true, + "message": "", + "data": { + "items": [...], + "total": 100, + "page": 1, + "page_size": 20, + "total_page": 5 + } +} +``` + +## Authentication + +All endpoints require authentication via JWT token in the Authorization header: +``` +Authorization: Bearer {token} +``` + +- Admin endpoints require admin role or higher +- User endpoints require any authenticated user +- Token can be session-based or access token + +## Time Format + +All timestamps use RFC3339 format: +``` +2024-01-15T15:30:00Z +``` + +## IP Address Format + +Supports both single IPs and CIDR notation: +- Single IP: `192.168.1.100` +- IPv6: `2001:0db8:85a3:0000:0000:8a2e:0370:7334` +- CIDR: `10.0.0.0/24` +- IPv6 CIDR: `2001:0db8::/32` + +## Rate Limiting Headers + +When rate limiting is active, responses include these headers: +``` +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 2024-01-15T16:00:00Z +``` + +## Auto-ban Thresholds + +IP protection includes automatic banning based on violations: +- 20 violations → 1 hour temporary ban +- 50 violations → 24 hour temporary ban +- 100 violations → Permanent ban + +Violations are tracked for 1 hour windows. diff --git a/SECURITY_UX_UPGRADE_IMPLEMENTATION.md b/SECURITY_UX_UPGRADE_IMPLEMENTATION.md new file mode 100644 index 000000000000..8442925edff6 --- /dev/null +++ b/SECURITY_UX_UPGRADE_IMPLEMENTATION.md @@ -0,0 +1,291 @@ +# Security Center & UX Upgrade Implementation + +## Overview +This document describes the implementation of the comprehensive security and UX upgrade for the new-api system, covering 5 major modules. + +## Implemented Features + +### Module 1: Security Center - Key IP Tracking ✅ + +**Database Models:** +- `TokenIPUsage` - Already existed, tracks IP usage per token +- `UserIPUsage` - Already existed, tracks IP usage per user + +**Backend APIs:** +- `GET /api/log/ip-usage/token/:id` - Get IP usage for a specific token +- `GET /api/log/ip-usage/user/:id` - Get IP usage for a specific user +- Supports `since` parameter for time-range filtering (RFC3339 format) + +**Features:** +- Automatic IP recording in TokenAuth middleware +- IP tracking with first seen, last seen, and request count +- Support for X-Forwarded-For, X-Real-IP headers for proxy scenarios + +### Module 2: Group Rate Scheduling System ✅ + +**Database Models:** +- `GroupRateSchedule` - Time-based rate multiplier rules for groups + +**Backend APIs:** +- `POST /api/admin/group-rate-schedule` - Create schedule rule +- `GET /api/admin/group-rate-schedule/group?group_name=xxx` - Get schedules for group +- `GET /api/admin/group-rate-schedule/current?group_name=xxx` - Get current effective rate +- `PUT /api/admin/group-rate-schedule` - Update schedule rule +- `DELETE /api/admin/group-rate-schedule/:id` - Delete schedule rule +- `GET /api/admin/group-rate-schedule` - Get all schedules (paginated) +- `POST /api/admin/group-rate-schedule/update` - Force update all group rates + +**Features:** +- Time-based rate multipliers (HH:MM format) +- Support for cross-midnight time ranges (e.g., 22:00-02:00) +- Redis caching with 5-minute TTL +- Automatic rate updates every minute via background scheduler +- Rate multiplier retrieval service for billing integration + +**Background Services:** +- `StartGroupRateScheduler()` - Updates rates every minute +- Cleanup of expired schedules + +### Module 3: IP Protection & Rate Limiting ✅ + +**Database Models:** +- `IPList` - Blacklist/whitelist entries with expiration support +- `IPRateLimit` - Rate limiting rules per IP +- `IPBan` - IP ban records (temporary/permanent) + +**Backend APIs:** +- Blacklist/Whitelist Management: + - `POST /api/admin/ip-protection/blacklist` - Add IP to blacklist + - `POST /api/admin/ip-protection/whitelist` - Add IP to whitelist + - `DELETE /api/admin/ip-protection/list/:id` - Remove IP from list + - `GET /api/admin/ip-protection/list?list_type=xxx` - Get IP lists + +- Rate Limiting: + - `POST /api/admin/ip-protection/rate-limit` - Create rate limit rule + - `PUT /api/admin/ip-protection/rate-limit` - Update rate limit rule + - `DELETE /api/admin/ip-protection/rate-limit/:id` - Delete rate limit rule + - `GET /api/admin/ip-protection/rate-limit` - Get all rate limit rules + +- IP Banning: + - `POST /api/admin/ip-protection/ban` - Ban an IP (temporary/permanent) + - `POST /api/admin/ip-protection/unban` - Unban an IP + - `GET /api/admin/ip-protection/banned` - Get banned IPs list + +- Statistics: + - `GET /api/admin/ip-protection/stats` - Get IP protection statistics + +**Features:** +- IP validation with CIDR support +- Automatic expiration of temporary bans and list entries +- Whitelist bypass for rate limiting +- Redis-based rate limiting with sliding window +- Auto-ban mechanism based on violation thresholds: + - 20 violations → 1 hour ban + - 50 violations → 24 hour ban + - 100 violations → permanent ban +- Hourly cleanup of expired bans and list entries + +**Middleware:** +- `IPProtection()` - Checks blacklist, whitelist, and bans +- `IPRateLimit()` - Enforces IP-based rate limiting +- `GetClientIP()` - Extracts real client IP from headers + +### Module 4: Daily Check-in System ✅ + +**Database Models:** +- `CheckInRecord` - Already existed + +**Backend APIs:** +- `POST /api/user/checkin` - Perform daily check-in +- `GET /api/user/checkin/status` - Get today's check-in status +- `GET /api/user/checkin/history` - Get check-in history (paginated) +- `GET /api/user/checkin/rewards` - Get reward configuration + +**Features:** +- Daily check-in with quota rewards +- Consecutive day tracking (resets if skipped) +- Unique constraint prevents duplicate check-ins +- Transaction-based quota award +- Reward tiers: + - Day 1: 100,000 quota + - Days 2-6: 100,000 quota/day + - Days 7-13: 200,000 quota/day + - Days 14-29: 300,000 quota/day + - Day 30+: 500,000 quota/day (bonus tier) +- Automatic log creation for check-in rewards + +### Module 5: Playground Enhancements ✅ + +**Backend APIs:** +- `GET /api/playground/ip-stats?hours=24` - Get IP user statistics (admin only) + +**Features:** +- IP-based user statistics for playground usage +- Configurable time range (1-720 hours) +- Groups users by IP address +- Identifies suspicious IPs (>5 users per IP) +- Returns: + - Total IPs count + - Suspicious IPs count + - Per-IP statistics: user count, usernames, last active time, total requests + - Results sorted by user count (descending) + +## Technical Implementation + +### Database Migrations +All new models are automatically migrated via GORM AutoMigrate in `model/main.go`: +- `GroupRateSchedule` +- `IPList` +- `IPRateLimit` +- `IPBan` + +### Redis Caching +- Group rate multipliers: `group:rate:current:{group_name}` (5 min TTL) +- IP rate limits: `rate_limit:ip:{ip}` (custom TTL per rule) +- IP violations: `ip_violations:{ip}` (1 hour TTL) + +### Background Services +Started in `main.go`: +- Group rate scheduler (1 minute interval) +- IP ban/list cleanup (1 hour interval) + +### Security +- All admin endpoints require `AdminAuth()` middleware +- User endpoints require `UserAuth()` middleware +- IP extraction supports proxy headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) +- Transaction-based operations for data consistency + +## API Routes Summary + +### Admin Routes +- `/api/admin/group-rate-schedule/*` - Group rate scheduling +- `/api/admin/ip-protection/*` - IP protection management +- `/api/log/ip-usage/*` - IP usage tracking +- `/api/playground/ip-stats` - Playground statistics + +### User Routes +- `/api/user/checkin` - Check-in system +- `/api/user/checkin/*` - Check-in related queries + +## Testing + +To test the implementation: + +1. **Group Rate Scheduling:** +```bash +# Create a schedule +curl -X POST http://localhost:3000/api/admin/group-rate-schedule \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "group_name": "default", + "time_start": "09:00", + "time_end": "18:00", + "rate_multiplier": 1.5, + "enabled": true + }' + +# Get current rate +curl http://localhost:3000/api/admin/group-rate-schedule/current?group_name=default \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" +``` + +2. **IP Protection:** +```bash +# Add IP to blacklist +curl -X POST http://localhost:3000/api/admin/ip-protection/blacklist \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "ip": "192.168.1.100", + "reason": "Suspicious activity", + "scope": "global" + }' + +# Ban an IP +curl -X POST http://localhost:3000/api/admin/ip-protection/ban \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "ip": "192.168.1.100", + "reason": "Rate limit exceeded", + "ban_type": "temporary", + "duration": 24 + }' +``` + +3. **Check-in:** +```bash +# Perform check-in +curl -X POST http://localhost:3000/api/user/checkin \ + -H "Authorization: Bearer YOUR_USER_TOKEN" + +# Get check-in status +curl http://localhost:3000/api/user/checkin/status \ + -H "Authorization: Bearer YOUR_USER_TOKEN" +``` + +4. **Playground Stats:** +```bash +# Get IP statistics (admin only) +curl http://localhost:3000/api/playground/ip-stats?hours=24 \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" +``` + +## Frontend Integration Notes + +For frontend developers, the following endpoints are available: + +### Security Center +- Display token/user IP usage in security dashboard +- Add search and filter by time range +- Show IP geolocation (optional, requires external service) + +### Group Rate Management +- Time picker for schedule rules (HH:MM format) +- Visual timeline showing 24-hour rate distribution +- Real-time current rate display +- Support for cross-midnight ranges + +### IP Protection Dashboard +- IP list management (add/remove with CIDR support) +- Rate limit rule configuration +- Ban management with auto-unban countdown +- Statistics dashboard with charts + +### Check-in UI +- Large check-in button with animation +- Consecutive days counter +- Monthly calendar view with check-in markers +- Reward tier display +- Check-in reminder notification + +### Playground +- Admin-only IP statistics panel +- Table showing IP → users mapping +- Highlight suspicious IPs (>5 users) +- Time range selector + +## Notes + +1. All date/time parameters use RFC3339 format for consistency +2. Pagination uses `page` and `page_size` query parameters +3. Redis is required for rate limiting and caching features +4. IP tracking happens automatically in TokenAuth middleware +5. Check-in dates use UTC timezone +6. All admin endpoints require admin authentication +7. Rate multiplier changes take effect within 1 minute via background scheduler +8. Temporary bans and expired list entries are cleaned up hourly + +## Future Enhancements + +Potential improvements for future iterations: +- IP geolocation service integration +- Advanced rate limiting algorithms (token bucket, leaky bucket) +- Machine learning-based anomaly detection +- Email notifications for security events +- Audit log for all security operations +- Custom check-in reward configuration via admin UI +- Check-in streak recovery mechanism +- Playground UI glassmorphism design implementation +- Real-time notifications via WebSocket diff --git a/controller/checkin.go b/controller/checkin.go index 85735f4342b1..a2c51d5ba061 100644 --- a/controller/checkin.go +++ b/controller/checkin.go @@ -1,68 +1,77 @@ package controller import ( - "net/http" + "net/http" - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/model" - "github.com/gin-gonic/gin" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" ) func CheckIn(c *gin.Context) { - userId := c.GetInt("id") + userId := c.GetInt("id") - quotaAwarded, consecutiveDays, err := model.CheckIn(userId) - if err != nil { - common.ApiError(c, err) - return - } + quotaAwarded, consecutiveDays, err := model.CheckIn(userId) + if err != nil { + common.ApiError(c, err) + return + } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "签到成功", - "data": gin.H{ - "quota_awarded": quotaAwarded, - "consecutive_days": consecutiveDays, - }, - }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "签到成功", + "data": gin.H{ + "quota_awarded": quotaAwarded, + "consecutive_days": consecutiveDays, + }, + }) } func GetCheckInStatus(c *gin.Context) { - userId := c.GetInt("id") + userId := c.GetInt("id") - hasCheckedIn, record, err := model.GetTodayCheckInStatus(userId) - if err != nil { - common.ApiError(c, err) - return - } + hasCheckedIn, record, err := model.GetTodayCheckInStatus(userId) + if err != nil { + common.ApiError(c, err) + return + } - consecutiveDays, err := model.GetUserConsecutiveDays(userId) - if err != nil { - consecutiveDays = 0 - } + consecutiveDays, err := model.GetUserConsecutiveDays(userId) + if err != nil { + consecutiveDays = 0 + } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": gin.H{ - "has_checked_in": hasCheckedIn, - "today_record": record, - "consecutive_days": consecutiveDays, - }, - }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "has_checked_in": hasCheckedIn, + "today_record": record, + "consecutive_days": consecutiveDays, + }, + }) } func GetCheckInHistory(c *gin.Context) { - userId := c.GetInt("id") - pageInfo := common.GetPageQuery(c) + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) - records, total, err := model.GetCheckInHistory(userId, pageInfo.GetPageSize(), pageInfo.GetStartIdx()) - if err != nil { - common.ApiError(c, err) - return - } + records, total, err := model.GetCheckInHistory(userId, pageInfo.GetPageSize(), pageInfo.GetStartIdx()) + if err != nil { + common.ApiError(c, err) + return + } - pageInfo.SetTotal(int(total)) - pageInfo.SetItems(records) - common.ApiSuccess(c, pageInfo) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(records) + common.ApiSuccess(c, pageInfo) +} + +func GetCheckInRewardConfig(c *gin.Context) { + config := model.GetCheckInRewardConfig() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": config, + }) } diff --git a/controller/group_rate_schedule.go b/controller/group_rate_schedule.go new file mode 100644 index 000000000000..f8b369ad5a1b --- /dev/null +++ b/controller/group_rate_schedule.go @@ -0,0 +1,238 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +func CreateGroupRateSchedule(c *gin.Context) { + var schedule model.GroupRateSchedule + if err := c.ShouldBindJSON(&schedule); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + // Validate time format + if !isValidTimeFormat(schedule.TimeStart) || !isValidTimeFormat(schedule.TimeEnd) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid time format. Use HH:MM format (e.g., 09:30)", + }) + return + } + + if schedule.RateMultiplier <= 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Rate multiplier must be greater than 0", + }) + return + } + + if err := model.CreateGroupRateSchedule(&schedule); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to create schedule: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Schedule created successfully", + "data": schedule, + }) +} + +func GetGroupRateSchedules(c *gin.Context) { + groupName := c.Query("group_name") + if groupName == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "group_name is required", + }) + return + } + + schedules, err := model.GetGroupRateSchedulesByGroup(groupName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get schedules: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": schedules, + }) +} + +func UpdateGroupRateSchedule(c *gin.Context) { + var schedule model.GroupRateSchedule + if err := c.ShouldBindJSON(&schedule); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if schedule.ID == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Schedule ID is required", + }) + return + } + + // Validate time format if provided + if schedule.TimeStart != "" && !isValidTimeFormat(schedule.TimeStart) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid time_start format. Use HH:MM format", + }) + return + } + + if schedule.TimeEnd != "" && !isValidTimeFormat(schedule.TimeEnd) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid time_end format. Use HH:MM format", + }) + return + } + + if err := model.UpdateGroupRateSchedule(&schedule); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update schedule: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Schedule updated successfully", + "data": schedule, + }) +} + +func DeleteGroupRateSchedule(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid schedule ID", + }) + return + } + + if err := model.DeleteGroupRateSchedule(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to delete schedule: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Schedule deleted successfully", + }) +} + +func GetCurrentGroupRate(c *gin.Context) { + groupName := c.Query("group_name") + if groupName == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "group_name is required", + }) + return + } + + multiplier, err := service.GetCachedGroupRateMultiplier(groupName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get current rate: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "group_name": groupName, + "rate_multiplier": multiplier, + "timestamp": time.Now().Format(time.RFC3339), + }, + }) +} + +func GetAllGroupRateSchedules(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + schedules, total, err := model.GetAllGroupRateSchedules(page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get schedules: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": schedules, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }, + }) +} + +func isValidTimeFormat(timeStr string) bool { + _, err := time.Parse("15:04", timeStr) + return err == nil +} + +func ForceUpdateGroupRates(c *gin.Context) { + if err := service.UpdateGroupRateMultipliers(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": fmt.Sprintf("Failed to update group rates: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Group rates updated successfully", + }) +} diff --git a/controller/ip_protection.go b/controller/ip_protection.go new file mode 100644 index 000000000000..982305b246c4 --- /dev/null +++ b/controller/ip_protection.go @@ -0,0 +1,452 @@ +package controller + +import ( + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +// AddIPToBlacklist adds an IP to blacklist +func AddIPToBlacklist(c *gin.Context) { + var req struct { + IP string `json:"ip" binding:"required"` + Reason string `json:"reason"` + Scope string `json:"scope"` + ScopeID int `json:"scope_id"` + ExpiresAt *time.Time `json:"expires_at"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if !service.IsValidIP(req.IP) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid IP address or CIDR format", + }) + return + } + + userId := c.GetInt("id") + ipList := &model.IPList{ + IP: req.IP, + ListType: "blacklist", + Reason: req.Reason, + Scope: req.Scope, + ScopeID: req.ScopeID, + ExpiresAt: req.ExpiresAt, + CreatedBy: userId, + } + + if err := model.AddToIPList(ipList); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to add IP to blacklist: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "IP added to blacklist successfully", + "data": ipList, + }) +} + +// AddIPToWhitelist adds an IP to whitelist +func AddIPToWhitelist(c *gin.Context) { + var req struct { + IP string `json:"ip" binding:"required"` + Reason string `json:"reason"` + Scope string `json:"scope"` + ScopeID int `json:"scope_id"` + ExpiresAt *time.Time `json:"expires_at"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if !service.IsValidIP(req.IP) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid IP address or CIDR format", + }) + return + } + + userId := c.GetInt("id") + ipList := &model.IPList{ + IP: req.IP, + ListType: "whitelist", + Reason: req.Reason, + Scope: req.Scope, + ScopeID: req.ScopeID, + ExpiresAt: req.ExpiresAt, + CreatedBy: userId, + } + + if err := model.AddToIPList(ipList); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to add IP to whitelist: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "IP added to whitelist successfully", + "data": ipList, + }) +} + +// RemoveIPFromList removes an IP from blacklist or whitelist +func RemoveIPFromList(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid list entry ID", + }) + return + } + + if err := model.RemoveFromIPList(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to remove IP from list: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "IP removed from list successfully", + }) +} + +// GetIPLists gets IP blacklist or whitelist +func GetIPLists(c *gin.Context) { + listType := c.Query("list_type") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + lists, total, err := model.GetIPLists(listType, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get IP lists: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": lists, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }, + }) +} + +// CreateIPRateLimit creates an IP rate limit rule +func CreateIPRateLimit(c *gin.Context) { + var limit model.IPRateLimit + if err := c.ShouldBindJSON(&limit); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if !service.IsValidIP(limit.IP) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid IP address or CIDR format", + }) + return + } + + if limit.MaxRequests <= 0 || limit.TimeWindow <= 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Max requests and time window must be greater than 0", + }) + return + } + + if err := model.CreateIPRateLimit(&limit); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to create rate limit: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Rate limit created successfully", + "data": limit, + }) +} + +// UpdateIPRateLimit updates an IP rate limit rule +func UpdateIPRateLimit(c *gin.Context) { + var limit model.IPRateLimit + if err := c.ShouldBindJSON(&limit); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if limit.ID == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Rate limit ID is required", + }) + return + } + + if err := model.UpdateIPRateLimit(&limit); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update rate limit: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Rate limit updated successfully", + "data": limit, + }) +} + +// DeleteIPRateLimit deletes an IP rate limit rule +func DeleteIPRateLimit(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid rate limit ID", + }) + return + } + + if err := model.DeleteIPRateLimit(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to delete rate limit: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Rate limit deleted successfully", + }) +} + +// GetIPRateLimits gets all IP rate limit rules +func GetIPRateLimits(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + limits, total, err := model.GetIPRateLimits(page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get rate limits: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": limits, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }, + }) +} + +// BanIPAddress bans an IP address +func BanIPAddress(c *gin.Context) { + var req struct { + IP string `json:"ip" binding:"required"` + Reason string `json:"reason"` + BanType string `json:"ban_type"` // "temporary" or "permanent" + Duration int `json:"duration"` // in hours, for temporary bans + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if !service.IsValidIP(req.IP) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid IP address", + }) + return + } + + userId := c.GetInt("id") + ban := &model.IPBan{ + IP: req.IP, + BanReason: req.Reason, + BanType: req.BanType, + BannedAt: time.Now(), + BannedBy: userId, + } + + if req.BanType == "temporary" { + if req.Duration <= 0 { + req.Duration = 24 // Default 24 hours + } + expiresAt := time.Now().Add(time.Duration(req.Duration) * time.Hour) + ban.ExpiresAt = &expiresAt + } else { + ban.BanType = "permanent" + } + + if err := model.BanIP(ban); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to ban IP: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "IP banned successfully", + "data": ban, + }) +} + +// UnbanIPAddress unbans an IP address +func UnbanIPAddress(c *gin.Context) { + var req struct { + IP string `json:"ip" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request: " + err.Error(), + }) + return + } + + if err := model.UnbanIPByAddress(req.IP); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to unban IP: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "IP unbanned successfully", + }) +} + +// GetIPBans gets all banned IPs +func GetIPBans(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + includeExpired := c.Query("include_expired") == "true" + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + bans, total, err := model.GetIPBans(page, pageSize, includeExpired) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get banned IPs: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": bans, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }, + }) +} + +// GetIPProtectionStats gets IP protection statistics +func GetIPProtectionStats(c *gin.Context) { + stats, err := service.GetIPStatistics() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get statistics: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": stats, + }) +} diff --git a/controller/log.go b/controller/log.go index da9bca468a2f..496b65cb7a5b 100644 --- a/controller/log.go +++ b/controller/log.go @@ -1,169 +1,258 @@ package controller import ( - "net/http" - "strconv" + "net/http" + "strconv" + "time" - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) func GetAllLogs(c *gin.Context) { - pageInfo := common.GetPageQuery(c) - logType, _ := strconv.Atoi(c.Query("type")) - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - username := c.Query("username") - tokenName := c.Query("token_name") - modelName := c.Query("model_name") - channel, _ := strconv.Atoi(c.Query("channel")) - group := c.Query("group") - logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group) - if err != nil { - common.ApiError(c, err) - return - } - pageInfo.SetTotal(int(total)) - pageInfo.SetItems(logs) - common.ApiSuccess(c, pageInfo) - return + pageInfo := common.GetPageQuery(c) + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + username := c.Query("username") + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) + return } func GetUserLogs(c *gin.Context) { - pageInfo := common.GetPageQuery(c) - userId := c.GetInt("id") - logType, _ := strconv.Atoi(c.Query("type")) - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - tokenName := c.Query("token_name") - modelName := c.Query("model_name") - group := c.Query("group") - logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group) - if err != nil { - common.ApiError(c, err) - return - } - pageInfo.SetTotal(int(total)) - pageInfo.SetItems(logs) - common.ApiSuccess(c, pageInfo) - return + pageInfo := common.GetPageQuery(c) + userId := c.GetInt("id") + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + group := c.Query("group") + logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) + return } func SearchAllLogs(c *gin.Context) { - keyword := c.Query("keyword") - logs, err := model.SearchAllLogs(keyword) - if err != nil { - common.ApiError(c, err) - return - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": logs, - }) - return + keyword := c.Query("keyword") + logs, err := model.SearchAllLogs(keyword) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return } func SearchUserLogs(c *gin.Context) { - keyword := c.Query("keyword") - userId := c.GetInt("id") - logs, err := model.SearchUserLogs(userId, keyword) - if err != nil { - common.ApiError(c, err) - return - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": logs, - }) - return + keyword := c.Query("keyword") + userId := c.GetInt("id") + logs, err := model.SearchUserLogs(userId, keyword) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return } func GetLogByKey(c *gin.Context) { - key := c.Query("key") - logs, err := model.GetLogByKey(key) - if err != nil { - c.JSON(200, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - c.JSON(200, gin.H{ - "success": true, - "message": "", - "data": logs, - }) + key := c.Query("key") + logs, err := model.GetLogByKey(key) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": logs, + }) } func GetLogsStat(c *gin.Context) { - logType, _ := strconv.Atoi(c.Query("type")) - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - tokenName := c.Query("token_name") - username := c.Query("username") - modelName := c.Query("model_name") - channel, _ := strconv.Atoi(c.Query("channel")) - group := c.Query("group") - stat := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) - //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "") - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": gin.H{ - "quota": stat.Quota, - "rpm": stat.Rpm, - "tpm": stat.Tpm, - }, - }) - return + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + username := c.Query("username") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + stat := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": stat.Quota, + "rpm": stat.Rpm, + "tpm": stat.Tpm, + }, + }) + return } func GetLogsSelfStat(c *gin.Context) { - username := c.GetString("username") - logType, _ := strconv.Atoi(c.Query("type")) - startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) - endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - tokenName := c.Query("token_name") - modelName := c.Query("model_name") - channel, _ := strconv.Atoi(c.Query("channel")) - group := c.Query("group") - quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) - //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName) - c.JSON(200, gin.H{ - "success": true, - "message": "", - "data": gin.H{ - "quota": quotaNum.Quota, - "rpm": quotaNum.Rpm, - "tpm": quotaNum.Tpm, - //"token": tokenNum, - }, - }) - return + username := c.GetString("username") + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName) + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum.Quota, + "rpm": quotaNum.Rpm, + "tpm": quotaNum.Tpm, + //"token": tokenNum, + }, + }) + return } func DeleteHistoryLogs(c *gin.Context) { - targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) - if targetTimestamp == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "target timestamp is required", - }) - return - } - count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100) - if err != nil { - common.ApiError(c, err) - return - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": count, - }) - return + targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) + if targetTimestamp == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "target timestamp is required", + }) + return + } + count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) + return +} + +func GetTokenIPUsage(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid token ID", + }) + return + } + + // Get since parameter (optional) + sinceStr := c.Query("since") + var since time.Time + if sinceStr != "" { + since, err = time.Parse(time.RFC3339, sinceStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid since time format. Use RFC3339 format.", + }) + return + } + } + + usages, totalRequests, err := model.GetTokenIPUsage(id, since) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get IP usage: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "usages": usages, + "total_requests": totalRequests, + }, + }) +} + +func GetUserIPUsage(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid user ID", + }) + return + } + + // Get since parameter (optional) + sinceStr := c.Query("since") + var since time.Time + if sinceStr != "" { + since, err = time.Parse(time.RFC3339, sinceStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid since time format. Use RFC3339 format.", + }) + return + } + } + + usages, totalRequests, err := model.GetUserIPUsage(id, since) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get IP usage: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "usages": usages, + "total_requests": totalRequests, + }, + }) } diff --git a/controller/playground_stats.go b/controller/playground_stats.go new file mode 100644 index 000000000000..1c8952921135 --- /dev/null +++ b/controller/playground_stats.go @@ -0,0 +1,120 @@ +package controller + +import ( + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +type PlaygroundIPStat struct { + IP string `json:"ip"` + UserCount int `json:"user_count"` + Usernames []string `json:"usernames"` + LastActiveAt string `json:"last_active_at"` + TotalRequests int64 `json:"total_requests"` +} + +// GetPlaygroundIPStats gets IP statistics for playground (admin only) +func GetPlaygroundIPStats(c *gin.Context) { + // Parse time range parameter + hoursStr := c.DefaultQuery("hours", "24") + var hours int + if _, err := fmt.Sscanf(hoursStr, "%d", &hours); err != nil { + hours = 24 + } + if hours < 1 { + hours = 24 + } + if hours > 720 { // Max 30 days + hours = 720 + } + + since := time.Now().Add(-time.Duration(hours) * time.Hour) + + // Query all user IP usage since the given time + var userIPUsages []model.UserIPUsage + err := model.LOG_DB.Where("last_seen_at >= ?", since). + Order("last_seen_at desc"). + Find(&userIPUsages).Error + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get IP statistics: " + err.Error(), + }) + return + } + + // Group by IP + ipMap := make(map[string]*PlaygroundIPStat) + userIDMap := make(map[string]map[int]bool) // ip -> user_id -> true + + for _, usage := range userIPUsages { + if _, exists := ipMap[usage.IP]; !exists { + ipMap[usage.IP] = &PlaygroundIPStat{ + IP: usage.IP, + Usernames: []string{}, + LastActiveAt: usage.LastSeenAt.Format(time.RFC3339), + TotalRequests: 0, + } + userIDMap[usage.IP] = make(map[int]bool) + } + + // Track unique users per IP + if !userIDMap[usage.IP][usage.UserId] { + userIDMap[usage.IP][usage.UserId] = true + + // Get username + var user model.User + if err := model.DB.Where("id = ?", usage.UserId).First(&user).Error; err == nil { + ipMap[usage.IP].Usernames = append(ipMap[usage.IP].Usernames, user.Username) + } + } + + ipMap[usage.IP].TotalRequests += usage.RequestCount + + // Update last active time if newer + if usage.LastSeenAt.Format(time.RFC3339) > ipMap[usage.IP].LastActiveAt { + ipMap[usage.IP].LastActiveAt = usage.LastSeenAt.Format(time.RFC3339) + } + } + + // Convert map to slice and calculate user counts + var stats []PlaygroundIPStat + for _, stat := range ipMap { + stat.UserCount = len(stat.Usernames) + stats = append(stats, *stat) + } + + // Sort by user count (descending) + for i := 0; i < len(stats); i++ { + for j := i + 1; j < len(stats); j++ { + if stats[j].UserCount > stats[i].UserCount { + stats[i], stats[j] = stats[j], stats[i] + } + } + } + + // Calculate summary + totalIPs := len(stats) + var suspiciousIPs int + for _, stat := range stats { + if stat.UserCount > 5 { + suspiciousIPs++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "total_ips": totalIPs, + "suspicious_ips": suspiciousIPs, + "time_range": hours, + "ip_stats": stats, + }, + }) +} diff --git a/main.go b/main.go index 8470307ab11c..345f82801f05 100644 --- a/main.go +++ b/main.go @@ -94,6 +94,22 @@ func main() { // 数据看板 go model.UpdateQuotaData() + // Start group rate scheduler + go service.StartGroupRateScheduler() + + // Cleanup expired IP bans and lists periodically + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + if !common.IsMasterNode { + continue + } + _ = model.CleanupExpiredBans() + _ = model.CleanupExpiredIPLists() + } + }() + go func() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() diff --git a/middleware/ip_protection.go b/middleware/ip_protection.go new file mode 100644 index 000000000000..3cd837715cb1 --- /dev/null +++ b/middleware/ip_protection.go @@ -0,0 +1,124 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +// IPProtection middleware checks IP against blacklist, whitelist, and bans +func IPProtection() gin.HandlerFunc { + return func(c *gin.Context) { + ip := GetClientIP(c) + if ip == "" { + c.Next() + return + } + + allowed, reason, err := service.CheckIPProtection(ip) + if err != nil { + // Log error but don't block on error + c.Next() + return + } + + if !allowed { + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "message": reason, + "type": "ip_blocked", + "code": "ip_protection_violation", + }, + }) + c.Abort() + return + } + + // If whitelisted, skip rate limiting by setting a flag + if reason == "whitelisted" { + c.Set("ip_whitelisted", true) + } + + c.Next() + } +} + +// IPRateLimit middleware enforces IP-based rate limiting +func IPRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip if IP is whitelisted + if whitelisted, exists := c.Get("ip_whitelisted"); exists && whitelisted.(bool) { + c.Next() + return + } + + ip := GetClientIP(c) + if ip == "" { + c.Next() + return + } + + allowed, remaining, resetTime, err := service.CheckIPRateLimit(ip) + if err != nil { + // Log error but don't block on error + c.Next() + return + } + + // Set rate limit headers + if remaining > 0 { + c.Header("X-RateLimit-Remaining", string(rune(remaining))) + c.Header("X-RateLimit-Reset", resetTime.Format("2006-01-02T15:04:05Z07:00")) + } + + if !allowed { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": gin.H{ + "message": "Rate limit exceeded. Please try again later.", + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "reset_at": resetTime.Format("2006-01-02T15:04:05Z07:00"), + }, + }) + c.Abort() + return + } + + c.Next() + } +} + +// GetClientIP extracts the real client IP from the request +// Handles X-Forwarded-For, X-Real-IP headers and supports proxy scenarios +func GetClientIP(c *gin.Context) string { + // Try X-Forwarded-For first (standard proxy header) + xff := c.GetHeader("X-Forwarded-For") + if xff != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + ips := strings.Split(xff, ",") + if len(ips) > 0 { + ip := strings.TrimSpace(ips[0]) + if ip != "" { + return ip + } + } + } + + // Try X-Real-IP (common in nginx) + xri := c.GetHeader("X-Real-IP") + if xri != "" { + return strings.TrimSpace(xri) + } + + // Try CF-Connecting-IP (Cloudflare) + cfip := c.GetHeader("CF-Connecting-IP") + if cfip != "" { + return strings.TrimSpace(cfip) + } + + // Fallback to remote address + ip := c.ClientIP() + return ip +} diff --git a/model/checkin.go b/model/checkin.go index e972f920765d..73a81d1890ec 100644 --- a/model/checkin.go +++ b/model/checkin.go @@ -1,135 +1,171 @@ package model import ( - "errors" - "fmt" - "time" + "errors" + "fmt" + "time" - "gorm.io/gorm" + "gorm.io/gorm" ) type CheckInRecord struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"not null;index"` - CheckInDate string `json:"check_in_date" gorm:"size:10;not null;index:idx_user_date"` - QuotaAwarded int `json:"quota_awarded" gorm:"not null;default:0"` - ConsecutiveDays int `json:"consecutive_days" gorm:"not null;default:1"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"not null;index"` + CheckInDate string `json:"check_in_date" gorm:"size:10;not null;index:idx_user_date"` + QuotaAwarded int `json:"quota_awarded" gorm:"not null;default:0"` + ConsecutiveDays int `json:"consecutive_days" gorm:"not null;default:1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (c *CheckInRecord) TableName() string { - return "check_in_records" + return "check_in_records" } // CheckIn performs daily check-in and awards quota func CheckIn(userId int) (quotaAwarded int, consecutiveDays int, err error) { - if userId == 0 { - return 0, 0, errors.New("invalid user id") - } - - today := time.Now().UTC().Format("2006-01-02") - yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") - - err = DB.Transaction(func(tx *gorm.DB) error { - var existing CheckInRecord - err := tx.Where("user_id = ? AND check_in_date = ?", userId, today).First(&existing).Error - if err == nil { - return errors.New("already checked in today") - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - var yesterdayRecord CheckInRecord - err = tx.Where("user_id = ? AND check_in_date = ?", userId, yesterday).First(&yesterdayRecord).Error - consecutiveDays = 1 - if err == nil { - consecutiveDays = yesterdayRecord.ConsecutiveDays + 1 - } - - quotaAwarded = calculateCheckInQuota(consecutiveDays) - - record := CheckInRecord{ - UserId: userId, - CheckInDate: today, - QuotaAwarded: quotaAwarded, - ConsecutiveDays: consecutiveDays, - } - if err := tx.Create(&record).Error; err != nil { - return err - } - - if err := tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", quotaAwarded)).Error; err != nil { - return err - } - - return nil - }) - - if err != nil { - return 0, 0, err - } - - RecordLog(userId, LogTypeTopup, fmt.Sprintf("签到奖励:连续签到%d天,获得 %d 额度", consecutiveDays, quotaAwarded)) - return quotaAwarded, consecutiveDays, nil + if userId == 0 { + return 0, 0, errors.New("invalid user id") + } + + today := time.Now().UTC().Format("2006-01-02") + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + + err = DB.Transaction(func(tx *gorm.DB) error { + var existing CheckInRecord + err := tx.Where("user_id = ? AND check_in_date = ?", userId, today).First(&existing).Error + if err == nil { + return errors.New("already checked in today") + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var yesterdayRecord CheckInRecord + err = tx.Where("user_id = ? AND check_in_date = ?", userId, yesterday).First(&yesterdayRecord).Error + consecutiveDays = 1 + if err == nil { + consecutiveDays = yesterdayRecord.ConsecutiveDays + 1 + } + + quotaAwarded = calculateCheckInQuota(consecutiveDays) + + record := CheckInRecord{ + UserId: userId, + CheckInDate: today, + QuotaAwarded: quotaAwarded, + ConsecutiveDays: consecutiveDays, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + + if err := tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", quotaAwarded)).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + return 0, 0, err + } + + RecordLog(userId, LogTypeTopup, fmt.Sprintf("签到奖励:连续签到%d天,获得 %d 额度", consecutiveDays, quotaAwarded)) + return quotaAwarded, consecutiveDays, nil } func calculateCheckInQuota(consecutiveDays int) int { - baseQuota := 100000 - if consecutiveDays >= 30 { - return baseQuota * 5 - } else if consecutiveDays >= 14 { - return baseQuota * 3 - } else if consecutiveDays >= 7 { - return baseQuota * 2 - } - return baseQuota + baseQuota := 100000 + if consecutiveDays >= 30 { + return baseQuota * 5 + } else if consecutiveDays >= 14 { + return baseQuota * 3 + } else if consecutiveDays >= 7 { + return baseQuota * 2 + } + return baseQuota } func GetCheckInHistory(userId int, limit int, offset int) ([]CheckInRecord, int64, error) { - var records []CheckInRecord - var total int64 + var records []CheckInRecord + var total int64 - query := DB.Where("user_id = ?", userId) + query := DB.Where("user_id = ?", userId) - if err := query.Model(&CheckInRecord{}).Count(&total).Error; err != nil { - return nil, 0, err - } + if err := query.Model(&CheckInRecord{}).Count(&total).Error; err != nil { + return nil, 0, err + } - if err := query.Order("check_in_date desc").Limit(limit).Offset(offset).Find(&records).Error; err != nil { - return nil, 0, err - } + if err := query.Order("check_in_date desc").Limit(limit).Offset(offset).Find(&records).Error; err != nil { + return nil, 0, err + } - return records, total, nil + return records, total, nil } func GetTodayCheckInStatus(userId int) (bool, *CheckInRecord, error) { - today := time.Now().UTC().Format("2006-01-02") - var record CheckInRecord - err := DB.Where("user_id = ? AND check_in_date = ?", userId, today).First(&record).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil, nil - } - return false, nil, err - } - return true, &record, nil + today := time.Now().UTC().Format("2006-01-02") + var record CheckInRecord + err := DB.Where("user_id = ? AND check_in_date = ?", userId, today).First(&record).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil, nil + } + return false, nil, err + } + return true, &record, nil } func GetUserConsecutiveDays(userId int) (int, error) { - today := time.Now().UTC().Format("2006-01-02") - yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") - - var record CheckInRecord - err := DB.Where("user_id = ? AND check_in_date IN ?", userId, []string{today, yesterday}). - Order("check_in_date desc").First(&record).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, nil - } - return 0, err - } - - return record.ConsecutiveDays, nil + today := time.Now().UTC().Format("2006-01-02") + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + + var record CheckInRecord + err := DB.Where("user_id = ? AND check_in_date IN ?", userId, []string{today, yesterday}). + Order("check_in_date desc").First(&record).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + + return record.ConsecutiveDays, nil +} + +// GetCheckInRewardConfig returns the check-in reward configuration +func GetCheckInRewardConfig() []map[string]interface{} { + return []map[string]interface{}{ + { + "day_range": "1", + "reward_quota": 100000, + "description": "第1天", + "is_bonus": false, + }, + { + "day_range": "2-6", + "reward_quota": 100000, + "description": "第2-6天", + "is_bonus": false, + }, + { + "day_range": "7-13", + "reward_quota": 200000, + "description": "第7-13天", + "is_bonus": false, + }, + { + "day_range": "14-29", + "reward_quota": 300000, + "description": "第14-29天", + "is_bonus": false, + }, + { + "day_range": "30+", + "reward_quota": 500000, + "description": "第30天及以上", + "is_bonus": true, + }, + } } diff --git a/model/group_rate_schedule.go b/model/group_rate_schedule.go new file mode 100644 index 000000000000..164415ca865b --- /dev/null +++ b/model/group_rate_schedule.go @@ -0,0 +1,112 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// GroupRateSchedule represents a time-based rate multiplier rule for a group +type GroupRateSchedule struct { + ID int `json:"id" gorm:"primaryKey"` + GroupName string `json:"group_name" gorm:"size:64;not null;index"` + TimeStart string `json:"time_start" gorm:"size:5;not null"` // HH:MM format + TimeEnd string `json:"time_end" gorm:"size:5;not null"` // HH:MM format + RateMultiplier float64 `json:"rate_multiplier" gorm:"not null;default:1.0"` + Enabled bool `json:"enabled" gorm:"not null;default:true;index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (g *GroupRateSchedule) TableName() string { + return "group_rate_schedules" +} + +// CreateGroupRateSchedule creates a new rate schedule rule +func CreateGroupRateSchedule(schedule *GroupRateSchedule) error { + return DB.Create(schedule).Error +} + +// GetGroupRateSchedule gets a schedule by ID +func GetGroupRateSchedule(id int) (*GroupRateSchedule, error) { + var schedule GroupRateSchedule + err := DB.Where("id = ?", id).First(&schedule).Error + if err != nil { + return nil, err + } + return &schedule, nil +} + +// GetGroupRateSchedulesByGroup gets all schedules for a group +func GetGroupRateSchedulesByGroup(groupName string) ([]GroupRateSchedule, error) { + var schedules []GroupRateSchedule + err := DB.Where("group_name = ?", groupName).Order("time_start ASC").Find(&schedules).Error + return schedules, err +} + +// GetEnabledGroupRateSchedules gets all enabled schedules for a group +func GetEnabledGroupRateSchedules(groupName string) ([]GroupRateSchedule, error) { + var schedules []GroupRateSchedule + err := DB.Where("group_name = ? AND enabled = ?", groupName, true). + Order("time_start ASC").Find(&schedules).Error + return schedules, err +} + +// UpdateGroupRateSchedule updates a schedule +func UpdateGroupRateSchedule(schedule *GroupRateSchedule) error { + return DB.Model(&GroupRateSchedule{}).Where("id = ?", schedule.ID).Updates(schedule).Error +} + +// DeleteGroupRateSchedule deletes a schedule +func DeleteGroupRateSchedule(id int) error { + return DB.Where("id = ?", id).Delete(&GroupRateSchedule{}).Error +} + +// GetAllGroupRateSchedules gets all schedules with pagination +func GetAllGroupRateSchedules(page int, pageSize int) ([]GroupRateSchedule, int64, error) { + var schedules []GroupRateSchedule + var total int64 + + db := DB.Model(&GroupRateSchedule{}) + err := db.Count(&total).Error + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err = db.Order("group_name ASC, time_start ASC").Limit(pageSize).Offset(offset).Find(&schedules).Error + return schedules, total, err +} + +// GetCurrentRateMultiplier gets the current effective rate multiplier for a group +func GetCurrentRateMultiplier(groupName string, currentTime time.Time) (float64, error) { + schedules, err := GetEnabledGroupRateSchedules(groupName) + if err != nil { + return 1.0, err + } + + if len(schedules) == 0 { + return 1.0, nil + } + + currentHHMM := currentTime.Format("15:04") + + for _, schedule := range schedules { + if isTimeInRange(currentHHMM, schedule.TimeStart, schedule.TimeEnd) { + return schedule.RateMultiplier, nil + } + } + + return 1.0, nil +} + +// isTimeInRange checks if currentTime is within the range [startTime, endTime) +// Supports cross-midnight ranges (e.g., 22:00 - 02:00) +func isTimeInRange(current, start, end string) bool { + if start <= end { + // Normal range: 08:00 - 18:00 + return current >= start && current < end + } + // Cross-midnight range: 22:00 - 02:00 + return current >= start || current < end +} diff --git a/model/ip_protection.go b/model/ip_protection.go new file mode 100644 index 000000000000..c28991bfce80 --- /dev/null +++ b/model/ip_protection.go @@ -0,0 +1,223 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// IPList represents an IP in blacklist or whitelist +type IPList struct { + ID int `json:"id" gorm:"primaryKey"` + IP string `json:"ip" gorm:"size:64;not null;index"` + ListType string `json:"list_type" gorm:"size:10;not null;index"` // "blacklist" or "whitelist" + Reason string `json:"reason" gorm:"size:255"` + Scope string `json:"scope" gorm:"size:20;default:'global'"` // "global", "user", "key" + ScopeID int `json:"scope_id" gorm:"default:0"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedBy int `json:"created_by" gorm:"index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (i *IPList) TableName() string { + return "ip_lists" +} + +// IPRateLimit represents rate limiting rules for IPs +type IPRateLimit struct { + ID int `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"size:100;not null"` + IP string `json:"ip" gorm:"size:64;not null;index"` + MaxRequests int `json:"max_requests" gorm:"not null"` + TimeWindow int `json:"time_window" gorm:"not null"` // in seconds + Action string `json:"action" gorm:"size:20;not null;default:'reject'"` // "reject", "warn", "ban" + Enabled bool `json:"enabled" gorm:"not null;default:true;index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (i *IPRateLimit) TableName() string { + return "ip_rate_limits" +} + +// IPBan represents a banned IP record +type IPBan struct { + ID int `json:"id" gorm:"primaryKey"` + IP string `json:"ip" gorm:"size:64;not null;index"` + BanReason string `json:"ban_reason" gorm:"size:255"` + BanType string `json:"ban_type" gorm:"size:20;not null;default:'temporary'"` // "temporary", "permanent" + BannedAt time.Time `json:"banned_at" gorm:"not null"` + ExpiresAt *time.Time `json:"expires_at"` + BannedBy int `json:"banned_by" gorm:"index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (i *IPBan) TableName() string { + return "ip_bans" +} + +// AddToIPList adds an IP to blacklist or whitelist +func AddToIPList(ip *IPList) error { + return DB.Create(ip).Error +} + +// RemoveFromIPList removes an IP from list +func RemoveFromIPList(id int) error { + return DB.Where("id = ?", id).Delete(&IPList{}).Error +} + +// GetIPLists gets IP lists with filters +func GetIPLists(listType string, page int, pageSize int) ([]IPList, int64, error) { + var lists []IPList + var total int64 + + query := DB.Model(&IPList{}) + if listType != "" { + query = query.Where("list_type = ?", listType) + } + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err = query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&lists).Error + return lists, total, err +} + +// IsIPInList checks if an IP is in a specific list (blacklist/whitelist) +func IsIPInList(ip string, listType string) (bool, *IPList, error) { + var list IPList + now := time.Now() + + query := DB.Where("ip = ? AND list_type = ?", ip, listType) + // Check expiration + query = query.Where("(expires_at IS NULL OR expires_at > ?)", now) + + err := query.First(&list).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return false, nil, nil + } + return false, nil, err + } + return true, &list, nil +} + +// CreateIPRateLimit creates a rate limit rule +func CreateIPRateLimit(limit *IPRateLimit) error { + return DB.Create(limit).Error +} + +// UpdateIPRateLimit updates a rate limit rule +func UpdateIPRateLimit(limit *IPRateLimit) error { + return DB.Model(&IPRateLimit{}).Where("id = ?", limit.ID).Updates(limit).Error +} + +// DeleteIPRateLimit deletes a rate limit rule +func DeleteIPRateLimit(id int) error { + return DB.Where("id = ?", id).Delete(&IPRateLimit{}).Error +} + +// GetIPRateLimits gets all rate limit rules +func GetIPRateLimits(page int, pageSize int) ([]IPRateLimit, int64, error) { + var limits []IPRateLimit + var total int64 + + query := DB.Model(&IPRateLimit{}) + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err = query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&limits).Error + return limits, total, err +} + +// GetIPRateLimitForIP gets rate limit rules for a specific IP +func GetIPRateLimitForIP(ip string) (*IPRateLimit, error) { + var limit IPRateLimit + err := DB.Where("ip = ? AND enabled = ?", ip, true).First(&limit).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &limit, nil +} + +// BanIP bans an IP address +func BanIP(ban *IPBan) error { + return DB.Create(ban).Error +} + +// UnbanIP removes a ban +func UnbanIP(id int) error { + return DB.Where("id = ?", id).Delete(&IPBan{}).Error +} + +// UnbanIPByAddress removes a ban by IP address +func UnbanIPByAddress(ip string) error { + return DB.Where("ip = ?", ip).Delete(&IPBan{}).Error +} + +// GetIPBans gets all banned IPs +func GetIPBans(page int, pageSize int, includeExpired bool) ([]IPBan, int64, error) { + var bans []IPBan + var total int64 + + query := DB.Model(&IPBan{}) + + if !includeExpired { + now := time.Now() + query = query.Where("ban_type = ? OR (ban_type = ? AND expires_at > ?)", + "permanent", "temporary", now) + } + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err = query.Order("banned_at DESC").Limit(pageSize).Offset(offset).Find(&bans).Error + return bans, total, err +} + +// IsIPBanned checks if an IP is currently banned +func IsIPBanned(ip string) (bool, *IPBan, error) { + var ban IPBan + now := time.Now() + + query := DB.Where("ip = ?", ip) + query = query.Where("ban_type = ? OR (ban_type = ? AND expires_at > ?)", + "permanent", "temporary", now) + + err := query.First(&ban).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return false, nil, nil + } + return false, nil, err + } + return true, &ban, nil +} + +// CleanupExpiredBans removes expired temporary bans +func CleanupExpiredBans() error { + now := time.Now() + return DB.Where("ban_type = ? AND expires_at <= ?", "temporary", now). + Delete(&IPBan{}).Error +} + +// CleanupExpiredIPLists removes expired IP list entries +func CleanupExpiredIPLists() error { + now := time.Now() + return DB.Where("expires_at IS NOT NULL AND expires_at <= ?", now). + Delete(&IPList{}).Error +} diff --git a/model/main.go b/model/main.go index 5b9d046d9580..83ac601e069e 100644 --- a/model/main.go +++ b/model/main.go @@ -289,6 +289,10 @@ func migrateDB() error { &SecurityViolation{}, &UserSecurity{}, &Ticket{}, + &GroupRateSchedule{}, + &IPList{}, + &IPRateLimit{}, + &IPBan{}, ) if err != nil { return err @@ -338,6 +342,10 @@ func migrateDBFast() error { {&SecurityViolation{}, "SecurityViolation"}, {&UserSecurity{}, "UserSecurity"}, {&Ticket{}, "Ticket"}, + {&GroupRateSchedule{}, "GroupRateSchedule"}, + {&IPList{}, "IPList"}, + {&IPRateLimit{}, "IPRateLimit"}, + {&IPBan{}, "IPBan"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/router/api-router.go b/router/api-router.go index 20e4e4cc0014..9c5244eaec59 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -106,6 +106,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/checkin", controller.CheckIn) selfRoute.GET("/checkin/status", controller.GetCheckInStatus) selfRoute.GET("/checkin/history", controller.GetCheckInHistory) + selfRoute.GET("/checkin/rewards", controller.GetCheckInRewardConfig) // Lottery routes selfRoute.POST("/lottery/draw", controller.DrawLottery) @@ -243,6 +244,12 @@ func SetApiRouter(router *gin.Engine) { logAdmin.GET("/ip-usage/user/:id", controller.GetUserIPUsage) } + playgroundAdmin := apiRouter.Group("/playground") + playgroundAdmin.Use(middleware.AdminAuth()) + { + playgroundAdmin.GET("/ip-stats", controller.GetPlaygroundIPStats) + } + lotteryRoute := apiRouter.Group("/lottery") lotteryRoute.Use(middleware.AdminAuth()) { @@ -392,5 +399,43 @@ func SetApiRouter(router *gin.Engine) { ticketAdminRoute.PUT("/:id/reply", controller.ReplyTicket) } } + + // Group rate schedule routes + groupRateRoute := apiRouter.Group("/admin/group-rate-schedule") + groupRateRoute.Use(middleware.AdminAuth()) + { + groupRateRoute.GET("/", controller.GetAllGroupRateSchedules) + groupRateRoute.GET("/group", controller.GetGroupRateSchedules) + groupRateRoute.GET("/current", controller.GetCurrentGroupRate) + groupRateRoute.POST("/", controller.CreateGroupRateSchedule) + groupRateRoute.PUT("/", controller.UpdateGroupRateSchedule) + groupRateRoute.DELETE("/:id", controller.DeleteGroupRateSchedule) + groupRateRoute.POST("/update", controller.ForceUpdateGroupRates) + } + + // IP protection routes + ipProtectionRoute := apiRouter.Group("/admin/ip-protection") + ipProtectionRoute.Use(middleware.AdminAuth()) + { + // Blacklist & Whitelist + ipProtectionRoute.POST("/blacklist", controller.AddIPToBlacklist) + ipProtectionRoute.POST("/whitelist", controller.AddIPToWhitelist) + ipProtectionRoute.DELETE("/list/:id", controller.RemoveIPFromList) + ipProtectionRoute.GET("/list", controller.GetIPLists) + + // Rate limits + ipProtectionRoute.POST("/rate-limit", controller.CreateIPRateLimit) + ipProtectionRoute.PUT("/rate-limit", controller.UpdateIPRateLimit) + ipProtectionRoute.DELETE("/rate-limit/:id", controller.DeleteIPRateLimit) + ipProtectionRoute.GET("/rate-limit", controller.GetIPRateLimits) + + // Bans + ipProtectionRoute.POST("/ban", controller.BanIPAddress) + ipProtectionRoute.POST("/unban", controller.UnbanIPAddress) + ipProtectionRoute.GET("/banned", controller.GetIPBans) + + // Statistics + ipProtectionRoute.GET("/stats", controller.GetIPProtectionStats) + } } } diff --git a/service/group_rate_scheduler.go b/service/group_rate_scheduler.go new file mode 100644 index 000000000000..c8a91d634f42 --- /dev/null +++ b/service/group_rate_scheduler.go @@ -0,0 +1,98 @@ +package service + +import ( + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" +) + +// UpdateGroupRateMultipliers updates all group rate multipliers based on current time +func UpdateGroupRateMultipliers() error { + if !common.RedisEnabled { + common.SysLog("Redis not enabled, skipping group rate multiplier update") + return nil + } + + currentTime := time.Now() + + // Get all unique group names that have schedules + var groupNames []string + if err := model.DB.Model(&model.GroupRateSchedule{}). + Distinct("group_name"). + Where("enabled = ?", true). + Pluck("group_name", &groupNames).Error; err != nil { + return fmt.Errorf("failed to get group names: %w", err) + } + + for _, groupName := range groupNames { + multiplier, err := model.GetCurrentRateMultiplier(groupName, currentTime) + if err != nil { + common.SysLog(fmt.Sprintf("Failed to get rate multiplier for group %s: %v", groupName, err)) + continue + } + + // Store in Redis cache + cacheKey := fmt.Sprintf("group:rate:current:%s", groupName) + err = common.RedisSet(cacheKey, fmt.Sprintf("%.2f", multiplier), 5*time.Minute) + if err != nil { + common.SysLog(fmt.Sprintf("Failed to cache rate multiplier for group %s: %v", groupName, err)) + } + } + + return nil +} + +// GetCachedGroupRateMultiplier gets the cached rate multiplier for a group +func GetCachedGroupRateMultiplier(groupName string) (float64, error) { + if !common.RedisEnabled { + return 1.0, nil + } + + cacheKey := fmt.Sprintf("group:rate:current:%s", groupName) + value, err := common.RedisGet(cacheKey) + if err != nil || value == "" { + // Cache miss, calculate and cache + multiplier, err := model.GetCurrentRateMultiplier(groupName, time.Now()) + if err != nil { + return 1.0, err + } + + // Cache for 5 minutes + _ = common.RedisSet(cacheKey, fmt.Sprintf("%.2f", multiplier), 5*time.Minute) + return multiplier, nil + } + + var multiplier float64 + _, err = fmt.Sscanf(value, "%f", &multiplier) + if err != nil { + return 1.0, err + } + + return multiplier, nil +} + +// StartGroupRateScheduler starts the background scheduler for group rate multipliers +func StartGroupRateScheduler() { + if !common.IsMasterNode { + return + } + + common.SysLog("Starting group rate scheduler") + + // Initial update + if err := UpdateGroupRateMultipliers(); err != nil { + common.SysLog(fmt.Sprintf("Initial group rate multiplier update failed: %v", err)) + } + + // Update every minute + ticker := time.NewTicker(1 * time.Minute) + go func() { + for range ticker.C { + if err := UpdateGroupRateMultipliers(); err != nil { + common.SysLog(fmt.Sprintf("Group rate multiplier update failed: %v", err)) + } + } + }() +} diff --git a/service/ip_protection.go b/service/ip_protection.go new file mode 100644 index 000000000000..5adfa2b8e719 --- /dev/null +++ b/service/ip_protection.go @@ -0,0 +1,231 @@ +package service + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" +) + +// CheckIPProtection checks if an IP should be blocked +// Returns: allowed (bool), reason (string), error +func CheckIPProtection(ip string) (bool, string, error) { + // Check if IP is banned + isBanned, ban, err := model.IsIPBanned(ip) + if err != nil { + return true, "", err // Allow on error to avoid blocking legitimate users + } + if isBanned { + reason := fmt.Sprintf("IP banned: %s", ban.BanReason) + if ban.BanType == "temporary" && ban.ExpiresAt != nil { + reason += fmt.Sprintf(" (expires at %s)", ban.ExpiresAt.Format(time.RFC3339)) + } + return false, reason, nil + } + + // Check blacklist + isBlacklisted, blacklistEntry, err := model.IsIPInList(ip, "blacklist") + if err != nil { + return true, "", err + } + if isBlacklisted { + reason := fmt.Sprintf("IP in blacklist: %s", blacklistEntry.Reason) + return false, reason, nil + } + + // Check whitelist - if in whitelist, skip rate limiting + isWhitelisted, _, err := model.IsIPInList(ip, "whitelist") + if err != nil { + return true, "", err + } + if isWhitelisted { + return true, "whitelisted", nil + } + + return true, "", nil +} + +// CheckIPRateLimit checks if an IP has exceeded rate limits +// Returns: allowed (bool), remaining requests, reset time, error +func CheckIPRateLimit(ip string) (bool, int, time.Time, error) { + // Get rate limit rule for this IP + limit, err := model.GetIPRateLimitForIP(ip) + if err != nil { + return true, 0, time.Time{}, err + } + if limit == nil { + // No specific limit for this IP + return true, 0, time.Time{}, nil + } + + if !common.RedisEnabled { + // Redis not enabled, can't enforce rate limit + return true, 0, time.Time{}, nil + } + + key := fmt.Sprintf("rate_limit:ip:%s", ip) + count, err := common.RedisGet(key) + if err != nil { + // Redis error, allow request + return true, 0, time.Time{}, nil + } + + var currentCount int + if count == "" { + currentCount = 0 + } else { + fmt.Sscanf(count, "%d", ¤tCount) + } + + if currentCount >= limit.MaxRequests { + // Rate limit exceeded + ttl, _ := common.RedisTTL(key) + resetTime := time.Now().Add(time.Duration(ttl) * time.Second) + return false, 0, resetTime, nil + } + + // Increment counter + if currentCount == 0 { + // First request in window + err = common.RedisSet(key, "1", time.Duration(limit.TimeWindow)*time.Second) + } else { + newCount := currentCount + 1 + err = common.RedisSet(key, fmt.Sprintf("%d", newCount), -1) // Keep existing TTL + } + + if err != nil { + return true, 0, time.Time{}, err + } + + remaining := limit.MaxRequests - (currentCount + 1) + return true, remaining, time.Now().Add(time.Duration(limit.TimeWindow) * time.Second), nil +} + +// RecordIPViolation records an IP violation and may trigger auto-ban +func RecordIPViolation(ip string, reason string, userId int) error { + if !common.RedisEnabled { + return nil + } + + key := fmt.Sprintf("ip_violations:%s", ip) + count, err := common.RedisGet(key) + if err != nil { + count = "0" + } + + var violationCount int + fmt.Sscanf(count, "%d", &violationCount) + violationCount++ + + // Store violation count for 1 hour + err = common.RedisSet(key, fmt.Sprintf("%d", violationCount), time.Hour) + if err != nil { + return err + } + + // Auto-ban thresholds + var banDuration time.Duration + var banReason string + + if violationCount >= 100 { + // Permanent ban + banReason = fmt.Sprintf("Auto-ban: %d violations (%s)", violationCount, reason) + ban := &model.IPBan{ + IP: ip, + BanReason: banReason, + BanType: "permanent", + BannedAt: time.Now(), + BannedBy: 0, // System auto-ban + } + return model.BanIP(ban) + } else if violationCount >= 50 { + // 24 hour ban + banDuration = 24 * time.Hour + banReason = fmt.Sprintf("Auto-ban: %d violations (%s)", violationCount, reason) + } else if violationCount >= 20 { + // 1 hour ban + banDuration = time.Hour + banReason = fmt.Sprintf("Auto-ban: %d violations (%s)", violationCount, reason) + } else { + // No ban yet + return nil + } + + expiresAt := time.Now().Add(banDuration) + ban := &model.IPBan{ + IP: ip, + BanReason: banReason, + BanType: "temporary", + BannedAt: time.Now(), + ExpiresAt: &expiresAt, + BannedBy: 0, // System auto-ban + } + return model.BanIP(ban) +} + +// IsValidIP checks if a string is a valid IP address or CIDR +func IsValidIP(ipStr string) bool { + // Check if it's a CIDR + if strings.Contains(ipStr, "/") { + _, _, err := net.ParseCIDR(ipStr) + return err == nil + } + // Check if it's a plain IP + ip := net.ParseIP(ipStr) + return ip != nil +} + +// MatchIPPattern checks if an IP matches a pattern (supports CIDR) +func MatchIPPattern(ip string, pattern string) bool { + if pattern == ip { + return true + } + + // Check CIDR match + if strings.Contains(pattern, "/") { + _, ipNet, err := net.ParseCIDR(pattern) + if err != nil { + return false + } + ipAddr := net.ParseIP(ip) + if ipAddr == nil { + return false + } + return ipNet.Contains(ipAddr) + } + + return false +} + +// GetIPStatistics gets statistics about IP usage +func GetIPStatistics() (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // Count active bans + bans, _, err := model.GetIPBans(1, 1000, false) + if err != nil { + return nil, err + } + stats["active_bans"] = len(bans) + + // Count blacklist and whitelist entries + blacklist, blacklistTotal, _ := model.GetIPLists("blacklist", 1, 1) + stats["blacklist_count"] = blacklistTotal + + whitelist, whitelistTotal, _ := model.GetIPLists("whitelist", 1, 1) + stats["whitelist_count"] = whitelistTotal + + // Count rate limit rules + limits, limitsTotal, _ := model.GetIPRateLimits(1, 1) + stats["rate_limit_rules"] = limitsTotal + + // Additional statistics + stats["blacklist"] = blacklist + stats["whitelist"] = whitelist + stats["limits"] = limits + + return stats, nil +}