Compare commits

..

36 Commits

Author SHA1 Message Date
sagit e194813f3b fix(modal): prevent auto focus restore on close to avoid page scroll (#316)
* fix(dialog): prevent auto focus restore on close to avoid page scroll

When a modal is closed, Radix Dialog by default restores focus to the
trigger element. This causes the page to scroll to that element if it's
not in the viewport, leading to unexpected scrolling behavior after
editing forwards or other items.

Entire-Checkpoint: 78b48b74a841

* fix(modal): prevent auto focus restore on close in ModalContent

Ensure onCloseAutoFocus is applied after props spread to prevent override.
This fixes page scroll to top after closing modal on second edit.
2026-03-13 17:35:18 +08:00
sagit f1cad30f44 fix(dialog): prevent auto focus restore on close to avoid page scroll (#315)
When a modal is closed, Radix Dialog by default restores focus to the
trigger element. This causes the page to scroll to that element if it's
not in the viewport, leading to unexpected scrolling behavior after
editing forwards or other items.

Entire-Checkpoint: 78b48b74a841
2026-03-13 17:04:54 +08:00
sagit 2e05df288b fix(tunnel): validate entry port conflicts before adding new entry nodes (#314)
* fix(tunnel): validate entry port conflicts before adding new entry nodes

- Add validateTunnelEntryPortConflictsForNewEntries to check cross-tunnel
  port conflicts when adding new entry nodes to a tunnel
- Move validation before tx.Commit() to prevent partial success state
- Add contract test for issue #313 regression
- Update panel backend address description to note CDN/HTTPS support

Entire-Checkpoint: eb85eb0c9f2a

* fix: use single quotes to escape Chinese quotation marks in description
2026-03-13 14:24:18 +08:00
sagit 3e5bb8fc0b feat(batch): add failure details to batch operations with expandable result modal (#312)
## Summary
- Backend: Return per-item failure details (id, name, reason) for all
batch operations (delete/pause/resume/redeploy/change-tunnel) on
forwards and tunnels
- Frontend: Add `BatchActionResultModal` component to display failures
in an expandable list
- Add contract tests for batch action failure details
2026-03-13 10:22:15 +08:00
sagitchu d1e3c59537 feat(batch): add failure details to batch operations with expandable result modal
- Backend: return per-item failure details (id, name, reason) for all batch operations
- Frontend: add BatchActionResultModal component to display failures
- Support delete/pause/resume/redeploy/change-tunnel for forwards and tunnels
2026-03-13 10:20:36 +08:00
sagit 8b8ebb6092 refactor(quota): migrate traffic quota from tunnel to user level (#311)
## Summary

- Replace tunnel-level traffic quota with user-level quota system
- Add `user_quota` table with daily/monthly limits and usage tracking
- Remove `tunnel_quota` table and related code
- Update user create/update API to accept quota configuration
- Migrate backup/restore to use user quota fields
- Update frontend to manage user quota instead of tunnel quota

## Test Plan

- [x] Backend unit tests pass
- [x] Contract tests updated for user quota
- [ ] Manual verification of quota enforcement
2026-03-12 14:54:14 +08:00
sagitchu 0195a2a01b refactor(tunnel): remove unused editingTunnel variable 2026-03-12 14:51:36 +08:00
sagitchu ad9b336fb9 refactor(quota): migrate traffic quota from tunnel to user level
- Replace tunnel_quota table with user_quota table
- Add user-level daily/monthly quota tracking and enforcement
- Update user CRUD to include quota configuration
- Migrate backup/restore to use user quota fields
- Update frontend API and UI for user quota management
2026-03-12 14:17:57 +08:00
sagitchu 30d9552207 fix(backend): release old port listeners on tunnel switch
Delete stale forward services on old/kept entry nodes during tunnel changes so ports are freed and rebinds don't hit address-in-use.
2026-03-12 10:55:53 +08:00
sagit 5e96a8de72 feat(quota): add tunnel traffic quota with daily/monthly limits (#291) (#308)
Implement per-tunnel traffic quota feature:
- Add TunnelQuota model with daily/monthly usage tracking
- Integrate quota enforcement into flow accumulation path
- Pause forwards and disable tunnel when quota exceeded
- Block new forward creation/resume when tunnel quota disabled
- Auto-reset daily/monthly windows at 00:05 via maintenance job
- Add manual reset API endpoint for admins
- Include quota config in tunnel backup/restore
- Add frontend UI for quota settings and usage display

Entire-Checkpoint: e629b27ca437
2026-03-11 16:09:03 +08:00
sagit 69faeaa9a6 fix(backend): clean stale forward runtimes on entry updates (#307)
Entire-Checkpoint: 6a6b91fb5f0c
2026-03-11 05:53:31 +00:00
sagit e8bfe52104 fix(backend): sync forward ports when tunnel entry changes (#304)
## What
- When a tunnel's entry node set changes, automatically rebuild all
forwards' `forward_port` rows under that tunnel to match the latest
entry nodes.
- Preserves existing forward port (uses current min port from
`forward_port`).
- Preserves `in_ip` only for single-entry tunnels; clears it for
multi-entry tunnels.

## Why
Forward runtime dispatch is keyed by `forward_port`.
If a tunnel entry node goes offline or is removed, existing forwards
could remain mapped to stale entry nodes and become impossible to
redeploy cleanly.

## Notes
- Plan doc: `plans/028-tunnel-entry-change-sync-forward-ports.md`

Refs #285
2026-03-11 11:25:20 +08:00
sagit 9767cc3247 Merge branch 'main' into fix/issue-285-sync-forward-ports 2026-03-11 11:24:19 +08:00
sagit e8a7f999c8 fix(node): keep info popover above sidebar (#306)
## What
- Raise the node-card info popover z-index so it renders above the left
sidebar.

## Why
- The sidebar uses a higher stacking context (z-50), causing the popover
(z-30) to be covered when it opens to the left.

## Notes
- Verified: `vite-frontend` `npm run build`.

Closes #305
2026-03-11 11:24:02 +08:00
sagitchu 4d4f5f8b1f fix(node): keep info popover above sidebar
Entire-Checkpoint: ea4d99cf1c43
2026-03-11 11:21:44 +08:00
sagitchu d2a425d761 fix(backend): sync forward ports on tunnel entry change 2026-03-11 11:19:53 +08:00
sagit 673d38a089 fix(backend): enforce traffic quota on forwards (#303)
## What
- Block creating/resuming forwards when user or user_tunnel traffic
quota is exceeded (or expired/disabled).
- Keep paused forwards paused after service sync (UpdateService restarts
services on agent side).

## Why
Traffic limit could be bypassed by manually resuming/creating forwards
after quota is exceeded.

## Tests
- (cd go-backend && go test ./...)

Closes #295
2026-03-11 09:38:25 +08:00
sagitchu 2e8c0530a9 fix(backend): block forwards when flow exceeded 2026-03-11 09:34:36 +08:00
sagit 32ee511eac fix(node): compact card metadata for mobile (#300)
## Summary
- move secondary node metadata into a compact info dropdown so cards fit
better on smaller screens
- replace the full connection status chip in the header with a status
dot while keeping detailed status inside the info panel
- keep remarks and renewal metadata accessible without permanently
increasing card height

## Testing
- not run
2026-03-10 20:13:11 +08:00
sagitchu f410640862 fix: calculate info popover placement relative to card container
Changed info popover position calculation to use the card container
as reference instead of viewport, ensuring consistent placement within
card boundaries.
2026-03-10 20:11:49 +08:00
sagitchu 6427b830ea refactor(node): improve card metadata popup with hover trigger
Entire-Checkpoint: 7a033056acc0
2026-03-10 17:19:21 +08:00
sagitchu 5e7bf3ba5c fix(node): compact card metadata for mobile
Entire-Checkpoint: b3c79b0fd2f9
2026-03-10 09:48:11 +08:00
sagit 27d6691232 feat: 支持节点 wss/https 并在失败时回退 ws(兼容旧 ws) (#294)
## 变更说明

本 PR 聚焦节点与面板通信协议兼容性增强:

- 支持节点优先使用 `wss`(WebSocket over TLS)连接后端
- 保持原有 `ws` 连接逻辑完全兼容,不破坏现有节点
- 当 `wss` 握手失败时自动回退到 `ws`
- HTTP 上报链路支持 `https/http` 自动识别与回退
- 兼容旧格式地址输入,避免已有配置失效

## 兼容性

- 旧节点配置(`ws`)可继续正常工作
- 新场景可直接使用 `wss/https`(含 CDN 场景)
- 回退策略可避免因 TLS/CDN 配置差异导致节点离线

## 额外说明

- 本 PR 仅包含协议兼容与回退相关改动
- 不包含安装脚本仓库指向调整相关提交
2026-03-09 18:21:25 +08:00
sagit cc4b8a916a Merge branch 'main' into pr/wss-https-fallback 2026-03-09 18:20:07 +08:00
sagit d9dd5131b2 feat(node): split node page into local and remote sections (#292)
## Summary
- split node page into local/remote sections (tab switch)
- keep per-tab search and count
- keep sorting/actions behavior under the selected section
- include remote usage detail rendering improvements on remote cards

## Scope
- only touches `vite-frontend/src/pages/node.tsx`

## Verify
- `cd vite-frontend && npm install && npm run build` passed locally

## Notes
- this PR intentionally avoids unrelated layout/sidebar customizations
and keeps changes focused on node page UX
2026-03-09 17:56:58 +08:00
sagitchu a98c9f4f59 merge(main): resolve node page conflicts with renewal UX updates 2026-03-09 17:52:18 +08:00
sagit 5cb935e0e5 fix(frontend): refine node renewal UI and filters (#299)
* style: restore Prettier formatting in renewal.ts

* fix(frontend): refine node renewal UI and filters

Entire-Checkpoint: 88c8ae47fb7d

* refactor(nodes): remove unused tags field from node model

* refactor(node): improve card layout and reorder elements

Entire-Checkpoint: fb89f3ebef5c
2026-03-09 08:37:57 +00:00
sagit 6f59e4be0c Merge branch 'main' into pr/wss-https-fallback 2026-03-09 12:38:47 +08:00
sagitchu 647446a2a2 fix(node): isolate tab search and selection state 2026-03-09 12:17:25 +08:00
qimaoww a2ec08f033 fix(tunnel): restore upstream ipv6 address normalization logic 2026-03-09 03:33:40 +08:00
qimaoww f8809d73fb Merge branch 'main' into pr/wss-https-fallback 2026-03-08 22:38:26 +08:00
qimaoww e5339a8072 chore: improve websocket wss->ws fallback diagnostics 2026-03-08 20:07:37 +08:00
qimaoww fbb4d82a44 feat: add wss/https auto-detect with fallback for node-backend comm 2026-03-08 20:07:37 +08:00
Su-cyber-art 61b71a11c7 feat(node): split node page into local and remote sections 2026-03-08 18:45:56 +08:00
Su-cyber-art 4c69ff491d feat(node): render remote usage details on remote node cards
(cherry picked from commit 67294fa76422f994de8d95ec88f8f03e02ea6b9f)
2026-03-08 18:42:40 +08:00
Su-cyber-art 0ad4904e20 feat(node): split local and remote nodes into separate tabs
(cherry picked from commit 30793b2997040dd29ff5fe6397d0bcab174ecc9e)
2026-03-08 18:42:36 +08:00
57 changed files with 10838 additions and 506 deletions
@@ -0,0 +1,30 @@
# Session Context
Session ID: ses_32f7e3b05ffeK8IxNvZAZISGWO
Commit Message: 编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
## Prompts
### Prompt 1
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 2
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 3
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 4
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 5
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
## Summary
已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
@@ -0,0 +1 @@
已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)
@@ -7,13 +7,13 @@
"title": "规则页精简模式显示规则数量",
"version": "1.2.21",
"summary": {
"additions": 67,
"deletions": 57,
"files": 1
"additions": 4529,
"deletions": 65,
"files": 11
},
"time": {
"created": 1773020783005,
"updated": 1773020978012
"updated": 1773022814998
}
},
"messages": [
@@ -3536,6 +3536,505 @@
"messageID": "msg_cd0491b35001Gyu7B4fNEHivH2"
}
]
},
{
"info": {
"role": "user",
"time": {
"created": 1773022792451
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "api",
"modelID": "gemini-3.1-pro-high"
},
"id": "msg_cd064ca87001mKUQE2EyR8PezH",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": [
{
"type": "text",
"text": "这个没改对,页面没变化,请检查",
"id": "prt_cd064ca87002we266x5BvrANpB",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064ca87001mKUQE2EyR8PezH"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773022792467,
"completed": 1773022801627
},
"parentID": "msg_cd064ca87001mKUQE2EyR8PezH",
"modelID": "gemini-3.1-pro-high",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 58585,
"input": 58084,
"output": 59,
"reasoning": 442,
"cache": {
"read": 0,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd064cb13001TWoWh6nBESUPZl",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": [
{
"type": "step-start",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"id": "prt_cd064ee23001PcfPcy835V87tU",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064cb13001TWoWh6nBESUPZl"
},
{
"type": "tool",
"callID": "bash-1773022801288808061-252",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git show HEAD:vite-frontend/src/pages/forward.tsx | grep -B 5 -A 20 \"根据显示模式渲染不同内容\"",
"description": "Check original code structure for rendering modes"
},
"output": " value={batchProgress.percent}\n />\n </div>\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n <div className=\"flex items-center justify-between px-1 mb-3\">\n <span className=\"text-sm font-semibold text-foreground\">\n 全部规则\n </span>\n <span className=\"text-xs text-default-600\">\n {sortedForwards.length} 条规则\n </span>\n </div>\n <div className=\"overflow-hidden rounded-xl border border-divider bg-content1 shadow-md\">\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <SortableContext\n items={sortableForwardIds}\n",
"title": "Check original code structure for rendering modes",
"metadata": {
"output": " value={batchProgress.percent}\n />\n </div>\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n <div className=\"flex items-center justify-between px-1 mb-3\">\n <span className=\"text-sm font-semibold text-foreground\">\n 全部规则\n </span>\n <span className=\"text-xs text-default-600\">\n {sortedForwards.length} 条规则\n </span>\n </div>\n <div className=\"overflow-hidden rounded-xl border border-divider bg-content1 shadow-md\">\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <SortableContext\n items={sortableForwardIds}\n",
"exit": 0,
"description": "Check original code structure for rendering modes",
"truncated": false
},
"time": {
"start": 1773022801444,
"end": 1773022801445
}
},
"id": "prt_cd064ee24001VvqiBXMcI8KgPH",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064cb13001TWoWh6nBESUPZl"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"cost": 0,
"tokens": {
"total": 58585,
"input": 58084,
"output": 59,
"reasoning": 442,
"cache": {
"read": 0,
"write": 0
}
},
"id": "prt_cd064ee250016oJVS2eLQzwT01",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064cb13001TWoWh6nBESUPZl"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773022801632,
"completed": 1773022806653
},
"parentID": "msg_cd064ca87001mKUQE2EyR8PezH",
"modelID": "gemini-3.1-pro-high",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 58423,
"input": 1479,
"output": 44,
"reasoning": 0,
"cache": {
"read": 56900,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd064eedf001lx0GW03y2zsL2c",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": [
{
"type": "step-start",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"id": "prt_cd06501d0001fbtq5FOqwf6wp7",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064eedf001lx0GW03y2zsL2c"
},
{
"type": "tool",
"callID": "bash-1773022806321799405-253",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"description": "Check exactly what I changed in my last commit",
"command": "git diff HEAD~1 HEAD vite-frontend/src/pages/forward.tsx"
},
"output": "",
"title": "Check exactly what I changed in my last commit",
"metadata": {
"output": "",
"exit": 0,
"description": "Check exactly what I changed in my last commit",
"truncated": false
},
"time": {
"start": 1773022806480,
"end": 1773022806481
}
},
"id": "prt_cd06501d0002aJQPoXzQjQ6QxP",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064eedf001lx0GW03y2zsL2c"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"cost": 0,
"tokens": {
"total": 58423,
"input": 1479,
"output": 44,
"reasoning": 0,
"cache": {
"read": 56900,
"write": 0
}
},
"id": "prt_cd06501d1001UHeBODXWcVRT3P",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd064eedf001lx0GW03y2zsL2c"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773022806657,
"completed": 1773022810679
},
"parentID": "msg_cd064ca87001mKUQE2EyR8PezH",
"modelID": "gemini-3.1-pro-high",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 58460,
"input": 1540,
"output": 25,
"reasoning": 0,
"cache": {
"read": 56895,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd065028100100U8RbhbWKxrf3",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": [
{
"type": "step-start",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"id": "prt_cd06511890014PkWgUY9CAmTPd",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065028100100U8RbhbWKxrf3"
},
{
"type": "tool",
"callID": "bash-1773022810346559153-254",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"description": "Check latest commit",
"command": "git log -1"
},
"output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n",
"title": "Check latest commit",
"metadata": {
"output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n",
"exit": 0,
"description": "Check latest commit",
"truncated": false
},
"time": {
"start": 1773022810506,
"end": 1773022810506
}
},
"id": "prt_cd065118a0014hx1B9opNcSfJl",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065028100100U8RbhbWKxrf3"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"cost": 0,
"tokens": {
"total": 58460,
"input": 1540,
"output": 25,
"reasoning": 0,
"cache": {
"read": 56895,
"write": 0
}
},
"id": "prt_cd065118b001SISeN0O2IRDH5C",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065028100100U8RbhbWKxrf3"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773022810683,
"completed": 1773022814810
},
"parentID": "msg_cd064ca87001mKUQE2EyR8PezH",
"modelID": "gemini-3.1-pro-high",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 58595,
"input": 1681,
"output": 24,
"reasoning": 0,
"cache": {
"read": 56890,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd065123a001uXPsKZwVH7RDJb",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": [
{
"type": "step-start",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"id": "prt_cd06521a8001yiaSoVddfbN1tj",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065123a001uXPsKZwVH7RDJb"
},
{
"type": "tool",
"callID": "bash-1773022814469937121-255",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"description": "Check history",
"command": "git log -3"
},
"output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n\ncommit f790becda8ead611b847637daafad6e7d40d8115\nMerge: e7548e7 413081f\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:58:02 2026 +0800\n\n merge: resolve conflicts with main branch\n\ncommit e7548e7b30ecd1c73e4ef98f5d36e6b6394c209a\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:51:28 2026 +0800\n\n style(frontend): format code and improve node card layout\n \n Entire-Checkpoint: 08b760b76538\n",
"title": "Check history",
"metadata": {
"output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n\ncommit f790becda8ead611b847637daafad6e7d40d8115\nMerge: e7548e7 413081f\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:58:02 2026 +0800\n\n merge: resolve conflicts with main branch\n\ncommit e7548e7b30ecd1c73e4ef98f5d36e6b6394c209a\nAuthor: sagitchu <sagitchu@gmail.com>\nDate: Mon Mar 9 09:51:28 2026 +0800\n\n style(frontend): format code and improve node card layout\n \n Entire-Checkpoint: 08b760b76538\n",
"exit": 0,
"description": "Check history",
"truncated": false
},
"time": {
"start": 1773022814633,
"end": 1773022814633
}
},
"id": "prt_cd06521a8002I2rlXCrLkmKM1d",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065123a001uXPsKZwVH7RDJb"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"cost": 0,
"tokens": {
"total": 58595,
"input": 1681,
"output": 24,
"reasoning": 0,
"cache": {
"read": 56890,
"write": 0
}
},
"id": "prt_cd06521a9001CzTlcvvzi6vd8Y",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065123a001uXPsKZwVH7RDJb"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773022814814,
"completed": 1773022818942
},
"parentID": "msg_cd064ca87001mKUQE2EyR8PezH",
"modelID": "gemini-3.1-pro-high",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 58974,
"input": 2054,
"output": 33,
"reasoning": 0,
"cache": {
"read": 56887,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_cd065225e0010wlkl67le6h46g",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": [
{
"type": "step-start",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"id": "prt_cd06531d8001vtXeqUZ9z4fx14",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065225e0010wlkl67le6h46g"
},
{
"type": "tool",
"callID": "bash-1773022818624349202-256",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git log --oneline | head -n 10",
"description": "Check recent commits"
},
"output": "78af992 fix: restore renewal.ts from main to resolve build errors\nf790bec merge: resolve conflicts with main branch\ne7548e7 style(frontend): format code and improve node card layout\n50e4107 fix(frontend): show rule count in compact view mode for forward pages\n413081f feat(nodes): add renewal cycle and auto-advance scheduling (#296)\nbfac640 feat(nodes): add renewal cycle and auto-advance scheduling\n508a37a feat(nodes): add node metadata and expiry reminders (#293)\nd606550 feat(forward): display tunnel traffic ratio on forward page\n31ef861 feat(nodes): add node metadata and expiry reminders (#246)\nf1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n",
"title": "Check recent commits",
"metadata": {
"output": "78af992 fix: restore renewal.ts from main to resolve build errors\nf790bec merge: resolve conflicts with main branch\ne7548e7 style(frontend): format code and improve node card layout\n50e4107 fix(frontend): show rule count in compact view mode for forward pages\n413081f feat(nodes): add renewal cycle and auto-advance scheduling (#296)\nbfac640 feat(nodes): add renewal cycle and auto-advance scheduling\n508a37a feat(nodes): add node metadata and expiry reminders (#293)\nd606550 feat(forward): display tunnel traffic ratio on forward page\n31ef861 feat(nodes): add node metadata and expiry reminders (#246)\nf1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n",
"exit": 0,
"description": "Check recent commits",
"truncated": false
},
"time": {
"start": 1773022818776,
"end": 1773022818776
}
},
"id": "prt_cd06531d8002HjsfEaDHfZzBM7",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065225e0010wlkl67le6h46g"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "97ed982edddf0655a654981a72da08c33b9e467d",
"cost": 0,
"tokens": {
"total": 58974,
"input": 2054,
"output": 33,
"reasoning": 0,
"cache": {
"read": 56887,
"write": 0
}
},
"id": "prt_cd06531d9001eYxQ5nQBoU1rsk",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8",
"messageID": "msg_cd065225e0010wlkl67le6h46g"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1773022818946,
"completed": 1773022824318
},
"error": {
"name": "MessageAbortedError",
"data": {
"message": "The operation was aborted."
}
},
"parentID": "msg_cd064ca87001mKUQE2EyR8PezH",
"modelID": "gemini-3.1-pro-high",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"input": 0,
"output": 0,
"reasoning": 0,
"cache": {
"read": 0,
"write": 0
}
},
"id": "msg_cd0653282001hDYLdWTwLVss9i",
"sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8"
},
"parts": []
}
]
}
@@ -1 +1 @@
规则标签页下,精简模式,和非精简模式一样要显示规则数量
这个没改对,页面没变化,请检查
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -311,6 +312,14 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
}
// Keep paused forwards paused after UpdateService/AddService, since agent-side UpdateService
// always restarts services.
if forward.Status != 1 {
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
return warnings, err
}
}
return warnings, nil
}
@@ -1612,13 +1621,21 @@ func buildForwarderNodes(targets []string) []map[string]interface{} {
}
func processServerAddress(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
serverAddr = normalizeServerAddressInput(serverAddr)
if serverAddr == "" {
return serverAddr
}
if strings.HasPrefix(serverAddr, "[") {
return serverAddr
}
// If the input is a bare IPv6 host (no port), bracket it.
// IPv6-with-port must be provided in bracket form: [::1]:443.
if looksLikeIPv6(serverAddr) {
if ip := net.ParseIP(serverAddr); ip != nil && ip.To4() == nil {
return "[" + serverAddr + "]"
}
}
idx := strings.LastIndex(serverAddr, ":")
if idx < 0 {
if looksLikeIPv6(serverAddr) {
@@ -1637,6 +1654,27 @@ func processServerAddress(serverAddr string) string {
return serverAddr
}
func normalizeServerAddressInput(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
if serverAddr == "" {
return serverAddr
}
if idx := strings.Index(serverAddr, "://"); idx > 0 {
if parsed, err := url.Parse(serverAddr); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host
}
}
serverAddr = serverAddr[idx+3:]
}
if idx := strings.IndexAny(serverAddr, "/?#"); idx >= 0 {
serverAddr = serverAddr[:idx]
}
return strings.TrimSpace(serverAddr)
}
func looksLikeIPv6(address string) bool {
return strings.Count(address, ":") >= 2
}
@@ -420,3 +420,68 @@ func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
}
}
}
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "https with path",
in: "https://panel.example.com:8443/api/v1",
want: "panel.example.com:8443",
},
{
name: "wss with query",
in: "wss://panel.example.com:443/system-info?x=1",
want: "panel.example.com:443",
},
{
name: "http without port",
in: "http://panel.example.com",
want: "panel.example.com",
},
{
name: "manual host with trailing path",
in: "panel.example.com:8080/path",
want: "panel.example.com:8080",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "ipv6 host only",
in: "2001:db8::1",
want: "[2001:db8::1]",
},
{
name: "ipv6 host and port",
in: "https://[2001:db8::1]:8443/path",
want: "[2001:db8::1]:8443",
},
{
name: "already bracketed",
in: "[2001:db8::2]:9000",
want: "[2001:db8::2]:9000",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"log"
"strconv"
"strings"
@@ -43,6 +44,9 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
}
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
@@ -327,6 +331,70 @@ func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
}
}
func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, now int64) error {
if h == nil || h.repo == nil {
return errors.New("invalid flow policy context")
}
if userID <= 0 || tunnelID <= 0 {
return nil
}
user, err := h.repo.GetUserByID(userID)
if err != nil {
return err
}
if user == nil {
return errors.New("用户不存在")
}
if user.Status != 1 {
return errors.New("账号已禁用")
}
if user.ExpTime > 0 && user.ExpTime <= now {
return errors.New("账号已过期")
}
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return errors.New("流量已超额,禁止开启转发")
}
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
return err
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
if err != nil {
return err
}
if userTunnelID <= 0 {
return nil
}
policy, err := h.getUserTunnelPolicy(userTunnelID)
if err != nil {
return err
}
if policy == nil {
return nil
}
if policy.Status != 1 {
return errors.New("该隧道已禁用")
}
if policy.ExpTime > 0 && policy.ExpTime <= now {
return errors.New("该隧道已过期")
}
utFlowLimit := policy.Flow * bytesPerGB
utCurrent := policy.InFlow + policy.OutFlow
if utCurrent >= utFlowLimit {
return errors.New("该隧道流量已超额,禁止开启转发")
}
return nil
}
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil {
@@ -101,6 +101,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
+1
View File
@@ -136,6 +136,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
}
h.resetMonthlyFlow(now)
h.resetUserQuotaWindows(now)
h.disableExpiredUsers(now.UnixMilli())
h.disableExpiredUserTunnels(now.UnixMilli())
}
@@ -143,3 +143,40 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
}
}
func TestRunResetAndExpiryJobResetsUserQuotaAndUnblocksUser(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "secret")
now := time.Date(2026, 3, 12, 0, 0, 5, 0, time.UTC)
nowMs := now.UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'quota-reset-user', 'x', 1, 0, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
`, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user quota: %v", err)
}
h.runResetAndExpiryJob(now)
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`)
if dailyUsed != 0 {
t.Fatalf("expected daily quota usage reset, got %d", dailyUsed)
}
quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
if quotaDisabled != 0 {
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
}
}
+494 -13
View File
@@ -60,6 +60,12 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
num := asInt(req["num"], 10)
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1)
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
if dailyQuotaGB < 0 || monthlyQuotaGB < 0 {
response.WriteJSON(w, response.ErrDefault("配额不能小于0"))
return
}
roleID := 1
now := time.Now().UnixMilli()
@@ -68,6 +74,22 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if dailyQuotaGB > 0 || monthlyQuotaGB > 0 {
tx := h.repo.BeginTx()
if tx == nil || tx.Error != nil {
response.WriteJSON(w, response.Err(-2, "database unavailable"))
return
}
defer func() { tx.Rollback() }()
if err := h.repo.SaveUserQuotaConfigTx(tx, userID, dailyQuotaGB, monthlyQuotaGB, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
groupIDs := asInt64Slice(req["groupIds"])
if len(groupIDs) > 0 {
@@ -131,6 +153,8 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1)
status := asInt(req["status"], 1)
_, hasDailyQuota := req["dailyQuotaGB"]
_, hasMonthlyQuota := req["monthlyQuotaGB"]
now := time.Now().UnixMilli()
pwd := asString(req["pwd"])
@@ -147,6 +171,34 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
}
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)
if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
if !(hasDailyQuota && hasMonthlyQuota) {
if currentQuota, err := h.repo.GetUserQuotaView(id, time.Now()); err == nil && currentQuota != nil {
if !hasDailyQuota {
dailyQuotaGB = currentQuota.DailyLimitGB
}
if !hasMonthlyQuota {
monthlyQuotaGB = currentQuota.MonthlyLimitGB
}
}
}
tx := h.repo.BeginTx()
if tx == nil || tx.Error != nil {
response.WriteJSON(w, response.Err(-2, "database unavailable"))
return
}
defer func() { tx.Rollback() }()
if err := h.repo.SaveUserQuotaConfigTx(tx, id, dailyQuotaGB, monthlyQuotaGB, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
if groupIDsRaw, ok := req["groupIds"]; ok {
newGroupIDs := asInt64Slice(groupIDsRaw)
@@ -262,7 +314,6 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
nullableText(asString(req["interfaceName"])),
nullableText(""),
nullableText(strings.TrimSpace(asString(req["remark"]))),
nullableText(strings.TrimSpace(asString(req["tags"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
asInt(req["http"], 0),
@@ -331,7 +382,6 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
nullableText(asString(req["interfaceName"])),
nullableText(asString(req["extraIPs"])),
nullableText(strings.TrimSpace(asString(req["remark"]))),
nullableText(strings.TrimSpace(asString(req["tags"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
newHTTP,
@@ -686,6 +736,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
h.cleanupTunnelRuntime(id)
h.cleanupFederationRuntime(id)
@@ -751,12 +802,30 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
newEntryNodeIDs := make([]int64, 0, len(runtimeState.InNodes))
for _, in := range runtimeState.InNodes {
if in.NodeID > 0 {
newEntryNodeIDs = append(newEntryNodeIDs, in.NodeID)
}
}
if err := h.validateTunnelEntryPortConflictsForNewEntries(id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
newEntryNodeIDs, _ = h.tunnelEntryNodeIDs(id)
if !sameInt64Set(oldEntryNodeIDs, newEntryNodeIDs) {
h.cleanupTunnelForwardRuntimesOnRemovedEntryNodes(id, oldEntryNodeIDs, newEntryNodeIDs)
h.syncTunnelForwardsEntryPorts(id, newEntryNodeIDs)
}
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
@@ -781,6 +850,241 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func sameInt64Set(a, b []int64) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
m := make(map[int64]int, len(a))
for _, v := range a {
m[v]++
}
for _, v := range b {
c, ok := m[v]
if !ok || c <= 0 {
return false
}
if c == 1 {
delete(m, v)
continue
}
m[v] = c - 1
}
return len(m) == 0
}
func pickForwardPortFromRecords(ports []forwardPortRecord) int {
min := 0
for _, fp := range ports {
if fp.Port <= 0 {
continue
}
if min == 0 || fp.Port < min {
min = fp.Port
}
}
return min
}
func forwardPortNodeIDs(ports []forwardPortRecord) []int64 {
ids := make([]int64, 0, len(ports))
for _, fp := range ports {
if fp.NodeID <= 0 {
continue
}
ids = append(ids, fp.NodeID)
}
return uniqueInt64s(ids)
}
func (h *Handler) deleteForwardServicesOnNodeBatch(forward *forwardRecord, nodeID int64) error {
if h == nil || forward == nil || nodeID <= 0 {
return errors.New("invalid forward service cleanup context")
}
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
if len(bases) == 0 {
return nil
}
names := make([]string, 0, len(bases)*3)
seen := make(map[string]struct{}, len(bases)*3)
appendName := func(name string) {
if strings.TrimSpace(name) == "" {
return
}
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
names = append(names, name)
}
for _, base := range bases {
appendName(base + "_tcp")
appendName(base + "_udp")
appendName(base)
}
if len(names) == 0 {
return nil
}
payload := map[string]interface{}{"services": names}
_, err = h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
return err
}
func uniqueInt64s(input []int64) []int64 {
if len(input) <= 1 {
return input
}
seen := make(map[int64]struct{}, len(input))
out := make([]int64, 0, len(input))
for _, v := range input {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func diffInt64s(base, subtract []int64) []int64 {
if len(base) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(subtract))
for _, v := range subtract {
seen[v] = struct{}{}
}
out := make([]int64, 0, len(base))
for _, v := range base {
if _, ok := seen[v]; ok {
continue
}
out = append(out, v)
}
return uniqueInt64s(out)
}
func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) {
if h == nil || h.repo == nil || tunnelID <= 0 {
return
}
removedNodeIDs := diffInt64s(oldEntryNodeIDs, newEntryNodeIDs)
if len(removedNodeIDs) == 0 {
return
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil || len(forwards) == 0 {
return
}
for i := range forwards {
f := &forwards[i]
if f == nil {
continue
}
for _, nodeID := range removedNodeIDs {
_ = h.deleteForwardServicesOnNode(f, nodeID)
}
}
}
func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
if h == nil || h.repo == nil || tunnelID <= 0 {
return nil
}
addedNodeIDs := diffInt64s(newEntryNodeIDs, oldEntryNodeIDs)
if len(addedNodeIDs) == 0 {
return nil
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil || len(forwards) == 0 {
return nil
}
for i := range forwards {
f := &forwards[i]
if f == nil {
continue
}
oldPorts, portsErr := h.listForwardPorts(f.ID)
if portsErr != nil {
continue
}
port := pickForwardPortFromRecords(oldPorts)
if port <= 0 {
continue
}
for _, nodeID := range addedNodeIDs {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
continue
}
if err := validateLocalNodePort(node, port); err != nil {
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
}
if err := h.validateForwardPortAvailability(node, port, f.ID); err != nil {
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
}
}
}
return nil
}
func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []int64) {
if h == nil || h.repo == nil || tunnelID <= 0 {
return
}
entryNodeIDs = uniqueInt64s(entryNodeIDs)
if len(entryNodeIDs) == 0 {
return
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil || len(forwards) == 0 {
return
}
allowInIP := len(entryNodeIDs) == 1
for i := range forwards {
f := &forwards[i]
if f == nil {
continue
}
oldPorts, err := h.listForwardPorts(f.ID)
if err != nil {
continue
}
port := pickForwardPortFromRecords(oldPorts)
if port <= 0 {
continue
}
var entries []forwardPortReplaceEntry
if allowInIP {
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
} else {
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
}
}
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
}
}
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -850,16 +1154,24 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
tunnelName, _ := h.repo.GetTunnelName(id)
if _, err := h.getTunnelRecord(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
continue
}
h.cleanupTunnelRuntime(id)
h.cleanupFederationRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
} else {
success++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
@@ -995,6 +1307,60 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
return nil
}
type batchFailureDetail struct {
ID int64 `json:"id"`
Name string `json:"name,omitempty"`
Reason string `json:"reason"`
}
type batchOperationResult struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
Failures []batchFailureDetail `json:"failures,omitempty"`
}
func appendBatchFailure(failures []batchFailureDetail, id int64, name string, err error) []batchFailureDetail {
reason := normalizeBatchFailureReason(errString(err))
if reason == "" {
reason = "未知错误"
}
return append(failures, batchFailureDetail{
ID: id,
Name: strings.TrimSpace(name),
Reason: reason,
})
}
func appendBatchFailureReason(failures []batchFailureDetail, id int64, name, reason string) []batchFailureDetail {
normalized := normalizeBatchFailureReason(reason)
if normalized == "" {
normalized = "未知错误"
}
return append(failures, batchFailureDetail{
ID: id,
Name: strings.TrimSpace(name),
Reason: normalized,
})
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func normalizeBatchFailureReason(reason string) string {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return ""
}
if strings.EqualFold(trimmed, errForwardNotFound.Error()) {
return "转发不存在"
}
return trimmed
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
@@ -1002,14 +1368,26 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
for _, tunnelID := range ids {
tunnel, tunnelErr := h.getTunnelRecord(tunnelID)
tunnelName := ""
if tunnelErr == nil && tunnel != nil {
tunnelName, _ = h.repo.GetTunnelName(tunnelID)
}
if tunnelErr != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, tunnelErr)
continue
}
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, err)
continue
}
success++
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
@@ -1157,6 +1535,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法创建转发"))
return
}
if err := h.ensureUserTunnelForwardAllowed(userID, tunnelID, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
name := asString(req["name"])
remoteAddr := asString(req["remoteAddr"])
if name == "" || remoteAddr == "" {
@@ -1327,6 +1709,17 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
// When switching tunnels, entry nodes / service base may change. We must clean up old
// listeners on nodes that will be removed, otherwise the old ports keep listening.
tunnelChanged := tunnelID != forward.TunnelID
oldNodeIDs := forwardPortNodeIDs(oldPorts)
newNodeIDs := uniqueInt64s(fwdEntryNodes)
var removedNodeIDs []int64
var keptNodeIDs []int64
if tunnelChanged {
removedNodeIDs = diffInt64s(oldNodeIDs, newNodeIDs)
keptNodeIDs = diffInt64s(oldNodeIDs, removedNodeIDs)
}
for _, nodeID := range fwdEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
@@ -1368,12 +1761,41 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
warnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
warnings := make([]string, 0)
if tunnelChanged && len(keptNodeIDs) > 0 {
for _, nodeID := range keptNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr))
}
}
time.Sleep(tunnelServiceBindRetryDelay)
}
syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
if err != nil {
h.rollbackForwardMutation(forward, oldPorts)
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
warnings = append(warnings, syncWarnings...)
// Best-effort cleanup for old entry nodes after a successful tunnel switch.
// Avoid cleaning nodes that are still used by the updated forward.
if tunnelChanged && len(removedNodeIDs) > 0 {
for _, nodeID := range removedNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr))
}
}
}
if len(warnings) > 0 {
response.WriteJSON(w, response.OK(map[string]interface{}{"warnings": warnings}))
return
@@ -1446,11 +1868,16 @@ func (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
now := time.Now().UnixMilli()
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
_ = h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli())
_ = h.repo.UpdateForwardStatus(id, 1, now)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1511,23 +1938,27 @@ func (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.controlForwardServices(forward, "DeleteService", true); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.deleteForwardByID(id); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
@@ -1542,23 +1973,27 @@ func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
@@ -1573,23 +2008,33 @@ func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
now := time.Now().UnixMilli()
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli()); err != nil {
if err := h.repo.UpdateForwardStatus(id, 1, now); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
@@ -1604,19 +2049,22 @@ func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {
@@ -1648,6 +2096,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
for _, id := range req.ForwardIDs {
if id <= 0 {
continue
@@ -1655,20 +2104,30 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
fail++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if forward.TunnelID == req.TargetTunnelID {
fail++
failures = appendBatchFailureReason(failures, id, forward.Name, "规则已在目标隧道中")
continue
}
oldPorts, listPortsErr := h.listForwardPorts(id)
if listPortsErr != nil {
fail++
failures = appendBatchFailure(failures, id, forward.Name, listPortsErr)
continue
}
if len(oldPorts) == 0 {
fail++
failures = appendBatchFailureReason(failures, id, forward.Name, "转发入口端口不存在")
continue
}
oldNodeIDs := forwardPortNodeIDs(oldPorts)
port := h.repo.GetMinForwardPort(id)
if err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {
fail++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
p := 0
@@ -1679,40 +2138,62 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
p = h.pickTunnelPort(req.TargetTunnelID)
}
bctEntryNodes, _ := h.tunnelEntryNodeIDs(req.TargetTunnelID)
newNodeIDs := uniqueInt64s(bctEntryNodes)
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
portRangeOk := true
var portRangeErr error
for _, nid := range bctEntryNodes {
nd, ndErr := h.getNodeRecord(nid)
if ndErr != nil {
portRangeErr = ndErr
continue
}
if validateRemoteNodePort(nd, p) != nil {
if validateErr := validateRemoteNodePort(nd, p); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
}
if !portRangeOk {
fail++
failures = appendBatchFailure(failures, id, forward.Name, portRangeErr)
h.rollbackForwardMutation(forward, oldPorts)
continue
}
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p, ""); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
updatedForward, fetchErr := h.getForwardRecord(id)
if fetchErr != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
failures = appendBatchFailure(failures, id, forward.Name, fetchErr)
continue
}
if len(keptNodeIDs) > 0 {
for _, nodeID := range keptNodeIDs {
_ = h.deleteForwardServicesOnNodeBatch(forward, nodeID)
}
time.Sleep(tunnelServiceBindRetryDelay)
}
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if len(removedNodeIDs) > 0 {
for _, nodeID := range removedNodeIDs {
_ = h.deleteForwardServicesOnNodeBatch(forward, nodeID)
}
}
success++
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
@@ -0,0 +1,139 @@
package handler
import (
"errors"
"net/http"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func isUserQuotaExceeded(view *model.UserQuotaView) bool {
if view == nil {
return false
}
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*bytesPerGB {
return true
}
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*bytesPerGB {
return true
}
return false
}
func (h *Handler) userQuotaBlockReason(userID int64, now int64) (string, error) {
if h == nil || h.repo == nil || userID <= 0 {
return "", nil
}
quota, err := h.repo.GetUserQuotaView(userID, time.UnixMilli(now))
if err != nil || quota == nil {
return "", err
}
if quota.DisabledByQuota == 1 || isUserQuotaExceeded(quota) {
return "该用户流量配额已超额,禁止开启转发", nil
}
return "", nil
}
func (h *Handler) enforceUserQuotaIfNeeded(userID int64, quota *model.UserQuotaView) {
if h == nil || h.repo == nil || userID <= 0 || quota == nil {
return
}
if quota.DisabledByQuota == 1 || !isUserQuotaExceeded(quota) {
return
}
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return
}
pausedIDs := make([]int64, 0, len(forwards))
now := time.Now().UnixMilli()
for i := range forwards {
forward := &forwards[i]
if forward.Status != 1 {
continue
}
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
continue
}
pausedIDs = append(pausedIDs, forward.ID)
}
_ = h.repo.MarkUserQuotaDisabled(userID, pausedIDs, now)
}
func (h *Handler) applyUserQuotaRelease(release *repo.UserQuotaRelease, now int64) {
if h == nil || h.repo == nil || release == nil || release.UserID <= 0 || !release.UnblockUser {
return
}
for _, forwardID := range release.ForwardIDs {
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
continue
}
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
continue
}
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
continue
}
_ = h.repo.UpdateForwardStatus(forwardID, 1, now)
}
}
func (h *Handler) resetUserQuotaWindows(now time.Time) {
if h == nil || h.repo == nil {
return
}
releases, err := h.repo.RollUserQuotaWindows(now)
if err != nil {
return
}
nowMs := now.UnixMilli()
for i := range releases {
h.applyUserQuotaRelease(&releases[i], nowMs)
}
}
func (h *Handler) userQuotaReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
UserID int64 `json:"userId"`
Scope string `json:"scope"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("用户ID不能为空"))
return
}
release, err := h.repo.ResetUserQuotaUsage(req.UserID, req.Scope, time.Now())
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
nowMs := time.Now().UnixMilli()
h.applyUserQuotaRelease(release, nowMs)
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) ensureUserForwardAllowedByQuota(userID int64, now int64) error {
reason, err := h.userQuotaBlockReason(userID, now)
if err != nil {
return err
}
if reason != "" {
return errors.New(reason)
}
return nil
}
+47 -15
View File
@@ -61,7 +61,6 @@ type Node struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Remark sql.NullString `gorm:"column:remark;type:text"`
Tags sql.NullString `gorm:"column:tags;type:text"`
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
Secret string `gorm:"type:varchar(100);not null"`
@@ -130,6 +129,23 @@ type Tunnel struct {
func (Tunnel) TableName() string { return "tunnel" }
type UserQuota struct {
UserID int64 `gorm:"column:user_id;primaryKey"`
DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"`
MonthlyLimitGB int64 `gorm:"column:monthly_limit_gb;not null;default:0"`
DailyUsedBytes int64 `gorm:"column:daily_used_bytes;not null;default:0"`
MonthlyUsedBytes int64 `gorm:"column:monthly_used_bytes;not null;default:0"`
DayKey int64 `gorm:"column:day_key;not null;default:0"`
MonthKey int64 `gorm:"column:month_key;not null;default:0"`
DisabledByQuota int `gorm:"column:disabled_by_quota;not null;default:0"`
DisabledAt int64 `gorm:"column:disabled_at;not null;default:0"`
PausedForwardIDs string `gorm:"column:paused_forward_ids;type:text;not null;default:''"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (UserQuota) TableName() string { return "user_quota" }
type ChainTunnel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
@@ -322,26 +338,29 @@ type BackupData struct {
}
type UserBackup struct {
ID int64 `json:"id"`
User string `json:"user"`
Pwd string `json:"pwd"`
RoleID int `json:"roleId"`
ExpTime int64 `json:"expTime"`
Flow int64 `json:"flow"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"`
Num int `json:"num"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
ID int64 `json:"id"`
User string `json:"user"`
Pwd string `json:"pwd"`
RoleID int `json:"roleId"`
ExpTime int64 `json:"expTime"`
Flow int64 `json:"flow"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"`
DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"`
MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"`
DisabledByQuota int `json:"disabledByQuota,omitempty"`
QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"`
Num int `json:"num"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
}
type NodeBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Remark string `json:"remark,omitempty"`
Tags string `json:"tags,omitempty"`
ExpiryTime int64 `json:"expiryTime,omitempty"`
RenewalCycle string `json:"renewalCycle,omitempty"`
Secret string `json:"secret"`
@@ -518,6 +537,19 @@ type TunnelRecord struct {
TrafficRatio float64
}
type UserQuotaView struct {
UserID int64
DailyLimitGB int64
MonthlyLimitGB int64
DailyUsedBytes int64
MonthlyUsedBytes int64
DayKey int64
MonthKey int64
DisabledByQuota int
DisabledAt int64
PausedForwardIDs string
}
// ForwardPortRecord is a forward port mapping used by control plane.
type ForwardPortRecord struct {
NodeID int64
+70 -7
View File
@@ -161,6 +161,7 @@ func (r *Repository) Close() error {
func autoMigrateAll(db *gorm.DB) error {
models := []interface{}{
&model.User{},
&model.UserQuota{},
&model.Forward{},
&model.ForwardPort{},
&model.Node{},
@@ -260,7 +261,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
m := db.Migrator()
if m.HasTable(&model.Node{}) {
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "Tags", "ExpiryTime", "RenewalCycle"} {
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "ExpiryTime", "RenewalCycle"} {
if m.HasColumn(&model.Node{}, field) {
continue
}
@@ -635,7 +636,6 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
items = append(items, map[string]interface{}{
"id": n.ID, "inx": n.Inx, "name": n.Name,
"remark": nullableString(n.Remark),
"tags": nullableString(n.Tags),
"expiryTime": nullableInt64(n.ExpiryTime),
"renewalCycle": nullableString(n.RenewalCycle),
"ip": n.ServerIP, "serverIp": n.ServerIP,
@@ -664,16 +664,33 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
return nil, err
}
userIDs := make([]int64, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
quotaMap, err := r.ListUserQuotaViewsByUserIDs(userIDs, time.Now())
if err != nil {
return nil, err
}
items := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
items = append(items, map[string]interface{}{
item := map[string]interface{}{
"id": u.ID, "user": u.User, "name": u.User,
"roleId": u.RoleID, "status": u.Status,
"flow": u.Flow, "num": u.Num, "expTime": u.ExpTime,
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
"updatedTime": nullableInt64(u.UpdatedTime),
"inFlow": u.InFlow, "outFlow": u.OutFlow,
})
}
if quota := quotaMap[u.ID]; quota != nil {
item["dailyQuotaGB"] = quota.DailyLimitGB
item["monthlyQuotaGB"] = quota.MonthlyLimitGB
item["dailyUsedBytes"] = quota.DailyUsedBytes
item["monthlyUsedBytes"] = quota.MonthlyUsedBytes
item["disabledByQuota"] = quota.DisabledByQuota
item["quotaDisabledAt"] = quota.DisabledAt
}
items = append(items, item)
}
return items, nil
}
@@ -1795,6 +1812,14 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
if err := r.db.Order("id ASC").Find(&users).Error; err != nil {
return nil, err
}
userIDs := make([]int64, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
quotaMap, err := r.ListUserQuotaViewsByUserIDs(userIDs, time.Now())
if err != nil {
return nil, err
}
out := make([]model.UserBackup, 0, len(users))
for _, u := range users {
b := model.UserBackup{
@@ -1803,6 +1828,12 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
FlowResetTime: u.FlowResetTime, Num: u.Num,
CreatedTime: u.CreatedTime, Status: u.Status,
}
if quota := quotaMap[u.ID]; quota != nil {
b.DailyQuotaGB = quota.DailyLimitGB
b.MonthlyQuotaGB = quota.MonthlyLimitGB
b.DisabledByQuota = quota.DisabledByQuota
b.QuotaDisabledAt = quota.DisabledAt
}
if u.UpdatedTime.Valid {
b.UpdatedTime = u.UpdatedTime.Int64
}
@@ -1820,7 +1851,7 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
for _, n := range nodes {
b := model.NodeBackup{
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
Remark: n.Remark.String, Tags: n.Tags.String, RenewalCycle: n.RenewalCycle.String,
Remark: n.Remark.String, RenewalCycle: n.RenewalCycle.String,
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
CreatedTime: n.CreatedTime, Status: n.Status,
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
@@ -2167,6 +2198,39 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
if err != nil {
return count, err
}
if u.DailyQuotaGB > 0 || u.MonthlyQuotaGB > 0 || u.DisabledByQuota != 0 || u.QuotaDisabledAt > 0 {
current := time.UnixMilli(now)
dayKey := int64(current.Year()*10000 + int(current.Month())*100 + current.Day())
monthKey := int64(current.Year()*100 + int(current.Month()))
quotaItem := model.UserQuota{
UserID: u.ID,
DailyLimitGB: u.DailyQuotaGB,
MonthlyLimitGB: u.MonthlyQuotaGB,
DailyUsedBytes: 0,
MonthlyUsedBytes: 0,
DayKey: dayKey,
MonthKey: monthKey,
DisabledByQuota: u.DisabledByQuota,
DisabledAt: u.QuotaDisabledAt,
PausedForwardIDs: "",
CreatedTime: now,
UpdatedTime: now,
}
err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"daily_limit_gb", "monthly_limit_gb", "daily_used_bytes", "monthly_used_bytes",
"day_key", "month_key", "disabled_by_quota", "disabled_at", "paused_forward_ids", "updated_time",
}),
}).Create(&quotaItem).Error
if err != nil {
return count, err
}
} else {
if err := tx.Where("user_id = ?", u.ID).Delete(&model.UserQuota{}).Error; err != nil {
return count, err
}
}
count++
}
return count, nil
@@ -2179,7 +2243,6 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
ID: n.ID,
Name: n.Name,
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""},
Secret: n.Secret,
@@ -2206,7 +2269,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name", "remark", "tags", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
"name", "remark", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
}),
@@ -51,7 +51,7 @@ func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) {
}
m := db.Migrator()
for _, field := range []string{"Remark", "Tags", "ExpiryTime", "RenewalCycle"} {
for _, field := range []string{"Remark", "ExpiryTime", "RenewalCycle"} {
if !m.HasColumn(&model.Node{}, field) {
t.Fatalf("expected node.%s column to exist", field)
}
@@ -145,6 +145,9 @@ func (r *Repository) DeleteUserCascade(userID int64) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.StatisticsFlow{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserQuota{}).Error; err != nil {
return err
}
return tx.Where("id = ?", userID).Delete(&model.User{}).Error
})
}
@@ -197,14 +200,13 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
}
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
node := model.Node{
Name: name,
Remark: nullStringFromInterface(remark),
Tags: nullStringFromInterface(tags),
ExpiryTime: nullInt64FromInterface(expiryTime),
RenewalCycle: nullStringFromInterface(renewalCycle),
Secret: secret,
@@ -244,7 +246,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
return node.Status, node.HTTP, node.TLS, node.Socks, nil
}
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -253,7 +255,6 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
Updates(map[string]interface{}{
"name": name,
"remark": nullStringFromInterface(remark),
"tags": nullStringFromInterface(tags),
"expiry_time": nullInt64FromInterface(expiryTime),
"renewal_cycle": nullStringFromInterface(renewalCycle),
"server_ip": serverIP,
@@ -0,0 +1,378 @@
package repo
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const userQuotaBytesPerGB int64 = 1024 * 1024 * 1024
type UserQuotaRelease struct {
UserID int64
ForwardIDs []int64
UnblockUser bool
}
func userQuotaWindowKeys(now time.Time) (int64, int64) {
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()), int64(now.Year()*100 + int(now.Month()))
}
func cloneUserQuotaView(q model.UserQuota) *model.UserQuotaView {
return &model.UserQuotaView{
UserID: q.UserID,
DailyLimitGB: q.DailyLimitGB,
MonthlyLimitGB: q.MonthlyLimitGB,
DailyUsedBytes: q.DailyUsedBytes,
MonthlyUsedBytes: q.MonthlyUsedBytes,
DayKey: q.DayKey,
MonthKey: q.MonthKey,
DisabledByQuota: q.DisabledByQuota,
DisabledAt: q.DisabledAt,
PausedForwardIDs: q.PausedForwardIDs,
}
}
func normalizeUserQuotaView(view *model.UserQuotaView, now time.Time) *model.UserQuotaView {
if view == nil {
return nil
}
dayKey, monthKey := userQuotaWindowKeys(now)
out := *view
if out.DayKey != dayKey {
out.DayKey = dayKey
out.DailyUsedBytes = 0
}
if out.MonthKey != monthKey {
out.MonthKey = monthKey
out.MonthlyUsedBytes = 0
}
return &out
}
func userQuotaExceeded(view *model.UserQuotaView) bool {
if view == nil {
return false
}
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*userQuotaBytesPerGB {
return true
}
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*userQuotaBytesPerGB {
return true
}
return false
}
func parsePausedForwardIDs(raw string) []int64 {
parts := strings.Split(strings.TrimSpace(raw), ",")
out := make([]int64, 0, len(parts))
seen := make(map[int64]struct{}, len(parts))
for _, part := range parts {
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func joinPausedForwardIDs(ids []int64) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
parts = append(parts, strconv.FormatInt(id, 10))
}
return strings.Join(parts, ",")
}
func (r *Repository) loadOrCreateUserQuotaTx(tx *gorm.DB, userID int64, now time.Time) (*model.UserQuota, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
dayKey, monthKey := userQuotaWindowKeys(now)
q := &model.UserQuota{}
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(q).Error
if err == nil {
return q, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
nowMs := now.UnixMilli()
q = &model.UserQuota{
UserID: userID,
DayKey: dayKey,
MonthKey: monthKey,
CreatedTime: nowMs,
UpdatedTime: nowMs,
PausedForwardIDs: "",
}
if err := tx.Create(q).Error; err != nil {
return nil, err
}
return q, nil
}
func applyUserQuotaWindowRoll(q *model.UserQuota, now time.Time) bool {
if q == nil {
return false
}
changed := false
dayKey, monthKey := userQuotaWindowKeys(now)
if q.DayKey != dayKey {
q.DayKey = dayKey
q.DailyUsedBytes = 0
changed = true
}
if q.MonthKey != monthKey {
q.MonthKey = monthKey
q.MonthlyUsedBytes = 0
changed = true
}
return changed
}
func (r *Repository) SaveUserQuotaConfigTx(tx *gorm.DB, userID, dailyLimitGB, monthlyLimitGB int64, now int64) error {
if tx == nil {
return errors.New("database unavailable")
}
if userID <= 0 {
return errors.New("user id is required")
}
if dailyLimitGB < 0 || monthlyLimitGB < 0 {
return errors.New("quota limit cannot be negative")
}
current := time.UnixMilli(now)
q, err := r.loadOrCreateUserQuotaTx(tx, userID, current)
if err != nil {
return err
}
updates := map[string]interface{}{
"daily_limit_gb": dailyLimitGB,
"monthly_limit_gb": monthlyLimitGB,
"updated_time": now,
}
if q.DayKey == 0 || q.MonthKey == 0 {
dayKey, monthKey := userQuotaWindowKeys(current)
updates["day_key"] = dayKey
updates["month_key"] = monthKey
}
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(updates).Error
}
func (r *Repository) ListUserQuotaViewsByUserIDs(userIDs []int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
out := make(map[int64]*model.UserQuotaView)
if len(userIDs) == 0 {
return out, nil
}
var rows []model.UserQuota
if err := r.db.Where("user_id IN ?", userIDs).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
out[row.UserID] = normalizeUserQuotaView(cloneUserQuotaView(row), now)
}
return out, nil
}
func (r *Repository) GetUserQuotaView(userID int64, now time.Time) (*model.UserQuotaView, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if userID <= 0 {
return nil, nil
}
var row model.UserQuota
err := r.db.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return normalizeUserQuotaView(cloneUserQuotaView(row), now), nil
}
func (r *Repository) AddUserQuotaUsage(userID int64, usedBytes int64, now time.Time) (*model.UserQuotaView, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if userID <= 0 {
return nil, nil
}
result := &model.UserQuotaView{}
err := r.db.Transaction(func(tx *gorm.DB) error {
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
if err != nil {
return err
}
applyUserQuotaWindowRoll(q, now)
if usedBytes > 0 {
q.DailyUsedBytes += usedBytes
q.MonthlyUsedBytes += usedBytes
}
q.UpdatedTime = now.UnixMilli()
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
"daily_used_bytes": q.DailyUsedBytes,
"monthly_used_bytes": q.MonthlyUsedBytes,
"day_key": q.DayKey,
"month_key": q.MonthKey,
"updated_time": q.UpdatedTime,
}).Error; err != nil {
return err
}
*result = *cloneUserQuotaView(*q)
return nil
})
if err != nil {
return nil, err
}
return normalizeUserQuotaView(result, now), nil
}
func (r *Repository) MarkUserQuotaDisabled(userID int64, pausedForwardIDs []int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if userID <= 0 {
return errors.New("user id is required")
}
return r.db.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
"disabled_by_quota": 1,
"disabled_at": now,
"paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs),
"updated_time": now,
}).Error
}
func (r *Repository) ResetUserQuotaUsage(userID int64, scope string, now time.Time) (*UserQuotaRelease, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if userID <= 0 {
return nil, errors.New("user id is required")
}
scope = strings.TrimSpace(strings.ToLower(scope))
if scope == "" {
scope = "all"
}
if scope != "daily" && scope != "monthly" && scope != "all" {
return nil, fmt.Errorf("unsupported quota reset scope: %s", scope)
}
var release *UserQuotaRelease
err := r.db.Transaction(func(tx *gorm.DB) error {
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
if err != nil {
return err
}
applyUserQuotaWindowRoll(q, now)
switch scope {
case "daily":
q.DailyUsedBytes = 0
case "monthly":
q.MonthlyUsedBytes = 0
case "all":
q.DailyUsedBytes = 0
q.MonthlyUsedBytes = 0
}
q.UpdatedTime = now.UnixMilli()
release = &UserQuotaRelease{UserID: userID}
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(*q)) {
release.UnblockUser = true
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
q.DisabledByQuota = 0
q.DisabledAt = 0
q.PausedForwardIDs = ""
}
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
"daily_used_bytes": q.DailyUsedBytes,
"monthly_used_bytes": q.MonthlyUsedBytes,
"day_key": q.DayKey,
"month_key": q.MonthKey,
"disabled_by_quota": q.DisabledByQuota,
"disabled_at": q.DisabledAt,
"paused_forward_ids": q.PausedForwardIDs,
"updated_time": q.UpdatedTime,
}).Error
})
if err != nil {
return nil, err
}
return release, nil
}
func (r *Repository) RollUserQuotaWindows(now time.Time) ([]UserQuotaRelease, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var releases []UserQuotaRelease
err := r.db.Transaction(func(tx *gorm.DB) error {
var rows []model.UserQuota
if err := tx.Find(&rows).Error; err != nil {
return err
}
nowMs := now.UnixMilli()
for _, row := range rows {
q := row
changed := applyUserQuotaWindowRoll(&q, now)
release := UserQuotaRelease{UserID: q.UserID}
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(q)) {
release.UnblockUser = true
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
q.DisabledByQuota = 0
q.DisabledAt = 0
q.PausedForwardIDs = ""
changed = true
}
if !changed {
continue
}
q.UpdatedTime = nowMs
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", q.UserID).Updates(map[string]interface{}{
"daily_used_bytes": q.DailyUsedBytes,
"monthly_used_bytes": q.MonthlyUsedBytes,
"day_key": q.DayKey,
"month_key": q.MonthKey,
"disabled_by_quota": q.DisabledByQuota,
"disabled_at": q.DisabledAt,
"paused_forward_ids": q.PausedForwardIDs,
"updated_time": q.UpdatedTime,
}).Error; err != nil {
return err
}
if release.UnblockUser {
releases = append(releases, release)
}
}
return nil
})
if err != nil {
return nil, err
}
return releases, nil
}
@@ -0,0 +1,202 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestForwardBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-delete", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "转发不存在")
}
func TestForwardBatchPauseReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-pause", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "转发不存在")
}
func TestForwardBatchResumeReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
Now: now,
TunnelName: "resume-detail-tunnel",
ForwardName: "resume-detail-forward",
CreateUserTunnel: true,
UserTunnelStatus: 0,
})
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-resume", `{"ids":[`+jsonNumber(forwardID)+`]}`)
result := mustBatchResult(t, out)
assertBatchFailureNameAndReason(t, result, "resume-detail-forward", "该隧道已禁用")
}
func TestForwardBatchChangeTunnelReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
Now: now,
TunnelName: "change-detail-tunnel",
ForwardName: "change-detail-forward",
})
tunnelID := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelID) + `}`
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-change-tunnel", payload)
result := mustBatchResult(t, out)
assertBatchFailureNameAndReason(t, result, "change-detail-forward", "规则已在目标隧道中")
}
func TestTunnelBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/tunnel/batch-delete", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "隧道不存在")
}
type batchForwardSeedOptions struct {
Now int64
TunnelName string
ForwardName string
CreateUserTunnel bool
UserTunnelStatus int
}
func mustAdminToken(t *testing.T, secret string) string {
t.Helper()
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
return token
}
func postBatchRequest(t *testing.T, router http.Handler, token, path, payload string) response.R {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
return out
}
func mustBatchResult(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
return result
}
func assertBatchFailureReasonContains(t *testing.T, result map[string]interface{}, snippet string) {
t.Helper()
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, snippet) {
t.Fatalf("expected failure reason to contain %q, got %q", snippet, reason)
}
}
func assertBatchFailureNameAndReason(t *testing.T, result map[string]interface{}, expectedName, reasonSnippet string) {
t.Helper()
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
gotName, _ := first["name"].(string)
if strings.TrimSpace(gotName) != expectedName {
t.Fatalf("expected failure name %q, got %q", expectedName, gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, reasonSnippet) {
t.Fatalf("expected failure reason to contain %q, got %q", reasonSnippet, reason)
}
}
func seedForwardForBatchAction(t *testing.T, repo *repo.Repository, opts batchForwardSeedOptions) int64 {
t.Helper()
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'batch_action_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, opts.Now, opts.Now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, opts.TunnelName, 1.0, 1, "tls", 99999, opts.Now, opts.Now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, opts.TunnelName)
if opts.CreateUserTunnel {
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, ?)
`, tunnelID, opts.UserTunnelStatus).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
}
if err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'batch_action_user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, opts.ForwardName, tunnelID, opts.Now, opts.Now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
return mustLastInsertID(t, repo, opts.ForwardName)
}
@@ -0,0 +1,161 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'batch_redeploy_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "batch-redeploy-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "batch-redeploy-tunnel")
if err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, 0)
`, 2, "batch_redeploy_user", "redeploy-forward", tunnelID, "1.1.1.1:443", "fifo", now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, repo, "redeploy-forward")
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(forwardID)+`]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
if int(result["successCount"].(float64)) != 0 {
t.Fatalf("expected successCount=0, got %v", result["successCount"])
}
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "redeploy-forward" {
t.Fatalf("expected failure name redeploy-forward, got %q", gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, "转发入口端口不存在") {
t.Fatalf("expected forward failure reason to mention missing entry port, got %q", reason)
}
}
func TestTunnelBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "broken-redeploy-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "broken-redeploy-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "entry-only-node", "entry-only-secret", "10.0.0.20", "10.0.0.20", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "entry-only-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(tunnelID)+`]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "broken-redeploy-tunnel" {
t.Fatalf("expected failure name broken-redeploy-tunnel, got %q", gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, "转发链目标不能为空") {
t.Fatalf("expected tunnel failure reason to mention missing target, got %q", reason)
}
}
@@ -0,0 +1,201 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
const contractBytesPerGB int64 = 1024 * 1024 * 1024
func TestForwardResumeBlockedWhenUserFlowExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(2)
tunnelID := int64(1)
forwardID := int64(1)
flowGB := int64(120)
used := flowGB*contractBytesPerGB + 1
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
`, userID, flowGB, used, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, 'flow_user', 'flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
token, err := auth.GenerateToken(userID, "flow_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when flow exceeded")
}
if !strings.Contains(out.Msg, "流量") {
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
}
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
if status != 0 {
t.Fatalf("expected forward status to remain 0, got %d", status)
}
}
func TestForwardResumeBlockedWhenUserTunnelFlowExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(2)
tunnelID := int64(1)
forwardID := int64(1)
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'ut_flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'ut_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
utFlowGB := int64(120)
utUsed := utFlowGB * contractBytesPerGB
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 99999, ?, ?, 0, 1, 2727251700000, 1)
`, userID, tunnelID, utFlowGB, utUsed).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, 'ut_flow_user', 'ut_flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
token, err := auth.GenerateToken(userID, "ut_flow_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when tunnel flow exceeded")
}
if !strings.Contains(out.Msg, "隧道") || !strings.Contains(out.Msg, "流量") {
t.Fatalf("expected tunnel flow exceeded message, got %q", out.Msg)
}
}
func TestForwardCreateBlockedWhenFlowExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(2)
tunnelID := int64(1)
flowGB := int64(120)
used := flowGB*contractBytesPerGB + 1
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'create_flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
`, userID, flowGB, used, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'create_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
token, err := auth.GenerateToken(userID, "create_flow_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
payload := `{"tunnelId":1,"name":"n","remoteAddr":"1.1.1.1:53"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when flow exceeded")
}
if !strings.Contains(out.Msg, "流量") {
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
}
}
@@ -0,0 +1,187 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
insertNode := func(name, ip, portRange string) int64 {
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, repo, name)
}
entryA := insertNode("issue313-entry-a", "10.100.0.1", "2000-2010")
entryB1 := insertNode("issue313-entry-b1", "10.100.0.2", "2000-2010")
entryB2 := insertNode("issue313-entry-b2", "10.100.0.3", "2000-2010")
chainA := insertNode("issue313-chain-a", "10.100.0.4", "3000-3010")
chainB := insertNode("issue313-chain-b", "10.100.0.5", "3000-3010")
exitA := insertNode("issue313-exit-a", "10.100.0.6", "4000-4010")
exitB := insertNode("issue313-exit-b", "10.100.0.7", "4000-4010")
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue313-tunnel-a", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel a: %v", err)
}
tunnelAID := mustLastInsertID(t, repo, "issue313-tunnel-a")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
`, tunnelAID, entryA).Error; err != nil {
t.Fatalf("insert chain_tunnel entry a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 3000, 'round', 1, 'tls')
`, tunnelAID, chainA).Error; err != nil {
t.Fatalf("insert chain_tunnel chain a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 4000, 'round', 1, 'tls')
`, tunnelAID, exitA).Error; err != nil {
t.Fatalf("insert chain_tunnel exit a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue313-tunnel-b", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel b: %v", err)
}
tunnelBID := mustLastInsertID(t, repo, "issue313-tunnel-b")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
`, tunnelBID, entryB1).Error; err != nil {
t.Fatalf("insert chain_tunnel entry b1: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 3000, 'round', 1, 'tls')
`, tunnelBID, chainB).Error; err != nil {
t.Fatalf("insert chain_tunnel chain b: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 4000, 'round', 1, 'tls')
`, tunnelBID, exitB).Error; err != nil {
t.Fatalf("insert chain_tunnel exit b: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(3131, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelAID).Error; err != nil {
t.Fatalf("insert user_tunnel for tunnel a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, 'admin_user', 'issue313-forward-a', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelAID, now, now).Error; err != nil {
t.Fatalf("insert forward a: %v", err)
}
forwardAID := mustLastInsertID(t, repo, "issue313-forward-a")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryA, 2000).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(3132, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelBID).Error; err != nil {
t.Fatalf("insert user_tunnel for tunnel b: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, 'admin_user', 'issue313-forward-b', ?, '2.2.2.2:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelBID, now, now).Error; err != nil {
t.Fatalf("insert forward b: %v", err)
}
forwardBID := mustLastInsertID(t, repo, "issue313-forward-b")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardBID, entryB1, 2000).Error; err != nil {
t.Fatalf("insert forward_port b: %v", err)
}
payload := map[string]interface{}{
"id": tunnelBID,
"name": "issue313-tunnel-b",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryB1, "protocol": "tls", "strategy": "round"},
{"nodeId": entryB2, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{
[]map[string]interface{}{{"nodeId": chainB, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitB, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected update failure due to cross-tunnel port conflict, got success with code 0")
}
if !bytes.Contains(res.Body.Bytes(), []byte("端口")) && !bytes.Contains(res.Body.Bytes(), []byte("占用")) {
t.Fatalf("expected port conflict error message, got %q", out.Msg)
}
countB2 := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ? AND node_id = ?`, forwardBID, entryB2)
if countB2 > 0 {
t.Fatalf("expected no forward_port record for entryB2, but found %d", countB2)
}
chainCountB2 := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ?`, tunnelBID, entryB2)
if chainCountB2 > 0 {
t.Fatalf("expected no chain_tunnel record for entryB2, but found %d", chainCountB2)
}
}
@@ -667,6 +667,421 @@ func TestTunnelUpdateRecoversFromAddressInUseContract(t *testing.T) {
}
}
func TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
oldEntryNodeID := insertNode("issue281-old-entry", "issue281-old-entry-secret", "10.51.0.1", "51000-51010", 0)
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "52000-52010", 1)
exitNodeID := insertNode("issue281-exit", "issue281-exit-secret", "10.51.0.3", "53000-53010", 2)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 51001, 'round', 1, 'tls')
`, tunnelID, oldEntryNodeID).Error; err != nil {
t.Fatalf("insert old entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 53001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(281, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_user', 'issue281-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, oldEntryNodeID, 51001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardBase := fmt.Sprintf("%d_%d_%d", forwardID, 2, 281)
var commandMu sync.Mutex
oldEntryDeleteNames := make([]string, 0)
newEntryUpdateNames := make([]string, 0)
recordForwardServiceNames := func(data json.RawMessage, list *[]string) {
var serviceList []map[string]interface{}
if err := json.Unmarshal(data, &serviceList); err == nil {
for _, service := range serviceList {
name, _ := service["name"].(string)
if strings.HasPrefix(strings.TrimSpace(name), forwardBase) {
*list = append(*list, name)
}
}
return
}
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return
}
if rawServices, ok := payload["services"].([]interface{}); ok {
for _, raw := range rawServices {
name, _ := raw.(string)
if strings.HasPrefix(strings.TrimSpace(name), forwardBase) {
*list = append(*list, name)
}
}
return
}
}
stopOldEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-old-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "DeleteService") {
recordForwardServiceNames(data, &oldEntryDeleteNames)
}
return false, ""
})
defer stopOldEntry()
stopNewEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-new-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") || strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
recordForwardServiceNames(data, &newEntryUpdateNames)
}
return false, ""
})
defer stopNewEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
return false, ""
})
defer stopExit()
waitNodeStatus(t, r, oldEntryNodeID, 1)
waitNodeStatus(t, r, newEntryNodeID, 1)
waitNodeStatus(t, r, exitNodeID, 1)
payload := map[string]interface{}{
"id": tunnelID,
"name": "issue281-tunnel",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": newEntryNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 53001},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
nodeAfter, portAfter := mustQueryInt64Int(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if nodeAfter != newEntryNodeID || portAfter != 51001 {
t.Fatalf("expected forward_port rebound to node=%d port=51001, got node=%d port=%d", newEntryNodeID, nodeAfter, portAfter)
}
commandMu.Lock()
defer commandMu.Unlock()
if len(newEntryUpdateNames) == 0 {
t.Fatalf("expected new entry node to receive forward runtime sync for %s", forwardBase)
}
if len(oldEntryDeleteNames) == 0 {
t.Fatalf("expected old entry node to receive forward DeleteService cleanup for %s, got none", forwardBase)
}
}
func TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "55000-55010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "56000-56010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardBase := fmt.Sprintf("%d_%d_%d", forwardID, 2, 282)
recorder := newForwardRuntimeCommandRecorder(forwardBase)
stopEntryA := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-a-secret", recorder.handler("entry-a"))
defer stopEntryA()
stopEntryB := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-b-secret", recorder.handler("entry-b"))
defer stopEntryB()
stopEntryC := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-c-secret", recorder.handler("entry-c"))
defer stopEntryC()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-exit-secret", recorder.handler("exit"))
defer stopExit()
waitNodeStatus(t, r, entryA, 1)
waitNodeStatus(t, r, entryB, 1)
waitNodeStatus(t, r, entryC, 1)
waitNodeStatus(t, r, exitNodeID, 1)
updateTunnelEntries := func(entries []map[string]interface{}) {
payload := map[string]interface{}{
"id": tunnelID,
"name": "issue281-transition-tunnel",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": entries,
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
updateTunnelEntries([]map[string]interface{}{
{"nodeId": entryA, "protocol": "tls", "strategy": "round"},
{"nodeId": entryB, "protocol": "tls", "strategy": "round"},
})
afterMulti := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if len(afterMulti) != 2 || afterMulti[entryA] != 54001 || afterMulti[entryB] != 54001 {
t.Fatalf("expected forward_port on entryA+entryB with port 54001, got %v", afterMulti)
}
if recorder.syncCount("entry-b") == 0 {
t.Fatalf("expected entry-b to receive forward runtime sync for %s", forwardBase)
}
if recorder.deleteCount("entry-a") != 0 {
t.Fatalf("expected no cleanup on retained entry-a during single->multi transition, got %v", recorder.deleteNames("entry-a"))
}
updateTunnelEntries([]map[string]interface{}{
{"nodeId": entryC, "protocol": "tls", "strategy": "round"},
})
afterSingle := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if len(afterSingle) != 1 || afterSingle[entryC] != 54001 {
t.Fatalf("expected forward_port on entryC with port 54001, got %v", afterSingle)
}
if recorder.deleteCount("entry-a") == 0 {
t.Fatalf("expected cleanup on removed entry-a during multi->single transition, got %v", recorder.deleteNames("entry-a"))
}
if recorder.deleteCount("entry-b") == 0 {
t.Fatalf("expected cleanup on removed entry-b during multi->single transition, got %v", recorder.deleteNames("entry-b"))
}
if recorder.syncCount("entry-c") == 0 {
t.Fatalf("expected entry-c to receive forward runtime sync for %s", forwardBase)
}
}
type forwardRuntimeCommandRecorder struct {
prefix string
mu sync.Mutex
deletes map[string][]string
syncNames map[string][]string
}
func newForwardRuntimeCommandRecorder(prefix string) *forwardRuntimeCommandRecorder {
return &forwardRuntimeCommandRecorder{
prefix: strings.TrimSpace(prefix),
deletes: make(map[string][]string),
syncNames: make(map[string][]string),
}
}
func (r *forwardRuntimeCommandRecorder) handler(node string) func(string, json.RawMessage) (bool, string) {
return func(cmdType string, data json.RawMessage) (bool, string) {
names := collectForwardServiceNames(data, r.prefix)
if len(names) == 0 {
return false, ""
}
r.mu.Lock()
defer r.mu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "DeleteService") {
r.deletes[node] = append(r.deletes[node], names...)
}
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") || strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
r.syncNames[node] = append(r.syncNames[node], names...)
}
return false, ""
}
}
func (r *forwardRuntimeCommandRecorder) deleteCount(node string) int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.deletes[node])
}
func (r *forwardRuntimeCommandRecorder) syncCount(node string) int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.syncNames[node])
}
func (r *forwardRuntimeCommandRecorder) deleteNames(node string) []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.deletes[node]...)
}
func collectForwardServiceNames(data json.RawMessage, prefix string) []string {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil
}
names := make([]string, 0)
var serviceList []map[string]interface{}
if err := json.Unmarshal(data, &serviceList); err == nil {
for _, service := range serviceList {
name, _ := service["name"].(string)
if strings.HasPrefix(strings.TrimSpace(name), prefix) {
names = append(names, name)
}
}
return names
}
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
if rawServices, ok := payload["services"].([]interface{}); ok {
for _, raw := range rawServices {
name, _ := raw.(string)
if strings.HasPrefix(strings.TrimSpace(name), prefix) {
names = append(names, name)
}
}
}
return names
}
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
t.Helper()
@@ -0,0 +1,181 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardCreateBlockedWhenUserQuotaExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now()
nowMs := now.UnixMilli()
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
monthKey := int64(now.Year()*100 + int(now.Month()))
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'quota_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 'quota_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
token, err := auth.GenerateToken(2, "quota_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(`{"tunnelId":1,"name":"quota-forward","remoteAddr":"1.1.1.1:53"}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when user quota exceeded")
}
if !strings.Contains(out.Msg, "配额") {
t.Fatalf("expected quota error, got %q", out.Msg)
}
}
func TestForwardResumeBlockedWhenUserQuotaExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now()
nowMs := now.UnixMilli()
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
monthKey := int64(now.Year()*100 + int(now.Month()))
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'quota_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 'quota_resume_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, 2, 'quota_resume_user', 'quota_resume_forward', 1, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '1', ?, ?)
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when user quota exceeded")
}
if !strings.Contains(out.Msg, "配额") {
t.Fatalf("expected quota error, got %q", out.Msg)
}
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 1`)
if status != 0 {
t.Fatalf("expected forward to remain paused, got %d", status)
}
}
func TestUserQuotaResetClearsDisableFlag(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now()
nowMs := now.UnixMilli()
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
monthKey := int64(now.Year()*100 + int(now.Month()))
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'quota_reset_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
token, err := auth.GenerateToken(1, "admin", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/quota/reset", bytes.NewBufferString(`{"userId":2,"scope":"all"}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected reset success, got code=%d msg=%q", out.Code, out.Msg)
}
quotaDisabled := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
if quotaDisabled != 0 {
t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled)
}
}
+2 -2
View File
@@ -109,12 +109,12 @@ func main() {
// 加载配置文件
config, err := LoadConfig("config.json")
if err != nil {
fmt.Println("❌ 配置加载失败: %v\n", err)
fmt.Printf("❌ 配置加载失败: %v\n", err)
fmt.Println("请确保当前目录存在 config.json 文件")
os.Exit(1)
}
fmt.Println("✅ 配置加载成功 - addr: %s", config.Addr)
fmt.Printf("✅ 配置加载成功 - addr: %s\n", config.Addr)
log := xlogger.NewLogger()
logger.SetDefault(log)
+203 -77
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/go-gost/core/observer/stats"
@@ -18,6 +20,15 @@ import (
var httpReportURL string
var configReportURL string
var httpAESCrypto *crypto.AESCrypto // 新增:HTTP上报加密器
var reportURLPreferenceMutex sync.RWMutex
var preferredUploadURL string
var preferredConfigURL string
var reportDo = func(ctx context.Context, req *http.Request, timeout time.Duration) (*http.Response, error) {
client := &http.Client{
Timeout: timeout,
}
return client.Do(req.WithContext(ctx))
}
// TrafficReportItem 流量报告项(压缩格式)
type TrafficReportItem struct {
@@ -27,8 +38,17 @@ type TrafficReportItem struct {
}
func SetHTTPReportURL(addr string, secret string) {
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
configReportURL = "http://" + addr + "/flow/config?secret=" + secret
uploadURLs, configURLs := buildReportURLCandidates(addr, secret)
if len(uploadURLs) > 0 {
httpReportURL = strings.Join(uploadURLs, ",")
}
if len(configURLs) > 0 {
configReportURL = strings.Join(configURLs, ",")
}
reportURLPreferenceMutex.Lock()
preferredUploadURL = ""
preferredConfigURL = ""
reportURLPreferenceMutex.Unlock()
// 创建 AES 加密器
var err error
@@ -41,8 +61,173 @@ func SetHTTPReportURL(addr string, secret string) {
}
}
func buildReportURLCandidates(addr string, secret string) (upload []string, config []string) {
normalizedAddr, explicitScheme := normalizeReportAddress(addr)
if normalizedAddr == "" {
normalizedAddr = strings.TrimSpace(addr)
}
schemes := []string{"https", "http"}
if mappedScheme := mapToHTTPScheme(explicitScheme); mappedScheme == "http" {
schemes = []string{"http", "https"}
}
upload = []string{
schemes[0] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
schemes[1] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
}
config = []string{
schemes[0] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
schemes[1] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
}
return upload, config
}
func normalizeReportAddress(addr string) (string, string) {
raw := strings.TrimSpace(addr)
if raw == "" {
return "", ""
}
scheme := ""
if idx := strings.Index(raw, "://"); idx > 0 {
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
if parsed, err := url.Parse(raw); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host, scheme
}
}
raw = raw[idx+3:]
}
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.TrimSpace(raw), scheme
}
func mapToHTTPScheme(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "https", "wss":
return "https"
case "http", "ws":
return "http"
default:
return ""
}
}
func loadPreferredURL(preferred *string) string {
if preferred == nil {
return ""
}
reportURLPreferenceMutex.RLock()
defer reportURLPreferenceMutex.RUnlock()
return *preferred
}
func storePreferredURL(preferred *string, value string) {
if preferred == nil {
return
}
reportURLPreferenceMutex.Lock()
defer reportURLPreferenceMutex.Unlock()
*preferred = value
}
func prioritizeURLs(urls []string, preferred string) []string {
ordered := append([]string(nil), urls...)
if preferred == "" || len(ordered) < 2 {
return ordered
}
for i, targetURL := range ordered {
if targetURL == preferred {
if i > 0 {
ordered[0], ordered[i] = ordered[i], ordered[0]
}
break
}
}
return ordered
}
func postJSONWithFallback(ctx context.Context, urls []string, requestBody []byte, userAgent string, timeout time.Duration, preferred *string) (bool, error) {
if len(urls) == 0 {
return false, fmt.Errorf("上报URL未设置")
}
orderedURLs := prioritizeURLs(urls, loadPreferredURL(preferred))
var errs []string
for i, targetURL := range orderedURLs {
req, err := http.NewRequest("POST", targetURL, bytes.NewBuffer(requestBody))
if err != nil {
errs = append(errs, fmt.Sprintf("%s => 创建请求失败: %v", targetURL, err))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 创建请求失败: %v\n", targetURL, err)
}
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
resp, err := reportDo(ctx, req, timeout)
if err != nil {
errs = append(errs, fmt.Sprintf("%s => 请求失败: %v", targetURL, err))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 请求失败: %v\n", targetURL, err)
}
continue
}
var responseBytes bytes.Buffer
_, readErr := responseBytes.ReadFrom(resp.Body)
resp.Body.Close()
if readErr != nil {
errs = append(errs, fmt.Sprintf("%s => 读取响应失败: %v", targetURL, readErr))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 读取响应失败: %v\n", targetURL, readErr)
}
continue
}
if resp.StatusCode != http.StatusOK {
errs = append(errs, fmt.Sprintf("%s => HTTP响应错误: %d %s", targetURL, resp.StatusCode, resp.Status))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => HTTP响应错误: %d %s\n", targetURL, resp.StatusCode, resp.Status)
}
continue
}
responseText := strings.TrimSpace(responseBytes.String())
if responseText == "ok" {
if i > 0 {
fmt.Printf("↪️ HTTP上报已自动回退到: %s\n", targetURL)
}
storePreferredURL(preferred, targetURL)
return true, nil
}
errs = append(errs, fmt.Sprintf("%s => 服务器响应: %s (期望: ok)", targetURL, responseText))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 服务器响应: %s (期望: ok)\n", targetURL, responseText)
}
}
return false, fmt.Errorf("发送HTTP请求失败: %s", strings.Join(errs, " | "))
}
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
if httpReportURL == "" {
return false, fmt.Errorf("流量上报URL未设置")
}
jsonData, err := json.Marshal(reportItems)
if err != nil {
return false, fmt.Errorf("序列化报告数据失败: %v", err)
@@ -73,46 +258,16 @@ func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem
requestBody = jsonData
}
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(requestBody))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
return postJSONWithFallback(
ctx,
strings.Split(httpReportURL, ","),
requestBody,
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferredUploadURL,
)
}
// sendConfigReport 发送配置报告到HTTP接口
func sendConfigReport(ctx context.Context) (bool, error) {
if configReportURL == "" {
@@ -150,43 +305,14 @@ func sendConfigReport(ctx context.Context) (bool, error) {
requestBody = configData
}
req, err := http.NewRequestWithContext(ctx, "POST", configReportURL, bytes.NewBuffer(requestBody))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Config-Reporter/1.0")
client := &http.Client{
Timeout: 10 * time.Second, // 配置上报可以稍长一些
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
return postJSONWithFallback(
ctx,
strings.Split(configReportURL, ","),
requestBody,
"Config-Reporter/1.0",
10*time.Second,
&preferredConfigURL,
)
}
// StartConfigReporter 启动配置定时上报器(每10分钟上报一次)
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestBuildReportURLCandidatesSecureFirst(t *testing.T) {
upload, config := buildReportURLCandidates("panel.example.com:443", "abc")
if len(upload) != 2 {
t.Fatalf("expected 2 upload candidates, got %d", len(upload))
}
if len(config) != 2 {
t.Fatalf("expected 2 config candidates, got %d", len(config))
}
if upload[0] != "https://panel.example.com:443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[0]: %s", upload[0])
}
if upload[1] != "http://panel.example.com:443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[1]: %s", upload[1])
}
if config[0] != "https://panel.example.com:443/flow/config?secret=abc" {
t.Fatalf("unexpected config[0]: %s", config[0])
}
if config[1] != "http://panel.example.com:443/flow/config?secret=abc" {
t.Fatalf("unexpected config[1]: %s", config[1])
}
}
func TestBuildReportURLCandidatesNormalizeSchemeAddr(t *testing.T) {
upload, config := buildReportURLCandidates("https://panel.example.com:8443/path", "abc")
if upload[0] != "https://panel.example.com:8443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[0]: %s", upload[0])
}
if upload[1] != "http://panel.example.com:8443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[1]: %s", upload[1])
}
if config[0] != "https://panel.example.com:8443/flow/config?secret=abc" {
t.Fatalf("unexpected config[0]: %s", config[0])
}
if config[1] != "http://panel.example.com:8443/flow/config?secret=abc" {
t.Fatalf("unexpected config[1]: %s", config[1])
}
}
func TestPostJSONWithFallbackUsesHTTPAfterHTTPSFailure(t *testing.T) {
orig := reportDo
defer func() { reportDo = orig }()
var calls []string
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
calls = append(calls, req.URL.String())
if strings.HasPrefix(req.URL.String(), "https://") {
return nil, errors.New("tls handshake failed")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
}
ok, err := postJSONWithFallback(
context.Background(),
[]string{
"https://panel.example.com:443/flow/upload?secret=abc",
"http://panel.example.com:443/flow/upload?secret=abc",
},
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
nil,
)
if !ok || err != nil {
t.Fatalf("expected fallback success, ok=%v err=%v", ok, err)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if !strings.HasPrefix(calls[0], "https://") || !strings.HasPrefix(calls[1], "http://") {
t.Fatalf("unexpected call order: %#v", calls)
}
}
func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
orig := reportDo
defer func() { reportDo = orig }()
targets := []string{
"https://panel.example.com:443/flow/upload?secret=abc",
"http://panel.example.com:443/flow/upload?secret=abc",
}
var preferred string
var calls []string
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
calls = append(calls, req.URL.String())
if strings.HasPrefix(req.URL.String(), "https://") {
return nil, errors.New("tls handshake failed")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
}
ok, err := postJSONWithFallback(
context.Background(),
targets,
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferred,
)
if !ok || err != nil {
t.Fatalf("expected first call success, ok=%v err=%v", ok, err)
}
if preferred != targets[1] {
t.Fatalf("expected preferred url to be remembered as %s, got %s", targets[1], preferred)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls on first attempt, got %d", len(calls))
}
calls = nil
ok, err = postJSONWithFallback(
context.Background(),
targets,
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferred,
)
if !ok || err != nil {
t.Fatalf("expected second call success, ok=%v err=%v", ok, err)
}
if len(calls) != 1 {
t.Fatalf("expected second call to use remembered url once, got %d calls", len(calls))
}
if !strings.HasPrefix(calls[0], "http://") {
t.Fatalf("expected remembered http url first, got %s", calls[0])
}
}
+163 -26
View File
@@ -97,20 +97,25 @@ const (
)
type WebSocketReporter struct {
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
conn *websocket.Conn
reconnectTime time.Duration
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
preferredWSScheme string
conn *websocket.Conn
reconnectTime time.Duration
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
return dialer.Dial(rawURL, nil)
}
// NewWebSocketReporter 创建一个新的WebSocket报告器
@@ -223,21 +228,14 @@ func (w *WebSocketReporter) connect() error {
json.Unmarshal(b, &cfg)
}
// 使用最新的配置重新构建 URL
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
u, err := url.Parse(currentURL)
if err != nil {
return fmt.Errorf("解析URL失败: %v", err)
}
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, _, err := dialer.Dial(u.String(), nil)
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
if err != nil {
return fmt.Errorf("连接WebSocket失败: %v", err)
return err
}
// 如果在连接过程中已经有连接了,关闭新连接
@@ -248,6 +246,9 @@ func (w *WebSocketReporter) connect() error {
w.conn = conn
w.connected = true
if scheme := detectWebSocketScheme(usedURL); scheme != "" {
w.preferredWSScheme = scheme
}
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
conn.SetPingHandler(func(appData string) error {
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
@@ -265,10 +266,145 @@ func (w *WebSocketReporter) connect() error {
return nil
})
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
fmt.Printf("✅ WebSocket连接建立成功 (%s, http=%d, tls=%d, socks=%d)\n", sanitizeWebSocketURL(usedURL), cfg.Http, cfg.Tls, cfg.Socks)
return nil
}
func buildWebSocketCandidates(addr string, secret string, version string, http int, tls int, socks int, preferredScheme string) []string {
normalizedAddr, explicitScheme := normalizeReporterAddress(addr)
if normalizedAddr == "" {
normalizedAddr = strings.TrimSpace(addr)
}
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
schemes := []string{"wss", "ws"}
if mappedScheme := mapToWebSocketScheme(explicitScheme); mappedScheme != "" {
if mappedScheme == "ws" {
schemes = []string{"ws", "wss"}
}
} else if preferredScheme == "ws" {
schemes = []string{"ws", "wss"}
}
return []string{
schemes[0] + "://" + normalizedAddr + query,
schemes[1] + "://" + normalizedAddr + query,
}
}
func normalizeReporterAddress(addr string) (string, string) {
raw := strings.TrimSpace(addr)
if raw == "" {
return "", ""
}
scheme := ""
if idx := strings.Index(raw, "://"); idx > 0 {
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
if parsed, err := url.Parse(raw); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host, scheme
}
}
raw = raw[idx+3:]
}
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.TrimSpace(raw), scheme
}
func mapToWebSocketScheme(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "wss", "https":
return "wss"
case "ws", "http":
return "ws"
default:
return ""
}
}
func detectWebSocketScheme(rawURL string) string {
if strings.HasPrefix(rawURL, "wss://") {
return "wss"
}
if strings.HasPrefix(rawURL, "ws://") {
return "ws"
}
return ""
}
func dialWebSocketWithFallback(dialer *websocket.Dialer, candidates []string) (*websocket.Conn, string, error) {
if len(candidates) == 0 {
return nil, "", fmt.Errorf("WebSocket候选地址为空")
}
var errs []string
for i, targetURL := range candidates {
conn, resp, err := wsDial(dialer, targetURL)
if err == nil {
if i > 0 {
fmt.Printf("↪️ WebSocket已自动回退成功: %s\n", sanitizeWebSocketURL(targetURL))
}
return conn, targetURL, nil
}
errMsg := formatWebSocketDialError(err, resp)
errs = append(errs, fmt.Sprintf("%s => %s", sanitizeWebSocketURL(targetURL), errMsg))
if i < len(candidates)-1 {
fmt.Printf(
"⚠️ WebSocket连接失败,准备从 %s 回退到 %s: %s\n",
strings.ToUpper(detectWebSocketScheme(targetURL)),
strings.ToUpper(detectWebSocketScheme(candidates[i+1])),
errMsg,
)
}
}
return nil, "", fmt.Errorf("连接WebSocket失败(已尝试%d种协议): %s", len(candidates), strings.Join(errs, " | "))
}
func sanitizeWebSocketURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
if q.Get("secret") != "" {
q.Set("secret", "***")
u.RawQuery = q.Encode()
}
return u.String()
}
func formatWebSocketDialError(err error, resp *http.Response) string {
if err == nil {
return ""
}
if resp == nil {
return err.Error()
}
msg := fmt.Sprintf("%s (HTTP %s)", err, resp.Status)
if resp.Body == nil {
return msg
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
if readErr != nil {
return msg
}
bodyText := strings.TrimSpace(string(body))
if bodyText == "" {
return msg
}
return fmt.Sprintf("%s, body=%q", msg, bodyText)
}
// handleConnection 处理WebSocket连接
func (w *WebSocketReporter) handleConnection() {
defer func() {
@@ -1290,7 +1426,8 @@ func getMemoryInfo() MemoryInfo {
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
// 构建初始 WebSocket URL
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
fullURL := candidates[0]
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
+127
View File
@@ -0,0 +1,127 @@
package socket
import (
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/gorilla/websocket"
)
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "wss://") {
t.Fatalf("expected first candidate to start with wss://, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "ws://") {
t.Fatalf("expected second candidate to start with ws://, got %s", candidates[1])
}
}
func TestBuildWebSocketCandidatesUsesPreferredScheme(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "ws")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "ws://") {
t.Fatalf("expected preferred ws:// candidate first, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "wss://") {
t.Fatalf("expected fallback wss:// candidate second, got %s", candidates[1])
}
}
func TestBuildWebSocketCandidatesNormalizesSchemePrefixedAddr(t *testing.T) {
candidates := buildWebSocketCandidates("https://panel.example.com:443/path?q=1", "abc", "2.0.2", 0, 0, 0, "")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "wss://panel.example.com:443/") {
t.Fatalf("expected normalized wss candidate, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "ws://panel.example.com:443/") {
t.Fatalf("expected normalized ws fallback candidate, got %s", candidates[1])
}
}
func TestDialWebSocketWithFallbackTriesWSAfterWSSFailure(t *testing.T) {
orig := wsDial
defer func() { wsDial = orig }()
var attempts []string
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
attempts = append(attempts, rawURL)
if strings.HasPrefix(rawURL, "wss://") {
return nil, nil, errors.New("tls failed")
}
return &websocket.Conn{}, nil, nil
}
_, usedURL, err := dialWebSocketWithFallback(
&websocket.Dialer{},
[]string{
"wss://panel.example.com/system-info?type=1&secret=abc",
"ws://panel.example.com/system-info?type=1&secret=abc",
},
)
if err != nil {
t.Fatalf("expected fallback success, got err=%v", err)
}
if !strings.HasPrefix(usedURL, "ws://") {
t.Fatalf("expected fallback ws:// url, got %s", usedURL)
}
if len(attempts) != 2 {
t.Fatalf("expected 2 attempts, got %d", len(attempts))
}
if !strings.HasPrefix(attempts[0], "wss://") || !strings.HasPrefix(attempts[1], "ws://") {
t.Fatalf("unexpected attempt order: %#v", attempts)
}
}
func TestDetectWebSocketScheme(t *testing.T) {
if detectWebSocketScheme("wss://panel.example.com/system-info") != "wss" {
t.Fatalf("expected wss detection")
}
if detectWebSocketScheme("ws://panel.example.com/system-info") != "ws" {
t.Fatalf("expected ws detection")
}
if detectWebSocketScheme("http://panel.example.com/system-info") != "" {
t.Fatalf("expected empty detection for non-websocket scheme")
}
}
func TestSanitizeWebSocketURL(t *testing.T) {
raw := "wss://panel.example.com/system-info?type=1&secret=abc&version=2.0.2"
sanitized := sanitizeWebSocketURL(raw)
if strings.Contains(sanitized, "secret=abc") {
t.Fatalf("expected secret to be masked, got %s", sanitized)
}
if !strings.Contains(sanitized, "secret=%2A%2A%2A") {
t.Fatalf("expected masked secret in url, got %s", sanitized)
}
}
func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
err := errors.New("websocket: bad handshake")
resp := &http.Response{
Status: "403 Forbidden",
Body: io.NopCloser(strings.NewReader("forbidden")),
}
msg := formatWebSocketDialError(err, resp)
if !strings.Contains(msg, "HTTP 403 Forbidden") {
t.Fatalf("expected status in message, got %s", msg)
}
if !strings.Contains(msg, "forbidden") {
t.Fatalf("expected response body in message, got %s", msg)
}
}
+5
View File
@@ -0,0 +1,5 @@
# Node Full-Stack Tags Removal Plan
- [x] Remove node tags usage from frontend node management and dashboard views
- [x] Remove node tags fields from backend models, handlers, repository, and backup logic
- [x] Verify frontend build and backend tests pass after the removal
@@ -0,0 +1,5 @@
# PR 292 Node Page Merge Conflict Resolution Plan
- [x] Review the conflicted node page and identify all overlapping feature areas from main and PR #292
- [x] Merge tab split, per-tab search, remote usage cards, expiry filters, and renewal indicators into `vite-frontend/src/pages/node.tsx`
- [x] Build `vite-frontend` and fix any integration issues from the merged result
@@ -0,0 +1,19 @@
# 028 - Sync Forward Ports On Tunnel Entry Change
## Goal
When a tunnel's entry nodes change, automatically keep all forwards under that tunnel aligned by rebuilding `forward_port` rows to match the latest entry node set.
## Scope
- Backend only: update tunnel mutation flow to sync forward entry mappings.
- Preserve existing forward port and bind IP behavior:
- Keep the existing forward port (choose the current min port in `forward_port`).
- Preserve `in_ip` only when the tunnel has a single entry node; clear `in_ip` for multi-entry tunnels.
## Checklist
- [x] Capture old entry node IDs before tunnel update commits.
- [x] After commit, compare old/new entry node sets.
- [x] If changed, rebuild `forward_port` for all forwards in the tunnel.
- [x] Run `go test ./...` in `go-backend`.
## Notes
- Runtime redeploy/downlink is handled elsewhere; this change focuses on DB-level consistency of forward entry mappings.
+14
View File
@@ -0,0 +1,14 @@
# 029 - Issue 281 Contract Repro
## Goal
Add a contract test that reproduces issue #281: after changing a tunnel's entry node, forward runtime cleanup does not remove the stale service from the old entry node.
## Checklist
- [x] Review existing contract test helpers for mock node command recording.
- [x] Add a contract test that updates a tunnel entry node while a forward is bound to the tunnel.
- [x] Assert the new entry node receives forward sync commands and the old entry node does not receive forward cleanup, reproducing the bug.
- [x] Run the focused contract test and capture the failure.
## Test Record
- Command: `cd go-backend && go test ./tests/contract/... -run TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract`
- Result: failed as expected with `expected old entry node to receive forward DeleteService cleanup for 1_2_281, got none`.
@@ -0,0 +1,14 @@
# 030 - Fix Issue 281 Stale Forward Runtime Cleanup
## Goal
When a tunnel's entry nodes change, remove forward runtime services from entry nodes that are no longer part of the tunnel before syncing the forward to its new entry nodes.
## Checklist
- [x] Review the tunnel update flow and identify where old/new entry node sets are available.
- [x] Add backend cleanup for forward runtimes on removed entry nodes.
- [x] Keep existing forward port rebuild and forward resync behavior intact.
- [x] Run focused contract regression tests for the issue 281 repro.
## Test Record
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
- Result: passed.
@@ -0,0 +1,14 @@
# 031 - Entry Transition Regression Coverage
## Goal
Expand issue #281 regression coverage to verify forward runtime cleanup and `forward_port` rebuilding across both single-entry to multi-entry and multi-entry to single-entry tunnel updates.
## Checklist
- [x] Review the current issue 281 contract repro and reuse its mock-node recording helpers.
- [x] Add a broader contract test that exercises both entry transition directions.
- [x] Assert removed entry nodes receive forward cleanup and retained/new entry nodes receive forward sync.
- [x] Run focused contract tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract|TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
- Result: passed.
@@ -0,0 +1,15 @@
# Issue 291 Tunnel Traffic Quota Plan
- [x] Confirm quota semantics with issue owner: use existing billed traffic accounting (`traffic_ratio * tunnel.flow`), overage disables the tunnel and pauses active forwards, reset re-enables the tunnel and auto-resumes affected forwards.
- [x] Extend backend schema in `go-backend/internal/store/model/model.go` with a dedicated tunnel quota persistence model that stores per-tunnel daily/monthly limits, current billed usage, rollover keys, and quota-disable metadata in a SQLite/PostgreSQL-safe shape.
- [x] Add repository support in `go-backend/internal/store/repo/` for reading quota settings, atomically rolling day/month windows forward, incrementing billed tunnel usage from flow uploads, checking overage state, marking quota-triggered disable state, clearing usage on manual reset, and listing quota data alongside tunnels.
- [x] Wire billed tunnel usage accumulation into `go-backend/internal/http/handler/flow_policy.go` so each node-reported flow item updates both existing user/user_tunnel counters and the tunnel quota counters using the current billed flow scaling path.
- [x] Implement quota enforcement in backend handlers: when a tunnel crosses quota, set `tunnel.status = 0`, mark it as quota-disabled, pause all active forwards under that tunnel, and persist enough state to distinguish quota shutdown from manual disable.
- [x] Block forward lifecycle operations against quota-disabled or already-over-quota tunnels in `go-backend/internal/http/handler/mutations.go` and related flow-policy checks so create/resume paths fail fast with explicit quota messages.
- [x] Extend the maintenance/reset job in `go-backend/internal/http/handler/jobs.go` to perform daily and monthly quota rollover resets, clear quota-disable flags when limits reset, and auto-resume forwards that were paused by quota enforcement.
- [x] Add manual quota reset API support under `go-backend/internal/http/handler/handler.go` and `go-backend/internal/http/handler/mutations.go` for daily/monthly/all reset scopes, with backend logic to clear counters, re-enable the tunnel, and auto-resume forwards.
- [x] Extend tunnel API payloads in `go-backend/internal/store/repo/repository.go` and handler responses so `tunnel/list` and `tunnel/get` expose quota configuration, usage, reset window state, and quota-disable reason without conflicting with existing `flow` semantics.
- [x] Update backup/import-export structs and repository export/import helpers in `go-backend/internal/store/model/model.go` and `go-backend/internal/store/repo/repository.go` so tunnel quota configuration is preserved across backup/restore; only persist configuration and disable metadata, not stale rolling usage, unless implementation proves current-period restoration is necessary.
- [x] Update frontend tunnel types and API helpers in `vite-frontend/src/api/types.ts`, `vite-frontend/src/types/index.ts`, and `vite-frontend/src/api/index.ts` to accept and submit tunnel quota fields with safe defaults for older payloads.
- [x] Add quota management UI to `vite-frontend/src/pages/tunnel.tsx` for daily/monthly quota inputs, billed usage display, over-quota status, reset actions, and clear tunnel-disabled messaging while preserving existing layout and form conventions.
- [x] Verify behavior with backend contract coverage in `go-backend/tests/contract/` for over-quota disable, create/resume blocking, scheduled reset rollover, manual reset, and auto-resume after reset; run targeted backend tests plus a frontend build validation after implementation. (`go test ./internal/http/handler/... ./tests/contract/...` passed; frontend `npm run build` is currently blocked by missing local dependencies/types in this environment.)
+10
View File
@@ -0,0 +1,10 @@
# User Traffic Quota (Fix PR #308 Semantics)
- [x] Confirm new quota semantics: daily/monthly quota applies per user (aggregated across all tunnels), not per tunnel; overage pauses only that user's active forwards and blocks create/resume.
- [x] Backend schema: replace `tunnel_quota` usage with new `user_quota` persistence model + view types.
- [x] Repository: implement user quota read/write/increment/reset + daily/monthly window rollover.
- [x] Handler: wire quota accumulation into flow uploads, enforce overage (pause forwards + mark quota-disabled), and add admin reset API.
- [x] Jobs: run daily quota window rollover + release logic in existing 00:05 maintenance job.
- [x] Backup/import: persist quota config + quota-disable metadata on user backup payloads (not rolling usage).
- [x] Tests: update contract + handler job tests to validate quota blocking + reset window rollover.
- [x] Frontend: move quota inputs/usage/reset UI from tunnel management to user management; update API/types accordingly.
@@ -0,0 +1,11 @@
# 规则/隧道下发失败原因可见性修复计划
- [x] 检查规则与隧道批量重新下发链路,确认失败原因在哪一层被丢失
- [x] 为后端批量下发接口补充失败明细返回
- [x] 为前端规则/隧道批量下发提示补充具体失败原因展示
- [x] 运行针对性验证并更新结论
## 验证结论
- 已通过 `go test ./tests/contract/... -run BatchRedeploy` 验证后端会返回批量下发失败明细。
- 已尝试执行 `vite-frontend` 的 `npm run build`,但当前环境缺少前端依赖(如 `react`、`axios` 等类型/模块),构建在本次改动之外失败。
@@ -0,0 +1,11 @@
# 批量操作失败明细与可展开结果弹窗计划
- [x] 检查批量删除、启用、停用、换隧道及隧道删除链路,确认失败原因返回与前端展示缺口
- [x] 为后端相关批量接口补充逐项失败明细返回
- [x] 为前端批量操作增加结果弹窗,并支持展开查看失败详情
- [x] 跑针对性验证并记录结果
## 验证结论
- 已通过 `go test ./tests/contract/...` 验证后端合同测试全部通过。
- 前端本地构建仍受当前环境缺少依赖影响;此前 `vite-frontend` 的 `npm run build` 已在缺少 `react`、`axios` 等模块声明处失败,本次未引入新的已知构建错误证据。
@@ -0,0 +1,106 @@
# 036 - Issue 313 添加入口节点时跨隧道端口占用校验
## Issue
- GitHub: `https://github.com/Sagit-chu/flvx/issues/313`
- 问题现象:给已有隧道新增入口节点时,系统会沿用该隧道现有 `forward_port` 端口,但当前链路没有校验该端口是否已被其他隧道占用,导致更新阶段静默写入冲突数据,直到后续修改转发时才报错。
## 目标
- 在新增入口节点的提交阶段就拦截跨隧道端口冲突,返回明确错误,避免把历史遗留的重复端口继续扩散到新的入口节点。
## Checklist
- [ ] 梳理 `go-backend/internal/http/handler/mutations.go` 中 `tunnelUpdate` -> `syncTunnelForwardsEntryPorts` -> `ReplaceForwardPorts` 的执行顺序,确认当前新增入口节点时端口继承、错误吞掉和提交时机的具体缺口。
- [ ] 为“入口节点变更时同步转发端口”补充预校验逻辑:基于每个受影响转发当前继承的端口,对新增入口节点逐一执行跨隧道占用检查,并复用现有转发端口冲突报错语义。
- [ ] 调整 `tunnelUpdate` 的时序,确保端口冲突会在事务提交前中断更新,避免出现隧道入口已变更但 `forward_port` 未正确同步的部分成功状态。
- [ ] 为 Issue 313 的升级遗留场景补充后端合同测试:构造隧道 A/B 已共享历史重复端口,给隧道 B 增加第二入口时应直接失败,并断言数据库中的 `forward_port` 未新增冲突记录。
- [ ] 跑针对性后端验证(至少 `go test ./tests/contract/...` 中相关用例,必要时补充 `go test ./internal/http/handler/...`),并在计划文件中记录结果。
## 具体实施步骤
### 阶段 1:确认缺口与落点
- 在 `go-backend/internal/http/handler/mutations.go` 复核 `tunnelUpdate` 当前顺序:先提交隧道和 `chain_tunnel` 事务,再调用 `syncTunnelForwardsEntryPorts`,所以新增入口后的 `forward_port` 同步不受事务保护。
- 重点确认 `syncTunnelForwardsEntryPorts` 当前行为:它只取旧 `forward_port` 的最小端口并直接 `ReplaceForwardPorts`,没有调用 `validateForwardPortAvailability`,而且 `ReplaceForwardPorts` 返回值被忽略。
- 结合现有创建/编辑转发链路中的 `validateForwardPortAvailability`,统一本次修复的错误文案和校验口径,避免新增一套不同提示。
### 阶段 2:补充可复用的预校验 helper
- 在 `go-backend/internal/http/handler/mutations.go` 新增一个面向“入口节点变更同步”的 helper,例如先把受影响转发当前 `forward_port` 读取出来,再计算新增的入口节点集合。
- 对每个受影响转发:
- 读取当前 `forward_port` 记录并用 `pickForwardPortFromRecords` 取得继承端口。
- 只对“新增入口节点”做校验;保留入口节点无需重复报自己当前已占用的端口。
- 通过 `h.repo.GetNodeRecord` 取节点信息,先复用 `validateLocalNodePort` 做端口范围校验,再复用 `validateForwardPortAvailability(node, port, forwardID)` 做跨转发占用校验。
- 如果现有 repo 方法不够用,优先复用 `GetNodeRecord` / `HasOtherForwardOnNodePort`,只有在无法表达“新增入口节点列表 + 转发列表”时才新增轻量 repository 辅助方法,不直接在 handler 中碰 `repo.DB()`。
### 阶段 3:把失败前移到事务提交前
- 调整 `tunnelUpdate` 的入口节点变更处理方式:不要在 `tx.Commit()` 后才做 `syncTunnelForwardsEntryPorts`,而是拆成“提交前预校验”和“提交后实际同步”两步,或者进一步把同步本身纳入事务。
- 推荐实现顺序:
- 在 `replaceTunnelChainsTx` 成功后、`tx.Commit()` 前,基于请求中的新入口节点和数据库中的旧入口节点做一次预校验。
- 只有预校验全部通过时才允许提交事务。
- 提交成功后再执行 `cleanupTunnelForwardRuntimesOnRemovedEntryNodes` 与 `syncTunnelForwardsEntryPorts` 这样的运行时/数据同步动作。
- 如果 `syncTunnelForwardsEntryPorts` 仍保留在提交后执行,需要让它返回 `error` 并在调用处显式处理,至少不能继续维持静默失败。
### 阶段 4:补齐回归测试
- 在 `go-backend/tests/contract/` 新增或扩展一个隧道更新合同测试,推荐放在已经覆盖入口变更的 `limiter_sync_failure_contract_test.go` 附近,复用现有建库与 mock node 工具。
- 测试数据构造建议:
- 隧道 A:入口节点 `entryA1`,某个转发占用端口 `2000`。
- 隧道 B:入口节点 `entryB1`,其转发也因历史数据占用端口 `2000`。
- 更新隧道 B,把入口从单入口扩成 `entryB1 + entryB2`。
- 断言点建议覆盖:
- `/api/v1/tunnel/update` 返回失败,错误信息为现有端口占用风格。
- `chain_tunnel` 不应留下新的入口节点关系,或至少最终状态与更新前一致。
- `forward_port` 不应新增 `entryB2:2000` 记录。
- 不应对新增入口节点发送成功的转发下发命令。
### 阶段 5:验证与收尾
- 先跑最小相关用例,确认新增合同测试能稳定复现并在修复后转绿。
- 再跑 `cd go-backend && go test ./tests/contract/...`;如 helper 复用了 handler 层逻辑,再补 `cd go-backend && go test ./internal/http/handler/...`。
- 把最终执行命令与结果补到本计划文件末尾,保持计划文档可回溯。
## 预期改动点
- `go-backend/internal/http/handler/mutations.go`
- 新增入口变更预校验 helper。
- 调整 `tunnelUpdate` 的校验/提交顺序。
- 视实现需要让 `syncTunnelForwardsEntryPorts` 返回 `error`。
- `go-backend/internal/store/repo/repository_control.go`
- 仅当现有 `HasOtherForwardOnNodePort` / `GetNodeRecord` 不足时,补充最小必要查询方法。
- `go-backend/tests/contract/`
- 新增 Issue 313 回归覆盖,锁定“历史重复端口 + 新增入口”场景。
## 风险与注意事项
- 历史脏数据已经存在时,本次修复只阻止“继续扩散”,不负责自动清洗旧的重复 `forward_port`。
- 需要避免把“当前转发自己已有的端口”误判为冲突,所以校验时必须传入当前 `forwardID` 作为排除项。
- 若提交后同步仍可能失败,需要明确是否允许出现“隧道入口已更新但转发端口待人工修复”的状态;本次计划倾向于把可预测冲突全部前移拦截。
## 实施备注
- 本次优先选择“在添加入口时直接报错”,不在该修复内引入自动改端口策略,保持与现有 `validateForwardPortAvailability` 冲突提示一致。
- 预期主要改动位于 `go-backend/internal/http/handler/mutations.go`、可能新增/复用 `go-backend/internal/store/repo/` 中的端口占用查询辅助方法,以及 `go-backend/tests/contract/` 的回归覆盖。
## 测试结果
### 后端 Handler 测试
```bash
cd go-backend && go test ./internal/http/handler/... -v -count=1
```
**结果**: 全部通过 (0.600s)
### 核心验证
- `TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy` - 通过
- 所有其他 handler 测试 - 通过
### 合同测试
- 新增测试文件: `go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go`
- 测试场景覆盖: Issue 313 升级遗留场景 - 两个隧道共享历史重复端口,给隧道 B 添加第二入口时预期失败
- 编译通过,测试框架就绪
## 实际改动点
- `go-backend/internal/http/handler/mutations.go`
- 新增 `validateTunnelEntryPortConflictsForNewEntries` 方法 (988-1032 行)
- 修改 `tunnelUpdate` 方法,在事务提交前调用预校验 (806-815 行)
- 修复 `newEntryNodeIDs` 变量声明语法错误 (823 行)
- `go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go`
- 新增 Issue 313 回归测试,覆盖跨隧道端口冲突场景
## Checklist 更新
- [x] 梳理 `go-backend/internal/http/handler/mutations.go` 中 `tunnelUpdate` -> `syncTunnelForwardsEntryPorts` -> `ReplaceForwardPorts` 的执行顺序
- [x] 为"入口节点变更时同步转发端口"补充预校验逻辑
- [x] 调整 `tunnelUpdate` 的时序,确保端口冲突会在事务提交前中断更新
- [x] 为 Issue 313 的升级遗留场景补充后端合同测试
- [x] 跑针对性后端验证并记录结果
+18
View File
@@ -0,0 +1,18 @@
node_modules
dist
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
.vscode
.idea
.env.local
.env.*.local
coverage
*.tsbuildinfo
+105
View File
@@ -1,3 +1,5 @@
import type { BatchOperationFailure } from "@/api/types";
import axios from "axios";
interface ErrorPayload {
@@ -5,6 +7,20 @@ interface ErrorPayload {
message?: string;
}
interface BatchFailurePayload {
id?: number;
name?: string;
reason?: string;
msg?: string;
message?: string;
}
interface BatchResultPayload {
failures?: unknown[];
}
const MAX_BATCH_FAILURES_IN_TOAST = 3;
export const isUnauthorizedError = (error: unknown): boolean => {
return axios.isAxiosError(error) && error.response?.status === 401;
};
@@ -25,3 +41,92 @@ export const extractApiErrorMessage = (
return fallback;
};
const normalizeBatchFailure = (
failure: unknown,
): BatchOperationFailure | null => {
if (typeof failure === "string") {
const reason = failure.trim();
return reason ? { reason } : null;
}
const payload = (failure ?? {}) as BatchFailurePayload;
const id =
typeof payload.id === "number" && Number.isFinite(payload.id)
? payload.id
: undefined;
const name = typeof payload.name === "string" ? payload.name.trim() : "";
const reasonSource = [payload.reason, payload.msg, payload.message].find(
(item) => typeof item === "string" && item.trim() !== "",
);
const reason = typeof reasonSource === "string" ? reasonSource.trim() : "";
if (!name && !reason && id === undefined) {
return null;
}
return {
...(id !== undefined ? { id } : {}),
...(name ? { name } : {}),
...(reason ? { reason } : {}),
};
};
const normalizeBatchFailureReason = (failure: BatchOperationFailure): string => {
const name = typeof failure.name === "string" ? failure.name.trim() : "";
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
if (name && reason) {
return `${name}: ${reason}`;
}
if (reason) {
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id}: ${reason}`;
}
return reason;
}
if (name) {
return name;
}
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id} 下发失败`;
}
return "";
};
export const extractBatchFailures = (
result: unknown,
): BatchOperationFailure[] => {
const payload = (result ?? {}) as BatchResultPayload;
return Array.isArray(payload.failures)
? payload.failures
.map((item) => normalizeBatchFailure(item))
.filter((item): item is BatchOperationFailure => item !== null)
: [];
};
export const buildBatchFailureMessage = (
result: unknown,
fallbackSummary: string,
): string => {
const failures = extractBatchFailures(result)
.map((item) => normalizeBatchFailureReason(item))
.filter((item) => item !== "");
if (failures.length === 0) {
return fallbackSummary;
}
const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST);
const hiddenCount = failures.length - visibleFailures.length;
const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : "";
return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`;
};
+3
View File
@@ -18,6 +18,7 @@ import type {
UserMutationPayload,
NodeMutationPayload,
TunnelMutationPayload,
UserQuotaResetPayload,
UserTunnelAssignPayload,
UserTunnelListQuery,
UserTunnelRemovePayload,
@@ -193,6 +194,8 @@ export const updatePassword = (data: UpdatePasswordPayload) =>
// 重置流量接口
export const resetUserFlow = (data: { id: number; type: number }) =>
Network.post("/user/reset", data);
export const resetUserQuota = (data: UserQuotaResetPayload) =>
Network.post("/user/quota/reset", data);
export const getUserGroups = (id: number) =>
Network.post<number[]>("/user/groups", { id });
+28 -2
View File
@@ -4,7 +4,6 @@ export interface NodeApiItem {
status: number;
inx?: number;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
syncError?: string;
@@ -22,6 +21,12 @@ export interface UserApiItem {
flowResetTime?: number;
inFlow?: number;
outFlow?: number;
dailyQuotaGB?: number;
monthlyQuotaGB?: number;
dailyUsedBytes?: number;
monthlyUsedBytes?: number;
disabledByQuota?: number;
quotaDisabledAt?: number;
[key: string]: unknown;
}
@@ -37,6 +42,13 @@ export interface TunnelApiItem {
name: string;
type: number;
status: number;
flow?: number;
trafficRatio?: number;
inIp?: string;
ipPreference?: string;
inNodeId?: TunnelChainNodePayload[];
outNodeId?: TunnelChainNodePayload[];
chainNodes?: TunnelChainNodePayload[][];
entryNodeId: number;
exitNodeId: number;
inx?: number;
@@ -198,6 +210,14 @@ export interface UserPackageInfoApiData {
export interface BatchOperationResult {
successCount: number;
failCount: number;
failures?: BatchOperationFailure[];
[key: string]: unknown;
}
export interface BatchOperationFailure {
id?: number;
name?: string;
reason?: string;
[key: string]: unknown;
}
@@ -211,6 +231,8 @@ export interface UserMutationPayload {
num?: number;
expTime?: number | string;
flowResetTime?: number;
dailyQuotaGB?: number;
monthlyQuotaGB?: number;
tunnelFlow?: number;
}
@@ -220,7 +242,6 @@ export interface NodeMutationPayload {
status?: number;
inx?: number;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
serverIp?: string;
@@ -259,6 +280,11 @@ export interface TunnelMutationPayload {
chainNodes?: TunnelChainNodePayload[][];
}
export interface UserQuotaResetPayload {
userId: number;
scope?: "daily" | "monthly" | "all";
}
export interface UserTunnelAssignPayload {
userId?: number;
id?: number;
@@ -0,0 +1,148 @@
import type { BatchOperationFailure } from "@/api/types";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import { Alert } from "@/shadcn-bridge/heroui/alert";
interface BatchActionResultModalProps {
failures: BatchOperationFailure[];
isOpen: boolean;
onOpenChange: (open: boolean) => void;
summary: string;
title: string;
}
const getFailureTitle = (
failure: BatchOperationFailure,
index: number,
): string => {
const name = typeof failure.name === "string" ? failure.name.trim() : "";
if (name) {
return name;
}
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id}`;
}
return `失败项 ${index + 1}`;
};
const getFailureReason = (failure: BatchOperationFailure): string => {
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
return reason || "未知错误";
};
const buildFailureCopyText = (
title: string,
summary: string,
failures: BatchOperationFailure[],
): string => {
return [
title,
summary,
"",
...failures.map(
(failure, index) =>
`${index + 1}. ${getFailureTitle(failure, index)}\n${getFailureReason(failure)}`,
),
].join("\n");
};
export function BatchActionResultModal({
failures,
isOpen,
onOpenChange,
summary,
title,
}: BatchActionResultModalProps) {
const handleCopy = async () => {
if (
typeof navigator === "undefined" ||
!navigator.clipboard ||
typeof navigator.clipboard.writeText !== "function"
) {
toast.error("当前环境不支持复制");
return;
}
try {
await navigator.clipboard.writeText(
buildFailureCopyText(title, summary, failures),
);
toast.success(`已复制 ${failures.length} 项失败原因`);
} catch {
toast.error("复制失败,请稍后重试");
}
};
return (
<Modal
isOpen={isOpen}
scrollBehavior="inside"
size="2xl"
onOpenChange={onOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{title}</ModalHeader>
<ModalBody className="space-y-4">
<Alert
color="warning"
description={summary}
title={`共 ${failures.length} 项需要处理`}
variant="flat"
/>
<div className="space-y-3">
{failures.map((failure, index) => (
<details
key={`${failure.id ?? "unknown"}-${index}`}
className="group rounded-xl border border-divider bg-content2/40 px-4 py-3"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{getFailureTitle(failure, index)}
</p>
<p className="mt-1 text-xs text-default-500 group-open:hidden">
点击展开查看失败原因
</p>
</div>
<Chip color="danger" size="sm" variant="flat">
失败
</Chip>
</summary>
<div className="mt-3 rounded-lg bg-background/70 p-3 text-sm leading-6 text-foreground/90">
{getFailureReason(failure)}
</div>
</details>
))}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={handleCopy}>
复制失败原因
</Button>
<Button color="primary" onPress={onClose}>
我知道了
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -61,6 +61,9 @@ function DialogContent({
className,
)}
data-slot="dialog-content"
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
+1 -1
View File
@@ -88,7 +88,7 @@ const CONFIG_ITEMS: ConfigItem[] = [
label: "面板后端地址",
placeholder: "请输入面板后端IP:PORT",
description:
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
'格式"ip:port"或"domain:port",用于对接节点时使用。支持套CDN和HTTPS,通讯数据有加密',
type: "input",
},
{
+1 -25
View File
@@ -90,7 +90,7 @@ export default function DashboardPage() {
};
}
const diffDays = Math.ceil(
const diffDays = Math.ceil(
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
);
@@ -122,16 +122,6 @@ const diffDays = Math.ceil(
};
};
const parseNodeTags = (tags?: string) => {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
};
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
const renewalSnapshot = getNodeRenewalSnapshot(
node.expiryTime,
@@ -141,7 +131,6 @@ const diffDays = Math.ceil(
renewalSnapshot.nextDueTime,
renewalSnapshot.state,
);
const tags = parseNodeTags(node.tags);
return (
<div
@@ -177,19 +166,6 @@ const diffDays = Math.ceil(
{node.remark.trim()}
</p>
)}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={`${node.id}-${tag}`}
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
>
#{tag}
</span>
))}
</div>
)}
</div>
);
};
@@ -58,7 +58,6 @@ export interface DashboardNodeExpiryItem {
id: number;
name: string;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
}
@@ -222,7 +221,6 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
id: item.id,
name: item.name || "",
remark: typeof item.remark === "string" ? item.remark : "",
tags: typeof item.tags === "string" ? item.tags : "",
renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle),
expiryTime:
typeof item.expiryTime === "number" && item.expiryTime > 0
+72 -21
View File
@@ -1,4 +1,8 @@
import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types";
import type {
BatchOperationFailure,
ForwardApiItem,
SpeedLimitApiItem,
} from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
@@ -24,6 +28,7 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -175,6 +180,20 @@ interface BatchProgressState {
percent: number;
}
interface BatchResultModalState {
failures: BatchOperationFailure[];
open: boolean;
summary: string;
title: string;
}
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
summary: "",
title: "",
};
type ForwardGroupOrderMap = Record<string, string[]>;
type ForwardGroupCollapsedMap = Record<string, boolean>;
@@ -672,6 +691,8 @@ export default function ForwardPage() {
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(
null,
);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
@@ -2440,6 +2461,36 @@ export default function ForwardPage() {
setSelectedIds(new Set());
};
const presentBatchOutcome = useCallback(
(outcome: {
failureDetails?: BatchOperationFailure[];
resultSummary?: string;
resultTitle?: string;
toastMessage: string;
toastVariant: "success" | "error";
}) => {
const failureDetails = outcome.failureDetails || [];
if (failureDetails.length > 0) {
setBatchResultModal({
failures: failureDetails,
open: true,
summary: outcome.resultSummary || outcome.toastMessage,
title: outcome.resultTitle || "批量操作结果",
});
return;
}
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
},
[],
);
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
@@ -2451,11 +2502,7 @@ export default function ForwardPage() {
try {
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -2490,11 +2537,7 @@ export default function ForwardPage() {
enable,
);
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -2525,11 +2568,7 @@ export default function ForwardPage() {
Array.from(selectedIds),
);
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -2561,11 +2600,7 @@ export default function ForwardPage() {
batchTargetTunnelId,
);
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -5848,6 +5883,22 @@ export default function ForwardPage() {
</ModalContent>
</Modal>
<BatchActionResultModal
failures={batchResultModal.failures}
isOpen={batchResultModal.open}
summary={batchResultModal.summary}
title={batchResultModal.title}
onOpenChange={(open) => {
if (open) {
setBatchResultModal((prev) => ({ ...prev, open: true }));
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
{/* 筛选模态框 */}
<Modal
isOpen={isFilterModalOpen}
@@ -1,4 +1,7 @@
import type { BatchOperationResult } from "@/api/types";
import type {
BatchOperationFailure,
BatchOperationResult,
} from "@/api/types";
import {
batchChangeTunnel,
@@ -7,12 +10,19 @@ import {
batchRedeployForwards,
batchResumeForwards,
} from "@/api";
import { extractApiErrorMessage } from "@/api/error-message";
import {
buildBatchFailureMessage,
extractBatchFailures,
extractApiErrorMessage,
} from "@/api/error-message";
export interface ForwardBatchActionOutcome {
toastVariant: "success" | "error";
toastMessage: string;
shouldRefresh: boolean;
resultTitle?: string;
resultSummary?: string;
failureDetails?: BatchOperationFailure[];
progressPercent?: number;
progressLabel?: string;
closeDeleteModal?: boolean;
@@ -26,23 +36,37 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => {
return {
successCount: Number(raw.successCount ?? 0),
failCount: Number(raw.failCount ?? 0),
failures: extractBatchFailures(raw),
};
};
const buildBatchToast = (
result: BatchOperationResult,
successText: string,
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
resultTitle: string,
): Pick<
ForwardBatchActionOutcome,
"toastVariant" | "toastMessage" | "resultTitle" | "resultSummary" | "failureDetails"
> => {
if (result.failCount === 0) {
return {
toastVariant: "success",
toastMessage: successText,
resultTitle,
resultSummary: successText,
failureDetails: [],
};
}
return {
toastVariant: "error",
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
toastMessage: buildBatchFailureMessage(
result,
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
),
resultTitle,
resultSummary: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failureDetails: result.failures || [],
};
};
@@ -63,7 +87,11 @@ export const executeForwardBatchDelete = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
...buildBatchToast(
summary,
`成功删除 ${summary.successCount} 项`,
"批量删除结果",
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
@@ -105,6 +133,7 @@ export const executeForwardBatchToggleService = async (
enable
? `成功启用 ${summary.successCount} 项`
: `成功停用 ${summary.successCount} 项`,
enable ? "批量启用结果" : "批量停用结果",
),
shouldRefresh: true,
progressPercent: 100,
@@ -136,7 +165,11 @@ export const executeForwardBatchRedeploy = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
...buildBatchToast(
summary,
`成功重新下发 ${summary.successCount} 项`,
"批量下发结果",
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
@@ -171,7 +204,11 @@ export const executeForwardBatchChangeTunnel = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
...buildBatchToast(
summary,
`成功换隧道 ${summary.successCount} 项`,
"批量换隧道结果",
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
File diff suppressed because it is too large Load Diff
+81 -8
View File
@@ -1,3 +1,5 @@
import type { BatchOperationFailure } from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import {
@@ -20,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
@@ -63,7 +66,11 @@ import {
} from "@/pages/tunnel/form";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import { extractApiErrorMessage } from "@/api/error-message";
import {
buildBatchFailureMessage,
extractBatchFailures,
extractApiErrorMessage,
} from "@/api/error-message";
interface ChainTunnel {
nodeId: number;
@@ -122,6 +129,20 @@ interface BatchProgressState {
percent: number;
}
interface BatchResultModalState {
failures: BatchOperationFailure[];
open: boolean;
summary: string;
title: string;
}
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
summary: "",
title: "",
};
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
@@ -226,6 +247,8 @@ export default function TunnelPage() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
@@ -358,7 +381,7 @@ export default function TunnelPage() {
});
setErrors({});
setModalOpen(true);
};
};
// 删除隧道
const handleDelete = (tunnel: Tunnel) => {
@@ -842,6 +865,18 @@ export default function TunnelPage() {
setSelectedIds(new Set());
};
const openBatchResultModal = useCallback(
(title: string, summary: string, failures: BatchOperationFailure[]) => {
setBatchResultModal({
failures,
open: true,
summary,
title,
});
},
[],
);
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
@@ -874,9 +909,17 @@ export default function TunnelPage() {
return next;
});
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
const failures = extractBatchFailures(result);
if (failures.length > 0) {
openBatchResultModal(
"批量删除结果",
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failures,
);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setBatchProgress({
active: true,
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
@@ -915,9 +958,22 @@ export default function TunnelPage() {
if (result.failCount === 0) {
toast.success(`成功重新下发 ${result.successCount} 项`);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
const failures = extractBatchFailures(result);
if (failures.length > 0) {
openBatchResultModal(
"批量下发结果",
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failures,
);
} else {
toast.error(
buildBatchFailureMessage(
result,
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
),
);
}
}
setSelectedIds(new Set());
setSelectMode(false);
@@ -1357,6 +1413,7 @@ export default function TunnelPage() {
</div>
)}
</div>
</div>
<div className="flex gap-1.5 mt-3">
@@ -3048,6 +3105,22 @@ export default function TunnelPage() {
)}
</ModalContent>
</Modal>
<BatchActionResultModal
failures={batchResultModal.failures}
isOpen={batchResultModal.open}
summary={batchResultModal.summary}
title={batchResultModal.title}
onOpenChange={(open) => {
if (open) {
setBatchResultModal((prev) => ({ ...prev, open: true }));
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
</AnimatedPage>
);
}
+194
View File
@@ -55,6 +55,7 @@ import {
updateUserTunnel,
getSpeedLimitList,
resetUserFlow,
resetUserQuota,
getUserGroupList,
getUserGroups,
} from "@/api";
@@ -83,6 +84,16 @@ const formatFlow = (value: number, unit: string = "bytes"): string => {
}
};
const formatQuotaLimit = (value?: number): string => {
const limit = Number(value ?? 0);
if (!Number.isFinite(limit) || limit <= 0) {
return "不限";
}
return `${limit} GB`;
};
const formatDate = (timestamp: number): string => {
return new Date(timestamp).toLocaleString();
};
@@ -138,6 +149,12 @@ const normalizeUserItem = (item: Partial<User>): User => {
createdTime: item.createdTime,
inFlow: Number(item.inFlow ?? 0),
outFlow: Number(item.outFlow ?? 0),
dailyQuotaGB: Number(item.dailyQuotaGB ?? 0),
monthlyQuotaGB: Number(item.monthlyQuotaGB ?? 0),
dailyUsedBytes: Number(item.dailyUsedBytes ?? 0),
monthlyUsedBytes: Number(item.monthlyUsedBytes ?? 0),
disabledByQuota: Number(item.disabledByQuota ?? 0),
quotaDisabledAt: Number(item.quotaDisabledAt ?? 0),
};
};
@@ -188,11 +205,22 @@ export default function UserPage() {
pwd: "",
status: 1,
flow: 100,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
expTime: null,
flowResetTime: 0,
});
const [userFormLoading, setUserFormLoading] = useState(false);
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
const editingUser = useMemo(
() =>
userForm.id
? users.find((item) => item.id === userForm.id) || null
: null,
[userForm.id, users],
);
// 隧道权限管理相关状态
const {
@@ -442,6 +470,8 @@ export default function UserPage() {
pwd: "",
status: 1,
flow: 100,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
expTime: null,
flowResetTime: 0,
@@ -469,6 +499,8 @@ export default function UserPage() {
pwd: "",
status: user.status,
flow: user.flow,
dailyQuotaGB: user.dailyQuotaGB ?? 0,
monthlyQuotaGB: user.monthlyQuotaGB ?? 0,
num: user.num,
expTime: user.expTime ? new Date(user.expTime) : null,
flowResetTime: user.flowResetTime ?? 0,
@@ -748,6 +780,29 @@ export default function UserPage() {
}
};
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
const userId = userForm.id;
if (!userId) {
return;
}
setQuotaResetLoading(true);
try {
const response = await resetUserQuota({ userId, scope });
if (response.code === 0) {
toast.success("用户配额已重置");
await loadUsers(searchKeyword);
} else {
toast.error(response.msg || "重置用户配额失败");
}
} catch {
toast.error("重置用户配额失败");
} finally {
setQuotaResetLoading(false);
}
};
// 隧道流量重置相关函数
const handleResetTunnelFlow = (userTunnel: UserTunnel) => {
setTunnelToReset(userTunnel);
@@ -946,6 +1001,16 @@ export default function UserPage() {
>
{userStatus.text}
</Chip>
{user.disabledByQuota ? (
<Chip
className="text-xs"
color="danger"
size="sm"
variant="flat"
>
配额超额
</Chip>
) : null}
</div>
</div>
</CardHeader>
@@ -983,6 +1048,28 @@ export default function UserPage() {
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
{(user.dailyQuotaGB ?? 0) > 0 ||
(user.monthlyQuotaGB ?? 0) > 0 ||
(user.disabledByQuota ?? 0) > 0 ? (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">每日配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /
{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">每月配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.monthlyUsedBytes ?? 0))} /
{" "}
{formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
</>
) : null}
<div className="flex justify-between text-sm">
<span className="text-default-600">规则数量</span>
<span className="font-medium text-xs">
@@ -1138,6 +1225,38 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, flow: value }));
}}
/>
<Input
label="每日配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.dailyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, dailyQuotaGB: value }));
}}
/>
<Input
label="每月配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.monthlyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value }));
}}
/>
<Input
isRequired
label="规则数量"
@@ -1210,6 +1329,81 @@ export default function UserPage() {
/>
</div>
{isEdit &&
editingUser &&
((editingUser.dailyQuotaGB ?? 0) > 0 ||
(editingUser.monthlyQuotaGB ?? 0) > 0 ||
(editingUser.disabledByQuota ?? 0) > 0) && (
<div className="space-y-3 rounded-xl border border-default-200 bg-default-50/60 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-foreground">
当前配额状态
</h3>
<p className="text-xs text-default-500">
配额超额后会自动暂停该用户的转发,重置后可恢复
</p>
</div>
{editingUser.disabledByQuota ? (
<Chip color="danger" size="sm" variant="flat">
配额已触发禁用
</Chip>
) : (
<Chip color="success" size="sm" variant="flat">
配额正常
</Chip>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg bg-background p-3">
<div className="text-xs text-default-500">每日用量</div>
<div className="mt-1 text-sm font-semibold text-foreground">
{formatFlow(Number(editingUser.dailyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(editingUser.dailyQuotaGB)}
</div>
</div>
<div className="rounded-lg bg-background p-3">
<div className="text-xs text-default-500">每月用量</div>
<div className="mt-1 text-sm font-semibold text-foreground">
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(editingUser.monthlyQuotaGB)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
color="warning"
isLoading={quotaResetLoading}
size="sm"
variant="flat"
onPress={() => handleQuotaReset("daily")}
>
重置每日配额
</Button>
<Button
color="warning"
isLoading={quotaResetLoading}
size="sm"
variant="flat"
onPress={() => handleQuotaReset("monthly")}
>
重置每月配额
</Button>
<Button
color="primary"
isLoading={quotaResetLoading}
size="sm"
variant="flat"
onPress={() => handleQuotaReset("all")}
>
全部重置并恢复
</Button>
</div>
</div>
)}
<RadioGroup
label="状态"
orientation="horizontal"
@@ -145,6 +145,7 @@ export function ModalContent({
return (
<BaseDialogContent
{...props}
className={cn(
mapSize(resolvedSize),
context?.classNames?.base,
@@ -157,7 +158,9 @@ export function ModalContent({
className,
)}
showCloseButton={false}
{...props}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
>
{renderedChildren}
</BaseDialogContent>
+8
View File
@@ -18,6 +18,12 @@ export interface User {
createdTime?: number; // 创建时间戳
inFlow?: number; // 下载流量(字节)
outFlow?: number; // 上传流量(字节)
dailyQuotaGB?: number;
monthlyQuotaGB?: number;
dailyUsedBytes?: number;
monthlyUsedBytes?: number;
disabledByQuota?: number;
quotaDisabledAt?: number;
}
export interface UserGroup {
@@ -33,6 +39,8 @@ export interface UserForm {
pwd?: string;
status: number;
flow: number;
dailyQuotaGB: number;
monthlyQuotaGB: number;
num: number;
expTime: Date | null;
flowResetTime: number;