feat(admin): 运营数据 - 增加余额消耗统计、通知排序与用户积分汇总

This commit is contained in:
ddcat666
2026-10-10 07:16:22 +08:00
parent 00fdd9e879
commit e0778ef727
14 changed files with 84 additions and 27 deletions

View File

@@ -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

View File

@@ -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]++

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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"`

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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` 是标题前的图标键 |
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。

View File

@@ -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({
<Form.Item name="status" label="发布状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="sortOrder" label="展示顺序" extra="数字越小越靠前;相同顺序按创建时间倒序">
<InputNumber min={0} max={999999} precision={0} className="w-full" />
</Form.Item>
<Form.Item name="dateRange" label="有效期(可选)" extra="留空表示长期有效;设置时间段后仅在有效期内于前台展示">
<DatePicker.RangePicker showTime format="YYYY-MM-DD HH:mm" className="w-full" />

View File

@@ -122,6 +122,8 @@ export function AdminUserDetailDrawer({ userId, onClose, previousUserId, nextUse
{ key: "role", label: "角色", children: detail.user.role === "admin" ? "管理员" : "普通用户" },
{ key: "status", label: "状态", children: <AdminStatusBadge label={detail.user.status === "active" ? "启用" : "停用"} tone={detail.user.status === "active" ? "success" : "neutral"} /> },
{ key: "available", label: "可用积分", children: formatCredits(detail.account.availableMicrocredits) },
{ key: "recharge", label: "累计充值积分", children: formatCredits(detail.counts.rechargeMicrocredits) },
{ key: "checkin", label: "累计签到积分", children: formatCredits(detail.counts.checkinMicrocredits) },
{ key: "reserved", label: "冻结积分", children: formatCredits(detail.account.reservedMicrocredits) },
{ key: "created", label: "注册时间", children: formatTime(detail.user.createdAt) },
{ key: "login", label: "最后登录", children: formatTime(detail.user.lastLoginAt) },

View File

@@ -300,6 +300,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)}
detail={data ? `日 ${formatNumber(data.kpi.dau)} · 周 ${formatNumber(data.kpi.wau)} · 月 ${formatNumber(data.kpi.mau)}` : undefined}
/>
<AnalyticsHealthCard icon={<CircleDollarSign className="size-4" />} label="用户总余额" value={data ? formatCredits(data.kpi.totalBalanceMicrocredits ?? 0) : "--"} detail="当前所有用户可用积分余额" />
<AnalyticsHealthCard icon={<CircleDollarSign className="size-4" />} label="范围消耗" value={data ? formatCredits(data.kpi.totalConsumedMicrocredits ?? 0) : "--"} detail="筛选范围内已计费调用" />
<AnalyticsHealthCard
icon={<Workflow className="size-4" />}
label="生成任务"

View File

@@ -87,6 +87,7 @@ export type BannerAnnouncement = {
noticeType?: BannerNoticeType;
link?: string;
status: "active" | "disabled";
sortOrder?: number;
startsAt?: string;
endsAt?: string;
createdBy: string;
@@ -107,6 +108,7 @@ export type AdminBannerPayload = {
noticeType?: BannerNoticeType;
link?: string;
status: "active" | "disabled";
sortOrder?: number;
startsAt?: string;
endsAt?: string;
};

View File

@@ -126,7 +126,7 @@ export type AdminAuditEvent = {
export type AdminUserDetail = {
user: LocalUser;
account: { userId: string; availableMicrocredits: number; reservedMicrocredits: number; version: number };
counts: { ledgerEntries: number; tasks: number; apiCalls: number; auditEvents: number };
counts: { ledgerEntries: number; tasks: number; apiCalls: number; auditEvents: number; rechargeMicrocredits: number; checkinMicrocredits: number };
storageUsage: {
assetCount: number;
assetBytes: number;
@@ -188,9 +188,11 @@ export type AdminAnalytics = {
successRate: number;
p95DurationMs: number;
currentQueuedTasks: number;
totalBalanceMicrocredits?: number;
totalConsumedMicrocredits?: number;
finance?: AnalyticsFinance | null;
};
trend: Array<{ day: string; tasks: number; requests: number; activeUsers: number; requestSuccessRate: number }>;
trend: Array<{ day: string; tasks: number; requests: number; activeUsers: number; requestSuccessRate: number; consumedMicrocredits?: number }>;
models: Array<{
model: string;
capability: string;