Files
2026-08-15 17:04:47 +08:00

307 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"strings"
"time"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// AdminService 运营后台:统计、用户、团队。
type AdminService struct {
DB *gorm.DB
}
type StatsPoint struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
type TokenPoint struct {
Date string `json:"date"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
Calls int64 `json:"calls"`
}
type OverviewStats struct {
UserCount int64 `json:"userCount"`
TeamCount int64 `json:"teamCount"`
DAUToday int64 `json:"dauToday"`
DAUSeries []StatsPoint `json:"dauSeries"`
TokenToday TokenPoint `json:"tokenToday"`
TokenSeries []TokenPoint `json:"tokenSeries"`
}
// Overview 概览统计。
func (s *AdminService) Overview(days int) (*OverviewStats, error) {
if days <= 0 || days > 90 {
days = 14
}
out := &OverviewStats{
DAUSeries: []StatsPoint{},
TokenSeries: []TokenPoint{},
}
s.DB.Model(&model.User{}).Count(&out.UserCount)
s.DB.Model(&model.Team{}).Count(&out.TeamCount)
today := time.Now().In(time.Local).Format("2006-01-02")
s.DB.Model(&model.UserDailyActive{}).Where("active_date = ?", today).Count(&out.DAUToday)
start := time.Now().In(time.Local).AddDate(0, 0, -(days - 1)).Format("2006-01-02")
type dauRow struct {
ActiveDate string
Cnt int64
}
var dauRows []dauRow
s.DB.Model(&model.UserDailyActive{}).
Select("active_date, COUNT(*) AS cnt").
Where("active_date >= ?", start).
Group("active_date").
Order("active_date").
Scan(&dauRows)
dauMap := map[string]int64{}
for _, r := range dauRows {
dauMap[r.ActiveDate] = r.Cnt
}
type tokRow struct {
UsageDate string
PromptTokens int64
CompletionTokens int64
Calls int64
}
var tokRows []tokRow
s.DB.Model(&model.AIUsageDaily{}).
Select("usage_date, SUM(prompt_tokens) AS prompt_tokens, SUM(completion_tokens) AS completion_tokens, SUM(calls) AS calls").
Where("usage_date >= ?", start).
Group("usage_date").
Order("usage_date").
Scan(&tokRows)
tokMap := map[string]tokRow{}
for _, r := range tokRows {
tokMap[r.UsageDate] = r
}
for i := 0; i < days; i++ {
d := time.Now().In(time.Local).AddDate(0, 0, -(days - 1 - i)).Format("2006-01-02")
out.DAUSeries = append(out.DAUSeries, StatsPoint{Date: d, Count: dauMap[d]})
t := tokMap[d]
pt := TokenPoint{
Date: d,
PromptTokens: t.PromptTokens,
CompletionTokens: t.CompletionTokens,
Calls: t.Calls,
}
out.TokenSeries = append(out.TokenSeries, pt)
if d == today {
out.TokenToday = pt
}
}
return out, nil
}
type AdminUserItem struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AIBanned int `json:"aiBanned"`
Disabled int `json:"disabled"`
LastLoginIP string `json:"lastLoginIp"`
LastSeenAt string `json:"lastSeenAt"`
CreatedAt string `json:"createdAt"`
}
// ListUsers 用户列表。
func (s *AdminService) ListUsers() ([]AdminUserItem, error) {
var users []model.User
if err := s.DB.Order("id ASC").Find(&users).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
ids := make([]int64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
nick := map[int64]string{}
if len(ids) > 0 {
var profiles []model.UserProfile
s.DB.Where("user_id IN ?", ids).Find(&profiles)
for _, p := range profiles {
nick[p.UserID] = p.Nickname
}
}
out := make([]AdminUserItem, 0, len(users))
for _, u := range users {
out = append(out, AdminUserItem{
ID: u.ID,
Username: u.Username,
Nickname: nick[u.ID],
AIBanned: u.AIBanned,
Disabled: u.Disabled,
LastLoginIP: u.LastLoginIP,
LastSeenAt: u.LastSeenAt,
CreatedAt: u.CreatedAt,
})
}
return out, nil
}
// PatchUser 更新用户 ai_banned / disabled不可禁用自己
func (s *AdminService) PatchUser(actorID, targetID int64, aiBanned, disabled *int) error {
if actorID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
if targetID <= 0 {
return commonservice.BadRequest("BAD_REQUEST")
}
if targetID == commonservice.AdminUserID && disabled != nil && *disabled != 0 {
return commonservice.BadRequest("CANNOT_DISABLE_ADMIN")
}
updates := map[string]any{}
if aiBanned != nil {
if *aiBanned != 0 {
updates["ai_banned"] = 1
} else {
updates["ai_banned"] = 0
}
}
if disabled != nil {
if *disabled != 0 {
updates["disabled"] = 1
} else {
updates["disabled"] = 0
}
}
if len(updates) == 0 {
return commonservice.BadRequest("BAD_REQUEST")
}
res := s.DB.Model(&model.User{}).Where("id = ?", targetID).Updates(updates)
if res.Error != nil {
return commonservice.Internal("SAVE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}
type AdminTeamItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
OwnerName string `json:"ownerName"`
AIBanned int `json:"aiBanned"`
Members int64 `json:"members"`
CreatedAt string `json:"createdAt"`
}
// ListTeams 团队列表。
func (s *AdminService) ListTeams() ([]AdminTeamItem, error) {
var teams []model.Team
if err := s.DB.Order("id ASC").Find(&teams).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out := make([]AdminTeamItem, 0, len(teams))
for _, t := range teams {
item := AdminTeamItem{
ID: t.ID, Name: t.Name, OwnerID: t.OwnerID,
AIBanned: t.AIBanned, CreatedAt: t.CreatedAt,
}
var u model.User
if s.DB.Select("username").First(&u, t.OwnerID).Error == nil {
item.OwnerName = u.Username
}
s.DB.Model(&model.TeamMember{}).Where("team_id = ?", t.ID).Count(&item.Members)
out = append(out, item)
}
return out, nil
}
// PatchTeam 更新团队 ai_banned。
func (s *AdminService) PatchTeam(actorID, teamID int64, aiBanned *int) error {
if actorID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
if teamID <= 0 || aiBanned == nil {
return commonservice.BadRequest("BAD_REQUEST")
}
v := 0
if *aiBanned != 0 {
v = 1
}
res := s.DB.Model(&model.Team{}).Where("id = ?", teamID).Update("ai_banned", v)
if res.Error != nil {
return commonservice.Internal("SAVE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}
// AIPolicy 当前用户是否允许使用 AI。
type AIPolicy struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}
// GetAIPolicy 综合用户禁用/禁 AI 与所属团队禁 AI。
func (s *AdminService) GetAIPolicy(userID int64) (*AIPolicy, error) {
var u model.User
if err := s.DB.Select("id", "disabled", "ai_banned").First(&u, userID).Error; err != nil {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
if u.Disabled != 0 {
return &AIPolicy{Allowed: false, Reason: "ACCOUNT_DISABLED"}, nil
}
if u.AIBanned != 0 {
return &AIPolicy{Allowed: false, Reason: "USER_AI_BANNED"}, nil
}
var n int64
s.DB.Table("team_members").
Joins("JOIN teams ON teams.id = team_members.team_id").
Where("team_members.user_id = ? AND teams.ai_banned = 1", userID).
Count(&n)
if n > 0 {
return &AIPolicy{Allowed: false, Reason: "TEAM_AI_BANNED"}, nil
}
return &AIPolicy{Allowed: true}, nil
}
// ReportAIUsage 累加当日 token 用量。
func (s *AdminService) ReportAIUsage(userID, teamID int64, provider string, prompt, completion int64, estimated bool) error {
if userID <= 0 {
return commonservice.Unauthorized("UNAUTHORIZED")
}
provider = strings.TrimSpace(provider)
if provider == "" {
provider = "unknown"
}
if prompt < 0 {
prompt = 0
}
if completion < 0 {
completion = 0
}
today := time.Now().In(time.Local).Format("2006-01-02")
est := 0
if estimated {
est = 1
}
q := `INSERT INTO ai_usage_daily(user_id,team_id,usage_date,provider,prompt_tokens,completion_tokens,calls,estimated)
VALUES(?,?,?,?,?,?,1,?)
ON DUPLICATE KEY UPDATE
prompt_tokens=prompt_tokens+VALUES(prompt_tokens),
completion_tokens=completion_tokens+VALUES(completion_tokens),
calls=calls+1,
estimated=IF(VALUES(estimated)=1,1,estimated)`
if err := s.DB.Exec(q, userID, teamID, today, provider, prompt, completion, est).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
return nil
}