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
+}