Files
code-utils/admin.go
李琦 c430a0f6da 1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00

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