From 18d47d78523d1ddd123846f925d99e537221a057 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:16:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=BF=90=E8=90=A5=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20-=20=E5=A2=9E=E5=8A=A0=E4=BD=99=E9=A2=9D=E6=B6=88?= =?UTF-8?q?=E8=80=97=E7=BB=9F=E8=AE=A1=E3=80=81=E9=80=9A=E7=9F=A5=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E4=B8=8E=E7=94=A8=E6=88=B7=E7=A7=AF=E5=88=86=E6=B1=87?= =?UTF-8?q?=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/analytics.go | 43 ++++++++++++------- backend/internal/app/analytics_build.go | 3 ++ backend/internal/app/announcement.go | 3 ++ backend/internal/database/migrations.go | 8 +++- backend/internal/model/models_project.go | 1 + backend/internal/repository/admin_audit.go | 18 ++++++-- backend/internal/repository/announcement.go | 4 +- .../internal/repository/repository_users.go | 6 +++ .../content/docs/backend/backend-database.mdx | 4 +- .../admin-banner-announcements-panel.tsx | 9 +++- .../components/admin-user-detail-drawer.tsx | 2 + .../admin/components/analytics-panel.tsx | 2 + web/src/services/api/announcements.ts | 2 + web/src/services/api/auth.ts | 6 ++- 14 files changed, 84 insertions(+), 27 deletions(-) diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index bc9f28170..bbdacdbb6 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -28,24 +28,27 @@ type AnalyticsOverview struct { } type AnalyticsKPI struct { - ActiveUsers int `json:"activeUsers"` - DAU int `json:"dau"` - WAU int `json:"wau"` - MAU int `json:"mau"` - GenerationTasks int `json:"generationTasks"` - UpstreamRequests int `json:"upstreamRequests"` - SuccessRate float64 `json:"successRate"` - P95DurationMs int64 `json:"p95DurationMs"` - CurrentQueuedTasks int64 `json:"currentQueuedTasks"` - Finance AnalyticsFinance `json:"finance"` + ActiveUsers int `json:"activeUsers"` + DAU int `json:"dau"` + WAU int `json:"wau"` + MAU int `json:"mau"` + GenerationTasks int `json:"generationTasks"` + UpstreamRequests int `json:"upstreamRequests"` + SuccessRate float64 `json:"successRate"` + P95DurationMs int64 `json:"p95DurationMs"` + CurrentQueuedTasks int64 `json:"currentQueuedTasks"` + Finance AnalyticsFinance `json:"finance"` + TotalBalanceMicrocredits int64 `json:"totalBalanceMicrocredits"` + TotalConsumedMicrocredits int64 `json:"totalConsumedMicrocredits"` } type AnalyticsTrendPoint struct { - Day string `json:"day"` - Tasks int `json:"tasks"` - Requests int `json:"requests"` - ActiveUsers int `json:"activeUsers"` - RequestSuccessRate float64 `json:"requestSuccessRate"` + Day string `json:"day"` + Tasks int `json:"tasks"` + Requests int `json:"requests"` + ActiveUsers int `json:"activeUsers"` + RequestSuccessRate float64 `json:"requestSuccessRate"` + ConsumedMicrocredits int64 `json:"consumedMicrocredits"` } type AnalyticsModelRow struct { @@ -163,11 +166,21 @@ func (s *Service) AdminAnalytics(actor *model.User, query AnalyticsQuery) (*Anal if err != nil { return nil, err } + totalBalance, err := s.repo.TotalCreditBalance() + if err != nil { + return nil, err + } queued, err := s.repo.CurrentQueuedTaskCount() if err != nil { return nil, err } result := buildAnalyticsOverview(filter, tasks, rollingTasks, rollingLogs, logs, activities, users) + result.KPI.TotalBalanceMicrocredits = totalBalance + for _, log := range logs { + if log.BillingAmount > 0 { + result.KPI.TotalConsumedMicrocredits += log.BillingAmount + } + } records, err := s.analyticsFinancialRecords(logs) if err != nil { return nil, err diff --git a/backend/internal/app/analytics_build.go b/backend/internal/app/analytics_build.go index 559660a24..8d5875cd2 100644 --- a/backend/internal/app/analytics_build.go +++ b/backend/internal/app/analytics_build.go @@ -117,6 +117,9 @@ func buildAnalyticsTrend(filter repository.AnalyticsFilter, tasks []model.Task, key := log.CreatedAt.UTC().Format("2006-01-02") if point := points[key]; point != nil { point.Requests++ + if log.BillingAmount > 0 { + point.ConsumedMicrocredits += log.BillingAmount + } requestTotals[key]++ if log.Status == model.ApiCallStatusSucceeded { requestSuccess[key]++ diff --git a/backend/internal/app/announcement.go b/backend/internal/app/announcement.go index 1edc6e41e..9c5d7737a 100644 --- a/backend/internal/app/announcement.go +++ b/backend/internal/app/announcement.go @@ -520,6 +520,7 @@ type CreateBannerAnnouncementRequest struct { NoticeType string `json:"noticeType"` Link string `json:"link"` Status string `json:"status"` // "active" | "disabled" + SortOrder int `json:"sortOrder"` StartsAt *time.Time `json:"startsAt"` EndsAt *time.Time `json:"endsAt"` } @@ -763,6 +764,7 @@ func (s *Service) CreateBannerAnnouncement(actor *model.User, req CreateBannerAn NoticeType: noticeType, Link: link, Status: status, + SortOrder: req.SortOrder, StartsAt: req.StartsAt, EndsAt: req.EndsAt, CreatedBy: actor.ID, @@ -810,6 +812,7 @@ func (s *Service) UpdateBannerAnnouncement(actor *model.User, id string, req Upd banner.NoticeType = noticeType banner.Link = link banner.Status = status + banner.SortOrder = req.SortOrder banner.StartsAt = req.StartsAt banner.EndsAt = req.EndsAt banner.UpdatedAt = time.Now() diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 7599fa228..b781999b6 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 48 +const CurrentSchemaVersion int64 = 49 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -157,6 +157,12 @@ var schemaMigrations = []migration{ } return nil }}, + {version: 49, name: "banner_sort_order", checksum: "sha256:banner-sort-order-v49-20261010", apply: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&model.BannerAnnouncement{}, "SortOrder") { + return tx.Migrator().AddColumn(&model.BannerAnnouncement{}, "SortOrder") + } + return nil + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 03697500b..382013e0f 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -476,6 +476,7 @@ type BannerAnnouncement struct { NoticeType string `json:"noticeType" gorm:"size:24"` Link string `json:"link" gorm:"size:500"` // 点击跳转目标:http(s) 外链或 / 开头的站内路径;空表示不可点击 Status string `json:"status" gorm:"size:24;index"` // "active" | "disabled" + SortOrder int `json:"sortOrder" gorm:"index"` StartsAt *time.Time `json:"startsAt,omitempty" gorm:"index"` EndsAt *time.Time `json:"endsAt,omitempty" gorm:"index"` CreatedBy string `json:"createdBy" gorm:"size:36"` diff --git a/backend/internal/repository/admin_audit.go b/backend/internal/repository/admin_audit.go index 41952a293..cc2de1b6a 100644 --- a/backend/internal/repository/admin_audit.go +++ b/backend/internal/repository/admin_audit.go @@ -17,10 +17,12 @@ var ( ) type AdminUserCounts struct { - LedgerEntries int64 `json:"ledgerEntries"` - Tasks int64 `json:"tasks"` - APICalls int64 `json:"apiCalls"` - AuditEvents int64 `json:"auditEvents"` + LedgerEntries int64 `json:"ledgerEntries"` + Tasks int64 `json:"tasks"` + APICalls int64 `json:"apiCalls"` + AuditEvents int64 `json:"auditEvents"` + RechargeMicrocredits int64 `json:"rechargeMicrocredits"` + CheckinMicrocredits int64 `json:"checkinMicrocredits"` } func (r *Repository) AppendAdminAudit(event *model.AdminAuditEvent) error { @@ -112,6 +114,14 @@ func (r *Repository) AdminUserCounts(userID string) (AdminUserCounts, error) { return AdminUserCounts{}, err } } + for entryType, target := range map[model.CreditLedgerType]*int64{ + model.CreditLedgerPaymentTopup: &counts.RechargeMicrocredits, + model.CreditLedgerCheckinBonus: &counts.CheckinMicrocredits, + } { + if err := r.db.Model(&model.CreditLedgerEntry{}).Where("user_id = ? AND type = ?", userID, entryType).Select("COALESCE(SUM(amount_microcredits), 0)").Scan(target).Error; err != nil { + return AdminUserCounts{}, err + } + } return counts, nil } diff --git a/backend/internal/repository/announcement.go b/backend/internal/repository/announcement.go index bcb9cfc07..e13a20e8b 100644 --- a/backend/internal/repository/announcement.go +++ b/backend/internal/repository/announcement.go @@ -85,7 +85,7 @@ func (r *Repository) ActiveBannerAnnouncements(now time.Time) ([]model.BannerAnn Where("status = ?", "active"). Where("starts_at IS NULL OR starts_at <= ?", now). Where("ends_at IS NULL OR ends_at >= ?", now). - Order("created_at desc"). + Order("sort_order asc, created_at desc"). Find(&banners).Error decodeBannerTitleRuns(banners) return banners, err @@ -104,7 +104,7 @@ func (r *Repository) AdminBannerAnnouncements(keyword string, status string, lim if err := query.Count(&total).Error; err != nil { return nil, 0, err } - if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&banners).Error; err != nil { + if err := query.Order("sort_order asc, created_at desc").Limit(limit).Offset(offset).Find(&banners).Error; err != nil { return nil, 0, err } decodeBannerTitleRuns(banners) diff --git a/backend/internal/repository/repository_users.go b/backend/internal/repository/repository_users.go index 0a6d62a54..cd5965dde 100644 --- a/backend/internal/repository/repository_users.go +++ b/backend/internal/repository/repository_users.go @@ -63,6 +63,12 @@ func (r *Repository) Users() ([]model.User, error) { return users, err } +func (r *Repository) TotalCreditBalance() (int64, error) { + var total int64 + err := r.db.Model(&model.CreditAccount{}).Select("COALESCE(SUM(available_microcredits), 0)").Scan(&total).Error + return total, err +} + func (r *Repository) AdminUsers(keyword string, role model.UserRole, status model.UserStatus, limit int, offset int) ([]model.User, int64, error) { var users []model.User var total int64 diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 6950bc7a7..8385bb7ea 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -31,7 +31,7 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex ## 结构迁移与版本 -当前结构版本为 48。版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 +当前结构版本为 49。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 版本 36 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 @@ -264,7 +264,7 @@ schema v27 为 `channel_model_price_tiers` 和 `billing_orders` 添加 `cost_con | `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变排序;`image_resource_id` 只能绑定由当前管理员上传并在发布事务中消费的公告配图草稿 | | `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 | | `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 | -| `banner_announcements` | 保存首页顶部常驻展示的滚动通知条 | 与系统公告完全独立:只有标题、图标与跳转链接,无正文、无配图、无已读状态;`link` 仅接受 `http(s)` 外链或 `/` 开头的站内路径;`status` 为 `active` / `disabled`,`starts_at` / `ends_at` 构成生效窗口(可预约未来开始);`title_runs` 以 JSON 文本保存标题的样式分段;`notice_type` 决定通知条底色,`icon` 是标题前的图标键 | +| `banner_announcements` | 保存首页顶部常驻展示的滚动通知条 | 与系统公告完全独立:只有标题、图标与跳转链接,无正文、无配图、无已读状态;`link` 仅接受 `http(s)` 外链或 `/` 开头的站内路径;`status` 为 `active` / `disabled`,`sort_order` 越小越靠前,`starts_at` / `ends_at` 构成生效窗口(可预约未来开始);`title_runs` 以 JSON 文本保存标题的样式分段;`notice_type` 决定通知条底色,`icon` 是标题前的图标键 | 活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。 diff --git a/web/src/pages/admin/components/admin-banner-announcements-panel.tsx b/web/src/pages/admin/components/admin-banner-announcements-panel.tsx index e2c8f0afb..a53fe22a9 100644 --- a/web/src/pages/admin/components/admin-banner-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-banner-announcements-panel.tsx @@ -1,4 +1,4 @@ -import { App, Button, DatePicker, Form, Input, Modal, Popconfirm, Switch } from "antd"; +import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Switch } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Trash2, Edit3, Search } from "lucide-react"; import dayjs, { type Dayjs } from "dayjs"; @@ -29,6 +29,7 @@ type FormValues = { noticeType: BannerNoticeType; link?: string; status: boolean; + sortOrder?: number; dateRange?: [Dayjs | null, Dayjs | null] | null; }; @@ -90,6 +91,7 @@ export default function AdminBannerAnnouncementsPanel({ form.setFieldsValue({ noticeType: BANNER_NOTICE_DEFAULT_TYPE, status: true, + sortOrder: 0, }); setDialog({ mode: "create" }); }, [form]); @@ -112,6 +114,7 @@ export default function AdminBannerAnnouncementsPanel({ noticeType: type, link: banner.link || undefined, status: banner.status === "active", + sortOrder: banner.sortOrder ?? 0, dateRange: banner.startsAt || banner.endsAt ? [banner.startsAt ? dayjs(banner.startsAt) : null, banner.endsAt ? dayjs(banner.endsAt) : null] : null, }); setDialog({ mode: "edit", banner }); @@ -187,6 +190,7 @@ export default function AdminBannerAnnouncementsPanel({ noticeType: selectedNoticeType, link: values.link?.trim() || undefined, status: (values.status ? "active" : "disabled") as "active" | "disabled", + sortOrder: values.sortOrder || 0, startsAt, endsAt, }; @@ -428,6 +432,9 @@ export default function AdminBannerAnnouncementsPanel({