307 lines
8.3 KiB
Go
307 lines
8.3 KiB
Go
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
|
||
}
|