feat(admin): 运营数据 - 增加余额消耗统计、通知排序与用户积分汇总
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]++
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 是标题前的图标键 |
|
||||
|
||||
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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="生成任务"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user