diff --git a/docs/docs.go b/docs/docs.go index 8dddf132..ab244010 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -432,6 +432,12 @@ const docTemplate = `{ "name": "current", "in": "query" }, + { + "maxLength": 255, + "type": "string", + "name": "search", + "in": "query" + }, { "maximum": 100, "minimum": 1, @@ -450,6 +456,33 @@ const docTemplate = `{ } } }, + "/api/v1/projects/received/chart": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "parameters": [ + { + "maximum": 180, + "minimum": 1, + "type": "integer", + "name": "day", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/project.ListReceiveHistoryChartResponse" + } + } + } + } + }, "/api/v1/projects/{id}": { "get": { "description": "获取指定项目所有信息以及领取情况 (Get all information and claim status for a specific project)", @@ -1188,6 +1221,20 @@ const docTemplate = `{ } } }, + "project.ListReceiveHistoryChartResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/project.ReceiveHistoryChartPoint" + } + }, + "error_msg": { + "type": "string" + } + } + }, "project.ListReceiveHistoryResponse": { "type": "object", "properties": { @@ -1273,6 +1320,20 @@ const docTemplate = `{ "ProjectStatusViolation" ] }, + "project.ReceiveHistoryChartPoint": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, "project.ReportProjectRequestBody": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 1729ec35..88d92b93 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -423,6 +423,12 @@ "name": "current", "in": "query" }, + { + "maxLength": 255, + "type": "string", + "name": "search", + "in": "query" + }, { "maximum": 100, "minimum": 1, @@ -441,6 +447,33 @@ } } }, + "/api/v1/projects/received/chart": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "parameters": [ + { + "maximum": 180, + "minimum": 1, + "type": "integer", + "name": "day", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/project.ListReceiveHistoryChartResponse" + } + } + } + } + }, "/api/v1/projects/{id}": { "get": { "description": "获取指定项目所有信息以及领取情况 (Get all information and claim status for a specific project)", @@ -1179,6 +1212,20 @@ } } }, + "project.ListReceiveHistoryChartResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/project.ReceiveHistoryChartPoint" + } + }, + "error_msg": { + "type": "string" + } + } + }, "project.ListReceiveHistoryResponse": { "type": "object", "properties": { @@ -1264,6 +1311,20 @@ "ProjectStatusViolation" ] }, + "project.ReceiveHistoryChartPoint": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, "project.ReportProjectRequestBody": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2cc64f36..fe066ee2 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -326,6 +326,15 @@ definitions: total_items: type: integer type: object + project.ListReceiveHistoryChartResponse: + properties: + data: + items: + $ref: '#/definitions/project.ReceiveHistoryChartPoint' + type: array + error_msg: + type: string + type: object project.ListReceiveHistoryResponse: properties: data: @@ -383,6 +392,15 @@ definitions: - ProjectStatusNormal - ProjectStatusHidden - ProjectStatusViolation + project.ReceiveHistoryChartPoint: + properties: + count: + type: integer + date: + type: string + label: + type: string + type: object project.ReportProjectRequestBody: properties: reason: @@ -850,6 +868,10 @@ paths: minimum: 1 name: current type: integer + - in: query + maxLength: 255 + name: search + type: string - in: query maximum: 100 minimum: 1 @@ -864,6 +886,23 @@ paths: $ref: '#/definitions/project.ListReceiveHistoryResponse' tags: - project + /api/v1/projects/received/chart: + get: + parameters: + - in: query + maximum: 180 + minimum: 1 + name: day + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/project.ListReceiveHistoryChartResponse' + tags: + - project /api/v1/tags: get: consumes: diff --git a/frontend/components/common/received/DataChart.tsx b/frontend/components/common/received/DataChart.tsx index c6412278..9795c41c 100644 --- a/frontend/components/common/received/DataChart.tsx +++ b/frontend/components/common/received/DataChart.tsx @@ -5,7 +5,7 @@ import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts'; import {useIsMobile} from '@/hooks/use-mobile'; import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from '@/components/ui/chart'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; -import {ReceiveHistoryItem} from '@/lib/services/project/types'; +import {ReceiveHistoryChartPoint} from '@/lib/services/project/types'; import {CountingNumber} from '@/components/animate-ui/text/counting-number'; import {motion} from 'motion/react'; @@ -33,14 +33,18 @@ type TimeRange = keyof typeof TIME_RANGE_CONFIG */ interface DataChartProps { /** 领取历史数据 */ - data: ReceiveHistoryItem[] + data: ReceiveHistoryChartPoint[] + /** 当前选择的天数 */ + selectedDay: number + /** 范围变更回调 */ + onRangeChange: (day: number) => void } /** * 统计数据卡片组件 */ const StatCard = ({title, value, suffix = ''}: {title: string; value: number; suffix?: string}) => { - const decimalPlaces = value % 1 === 0 ? 0 : 2; + const decimalPlaces = Number.isInteger(value) ? 0 : 1; return ( ('7d'); + const safeData = React.useMemo(() => data ?? [], [data]); React.useEffect(() => { if (isMobile) setTimeRange('7d'); }, [isMobile]); + React.useEffect(() => { + if (selectedDay <= 7) { + setTimeRange('7d'); + } else if (selectedDay <= 30) { + setTimeRange('30d'); + } else if (selectedDay <= 90) { + setTimeRange('3m'); + } else { + setTimeRange('6m'); + } + }, [selectedDay]); + /** * 生成图表数据,根据时间范围聚合统计 */ const chartData = React.useMemo(() => { - const config = TIME_RANGE_CONFIG[timeRange]; - const isMonthRange = 'months' in config; - const today = new Date(); - const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - - const statsMap = new Map(); - data.forEach((item) => { - if (item.received_at) { - const date = new Date(item.received_at); - let key: string; - - if (isMonthRange) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - key = `${year}-${month}`; - } else { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - key = `${year}-${month}-${day}`; - } - - statsMap.set(key, (statsMap.get(key) || 0) + 1); - } - }); - - const dateRange: string[] = []; - if (isMonthRange) { - for (let i = config.months - 1; i >= 0; i--) { - const date = new Date(startDate.getFullYear(), startDate.getMonth() - i, 1); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - dateRange.push(`${year}-${month}`); - } - } else { - for (let i = config.days - 1; i >= 0; i--) { - const date = new Date(startDate.getTime() - i * 24 * 60 * 60 * 1000); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - dateRange.push(`${year}-${month}-${day}`); - } - } - - return dateRange.map((dateKey) => { - let displayDate: string; - - if (isMonthRange) { - const month = dateKey.split('-')[1]; - displayDate = `${month}月`; - } else { - const [, month, day] = dateKey.split('-'); - displayDate = `${month}/${day}`; - } - - return { - date: dateKey, - displayDate, - count: statsMap.get(dateKey) || 0, - }; - }); - }, [data, timeRange]); + return safeData.map((item) => ({ + date: item.date, + displayDate: item.label, + count: item.count, + })); + }, [safeData]); /** * 计算统计数据(总计、今日、本月、日均) @@ -152,18 +113,12 @@ export function DataChart({data}: DataChartProps) { let todayCount = 0; let thisMonthCount = 0; - data.forEach((item) => { - if (item.received_at) { - const date = new Date(item.received_at); - const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; - const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; - - if (dateStr === todayStr) { - todayCount++; - } - if (monthStr === thisMonthStr) { - thisMonthCount++; - } + safeData.forEach((item) => { + if (item.date === todayStr) { + todayCount += item.count; + } + if (item.date.startsWith(thisMonthStr)) { + thisMonthCount += item.count; } }); @@ -171,12 +126,12 @@ export function DataChart({data}: DataChartProps) { const avgDaily = currentDay > 0 ? Math.round(thisMonthCount / currentDay * 10) / 10 : 0; return { - total: data.length, + total: safeData.reduce((sum, item) => sum + item.count, 0), today: todayCount, thisMonth: thisMonthCount, avgDaily, }; - }, [data]); + }, [safeData]); const containerVariants = { hidden: {opacity: 0, y: 20}, @@ -224,7 +179,11 @@ export function DataChart({data}: DataChartProps) {
setSearchTerm(e.target.value)} + onChange={(e) => onSearchChange(e.target.value)} className="pl-8 w-48" />
@@ -278,43 +212,15 @@ export function DataTable({data}: DataTableProps) { - - - - - - - - - - - - + 领取时间 + 项目名称 + 创建者 + 项目内容 - {paginatedData.length === 0 ? ( + {safeData.length === 0 ? ( ) : ( - paginatedData.map((item) => ( + safeData.map((item) => ( {item.received_at ? formatDateTimeWithSeconds(item.received_at) : '-'} @@ -403,8 +309,9 @@ export function DataTable({data}: DataTableProps) { diff --git a/frontend/components/common/received/ReceivedMain.tsx b/frontend/components/common/received/ReceivedMain.tsx index 07f1a2a9..792c733d 100644 --- a/frontend/components/common/received/ReceivedMain.tsx +++ b/frontend/components/common/received/ReceivedMain.tsx @@ -1,16 +1,17 @@ 'use client'; -import {useState, useEffect} from 'react'; +import {useState, useEffect, useCallback} from 'react'; import {toast} from 'sonner'; import {Skeleton} from '@/components/ui/skeleton'; import {Separator} from '@/components/ui/separator'; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'; import {DataChart, DataTable} from '@/components/common/received'; import services from '@/lib/services'; -import {ReceiveHistoryItem} from '@/lib/services/project/types'; +import {ReceiveHistoryItem, ReceiveHistoryChartPoint} from '@/lib/services/project/types'; import {motion} from 'motion/react'; +import {useDebounce} from '@/hooks/use-debounce'; -const PAGE_SIZE = 100; +const PAGE_SIZE = 20; /** * 数据图表骨架屏组件 @@ -136,59 +137,72 @@ const DataTableSkeleton = () => ( * 我的领取页面主组件 */ export function ReceivedMain() { - const [data, setData] = useState([]); - const [Loading, setLoading] = useState(true); + const [chartData, setChartData] = useState([]); + const [tableData, setTableData] = useState([]); + const [totalItems, setTotalItems] = useState(0); + const [chartLoading, setChartLoading] = useState(true); + const [tableLoading, setTableLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [searchInput, setSearchInput] = useState(''); + const debouncedSearch = useDebounce(searchInput, 500); + const [chartDay, setChartDay] = useState(7); /** - * 获取所有领取记录 + * 获取图表数据 */ - const fetchAllReceiveHistory = async () => { + const fetchChartData = async (day: number) => { try { - setLoading(true); + setChartLoading(true); - const firstPageResult = await services.project.getReceiveHistorySafe({ - current: 1, - size: PAGE_SIZE, + const result = await services.project.getReceiveHistoryChartSafe({ + day, }); - if (!firstPageResult.success || !firstPageResult.data) { - throw new Error(firstPageResult.error || '获取数据失败'); + if (!result.success || !result.data) { + throw new Error(result.error || '获取图表数据失败'); } - const {total, results} = firstPageResult.data; - const allResults = [...results]; + setChartData(result.data); + } catch (err) { + toast.error(err instanceof Error ? err.message : '获取图表数据失败'); + } finally { + setChartLoading(false); + } + }; - if (total > PAGE_SIZE) { - const totalPages = Math.ceil(total / PAGE_SIZE); - const remainingPages = Array.from({length: totalPages - 1}, (_, i) => i + 2); + useEffect(() => { + fetchChartData(chartDay); + }, [chartDay]); - const remainingRequests = remainingPages.map((page) => - services.project.getReceiveHistorySafe({ - current: page, - size: PAGE_SIZE, - }), - ); + /** + * 获取表格数据 + */ + const fetchTableData = async (page: number, search: string) => { + try { + setTableLoading(true); - const remainingResults = await Promise.all(remainingRequests); + const result = await services.project.getReceiveHistorySafe({ + current: page, + size: PAGE_SIZE, + search, + }); - remainingResults.forEach((result) => { - if (result.success && result.data) { - allResults.push(...result.data.results); - } - }); + if (!result.success || !result.data) { + throw new Error(result.error || '获取表格数据失败'); } - setData(allResults); + setTableData(result.data.results || []); + setTotalItems(result.data.total); } catch (err) { - toast.error(err instanceof Error ? err.message : '获取数据失败'); + toast.error(err instanceof Error ? err.message : '获取表格数据失败'); } finally { - setLoading(false); + setTableLoading(false); } }; useEffect(() => { - fetchAllReceiveHistory(); - }, []); + fetchTableData(currentPage, debouncedSearch); + }, [currentPage, debouncedSearch]); const containerVariants = { hidden: {opacity: 0}, @@ -211,6 +225,18 @@ export function ReceivedMain() { }, }; + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + }, []); + + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearch]); + return ( - {Loading ? ( - <> - - - - + {chartLoading ? ( + + ) : ( + + )} + + + + {tableLoading ? ( + ) : ( - <> - - - - + )} diff --git a/frontend/lib/services/project/project.service.ts b/frontend/lib/services/project/project.service.ts index d36ebfc7..f12ed6d0 100644 --- a/frontend/lib/services/project/project.service.ts +++ b/frontend/lib/services/project/project.service.ts @@ -20,6 +20,9 @@ import { ReportProjectResponse, ProjectReceiver, ProjectReceiversResponse, + ReceiveHistoryChartPoint, + ReceiveHistoryChartRequest, + ReceiveHistoryChartResponse, } from './types'; import apiClient from '../core/api-client'; @@ -145,6 +148,7 @@ export class ProjectService extends BaseService { params: { current: params.current, size: params.size, + search: params.search || '', }, }); @@ -155,6 +159,25 @@ export class ProjectService extends BaseService { return response.data.data; } + /** + * 获取领取历史图表 + * @param params - 图表参数 + * @returns 领取历史图表数据 + */ + static async getReceiveHistoryChart(params: ReceiveHistoryChartRequest): Promise { + const response = await apiClient.get(`${this.basePath}/received/chart`, { + params: { + day: params.day, + }, + }); + + if (response.data.error_msg) { + throw new Error(response.data.error_msg); + } + + return response.data.data || []; + } + /** * 获取标签列表 * @returns 所有可用标签 @@ -390,6 +413,32 @@ export class ProjectService extends BaseService { } } + /** + * 获取领取历史图表(带错误处理) + * @param params - 图表参数 + * @returns 领取历史图表结果,包含成功状态、数据和错误信息 + */ + static async getReceiveHistoryChartSafe(params: ReceiveHistoryChartRequest): Promise<{ + success: boolean; + data?: ReceiveHistoryChartPoint[]; + error?: string; + }> { + try { + const data = await this.getReceiveHistoryChart(params); + return { + success: true, + data, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取领取历史图表失败'; + return { + success: false, + data: [], + error: errorMessage, + }; + } + } + /** * 获取标签列表(带错误处理) * @returns 获取结果,包含标签数组和错误信息 diff --git a/frontend/lib/services/project/types.ts b/frontend/lib/services/project/types.ts index 500be297..b9c17919 100644 --- a/frontend/lib/services/project/types.ts +++ b/frontend/lib/services/project/types.ts @@ -173,6 +173,8 @@ export interface ReceiveHistoryRequest { current: number; /** 每页数量 */ size: number; + /** 搜索关键词 */ + search?: string; } /** @@ -314,3 +316,28 @@ export interface ProjectReceiver { * 项目领取者响应类型 */ export type ProjectReceiversResponse = BackendResponse; + +/** + * 领取历史图表数据点 + */ +export interface ReceiveHistoryChartPoint { + /** 日期(yyyy-MM-dd) */ + date: string; + /** 展示标签 */ + label: string; + /** 领取数量 */ + count: number; +} + +/** + * 领取历史图表请求参数 + */ +export interface ReceiveHistoryChartRequest { + /** 查询天数(最大6个月) */ + day: number; +} + +/** + * 领取历史图表响应类型 + */ +export type ReceiveHistoryChartResponse = BackendResponse; diff --git a/internal/apps/oauth/models.go b/internal/apps/oauth/models.go index d6ae48d8..5cb7db8c 100644 --- a/internal/apps/oauth/models.go +++ b/internal/apps/oauth/models.go @@ -25,6 +25,14 @@ package oauth import ( + "context" + "encoding/json" + "fmt" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" + "github.com/linux-do/cdk/internal/utils" + "net/http" "time" "gorm.io/gorm" @@ -75,3 +83,48 @@ func (u *User) SetScore(tx *gorm.DB, newScore int) error { func (u *User) RiskLevel() int8 { return BaseUserScore - u.Score } + +func (u *User) GetUserBadges(ctx context.Context) (*UserBadgeResponse, error) { + url := fmt.Sprintf("https://linux.do/user-badges/%s.json", u.Username) + resp, err := utils.Request(ctx, http.MethodGet, url, nil, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取用户徽章失败,状态码: %d", resp.StatusCode) + } + + var response UserBadgeResponse + if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("解析用户徽章响应失败: %w", err) + } + return &response, nil +} + +func (u *User) CalculateUserScore(badges []Badge, badgeScores map[int]int) int { + var totalScore int + for _, badge := range badges { + // 从缓存中查找徽章分数 + if score, exists := badgeScores[badge.ID]; exists { + totalScore += score + } + } + return totalScore - int(config.Config.ProjectApp.DeductionPerOffense)*int(u.ViolationCount) +} + +func (u *User) UpdateUserScore(ctx context.Context, newScore int) error { + // 如果分数没变化,不更新 + if int(u.Score) == newScore || (newScore > MaxUserScore && int(u.Score) == MaxUserScore) { + return nil + } + + // 更新用户分数 + if err := u.SetScore(db.DB(ctx), newScore); err != nil { + return fmt.Errorf("更新用户[%s]分数失败: %w", u.Username, err) + } + + logger.InfoF(ctx, "用户[%s]徽章分数更新成功: %d -> %d", u.Username, u.Score, newScore) + return nil +} diff --git a/internal/apps/oauth/tasks.go b/internal/apps/oauth/tasks.go index 03e555df..e3a5a079 100644 --- a/internal/apps/oauth/tasks.go +++ b/internal/apps/oauth/tasks.go @@ -28,12 +28,13 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "time" + "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/task" "github.com/linux-do/cdk/internal/task/schedule" "github.com/linux-do/cdk/internal/utils" - "net/http" - "time" "github.com/hibiken/asynq" "github.com/linux-do/cdk/internal/db" @@ -74,71 +75,29 @@ func loadBadgeScores(ctx context.Context) (map[int]int, error) { return badgeScores, nil } -// getUserBadges 获取用户的徽章 -func getUserBadges(ctx context.Context, username string) (*UserBadgeResponse, error) { - url := fmt.Sprintf("https://linux.do/user-badges/%s.json", username) - resp, err := utils.Request(ctx, http.MethodGet, url, nil, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("获取用户徽章失败,状态码: %d", resp.StatusCode) - } - - var response UserBadgeResponse - if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("解析用户徽章响应失败: %w", err) - } - return &response, nil -} - -// calculateUserScore 计算用户分数 -func calculateUserScore(user *User, badges []Badge, badgeScores map[int]int) int { - var totalScore int - for _, badge := range badges { - // 从缓存中查找徽章分数 - if score, exists := badgeScores[badge.ID]; exists { - totalScore += score - } - } - return totalScore - int(config.Config.ProjectApp.DeductionPerOffense)*int(user.ViolationCount) -} - -// updateUserScore 更新用户分数 -func updateUserScore(ctx context.Context, user *User, newScore int) error { - // 如果分数没变化,不更新 - if int(user.Score) == newScore || (newScore > MaxUserScore && int(user.Score) == MaxUserScore) { - return nil - } - - // 更新用户分数 - if err := user.SetScore(db.DB(ctx), newScore); err != nil { - return fmt.Errorf("更新用户[%s]分数失败: %w", user.Username, err) - } - - logger.InfoF(ctx, "用户[%s]徽章分数更新成功: %d -> %d", user.Username, user.Score, newScore) - return nil -} - // HandleUpdateUserBadgeScores 处理所有用户徽章分数更新任务 func HandleUpdateUserBadgeScores(ctx context.Context, t *asynq.Task) error { - // 分页处理用户 pageSize := 200 page := 0 currentDelay := 0 * time.Second + // 计算一周前日期 now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - oneWeekAgo := today.AddDate(0, 0, -6) + sessionAgeDays := config.Config.App.SessionAge / 86400 + if sessionAgeDays < 7 { + sessionAgeDays = 7 + } + oneWeekAgo := today.AddDate(0, 0, -sessionAgeDays) for { var users []User - if err := db.DB(ctx).Where("last_login_at >= ? AND is_active = ?", oneWeekAgo, true). - Select("id, username"). - Offset(page * pageSize).Limit(pageSize). + if err := db.DB(ctx). + Table("users u"). + Select("u.id, u.username"). + Joins("INNER JOIN (SELECT id FROM users WHERE last_login_at >= ? ORDER BY last_login_at DESC LIMIT ? OFFSET ?) tmp ON u.id = tmp.id", + oneWeekAgo, pageSize, page*pageSize). Find(&users).Error; err != nil { logger.ErrorF(ctx, "查询用户失败: %v", err) return err @@ -194,17 +153,17 @@ func HandleUpdateSingleUserBadgeScore(ctx context.Context, t *asynq.Task) error } // 获取用户徽章 - response, err := getUserBadges(ctx, user.Username) + response, err := user.GetUserBadges(ctx) if err != nil { logger.ErrorF(ctx, "处理用户[%s]失败: %v", user.Username, err) return err } // 计算用户分数 - totalScore := calculateUserScore(&user, response.Badges, badgeScores) + totalScore := user.CalculateUserScore(response.Badges, badgeScores) // 更新用户分数 - return updateUserScore(ctx, &user, totalScore) + return user.UpdateUserScore(ctx, totalScore) } // getAllBadges 获取所有徽章数据 diff --git a/internal/apps/oauth/utils.go b/internal/apps/oauth/utils.go index 1d13ceda..592ff0cc 100644 --- a/internal/apps/oauth/utils.go +++ b/internal/apps/oauth/utils.go @@ -123,7 +123,7 @@ func doOAuth(ctx context.Context, code string) (*User, error) { AvatarUrl: userInfo.AvatarUrl, IsActive: userInfo.Active, TrustLevel: userInfo.TrustLevel, - LastLoginAt: time.Now().UTC(), + LastLoginAt: time.Now(), } tx = db.DB(ctx).Create(&user) if tx.Error != nil { @@ -157,7 +157,7 @@ func doOAuth(ctx context.Context, code string) (*User, error) { user.AvatarUrl = userInfo.AvatarUrl user.IsActive = userInfo.Active user.TrustLevel = userInfo.TrustLevel - user.LastLoginAt = time.Now().UTC() + user.LastLoginAt = time.Now() tx = db.DB(ctx).Save(&user) if tx.Error != nil { span.SetStatus(codes.Error, tx.Error.Error()) diff --git a/internal/apps/project/routers.go b/internal/apps/project/routers.go index ffcc0c37..6ae7a4de 100644 --- a/internal/apps/project/routers.go +++ b/internal/apps/project/routers.go @@ -566,8 +566,9 @@ func ReportProject(c *gin.Context) { } type ListReceiveHistoryRequest struct { - Current int `json:"current" form:"current" binding:"min=1"` - Size int `json:"size" form:"size" binding:"min=1,max=100"` + Current int `json:"current" form:"current" binding:"min=1"` + Size int `json:"size" form:"size" binding:"min=1,max=100"` + Search string `json:"search" form:"search" binding:"max=255"` } type ListReceiveHistoryResponseDataResult struct { @@ -607,40 +608,55 @@ func ListReceiveHistory(c *gin.Context) { } offset := (req.Current - 1) * req.Size - // query db + // build base query + baseQuery := db.DB(c.Request.Context()). + Table("project_items"). + Joins("INNER JOIN projects ON projects.id = project_items.project_id"). + Joins("INNER JOIN users ON users.id = projects.creator_id"). + Where("project_items.receiver_id = ?", userID) + + // apply search filter + if req.Search != "" { + searchPattern := strings.TrimSpace(req.Search) + "%" + if len(searchPattern) > 21 { + baseQuery = baseQuery.Where( + "users.nickname LIKE ? OR projects.name LIKE ?", + "%"+searchPattern, "%"+searchPattern, + ) + } else { + baseQuery = baseQuery.Where( + "users.username LIKE ? OR users.nickname LIKE ? OR projects.name LIKE ?", + searchPattern, "%"+searchPattern, "%"+searchPattern, + ) + } + } + + // query total count var total int64 - if err := db.DB(c.Request.Context()).Model(&ProjectItem{}).Where("receiver_id = ?", userID).Count(&total).Error; err != nil { + if err := baseQuery.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, ListReceiveHistoryResponse{ErrorMsg: err.Error()}) return } - var items []*ProjectItem - if err := db.DB(c.Request.Context()). - Model(&ProjectItem{}). - Where("receiver_id = ?", userID). + + // build data query with COALESCE for nickname default + var results []ListReceiveHistoryResponseDataResult + if err := baseQuery. + Select(` + project_items.project_id, + projects.name as project_name, + users.username as project_creator, + COALESCE(NULLIF(users.nickname, ''), users.username) as project_creator_nickname, + project_items.content, + project_items.received_at + `). + Order("project_items.received_at DESC, project_items.id DESC"). Offset(offset). Limit(req.Size). - Preload("Project.Creator"). - Find(&items).Error; err != nil { + Scan(&results).Error; err != nil { c.JSON(http.StatusInternalServerError, ListReceiveHistoryResponse{ErrorMsg: err.Error()}) return } - // response - results := make([]ListReceiveHistoryResponseDataResult, len(items)) - for i, item := range items { - creatorNickname := item.Project.Creator.Nickname - if creatorNickname == "" { - creatorNickname = item.Project.Creator.Username - } - results[i] = ListReceiveHistoryResponseDataResult{ - ProjectID: item.ProjectID, - ProjectName: item.Project.Name, - ProjectCreator: item.Project.Creator.Username, - ProjectCreatorNickname: creatorNickname, - Content: item.Content, - ReceivedAt: item.ReceivedAt, - } - } c.JSON( http.StatusOK, ListReceiveHistoryResponse{ @@ -762,3 +778,74 @@ func ListMyProjects(c *gin.Context) { Data: pagedData, }) } + +type ListReceiveHistoryChartRequest struct { + Day int `json:"day" form:"day" binding:"min=1,max=180"` +} + +type ReceiveHistoryChartPoint struct { + Date string `json:"date"` + Label string `json:"label"` + Count int64 `json:"count"` +} + +type ListReceiveHistoryChartResponse struct { + ErrorMsg string `json:"error_msg"` + Data []ReceiveHistoryChartPoint `json:"data"` +} + +// ListReceiveHistoryChart +// @Tags project +// @Param request query ListReceiveHistoryChartRequest true "request query" +// @Produce json +// @Success 200 {object} ListReceiveHistoryChartResponse +// @Router /api/v1/projects/received/chart [get] +func ListReceiveHistoryChart(c *gin.Context) { + userID := oauth.GetUserIDFromContext(c) + + req := &ListReceiveHistoryChartRequest{} + if err := c.ShouldBindQuery(req); err != nil { + c.JSON(http.StatusBadRequest, ListReceiveHistoryChartResponse{ErrorMsg: err.Error()}) + return + } + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + startDate := today.AddDate(0, 0, -(req.Day - 1)) + nextDay := today.AddDate(0, 0, 1) + + var rows []struct { + Day time.Time `json:"day"` + Count int64 `json:"count"` + } + + if err := db.DB(c.Request.Context()). + Table("project_items"). + Select("DATE(received_at) AS day, COUNT(*) AS count"). + Where("receiver_id = ? AND received_at IS NOT NULL AND received_at >= ? AND received_at < ?", userID, startDate, nextDay). + Group("DATE(received_at)"). + Order("day ASC"). + Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, ListReceiveHistoryChartResponse{ErrorMsg: err.Error()}) + return + } + + dayCountMap := make(map[string]int64, len(rows)) + for _, row := range rows { + key := row.Day.Format("2006-01-02") + dayCountMap[key] = row.Count + } + + results := make([]ReceiveHistoryChartPoint, 0, req.Day) + for i := 0; i < req.Day; i++ { + day := startDate.AddDate(0, 0, i) + key := day.Format("2006-01-02") + results = append(results, ReceiveHistoryChartPoint{ + Date: key, + Label: day.Format("01/02"), + Count: dayCountMap[key], + }) + } + + c.JSON(http.StatusOK, ListReceiveHistoryChartResponse{Data: results}) +} diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 05f785cc..b3f566d1 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -26,6 +26,7 @@ package migrator import ( "context" + "github.com/linux-do/cdk/internal/config" "log" "os" "strings" @@ -36,6 +37,10 @@ import ( ) func Migrate() { + if !config.Config.Database.Enabled { + return + } + if err := db.DB(context.Background()).AutoMigrate( &oauth.User{}, &project.Project{}, diff --git a/internal/router/router.go b/internal/router/router.go index 6f77aa9e..30847451 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -27,6 +27,9 @@ package router import ( "context" "fmt" + "log" + "strconv" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/redis" "github.com/gin-gonic/gin" @@ -41,8 +44,6 @@ import ( swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" - "log" - "strconv" ) func Serve() { @@ -115,6 +116,7 @@ func Serve() { projectRouter.GET("/:id/receivers", project.ProjectCreatorPermMiddleware(), project.ListProjectReceivers) projectRouter.POST("/:id/receive", project.ReceiveProjectMiddleware(), project.ReceiveProject) projectRouter.POST("/:id/report", project.ReportProject) + projectRouter.GET("/received/chart", project.ListReceiveHistoryChart) projectRouter.GET("/received", project.ListReceiveHistory) projectRouter.GET("/:id", project.GetProject) }