Files
code-utils/admin.go

373 lines
9.8 KiB
Go
Raw Normal View History

2026-08-15 17:18:00 +08:00
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
}
// 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 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,
"estimated": estimated,
}, nil, true)
}