From 4f3bb7e3ab33541377970e850969ad250b8b5dc5 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 9 Jun 2026 01:24:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9C=8B=E6=9D=BFsql=E7=9A=84=E6=95=B0=E6=8D=AE=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/dashboard_repo.go | 61 +++++++++++----- frontend/utils/assetImageHelper.js | 69 +++++++++++++++++-- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/backend/services/statisticService/repository/dashboard_repo.go b/backend/services/statisticService/repository/dashboard_repo.go index 382a57f..8554b83 100644 --- a/backend/services/statisticService/repository/dashboard_repo.go +++ b/backend/services/statisticService/repository/dashboard_repo.go @@ -122,26 +122,32 @@ type ExhibitionSummary struct { // TopExhibitionRow top5 藏品 type TopExhibitionRow struct { - AssetID int64 - AssetName string - AssetThumb string - Duration7d string - Earnings7d int64 - AvgEarnings int32 + AssetID int64 + AssetName string + AssetThumb string + Duration7d string + Earnings7d int64 + AvgEarnings int32 } func (r *DashboardRepository) GetExhibitionIncomeSummary(ctx context.Context, userID, starID int64) (*ExhibitionSummary, error) { // 简化实现:直接统计 events(生产环境用 mv_daily_exhibition_revenue) + // LEFT JOIN assets 取 name/cover_url;event 里 asset_id 可能为 null/非数字,需要 WHERE 防御 rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT - (properties->>'asset_id')::BIGINT AS asset_id, - SUM((properties->>'amount')::BIGINT) AS earnings, - SUM((properties->>'duration_ms')::BIGINT) AS duration_ms - FROM %s.events - WHERE user_id=$1 AND star_id=$2 - AND event_type='exhibition.revenue' - AND received_at >= NOW() - INTERVAL '7 days' - GROUP BY asset_id + (e.properties->>'asset_id')::BIGINT AS asset_id, + COALESCE(MIN(a.name), '') AS asset_name, + COALESCE(MIN(a.cover_url), '') AS asset_thumb, + SUM((e.properties->>'amount')::BIGINT) AS earnings, + SUM((e.properties->>'duration_ms')::BIGINT) AS duration_ms + FROM %s.events e + LEFT JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT + WHERE e.user_id=$1 AND e.star_id=$2 + AND e.event_type='exhibition.revenue' + AND e.received_at >= NOW() - INTERVAL '7 days' + AND (e.properties->>'asset_id') IS NOT NULL + AND (e.properties->>'asset_id') ~ '^[0-9]+$' + GROUP BY (e.properties->>'asset_id')::BIGINT ORDER BY earnings DESC LIMIT 5 `, r.schema), userID, starID) @@ -154,7 +160,7 @@ func (r *DashboardRepository) GetExhibitionIncomeSummary(ctx context.Context, us for rows.Next() { var t TopExhibitionRow var durationMs int64 - if err := rows.Scan(&t.AssetID, &t.Earnings7d, &durationMs); err != nil { + if err := rows.Scan(&t.AssetID, &t.AssetName, &t.AssetThumb, &t.Earnings7d, &durationMs); err != nil { return nil, err } t.Duration7d = formatDuration(durationMs) @@ -166,10 +172,29 @@ func (r *DashboardRepository) GetExhibitionIncomeSummary(ctx context.Context, us top5 = append(top5, t) } - // 简化:exhibiting_count / starbook_count 留 0 + // 展出中:当前 active 的 exhibitions(未删除 + 未过期) + var exhibitingCount int32 + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*)::INT FROM public.exhibitions + WHERE occupier_uid=$1 AND occupier_star_id=$2 + AND deleted_at IS NULL + AND expire_at > EXTRACT(EPOCH FROM NOW()) * 1000 + `, userID, starID).Scan(&exhibitingCount); err != nil { + exhibitingCount = 0 + } + + // 星册数:该用户在此 star 下启用的 booth_slots 总数 + var starbookCount int32 + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*)::INT FROM public.booth_slots + WHERE user_id=$1 AND star_id=$2 AND is_enabled = TRUE + `, userID, starID).Scan(&starbookCount); err != nil { + starbookCount = 0 + } + return &ExhibitionSummary{ - ExhibitingCount: 0, - StarbookCount: 0, + ExhibitingCount: exhibitingCount, + StarbookCount: starbookCount, TotalDuration: formatDuration(totalDurationMs), TotalEarnings: totalEarnings, Top5: top5, diff --git a/frontend/utils/assetImageHelper.js b/frontend/utils/assetImageHelper.js index 0552867..839b8db 100644 --- a/frontend/utils/assetImageHelper.js +++ b/frontend/utils/assetImageHelper.js @@ -65,26 +65,85 @@ export function extractOssObjectPath(url) { } } +/** + * 判断 URL 是否为带 Expires 的 OSS 预签名 URL,且已过期 + * @param {String} url + * @returns {Boolean} + */ +function isExpiredPresignedUrl(url) { + if (!url || typeof url !== 'string') return false + const m = url.match(/[?&]Expires=(\d+)/) + if (!m) return false + // OSS Expires 单位是秒,Date.now() 单位是毫秒 + return Number(m[1]) * 1000 < Date.now() +} + +/** + * 从完整 OSS URL 提取对象路径(去掉 ?Expires=...&OSSAccessKeyId=...&Signature=... 部分) + * 例如:https://bucket.oss-cn-shanghai.aliyuncs.com/laser-card/x.png?Expires=... → laser-card/x.png + * @param {String} url + * @returns {String} + */ +function extractOssObjectPathFromUrl(url) { + try { + // 去掉 query string + const pathOnly = url.split('?')[0] + // 提取 aliyuncs.com/ 之后的路径 + const marker = 'aliyuncs.com/' + const idx = pathOnly.indexOf(marker) + if (idx !== -1) { + return pathOnly.substring(idx + marker.length) + } + // 兜底:去掉协议 + 域名 + const protoEnd = pathOnly.indexOf('://') + const pathStart = pathOnly.indexOf('/', protoEnd + 3) + if (pathStart !== -1) { + return pathOnly.substring(pathStart + 1) + } + return pathOnly + } catch (e) { + return url + } +} + /** * 获取藏品封面的真实URL - 使用 OSS 预签名 URL - * @param {String} coverUrl - 后端返回的cover_url(相对路径或完整URL) + * 处理 3 种 coverUrl 形态: + * 1) /static/... 本地静态资源 → 原样返回 + * 2) 相对路径 asset/1/87/xxx.jpg → 调 getOssPresignedUrlApi 重签 + * 3) 完整 OSS 预签名 URL(可能已过期) → 检测 Expires,过期则提取对象路径重签 + * @param {String} coverUrl - 后端返回的cover_url * @returns {Promise} 真实可访问的URL */ export async function getAssetCoverRealUrl(coverUrl) { // 默认图片路径 const DEFAULT_IMAGE = '/static/nft/collection.png'; - // 如果是本地静态资源或为空,直接返回 + // 1. 本地静态资源或空 → 直接返回 if (!coverUrl || coverUrl.startsWith('/static/')) { return coverUrl || DEFAULT_IMAGE; } - // 如果是完整的绝对URL(包含 ://),直接返回 + // 2. 完整 URL(带 ://) if (coverUrl.includes('://')) { - return coverUrl; + // 检查是否带 Expires 且已过期 → 提取对象路径重签 + if (isExpiredPresignedUrl(coverUrl)) { + const objectPath = extractOssObjectPathFromUrl(coverUrl) + try { + const res = await getOssPresignedUrlApi(objectPath, 3600, 'asset') + if (res && res.data && res.data.url) { + return res.data.url + } + } catch (e) { + console.error('[assetImageHelper] re-sign expired url failed:', e) + } + return objectPath // 兜底:返回相对路径 + } + // 未过期:原样返回 + return coverUrl } - // 相对路径:调用预签名接口获取可访问的URL + // 3. 相对路径:调预签名接口 try { const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset'); if (res && res.data && res.data.url) {