mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 08:44:24 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e5bb8fc0b | |||
| d1e3c59537 | |||
| 8b8ebb6092 | |||
| 0195a2a01b | |||
| ad9b336fb9 | |||
| 30d9552207 | |||
| 5e96a8de72 | |||
| 69faeaa9a6 | |||
| e8bfe52104 | |||
| 9767cc3247 | |||
| e8a7f999c8 | |||
| 4d4f5f8b1f | |||
| d2a425d761 | |||
| 673d38a089 | |||
| 2e8c0530a9 | |||
| 32ee511eac | |||
| f410640862 | |||
| 6427b830ea | |||
| 5e7bf3ba5c | |||
| 27d6691232 | |||
| cc4b8a916a | |||
| d9dd5131b2 | |||
| a98c9f4f59 | |||
| 5cb935e0e5 | |||
| 6f59e4be0c | |||
| 647446a2a2 | |||
| a2ec08f033 | |||
| f8809d73fb | |||
| e5339a8072 | |||
| fbb4d82a44 | |||
| 61b71a11c7 | |||
| 4c69ff491d | |||
| 0ad4904e20 |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -757,6 +808,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
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 +838,195 @@ 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) 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 +1096,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 +1249,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 +1310,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 +1477,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 +1651,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 +1703,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 +1810,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 +1880,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 +1915,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 +1950,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 +1991,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 +2038,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 +2046,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 +2080,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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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("aItem).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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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分钟上报一次)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
@@ -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,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
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} 项`,
|
||||
|
||||
+722
-288
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user