diff --git a/docs/superpowers/plans/2026-07-10-forward-flow-reset.md b/docs/superpowers/plans/2026-07-10-forward-flow-reset.md new file mode 100644 index 0000000..ac15171 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-forward-flow-reset.md @@ -0,0 +1,650 @@ +# Forward Flow Reset Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a permission-checked action that resets only one forward rule's displayed upload and download counters. + +**Architecture:** A dedicated repository method updates only the selected `forward` row. A dedicated authenticated handler reuses `resolveForwardAccess`, and the React page calls the endpoint from all three rule views through one confirmation modal. + +**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL-compatible models, React, TypeScript, shadcn bridge components, Tailwind CSS v4. + +## Global Constraints + +- Only `forward.in_flow`, `forward.out_flow`, and `forward.updated_time` may change during reset. +- Do not modify `user`, `user_tunnel`, quota, historical statistics, nftables counter state, or running services. +- Administrators may reset any rule; non-admin users may reset only their own rules through existing `resolveForwardAccess` behavior. +- All API responses must keep the `{code, msg, data, ts}` envelope. +- Frontend imports must use `src/shadcn-bridge/heroui/*`; do not add `@heroui/*` or `@nextui-org/*` dependencies. +- Do not add frontend test infrastructure. +- Do not edit generated protobuf files, `install.sh`, or `panel_install.sh`. + +--- + +### Task 1: Add the repository flow-reset primitive + +**Files:** +- Create: `go-backend/internal/store/repo/repository_forward_flow_reset_test.go` +- Modify: `go-backend/internal/store/repo/repository_mutations.go` + +**Interfaces:** +- Consumes: `model.Forward`, the repository's GORM database handle, and an explicit Unix-millisecond timestamp. +- Produces: `func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error`. + +- [ ] **Step 1: Write the failing repository tests** + +Create `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`: + +```go +package repo + +import ( + "path/filepath" + "testing" +) + +func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + const originalUpdated int64 = 1000 + 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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1) + `).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0) + `).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1) + `).Error; err != nil { + t.Fatalf("insert user tunnel: %v", err) + } + if err := r.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 + (20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0), + (21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1) + `, originalUpdated, originalUpdated).Error; err != nil { + t.Fatalf("insert forwards: %v", err) + } + + const resetAt int64 = 2000 + if err := r.ResetForwardFlow(20, resetAt); err != nil { + t.Fatalf("ResetForwardFlow: %v", err) + } + + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0) + assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt) + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444) + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900) + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600) +} + +func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) { + var r *Repository + if err := r.ResetForwardFlow(20, 2000); err == nil { + t.Fatal("expected uninitialized repository error") + } +} + +func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) { + t.Helper() + var got int64 + if err := r.DB().Raw(query).Scan(&got).Error; err != nil { + t.Fatalf("query %q: %v", query, err) + } + if got != want { + t.Fatalf("query %q returned %d, want %d", query, got, want) + } +} +``` + +- [ ] **Step 2: Run the repository tests and verify the missing method failure** + +Run: + +```bash +cd go-backend && go test ./internal/store/repo -run TestResetForwardFlow -count=1 +``` + +Expected: compilation fails because `ResetForwardFlow` is undefined. + +- [ ] **Step 3: Implement the minimal repository method** + +Add to the flow-reset section of `go-backend/internal/store/repo/repository_mutations.go`: + +```go +func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Model(&model.Forward{}). + Where("id = ?", forwardID). + Updates(map[string]interface{}{ + "in_flow": 0, + "out_flow": 0, + "updated_time": now, + }).Error +} +``` + +The file already imports `errors` and `model`; do not add a new dependency. + +- [ ] **Step 4: Format and run the focused repository tests** + +Run: + +```bash +cd go-backend && gofmt -w internal/store/repo/repository_forward_flow_reset_test.go internal/store/repo/repository_mutations.go +go test ./internal/store/repo -run TestResetForwardFlow -count=1 +``` + +Expected: both reset tests pass. + +- [ ] **Step 5: Commit the repository change** + +```bash +git add go-backend/internal/store/repo/repository_mutations.go go-backend/internal/store/repo/repository_forward_flow_reset_test.go +git commit -m "feat: add forward flow reset repository method" +``` + +--- + +### Task 2: Add the authenticated reset endpoint + +**Files:** +- Create: `go-backend/internal/http/handler/forward_reset_flow_test.go` +- Modify: `go-backend/internal/http/handler/handler.go` +- Modify: `go-backend/internal/http/handler/mutations.go` + +**Interfaces:** +- Consumes: `POST` JSON `{ "id": number }`, `resolveForwardAccess`, and `Repository.ResetForwardFlow` from Task 1. +- Produces: `POST /api/v1/forward/reset-flow` and `func (h *Handler) forwardResetFlow(http.ResponseWriter, *http.Request)`. + +- [ ] **Step 1: Write the failing handler tests** + +Create `go-backend/internal/http/handler/forward_reset_flow_test.go`: + +```go +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" + + "go-backend/internal/auth" + "go-backend/internal/http/middleware" + "go-backend/internal/store/repo" +) + +func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) { + tests := []struct { + name string + actorID int64 + actorRole int + forwardID int64 + wantCode int + wantInFlow int64 + wantOutFlow int64 + }{ + {name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0}, + {name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0}, + {name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222}, + {name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, r := setupForwardResetFlowHandler(t) + req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole) + res := httptest.NewRecorder() + + h.forwardResetFlow(res, req) + + if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode { + t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String()) + } + assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow) + assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow) + assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700) + assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600) + }) + } +} + +func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) { + h, _ := setupForwardResetFlowHandler(t) + + t.Run("non post", func(t *testing.T) { + req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0) + res := httptest.NewRecorder() + h.forwardResetFlow(res, req) + if code := decodeForwardResetFlowCode(t, res); code != -1 { + t.Fatalf("code = %d, want -1", code) + } + }) + + t.Run("invalid id", func(t *testing.T) { + req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0) + res := httptest.NewRecorder() + h.forwardResetFlow(res, req) + if code := decodeForwardResetFlowCode(t, res); code != -1 { + t.Fatalf("code = %d, want -1", code) + } + }) +} + +func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) { + t.Helper() + r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + + statements := []string{ + `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(1, 'admin', 'pwd', 0, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`, + `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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`, + `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(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`, + `INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`, + `INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`, + `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(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`, + } + for _, statement := range statements { + if err := r.DB().Exec(statement).Error; err != nil { + t.Fatalf("seed database: %v", err) + } + } + return New(r, "test-secret"), r +} + +func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request { + t.Helper() + body, err := json.Marshal(map[string]int64{"id": forwardID}) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body)) + claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID} + return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims)) +} + +func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int { + t.Helper() + var payload struct { + Code int `json:"code"` + } + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v; body=%s", err, res.Body.String()) + } + return payload.Code +} + +func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) { + t.Helper() + var got int64 + if err := r.DB().Raw(query).Scan(&got).Error; err != nil { + t.Fatalf("query %q: %v", query, err) + } + if got != want { + t.Fatalf("query %q returned %d, want %d", query, got, want) + } +} +``` + +If the project's default error code differs from `-1`, replace the test expectation with the actual `response.ErrDefault` code after inspecting one existing handler response; do not weaken the success and database assertions. + +- [ ] **Step 2: Run the handler tests and verify the missing handler failure** + +Run: + +```bash +cd go-backend && go test ./internal/http/handler -run TestForwardResetFlow -count=1 +``` + +Expected: compilation fails because `forwardResetFlow` is undefined. + +- [ ] **Step 3: Register and implement the endpoint** + +Add this route beside the other forward routes in `go-backend/internal/http/handler/handler.go`: + +```go +mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow) +``` + +Add this handler beside `forwardPause` and `forwardResume` in `go-backend/internal/http/handler/mutations.go`: + +```go +func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) { + id := idFromBody(r, w) + if id <= 0 { + return + } + if _, _, _, err := h.resolveForwardAccess(r, id); err != nil { + if errors.Is(err, errForwardNotFound) { + response.WriteJSON(w, response.ErrDefault("转发不存在")) + return + } + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} +``` + +This deliberately does not call runtime service controls or nftables reconciliation. + +- [ ] **Step 4: Format and run the focused handler tests** + +Run: + +```bash +cd go-backend && gofmt -w internal/http/handler/forward_reset_flow_test.go internal/http/handler/handler.go internal/http/handler/mutations.go +go test ./internal/http/handler -run TestForwardResetFlow -count=1 +``` + +Expected: all reset endpoint tests pass. + +- [ ] **Step 5: Run all backend tests** + +Run: + +```bash +cd go-backend && go test ./... +``` + +Expected: all backend packages and contract tests pass, excluding environment-gated PostgreSQL tests when `FLVX_POSTGRES_TEST_DSN` is unset. + +- [ ] **Step 6: Commit the endpoint change** + +```bash +git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/forward_reset_flow_test.go +git commit -m "feat: add forward flow reset endpoint" +``` + +--- + +### Task 3: Add the rule-page reset action and confirmation modal + +**Files:** +- Modify: `vite-frontend/src/api/index.ts` +- Modify: `vite-frontend/src/pages/forward.tsx` + +**Interfaces:** +- Consumes: `POST /forward/reset-flow`, the page's `Forward` shape, `refreshForwardList`, toast notifications, and existing modal/button bridge components. +- Produces: `resetForwardFlow(id: number)`, a shared reset handler, disabled zero-usage actions in all rule views, and one confirmation modal. + +- [ ] **Step 1: Add the frontend API wrapper** + +Add beside the forward control operations in `vite-frontend/src/api/index.ts`: + +```ts +export const resetForwardFlow = (forwardId: number) => + Network.post("/forward/reset-flow", { id: forwardId }); +``` + +Import `resetForwardFlow` from `@/api` in `vite-frontend/src/pages/forward.tsx`. + +- [ ] **Step 2: Add page state and shared reset handlers** + +Add state beside the existing delete modal state: + +```ts +const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false); +const [resetFlowLoading, setResetFlowLoading] = useState(false); +const [forwardToResetFlow, setForwardToResetFlow] = useState(null); +``` + +Add these handlers beside `handleDelete` and `confirmDelete`: + +```ts +const handleResetFlow = (forward: Forward) => { + if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return; + setForwardToResetFlow(forward); + setResetFlowModalOpen(true); +}; + +const confirmResetFlow = async () => { + if (!forwardToResetFlow) return; + + setResetFlowLoading(true); + try { + const res = await resetForwardFlow(forwardToResetFlow.id); + + if (res.code !== 0) { + toast.error(res.msg || "流量清零失败"); + return; + } + + toast.success("规则流量已清零"); + setResetFlowModalOpen(false); + setForwardToResetFlow(null); + await refreshForwardList(false); + } catch { + toast.error("流量清零失败"); + } finally { + setResetFlowLoading(false); + } +}; +``` + +- [ ] **Step 3: Add one reusable reset icon button to both table row components** + +Pass `handleResetFlow` into `SortableTableRow` and `SortableCompactTableRow` at every render site. Add it to each component's destructured props. + +Insert this button between diagnosis and delete in each table action cell: + +```tsx + +``` + +- [ ] **Step 4: Add the reset action to the card view** + +Insert a fourth action button between diagnosis and delete in `renderForwardCard`: + +```tsx + +``` + +Change the card action container from `flex gap-1.5 mt-3` to `grid grid-cols-2 gap-1.5 mt-3` so all four actions remain readable at the smallest supported card width. + +- [ ] **Step 5: Add the confirmation modal** + +Add beside the delete confirmation modal: + +```tsx + + + {(onClose) => ( + <> + +

确认流量清零

+
+ +

+ 确定要清零规则{" "} + + "{forwardToResetFlow?.name}" + {" "} + 当前显示的上传和下载流量吗? +

+

+ 此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。 +

+
+ + + + + + )} +
+
+``` + +Add this wrapper beside the other reset handlers and pass it to the modal as `onOpenChange={handleResetFlowModalOpenChange}`: + +```ts +const handleResetFlowModalOpenChange = (isOpen: boolean) => { + if (resetFlowLoading) return; + setResetFlowModalOpen(isOpen); + if (!isOpen) { + setForwardToResetFlow(null); + } +}; +``` + +- [ ] **Step 6: Format and verify the frontend** + +Run: + +```bash +cd vite-frontend && pnpm exec prettier --write src/api/index.ts src/pages/forward.tsx +pnpm run build +pnpm run lint +``` + +Expected: TypeScript/Vite build succeeds and ESLint finishes without errors. + +- [ ] **Step 7: Commit the frontend change** + +```bash +git add vite-frontend/src/api/index.ts vite-frontend/src/pages/forward.tsx +git commit -m "feat: add forward flow reset action" +``` + +--- + +### Task 4: Perform integrated verification + +**Files:** +- Verify only; no planned source changes. + +**Interfaces:** +- Consumes: the repository method, API endpoint, and rule-page action from Tasks 1-3. +- Produces: evidence that the complete feature builds and all affected tests pass. + +- [ ] **Step 1: Run the complete backend suite** + +```bash +cd go-backend && go test ./... +``` + +Expected: all available backend tests pass. + +- [ ] **Step 2: Run the complete frontend checks** + +```bash +cd vite-frontend && pnpm run build && pnpm run lint +``` + +Expected: both commands exit successfully. + +- [ ] **Step 3: Check formatting and working-tree scope** + +```bash +git diff --check +git status --short +git log -4 --oneline +``` + +Expected: no whitespace errors; the working tree is clean; the three feature commits are visible after the design and implementation-plan commits. + +- [ ] **Step 4: Manually verify the feature when a local panel is available** + +1. Open the Rules page as an administrator and reset a rule with non-zero upload/download traffic. +2. Confirm the modal states that user totals, tunnel quota, and history are unaffected. +3. Confirm the rule immediately shows zero after success. +4. Confirm the user page's total traffic and user-tunnel traffic values did not change. +5. Generate new traffic and confirm the rule starts accumulating from zero. +6. Log in as a normal user and confirm the user can reset an owned rule but cannot access another user's rule through a direct API request. + +Expected: all six checks match the design specification. diff --git a/docs/superpowers/specs/2026-07-10-forward-flow-reset-design.md b/docs/superpowers/specs/2026-07-10-forward-flow-reset-design.md new file mode 100644 index 0000000..323a77c --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-forward-flow-reset-design.md @@ -0,0 +1,197 @@ +# 规则流量清零设计 + +## 背景 + +Issue #523 希望“规则”页面中每条隧道规则显示的流量使用量支持手动清零。 + +当前规则流量保存在 `forward.in_flow` 和 `forward.out_flow`。流量上报时,同一份增量还会累计到用户总流量、用户隧道流量和相关配额统计中。因此,本功能必须将“规则展示计数器清零”与“用户或隧道配额重置”严格区分。 + +## 目标 + +为单条规则提供手动流量清零能力: + +- 将所选规则的上传流量和下载流量清零。 +- 管理员可以清零任意规则。 +- 普通用户只能清零自己的规则。 +- 清零后,新产生的流量继续从零正常累计。 + +## 非目标 + +本功能不会: + +- 修改用户总流量 `user.in_flow` 或 `user.out_flow`。 +- 修改用户隧道流量 `user_tunnel.in_flow` 或 `user_tunnel.out_flow`。 +- 修改每日或每月配额用量。 +- 修改历史流量统计。 +- 重置 nftables 节点计数器或其增量计算基线。 +- 重启、暂停、恢复或重新部署规则服务。 +- 增加批量流量清零功能。 + +## 后端设计 + +### API + +新增接口: + +```text +POST /api/v1/forward/reset-flow +``` + +请求体: + +```json +{ + "id": 123 +} +``` + +成功响应沿用统一 envelope: + +```json +{ + "code": 0, + "msg": "success", + "data": null, + "ts": 0 +} +``` + +具体 `msg`、`data` 和 `ts` 值继续由现有 response helper 生成。 + +### 参数与权限校验 + +Handler 执行以下步骤: + +1. 只接受 `POST` 请求。 +2. 从 JSON 请求体读取正整数规则 ID。 +3. 调用现有 `resolveForwardAccess`: + - 管理员角色可以访问任意存在的规则。 + - 普通用户仅能访问 `forward.user_id` 等于当前用户 ID 的规则。 + - 对普通用户访问他人规则的情况,沿用现有逻辑返回“转发不存在”,避免暴露规则存在性。 +4. 调用 Repository 完成清零。 +5. 返回统一成功响应。 + +### Repository + +新增方法: + +```go +func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error +``` + +该方法只更新指定 `forward` 记录: + +```text +in_flow = 0 +out_flow = 0 +updated_time = now +``` + +Repository 不直接操作 Handler 的身份信息,也不更新任何其他表。 + +### 并发与后续流量 + +清零使用单条 SQL `UPDATE`。agent 流量上报和 nftables 流量采集仍使用原有增量累加逻辑。清零不会重置采集基线,因此下一次采集只会把清零之后新计算出的增量加回规则计数,不会把清零前的累计值整体恢复。 + +若清零 SQL 与流量增量 SQL 同时执行,数据库按实际语句执行顺序决定最终值;每条更新本身保持原子性。本功能不引入暂停采集或跨节点同步流程。 + +## 前端设计 + +### API 封装 + +在 `vite-frontend/src/api/index.ts` 新增: + +```ts +export const resetForwardFlow = (id: number) => + Network.post("/forward/reset-flow", { id }); +``` + +### 入口 + +在规则页面所有单条规则操作入口中增加“流量清零”操作: + +- 分组表格视图。 +- 精简表格视图。 +- 卡片视图。 + +按钮使用独立的清零/刷新语义图标和提示文本,不复用删除按钮样式。 + +当规则的 `inFlow + outFlow` 等于零时,按钮禁用,避免重复请求。 + +### 确认交互 + +点击按钮后打开确认弹窗,显示规则名称,并明确说明: + +- 仅清零当前规则显示的上传和下载流量。 +- 不影响用户总流量、用户隧道配额和历史统计。 +- 操作不可撤销。 + +确认期间显示 loading 状态并阻止重复提交。 + +### 成功与失败 + +- 成功:关闭弹窗,显示成功 toast,并刷新规则列表。 +- 失败:保留弹窗,显示后端错误信息或通用失败 toast。 +- 刷新后,该规则上传和下载均显示为零;后续流量继续正常累计。 + +## 错误处理 + +- 非 POST 请求:返回现有通用请求失败响应。 +- 请求体无法解析、ID 缺失或 ID 非正数:返回“请求参数错误”。 +- 规则不存在或普通用户访问他人规则:返回“转发不存在”。 +- Repository 更新失败:返回包含 Repository 错误信息的统一错误响应。 +- 前端网络错误:显示“流量清零失败”。 + +## 测试策略 + +### Repository 测试 + +验证: + +- 指定规则的 `in_flow`、`out_flow` 被清零。 +- 指定规则的 `updated_time` 被更新。 +- 其他规则的流量不变。 +- 用户总流量不变。 +- 用户隧道流量不变。 +- Repository 未初始化时返回错误。 + +### Handler 测试 + +验证: + +- 管理员能够清零任意存在的规则。 +- 普通用户能够清零自己的规则。 +- 普通用户不能清零他人的规则。 +- 不存在的规则返回错误。 +- 无效 ID 返回参数错误。 +- 非 POST 请求返回请求失败。 +- 成功请求不修改用户和用户隧道流量。 + +### 前端验证 + +项目没有配置前端测试框架,因此不新增前端单元测试。使用以下命令验证: + +```bash +(cd vite-frontend && pnpm run build) +(cd vite-frontend && pnpm run lint) +``` + +后端使用: + +```bash +(cd go-backend && go test ./...) +``` + +## 文件范围 + +预计修改: + +- `go-backend/internal/http/handler/handler.go` +- `go-backend/internal/http/handler/mutations.go` +- `go-backend/internal/http/handler/*_test.go` +- `go-backend/internal/store/repo/repository_mutations.go` +- `go-backend/internal/store/repo/*_test.go` +- `vite-frontend/src/api/index.ts` +- `vite-frontend/src/pages/forward.tsx` + +不需要数据库迁移或新增依赖。 diff --git a/go-backend/internal/http/handler/forward_reset_flow_test.go b/go-backend/internal/http/handler/forward_reset_flow_test.go new file mode 100644 index 0000000..2aa1fb5 --- /dev/null +++ b/go-backend/internal/http/handler/forward_reset_flow_test.go @@ -0,0 +1,129 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" + + "go-backend/internal/auth" + "go-backend/internal/http/middleware" + "go-backend/internal/store/repo" +) + +func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) { + tests := []struct { + name string + actorID int64 + actorRole int + forwardID int64 + wantCode int + wantInFlow int64 + wantOutFlow int64 + }{ + {name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0}, + {name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0}, + {name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222}, + {name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, r := setupForwardResetFlowHandler(t) + req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole) + res := httptest.NewRecorder() + + h.forwardResetFlow(res, req) + + if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode { + t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String()) + } + assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow) + assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow) + assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700) + assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600) + }) + } +} + +func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) { + h, _ := setupForwardResetFlowHandler(t) + + t.Run("non post", func(t *testing.T) { + req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0) + res := httptest.NewRecorder() + h.forwardResetFlow(res, req) + if code := decodeForwardResetFlowCode(t, res); code != -1 { + t.Fatalf("code = %d, want -1", code) + } + }) + + t.Run("invalid id", func(t *testing.T) { + req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0) + res := httptest.NewRecorder() + h.forwardResetFlow(res, req) + if code := decodeForwardResetFlowCode(t, res); code != -1 { + t.Fatalf("code = %d, want -1", code) + } + }) +} + +func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) { + t.Helper() + r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + + statements := []string{ + `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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`, + `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(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`, + `INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`, + `INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`, + `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(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`, + } + for _, statement := range statements { + if err := r.DB().Exec(statement).Error; err != nil { + t.Fatalf("seed database: %v", err) + } + } + return New(r, "test-secret"), r +} + +func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request { + t.Helper() + body, err := json.Marshal(map[string]int64{"id": forwardID}) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body)) + claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID} + return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims)) +} + +func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int { + t.Helper() + var payload struct { + Code int `json:"code"` + } + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v; body=%s", err, res.Body.String()) + } + return payload.Code +} + +func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) { + t.Helper() + var got int64 + if err := r.DB().Raw(query).Scan(&got).Error; err != nil { + t.Fatalf("query %q: %v", query, err) + } + if got != want { + t.Fatalf("query %q returned %d, want %d", query, got, want) + } +} diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 6105a2b..32ac730 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -221,6 +221,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete) mux.HandleFunc("/api/v1/forward/pause", h.forwardPause) mux.HandleFunc("/api/v1/forward/resume", h.forwardResume) + mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow) mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose) mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream) mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9d425a7..8b9f4b9 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -2502,6 +2502,26 @@ func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.OKEmpty()) } +func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) { + id := idFromBody(r, w) + if id <= 0 { + return + } + if _, _, _, err := h.resolveForwardAccess(r, id); err != nil { + if errors.Is(err, errForwardNotFound) { + response.WriteJSON(w, response.ErrDefault("转发不存在")) + return + } + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} + func (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) { id := idFromBody(r, w) if id <= 0 { diff --git a/go-backend/internal/store/repo/repository_forward_flow_reset_test.go b/go-backend/internal/store/repo/repository_forward_flow_reset_test.go new file mode 100644 index 0000000..ab0ee9d --- /dev/null +++ b/go-backend/internal/store/repo/repository_forward_flow_reset_test.go @@ -0,0 +1,75 @@ +package repo + +import ( + "path/filepath" + "testing" +) + +func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + const originalUpdated int64 = 1000 + 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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1) + `).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0) + `).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1) + `).Error; err != nil { + t.Fatalf("insert user tunnel: %v", err) + } + if err := r.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 + (20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0), + (21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1) + `, originalUpdated, originalUpdated).Error; err != nil { + t.Fatalf("insert forwards: %v", err) + } + + const resetAt int64 = 2000 + if err := r.ResetForwardFlow(20, resetAt); err != nil { + t.Fatalf("ResetForwardFlow: %v", err) + } + + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0) + assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt) + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444) + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900) + assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500) + assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600) +} + +func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) { + var r *Repository + if err := r.ResetForwardFlow(20, 2000); err == nil { + t.Fatal("expected uninitialized repository error") + } +} + +func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) { + t.Helper() + var got int64 + if err := r.DB().Raw(query).Scan(&got).Error; err != nil { + t.Fatalf("query %q: %v", query, err) + } + if got != want { + t.Fatalf("query %q returned %d, want %d", query, got, want) + } +} diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 20bc83d..5b3685d 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -197,6 +197,19 @@ func (r *Repository) ResetUserFlowByUserTunnel(userTunnelID int64) { Updates(map[string]interface{}{"in_flow": 0, "out_flow": 0}).Error } +func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Model(&model.Forward{}). + Where("id = ?", forwardID). + Updates(map[string]interface{}{ + "in_flow": 0, + "out_flow": 0, + "updated_time": now, + }).Error +} + func (r *Repository) GetUsernameByID(userID int64) string { if r == nil || r.db == nil { return "" diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index acdc7aa..c97ddc4 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -217,6 +217,8 @@ export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId }); export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId }); +export const resetForwardFlow = (forwardId: number) => + Network.post("/forward/reset-flow", { id: forwardId }); // 转发诊断操作 export const diagnoseForward = (forwardId: number) => diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 10d2a6c..f7fc3ba 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -69,6 +69,7 @@ import { getNodeList, pauseForwardService, resumeForwardService, + resetForwardFlow, diagnoseForward, updateForwardOrder, getConfigByName, @@ -230,7 +231,7 @@ const FORWARD_GROUPED_TABLE_COLUMN_CLASS = { strategy: "w-[100px]", totalFlow: "w-[120px]", status: "w-[100px]", - actions: "w-[144px] text-right", + actions: "w-[176px] text-right", } as const; const normalizeForwardUserName = (userName?: string): string => { @@ -764,6 +765,7 @@ const SortableTableRow = ({ handleEdit, handleDelete, handleDiagnose, + handleResetFlow, showAddressModal, formatFlow, }: any) => { @@ -919,6 +921,29 @@ const SortableTableRow = ({ /> + + + + + + + )} + + + {/* 地址列表弹窗 */}