桌面应用图标替换为 B-brackets 设计(深藏青 + 白/蓝花括号)
- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成: icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo - 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效 - 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
This commit is contained in:
1 parent
71a6d986a4
commit
39f4d5addc
161 files changed
+3289
-515
No files matched your search
@@ -3,17 +3,28 @@ package main
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"opsxapi/server/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("OPSXAPI_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8787"
|
||||
}
|
||||
addr := resolveListenAddr()
|
||||
log.Printf("OpsxAPI server listening on %s", addr)
|
||||
if err := server.NewRouter().Run(addr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveListenAddr:默认仅本机 127.0.0.1:8787(Mock 本机优先)。
|
||||
// 显式对外:OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=0.0.0.0:8787 / :8787。
|
||||
func resolveListenAddr() string {
|
||||
if v := strings.TrimSpace(os.Getenv("OPSXAPI_ADDR")); v != "" {
|
||||
return v
|
||||
}
|
||||
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXAPI_BIND_PUBLIC")))
|
||||
if pub == "1" || pub == "true" || pub == "yes" {
|
||||
return ":8787"
|
||||
}
|
||||
return "127.0.0.1:8787"
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
server:
|
||||
addr: ":8787"
|
||||
# 默认仅本机;公网需显式 OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=:8787
|
||||
addr: "127.0.0.1:8787"
|
||||
proxy:
|
||||
timeout_ms: 30000
|
||||
# Environment overrides:
|
||||
# OPSXAPI_ADDR=:8787
|
||||
# OPSXAPI_ADDR=127.0.0.1:8787
|
||||
# OPSXAPI_BIND_PUBLIC=1
|
||||
# OPSXAPI_PROXY_TIMEOUT_MS=30000
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -43,6 +44,9 @@ func (a *API) Register(r *gin.Engine) {
|
||||
g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace))
|
||||
g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace))
|
||||
g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock))
|
||||
g.PUT("/mock/local", a.handlePublishLocalMock)
|
||||
g.DELETE("/mock/local", a.handleRevokeLocalMock)
|
||||
g.DELETE("/workspaces/:id/mock", a.auth(a.handleRevokeMock))
|
||||
}
|
||||
|
||||
/* ---------------- 中间件 ---------------- */
|
||||
@@ -312,7 +316,8 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
|
||||
Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
|
||||
TTLSeconds int64 `json:"ttlSeconds"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"})
|
||||
@@ -322,15 +327,39 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"})
|
||||
return
|
||||
}
|
||||
if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
|
||||
return
|
||||
}
|
||||
if err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
var expiresAt int64
|
||||
if req.TTLSeconds > 0 {
|
||||
expiresAt = time.Now().Unix() + req.TTLSeconds
|
||||
}
|
||||
if err := a.store.SetMockExpiry(ws.ID, expiresAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
base := ws.MockToken
|
||||
if base == "" {
|
||||
base = strconv.FormatInt(ws.ID, 10) // 兜底(不应出现)
|
||||
base = strconv.FormatInt(ws.ID, 10)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base, "expiresAt": expiresAt})
|
||||
}
|
||||
|
||||
func (a *API) handleRevokeMock(c *gin.Context, user *store.User) {
|
||||
ws := a.workspaceOf(c, user)
|
||||
if ws == nil {
|
||||
return
|
||||
}
|
||||
if _, err := a.store.RevokeMock(ws.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
/* ---------------- 工作区同步 ---------------- */
|
||||
@@ -385,3 +414,76 @@ func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updatedAt": now})
|
||||
}
|
||||
|
||||
/* ---------------- 本机联调 Mock(不绑账号) ---------------- */
|
||||
|
||||
func (a *API) handlePublishLocalMock(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "本机联调发布仅允许从 127.0.0.1 / ::1 调用"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
|
||||
TTLSeconds int64 `json:"ttlSeconds"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"})
|
||||
return
|
||||
}
|
||||
if len(req.Endpoints) > 200 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"})
|
||||
return
|
||||
}
|
||||
if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
|
||||
return
|
||||
}
|
||||
wsID, token, err := a.store.EnsureLocalMockWorkspace()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := a.store.ReplaceMockEndpoints(wsID, req.Endpoints); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
var expiresAt int64
|
||||
if req.TTLSeconds > 0 {
|
||||
expiresAt = time.Now().Unix() + req.TTLSeconds
|
||||
}
|
||||
if err := a.store.SetMockExpiry(wsID, expiresAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + token, "token": token, "expiresAt": expiresAt})
|
||||
}
|
||||
|
||||
func (a *API) handleRevokeLocalMock(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "本机联调作废仅允许从 127.0.0.1 / ::1 调用"})
|
||||
return
|
||||
}
|
||||
wsID, _, err := a.store.EnsureLocalMockWorkspace()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := a.store.RevokeMock(wsID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func isLoopbackRequest(c *gin.Context) bool {
|
||||
ip := net.ParseIP(c.ClientIP())
|
||||
if ip != nil && ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||
if err != nil {
|
||||
host = c.Request.RemoteAddr
|
||||
}
|
||||
ip = net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func localhostCORS() gin.HandlerFunc {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
|
||||
@@ -108,6 +108,7 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id);
|
||||
if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil {
|
||||
_, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`)
|
||||
}
|
||||
_, _ = s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_expires_at INTEGER NOT NULL DEFAULT 0`)
|
||||
// 老库补擅长列(幂等)
|
||||
_, _ = s.db.Exec(`ALTER TABLE team_members ADD COLUMN specialty TEXT NOT NULL DEFAULT ''`)
|
||||
rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`)
|
||||
@@ -511,19 +512,114 @@ func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocks
|
||||
return e, true, nil
|
||||
}
|
||||
|
||||
// ResolveMockToken:mock token → workspace id(不存在返回 false,不暴露信息)
|
||||
// ResolveMockToken:mock token → workspace id(不存在 / 已过期 / 已作废 均返回 false)
|
||||
func (s *Store) ResolveMockToken(token string) (int64, bool, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRow(`SELECT id FROM workspaces WHERE mock_token = ?`, token).Scan(&id)
|
||||
var expiresAt int64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, IFNULL(mock_expires_at, 0) FROM workspaces WHERE mock_token = ? AND mock_token != ''`,
|
||||
token,
|
||||
).Scan(&id, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if expiresAt > 0 && time.Now().Unix() >= expiresAt {
|
||||
return 0, false, nil
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// SetMockExpiry 设置可选 TTL(expiresAtUnix=0 表示不过期)。
|
||||
func (s *Store) SetMockExpiry(workspaceID int64, expiresAtUnix int64) error {
|
||||
_, err := s.db.Exec(`UPDATE workspaces SET mock_expires_at = ? WHERE id = ?`, expiresAtUnix, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeMock 作废当前 mock token:轮换新 token、清零过期、清空 endpoints;旧链接立刻失效。
|
||||
func (s *Store) RevokeMock(workspaceID int64) (newToken string, err error) {
|
||||
newToken = newMockToken()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE workspaces SET mock_token = ?, mock_expires_at = 0 WHERE id = ?`,
|
||||
newToken, workspaceID,
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newToken, nil
|
||||
}
|
||||
|
||||
const localMockWorkspaceName = "__opsxapi_local_mock__"
|
||||
const localMockUserEmail = "__local_mock__@opsxapi.internal"
|
||||
|
||||
// ensureLocalMockOwner 系统账号,仅作 FK 锚点,不出现在登录流程。
|
||||
func (s *Store) ensureLocalMockOwner() (int64, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRow(`SELECT id FROM users WHERE email = ?`, localMockUserEmail).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`,
|
||||
"本机联调", localMockUserEmail, hashPassword(newMockToken()), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// EnsureLocalMockWorkspace 为本机联调预留的系统空间(不绑真实用户账号)。
|
||||
func (s *Store) EnsureLocalMockWorkspace() (id int64, token string, err error) {
|
||||
err = s.db.QueryRow(
|
||||
`SELECT id, IFNULL(mock_token,'') FROM workspaces WHERE name = ? AND team_id IS NULL LIMIT 1`,
|
||||
localMockWorkspaceName,
|
||||
).Scan(&id, &token)
|
||||
if err == nil {
|
||||
if token == "" {
|
||||
token = newMockToken()
|
||||
if _, e := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, token, id); e != nil {
|
||||
return 0, "", e
|
||||
}
|
||||
}
|
||||
return id, token, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, "", err
|
||||
}
|
||||
ownerID, err := s.ensureLocalMockOwner()
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
token = newMockToken()
|
||||
now := time.Now().Unix()
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`,
|
||||
localMockWorkspaceName, ownerID, now, token,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
return id, token, err
|
||||
}
|
||||
|
||||
|
||||
func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
_, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID)
|
||||
|
||||
Reference in new issue
Block a user