Files
code-utils/admin.go
李琦 7c4109f687 feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。
热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
2026-08-19 15:46:03 +08:00

403 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 (
"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()
base := a.apiBaseURL()
if base == "" {
return nil, errors.New("SYNC_NOT_CONFIGURED")
}
pr, pw := io.Pipe()
w := multipart.NewWriter(pw)
go func() {
var copyErr error
defer func() {
_ = w.Close()
if copyErr != nil {
_ = pw.CloseWithError(copyErr)
} else {
_ = pw.Close()
}
}()
if e := w.WriteField("version", strings.TrimSpace(version)); e != nil {
copyErr = e
return
}
if e := w.WriteField("channel", strings.TrimSpace(channel)); e != nil {
copyErr = e
return
}
if e := w.WriteField("changelog", changelog); e != nil {
copyErr = e
return
}
part, e := w.CreateFormFile("file", filepath.Base(filePath))
if e != nil {
copyErr = e
return
}
if _, e = io.Copy(part, f); e != nil {
copyErr = errors.New("SAVE_FAILED")
}
}()
req, e := http.NewRequest(http.MethodPost, base+"/api/v1/admin/releases", pr)
if e != nil {
_ = pw.Close()
return nil, errors.New("SYNC_OFFLINE")
}
req.Header.Set("Content-Type", w.FormDataContentType())
tok := strings.TrimSpace(a.store.Meta("sync_access_token"))
if tok == "" {
_ = pw.Close()
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: 20 * 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)
}