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) }