Files
code-utils/admin.go
李琦 e71ce451ff 模块介绍 / 今日规划 / 下班日报 / 团队摘要 生成成功后没有上报,也几乎不写「AI」日志。
流结束时的 usage 用了非阻塞发送,通道一满就被丢掉;很多服务商默认还不在流里带 usage。
现在每次真正打到模型的调用都会:

在本地日志(分类 AI,消息 AI 调用完成)写下 scene / prompt / completion / cached
登录后同步上报到后台日汇总
2026-08-17 14:00:47 +08:00

383 lines
10 KiB
Go
Raw 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 main
// admin.go 云端管理员id=1运营后台TOTP/stepup、统计、用户/团队、发版。
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (a *App) requireCloudAdmin() error {
if a.syncUserID() != festivalAdminID {
return errors.New("FORBIDDEN")
}
return nil
}
func (a *App) currentStepUpToken() string {
a.stepupMu.Lock()
defer a.stepupMu.Unlock()
if a.stepupToken == "" || time.Now().After(a.stepupExp) {
a.stepupToken = ""
return ""
}
return a.stepupToken
}
func (a *App) setStepUpToken(tok string, exp time.Time) {
a.stepupMu.Lock()
a.stepupToken = strings.TrimSpace(tok)
a.stepupExp = exp
a.stepupMu.Unlock()
}
func (a *App) clearStepUpToken() {
a.stepupMu.Lock()
a.stepupToken = ""
a.stepupExp = time.Time{}
a.stepupMu.Unlock()
}
func (a *App) stepUpHeaders() (map[string]string, error) {
tok := a.currentStepUpToken()
if tok == "" {
return nil, errors.New("ADMIN_STEPUP_REQUIRED")
}
return map[string]string{"X-Admin-StepUp": tok}, nil
}
func (a *App) adminDecode(method, path string, body, out any, needStepUp bool) error {
if e := a.ready(); e != nil {
return e
}
if e := a.requireCloudAdmin(); e != nil {
return e
}
var headers map[string]string
if needStepUp {
h, e := a.stepUpHeaders()
if e != nil {
return e
}
headers = h
}
err := a.apiDecodeHeaders(method, path, body, out, true, headers)
if err != nil {
code := err.Error()
if code == "ADMIN_IP_CHANGED" || code == "ADMIN_STEPUP_REQUIRED" {
a.clearStepUpToken()
}
}
return err
}
// AdminTOTPStatus 查询 TOTP 绑定状态。
func (a *App) AdminTOTPStatus() (map[string]any, error) {
var out map[string]any
if e := a.adminDecode(http.MethodGet, "/api/v1/admin/totp/status", nil, &out, false); e != nil {
return nil, e
}
out["stepupActive"] = a.currentStepUpToken() != ""
if tok := a.currentStepUpToken(); tok != "" {
a.stepupMu.Lock()
out["stepupExpiresAt"] = a.stepupExp.UTC().Format(time.RFC3339)
a.stepupMu.Unlock()
}
return out, nil
}
// AdminTOTPSetupBegin 开始绑定 Google Authenticator。
func (a *App) AdminTOTPSetupBegin() (map[string]any, error) {
var out map[string]any
if e := a.adminDecode(http.MethodPost, "/api/v1/admin/totp/setup", map[string]any{}, &out, false); e != nil {
return nil, e
}
return out, nil
}
// AdminTOTPSetupConfirm 确认绑定。
func (a *App) AdminTOTPSetupConfirm(code string) error {
return a.adminDecode(http.MethodPost, "/api/v1/admin/totp/confirm", map[string]string{"code": code}, nil, false)
}
// AdminStepUp 用动态码换取 2h 敏感操作凭证。
func (a *App) AdminStepUp(code string) (map[string]any, error) {
var out struct {
StepupToken string `json:"stepupToken"`
ExpiresAt string `json:"expiresAt"`
}
if e := a.adminDecode(http.MethodPost, "/api/v1/admin/stepup", map[string]string{"code": code}, &out, false); e != nil {
return nil, e
}
exp, _ := time.Parse(time.RFC3339, out.ExpiresAt)
if exp.IsZero() {
exp = time.Now().UTC().Add(2 * time.Hour)
}
a.setStepUpToken(out.StepupToken, exp)
return map[string]any{
"ok": true,
"expiresAt": exp.UTC().Format(time.RFC3339),
"stepupActive": true,
}, nil
}
// AdminHasStepUp 前端判断是否还需弹动态码。
func (a *App) AdminHasStepUp() bool {
return a.currentStepUpToken() != ""
}
// AdminOverview 运营概览。
func (a *App) AdminOverview(days int) (map[string]any, error) {
if days <= 0 {
days = 14
}
var out map[string]any
if e := a.adminDecode(http.MethodGet, "/api/v1/admin/stats/overview?days="+strconv.Itoa(days), nil, &out, false); e != nil {
return nil, e
}
return out, nil
}
// AdminListUsers 用户列表。
func (a *App) AdminListUsers() ([]map[string]any, error) {
var resp struct {
Items []map[string]any `json:"items"`
}
if e := a.adminDecode(http.MethodGet, "/api/v1/admin/users", nil, &resp, false); e != nil {
return nil, e
}
if resp.Items == nil {
return []map[string]any{}, nil
}
return resp.Items, nil
}
// AdminGetUser 用户详情(含待办/工单/笔记/团队数量)。
func (a *App) AdminGetUser(id int64) (map[string]any, error) {
var out map[string]any
if e := a.adminDecode(http.MethodGet, "/api/v1/admin/users/"+strconv.FormatInt(id, 10), nil, &out, false); e != nil {
return nil, e
}
return out, nil
}
// AdminPatchUser 更新用户禁 AI / 禁用。field: aiBanned | disabled。
func (a *App) AdminPatchUser(id int64, field string, value int) error {
body := map[string]any{}
switch field {
case "aiBanned":
body["aiBanned"] = value
case "disabled":
body["disabled"] = value
default:
return errors.New("BAD_REQUEST")
}
return a.adminDecode(http.MethodPatch, "/api/v1/admin/users/"+strconv.FormatInt(id, 10), body, nil, true)
}
// AdminListTeams 团队列表。
func (a *App) AdminListTeams() ([]map[string]any, error) {
var resp struct {
Items []map[string]any `json:"items"`
}
if e := a.adminDecode(http.MethodGet, "/api/v1/admin/teams", nil, &resp, false); e != nil {
return nil, e
}
if resp.Items == nil {
return []map[string]any{}, nil
}
return resp.Items, nil
}
// AdminPatchTeam 更新团队禁 AI。
func (a *App) AdminPatchTeam(id int64, aiBanned int) error {
return a.adminDecode(http.MethodPatch, "/api/v1/admin/teams/"+strconv.FormatInt(id, 10), map[string]any{
"aiBanned": aiBanned,
}, nil, true)
}
// AdminListReleases 发版列表。
func (a *App) AdminListReleases() ([]map[string]any, error) {
var resp struct {
Items []map[string]any `json:"items"`
}
if e := a.adminDecode(http.MethodGet, "/api/v1/admin/releases", nil, &resp, false); e != nil {
return nil, e
}
if resp.Items == nil {
return []map[string]any{}, nil
}
return resp.Items, nil
}
// AdminSelectReleaseFile 选择本地 NSIS 安装包。
func (a *App) AdminSelectReleaseFile() (string, error) {
return application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择安装包", "Select installer")).
CanChooseDirectories(false).
CanChooseFiles(true).
AddFilter("Installer", "*.exe").
PromptForSingleSelection()
}
// AdminUploadRelease 上传发版包(需 stepup
func (a *App) AdminUploadRelease(version, channel, changelog, filePath string) (map[string]any, error) {
if e := a.ready(); e != nil {
return nil, e
}
if e := a.requireCloudAdmin(); e != nil {
return nil, e
}
headers, e := a.stepUpHeaders()
if e != nil {
return nil, e
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return nil, errors.New("FILE_REQUIRED")
}
f, e := os.Open(filePath)
if e != nil {
return nil, errors.New("FILE_REQUIRED")
}
defer f.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("version", strings.TrimSpace(version))
_ = w.WriteField("channel", strings.TrimSpace(channel))
_ = w.WriteField("changelog", changelog)
part, e := w.CreateFormFile("file", filepath.Base(filePath))
if e != nil {
return nil, e
}
if _, e = io.Copy(part, f); e != nil {
return nil, errors.New("SAVE_FAILED")
}
_ = w.Close()
base := a.apiBaseURL()
if base == "" {
return nil, errors.New("SYNC_NOT_CONFIGURED")
}
req, e := http.NewRequest(http.MethodPost, base+"/api/v1/admin/releases", &buf)
if e != nil {
return nil, errors.New("SYNC_OFFLINE")
}
req.Header.Set("Content-Type", w.FormDataContentType())
tok := strings.TrimSpace(a.store.Meta("sync_access_token"))
if tok == "" {
return nil, errors.New("SYNC_NOT_LOGGED_IN")
}
req.Header.Set("Authorization", "Bearer "+tok)
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, e := client.Do(req)
if e != nil {
return nil, errors.New("SYNC_OFFLINE")
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode >= 400 {
var er struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &er) == nil && er.Error != "" {
if er.Error == "ADMIN_IP_CHANGED" || er.Error == "ADMIN_STEPUP_REQUIRED" {
a.clearStepUpToken()
}
return nil, errors.New(er.Error)
}
return nil, errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode))
}
var out map[string]any
if json.Unmarshal(raw, &out) != nil {
return nil, errors.New("SYNC_BAD_RESPONSE")
}
return out, nil
}
// AdminPublishRelease 发布为最新。
func (a *App) AdminPublishRelease(id int64) (map[string]any, error) {
var out map[string]any
if e := a.adminDecode(http.MethodPost, "/api/v1/admin/releases/"+strconv.FormatInt(id, 10)+"/publish", map[string]any{}, &out, true); e != nil {
return nil, e
}
return out, nil
}
// runActivityPingLoop 登录后每 10 分钟上报一次日活。
func (a *App) runActivityPingLoop() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
// 启动稍后 ping 一次
time.Sleep(45 * time.Second)
a.activityPingOnce()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
a.activityPingOnce()
}
}
}
func (a *App) activityPingOnce() {
if a.store == nil || a.bootstrap.State != BootstrapReady {
return
}
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" {
return
}
_ = a.apiDecode(http.MethodPost, "/api/v1/activity/ping", map[string]any{}, nil, true)
}
// checkAIPolicy 登录用户调用 AI 前检查服务端策略。
func (a *App) checkAIPolicy() error {
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" {
return nil // 离线/未登录:本地 BYOK 不拦
}
var out struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
}
if e := a.apiDecode(http.MethodGet, "/api/v1/ai/policy", nil, &out, true); e != nil {
return nil // 策略接口失败不阻断本地 AI
}
if !out.Allowed {
if out.Reason == "" {
return errors.New("USER_AI_BANNED")
}
return errors.New(out.Reason)
}
return nil
}
// reportAIUsage 上报一次 AI 用量(失败忽略)。
func (a *App) reportAIUsage(provider string, prompt, completion, cached int64, estimated bool) {
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" {
return
}
_ = a.apiDecode(http.MethodPost, "/api/v1/ai/usage", map[string]any{
"provider": provider,
"promptTokens": prompt,
"completionTokens": completion,
"cachedTokens": cached,
"estimated": estimated,
}, nil, true)
}