373 lines
9.8 KiB
Go
373 lines
9.8 KiB
Go
|
|
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)
|
|||
|
|
}
|