From 1ad2bcfe56a7b4491975dbd6508911b61db871cd Mon Sep 17 00:00:00 2001 From: wsr2002 <2327909382@qq.com> Date: Fri, 13 Mar 2026 15:32:08 -0700 Subject: [PATCH 1/3] test testsprite integration --- 111 | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 111 diff --git a/111 b/111 new file mode 100644 index 0000000..e69de29 From b8d4afa9fe48f5c178e859ee02e19da6457595ee Mon Sep 17 00:00:00 2001 From: wsr2002 <2327909382@qq.com> Date: Fri, 13 Mar 2026 15:40:15 -0700 Subject: [PATCH 2/3] 123 --- .gitignore | 1 - testsprite_tests/tmp/code_summary.yaml | 281 +++++++++++++++ testsprite_tests/tmp/config.json | 19 + testsprite_tests/tmp/execution.lock | 5 + testsprite_tests/tmp/mcp.log | 327 ++++++++++++++++++ .../tmp/prd_files/PRODUCT_SPECIFICATION.md | 152 ++++++++ testsprite_tests/tmp/raw_report.md | 77 +++++ testsprite_tests/tmp/test_results.json | 62 ++++ 8 files changed, 923 insertions(+), 1 deletion(-) create mode 100644 testsprite_tests/tmp/code_summary.yaml create mode 100644 testsprite_tests/tmp/config.json create mode 100644 testsprite_tests/tmp/execution.lock create mode 100644 testsprite_tests/tmp/mcp.log create mode 100644 testsprite_tests/tmp/prd_files/PRODUCT_SPECIFICATION.md create mode 100644 testsprite_tests/tmp/raw_report.md create mode 100644 testsprite_tests/tmp/test_results.json diff --git a/.gitignore b/.gitignore index 7451755..4aee804 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ dist/ *.local # TestSprite 运行时(含可能敏感配置) -testsprite_tests/tmp/ # IDE / OS .idea/ diff --git a/testsprite_tests/tmp/code_summary.yaml b/testsprite_tests/tmp/code_summary.yaml new file mode 100644 index 0000000..113c9cb --- /dev/null +++ b/testsprite_tests/tmp/code_summary.yaml @@ -0,0 +1,281 @@ +version: "2" +type: backend +tech_stack: + - Python + - FastAPI + - SQLAlchemy 2.0 (async) + - SQLite/PostgreSQL (asyncpg) + - JWT (python-jose) + - Pydantic v2 + - passlib/bcrypt +features: + - name: Health Check API + description: Basic and database health check endpoints + files: + - backend/app/api/routes/health.py + - backend/app/main.py + endpoints: + - method: GET + path: /api/v1/health + description: Basic health check + auth_required: false + response_schema: + "200": object with status, app, version, timestamp + - method: GET + path: /api/v1/health/db + description: Database connectivity check + auth_required: false + response_schema: + "200": object with status, database + depends_on: [] + - name: Authentication API + description: Login, refresh token, and current user + files: + - backend/app/api/routes/auth.py + - backend/app/services/auth_service.py + - backend/app/schemas/auth.py + endpoints: + - method: POST + path: /api/v1/auth/login + description: Login with username/email and password + auth_required: false + request_schema: + body: + username: string + password: string + response_schema: + "200": Token (access_token, refresh_token, token_type, expires_in) + "401": Unauthorized + - method: POST + path: /api/v1/auth/refresh + description: Refresh access token + auth_required: false + request_schema: + body: + refresh_token: string + response_schema: + "200": Token + "401": Unauthorized + - method: GET + path: /api/v1/auth/me + description: Get current user info + auth_required: true + response_schema: + "200": UserResponse + depends_on: [] + - name: User Management API + description: User registration and CRUD + files: + - backend/app/api/routes/users.py + - backend/app/services/user_service.py + - backend/app/models/user.py + - backend/app/schemas/user.py + endpoints: + - method: POST + path: /api/v1/users + description: Register new user + auth_required: false + request_schema: + body: + email: string + username: string + password: string + full_name: string + response_schema: + "201": UserResponse + "400": Validation or duplicate error + - method: GET + path: /api/v1/users + description: List users with pagination + auth_required: true + response_schema: + "200": PaginatedResponse UserResponse + - method: GET + path: /api/v1/users/{user_id} + description: Get user by ID + auth_required: true + response_schema: + "200": UserResponse + "404": Not found + - method: PATCH + path: /api/v1/users/{user_id} + description: Update user (self or admin) + auth_required: true + response_schema: + "200": UserResponse + "403": Forbidden + "404": Not found + - method: DELETE + path: /api/v1/users/{user_id} + description: Delete user (self or admin) + auth_required: true + response_schema: + "204": No content + "403": Forbidden + "404": Not found + depends_on: + - Authentication API + - name: Category Management API + description: Category CRUD and listing + files: + - backend/app/api/routes/categories.py + - backend/app/services/category_service.py + - backend/app/models/category.py + - backend/app/schemas/category.py + endpoints: + - method: POST + path: /api/v1/categories + description: Create category + auth_required: true + request_schema: + body: + name: string + slug: string + description: optional string + response_schema: + "201": CategoryResponse + "400": slug exists + - method: GET + path: /api/v1/categories + description: List all categories (optional parent_id filter) + auth_required: true + response_schema: + "200": list CategoryResponse + - method: GET + path: /api/v1/categories/paginated + description: List categories with pagination + auth_required: true + response_schema: + "200": PaginatedResponse CategoryResponse + - method: GET + path: /api/v1/categories/{category_id} + description: Get category by ID + auth_required: true + response_schema: + "200": CategoryResponse + "404": Not found + - method: PATCH + path: /api/v1/categories/{category_id} + description: Update category + auth_required: true + response_schema: + "200": CategoryResponse + "404": Not found + - method: DELETE + path: /api/v1/categories/{category_id} + description: Delete category + auth_required: true + response_schema: + "204": No content + "404": Not found + depends_on: + - Authentication API + - name: Item Management API + description: Product/item CRUD and listing + files: + - backend/app/api/routes/items.py + - backend/app/services/item_service.py + - backend/app/models/item.py + - backend/app/schemas/item.py + endpoints: + - method: POST + path: /api/v1/items + description: Create item (requires auth) + auth_required: true + request_schema: + body: + name: string + slug: string + description: optional string + price: number + category_id: optional string + response_schema: + "201": ItemResponse + "400": slug exists + - method: GET + path: /api/v1/items + description: List items with pagination and filters (category_id, is_active) + auth_required: false + response_schema: + "200": PaginatedResponse ItemResponse + - method: GET + path: /api/v1/items/{item_id} + description: Get item by ID + auth_required: false + response_schema: + "200": ItemResponse + "404": Not found + - method: GET + path: /api/v1/items/slug/{slug} + description: Get item by slug + auth_required: false + response_schema: + "200": ItemResponse + "404": Not found + - method: PATCH + path: /api/v1/items/{item_id} + description: Update item + auth_required: true + response_schema: + "200": ItemResponse + "404": Not found + - method: DELETE + path: /api/v1/items/{item_id} + description: Delete item + auth_required: true + response_schema: + "204": No content + "404": Not found + depends_on: + - Authentication API + - name: Order Management API + description: Order creation and CRUD + files: + - backend/app/api/routes/orders.py + - backend/app/services/order_service.py + - backend/app/models/order.py + - backend/app/schemas/order.py + endpoints: + - method: POST + path: /api/v1/orders + description: Create order + auth_required: true + request_schema: + body: + items: array of item_id and quantity + shipping_address: optional string + response_schema: + "201": OrderResponse + "400": Validation error + - method: GET + path: /api/v1/orders + description: List current user orders with pagination + auth_required: true + response_schema: + "200": PaginatedResponse OrderResponse + - method: GET + path: /api/v1/orders/admin + description: List all orders (admin only) + auth_required: true + response_schema: + "200": PaginatedResponse OrderResponse + "403": Forbidden + - method: GET + path: /api/v1/orders/{order_id} + description: Get order detail (owner or admin) + auth_required: true + response_schema: + "200": OrderResponse + "403": Forbidden + "404": Not found + - method: PATCH + path: /api/v1/orders/{order_id} + description: Update order (e.g. status, address) + auth_required: true + response_schema: + "200": OrderResponse + "403": Forbidden + "404": Not found + depends_on: + - Authentication API +known_limitations: [] diff --git a/testsprite_tests/tmp/config.json b/testsprite_tests/tmp/config.json new file mode 100644 index 0000000..7a7150f --- /dev/null +++ b/testsprite_tests/tmp/config.json @@ -0,0 +1,19 @@ +{ + "status": "commited", + "type": "backend", + "scope": "codebase", + "localEndpoint": "http://localhost:8000", + "backendAuthType": "public", + "executionArgs": { + "projectName": "试验", + "projectPath": "d:\\testsprite\\试验", + "testIds": [], + "additionalInstruction": "", + "serverMode": "development", + "envs": { + "API_KEY": "sk-user-e1bveLHEvmHHzPmJqyRp62oct-Q7s1X6MN3X-ekGay4h8NjZ4850OMsLY-0D1rMCchuiaI9QgBYxc96y0f6yvInPN2VFEqqx6CUBQ0EL8W6wDh9C06ob_48qi7eY1_yqPPg" + } + }, + "serverPort": 5187, + "proxy": "http://0a6cea99-305c-4943-b9d3-6fd87d6a0a59:qKAsAwQhvfR6q6Hcuc6FKpZ92hGFztra@tun.testsprite.com:8080" +} diff --git a/testsprite_tests/tmp/execution.lock b/testsprite_tests/tmp/execution.lock new file mode 100644 index 0000000..1193e10 --- /dev/null +++ b/testsprite_tests/tmp/execution.lock @@ -0,0 +1,5 @@ +{ + "pid": 12068, + "startTime": "2026-03-13T22:22:24.187Z", + "functionName": "_generateCodeAndExecute" +} \ No newline at end of file diff --git a/testsprite_tests/tmp/mcp.log b/testsprite_tests/tmp/mcp.log new file mode 100644 index 0000000..a3eb07c --- /dev/null +++ b/testsprite_tests/tmp/mcp.log @@ -0,0 +1,327 @@ +{"timestamp":"2026-03-12T01:09:11.725Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-12T01:09:11.744Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-12T01:09:12.256Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-12T01:09:12.761Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-12T01:09:13.269Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-12T01:10:29.426Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-12T01:10:29.444Z","level":"info","message":"HTTP server started successfully on port 13365"} +{"timestamp":"2026-03-12T01:14:10.062Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-12T01:14:10.093Z","level":"info","message":"HTTP server started successfully on port 13690"} +{"timestamp":"2026-03-12T01:18:18.026Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-12T01:18:18.047Z","level":"info","message":"Attempting to start server (attempt 1/3)"} +{"timestamp":"2026-03-12T01:20:03.348Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-12T01:20:03.372Z","level":"info","message":"HTTP server started successfully on port 14507"} +{"timestamp":"2026-03-12T01:26:09.405Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-12T01:26:09.424Z","level":"info","message":"Attempting to start server (attempt 1/3)"} +{"timestamp":"2026-03-12T01:26:09.438Z","level":"info","message":"Attempting to start HTTP proxy (attempt 1/3)"} +{"timestamp":"2026-03-13T21:34:11.704Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-13T21:34:11.723Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:12.233Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:12.744Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:13.251Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:13.761Z","level":"info","message":"checkPortListening tcp failed, trying http fallback: 8000 localhost"} +{"timestamp":"2026-03-13T21:34:13.790Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:13.772Z","level":"error","message":"checkPortListening failed (tcp + http): 8000 localhost"} +{"timestamp":"2026-03-13T21:34:13.772Z","level":"info","message":"Attempting to start server (attempt 1/3)"} +{"timestamp":"2026-03-13T21:34:14.306Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:13.771Z","level":"error","message":"checkPortListening http check failed: http://localhost:8000/ TypeError: fetch failed"} +{"timestamp":"2026-03-13T21:34:13.775Z","level":"info","message":"HTTP server started successfully on port 58050"} +{"timestamp":"2026-03-13T21:34:14.817Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:15.328Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:15.833Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:16.345Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:16.861Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:17.373Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:17.897Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:18.405Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:18.918Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:19.427Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:19.941Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:20.445Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:20.956Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:34:21.462Z","level":"error","message":"checkPortListening tcp error: 8000 localhost\nAggregateError"} +{"timestamp":"2026-03-13T21:35:04.924Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-13T21:35:04.955Z","level":"info","message":"HTTP server started successfully on port 58334"} +{"timestamp":"2026-03-13T21:37:41.137Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-13T21:37:41.159Z","level":"info","message":"HTTP server started successfully on port 58710"} +{"timestamp":"2026-03-13T22:22:24.189Z","level":"debug","message":"Generate code and execute params","context":{"projectName":"试验","projectPath":"d:\\testsprite\\试验","testIds":[],"serverMode":"development"}} +{"timestamp":"2026-03-13T22:22:24.206Z","level":"info","message":"Attempting to start server (attempt 1/3)"} +{"timestamp":"2026-03-13T22:22:24.220Z","level":"info","message":"Attempting to start HTTP proxy (attempt 1/3)"} +{"timestamp":"2026-03-13T22:22:24.207Z","level":"info","message":"HTTP server started successfully on port 5187"} +{"timestamp":"2026-03-13T22:22:24.221Z","level":"info","message":"HTTP proxy started successfully on port 5190"} +{"timestamp":"2026-03-13T22:22:24.660Z","level":"info","message":"📋 Config: localhost:5190 -> tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:24.661Z","level":"info","message":"⏱️ Network timeout: 10000ms, Auth: enabled"} +{"timestamp":"2026-03-13T22:22:24.879Z","level":"info","message":"Verifying tunnel connectivity..."} +{"timestamp":"2026-03-13T22:22:24.660Z","level":"info","message":"🚀 Starting tunnel client: 0a6cea99-305c-4943-b9d3-6fd87d6a0a59"} +{"timestamp":"2026-03-13T22:22:24.879Z","level":"info","message":"proxy: [REDACTED]tun.testsprite.com:8080"} +{"timestamp":"2026-03-13T22:22:25.087Z","level":"info","message":"✅ Tunnel established, remote port: 8080"} +{"timestamp":"2026-03-13T22:22:24.879Z","level":"info","message":"Tunnel probe attempt 1/2 through tun.testsprite.com:8080 to http://localhost:8000..."} +{"timestamp":"2026-03-13T22:22:25.254Z","level":"info","message":"🔄 [1] New connection request: 5065d315-d807-491b-8043-5ac8f97d81ae"} +{"timestamp":"2026-03-13T22:22:25.254Z","level":"info","message":"TIME: connection-1"} +{"timestamp":"2026-03-13T22:22:24.661Z","level":"info","message":"🔗 Establishing control connection..."} +{"timestamp":"2026-03-13T22:22:25.364Z","level":"info","message":"✅ [1] Remote connection established"} +{"timestamp":"2026-03-13T22:22:25.366Z","level":"info","message":"👋 [1] Hello message sent"} +{"timestamp":"2026-03-13T22:22:25.255Z","level":"info","message":"TIME: remote-connect-1"} +{"timestamp":"2026-03-13T22:22:25.363Z","level":"info","message":"TIME END: remote-connect-1"} +{"timestamp":"2026-03-13T22:22:25.366Z","level":"info","message":"🤝 [1] Starting handshake"} +{"timestamp":"2026-03-13T22:22:25.766Z","level":"info","message":"Tunnel connectivity verified."} +{"timestamp":"2026-03-13T22:22:25.255Z","level":"info","message":"🌐 [1] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:25.255Z","level":"info","message":"🔗 [1] Starting handleConnection for 5065d315-d807-491b-8043-5ac8f97d81ae"} +{"timestamp":"2026-03-13T22:22:25.364Z","level":"info","message":"📡 [1] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:25.364Z","level":"info","message":"local-connect-1"} +{"timestamp":"2026-03-13T22:22:25.766Z","level":"info","message":"Tunnel probe succeeded on attempt 1: status=200"} +{"timestamp":"2026-03-13T22:22:25.366Z","level":"info","message":"🔐 [1] Performing authentication"} +{"timestamp":"2026-03-13T22:22:25.452Z","level":"info","message":"✅ [1] Authentication completed"} +{"timestamp":"2026-03-13T22:22:25.366Z","level":"info","message":"✅ [1] Local connection established"} +{"timestamp":"2026-03-13T22:22:25.453Z","level":"info","message":"🔄 [1] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:25.365Z","level":"info","message":"TIME END: local-connect-1"} +{"timestamp":"2026-03-13T22:22:25.452Z","level":"info","message":"✋ [1] Sending Accept message for 5065d315-d807-491b-8043-5ac8f97d81ae"} +{"timestamp":"2026-03-13T22:22:25.453Z","level":"info","message":"✅ [1] Connection 5065d315-d807-491b-8043-5ac8f97d81ae fully established and proxying"} +{"timestamp":"2026-03-13T22:22:25.453Z","level":"info","message":"TIME END: connection-1"} +{"timestamp":"2026-03-13T22:22:36.634Z","level":"info","message":"🔄 [2] New connection request: dc02824e-1ba8-427f-a22b-f4b67b518b49"} +{"timestamp":"2026-03-13T22:22:36.716Z","level":"info","message":"TIME END: remote-connect-2"} +{"timestamp":"2026-03-13T22:22:36.635Z","level":"info","message":"TIME: remote-connect-2"} +{"timestamp":"2026-03-13T22:22:36.817Z","level":"info","message":"✅ [2] Authentication completed"} +{"timestamp":"2026-03-13T22:22:36.818Z","level":"info","message":"🔄 [2] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:36.635Z","level":"info","message":"TIME: connection-2"} +{"timestamp":"2026-03-13T22:22:36.717Z","level":"info","message":"📡 [2] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:36.818Z","level":"info","message":"TIME END: connection-2"} +{"timestamp":"2026-03-13T22:22:37.158Z","level":"info","message":"TIME: connection-3"} +{"timestamp":"2026-03-13T22:22:37.247Z","level":"info","message":"📡 [3] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:37.158Z","level":"info","message":"TIME: remote-connect-3"} +{"timestamp":"2026-03-13T22:22:37.327Z","level":"info","message":"✅ [3] Authentication completed"} +{"timestamp":"2026-03-13T22:22:37.248Z","level":"info","message":"local-connect-3"} +{"timestamp":"2026-03-13T22:22:36.635Z","level":"info","message":"🌐 [2] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:36.635Z","level":"info","message":"🔗 [2] Starting handleConnection for dc02824e-1ba8-427f-a22b-f4b67b518b49"} +{"timestamp":"2026-03-13T22:22:36.717Z","level":"info","message":"✅ [2] Remote connection established"} +{"timestamp":"2026-03-13T22:22:36.720Z","level":"info","message":"TIME END: local-connect-2"} +{"timestamp":"2026-03-13T22:22:36.718Z","level":"info","message":"local-connect-2"} +{"timestamp":"2026-03-13T22:22:36.720Z","level":"info","message":"✅ [2] Local connection established"} +{"timestamp":"2026-03-13T22:22:36.720Z","level":"info","message":"🔐 [2] Performing authentication"} +{"timestamp":"2026-03-13T22:22:36.720Z","level":"info","message":"👋 [2] Hello message sent"} +{"timestamp":"2026-03-13T22:22:36.720Z","level":"info","message":"🤝 [2] Starting handshake"} +{"timestamp":"2026-03-13T22:22:37.157Z","level":"info","message":"🔄 [3] New connection request: 438cb4e7-a874-4e4a-80c8-8e52e541382d"} +{"timestamp":"2026-03-13T22:22:36.818Z","level":"info","message":"✅ [2] Connection dc02824e-1ba8-427f-a22b-f4b67b518b49 fully established and proxying"} +{"timestamp":"2026-03-13T22:22:36.817Z","level":"info","message":"✋ [2] Sending Accept message for dc02824e-1ba8-427f-a22b-f4b67b518b49"} +{"timestamp":"2026-03-13T22:22:37.251Z","level":"info","message":"🤝 [3] Starting handshake"} +{"timestamp":"2026-03-13T22:22:37.621Z","level":"info","message":"🔄 [4] New connection request: e8ec9d0d-6fa1-438f-8c29-29f670533f82"} +{"timestamp":"2026-03-13T22:22:37.328Z","level":"info","message":"🔄 [3] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:37.707Z","level":"info","message":"TIME END: remote-connect-4"} +{"timestamp":"2026-03-13T22:22:37.622Z","level":"info","message":"🌐 [4] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:37.791Z","level":"info","message":"✅ [4] Authentication completed"} +{"timestamp":"2026-03-13T22:22:37.707Z","level":"info","message":"📡 [4] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:37.158Z","level":"info","message":"🔗 [3] Starting handleConnection for 438cb4e7-a874-4e4a-80c8-8e52e541382d"} +{"timestamp":"2026-03-13T22:22:37.158Z","level":"info","message":"🌐 [3] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:37.792Z","level":"info","message":"✅ [4] Connection e8ec9d0d-6fa1-438f-8c29-29f670533f82 fully established and proxying"} +{"timestamp":"2026-03-13T22:22:37.622Z","level":"info","message":"🔗 [4] Starting handleConnection for e8ec9d0d-6fa1-438f-8c29-29f670533f82"} +{"timestamp":"2026-03-13T22:22:37.250Z","level":"info","message":"✅ [3] Local connection established"} +{"timestamp":"2026-03-13T22:22:37.247Z","level":"info","message":"✅ [3] Remote connection established"} +{"timestamp":"2026-03-13T22:22:37.247Z","level":"info","message":"TIME END: remote-connect-3"} +{"timestamp":"2026-03-13T22:22:37.251Z","level":"info","message":"🔐 [3] Performing authentication"} +{"timestamp":"2026-03-13T22:22:37.250Z","level":"info","message":"TIME END: local-connect-3"} +{"timestamp":"2026-03-13T22:22:37.251Z","level":"info","message":"👋 [3] Hello message sent"} +{"timestamp":"2026-03-13T22:22:37.707Z","level":"info","message":"✅ [4] Remote connection established"} +{"timestamp":"2026-03-13T22:22:37.329Z","level":"info","message":"TIME END: connection-3"} +{"timestamp":"2026-03-13T22:22:37.328Z","level":"info","message":"✅ [3] Connection 438cb4e7-a874-4e4a-80c8-8e52e541382d fully established and proxying"} +{"timestamp":"2026-03-13T22:22:37.328Z","level":"info","message":"✋ [3] Sending Accept message for 438cb4e7-a874-4e4a-80c8-8e52e541382d"} +{"timestamp":"2026-03-13T22:22:37.791Z","level":"info","message":"✋ [4] Sending Accept message for e8ec9d0d-6fa1-438f-8c29-29f670533f82"} +{"timestamp":"2026-03-13T22:22:38.172Z","level":"info","message":"TIME: connection-5"} +{"timestamp":"2026-03-13T22:22:38.257Z","level":"info","message":"📡 [5] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:38.172Z","level":"info","message":"🔗 [5] Starting handleConnection for cb885ff7-ba5b-47b4-bec8-dda01a2a2c54"} +{"timestamp":"2026-03-13T22:22:38.338Z","level":"info","message":"✋ [5] Sending Accept message for cb885ff7-ba5b-47b4-bec8-dda01a2a2c54"} +{"timestamp":"2026-03-13T22:22:37.622Z","level":"info","message":"TIME: connection-4"} +{"timestamp":"2026-03-13T22:22:37.623Z","level":"info","message":"TIME: remote-connect-4"} +{"timestamp":"2026-03-13T22:22:37.707Z","level":"info","message":"local-connect-4"} +{"timestamp":"2026-03-13T22:22:37.709Z","level":"info","message":"🔐 [4] Performing authentication"} +{"timestamp":"2026-03-13T22:22:37.708Z","level":"info","message":"TIME END: local-connect-4"} +{"timestamp":"2026-03-13T22:22:37.709Z","level":"info","message":"👋 [4] Hello message sent"} +{"timestamp":"2026-03-13T22:22:37.709Z","level":"info","message":"✅ [4] Local connection established"} +{"timestamp":"2026-03-13T22:22:37.709Z","level":"info","message":"🤝 [4] Starting handshake"} +{"timestamp":"2026-03-13T22:22:38.337Z","level":"info","message":"✅ [5] Authentication completed"} +{"timestamp":"2026-03-13T22:22:38.172Z","level":"info","message":"🌐 [5] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:37.791Z","level":"info","message":"🔄 [4] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:37.792Z","level":"info","message":"TIME END: connection-4"} +{"timestamp":"2026-03-13T22:22:38.257Z","level":"info","message":"local-connect-5"} +{"timestamp":"2026-03-13T22:22:38.637Z","level":"info","message":"TIME: connection-6"} +{"timestamp":"2026-03-13T22:22:38.338Z","level":"info","message":"✅ [5] Connection cb885ff7-ba5b-47b4-bec8-dda01a2a2c54 fully established and proxying"} +{"timestamp":"2026-03-13T22:22:38.730Z","level":"info","message":"local-connect-6"} +{"timestamp":"2026-03-13T22:22:38.637Z","level":"info","message":"🔗 [6] Starting handleConnection for 9d8c8323-8ef7-4cdc-8182-7cad1165728c"} +{"timestamp":"2026-03-13T22:22:38.810Z","level":"info","message":"✅ [6] Authentication completed"} +{"timestamp":"2026-03-13T22:22:38.730Z","level":"info","message":"📡 [6] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:38.172Z","level":"info","message":"TIME: remote-connect-5"} +{"timestamp":"2026-03-13T22:22:38.172Z","level":"info","message":"🔄 [5] New connection request: cb885ff7-ba5b-47b4-bec8-dda01a2a2c54"} +{"timestamp":"2026-03-13T22:22:38.811Z","level":"info","message":"✅ [6] Connection 9d8c8323-8ef7-4cdc-8182-7cad1165728c fully established and proxying"} +{"timestamp":"2026-03-13T22:22:38.638Z","level":"info","message":"🌐 [6] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:38.258Z","level":"info","message":"🤝 [5] Starting handshake"} +{"timestamp":"2026-03-13T22:22:38.257Z","level":"info","message":"✅ [5] Remote connection established"} +{"timestamp":"2026-03-13T22:22:38.258Z","level":"info","message":"👋 [5] Hello message sent"} +{"timestamp":"2026-03-13T22:22:38.258Z","level":"info","message":"🔐 [5] Performing authentication"} +{"timestamp":"2026-03-13T22:22:38.258Z","level":"info","message":"✅ [5] Local connection established"} +{"timestamp":"2026-03-13T22:22:38.257Z","level":"info","message":"TIME END: remote-connect-5"} +{"timestamp":"2026-03-13T22:22:38.258Z","level":"info","message":"TIME END: local-connect-5"} +{"timestamp":"2026-03-13T22:22:38.729Z","level":"info","message":"TIME END: remote-connect-6"} +{"timestamp":"2026-03-13T22:22:38.338Z","level":"info","message":"🔄 [5] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:38.339Z","level":"info","message":"TIME END: connection-5"} +{"timestamp":"2026-03-13T22:22:38.811Z","level":"info","message":"🔄 [6] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:38.638Z","level":"info","message":"TIME: remote-connect-6"} +{"timestamp":"2026-03-13T22:22:38.637Z","level":"info","message":"🔄 [6] New connection request: 9d8c8323-8ef7-4cdc-8182-7cad1165728c"} +{"timestamp":"2026-03-13T22:22:38.731Z","level":"info","message":"✅ [6] Local connection established"} +{"timestamp":"2026-03-13T22:22:38.732Z","level":"info","message":"👋 [6] Hello message sent"} +{"timestamp":"2026-03-13T22:22:38.732Z","level":"info","message":"🔐 [6] Performing authentication"} +{"timestamp":"2026-03-13T22:22:38.731Z","level":"info","message":"🤝 [6] Starting handshake"} +{"timestamp":"2026-03-13T22:22:38.731Z","level":"info","message":"TIME END: local-connect-6"} +{"timestamp":"2026-03-13T22:22:38.730Z","level":"info","message":"✅ [6] Remote connection established"} +{"timestamp":"2026-03-13T22:22:38.811Z","level":"info","message":"TIME END: connection-6"} +{"timestamp":"2026-03-13T22:22:38.810Z","level":"info","message":"✋ [6] Sending Accept message for 9d8c8323-8ef7-4cdc-8182-7cad1165728c"} +{"timestamp":"2026-03-13T22:22:46.433Z","level":"info","message":"🔄 [7] New connection request: fbcf6f3d-4910-4b0d-adfc-e1c0c47c4d6a"} +{"timestamp":"2026-03-13T22:22:46.434Z","level":"info","message":"TIME: connection-7"} +{"timestamp":"2026-03-13T22:22:46.566Z","level":"info","message":"TIME END: remote-connect-7"} +{"timestamp":"2026-03-13T22:22:46.567Z","level":"info","message":"📡 [7] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:46.706Z","level":"info","message":"✅ [7] Authentication completed"} +{"timestamp":"2026-03-13T22:22:46.434Z","level":"info","message":"TIME: remote-connect-7"} +{"timestamp":"2026-03-13T22:22:46.706Z","level":"info","message":"✅ [7] Connection fbcf6f3d-4910-4b0d-adfc-e1c0c47c4d6a fully established and proxying"} +{"timestamp":"2026-03-13T22:22:46.567Z","level":"info","message":"local-connect-7"} +{"timestamp":"2026-03-13T22:22:46.706Z","level":"info","message":"🔄 [7] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:46.434Z","level":"info","message":"🌐 [7] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:46.434Z","level":"info","message":"🔗 [7] Starting handleConnection for fbcf6f3d-4910-4b0d-adfc-e1c0c47c4d6a"} +{"timestamp":"2026-03-13T22:22:46.569Z","level":"info","message":"TIME END: local-connect-7"} +{"timestamp":"2026-03-13T22:22:46.569Z","level":"info","message":"🔐 [7] Performing authentication"} +{"timestamp":"2026-03-13T22:22:46.569Z","level":"info","message":"👋 [7] Hello message sent"} +{"timestamp":"2026-03-13T22:22:46.569Z","level":"info","message":"✅ [7] Local connection established"} +{"timestamp":"2026-03-13T22:22:46.569Z","level":"info","message":"🤝 [7] Starting handshake"} +{"timestamp":"2026-03-13T22:22:46.567Z","level":"info","message":"✅ [7] Remote connection established"} +{"timestamp":"2026-03-13T22:22:47.370Z","level":"info","message":"🔄 [8] New connection request: 2c6619d0-e1e4-4235-b561-91f082e5def5"} +{"timestamp":"2026-03-13T22:22:46.706Z","level":"info","message":"TIME END: connection-7"} +{"timestamp":"2026-03-13T22:22:46.706Z","level":"info","message":"✋ [7] Sending Accept message for fbcf6f3d-4910-4b0d-adfc-e1c0c47c4d6a"} +{"timestamp":"2026-03-13T22:22:47.452Z","level":"info","message":"TIME END: remote-connect-8"} +{"timestamp":"2026-03-13T22:22:47.371Z","level":"info","message":"TIME: connection-8"} +{"timestamp":"2026-03-13T22:22:47.531Z","level":"info","message":"✋ [8] Sending Accept message for 2c6619d0-e1e4-4235-b561-91f082e5def5"} +{"timestamp":"2026-03-13T22:22:47.452Z","level":"info","message":"📡 [8] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:47.531Z","level":"info","message":"✅ [8] Authentication completed"} +{"timestamp":"2026-03-13T22:22:47.371Z","level":"info","message":"🌐 [8] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:47.452Z","level":"info","message":"local-connect-8"} +{"timestamp":"2026-03-13T22:22:47.532Z","level":"info","message":"TIME END: connection-8"} +{"timestamp":"2026-03-13T22:22:47.896Z","level":"info","message":"🔄 [9] New connection request: d1cedf53-6a6b-4d74-9630-ddeb413da29c"} +{"timestamp":"2026-03-13T22:22:47.897Z","level":"info","message":"🔗 [9] Starting handleConnection for d1cedf53-6a6b-4d74-9630-ddeb413da29c"} +{"timestamp":"2026-03-13T22:22:48.020Z","level":"info","message":"TIME END: remote-connect-9"} +{"timestamp":"2026-03-13T22:22:47.371Z","level":"info","message":"🔗 [8] Starting handleConnection for 2c6619d0-e1e4-4235-b561-91f082e5def5"} +{"timestamp":"2026-03-13T22:22:47.372Z","level":"info","message":"TIME: remote-connect-8"} +{"timestamp":"2026-03-13T22:22:48.117Z","level":"info","message":"✅ [9] Authentication completed"} +{"timestamp":"2026-03-13T22:22:48.021Z","level":"info","message":"📡 [9] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:47.454Z","level":"info","message":"TIME END: local-connect-8"} +{"timestamp":"2026-03-13T22:22:47.455Z","level":"info","message":"🔐 [8] Performing authentication"} +{"timestamp":"2026-03-13T22:22:47.452Z","level":"info","message":"✅ [8] Remote connection established"} +{"timestamp":"2026-03-13T22:22:47.454Z","level":"info","message":"🤝 [8] Starting handshake"} +{"timestamp":"2026-03-13T22:22:47.454Z","level":"info","message":"✅ [8] Local connection established"} +{"timestamp":"2026-03-13T22:22:47.454Z","level":"info","message":"👋 [8] Hello message sent"} +{"timestamp":"2026-03-13T22:22:47.897Z","level":"info","message":"🌐 [9] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:48.118Z","level":"info","message":"✅ [9] Connection d1cedf53-6a6b-4d74-9630-ddeb413da29c fully established and proxying"} +{"timestamp":"2026-03-13T22:22:47.532Z","level":"info","message":"🔄 [8] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:47.532Z","level":"info","message":"✅ [8] Connection 2c6619d0-e1e4-4235-b561-91f082e5def5 fully established and proxying"} +{"timestamp":"2026-03-13T22:22:48.020Z","level":"info","message":"✅ [9] Remote connection established"} +{"timestamp":"2026-03-13T22:22:48.117Z","level":"info","message":"✋ [9] Sending Accept message for d1cedf53-6a6b-4d74-9630-ddeb413da29c"} +{"timestamp":"2026-03-13T22:22:47.896Z","level":"info","message":"TIME: connection-9"} +{"timestamp":"2026-03-13T22:22:47.897Z","level":"info","message":"TIME: remote-connect-9"} +{"timestamp":"2026-03-13T22:22:48.022Z","level":"info","message":"🤝 [9] Starting handshake"} +{"timestamp":"2026-03-13T22:22:48.021Z","level":"info","message":"local-connect-9"} +{"timestamp":"2026-03-13T22:22:48.022Z","level":"info","message":"TIME END: local-connect-9"} +{"timestamp":"2026-03-13T22:22:48.023Z","level":"info","message":"👋 [9] Hello message sent"} +{"timestamp":"2026-03-13T22:22:48.022Z","level":"info","message":"✅ [9] Local connection established"} +{"timestamp":"2026-03-13T22:22:48.023Z","level":"info","message":"🔐 [9] Performing authentication"} +{"timestamp":"2026-03-13T22:22:48.118Z","level":"info","message":"🔄 [9] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:48.118Z","level":"info","message":"TIME END: connection-9"} +{"timestamp":"2026-03-13T22:22:54.036Z","level":"info","message":"🔄 [10] New connection request: 6322b594-25d0-4d9f-a88c-fce5bcfc186a"} +{"timestamp":"2026-03-13T22:22:54.119Z","level":"info","message":"✅ [10] Remote connection established"} +{"timestamp":"2026-03-13T22:22:54.036Z","level":"info","message":"TIME: connection-10"} +{"timestamp":"2026-03-13T22:22:54.206Z","level":"info","message":"✅ [10] Authentication completed"} +{"timestamp":"2026-03-13T22:22:54.119Z","level":"info","message":"📡 [10] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:54.207Z","level":"info","message":"🔄 [10] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:54.037Z","level":"info","message":"🌐 [10] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:54.119Z","level":"info","message":"TIME END: remote-connect-10"} +{"timestamp":"2026-03-13T22:22:54.207Z","level":"info","message":"TIME END: connection-10"} +{"timestamp":"2026-03-13T22:22:54.037Z","level":"info","message":"TIME: remote-connect-10"} +{"timestamp":"2026-03-13T22:22:54.037Z","level":"info","message":"🔗 [10] Starting handleConnection for 6322b594-25d0-4d9f-a88c-fce5bcfc186a"} +{"timestamp":"2026-03-13T22:22:54.119Z","level":"info","message":"local-connect-10"} +{"timestamp":"2026-03-13T22:22:54.121Z","level":"info","message":"TIME END: local-connect-10"} +{"timestamp":"2026-03-13T22:22:54.122Z","level":"info","message":"🤝 [10] Starting handshake"} +{"timestamp":"2026-03-13T22:22:54.122Z","level":"info","message":"👋 [10] Hello message sent"} +{"timestamp":"2026-03-13T22:22:54.122Z","level":"info","message":"🔐 [10] Performing authentication"} +{"timestamp":"2026-03-13T22:22:54.121Z","level":"info","message":"✅ [10] Local connection established"} +{"timestamp":"2026-03-13T22:22:54.206Z","level":"info","message":"✋ [10] Sending Accept message for 6322b594-25d0-4d9f-a88c-fce5bcfc186a"} +{"timestamp":"2026-03-13T22:22:54.207Z","level":"info","message":"✅ [10] Connection 6322b594-25d0-4d9f-a88c-fce5bcfc186a fully established and proxying"} +{"timestamp":"2026-03-13T22:22:55.615Z","level":"info","message":"TIME: connection-11"} +{"timestamp":"2026-03-13T22:22:55.615Z","level":"info","message":"🌐 [11] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:55.775Z","level":"info","message":"✅ [11] Remote connection established"} +{"timestamp":"2026-03-13T22:22:55.776Z","level":"info","message":"📡 [11] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:55.614Z","level":"info","message":"🔄 [11] New connection request: 63a22910-2e01-41d9-b31c-38f1556082de"} +{"timestamp":"2026-03-13T22:22:55.934Z","level":"info","message":"✅ [11] Authentication completed"} +{"timestamp":"2026-03-13T22:22:55.934Z","level":"info","message":"✋ [11] Sending Accept message for 63a22910-2e01-41d9-b31c-38f1556082de"} +{"timestamp":"2026-03-13T22:22:55.777Z","level":"info","message":"TIME END: local-connect-11"} +{"timestamp":"2026-03-13T22:22:55.934Z","level":"info","message":"TIME END: connection-11"} +{"timestamp":"2026-03-13T22:22:55.615Z","level":"info","message":"TIME: remote-connect-11"} +{"timestamp":"2026-03-13T22:22:55.615Z","level":"info","message":"🔗 [11] Starting handleConnection for 63a22910-2e01-41d9-b31c-38f1556082de"} +{"timestamp":"2026-03-13T22:22:55.777Z","level":"info","message":"🤝 [11] Starting handshake"} +{"timestamp":"2026-03-13T22:22:55.776Z","level":"info","message":"local-connect-11"} +{"timestamp":"2026-03-13T22:22:55.777Z","level":"info","message":"✅ [11] Local connection established"} +{"timestamp":"2026-03-13T22:22:55.775Z","level":"info","message":"TIME END: remote-connect-11"} +{"timestamp":"2026-03-13T22:22:55.778Z","level":"info","message":"🔐 [11] Performing authentication"} +{"timestamp":"2026-03-13T22:22:55.778Z","level":"info","message":"👋 [11] Hello message sent"} +{"timestamp":"2026-03-13T22:22:55.934Z","level":"info","message":"✅ [11] Connection 63a22910-2e01-41d9-b31c-38f1556082de fully established and proxying"} +{"timestamp":"2026-03-13T22:22:55.934Z","level":"info","message":"🔄 [11] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:56.788Z","level":"info","message":"🔄 [12] New connection request: 53fbbe43-f47e-4cf1-9b1b-71dafe9424d1"} +{"timestamp":"2026-03-13T22:22:56.788Z","level":"info","message":"TIME: connection-12"} +{"timestamp":"2026-03-13T22:22:56.893Z","level":"info","message":"🔐 [12] Performing authentication"} +{"timestamp":"2026-03-13T22:22:56.989Z","level":"info","message":"✅ [12] Authentication completed"} +{"timestamp":"2026-03-13T22:22:56.788Z","level":"info","message":"TIME: remote-connect-12"} +{"timestamp":"2026-03-13T22:22:56.890Z","level":"info","message":"TIME END: remote-connect-12"} +{"timestamp":"2026-03-13T22:22:56.990Z","level":"info","message":"✋ [12] Sending Accept message for 53fbbe43-f47e-4cf1-9b1b-71dafe9424d1"} +{"timestamp":"2026-03-13T22:22:56.788Z","level":"info","message":"🌐 [12] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:56.788Z","level":"info","message":"🔗 [12] Starting handleConnection for 53fbbe43-f47e-4cf1-9b1b-71dafe9424d1"} +{"timestamp":"2026-03-13T22:22:56.890Z","level":"info","message":"✅ [12] Remote connection established"} +{"timestamp":"2026-03-13T22:22:56.892Z","level":"info","message":"✅ [12] Local connection established"} +{"timestamp":"2026-03-13T22:22:56.890Z","level":"info","message":"local-connect-12"} +{"timestamp":"2026-03-13T22:22:56.890Z","level":"info","message":"📡 [12] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:56.892Z","level":"info","message":"TIME END: local-connect-12"} +{"timestamp":"2026-03-13T22:22:56.892Z","level":"info","message":"👋 [12] Hello message sent"} +{"timestamp":"2026-03-13T22:22:56.892Z","level":"info","message":"🤝 [12] Starting handshake"} +{"timestamp":"2026-03-13T22:22:57.614Z","level":"info","message":"TIME: connection-13"} +{"timestamp":"2026-03-13T22:22:56.991Z","level":"info","message":"✅ [12] Connection 53fbbe43-f47e-4cf1-9b1b-71dafe9424d1 fully established and proxying"} +{"timestamp":"2026-03-13T22:22:56.991Z","level":"info","message":"TIME END: connection-12"} +{"timestamp":"2026-03-13T22:22:56.990Z","level":"info","message":"🔄 [12] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:57.614Z","level":"info","message":"🔄 [13] New connection request: 7e3791b7-0b4b-43cd-9860-55534da50c7b"} +{"timestamp":"2026-03-13T22:22:57.614Z","level":"info","message":"TIME: remote-connect-13"} +{"timestamp":"2026-03-13T22:22:58.052Z","level":"info","message":"TIME END: remote-connect-13"} +{"timestamp":"2026-03-13T22:22:58.137Z","level":"info","message":"✋ [13] Sending Accept message for 7e3791b7-0b4b-43cd-9860-55534da50c7b"} +{"timestamp":"2026-03-13T22:22:58.053Z","level":"info","message":"local-connect-13"} +{"timestamp":"2026-03-13T22:22:58.138Z","level":"info","message":"🔄 [13] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:57.614Z","level":"info","message":"🌐 [13] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:57.614Z","level":"info","message":"🔗 [13] Starting handleConnection for 7e3791b7-0b4b-43cd-9860-55534da50c7b"} +{"timestamp":"2026-03-13T22:22:58.053Z","level":"info","message":"✅ [13] Remote connection established"} +{"timestamp":"2026-03-13T22:22:58.138Z","level":"info","message":"✅ [13] Connection 7e3791b7-0b4b-43cd-9860-55534da50c7b fully established and proxying"} +{"timestamp":"2026-03-13T22:22:58.769Z","level":"info","message":"🔄 [14] New connection request: fc05ae0f-91f6-44ef-a551-1ab2c523a026"} +{"timestamp":"2026-03-13T22:22:58.055Z","level":"info","message":"👋 [13] Hello message sent"} +{"timestamp":"2026-03-13T22:22:58.053Z","level":"info","message":"📡 [13] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:58.055Z","level":"info","message":"🔐 [13] Performing authentication"} +{"timestamp":"2026-03-13T22:22:58.055Z","level":"info","message":"🤝 [13] Starting handshake"} +{"timestamp":"2026-03-13T22:22:58.055Z","level":"info","message":"✅ [13] Local connection established"} +{"timestamp":"2026-03-13T22:22:58.054Z","level":"info","message":"TIME END: local-connect-13"} +{"timestamp":"2026-03-13T22:22:58.852Z","level":"info","message":"TIME END: remote-connect-14"} +{"timestamp":"2026-03-13T22:22:58.137Z","level":"info","message":"✅ [13] Authentication completed"} +{"timestamp":"2026-03-13T22:22:58.138Z","level":"info","message":"TIME END: connection-13"} +{"timestamp":"2026-03-13T22:22:58.951Z","level":"info","message":"✅ [14] Authentication completed"} +{"timestamp":"2026-03-13T22:22:58.852Z","level":"info","message":"📡 [14] Connecting to local localhost:5190"} +{"timestamp":"2026-03-13T22:22:58.951Z","level":"info","message":"🔄 [14] Starting data proxy"} +{"timestamp":"2026-03-13T22:22:58.769Z","level":"info","message":"🌐 [14] Connecting to remote tun.testsprite.com:7300"} +{"timestamp":"2026-03-13T22:22:58.852Z","level":"info","message":"✅ [14] Remote connection established"} +{"timestamp":"2026-03-13T22:22:58.951Z","level":"info","message":"✅ [14] Connection fc05ae0f-91f6-44ef-a551-1ab2c523a026 fully established and proxying"} +{"timestamp":"2026-03-13T22:22:58.769Z","level":"info","message":"TIME: connection-14"} +{"timestamp":"2026-03-13T22:22:58.769Z","level":"info","message":"🔗 [14] Starting handleConnection for fc05ae0f-91f6-44ef-a551-1ab2c523a026"} +{"timestamp":"2026-03-13T22:22:58.769Z","level":"info","message":"TIME: remote-connect-14"} +{"timestamp":"2026-03-13T22:22:58.853Z","level":"info","message":"✅ [14] Local connection established"} +{"timestamp":"2026-03-13T22:22:58.853Z","level":"info","message":"TIME END: local-connect-14"} +{"timestamp":"2026-03-13T22:22:58.853Z","level":"info","message":"🤝 [14] Starting handshake"} +{"timestamp":"2026-03-13T22:22:58.853Z","level":"info","message":"👋 [14] Hello message sent"} +{"timestamp":"2026-03-13T22:22:58.852Z","level":"info","message":"local-connect-14"} +{"timestamp":"2026-03-13T22:22:58.854Z","level":"info","message":"🔐 [14] Performing authentication"} +{"timestamp":"2026-03-13T22:22:58.951Z","level":"info","message":"✋ [14] Sending Accept message for fc05ae0f-91f6-44ef-a551-1ab2c523a026"} +{"timestamp":"2026-03-13T22:22:58.951Z","level":"info","message":"TIME END: connection-14"} diff --git a/testsprite_tests/tmp/prd_files/PRODUCT_SPECIFICATION.md b/testsprite_tests/tmp/prd_files/PRODUCT_SPECIFICATION.md new file mode 100644 index 0000000..c185ec6 --- /dev/null +++ b/testsprite_tests/tmp/prd_files/PRODUCT_SPECIFICATION.md @@ -0,0 +1,152 @@ +# Product Specification Document + +## 1. Product Overview + +**Product Name:** Demo Full-Stack Application (API + Web Client) + +**Purpose:** +A full-stack demo application consisting of a REST API backend and a single-page web frontend. The product provides user management, authentication, product catalog with categories, and order management. It is intended as a reference implementation, learning base, or starting point for building similar business applications. + +**Scope:** +- Backend: REST API (FastAPI, async, SQLite/DB). +- Frontend: SPA (React, TypeScript, Vite) consuming the API with login-protected and public areas. + +--- + +## 2. Target Users + +- **End users:** Register, log in, browse products, view product details, manage profile, and place/view orders. +- **Administrators:** Same as end users, plus manage users, categories, products, and view/update all orders. + +--- + +## 3. Features & How They Work + +### 3.1 Authentication + +- **Registration** + - User can register with email, username, password, and optional full name. + - Email and username must be unique. + - After successful registration, user is redirected to login. + +- **Login** + - User logs in with username (or email) and password. + - On success, the server returns an access token (and optional refresh token). + - The frontend stores tokens and sends the access token in the `Authorization` header for protected requests. + - Invalid or expired token results in 401; frontend clears storage and redirects to login. + +- **Session / Token** + - Access token is used for API authorization. + - Refresh token (if implemented) can be used to obtain a new access token without re-entering password. + - Logout clears stored tokens and returns the user to the public area. + +### 3.2 User Management + +- **Profile (current user)** + - Logged-in user can view and edit their own profile (e.g. full name, bio). + - Changes are persisted via the API. + +- **User list (admin or designated role)** + - List users with pagination. + - Optional: view user details, disable/enable user, or delete user, subject to permissions (e.g. admin only or self-delete). + +### 3.3 Categories + +- **List categories** + - Display all categories (flat or tree, as designed). + - Optional: filter by parent (e.g. parent_id) for hierarchy. + +- **Create / Edit / Delete category** + - Authenticated users (or admin only) can create a category with name, slug, optional description, optional parent. + - Same users can edit (name, slug, description, parent) and delete categories. + - Slug must be unique; used in URLs or API filters. + +### 3.4 Products (Items) + +- **List products** + - Public or authenticated users can list products with pagination. + - Optional filters: category, active/inactive status. + - Each item shows at least: title, price, stock, optional image, category. + +- **Product detail** + - Anyone can open a product by ID (or slug) and see full details: title, description, price, stock, image, category. + - Logged-in user may see a “Place order” or “Add to cart” entry point. + +- **Create / Edit / Delete product** + - Authenticated users (or admin) can create products (title, slug, description, price, stock, image URL, category, active flag). + - Same users can update or delete products. + - Slug must be unique. + +### 3.5 Orders + +- **Create order** + - Logged-in user submits an order with one or more line items (item ID, quantity, unit price). + - Optional: shipping address, note. + - Backend validates items exist and stock is sufficient; reduces stock and creates order with status (e.g. pending). + - Total amount is computed from line items. + +- **List orders** + - Logged-in user sees only their own orders, with pagination and optional status filter. + - Admin sees all orders (optional “admin view” toggle) with same filters. + +- **Order detail** + - User (or admin) opens an order by ID and sees: status, total, line items (item, quantity, unit price, subtotal), shipping address, note, timestamps. + - User (or admin) can update order status (e.g. pending → paid → shipped → completed, or cancelled) where allowed by business rules. + +### 3.6 General Behavior + +- **Authorization** + - Endpoints that modify data or access private resources require a valid access token. + - Some endpoints (e.g. list products, product detail) may be public. + - Admin-only actions (e.g. list all users, list all orders) are restricted by role (e.g. is_superuser). + +- **Errors** + - API returns appropriate HTTP status codes (400, 401, 403, 404, 422, 500). + - Validation errors (e.g. invalid body) return 422 with error details. + - Frontend shows user-friendly messages where applicable (e.g. “Login failed”, “Product not found”). + +- **Navigation & routing** + - Frontend has distinct routes for: home, login, register, dashboard, users, categories, products list, product detail, orders list, order detail, profile. + - Protected routes redirect unauthenticated users to login. + - 404 page is shown for unknown paths. + +--- + +## 4. Non-Functional Expectations + +- **API** + - REST over HTTP/HTTPS. + - JSON request/response. + - CORS configured to allow the frontend origin (e.g. localhost:3000). + - Health check endpoint (e.g. `/api/v1/health` or `/health`) for availability checks. + +- **Frontend** + - Responsive layout; usable on desktop and small screens. + - Forms validate required fields and show errors. + - Loading states and basic error handling for API calls. + +- **Data** + - Persistent storage (e.g. SQLite or other DB) for users, categories, products, orders, and order lines. + - Passwords stored hashed; tokens signed (e.g. JWT). + +--- + +## 5. Out of Scope (for this spec) + +- Payment gateway integration. +- Email verification or password reset flows (can be added later). +- File upload for product images (currently URL-only). +- Real-time notifications or websockets. +- Mobile native apps. + +--- + +## 6. Summary + +The product is a **demo e-commerce–style application** with: + +- **Purpose:** Demonstrate and implement user auth, product catalog, categories, and orders end-to-end. +- **Features:** Registration, login, profile, user list, category CRUD, product CRUD and listing, order creation and management, with role-based access where applicable. +- **How it works:** Frontend (React SPA) talks to backend (REST API); users authenticate via tokens; data is persisted in a database; admins have extended permissions over users and orders. + +This document describes the desired features, purpose, and behavior for implementation and testing. diff --git a/testsprite_tests/tmp/raw_report.md b/testsprite_tests/tmp/raw_report.md new file mode 100644 index 0000000..3c70247 --- /dev/null +++ b/testsprite_tests/tmp/raw_report.md @@ -0,0 +1,77 @@ + +# TestSprite AI Testing Report(MCP) + +--- + +## 1️⃣ Document Metadata +- **Project Name:** 试验 +- **Date:** 2026-03-13 +- **Prepared by:** TestSprite AI Team + +--- + +## 2️⃣ Requirement Validation Summary + +#### Test TC001 Health check endpoint returns 200 and status ok +- **Test Code:** [TC001_Health_check_endpoint_returns_200_and_status_ok.py](./TC001_Health_check_endpoint_returns_200_and_status_ok.py) +- **Test Error:** Traceback (most recent call last): + File "/var/task/handler.py", line 258, in run_with_retry + exec(code, exec_env) + File "", line 27, in + File "", line 25, in test_tc001_health_check_endpoint_returns_200_and_status_ok +AssertionError: 'app' field should be a JSON object + +- **Test Visualization and Result:** https://www.testsprite.com/dashboard/mcp/tests/e84cf747-7b05-4ce1-be44-2df0fbafc8df/5cffc80f-c49d-4357-badd-d1ed0034b3dc +- **Status:** ❌ Failed +- **Analysis / Findings:** {{TODO:AI_ANALYSIS}}. +--- + +#### Test TC002 Root endpoint returns API info +- **Test Code:** [TC002_Root_endpoint_returns_API_info.py](./TC002_Root_endpoint_returns_API_info.py) +- **Test Error:** Traceback (most recent call last): + File "/var/task/handler.py", line 258, in run_with_retry + exec(code, exec_env) + File "", line 18, in + File "", line 12, in test_root_endpoint_returns_api_info +AssertionError: Response JSON does not contain 'apiPrefix' + +- **Test Visualization and Result:** https://www.testsprite.com/dashboard/mcp/tests/e84cf747-7b05-4ce1-be44-2df0fbafc8df/6635d32f-505d-4063-98c3-90ae6cca9164 +- **Status:** ❌ Failed +- **Analysis / Findings:** {{TODO:AI_ANALYSIS}}. +--- + +#### Test TC003 User registration creates user +- **Test Code:** [TC003_User_registration_creates_user.py](./TC003_User_registration_creates_user.py) +- **Test Error:** Traceback (most recent call last): + File "/var/task/handler.py", line 258, in run_with_retry + exec(code, exec_env) + File "", line 40, in + File "", line 24, in test_user_registration_creates_user +AssertionError: Expected status code 201, got 422 + +- **Test Visualization and Result:** https://www.testsprite.com/dashboard/mcp/tests/e84cf747-7b05-4ce1-be44-2df0fbafc8df/fc6ab1f5-9962-45d0-b9d4-7ef4354b7d55 +- **Status:** ❌ Failed +- **Analysis / Findings:** {{TODO:AI_ANALYSIS}}. +--- + +#### Test TC004 Login with valid credentials returns token +- **Test Code:** [TC004_Login_with_valid_credentials_returns_token.py](./TC004_Login_with_valid_credentials_returns_token.py) +- **Test Visualization and Result:** https://www.testsprite.com/dashboard/mcp/tests/e84cf747-7b05-4ce1-be44-2df0fbafc8df/a4c513f4-2d79-4e91-aa9b-e47f65f7b056 +- **Status:** ✅ Passed +- **Analysis / Findings:** {{TODO:AI_ANALYSIS}}. +--- + + +## 3️⃣ Coverage & Matching Metrics + +- **25.00** of tests passed + +| Requirement | Total Tests | ✅ Passed | ❌ Failed | +|--------------------|-------------|-----------|------------| +| ... | ... | ... | ... | +--- + + +## 4️⃣ Key Gaps / Risks +{AI_GNERATED_KET_GAPS_AND_RISKS} +--- \ No newline at end of file diff --git a/testsprite_tests/tmp/test_results.json b/testsprite_tests/tmp/test_results.json new file mode 100644 index 0000000..e474a93 --- /dev/null +++ b/testsprite_tests/tmp/test_results.json @@ -0,0 +1,62 @@ +[ + { + "projectId": "e84cf747-7b05-4ce1-be44-2df0fbafc8df", + "testId": "5cffc80f-c49d-4357-badd-d1ed0034b3dc", + "userId": "44b89488-f0d1-7074-ef33-339ff2c909bd", + "title": "TC001-Health check endpoint returns 200 and status ok", + "description": "GET /api/v1/health returns 200 with status ok and app info", + "code": "import requests\n\ndef test_tc001_health_check_endpoint_returns_200_and_status_ok():\n base_url = \"http://localhost:8000\"\n url = f\"{base_url}/api/v1/health\"\n headers = {\n \"Accept\": \"application/json\"\n }\n\n try:\n response = requests.get(url, headers=headers, timeout=30)\n response.raise_for_status()\n except requests.RequestException as e:\n assert False, f\"Request failed: {e}\"\n\n assert response.status_code == 200, f\"Expected status code 200, got {response.status_code}\"\n try:\n data = response.json()\n except ValueError:\n assert False, \"Response is not a valid JSON\"\n\n assert \"status\" in data, \"Response JSON missing 'status' field\"\n assert data[\"status\"].lower() == \"ok\", f\"Expected status 'ok', got '{data['status']}'\"\n assert \"app\" in data, \"Response JSON missing 'app' field\"\n assert isinstance(data[\"app\"], dict), \"'app' field should be a JSON object\"\n\ntest_tc001_health_check_endpoint_returns_200_and_status_ok()", + "testStatus": "FAILED", + "testError": "Traceback (most recent call last):\n File \"/var/task/handler.py\", line 258, in run_with_retry\n exec(code, exec_env)\n File \"\", line 27, in \n File \"\", line 25, in test_tc001_health_check_endpoint_returns_200_and_status_ok\nAssertionError: 'app' field should be a JSON object\n", + "testType": "BACKEND", + "createFrom": "mcp", + "priority": "High", + "created": "2026-03-13T22:22:26.348Z", + "modified": "2026-03-13T22:22:41.511Z" + }, + { + "projectId": "e84cf747-7b05-4ce1-be44-2df0fbafc8df", + "testId": "6635d32f-505d-4063-98c3-90ae6cca9164", + "userId": "44b89488-f0d1-7074-ef33-339ff2c909bd", + "title": "TC002-Root endpoint returns API info", + "description": "GET / returns 200 with app and api prefix", + "code": "import requests\n\ndef test_root_endpoint_returns_api_info():\n base_url = \"http://localhost:8000\"\n url = f\"{base_url}/\"\n try:\n response = requests.get(url, timeout=30)\n response.raise_for_status()\n data = response.json()\n assert response.status_code == 200, f\"Expected status code 200, got {response.status_code}\"\n assert \"app\" in data, \"Response JSON does not contain 'app'\"\n assert \"apiPrefix\" in data, \"Response JSON does not contain 'apiPrefix'\"\n except requests.RequestException as e:\n assert False, f\"Request to {url} failed: {e}\"\n except ValueError:\n assert False, \"Response content is not valid JSON\"\n\ntest_root_endpoint_returns_api_info()", + "testStatus": "FAILED", + "testError": "Traceback (most recent call last):\n File \"/var/task/handler.py\", line 258, in run_with_retry\n exec(code, exec_env)\n File \"\", line 18, in \n File \"\", line 12, in test_root_endpoint_returns_api_info\nAssertionError: Response JSON does not contain 'apiPrefix'\n", + "testType": "BACKEND", + "createFrom": "mcp", + "priority": "High", + "created": "2026-03-13T22:22:26.356Z", + "modified": "2026-03-13T22:22:41.056Z" + }, + { + "projectId": "e84cf747-7b05-4ce1-be44-2df0fbafc8df", + "testId": "fc6ab1f5-9962-45d0-b9d4-7ef4354b7d55", + "userId": "44b89488-f0d1-7074-ef33-339ff2c909bd", + "title": "TC003-User registration creates user", + "description": "POST /api/v1/users with valid payload returns 201 and user data", + "code": "import requests\n\nBASE_URL = \"http://localhost:8000\"\nAPI_PREFIX = \"/api/v1\"\nUSERS_ENDPOINT = f\"{BASE_URL}{API_PREFIX}/users\"\nTIMEOUT = 30\n\ndef test_user_registration_creates_user():\n # Define a valid user registration payload\n payload = {\n \"email\": \"testuser_tc003@example.com\",\n \"password\": \"StrongPassword123!\",\n \"full_name\": \"Test User\"\n }\n headers = {\n \"Content-Type\": \"application/json\"\n }\n response = None\n created_user_id = None\n\n try:\n response = requests.post(USERS_ENDPOINT, json=payload, headers=headers, timeout=TIMEOUT)\n # Assert the response status code is 201 Created\n assert response.status_code == 201, f\"Expected status code 201, got {response.status_code}\"\n response_json = response.json()\n\n # Assert the response contains expected user data\n assert \"id\" in response_json, \"Response JSON missing 'id'\"\n created_user_id = response_json[\"id\"]\n assert response_json.get(\"email\") == payload[\"email\"], \"Response email does not match\"\n\n finally:\n # Cleanup: Delete the created user if id is available (requires auth)\n if created_user_id:\n # To delete a user we need authentication, but this test case has no auth instructions,\n # so deletion cannot be performed here.\n # If auth was available, implement deletion logic here.\n pass\n\ntest_user_registration_creates_user()\n", + "testStatus": "FAILED", + "testError": "Traceback (most recent call last):\n File \"/var/task/handler.py\", line 258, in run_with_retry\n exec(code, exec_env)\n File \"\", line 40, in \n File \"\", line 24, in test_user_registration_creates_user\nAssertionError: Expected status code 201, got 422\n", + "testType": "BACKEND", + "createFrom": "mcp", + "priority": "High", + "created": "2026-03-13T22:22:26.360Z", + "modified": "2026-03-13T22:22:54.703Z" + }, + { + "projectId": "e84cf747-7b05-4ce1-be44-2df0fbafc8df", + "testId": "a4c513f4-2d79-4e91-aa9b-e47f65f7b056", + "userId": "44b89488-f0d1-7074-ef33-339ff2c909bd", + "title": "TC004-Login with valid credentials returns token", + "description": "POST /api/v1/auth/login returns 200 with access_token", + "code": "import requests\n\ndef test_login_with_valid_credentials_returns_token():\n base_url = \"http://localhost:8000\"\n login_url = f\"{base_url}/api/v1/auth/login\"\n timeout = 30\n\n # The user to test login with. For this test, we need valid credentials.\n # Since the PRD does not specify default users, we will create a new user then login.\n # Create user endpoint: POST /api/v1/users (no auth required)\n create_user_url = f\"{base_url}/api/v1/users\"\n user_payload = {\n \"username\": \"testuser_tc004\",\n \"password\": \"TestPassword123!\",\n \"full_name\": \"Test User TC004\",\n \"email\": \"testuser_tc004@example.com\"\n }\n\n try:\n # Create user\n create_resp = requests.post(create_user_url, json=user_payload, timeout=timeout)\n assert create_resp.status_code == 201, f\"User creation failed: {create_resp.text}\"\n created_user = create_resp.json()\n assert created_user.get(\"username\") == user_payload[\"username\"]\n\n # Login with the created user credentials\n login_payload = {\n \"username\": user_payload[\"username\"],\n \"password\": user_payload[\"password\"]\n }\n login_resp = requests.post(login_url, json=login_payload, timeout=timeout)\n assert login_resp.status_code == 200, f\"Login failed: {login_resp.text}\"\n login_json = login_resp.json()\n assert \"access_token\" in login_json and isinstance(login_json[\"access_token\"], str) and login_json[\"access_token\"]\n assert login_json.get(\"token_type\") == \"bearer\"\n\n finally:\n # Cleanup: Delete created user (requires auth)\n # Need to login to get token first for delete\n try:\n login_resp_for_del = requests.post(login_url, json={\n \"username\": user_payload[\"username\"],\n \"password\": user_payload[\"password\"]\n }, timeout=timeout)\n if login_resp_for_del.status_code == 200:\n token = login_resp_for_del.json().get(\"access_token\")\n if token:\n headers = {\"Authorization\": f\"Bearer {token}\"}\n user_id = created_user.get(\"id\")\n if user_id:\n delete_url = f\"{base_url}/api/v1/users/{user_id}\"\n del_resp = requests.delete(delete_url, headers=headers, timeout=timeout)\n # We won't assert here because we're in cleanup\n except Exception:\n pass\n\ntest_login_with_valid_credentials_returns_token()", + "testStatus": "PASSED", + "testError": "", + "testType": "BACKEND", + "createFrom": "mcp", + "priority": "High", + "created": "2026-03-13T22:22:26.366Z", + "modified": "2026-03-13T22:22:59.524Z" + } +] From 8dfe3d4c4fc0e2a279806101764a0d84f4c1bbe3 Mon Sep 17 00:00:00 2001 From: wsr2002 <2327909382@qq.com> Date: Fri, 13 Mar 2026 15:53:37 -0700 Subject: [PATCH 3/3] 1 --- README.md | 24 +++ backend/README.md | 89 +---------- backend/app/__init__.py | 4 - backend/app/api/__init__.py | 3 - backend/app/api/routes/__init__.py | 15 -- backend/app/api/routes/auth.py | 66 --------- backend/app/api/routes/categories.py | 99 ------------- backend/app/api/routes/health.py | 36 ----- backend/app/api/routes/items.py | 101 ------------- backend/app/api/routes/orders.py | 107 -------------- backend/app/api/routes/users.py | 99 ------------- backend/app/config.py | 64 -------- backend/app/database.py | 73 --------- backend/app/exceptions.py | 68 --------- backend/app/main.py | 63 -------- backend/app/middleware/__init__.py | 7 - backend/app/middleware/logging.py | 21 --- backend/app/middleware/timing.py | 20 --- backend/app/models/__init__.py | 17 --- backend/app/models/audit.py | 42 ------ backend/app/models/base.py | 41 ------ backend/app/models/category.py | 33 ----- backend/app/models/item.py | 49 ------- backend/app/models/order.py | 88 ----------- backend/app/models/user.py | 45 ------ backend/app/schemas/__init__.py | 31 ---- backend/app/schemas/auth.py | 45 ------ backend/app/schemas/category.py | 41 ------ backend/app/schemas/common.py | 43 ------ backend/app/schemas/item.py | 50 ------- backend/app/schemas/order.py | 69 --------- backend/app/schemas/user.py | 54 ------- backend/app/services/__init__.py | 16 -- backend/app/services/auth_service.py | 67 --------- backend/app/services/category_service.py | 72 --------- backend/app/services/item_service.py | 78 ---------- backend/app/services/order_service.py | 103 ------------- backend/app/services/user_service.py | 61 -------- backend/app/utils/__init__.py | 15 -- backend/app/utils/dependencies.py | 78 ---------- backend/app/utils/logging_config.py | 24 --- backend/app/utils/security.py | 71 --------- backend/app/utils/validators.py | 26 ---- backend/main.py | 33 +++++ backend/pytest.ini | 5 - backend/requirements.txt | 27 ---- backend/scripts/seed.py | 62 -------- backend/tests/__init__.py | 1 - backend/tests/conftest.py | 72 --------- backend/tests/test_auth.py | 45 ------ backend/tests/test_health.py | 24 --- frontend/index.html | 6 +- frontend/package.json | 8 +- frontend/postcss.config.js | 6 - frontend/src/App.tsx | 141 +++++++----------- frontend/src/api/auth.ts | 41 ------ frontend/src/api/categories.ts | 66 --------- frontend/src/api/client.ts | 32 ---- frontend/src/api/items.ts | 79 ---------- frontend/src/api/orders.ts | 83 ----------- frontend/src/api/users.ts | 53 ------- frontend/src/components/Button.tsx | 43 ------ frontend/src/components/Card.tsx | 16 -- frontend/src/components/Input.tsx | 25 ---- frontend/src/components/Layout.tsx | 16 -- frontend/src/components/Nav.tsx | 50 ------- frontend/src/components/Pagination.tsx | 43 ------ frontend/src/contexts/AuthContext.tsx | 113 -------------- frontend/src/index.css | 47 +----- frontend/src/main.tsx | 8 +- frontend/src/pages/Categories.tsx | 179 ----------------------- frontend/src/pages/Dashboard.tsx | 51 ------- frontend/src/pages/Home.tsx | 61 -------- frontend/src/pages/ItemDetail.tsx | 77 ---------- frontend/src/pages/Items.tsx | 84 ----------- frontend/src/pages/Login.tsx | 71 --------- frontend/src/pages/NotFound.tsx | 13 -- frontend/src/pages/OrderDetail.tsx | 127 ---------------- frontend/src/pages/Orders.tsx | 125 ---------------- frontend/src/pages/Profile.tsx | 69 --------- frontend/src/pages/Register.tsx | 102 ------------- frontend/src/pages/Users.tsx | 106 -------------- frontend/src/types/index.ts | 16 -- frontend/tailwind.config.js | 29 ---- 84 files changed, 120 insertions(+), 4353 deletions(-) create mode 100644 README.md delete mode 100644 backend/app/__init__.py delete mode 100644 backend/app/api/__init__.py delete mode 100644 backend/app/api/routes/__init__.py delete mode 100644 backend/app/api/routes/auth.py delete mode 100644 backend/app/api/routes/categories.py delete mode 100644 backend/app/api/routes/health.py delete mode 100644 backend/app/api/routes/items.py delete mode 100644 backend/app/api/routes/orders.py delete mode 100644 backend/app/api/routes/users.py delete mode 100644 backend/app/config.py delete mode 100644 backend/app/database.py delete mode 100644 backend/app/exceptions.py delete mode 100644 backend/app/main.py delete mode 100644 backend/app/middleware/__init__.py delete mode 100644 backend/app/middleware/logging.py delete mode 100644 backend/app/middleware/timing.py delete mode 100644 backend/app/models/__init__.py delete mode 100644 backend/app/models/audit.py delete mode 100644 backend/app/models/base.py delete mode 100644 backend/app/models/category.py delete mode 100644 backend/app/models/item.py delete mode 100644 backend/app/models/order.py delete mode 100644 backend/app/models/user.py delete mode 100644 backend/app/schemas/__init__.py delete mode 100644 backend/app/schemas/auth.py delete mode 100644 backend/app/schemas/category.py delete mode 100644 backend/app/schemas/common.py delete mode 100644 backend/app/schemas/item.py delete mode 100644 backend/app/schemas/order.py delete mode 100644 backend/app/schemas/user.py delete mode 100644 backend/app/services/__init__.py delete mode 100644 backend/app/services/auth_service.py delete mode 100644 backend/app/services/category_service.py delete mode 100644 backend/app/services/item_service.py delete mode 100644 backend/app/services/order_service.py delete mode 100644 backend/app/services/user_service.py delete mode 100644 backend/app/utils/__init__.py delete mode 100644 backend/app/utils/dependencies.py delete mode 100644 backend/app/utils/logging_config.py delete mode 100644 backend/app/utils/security.py delete mode 100644 backend/app/utils/validators.py create mode 100644 backend/main.py delete mode 100644 backend/pytest.ini delete mode 100644 backend/scripts/seed.py delete mode 100644 backend/tests/__init__.py delete mode 100644 backend/tests/conftest.py delete mode 100644 backend/tests/test_auth.py delete mode 100644 backend/tests/test_health.py delete mode 100644 frontend/postcss.config.js delete mode 100644 frontend/src/api/auth.ts delete mode 100644 frontend/src/api/categories.ts delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/api/items.ts delete mode 100644 frontend/src/api/orders.ts delete mode 100644 frontend/src/api/users.ts delete mode 100644 frontend/src/components/Button.tsx delete mode 100644 frontend/src/components/Card.tsx delete mode 100644 frontend/src/components/Input.tsx delete mode 100644 frontend/src/components/Layout.tsx delete mode 100644 frontend/src/components/Nav.tsx delete mode 100644 frontend/src/components/Pagination.tsx delete mode 100644 frontend/src/contexts/AuthContext.tsx delete mode 100644 frontend/src/pages/Categories.tsx delete mode 100644 frontend/src/pages/Dashboard.tsx delete mode 100644 frontend/src/pages/Home.tsx delete mode 100644 frontend/src/pages/ItemDetail.tsx delete mode 100644 frontend/src/pages/Items.tsx delete mode 100644 frontend/src/pages/Login.tsx delete mode 100644 frontend/src/pages/NotFound.tsx delete mode 100644 frontend/src/pages/OrderDetail.tsx delete mode 100644 frontend/src/pages/Orders.tsx delete mode 100644 frontend/src/pages/Profile.tsx delete mode 100644 frontend/src/pages/Register.tsx delete mode 100644 frontend/src/pages/Users.tsx delete mode 100644 frontend/src/types/index.ts delete mode 100644 frontend/tailwind.config.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..7165055 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# 简易前后端项目 + +## 结构 + +- **后端**:`backend/main.py`(单文件 FastAPI,内存存储) +- **前端**:`frontend/src/App.tsx` + `main.tsx`(单页待办) + +## 启动 + +**后端**(终端 1): +```bash +cd backend +pip install -r requirements.txt +uvicorn main:app --reload --host 127.0.0.1 --port 8000 +``` + +**前端**(终端 2): +```bash +cd frontend +npm install +npm run dev +``` + +浏览器打开 http://localhost:3000 ,前端会通过 Vite 代理访问 http://localhost:8000 的 `/api`。 diff --git a/backend/README.md b/backend/README.md index adafcc4..6e16b08 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,89 +1,10 @@ -# 示例后端项目 (FastAPI) - -基于 **FastAPI** 的异步后端,包含用户、分类、商品、订单模块与 JWT 认证,代码量较多,适合学习或二次开发。 - -## 技术栈 - -- **框架**: FastAPI -- **数据库**: SQLAlchemy 2.0(异步),默认 SQLite -- **认证**: JWT (python-jose + passlib/bcrypt) -- **校验**: Pydantic v2 - -## 项目结构 - -``` -backend/ -├── app/ -│ ├── __init__.py -│ ├── config.py # 配置(环境变量) -│ ├── database.py # 异步数据库与会话 -│ ├── main.py # 应用入口 -│ ├── exceptions.py # 自定义异常与全局处理 -│ ├── api/ -│ │ └── routes/ # 路由:health, auth, users, items, categories, orders -│ ├── models/ # SQLAlchemy 模型:User, Category, Item, Order, OrderItem, AuditLog -│ ├── schemas/ # Pydantic 请求/响应模型 -│ ├── services/ # 业务逻辑层 -│ ├── utils/ # 安全、依赖注入等 -│ └── middleware/ # 日志、耗时中间件 -├── requirements.txt -└── README.md -``` - -## 快速开始 - -### 1. 安装依赖 +# 简易后端(单文件) ```bash -cd backend pip install -r requirements.txt +uvicorn main:app --reload --host 127.0.0.1 --port 8000 ``` -### 2. 启动服务 - -```bash -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` - -- 接口文档: http://127.0.0.1:8000/docs -- ReDoc: http://127.0.0.1:8000/redoc - -### 3. 环境变量(可选) - -在项目根目录创建 `.env`,例如: - -```env -SECRET_KEY=your-secret-key -DATABASE_URL=sqlite+aiosqlite:///./app.db -DEBUG=false -ACCESS_TOKEN_EXPIRE_MINUTES=30 -``` - -## API 概览 - -| 模块 | 前缀 | 说明 | -|------------|--------------------|----------------| -| 健康检查 | `/api/v1/health` | 基础 / DB 检查 | -| 认证 | `/api/v1/auth` | 登录、刷新、me | -| 用户 | `/api/v1/users` | 注册、列表、CRUD | -| 分类 | `/api/v1/categories` | 分类 CRUD、分页 | -| 商品 | `/api/v1/items` | 商品 CRUD、分页、按分类 | -| 订单 | `/api/v1/orders` | 下单、我的订单、管理员列表 | - -除注册、登录、健康检查、商品列表/详情外,其余接口需在请求头携带: - -```http -Authorization: Bearer -``` - -## 测试(示例) - -```bash -pytest tests/ -v -``` - -(需先编写 `tests/` 下用例;当前项目已预留依赖 `pytest`、`pytest-asyncio`、`httpx`。) - -## 许可证 - -MIT +- `GET /api/health` 健康检查 +- `GET /api/items` 列表 +- `POST /api/items` 添加(body: `{"title": "xxx"}`) diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index a320d0c..0000000 --- a/backend/app/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -后端应用主包 -""" -__version__ = "1.0.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index f198672..0000000 --- a/backend/app/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -API 路由包 -""" diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py deleted file mode 100644 index beea877..0000000 --- a/backend/app/api/routes/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -路由模块 -""" -from fastapi import APIRouter - -from app.api.routes import auth, users, items, categories, orders, health - -api_router = APIRouter() - -api_router.include_router(health.router, prefix="/health", tags=["健康检查"]) -api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) -api_router.include_router(users.router, prefix="/users", tags=["用户"]) -api_router.include_router(categories.router, prefix="/categories", tags=["分类"]) -api_router.include_router(items.router, prefix="/items", tags=["商品"]) -api_router.include_router(orders.router, prefix="/orders", tags=["订单"]) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py deleted file mode 100644 index ef8fbf5..0000000 --- a/backend/app/api/routes/auth.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -认证相关接口 -""" -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.schemas.auth import LoginRequest, Token, RefreshRequest -from app.schemas.user import UserResponse -from app.services.auth_service import AuthService -from app.utils.dependencies import get_current_active_user -from app.models.user import User - -router = APIRouter() - - -@router.post("/login", response_model=Token) -async def login( - body: LoginRequest, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Token: - """用户名/邮箱 + 密码登录""" - user = await AuthService.authenticate(db, body.username, body.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户名或密码错误", - ) - access_token, refresh_token = AuthService.create_tokens(user) - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=AuthService.get_expires_in_seconds(), - ) - - -@router.post("/refresh", response_model=Token) -async def refresh( - body: RefreshRequest, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Token: - """使用 refresh token 换取新的 access token""" - user = await AuthService.get_user_from_refresh_token(db, body.refresh_token) - if not user or not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效的刷新 Token 或用户已禁用", - ) - access_token, refresh_token = AuthService.create_tokens(user) - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=AuthService.get_expires_in_seconds(), - ) - - -@router.get("/me", response_model=UserResponse) -async def me( - current_user: Annotated[User, Depends(get_current_active_user)], -) -> User: - """获取当前登录用户信息""" - return current_user diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py deleted file mode 100644 index aae7add..0000000 --- a/backend/app/api/routes/categories.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -分类 CRUD 接口 -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.models.category import Category -from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.category_service import CategoryService -from app.utils.dependencies import get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) -async def create_category( - body: CategoryCreate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Category: - """创建分类""" - if await CategoryService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await CategoryService.create(db, body) - - -@router.get("", response_model=list[CategoryResponse]) -async def list_categories( - db: Annotated[AsyncSession, Depends(get_db)], - parent_id: Optional[str] = Query(None), -) -> list[Category]: - """分类列表(可选按父级筛选)""" - categories = await CategoryService.list_all(db, parent_id=parent_id) - return categories - - -@router.get("/paginated", response_model=PaginatedResponse[CategoryResponse]) -async def list_categories_paginated( - db: Annotated[AsyncSession, Depends(get_db)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - parent_id: Optional[str] = Query(None), -) -> PaginatedResponse[CategoryResponse]: - """分类列表(分页)""" - params = PageParams(page=page, page_size=page_size) - categories, total = await CategoryService.list_paginated(db, params, parent_id=parent_id) - return PaginatedResponse.create( - [CategoryResponse.model_validate(c) for c in categories], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{category_id}", response_model=CategoryResponse) -async def get_category( - category_id: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Category: - """根据 ID 获取分类""" - category = await CategoryService.get_by_id(db, category_id) - if not category: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - return category - - -@router.patch("/{category_id}", response_model=CategoryResponse) -async def update_category( - category_id: str, - body: CategoryUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Category: - """更新分类""" - category = await CategoryService.get_by_id(db, category_id) - if not category: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - if body.slug and body.slug != category.slug and await CategoryService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await CategoryService.update(db, category, body) - - -@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_category( - category_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> None: - """删除分类""" - category = await CategoryService.get_by_id(db, category_id) - if not category: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - await db.delete(category) - await db.flush() diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py deleted file mode 100644 index ba13ec2..0000000 --- a/backend/app/api/routes/health.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -健康检查接口 -""" -from datetime import datetime, timezone -from typing import Any - -from fastapi import APIRouter, Depends -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.config import get_settings - -router = APIRouter() -settings = get_settings() - - -@router.get("") -async def health() -> dict[str, Any]: - """基础健康检查""" - return { - "status": "ok", - "app": settings.APP_NAME, - "version": settings.APP_VERSION, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - -@router.get("/db") -async def health_db(db: AsyncSession = Depends(get_db)) -> dict[str, Any]: - """数据库连通性检查""" - try: - await db.execute(text("SELECT 1")) - return {"status": "ok", "database": "connected"} - except Exception as e: - return {"status": "error", "database": "disconnected", "detail": str(e)} diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py deleted file mode 100644 index e408d04..0000000 --- a/backend/app/api/routes/items.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -商品 CRUD 接口 -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.models.item import Item -from app.schemas.item import ItemCreate, ItemUpdate, ItemResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.item_service import ItemService -from app.utils.dependencies import get_current_user, get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) -async def create_item( - body: ItemCreate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Item: - """创建商品(需登录)""" - if await ItemService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await ItemService.create(db, body) - - -@router.get("", response_model=PaginatedResponse[ItemResponse]) -async def list_items( - db: Annotated[AsyncSession, Depends(get_db)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - category_id: Optional[str] = Query(None), - is_active: Optional[bool] = Query(None), -) -> PaginatedResponse[ItemResponse]: - """商品列表(分页,可按分类、是否上架筛选)""" - params = PageParams(page=page, page_size=page_size) - items, total = await ItemService.list_paginated(db, params, category_id=category_id, is_active=is_active) - return PaginatedResponse.create( - [ItemResponse.model_validate(i) for i in items], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{item_id}", response_model=ItemResponse) -async def get_item( - item_id: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Item: - """根据 ID 获取商品""" - item = await ItemService.get_by_id(db, item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - return item - - -@router.get("/slug/{slug}", response_model=ItemResponse) -async def get_item_by_slug( - slug: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Item: - """根据 slug 获取商品""" - item = await ItemService.get_by_slug(db, slug) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - return item - - -@router.patch("/{item_id}", response_model=ItemResponse) -async def update_item( - item_id: str, - body: ItemUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Item: - """更新商品""" - item = await ItemService.get_by_id(db, item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - if body.slug and body.slug != item.slug and await ItemService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await ItemService.update(db, item, body) - - -@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_item( - item_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> None: - """删除商品""" - item = await ItemService.get_by_id(db, item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - await ItemService.delete(db, item) diff --git a/backend/app/api/routes/orders.py b/backend/app/api/routes/orders.py deleted file mode 100644 index 3a4be1a..0000000 --- a/backend/app/api/routes/orders.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -订单 CRUD 接口 -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.models.order import Order -from app.schemas.order import OrderCreate, OrderUpdate, OrderResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.order_service import OrderService -from app.utils.dependencies import get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=OrderResponse, status_code=status.HTTP_201_CREATED) -async def create_order( - body: OrderCreate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Order: - """创建订单""" - try: - order = await OrderService.create(db, current_user, body) - return order - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -@router.get("", response_model=PaginatedResponse[OrderResponse]) -async def list_my_orders( - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - status_filter: Optional[str] = Query(None, alias="status"), -) -> PaginatedResponse[OrderResponse]: - """当前用户的订单列表(分页)""" - params = PageParams(page=page, page_size=page_size) - orders, total = await OrderService.list_by_user_paginated( - db, current_user.id, params, status_filter=status_filter - ) - return PaginatedResponse.create( - [OrderResponse.model_validate(o) for o in orders], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/admin", response_model=PaginatedResponse[OrderResponse]) -async def list_all_orders( - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - user_id: Optional[str] = Query(None), - status_filter: Optional[str] = Query(None, alias="status"), -) -> PaginatedResponse[OrderResponse]: - """全部订单列表(管理员)""" - if not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") - params = PageParams(page=page, page_size=page_size) - orders, total = await OrderService.list_paginated( - db, params, user_id=user_id, status_filter=status_filter - ) - return PaginatedResponse.create( - [OrderResponse.model_validate(o) for o in orders], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{order_id}", response_model=OrderResponse) -async def get_order( - order_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Order: - """获取订单详情(本人或管理员)""" - order = await OrderService.get_by_id(db, order_id) - if not order: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在") - if order.user_id != current_user.id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - return order - - -@router.patch("/{order_id}", response_model=OrderResponse) -async def update_order( - order_id: str, - body: OrderUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Order: - """更新订单(如状态、地址等)""" - order = await OrderService.get_by_id(db, order_id) - if not order: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在") - if order.user_id != current_user.id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - return await OrderService.update(db, order, body) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py deleted file mode 100644 index b75aba7..0000000 --- a/backend/app/api/routes/users.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -用户 CRUD 接口 -""" -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate, UserResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.user_service import UserService -from app.utils.dependencies import get_current_user, get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -async def create_user( - body: UserCreate, - db: Annotated[AsyncSession, Depends(get_db)], -) -> User: - """注册新用户""" - if await UserService.get_by_email(db, body.email): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱已被注册") - if await UserService.get_by_username(db, body.username): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已被占用") - return await UserService.create(db, body) - - -@router.get("", response_model=PaginatedResponse[UserResponse]) -async def list_users( - db: Annotated[AsyncSession, Depends(get_db)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), -) -> PaginatedResponse[UserResponse]: - """用户列表(分页)""" - params = PageParams(page=page, page_size=page_size) - from sqlalchemy import select, func - count_result = await db.execute(select(func.count()).select_from(User)) - total = count_result.scalar() or 0 - q = select(User).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - items = result.scalars().all() - return PaginatedResponse.create( - [UserResponse.model_validate(u) for u in items], - total=total or len(items), - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{user_id}", response_model=UserResponse) -async def get_user( - user_id: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> User: - """根据 ID 获取用户""" - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - return user - - -@router.patch("/{user_id}", response_model=UserResponse) -async def update_user( - user_id: str, - body: UserUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> User: - """更新用户(本人或管理员)""" - if current_user.id != user_id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - if body.email and body.email != user.email and await UserService.get_by_email(db, body.email): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱已被注册") - if body.username and body.username != user.username and await UserService.get_by_username(db, body.username): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已被占用") - return await UserService.update(db, user, body) - - -@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user( - user_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> None: - """删除用户(仅管理员或本人)""" - if current_user.id != user_id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - await db.delete(user) - await db.flush() diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index 53039f3..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -应用配置 - 从环境变量与默认值加载 -""" -from functools import lru_cache -from typing import List, Optional - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """应用配置类""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - # 应用基础 - APP_NAME: str = "Demo Backend API" - APP_VERSION: str = "1.0.0" - DEBUG: bool = False - API_V1_PREFIX: str = "/api/v1" - - # 服务 - HOST: str = "0.0.0.0" - PORT: int = 8000 - - # 数据库 - DATABASE_URL: str = "sqlite+aiosqlite:///./app.db" - DATABASE_ECHO: bool = False - DATABASE_POOL_SIZE: int = 5 - DATABASE_MAX_OVERFLOW: int = 10 - - # JWT 认证 - SECRET_KEY: str = "your-super-secret-key-change-in-production" - ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - - # CORS - CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"] - CORS_ALLOW_CREDENTIALS: bool = True - CORS_ALLOW_METHODS: List[str] = ["*"] - CORS_ALLOW_HEADERS: List[str] = ["*"] - - # 分页 - DEFAULT_PAGE_SIZE: int = 20 - MAX_PAGE_SIZE: int = 100 - - # 限流(可选) - RATE_LIMIT_REQUESTS: int = 100 - RATE_LIMIT_WINDOW_SECONDS: int = 60 - - # 日志 - LOG_LEVEL: str = "INFO" - LOG_FORMAT: str = "{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}" - - -@lru_cache -def get_settings() -> Settings: - """获取单例配置""" - return Settings() diff --git a/backend/app/database.py b/backend/app/database.py deleted file mode 100644 index dcf3b7f..0000000 --- a/backend/app/database.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -数据库连接与会话管理(异步) -""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import AsyncGenerator as TypingAsyncGenerator - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase - -from app.config import get_settings - -settings = get_settings() - -# 异步引擎(SQLite 不使用 pool 参数) -_engine_kw: dict = { - "echo": settings.DATABASE_ECHO, -} -if "sqlite" not in settings.DATABASE_URL: - _engine_kw["pool_size"] = settings.DATABASE_POOL_SIZE - _engine_kw["max_overflow"] = settings.DATABASE_MAX_OVERFLOW -engine = create_async_engine(settings.DATABASE_URL, **_engine_kw) - -# 异步会话工厂 -async_session_factory = async_sessionmaker( - engine, - class_=AsyncSession, - expire_on_commit=False, - autocommit=False, - autoflush=False, -) - - -class Base(DeclarativeBase): - """ORM 基类""" - pass - - -async def get_db() -> TypingAsyncGenerator[AsyncSession, None]: - """依赖注入:获取数据库会话""" - async with async_session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - finally: - await session.close() - - -@asynccontextmanager -async def get_db_context() -> AsyncGenerator[AsyncSession, None]: - """上下文管理器方式获取会话""" - async with async_session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - - -async def init_db() -> None: - """初始化数据库表""" - from app.models import User, Item, Order, OrderItem, Category, AuditLog # noqa: F401 - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - -async def close_db() -> None: - """关闭数据库连接""" - await engine.dispose() diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py deleted file mode 100644 index ca4b78f..0000000 --- a/backend/app/exceptions.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -自定义异常与全局异常处理 -""" -from typing import Any, Optional - -from fastapi import Request, status -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse - - -class AppException(Exception): - """应用基础异常""" - - def __init__( - self, - message: str = "服务器内部错误", - status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, - detail: Optional[Any] = None, - ): - self.message = message - self.status_code = status_code - self.detail = detail - super().__init__(message) - - -class NotFoundError(AppException): - """资源不存在""" - - def __init__(self, message: str = "资源不存在", detail: Optional[Any] = None): - super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND, detail=detail) - - -class ForbiddenError(AppException): - """无权限""" - - def __init__(self, message: str = "无权限", detail: Optional[Any] = None): - super().__init__(message=message, status_code=status.HTTP_403_FORBIDDEN, detail=detail) - - -class BadRequestError(AppException): - """错误请求""" - - def __init__(self, message: str = "请求参数错误", detail: Optional[Any] = None): - super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST, detail=detail) - - -async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: - """统一处理 AppException""" - return JSONResponse( - status_code=exc.status_code, - content={ - "success": False, - "message": exc.message, - "detail": exc.detail, - }, - ) - - -async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - """校验错误(Pydantic)统一格式""" - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "success": False, - "message": "请求体验证失败", - "detail": exc.errors(), - }, - ) diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index adc3f2f..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -FastAPI 应用入口 -""" -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.exceptions import RequestValidationError - -from app.config import get_settings -from app.database import init_db, close_db -from app.api.routes import api_router -from app.exceptions import AppException, app_exception_handler, validation_exception_handler -from app.middleware import LoggingMiddleware, RequestTimingMiddleware - -settings = get_settings() - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """应用生命周期:启动时建表,关闭时断开 DB""" - await init_db() - yield - await close_db() - - -app = FastAPI( - title=settings.APP_NAME, - version=settings.APP_VERSION, - description="示例后端 API:用户、分类、商品、订单与 JWT 认证", - lifespan=lifespan, - docs_url="/docs", - redoc_url="/redoc", -) - -# CORS -app.add_middleware( - CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, - allow_credentials=settings.CORS_ALLOW_CREDENTIALS, - allow_methods=settings.CORS_ALLOW_METHODS, - allow_headers=settings.CORS_ALLOW_HEADERS, -) -app.add_middleware(RequestTimingMiddleware) -app.add_middleware(LoggingMiddleware) - -# 异常处理 -app.add_exception_handler(AppException, app_exception_handler) -app.add_exception_handler(RequestValidationError, validation_exception_handler) - -# 路由 -app.include_router(api_router, prefix=settings.API_V1_PREFIX) - - -@app.get("/") -async def root(): - """根路径""" - return { - "app": settings.APP_NAME, - "version": settings.APP_VERSION, - "docs": "/docs", - "api": settings.API_V1_PREFIX, - } diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py deleted file mode 100644 index 8f0b657..0000000 --- a/backend/app/middleware/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -中间件包 -""" -from app.middleware.logging import LoggingMiddleware -from app.middleware.timing import RequestTimingMiddleware - -__all__ = ["LoggingMiddleware", "RequestTimingMiddleware"] diff --git a/backend/app/middleware/logging.py b/backend/app/middleware/logging.py deleted file mode 100644 index 55ad5c2..0000000 --- a/backend/app/middleware/logging.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -请求日志中间件 -""" -import time -from typing import Callable - -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response - - -class LoggingMiddleware(BaseHTTPMiddleware): - """记录请求方法、路径、状态码与耗时""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - start = time.perf_counter() - response = await call_next(request) - duration = time.perf_counter() - start - # 可接入 loguru 等 - print(f"[{request.method}] {request.url.path} -> {response.status_code} ({duration:.3f}s)") - return response diff --git a/backend/app/middleware/timing.py b/backend/app/middleware/timing.py deleted file mode 100644 index c9b6985..0000000 --- a/backend/app/middleware/timing.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -请求耗时中间件(在 Response 头中返回耗时) -""" -import time -from typing import Callable - -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response - - -class RequestTimingMiddleware(BaseHTTPMiddleware): - """在 X-Process-Time 响应头中返回处理耗时(秒)""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - start = time.perf_counter() - response = await call_next(request) - duration = time.perf_counter() - start - response.headers["X-Process-Time"] = f"{duration:.3f}" - return response diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 5c0f03d..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -数据模型包 -""" -from app.models.user import User -from app.models.item import Item -from app.models.order import Order, OrderItem -from app.models.category import Category -from app.models.audit import AuditLog - -__all__ = [ - "User", - "Item", - "Order", - "OrderItem", - "Category", - "AuditLog", -] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py deleted file mode 100644 index 4a37941..0000000 --- a/backend/app/models/audit.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -审计日志模型 -""" -from typing import TYPE_CHECKING, Optional - -from sqlalchemy import ForeignKey, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.user import User - - -class AuditLog(Base, UUIDMixin, TimestampMixin): - """审计日志表""" - - __tablename__ = "audit_logs" - - user_id: Mapped[Optional[str]] = mapped_column( - String(36), - ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - action: Mapped[str] = mapped_column(String(64), nullable=False, index=True) - resource_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) - resource_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True) - details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) - user_agent: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - - user: Mapped[Optional["User"]] = relationship( - "User", - back_populates="audit_logs", - lazy="joined", - foreign_keys=[user_id], - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/base.py b/backend/app/models/base.py deleted file mode 100644 index 7d1e6b0..0000000 --- a/backend/app/models/base.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -模型基类与公共字段 -""" -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, func -from sqlalchemy.dialects.sqlite import CHAR -from sqlalchemy.orm import Mapped, mapped_column - -from app.database import Base - - -def generate_uuid() -> str: - return str(uuid4()) - - -class TimestampMixin: - """创建/更新时间混入""" - - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - -class UUIDMixin: - """UUID 主键混入""" - - id: Mapped[str] = mapped_column( - CHAR(36), - primary_key=True, - default=generate_uuid, - ) diff --git a/backend/app/models/category.py b/backend/app/models/category.py deleted file mode 100644 index 9a0ac86..0000000 --- a/backend/app/models/category.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -分类模型 -""" -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.item import Item - - -class Category(Base, UUIDMixin, TimestampMixin): - """分类表""" - - __tablename__ = "categories" - - name: Mapped[str] = mapped_column(String(128), nullable=False, index=True) - slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - parent_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True) - - items: Mapped[List["Item"]] = relationship( - "Item", - back_populates="category", - lazy="selectin", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/item.py b/backend/app/models/item.py deleted file mode 100644 index 7b1b309..0000000 --- a/backend/app/models/item.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -商品/条目模型 -""" -from decimal import Decimal -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import DECIMAL, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.category import Category - from app.models.order import OrderItem - - -class Item(Base, UUIDMixin, TimestampMixin): - """商品表""" - - __tablename__ = "items" - - title: Mapped[str] = mapped_column(String(256), nullable=False, index=True) - slug: Mapped[str] = mapped_column(String(256), unique=True, nullable=False, index=True) - description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - price: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) - stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False) - image_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - category_id: Mapped[Optional[str]] = mapped_column( - String(36), - ForeignKey("categories.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - is_active: Mapped[bool] = mapped_column(default=True, nullable=False) - - category: Mapped[Optional["Category"]] = relationship( - "Category", - back_populates="items", - lazy="joined", - ) - order_items: Mapped[List["OrderItem"]] = relationship( - "OrderItem", - back_populates="item", - lazy="selectin", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/order.py b/backend/app/models/order.py deleted file mode 100644 index 36475bb..0000000 --- a/backend/app/models/order.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -订单模型 -""" -from decimal import Decimal -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import DECIMAL, ForeignKey, Integer, String -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.user import User - from app.models.item import Item - - -class Order(Base, UUIDMixin, TimestampMixin): - """订单表""" - - __tablename__ = "orders" - - user_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - status: Mapped[str] = mapped_column( - String(32), - default="pending", - nullable=False, - index=True, - ) # pending, paid, shipped, completed, cancelled - total_amount: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), default=0, nullable=False) - shipping_address: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - note: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - - user: Mapped["User"] = relationship( - "User", - back_populates="orders", - lazy="joined", - ) - order_items: Mapped[List["OrderItem"]] = relationship( - "OrderItem", - back_populates="order", - lazy="selectin", - cascade="all, delete-orphan", - ) - - def __repr__(self) -> str: - return f"" - - -class OrderItem(Base, UUIDMixin, TimestampMixin): - """订单明细表""" - - __tablename__ = "order_items" - - order_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("orders.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - item_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("items.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - quantity: Mapped[int] = mapped_column(Integer, nullable=False) - unit_price: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) - subtotal: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) - - order: Mapped["Order"] = relationship( - "Order", - back_populates="order_items", - lazy="joined", - ) - item: Mapped["Item"] = relationship( - "Item", - back_populates="order_items", - lazy="joined", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py deleted file mode 100644 index 43c7ee4..0000000 --- a/backend/app/models/user.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -用户模型 -""" -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import Boolean, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.order import Order - from app.models.audit import AuditLog - - -class User(Base, UUIDMixin, TimestampMixin): - """用户表""" - - __tablename__ = "users" - - email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) - username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - full_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) - avatar_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - orders: Mapped[List["Order"]] = relationship( - "Order", - back_populates="user", - lazy="selectin", - cascade="all, delete-orphan", - ) - audit_logs: Mapped[List["AuditLog"]] = relationship( - "AuditLog", - back_populates="user", - lazy="selectin", - foreign_keys="AuditLog.user_id", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py deleted file mode 100644 index 2d4c774..0000000 --- a/backend/app/schemas/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Pydantic 模式包 -""" -from app.schemas.common import PageParams, PaginatedResponse -from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserInDB -from app.schemas.auth import Token, TokenPayload -from app.schemas.item import ItemCreate, ItemUpdate, ItemResponse -from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse -from app.schemas.order import OrderCreate, OrderUpdate, OrderResponse, OrderItemCreate, OrderItemResponse - -__all__ = [ - "PageParams", - "PaginatedResponse", - "UserCreate", - "UserUpdate", - "UserResponse", - "UserInDB", - "Token", - "TokenPayload", - "ItemCreate", - "ItemUpdate", - "ItemResponse", - "CategoryCreate", - "CategoryUpdate", - "CategoryResponse", - "OrderCreate", - "OrderUpdate", - "OrderResponse", - "OrderItemCreate", - "OrderItemResponse", -] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py deleted file mode 100644 index 373002b..0000000 --- a/backend/app/schemas/auth.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -认证相关模式 -""" -from typing import Optional - -from pydantic import BaseModel, EmailStr, Field - - -class LoginRequest(BaseModel): - """登录请求""" - - username: str # 支持用户名或邮箱 - password: str = Field(..., min_length=1) - - -class Token(BaseModel): - """Token 响应""" - - access_token: str - refresh_token: Optional[str] = None - token_type: str = "bearer" - expires_in: int # 秒 - - -class TokenPayload(BaseModel): - """JWT 载荷""" - - sub: str # user id - username: str - exp: int - iat: int - type: str = "access" # access | refresh - - -class RefreshRequest(BaseModel): - """刷新 Token 请求""" - - refresh_token: str - - -class PasswordChange(BaseModel): - """修改密码""" - - old_password: str - new_password: str = Field(..., min_length=8) diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py deleted file mode 100644 index 24ed2b1..0000000 --- a/backend/app/schemas/category.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -分类相关模式 -""" -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field - - -class CategoryBase(BaseModel): - """分类基础字段""" - - name: str = Field(..., min_length=1, max_length=128) - slug: str = Field(..., min_length=1, max_length=128) - description: Optional[str] = None - parent_id: Optional[str] = None - - -class CategoryCreate(CategoryBase): - """创建分类""" - pass - - -class CategoryUpdate(BaseModel): - """更新分类""" - - name: Optional[str] = Field(None, min_length=1, max_length=128) - slug: Optional[str] = Field(None, min_length=1, max_length=128) - description: Optional[str] = None - parent_id: Optional[str] = None - - -class CategoryResponse(CategoryBase): - """分类响应""" - - id: str - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py deleted file mode 100644 index 66a5507..0000000 --- a/backend/app/schemas/common.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -通用请求/响应模式 -""" -from typing import Generic, List, TypeVar - -from pydantic import BaseModel, Field - -T = TypeVar("T") - - -class PageParams(BaseModel): - """分页参数""" - - page: int = Field(1, ge=1, description="页码") - page_size: int = Field(20, ge=1, le=100, description="每页条数") - - -class PaginatedResponse(BaseModel, Generic[T]): - """分页响应""" - - items: List[T] - total: int - page: int - page_size: int - pages: int - - @classmethod - def create(cls, items: List[T], total: int, page: int, page_size: int) -> "PaginatedResponse[T]": - pages = (total + page_size - 1) // page_size if page_size else 0 - return cls( - items=items, - total=total, - page=page, - page_size=page_size, - pages=pages, - ) - - -class MessageResponse(BaseModel): - """简单消息响应""" - - message: str - success: bool = True diff --git a/backend/app/schemas/item.py b/backend/app/schemas/item.py deleted file mode 100644 index bbaf7d8..0000000 --- a/backend/app/schemas/item.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -商品相关模式 -""" -from datetime import datetime -from decimal import Decimal -from typing import Optional - -from pydantic import BaseModel, Field - - -class ItemBase(BaseModel): - """商品基础字段""" - - title: str = Field(..., min_length=1, max_length=256) - slug: str = Field(..., min_length=1, max_length=256) - description: Optional[str] = None - price: Decimal = Field(..., ge=0) - stock: int = Field(0, ge=0) - image_url: Optional[str] = None - category_id: Optional[str] = None - is_active: bool = True - - -class ItemCreate(ItemBase): - """创建商品""" - pass - - -class ItemUpdate(BaseModel): - """更新商品(全部可选)""" - - title: Optional[str] = Field(None, min_length=1, max_length=256) - slug: Optional[str] = Field(None, min_length=1, max_length=256) - description: Optional[str] = None - price: Optional[Decimal] = Field(None, ge=0) - stock: Optional[int] = Field(None, ge=0) - image_url: Optional[str] = None - category_id: Optional[str] = None - is_active: Optional[bool] = None - - -class ItemResponse(ItemBase): - """商品响应""" - - id: str - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py deleted file mode 100644 index f37399f..0000000 --- a/backend/app/schemas/order.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -订单相关模式 -""" -from datetime import datetime -from decimal import Decimal -from typing import List, Optional - -from pydantic import BaseModel, Field - - -class OrderItemBase(BaseModel): - """订单项基础""" - - item_id: str - quantity: int = Field(..., ge=1) - unit_price: Decimal = Field(..., ge=0) - - -class OrderItemCreate(OrderItemBase): - """创建订单项""" - pass - - -class OrderItemResponse(OrderItemBase): - """订单项响应""" - - id: str - order_id: str - subtotal: Decimal - created_at: datetime - - class Config: - from_attributes = True - - -class OrderBase(BaseModel): - """订单基础""" - - shipping_address: Optional[str] = None - note: Optional[str] = None - - -class OrderCreate(OrderBase): - """创建订单(含订单项)""" - - items: List[OrderItemCreate] = Field(..., min_length=1) - - -class OrderUpdate(BaseModel): - """更新订单""" - - status: Optional[str] = Field(None, pattern="^(pending|paid|shipped|completed|cancelled)$") - shipping_address: Optional[str] = None - note: Optional[str] = None - - -class OrderResponse(OrderBase): - """订单响应""" - - id: str - user_id: str - status: str - total_amount: Decimal - order_items: List[OrderItemResponse] = [] - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py deleted file mode 100644 index 57e7294..0000000 --- a/backend/app/schemas/user.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -用户相关模式 -""" -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, EmailStr, Field - - -class UserBase(BaseModel): - """用户基础字段""" - - email: EmailStr - username: str = Field(..., min_length=2, max_length=64) - full_name: Optional[str] = Field(None, max_length=128) - bio: Optional[str] = None - is_active: bool = True - - -class UserCreate(UserBase): - """创建用户""" - - password: str = Field(..., min_length=8, max_length=128) - - -class UserUpdate(BaseModel): - """更新用户(全部可选)""" - - email: Optional[EmailStr] = None - username: Optional[str] = Field(None, min_length=2, max_length=64) - full_name: Optional[str] = None - avatar_url: Optional[str] = None - bio: Optional[str] = None - is_active: Optional[bool] = None - password: Optional[str] = Field(None, min_length=8) - - -class UserResponse(UserBase): - """用户响应(不含密码)""" - - id: str - avatar_url: Optional[str] = None - is_superuser: bool = False - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class UserInDB(UserResponse): - """数据库中的用户(含哈希密码,仅内部使用)""" - - hashed_password: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py deleted file mode 100644 index 5f28436..0000000 --- a/backend/app/services/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -业务逻辑服务包 -""" -from app.services.user_service import UserService -from app.services.auth_service import AuthService -from app.services.item_service import ItemService -from app.services.category_service import CategoryService -from app.services.order_service import OrderService - -__all__ = [ - "UserService", - "AuthService", - "ItemService", - "CategoryService", - "OrderService", -] diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py deleted file mode 100644 index 87ef51f..0000000 --- a/backend/app/services/auth_service.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -认证业务逻辑 -""" -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.schemas.auth import TokenPayload -from app.utils.security import ( - verify_password, - create_access_token, - create_refresh_token, - decode_token, -) -from app.config import get_settings - -settings = get_settings() - - -class AuthService: - """认证服务""" - - @staticmethod - async def authenticate( - db: AsyncSession, - username_or_email: str, - password: str, - ) -> Optional[User]: - """用户名/邮箱 + 密码认证""" - from app.services.user_service import UserService - - user = await UserService.get_by_username(db, username_or_email) - if not user: - user = await UserService.get_by_email(db, username_or_email) - if not user or not verify_password(password, user.hashed_password): - return None - if not user.is_active: - return None - return user - - @staticmethod - def create_tokens(user: User) -> tuple[str, str]: - """生成 access + refresh token""" - access = create_access_token(str(user.id), user.username) - refresh = create_refresh_token(str(user.id), user.username) - return access, refresh - - @staticmethod - def get_expires_in_seconds() -> int: - return settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 - - @staticmethod - async def get_user_from_refresh_token( - db: AsyncSession, - refresh_token: str, - ) -> Optional[User]: - """用 refresh token 取用户""" - payload = decode_token(refresh_token) - if not payload or payload.get("type") != "refresh": - return None - user_id = payload.get("sub") - if not user_id: - return None - from app.services.user_service import UserService - return await UserService.get_by_id(db, user_id) diff --git a/backend/app/services/category_service.py b/backend/app/services/category_service.py deleted file mode 100644 index f6b0940..0000000 --- a/backend/app/services/category_service.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -分类业务逻辑 -""" -from typing import List, Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.category import Category -from app.schemas.category import CategoryCreate, CategoryUpdate -from app.schemas.common import PageParams - - -class CategoryService: - """分类服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, category_id: str) -> Optional[Category]: - result = await db.execute(select(Category).where(Category.id == category_id)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_slug(db: AsyncSession, slug: str) -> Optional[Category]: - result = await db.execute(select(Category).where(Category.slug == slug)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, data: CategoryCreate) -> Category: - category = Category( - name=data.name, - slug=data.slug, - description=data.description, - parent_id=data.parent_id, - ) - db.add(category) - await db.flush() - await db.refresh(category) - return category - - @staticmethod - async def update(db: AsyncSession, category: Category, data: CategoryUpdate) -> Category: - update_data = data.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(category, key, value) - await db.flush() - await db.refresh(category) - return category - - @staticmethod - async def list_all(db: AsyncSession, parent_id: Optional[str] = None) -> List[Category]: - q = select(Category) - if parent_id is not None: - q = q.where(Category.parent_id == parent_id) - q = q.order_by(Category.name) - result = await db.execute(q) - return list(result.scalars().all()) - - @staticmethod - async def list_paginated( - db: AsyncSession, - params: PageParams, - parent_id: Optional[str] = None, - ) -> tuple[List[Category], int]: - q = select(Category) - count_q = select(func.count()).select_from(Category) - if parent_id is not None: - q = q.where(Category.parent_id == parent_id) - count_q = count_q.where(Category.parent_id == parent_id) - total = (await db.execute(count_q)).scalar() or 0 - q = q.order_by(Category.name).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - return list(result.scalars().all()), total diff --git a/backend/app/services/item_service.py b/backend/app/services/item_service.py deleted file mode 100644 index fde4e0b..0000000 --- a/backend/app/services/item_service.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -商品业务逻辑 -""" -from decimal import Decimal -from typing import List, Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.item import Item -from app.schemas.item import ItemCreate, ItemUpdate -from app.schemas.common import PageParams - - -class ItemService: - """商品服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, item_id: str) -> Optional[Item]: - result = await db.execute(select(Item).where(Item.id == item_id)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_slug(db: AsyncSession, slug: str) -> Optional[Item]: - result = await db.execute(select(Item).where(Item.slug == slug)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, data: ItemCreate) -> Item: - item = Item( - title=data.title, - slug=data.slug, - description=data.description, - price=data.price, - stock=data.stock, - image_url=data.image_url, - category_id=data.category_id, - is_active=data.is_active, - ) - db.add(item) - await db.flush() - await db.refresh(item) - return item - - @staticmethod - async def update(db: AsyncSession, item: Item, data: ItemUpdate) -> Item: - update_data = data.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(item, key, value) - await db.flush() - await db.refresh(item) - return item - - @staticmethod - async def list_paginated( - db: AsyncSession, - params: PageParams, - category_id: Optional[str] = None, - is_active: Optional[bool] = None, - ) -> tuple[List[Item], int]: - q = select(Item) - count_q = select(func.count()).select_from(Item) - if category_id is not None: - q = q.where(Item.category_id == category_id) - count_q = count_q.where(Item.category_id == category_id) - if is_active is not None: - q = q.where(Item.is_active == is_active) - count_q = count_q.where(Item.is_active == is_active) - total = (await db.execute(count_q)).scalar() or 0 - q = q.offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - items = list(result.scalars().all()) - return items, total - - @staticmethod - async def delete(db: AsyncSession, item: Item) -> None: - await db.delete(item) - await db.flush() diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py deleted file mode 100644 index 0000ada..0000000 --- a/backend/app/services/order_service.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -订单业务逻辑 -""" -from decimal import Decimal -from typing import List, Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.order import Order, OrderItem -from app.models.item import Item -from app.models.user import User -from app.schemas.order import OrderCreate, OrderUpdate, OrderItemCreate -from app.schemas.common import PageParams - - -class OrderService: - """订单服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, order_id: str) -> Optional[Order]: - result = await db.execute(select(Order).where(Order.id == order_id)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, user: User, data: OrderCreate) -> Order: - order = Order( - user_id=user.id, - status="pending", - shipping_address=data.shipping_address, - note=data.note, - ) - db.add(order) - await db.flush() - total = Decimal("0") - for oi in data.items: - item_result = await db.execute(select(Item).where(Item.id == oi.item_id)) - item = item_result.scalar_one_or_none() - if not item: - raise ValueError(f"商品不存在: {oi.item_id}") - if item.stock < oi.quantity: - raise ValueError(f"商品 {item.title} 库存不足") - subtotal = oi.unit_price * oi.quantity - total += subtotal - order_item = OrderItem( - order_id=order.id, - item_id=item.id, - quantity=oi.quantity, - unit_price=oi.unit_price, - subtotal=subtotal, - ) - db.add(order_item) - item.stock -= oi.quantity - order.total_amount = total - await db.flush() - await db.refresh(order) - return order - - @staticmethod - async def update(db: AsyncSession, order: Order, data: OrderUpdate) -> Order: - update_data = data.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(order, key, value) - await db.flush() - await db.refresh(order) - return order - - @staticmethod - async def list_by_user_paginated( - db: AsyncSession, - user_id: str, - params: PageParams, - status_filter: Optional[str] = None, - ) -> tuple[List[Order], int]: - q = select(Order).where(Order.user_id == user_id) - count_q = select(func.count()).select_from(Order).where(Order.user_id == user_id) - if status_filter: - q = q.where(Order.status == status_filter) - count_q = count_q.where(Order.status == status_filter) - total = (await db.execute(count_q)).scalar() or 0 - q = q.order_by(Order.created_at.desc()).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - return list(result.scalars().all()), total - - @staticmethod - async def list_paginated( - db: AsyncSession, - params: PageParams, - user_id: Optional[str] = None, - status_filter: Optional[str] = None, - ) -> tuple[List[Order], int]: - q = select(Order) - count_q = select(func.count()).select_from(Order) - if user_id: - q = q.where(Order.user_id == user_id) - count_q = count_q.where(Order.user_id == user_id) - if status_filter: - q = q.where(Order.status == status_filter) - count_q = count_q.where(Order.status == status_filter) - total = (await db.execute(count_q)).scalar() or 0 - q = q.order_by(Order.created_at.desc()).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - return list(result.scalars().all()), total diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py deleted file mode 100644 index 8839095..0000000 --- a/backend/app/services/user_service.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -用户业务逻辑 -""" -from typing import Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate -from app.utils.security import get_password_hash - - -class UserService: - """用户服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, user_id: str) -> Optional[User]: - result = await db.execute(select(User).where(User.id == user_id)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_email(db: AsyncSession, email: str) -> Optional[User]: - result = await db.execute(select(User).where(User.email == email)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_username(db: AsyncSession, username: str) -> Optional[User]: - result = await db.execute(select(User).where(User.username == username)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, data: UserCreate) -> User: - user = User( - email=data.email, - username=data.username, - hashed_password=get_password_hash(data.password), - full_name=data.full_name, - bio=data.bio, - is_active=data.is_active, - ) - db.add(user) - await db.flush() - await db.refresh(user) - return user - - @staticmethod - async def update(db: AsyncSession, user: User, data: UserUpdate) -> User: - update_data = data.model_dump(exclude_unset=True) - if "password" in update_data and update_data["password"]: - update_data["hashed_password"] = get_password_hash(update_data.pop("password")) - for key, value in update_data.items(): - setattr(user, key, value) - await db.flush() - await db.refresh(user) - return user - - @staticmethod - async def count(db: AsyncSession) -> int: - result = await db.execute(select(func.count()).select_from(User)) - return result.scalar() or 0 diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py deleted file mode 100644 index 9846889..0000000 --- a/backend/app/utils/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -工具包 -""" -from app.utils.security import get_password_hash, verify_password, create_access_token, create_refresh_token -from app.utils.dependencies import get_current_user, get_current_active_user, get_optional_user - -__all__ = [ - "get_password_hash", - "verify_password", - "create_access_token", - "create_refresh_token", - "get_current_user", - "get_current_active_user", - "get_optional_user", -] diff --git a/backend/app/utils/dependencies.py b/backend/app/utils/dependencies.py deleted file mode 100644 index 117656b..0000000 --- a/backend/app/utils/dependencies.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -FastAPI 依赖注入 -""" -from typing import Annotated, Optional - -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.utils.security import decode_token - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) -http_bearer = HTTPBearer(auto_error=False) - - -async def get_current_user( - db: Annotated[AsyncSession, Depends(get_db)], - credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(http_bearer)] = None, - token: Annotated[Optional[str], Depends(oauth2_scheme)] = None, -) -> User: - """从 JWT 解析当前用户,未认证则 401""" - raw = None - if credentials: - raw = credentials.credentials - if not raw and token: - raw = token - if not raw: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="未提供认证信息", - headers={"WWW-Authenticate": "Bearer"}, - ) - payload = decode_token(raw) - if not payload or payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效或过期的 Token", - headers={"WWW-Authenticate": "Bearer"}, - ) - user_id = payload.get("sub") - if not user_id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的 Token 载荷") - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") - return user - - -async def get_current_active_user( - current_user: Annotated[User, Depends(get_current_user)], -) -> User: - """当前用户且已激活""" - if not current_user.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已被禁用") - return current_user - - -async def get_optional_user( - db: Annotated[AsyncSession, Depends(get_db)], - credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(http_bearer)] = None, - token: Annotated[Optional[str], Depends(oauth2_scheme)] = None, -) -> Optional[User]: - """可选当前用户:有 Token 则解析,没有则返回 None""" - raw = credentials.credentials if credentials else token - if not raw: - return None - payload = decode_token(raw) - if not payload or payload.get("type") != "access": - return None - user_id = payload.get("sub") - if not user_id: - return None - result = await db.execute(select(User).where(User.id == user_id)) - return result.scalar_one_or_none() diff --git a/backend/app/utils/logging_config.py b/backend/app/utils/logging_config.py deleted file mode 100644 index 6ec94ba..0000000 --- a/backend/app/utils/logging_config.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -日志配置(可选接入 loguru) -""" -import sys -from typing import Optional - -from app.config import get_settings - -settings = get_settings() - - -def setup_logging( - level: Optional[str] = None, - format_string: Optional[str] = None, -) -> None: - """配置日志级别与格式;若已安装 loguru 可在此初始化""" - level = level or settings.LOG_LEVEL - format_string = format_string or settings.LOG_FORMAT - # 示例:若使用 loguru - # import loguru - # loguru.logger.remove() - # loguru.logger.add(sys.stderr, format=format_string, level=level) - # 当前仅占位,实际可接 loguru 或 logging - pass diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py deleted file mode 100644 index f39e3d7..0000000 --- a/backend/app/utils/security.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -安全相关:密码哈希、JWT -""" -from datetime import datetime, timedelta, timezone -from typing import Any, Optional - -from jose import JWTError, jwt -from passlib.context import CryptContext - -from app.config import get_settings - -settings = get_settings() -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def get_password_hash(password: str) -> str: - """生成密码哈希""" - return pwd_context.hash(password) - - -def verify_password(plain: str, hashed: str) -> bool: - """验证密码""" - return pwd_context.verify(plain, hashed) - - -def create_access_token( - subject: str, - username: str, - expires_delta: Optional[timedelta] = None, - extra: Optional[dict[str, Any]] = None, -) -> str: - """创建访问 Token""" - if expires_delta is None: - expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - expire = datetime.now(timezone.utc) + expires_delta - to_encode = { - "sub": subject, - "username": username, - "exp": expire, - "iat": datetime.now(timezone.utc), - "type": "access", - } - if extra: - to_encode.update(extra) - return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - - -def create_refresh_token(subject: str, username: str) -> str: - """创建刷新 Token""" - expires_delta = timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - expire = datetime.now(timezone.utc) + expires_delta - to_encode = { - "sub": subject, - "username": username, - "exp": expire, - "iat": datetime.now(timezone.utc), - "type": "refresh", - } - return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - - -def decode_token(token: str) -> Optional[dict[str, Any]]: - """解码 Token,失败返回 None""" - try: - return jwt.decode( - token, - settings.SECRET_KEY, - algorithms=[settings.ALGORITHM], - ) - except JWTError: - return None diff --git a/backend/app/utils/validators.py b/backend/app/utils/validators.py deleted file mode 100644 index 776a282..0000000 --- a/backend/app/utils/validators.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -通用校验函数 -""" -import re -from typing import Optional - - -def slug_valid(slug: Optional[str]) -> bool: - """校验 slug:小写字母、数字、连字符""" - if not slug: - return True - return bool(re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", slug)) - - -def username_valid(username: Optional[str]) -> bool: - """校验用户名:字母数字下划线,2-64 位""" - if not username: - return False - return bool(re.match(r"^[a-zA-Z0-9_]{2,64}$", username)) - - -def strong_password(password: Optional[str]) -> bool: - """简单强度:至少 8 位""" - if not password: - return False - return len(password) >= 8 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a521d68 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,33 @@ +""" +极简后端:单文件 FastAPI,内存存储 +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +app = FastAPI(title="简易 API", version="1.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# 内存存储 +items_db: list[dict] = [{"id": 1, "title": "示例事项"}] + + +class ItemCreate(BaseModel): + title: str + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/items") +def list_items(): + return {"items": items_db} + + +@app.post("/api/items") +def add_item(item: ItemCreate): + new_id = max((x["id"] for x in items_db), default=0) + 1 + items_db.append({"id": new_id, "title": item.title}) + return {"id": new_id, "title": item.title} diff --git a/backend/pytest.ini b/backend/pytest.ini deleted file mode 100644 index 98f49cf..0000000 --- a/backend/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -asyncio_mode = auto -testpaths = tests -python_files = test_*.py -python_functions = test_* diff --git a/backend/requirements.txt b/backend/requirements.txt index 20b9e26..c964103 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,30 +1,3 @@ -# Web 框架 fastapi==0.109.2 uvicorn[standard]==0.27.1 - -# 数据库 -sqlalchemy==2.0.25 -asyncpg==0.29.0 -aiosqlite==0.19.0 - -# 认证与安全 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -python-multipart==0.0.9 - -# 校验与工具 pydantic==2.6.1 -pydantic-settings==2.1.0 -email-validator==2.1.0 - -# 日志与监控 -loguru==0.7.2 - -# 测试 -pytest==8.0.0 -pytest-asyncio==0.23.4 -httpx==0.26.0 - -# 开发 -black==24.1.1 -isort==5.13.2 diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py deleted file mode 100644 index e34f8c4..0000000 --- a/backend/scripts/seed.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -数据库种子数据脚本(示例) -运行: python -m scripts.seed -需先启动前将 DATABASE_URL 指向目标库。 -""" -import asyncio -import sys -from pathlib import Path - -# 将项目根目录加入 path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import async_session_factory, init_db -from app.models.user import User -from app.models.category import Category -from app.models.item import Item -from app.utils.security import get_password_hash - - -async def run(): - await init_db() - async with async_session_factory() as db: # type: AsyncSession - # 检查是否已有数据 - from sqlalchemy import select - r = await db.execute(select(User).limit(1)) - if r.scalar_one_or_none(): - print("已有用户数据,跳过 seed") - return - - admin = User( - email="admin@example.com", - username="admin", - hashed_password=get_password_hash("admin123"), - full_name="管理员", - is_active=True, - is_superuser=True, - ) - db.add(admin) - await db.flush() - - cat = Category(name="默认分类", slug="default", description="默认分类描述") - db.add(cat) - await db.flush() - - item = Item( - title="示例商品", - slug="sample-item", - description="这是一个示例商品", - price=99.99, - stock=100, - category_id=cat.id, - is_active=True, - ) - db.add(item) - await db.commit() - print("Seed 完成: admin 用户、默认分类、示例商品已创建") - - -if __name__ == "__main__": - asyncio.run(run()) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index d4839a6..0000000 --- a/backend/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests package diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index 56b326e..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Pytest fixtures:测试客户端、数据库、测试用户 -""" -import asyncio -from typing import AsyncGenerator, Generator - -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.database import Base, get_db -from app.main import app -from app.models.user import User -from app.utils.security import get_password_hash - - -# 测试用数据库 -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" - - -@pytest.fixture(scope="session") -def event_loop() -> Generator: - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest_asyncio.fixture -async def engine(): - eng = create_async_engine(TEST_DATABASE_URL, echo=False) - async with eng.begin() as conn: - from app.models import User, Item, Order, OrderItem, Category, AuditLog # noqa: F401 - await conn.run_sync(Base.metadata.create_all) - yield eng - await eng.dispose() - - -@pytest_asyncio.fixture -async def db_session(engine) -> AsyncGenerator[AsyncSession, None]: - factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - async with factory() as session: - yield session - - -@pytest_asyncio.fixture -async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: - async def override_get_db(): - yield db_session - - app.dependency_overrides[get_db] = override_get_db - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as ac: - yield ac - app.dependency_overrides.clear() - - -@pytest_asyncio.fixture -async def test_user(db_session: AsyncSession) -> User: - user = User( - email="test@example.com", - username="testuser", - hashed_password=get_password_hash("password123"), - full_name="Test User", - is_active=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - return user diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index 8fc5564..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -认证接口测试 -""" -import pytest -from httpx import AsyncClient - -from app.models.user import User - - -@pytest.mark.asyncio -async def test_register_and_login(client: AsyncClient): - # 注册 - resp = await client.post( - "/api/v1/users", - json={ - "email": "auth@example.com", - "username": "authuser", - "password": "password123", - "full_name": "Auth User", - }, - ) - assert resp.status_code == 201 - user_data = resp.json() - assert user_data["email"] == "auth@example.com" - assert user_data["username"] == "authuser" - assert "hashed_password" not in user_data - - # 登录 - login_resp = await client.post( - "/api/v1/auth/login", - json={"username": "authuser", "password": "password123"}, - ) - assert login_resp.status_code == 200 - token_data = login_resp.json() - assert "access_token" in token_data - assert token_data.get("token_type") == "bearer" - - -@pytest.mark.asyncio -async def test_login_wrong_password(client: AsyncClient, test_user: User): - resp = await client.post( - "/api/v1/auth/login", - json={"username": test_user.username, "password": "wrong"}, - ) - assert resp.status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py deleted file mode 100644 index 1f01ca6..0000000 --- a/backend/tests/test_health.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -健康检查接口测试 -""" -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_health(client: AsyncClient): - response = await client.get("/api/v1/health") - assert response.status_code == 200 - data = response.json() - assert data.get("status") == "ok" - assert "app" in data - assert "timestamp" in data - - -@pytest.mark.asyncio -async def test_root(client: AsyncClient): - response = await client.get("/") - assert response.status_code == 200 - data = response.json() - assert "app" in data - assert data.get("api") == "/api/v1" diff --git a/frontend/index.html b/frontend/index.html index 6794b5e..1adf9da 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,12 +2,8 @@ - - Demo 前端 - - - + 简易待办
diff --git a/frontend/package.json b/frontend/package.json index d77e66b..a214252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,22 +6,16 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.22.0", - "axios": "^1.6.7" + "react-dom": "^18.2.0" }, "devDependencies": { "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.17", - "postcss": "^8.4.35", - "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.1.0" } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c174ed0..3eb60ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,96 +1,61 @@ -import { Routes, Route, Navigate } from 'react-router-dom' -import { useAuth } from './contexts/AuthContext' -import Layout from './components/Layout' -import Home from './pages/Home' -import Login from './pages/Login' -import Register from './pages/Register' -import Dashboard from './pages/Dashboard' -import Users from './pages/Users' -import Items from './pages/Items' -import ItemDetail from './pages/ItemDetail' -import Categories from './pages/Categories' -import Orders from './pages/Orders' -import OrderDetail from './pages/OrderDetail' -import Profile from './pages/Profile' -import NotFound from './pages/NotFound' +import { useState, useEffect } from 'react' -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { user, loading } = useAuth() - if (loading) { - return ( -
-
-
- ) +const API = '/api' + +type Item = { id: number; title: string } + +export default function App() { + const [items, setItems] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(true) + + const load = () => { + fetch(`${API}/items`) + .then((r) => r.json()) + .then((d) => setItems(d.items || [])) + .finally(() => setLoading(false)) } - if (!user) { - return + + useEffect(() => { + load() + }, []) + + const add = (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim()) return + fetch(`${API}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: input.trim() }), + }) + .then((r) => r.json()) + .then(() => { + setInput('') + load() + }) } - return <>{children} -} -export default function App() { return ( - - }> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - } - /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } +
+

简易待办

+
+ setInput(e.target.value)} + placeholder="输入事项" + style={{ flex: 1, padding: 8 }} /> - - } /> - + + + {loading ?

加载中...

: ( +
    + {items.map((it) => ( +
  • + {it.title} +
  • + ))} +
+ )} +
) } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts deleted file mode 100644 index 77d5aca..0000000 --- a/frontend/src/api/auth.ts +++ /dev/null @@ -1,41 +0,0 @@ -import api from './client' - -export interface LoginPayload { - username: string - password: string -} - -export interface TokenResponse { - access_token: string - refresh_token?: string - token_type: string - expires_in: number -} - -export interface UserResponse { - id: string - email: string - username: string - full_name: string | null - avatar_url: string | null - bio: string | null - is_active: boolean - is_superuser: boolean - created_at: string - updated_at: string -} - -export async function login(payload: LoginPayload): Promise { - const { data } = await api.post('/auth/login', payload) - return data -} - -export async function refreshToken(refreshToken: string): Promise { - const { data } = await api.post('/auth/refresh', { refresh_token: refreshToken }) - return data -} - -export async function getMe(): Promise { - const { data } = await api.get('/auth/me') - return data -} diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts deleted file mode 100644 index d85d187..0000000 --- a/frontend/src/api/categories.ts +++ /dev/null @@ -1,66 +0,0 @@ -import api from './client' - -export interface CategoryResponse { - id: string - name: string - slug: string - description: string | null - parent_id: string | null - created_at: string - updated_at: string -} - -export interface CategoryCreatePayload { - name: string - slug: string - description?: string - parent_id?: string -} - -export interface CategoryUpdatePayload { - name?: string - slug?: string - description?: string - parent_id?: string -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listCategories(parentId?: string): Promise { - const { data } = await api.get('/categories', { params: parentId ? { parent_id: parentId } : {} }) - return data -} - -export async function listCategoriesPaginated(params?: { - page?: number - page_size?: number - parent_id?: string -}): Promise> { - const { data } = await api.get>('/categories/paginated', { params }) - return data -} - -export async function getCategory(id: string): Promise { - const { data } = await api.get(`/categories/${id}`) - return data -} - -export async function createCategory(payload: CategoryCreatePayload): Promise { - const { data } = await api.post('/categories', payload) - return data -} - -export async function updateCategory(id: string, payload: CategoryUpdatePayload): Promise { - const { data } = await api.patch(`/categories/${id}`, payload) - return data -} - -export async function deleteCategory(id: string): Promise { - await api.delete(`/categories/${id}`) -} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 0970049..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axios, { AxiosError } from 'axios' - -const baseURL = import.meta.env.VITE_API_BASE_URL || '/api/v1' - -export const api = axios.create({ - baseURL, - headers: { - 'Content-Type': 'application/json', - }, -}) - -api.interceptors.request.use((config) => { - const token = localStorage.getItem('access_token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) - -api.interceptors.response.use( - (res) => res, - async (err: AxiosError) => { - if (err.response?.status === 401) { - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - window.dispatchEvent(new Event('auth:logout')) - } - return Promise.reject(err) - } -) - -export default api diff --git a/frontend/src/api/items.ts b/frontend/src/api/items.ts deleted file mode 100644 index 4c425dc..0000000 --- a/frontend/src/api/items.ts +++ /dev/null @@ -1,79 +0,0 @@ -import api from './client' - -export interface ItemResponse { - id: string - title: string - slug: string - description: string | null - price: string - stock: number - image_url: string | null - category_id: string | null - is_active: boolean - created_at: string - updated_at: string -} - -export interface ItemCreatePayload { - title: string - slug: string - description?: string - price: number - stock?: number - image_url?: string - category_id?: string - is_active?: boolean -} - -export interface ItemUpdatePayload { - title?: string - slug?: string - description?: string - price?: number - stock?: number - image_url?: string - category_id?: string - is_active?: boolean -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listItems(params?: { - page?: number - page_size?: number - category_id?: string - is_active?: boolean -}): Promise> { - const { data } = await api.get>('/items', { params }) - return data -} - -export async function getItem(id: string): Promise { - const { data } = await api.get(`/items/${id}`) - return data -} - -export async function getItemBySlug(slug: string): Promise { - const { data } = await api.get(`/items/slug/${slug}`) - return data -} - -export async function createItem(payload: ItemCreatePayload): Promise { - const { data } = await api.post('/items', payload) - return data -} - -export async function updateItem(id: string, payload: ItemUpdatePayload): Promise { - const { data } = await api.patch(`/items/${id}`, payload) - return data -} - -export async function deleteItem(id: string): Promise { - await api.delete(`/items/${id}`) -} diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts deleted file mode 100644 index f4e4f8b..0000000 --- a/frontend/src/api/orders.ts +++ /dev/null @@ -1,83 +0,0 @@ -import api from './client' - -export interface OrderItemResponse { - id: string - order_id: string - item_id: string - quantity: number - unit_price: string - subtotal: string - created_at: string -} - -export interface OrderResponse { - id: string - user_id: string - status: string - total_amount: string - shipping_address: string | null - note: string | null - order_items: OrderItemResponse[] - created_at: string - updated_at: string -} - -export interface OrderItemCreate { - item_id: string - quantity: number - unit_price: number -} - -export interface OrderCreatePayload { - items: OrderItemCreate[] - shipping_address?: string - note?: string -} - -export interface OrderUpdatePayload { - status?: string - shipping_address?: string - note?: string -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listMyOrders(params?: { - page?: number - page_size?: number - status?: string -}): Promise> { - const { data } = await api.get>('/orders', { params }) - return data -} - -export async function listAllOrders(params?: { - page?: number - page_size?: number - user_id?: string - status?: string -}): Promise> { - const { data } = await api.get>('/orders/admin', { params }) - return data -} - -export async function getOrder(id: string): Promise { - const { data } = await api.get(`/orders/${id}`) - return data -} - -export async function createOrder(payload: OrderCreatePayload): Promise { - const { data } = await api.post('/orders', payload) - return data -} - -export async function updateOrder(id: string, payload: OrderUpdatePayload): Promise { - const { data } = await api.patch(`/orders/${id}`, payload) - return data -} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts deleted file mode 100644 index 3ee554b..0000000 --- a/frontend/src/api/users.ts +++ /dev/null @@ -1,53 +0,0 @@ -import api from './client' -import type { UserResponse } from './auth' - -export interface UserCreatePayload { - email: string - username: string - password: string - full_name?: string - bio?: string - is_active?: boolean -} - -export interface UserUpdatePayload { - email?: string - username?: string - full_name?: string - avatar_url?: string - bio?: string - is_active?: boolean - password?: string -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listUsers(params?: { page?: number; page_size?: number }): Promise> { - const { data } = await api.get>('/users', { params }) - return data -} - -export async function getUser(id: string): Promise { - const { data } = await api.get(`/users/${id}`) - return data -} - -export async function createUser(payload: UserCreatePayload): Promise { - const { data } = await api.post('/users', payload) - return data -} - -export async function updateUser(id: string, payload: UserUpdatePayload): Promise { - const { data } = await api.patch(`/users/${id}`, payload) - return data -} - -export async function deleteUser(id: string): Promise { - await api.delete(`/users/${id}`) -} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx deleted file mode 100644 index 35c70e7..0000000 --- a/frontend/src/components/Button.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: Variant - loading?: boolean - children: React.ReactNode -} - -const variantClass: Record = { - primary: 'btn-primary', - secondary: 'btn-secondary', - ghost: 'btn-ghost', - danger: 'btn bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white', -} - -export default function Button({ - variant = 'primary', - loading = false, - disabled, - children, - className = '', - ...props -}: ButtonProps) { - return ( - - ) -} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx deleted file mode 100644 index aacb7fe..0000000 --- a/frontend/src/components/Card.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' - -interface CardProps { - children: React.ReactNode - className?: string - title?: string -} - -export default function Card({ children, className = '', title }: CardProps) { - return ( -
- {title &&

{title}

} - {children} -
- ) -} diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx deleted file mode 100644 index feab7b8..0000000 --- a/frontend/src/components/Input.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -interface InputProps extends React.InputHTMLAttributes { - label?: string - error?: string -} - -export default function Input({ label, error, className = '', id, ...props }: InputProps) { - const inputId = id || (label ? label.replace(/\s/g, '-').toLowerCase() : undefined) - return ( -
- {label && ( - - )} - - {error &&

{error}

} -
- ) -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 56878d9..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Outlet } from 'react-router-dom' -import Nav from './Nav' - -export default function Layout() { - return ( -
-
- ) -} diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx deleted file mode 100644 index b2dbb10..0000000 --- a/frontend/src/components/Nav.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Link, useNavigate } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' - -export default function Nav() { - const { user, logout } = useAuth() - const navigate = useNavigate() - - const handleLogout = () => { - logout() - navigate('/') - } - - return ( - - ) -} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx deleted file mode 100644 index 8352556..0000000 --- a/frontend/src/components/Pagination.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -interface PaginationProps { - page: number - pages: number - onPageChange: (page: number) => void - total: number - pageSize: number -} - -export default function Pagination({ page, pages, onPageChange, total, pageSize }: PaginationProps) { - const start = (page - 1) * pageSize + 1 - const end = Math.min(page * pageSize, total) - - return ( -
- - 第 {start}-{end} 条,共 {total} 条 - -
- - - {page} / {pages || 1} - - -
-
- ) -} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx deleted file mode 100644 index 0b199de..0000000 --- a/frontend/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react' -import type { UserResponse } from '../api/auth' -import { getMe, login as apiLogin, refreshToken } from '../api/auth' - -interface AuthState { - user: UserResponse | null - loading: boolean - error: string | null -} - -interface AuthContextValue extends AuthState { - login: (username: string, password: string) => Promise - logout: () => void - refreshUser: () => Promise -} - -const AuthContext = createContext(null) - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [state, setState] = useState({ - user: null, - loading: true, - error: null, - }) - - const loadUser = useCallback(async () => { - const token = localStorage.getItem('access_token') - const refresh = localStorage.getItem('refresh_token') - if (!token) { - if (refresh) { - try { - const res = await refreshToken(refresh) - localStorage.setItem('access_token', res.access_token) - if (res.refresh_token) localStorage.setItem('refresh_token', res.refresh_token) - const user = await getMe() - setState({ user, loading: false, error: null }) - return - } catch { - localStorage.removeItem('refresh_token') - } - } - setState({ user: null, loading: false, error: null }) - return - } - try { - const user = await getMe() - setState({ user, loading: false, error: null }) - } catch { - setState({ user: null, loading: false, error: null }) - } - }, []) - - useEffect(() => { - loadUser() - }, [loadUser]) - - useEffect(() => { - const handleLogout = () => { - setState({ user: null, loading: false, error: null }) - } - window.addEventListener('auth:logout', handleLogout) - return () => window.removeEventListener('auth:logout', handleLogout) - }, []) - - const login = useCallback(async (username: string, password: string) => { - setState((s) => ({ ...s, loading: true, error: null })) - try { - const res = await apiLogin({ username, password }) - localStorage.setItem('access_token', res.access_token) - if (res.refresh_token) localStorage.setItem('refresh_token', res.refresh_token) - const user = await getMe() - setState({ user, loading: false, error: null }) - } catch (err: unknown) { - const message = err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail as string | undefined - : '登录失败' - setState({ user: null, loading: false, error: message || '登录失败' }) - throw err - } - }, []) - - const logout = useCallback(() => { - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - setState({ user: null, loading: false, error: null }) - }, []) - - const refreshUser = useCallback(async () => { - const token = localStorage.getItem('access_token') - if (!token) return - try { - const user = await getMe() - setState((s) => ({ ...s, user })) - } catch { - logout() - } - }, [logout]) - - const value: AuthContextValue = { - ...state, - login, - logout, - refreshUser, - } - - return {children} -} - -export function useAuth() { - const ctx = useContext(AuthContext) - if (!ctx) throw new Error('useAuth must be used within AuthProvider') - return ctx -} diff --git a/frontend/src/index.css b/frontend/src/index.css index 39df664..1cc7ee5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,45 +1,2 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --color-bg: #0f172a; - --color-surface: #1e293b; - --color-border: #334155; - --color-text: #f1f5f9; - --color-muted: #94a3b8; -} - -body { - margin: 0; - font-family: 'DM Sans', system-ui, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: var(--color-bg); - color: var(--color-text); - min-height: 100vh; -} - -@layer components { - .btn { - @apply px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900; - } - .btn-primary { - @apply btn bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500; - } - .btn-secondary { - @apply btn bg-slate-600 text-white hover:bg-slate-500 focus:ring-slate-500; - } - .btn-ghost { - @apply btn bg-transparent hover:bg-slate-700 focus:ring-slate-500; - } - .input { - @apply w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; - } - .card { - @apply bg-slate-800/50 rounded-xl border border-slate-700 p-6 shadow-xl; - } - .page-title { - @apply text-2xl font-bold text-slate-100 mb-6; - } -} +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, sans-serif; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f92c451..964aeb4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,16 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' import App from './App' -import { AuthProvider } from './contexts/AuthContext' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + , ) diff --git a/frontend/src/pages/Categories.tsx b/frontend/src/pages/Categories.tsx deleted file mode 100644 index f7cf785..0000000 --- a/frontend/src/pages/Categories.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useState, useEffect } from 'react' -import { - listCategories, - createCategory, - updateCategory, - deleteCategory, - type CategoryResponse, - type CategoryCreatePayload, - type CategoryUpdatePayload, -} from '../api/categories' -import Card from '../components/Card' -import Button from '../components/Button' -import Input from '../components/Input' - -export default function Categories() { - const [categories, setCategories] = useState([]) - const [loading, setLoading] = useState(true) - const [editingId, setEditingId] = useState(null) - const [formOpen, setFormOpen] = useState(false) - const [formName, setFormName] = useState('') - const [formSlug, setFormSlug] = useState('') - const [formDesc, setFormDesc] = useState('') - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState('') - - const load = async () => { - setLoading(true) - try { - const data = await listCategories() - setCategories(data) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, []) - - const openCreate = () => { - setEditingId(null) - setFormName('') - setFormSlug('') - setFormDesc('') - setFormOpen(true) - setError('') - } - - const openEdit = (c: CategoryResponse) => { - setEditingId(c.id) - setFormName(c.name) - setFormSlug(c.slug) - setFormDesc(c.description || '') - setFormOpen(true) - setError('') - } - - const slugFromName = (name: string) => - name - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setSubmitLoading(true) - try { - if (editingId) { - await updateCategory(editingId, { - name: formName, - slug: formSlug, - description: formDesc || undefined, - }) - } else { - await createCategory({ - name: formName, - slug: formSlug, - description: formDesc || undefined, - }) - } - setFormOpen(false) - await load() - } catch (err: unknown) { - const data = err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { detail?: string } } }).response?.data - : null - setError(typeof data?.detail === 'string' ? data.detail : '操作失败') - } finally { - setSubmitLoading(false) - } - } - - const handleDelete = async (id: string) => { - if (!window.confirm('确定删除该分类?')) return - try { - await deleteCategory(id) - await load() - } catch { - setError('删除失败') - } - } - - return ( -
-
-

分类管理

- -
- - {formOpen && ( - -

- {editingId ? '编辑分类' : '新建分类'} -

-
- {error &&

{error}

} - { - setFormName(e.target.value) - if (!editingId) setFormSlug(slugFromName(e.target.value)) - }} - required - /> - setFormSlug(e.target.value)} - required - /> - setFormDesc(e.target.value)} - /> -
- - -
-
-
- )} - - - {loading ? ( -
-
-
- ) : ( -
    - {categories.map((c) => ( -
  • -
    - {c.name} - /{c.slug} - {c.description && ( -

    {c.description}

    - )} -
    -
    - - -
    -
  • - ))} -
- )} - -
- ) -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index 753e296..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Link } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' -import Card from '../components/Card' - -export default function Dashboard() { - const { user } = useAuth() - - return ( -
-

控制台

-

- 欢迎,{user?.full_name || user?.username}。以下是常用入口。 -

- -
- - 用户 -

管理用户列表

- - - 商品 -

商品列表与维护

- - - 分类 -

分类管理

- - - 订单 -

我的订单

- -
- - -
-
用户名
-
{user?.username}
-
邮箱
-
{user?.email}
-
昵称
-
{user?.full_name || '—'}
-
角色
-
{user?.is_superuser ? '管理员' : '普通用户'}
-
- - 编辑个人资料 - -
-
- ) -} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx deleted file mode 100644 index 9700013..0000000 --- a/frontend/src/pages/Home.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Link } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' - -export default function Home() { - const { user } = useAuth() - - return ( -
-
-

- 欢迎使用 Demo 前端 -

-

- 与 FastAPI 后端配套的 React 示例项目,包含用户、商品、分类与订单模块。 -

- {user ? ( -
- 进入控制台 - 浏览商品 -
- ) : ( -
- 登录 - 注册 - 浏览商品 -
- )} -
- -
-
-

用户与认证

-

- 注册、登录、JWT 刷新,个人资料与权限控制。 -

- - 前往 → - -
-
-

商品与分类

-

- 商品列表、详情、分类管理,支持分页与筛选。 -

- - 浏览商品 → - -
-
-

订单

-

- 下单、订单列表与详情,需登录后使用。 -

- - 我的订单 → - -
-
-
- ) -} diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx deleted file mode 100644 index b0a15f7..0000000 --- a/frontend/src/pages/ItemDetail.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, useEffect } from 'react' -import { useParams, Link } from 'react-router-dom' -import { getItem, type ItemResponse } from '../api/items' -import Card from '../components/Card' -import Button from '../components/Button' -import { useAuth } from '../contexts/AuthContext' - -export default function ItemDetail() { - const { id } = useParams<{ id: string }>() - const [item, setItem] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const { user } = useAuth() - - useEffect(() => { - if (!id) return - getItem(id) - .then(setItem) - .catch(() => setError('商品不存在或加载失败')) - .finally(() => setLoading(false)) - }, [id]) - - if (loading) { - return ( -
-
-
- ) - } - - if (error || !item) { - return ( -
-

{error || '未找到商品'}

- 返回列表 -
- ) - } - - return ( -
- - ← 返回商品列表 - -
-
- {item.image_url ? ( - {item.title} - ) : ( -
- 暂无图片 -
- )} -
-
- -

{item.title}

-

¥ {item.price}

-

库存:{item.stock}

- {item.description && ( -

{item.description}

- )} - {user && ( - - 去下单 - - )} -
-
-
-
- ) -} diff --git a/frontend/src/pages/Items.tsx b/frontend/src/pages/Items.tsx deleted file mode 100644 index 4de1045..0000000 --- a/frontend/src/pages/Items.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { listItems, type ItemResponse } from '../api/items' -import Card from '../components/Card' -import Pagination from '../components/Pagination' -import { useAuth } from '../contexts/AuthContext' - -export default function Items() { - const [items, setItems] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize] = useState(12) - const [loading, setLoading] = useState(true) - const { user } = useAuth() - - useEffect(() => { - let cancelled = false - setLoading(true) - listItems({ page, page_size: pageSize }) - .then((res) => { - if (!cancelled) { - setItems(res.items) - setTotal(res.total) - } - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { cancelled = true } - }, [page, pageSize]) - - const pages = Math.ceil(total / pageSize) || 1 - - return ( -
-
-

商品列表

- {user && ( - 我的订单 - )} -
- - {loading ? ( -
-
-
- ) : ( - <> -
- {items.map((item) => ( - - {item.image_url ? ( - {item.title} - ) : ( -
- 暂无图片 -
- )} -

{item.title}

-

¥ {item.price}

-

库存 {item.stock}

- - ))} -
- - - )} -
- ) -} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx deleted file mode 100644 index ab07fde..0000000 --- a/frontend/src/pages/Login.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' -import Input from '../components/Input' -import Button from '../components/Button' -import Card from '../components/Card' - -export default function Login() { - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const { login, user } = useAuth() - const navigate = useNavigate() - - if (user) { - navigate('/dashboard', { replace: true }) - return null - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setLoading(true) - try { - await login(username, password) - navigate('/dashboard', { replace: true }) - } catch { - setError('用户名或密码错误,请重试') - } finally { - setLoading(false) - } - } - - return ( -
- -
- {error && ( -
- {error} -
- )} - setUsername(e.target.value)} - required - autoComplete="username" - /> - setPassword(e.target.value)} - required - autoComplete="current-password" - /> -
- - - 没有账号?去注册 - -
-
-
-
- ) -} diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx deleted file mode 100644 index c0da97b..0000000 --- a/frontend/src/pages/NotFound.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Link } from 'react-router-dom' - -export default function NotFound() { - return ( -
-

404

-

页面不存在

- - 返回首页 - -
- ) -} diff --git a/frontend/src/pages/OrderDetail.tsx b/frontend/src/pages/OrderDetail.tsx deleted file mode 100644 index bd4ffe2..0000000 --- a/frontend/src/pages/OrderDetail.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState, useEffect } from 'react' -import { useParams, Link } from 'react-router-dom' -import { getOrder, updateOrder, type OrderResponse } from '../api/orders' -import Card from '../components/Card' -import Button from '../components/Button' -import { useAuth } from '../contexts/AuthContext' - -const statusMap: Record = { - pending: '待支付', - paid: '已支付', - shipped: '已发货', - completed: '已完成', - cancelled: '已取消', -} - -export default function OrderDetail() { - const { id } = useParams<{ id: string }>() - const [order, setOrder] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [updating, setUpdating] = useState(false) - const { user } = useAuth() - - const load = async () => { - if (!id) return - try { - const data = await getOrder(id) - setOrder(data) - } catch { - setError('订单不存在或加载失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, [id]) - - const handleStatusChange = async (newStatus: string) => { - if (!order) return - setUpdating(true) - try { - const updated = await updateOrder(order.id, { status: newStatus }) - setOrder(updated) - } finally { - setUpdating(false) - } - } - - if (loading) { - return ( -
-
-
- ) - } - - if (error || !order) { - return ( -
-

{error || '未找到订单'}

- 返回列表 -
- ) - } - - const canChangeStatus = user?.is_superuser || order.user_id === user?.id - - return ( -
- - ← 返回订单列表 - - - -
-
状态
-
{statusMap[order.status] ?? order.status}
-
总金额
-
¥ {order.total_amount}
-
创建时间
-
{new Date(order.created_at).toLocaleString('zh-CN')}
- {order.shipping_address && ( - <> -
收货地址
-
{order.shipping_address}
- - )} - {order.note && ( - <> -
备注
-
{order.note}
- - )} -
- {canChangeStatus && ( -
- {(['pending', 'paid', 'shipped', 'completed', 'cancelled'] as const).map((s) => ( - - ))} -
- )} -
- - -
    - {order.order_items.map((oi) => ( -
  • - 商品 ID: {oi.item_id.slice(0, 8)} - × {oi.quantity} - ¥ {oi.subtotal} -
  • - ))} -
-
-
- ) -} diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx deleted file mode 100644 index 23f259f..0000000 --- a/frontend/src/pages/Orders.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { listMyOrders, listAllOrders, type OrderResponse } from '../api/orders' -import Card from '../components/Card' -import Pagination from '../components/Pagination' -import { useAuth } from '../contexts/AuthContext' - -const statusMap: Record = { - pending: '待支付', - paid: '已支付', - shipped: '已发货', - completed: '已完成', - cancelled: '已取消', -} - -export default function Orders() { - const [orders, setOrders] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize] = useState(10) - const [statusFilter, setStatusFilter] = useState('') - const [loading, setLoading] = useState(true) - const [useAdmin, setUseAdmin] = useState(false) - const { user } = useAuth() - - const load = async () => { - setLoading(true) - try { - const params = { page, page_size: pageSize, ...(statusFilter ? { status: statusFilter } : {}) } - const res = user?.is_superuser && useAdmin - ? await listAllOrders(params) - : await listMyOrders(params) - setOrders(res.items) - setTotal(res.total) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, [page, statusFilter, useAdmin]) - - const pages = Math.ceil(total / pageSize) || 1 - - return ( -
-
-

订单列表

- {user?.is_superuser && ( - - )} -
- -
- -
- - - {loading ? ( -
-
-
- ) : orders.length === 0 ? ( -

暂无订单

- ) : ( - <> -
    - {orders.map((order) => ( -
  • -
    -
    - - 订单 #{order.id.slice(0, 8)} - - - {new Date(order.created_at).toLocaleString('zh-CN')} - -
    - - {statusMap[order.status] ?? order.status} - - - ¥ {order.total_amount} - -
    -

    - {order.order_items.length} 件商品 -

    -
  • - ))} -
- - - )} - -
- ) -} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx deleted file mode 100644 index ec3dcc4..0000000 --- a/frontend/src/pages/Profile.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '../contexts/AuthContext' -import { updateUser } from '../api/users' -import Card from '../components/Card' -import Input from '../components/Input' -import Button from '../components/Button' - -export default function Profile() { - const { user, refreshUser } = useAuth() - const [fullName, setFullName] = useState(user?.full_name ?? '') - const [bio, setBio] = useState(user?.bio ?? '') - const [loading, setLoading] = useState(false) - const [message, setMessage] = useState<{ type: 'ok' | 'err'; text: string } | null>(null) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!user) return - setLoading(true) - setMessage(null) - try { - await updateUser(user.id, { full_name: fullName, bio: bio }) - await refreshUser() - setMessage({ type: 'ok', text: '保存成功' }) - } catch { - setMessage({ type: 'err', text: '保存失败' }) - } finally { - setLoading(false) - } - } - - if (!user) return null - - return ( -
-

个人资料

- -
-
用户名
-
{user.username}
-
邮箱
-
{user.email}
-
角色
-
{user.is_superuser ? '管理员' : '普通用户'}
-
- -
- {message && ( -

{message.text}

- )} - setFullName(e.target.value)} - /> -
- -