From 93d21c08f236a721208daf0526a0f4bdc07f18bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Mon, 27 Jul 2026 11:10:00 +0800 Subject: [PATCH 01/21] =?UTF-8?q?feat(mobile):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E7=A1=AE=E8=AE=A4=E8=AE=A2=E5=8D=95=E9=A1=B5?= =?UTF-8?q?=20MobileCheckoutView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于组长基线 7a1e44c:补齐 /m/checkout 真实结算页(购物车已选商品、地址选择、备注、POST /orders、跳转支付),并提交 20260727 日报。仅切换 mobile-checkout 路由映射。 --- .../frontend/src/router/index.js | 3 +- .../src/views/mobile/MobileCheckoutView.vue | 409 ++++++++++++++++++ ...7-\345\220\264\346\235\276\351\235\222.md" | 60 +++ 3 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue create mode 100644 "reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" diff --git a/eshop-release-v1.0/frontend/src/router/index.js b/eshop-release-v1.0/frontend/src/router/index.js index df053fa..25bc750 100644 --- a/eshop-release-v1.0/frontend/src/router/index.js +++ b/eshop-release-v1.0/frontend/src/router/index.js @@ -116,12 +116,11 @@ const routes = [ { path: 'checkout', name: 'mobile-checkout', - component: MobilePlaceholderView, + component: () => import('../views/mobile/MobileCheckoutView.vue'), meta: { title: '确认订单', requiresAuth: true, moduleOwner: '订单与支付模块', - placeholderDescription: '手机订单确认页面待接入', }, }, { diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue new file mode 100644 index 0000000..dce61aa --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue @@ -0,0 +1,409 @@ + + + + + diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..b0a2192 --- /dev/null +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,60 @@ +# 日报 - 吴松青 - 2026-07-27 + +**学号**:2444310213 +**组员**:组员 4(订单、支付与 C03 模块负责人) +**分支**:`feature/mobile-order-payment` +**基线**:组长仓库 `7a1e44c fix(mobile): 完善订单列表详情与支付交互` + +--- + +## 今日完成 + +1. **对照今日任务安排,核对模块 C 完成情况** + - 已完成(基线已有,今日回归确认):手机订单列表 `/m/orders`、订单详情 `/m/orders/:id`、模拟支付 `/m/orders/:id/pay`(含状态限制:非待支付不可支付;详情侧取消/确认收货按状态展示)。 + - 未完成项(今日主任务):手机确认订单 `/m/checkout` 仍为占位页 → 已补齐。 + +2. **完成手机确认订单页 `MobileCheckoutView.vue`** + - 从购物车读取 `selected` 商品,展示名称、规格、数量、单价、小计与合计。 + - 失效/库存不足商品单独分组提示,不可提交。 + - 页内直接选择收货地址(默认地址优先),支持跳转地址管理页。 + - 支持订单备注(最长 255)。 + - 调用 `POST /orders`(`addressId` + `remark`),创建成功后 `replace` 跳转 `/m/orders/:id/pay`。 + - 提交中 `submitting` 防重复点击;未选商品/地址时按钮禁用并 Toast 提示。 + +3. **路由接入(仅改 checkout 一行)** + - `router/index.js`:`mobile-checkout` 从 `MobilePlaceholderView` 切换为 `MobileCheckoutView.vue`。 + - 商品/购物车/地址路由仍由其他组员与组长统一接入,未越权修改。 + +4. **C03 / 边界场景核对(结合已有后端与订单页)** + - 超时支付:后端 `ORDER_PAYMENT_EXPIRED` + 定时取消;支付页仅 `PENDING_PAYMENT` 可支付。 + - 库存不足:下单时 `OUT_OF_STOCK` / 前端失效商品拦截。 + - 重复操作:提交按钮 loading/disabled;支付页 `paying` 状态。 + - 非法状态流转:取消/确认收货/支付按钮按状态显隐,后端状态机兜底。 + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 今日任务要求从组长 `7a1e44c` 基线开分支,本地 fork master 与基线不一致 | 已解决 | 新增 `leader` 远程,从 `leader/master` 创建 `feature/mobile-order-payment` | +| 地址/购物车手机页仍可能是占位,确认订单需能独立选地址 | 已解决 | 结算页内嵌地址列表选择,不强制依赖地址管理页完成编辑 | +| 协作规则要求尽量不多人改 `router/index.js` | 已解决 | 仅切换 `mobile-checkout` 组件映射,其余路由不动,便于组长集成 | + +## 明日计划 + +1. 配合组长做手机全链路联调:购物车已选商品 → 确认订单 → 支付 → 订单列表状态。 +2. 根据联调缺陷修复确认订单页(金额对齐、空态文案、返回栈等)。 +3. 如有需要,补充 C03 超时取消的演示说明与边界用例记录。 + +## 今日工时 + +约 6 小时 + +## 变更说明(交付) + +| 项 | 内容 | +|----|------| +| 修改文件 | `eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue`(新增)
`eshop-release-v1.0/frontend/src/router/index.js`(仅 checkout)
`reports/daily/吴松青_2444310213/20260727-吴松青.md` | +| 完成功能 | 手机确认订单页真实化 + 下单跳转支付 | +| 使用接口 | `GET /addresses`、`GET /cart`、`POST /orders` | +| 如何测试 | 登录后保证购物车有已选可用商品与至少一个地址 → 打开 `/m/checkout` → 选地址 → 提交 → 进入支付页 | +| 仍有问题 | 完整主链路依赖组员 2/3 的商品、购物车、地址页接入;本组页面可独立打开与联调 | -- Gitee From c532cd316ff09e7ca3aa5cda6f26dbb8fae3e96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Mon, 27 Jul 2026 14:47:09 +0800 Subject: [PATCH 02/21] =?UTF-8?q?chore(scripts):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E5=90=8E=E7=AB=AF=E5=8D=A1=E6=AD=BB=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E9=87=8D=E5=90=AF=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 便于释放 8080、用本机 MySQL 重新拉起后端;日志与临时 bat 不入库。 --- eshop-release-v1.0/.gitignore | 7 + .../scripts/fix-and-restart.ps1 | 161 ++++++++++++++++++ .../scripts/restart-backend.ps1 | 57 +++++++ 3 files changed, 225 insertions(+) create mode 100644 eshop-release-v1.0/scripts/fix-and-restart.ps1 create mode 100644 eshop-release-v1.0/scripts/restart-backend.ps1 diff --git a/eshop-release-v1.0/.gitignore b/eshop-release-v1.0/.gitignore index f3eeeb0..8f1c267 100644 --- a/eshop-release-v1.0/.gitignore +++ b/eshop-release-v1.0/.gitignore @@ -14,3 +14,10 @@ data/ .idea/ .vscode/ *.iml + +# local restart logs +backend-restart.log +backend-restart.log.err +scripts/_run-backend-tmp.bat +scripts/_kill-8080.ps1 + diff --git a/eshop-release-v1.0/scripts/fix-and-restart.ps1 b/eshop-release-v1.0/scripts/fix-and-restart.ps1 new file mode 100644 index 0000000..15fcd9b --- /dev/null +++ b/eshop-release-v1.0/scripts/fix-and-restart.ps1 @@ -0,0 +1,161 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Fix hung E-Shop backend and restart services. +#> +param( + [string]$MysqlUser = "root", + [string]$MysqlPassword = "123456", + [int]$BackendPort = 8080, + [switch]$UseDocker +) + +$ErrorActionPreference = "Continue" +$Root = Split-Path -Parent $PSScriptRoot +Set-Location $Root + +function Write-Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } +function Write-Ok($msg) { Write-Host "OK $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host "WARN $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host "ERR $msg" -ForegroundColor Red } + +function Free-Port([int]$Port) { + Write-Step "Free port $Port" + $conns = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if (-not $conns) { + Write-Ok "Port $Port already free" + return $true + } + foreach ($c in $conns) { + $procId = $c.OwningProcess + Write-Host "Killing PID $procId ..." + & taskkill.exe /F /T /PID $procId 2>&1 | Out-Host + } + Start-Sleep -Seconds 2 + $left = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if ($left.Count -gt 0) { + Write-Warn "Port $Port still held by PID $($left[0].OwningProcess)" + return $false + } + Write-Ok "Port $Port is free" + return $true +} + +function Test-DockerReady { + try { + $null = & docker info 2>$null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Test-Health([string]$Url, [int]$Tries = 40, [int]$DelaySec = 3) { + for ($i = 0; $i -lt $Tries; $i++) { + try { + $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3 + if ($r.Content -match 'UP') { return $true } + } catch {} + Start-Sleep -Seconds $DelaySec + } + return $false +} + +Write-Step "Project: $Root" +[void](Free-Port -Port $BackendPort) + +# Try Docker first if requested or already ready +$dockerExe = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe" +if ($UseDocker -or (Test-DockerReady)) { + if (-not (Test-DockerReady) -and (Test-Path $dockerExe)) { + Write-Step "Starting Docker Desktop" + Start-Process $dockerExe | Out-Null + $deadline = (Get-Date).AddMinutes(4) + while (-not (Test-DockerReady) -and (Get-Date) -lt $deadline) { + Write-Host " waiting Docker engine..." + Start-Sleep -Seconds 5 + } + } + + if (Test-DockerReady) { + if (-not (Test-Path (Join-Path $Root ".env"))) { + Copy-Item (Join-Path $Root ".env.example") (Join-Path $Root ".env") -Force + } + Write-Step "docker compose up -d --build" + & docker compose --project-directory $Root up -d --build + if ($LASTEXITCODE -ne 0) { + Write-Err "docker compose failed" + exit 1 + } + Write-Step "Waiting http://127.0.0.1:8088/api/actuator/health" + if (Test-Health "http://127.0.0.1:8088/api/actuator/health" 48 5) { + Write-Ok "Docker stack UP -> http://127.0.0.1:8088" + exit 0 + } + Write-Warn "Compose started but health not ready. Run: docker compose logs" + exit 0 + } + Write-Warn "Docker not ready, fall back to local JAR" +} + +# Local JAR + local MySQL +$Jar = Join-Path $Root "backend\target\eshop-backend-0.0.1-SNAPSHOT.jar" +if (-not (Test-Path $Jar)) { + Write-Err "Missing JAR: $Jar" + exit 1 +} + +if ([string]::IsNullOrEmpty($MysqlPassword)) { + Write-Host "" + Write-Host "Local MySQL password is required (root password you set when installing MySQL)." + $sec = Read-Host "MySQL password for user '$MysqlUser'" -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) + $MysqlPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) +} + +$Java = "C:\Program Files\Eclipse Adoptium\jdk-17.0.19.10-hotspot\bin\java.exe" +if (-not (Test-Path $Java)) { + $Java = (Get-Command java -ErrorAction Stop).Source +} + +$Log = Join-Path $Root "backend-restart.log" +$ErrLog = Join-Path $Root "backend-restart.log.err" +$Bat = Join-Path $Root "scripts\_run-backend-tmp.bat" + +# Escape special chars for bat +$passEsc = $MysqlPassword -replace '%', '%%' -replace '[&|<>^]', '^$&' + +@( + "@echo off" + "set SERVER_PORT=$BackendPort" + "set JWT_SECRET=ZXNob3AtZGV2ZWxvcG1lbnQtc2VjcmV0LWtleS1jaGFuZ2UtbWUtMjAyNg==" + "set MYSQL_HOST=localhost" + "set MYSQL_PORT=3306" + "set MYSQL_DATABASE=eshop" + "set MYSQL_USER=$MysqlUser" + "set MYSQL_PASSWORD=$passEsc" + "set BOOTSTRAP_ADMIN_ENABLED=true" + "set BOOTSTRAP_ADMIN_USERNAME=admin" + "set BOOTSTRAP_ADMIN_PASSWORD=admin123" + "set ORDER_TIMEOUT_ENABLED=true" + "cd /d `"$(Join-Path $Root 'backend')`"" + "`"$Java`" -jar `"$Jar`" --server.port=$BackendPort > `"$Log`" 2> `"$ErrLog`"" +) | Set-Content -Path $Bat -Encoding ASCII + +Write-Step "Launch backend JAR on port $BackendPort" +Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$Bat`"" -WorkingDirectory $Root -WindowStyle Minimized + +Write-Step "Waiting http://127.0.0.1:$BackendPort/api/actuator/health" +if (Test-Health "http://127.0.0.1:$BackendPort/api/actuator/health" 40 3) { + Write-Ok "Backend UP" + Write-Host "Health: http://127.0.0.1:$BackendPort/api/actuator/health" + Write-Host "Frontend (vite): http://127.0.0.1:5173" + Write-Host "Admin bootstrap: admin / admin123" + exit 0 +} + +Write-Err "Backend failed to become healthy" +Write-Host "Log: $Log" +if (Test-Path $Log) { Get-Content $Log -Tail 40 } +if (Test-Path $ErrLog) { Get-Content $ErrLog -Tail 40 } +exit 1 diff --git a/eshop-release-v1.0/scripts/restart-backend.ps1 b/eshop-release-v1.0/scripts/restart-backend.ps1 new file mode 100644 index 0000000..0878067 --- /dev/null +++ b/eshop-release-v1.0/scripts/restart-backend.ps1 @@ -0,0 +1,57 @@ +# Restart local Spring Boot backend (default port 8081 if 8080 is stuck) +param( + [int]$Port = 8081, + [string]$MysqlUser = "root", + [string]$MysqlPassword = "root", + [string]$MysqlDatabase = "eshop" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Jar = Join-Path $Root "backend\target\eshop-backend-0.0.1-SNAPSHOT.jar" +$Log = Join-Path $Root "backend-restart.log" +$Java = "C:\Program Files\Eclipse Adoptium\jdk-17.0.19.10-hotspot\bin\java.exe" + +if (-not (Test-Path $Jar)) { + throw "JAR not found: $Jar. Run mvn package in backend first." +} +if (-not (Test-Path $Java)) { + $Java = (Get-Command java -ErrorAction Stop).Source +} + +# Free the chosen port if we can +$listeners = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue +foreach ($l in $listeners) { + try { + Stop-Process -Id $l.OwningProcess -Force -ErrorAction Stop + Write-Host "Killed PID $($l.OwningProcess) on port $Port" + } catch { + Write-Host "WARN: cannot kill PID $($l.OwningProcess) on port $Port : $($_.Exception.Message)" + } +} + +$env:SERVER_PORT = "$Port" +$env:JWT_SECRET = "ZXNob3AtZGV2ZWxvcG1lbnQtc2VjcmV0LWtleS1jaGFuZ2UtbWUtMjAyNg==" +$env:MYSQL_HOST = "localhost" +$env:MYSQL_PORT = "3306" +$env:MYSQL_DATABASE = $MysqlDatabase +$env:MYSQL_USER = $MysqlUser +$env:MYSQL_PASSWORD = $MysqlPassword +$env:BOOTSTRAP_ADMIN_ENABLED = "true" +$env:BOOTSTRAP_ADMIN_USERNAME = "admin" +$env:BOOTSTRAP_ADMIN_PASSWORD = "admin123" +$env:ORDER_TIMEOUT_ENABLED = "true" + +if (Test-Path $Log) { Remove-Item $Log -Force } + +Write-Host "Starting backend on port $Port ..." +Write-Host "MySQL: $MysqlUser@$($env:MYSQL_HOST):$($env:MYSQL_PORT)/$MysqlDatabase" +Write-Host "Log: $Log" + +$args = @( + "-jar", $Jar, + "--server.port=$Port" +) + +# Run in current process tree so env is inherited; redirect to log +& $Java @args *>> $Log -- Gitee From c1c6ca1347edb6c6a912538c85f252e82944e97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Mon, 27 Jul 2026 17:32:29 +0800 Subject: [PATCH 03/21] =?UTF-8?q?merge:=20=E8=9E=8D=E5=90=88=E7=BB=84?= =?UTF-8?q?=E9=95=BF=E6=9C=80=E6=96=B0=20eshop-release-v1.0=20=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20C=20=E6=A8=A1=E5=9D=97=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步组长压缩包中的手机商品/购物车/地址/个人中心、后台用户管理、PC 资料页等完整前端,以及后端与文档更新;保留吴松青日报、确认订单相关能力与本机重启脚本。前端 build 已通过。 --- .gitignore | 6 + eshop-release-v1.0/.gitignore | 7 - eshop-release-v1.0/frontend/src/api/admin.js | 15 + eshop-release-v1.0/frontend/src/api/auth.js | 1 + .../frontend/src/layouts/AdminLayout.vue | 2 +- .../frontend/src/layouts/PcShopLayout.vue | 1 + .../frontend/src/router/index.js | 23 +- .../src/views/admin/AdminUsersView.vue | 290 +++++++++++ .../src/views/mobile/MobileAddressView.vue | 458 +++++++++++++++++ .../src/views/mobile/MobileCartView.vue | 419 +++++++++++++++ .../src/views/mobile/MobileCheckoutView.vue | 114 +++-- .../views/mobile/MobileProductDetailView.vue | 484 ++++++++++++++++++ .../views/mobile/MobileProductListView.vue | 449 ++++++++++++++++ .../src/views/mobile/MobileProfileView.vue | 155 ++++++ .../frontend/src/views/pc/PcProfileView.vue | 180 +++++++ ...2-\345\220\264\346\235\276\351\235\222.md" | 23 + ...3-\345\220\264\346\235\276\351\235\222.md" | 41 ++ ...4-\345\220\264\346\235\276\351\235\222.md" | 61 +++ ...7-\345\220\264\346\235\276\351\235\222.md" | 60 +++ 19 files changed, 2725 insertions(+), 64 deletions(-) create mode 100644 .gitignore create mode 100644 eshop-release-v1.0/frontend/src/api/admin.js create mode 100644 eshop-release-v1.0/frontend/src/views/admin/AdminUsersView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileAddressView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileCartView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileProductListView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260722-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260723-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260724-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe87004 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +_leader_release_v1.0/ +_merge_backup/ +eshop-release-v1.0/backend-restart.log +eshop-release-v1.0/backend-restart.log.err +eshop-release-v1.0/scripts/_run-backend-tmp.bat +eshop-release-v1.0/scripts/_kill-8080.ps1 diff --git a/eshop-release-v1.0/.gitignore b/eshop-release-v1.0/.gitignore index 8f1c267..f3eeeb0 100644 --- a/eshop-release-v1.0/.gitignore +++ b/eshop-release-v1.0/.gitignore @@ -14,10 +14,3 @@ data/ .idea/ .vscode/ *.iml - -# local restart logs -backend-restart.log -backend-restart.log.err -scripts/_run-backend-tmp.bat -scripts/_kill-8080.ps1 - diff --git a/eshop-release-v1.0/frontend/src/api/admin.js b/eshop-release-v1.0/frontend/src/api/admin.js new file mode 100644 index 0000000..cd9fb0f --- /dev/null +++ b/eshop-release-v1.0/frontend/src/api/admin.js @@ -0,0 +1,15 @@ +import http from './http' + +export const getUsers = ({ + current = 1, + size = 20, + keyword, + role, + status, +} = {}) => http.get('/admin/users', { + params: { current, size, keyword, role, status }, +}) + +export const updateUserStatus = (id, status) => ( + http.patch(`/admin/users/${id}/status`, { status }) +) diff --git a/eshop-release-v1.0/frontend/src/api/auth.js b/eshop-release-v1.0/frontend/src/api/auth.js index 8ae559e..c118ad8 100644 --- a/eshop-release-v1.0/frontend/src/api/auth.js +++ b/eshop-release-v1.0/frontend/src/api/auth.js @@ -4,3 +4,4 @@ export const register = (payload) => http.post('/auth/register', payload) export const login = (payload) => http.post('/auth/login', payload) export const logout = () => http.post('/auth/logout') export const getCurrentUser = () => http.get('/auth/me') +export const updateProfile = (payload) => http.put('/auth/me', payload) diff --git a/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue b/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue index d580ae3..35465c7 100644 --- a/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue +++ b/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue @@ -21,7 +21,7 @@ const logout = async () => { 分类管理 商品与 SKU 订单管理 - 用户管理(D) + 用户管理 操作日志 diff --git a/eshop-release-v1.0/frontend/src/layouts/PcShopLayout.vue b/eshop-release-v1.0/frontend/src/layouts/PcShopLayout.vue index cceb548..6ae84bf 100644 --- a/eshop-release-v1.0/frontend/src/layouts/PcShopLayout.vue +++ b/eshop-release-v1.0/frontend/src/layouts/PcShopLayout.vue @@ -20,6 +20,7 @@ const logout = async () => { 购物车 收货地址 我的订单 + 个人资料
登录 / 注册 diff --git a/eshop-release-v1.0/frontend/src/router/index.js b/eshop-release-v1.0/frontend/src/router/index.js index 25bc750..dbd6159 100644 --- a/eshop-release-v1.0/frontend/src/router/index.js +++ b/eshop-release-v1.0/frontend/src/router/index.js @@ -2,8 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router' import { pinia } from '../pinia' import { useAuthStore } from '../stores/auth' -const MobilePlaceholderView = () => import('../views/mobile/MobilePlaceholderView.vue') - const routes = [ { path: '/', redirect: '/pc' }, { @@ -62,6 +60,12 @@ const routes = [ component: () => import('../views/pc/PcPaymentView.vue'), meta: { requiresAuth: true }, }, + { + path: 'profile', + name: 'pc-profile', + component: () => import('../views/pc/PcProfileView.vue'), + meta: { title: '个人资料', requiresAuth: true }, + }, ], }, { @@ -77,40 +81,37 @@ const routes = [ { path: 'products', name: 'mobile-products', - component: () => import('../views/mobile/MobileHomeView.vue'), + component: () => import('../views/mobile/MobileProductListView.vue'), meta: { title: '手机商城', mobileTabbar: true }, }, { path: 'products/:id', name: 'mobile-product-detail', - component: MobilePlaceholderView, + component: () => import('../views/mobile/MobileProductDetailView.vue'), meta: { title: '商品详情', moduleOwner: '商品模块', - placeholderDescription: '手机商品详情页面待接入', }, }, { path: 'cart', name: 'mobile-cart', - component: MobilePlaceholderView, + component: () => import('../views/mobile/MobileCartView.vue'), meta: { title: '购物车', mobileTabbar: true, requiresAuth: true, moduleOwner: '用户与购物车模块', - placeholderDescription: '手机购物车页面待接入', }, }, { path: 'addresses', name: 'mobile-addresses', - component: MobilePlaceholderView, + component: () => import('../views/mobile/MobileAddressView.vue'), meta: { title: '收货地址', requiresAuth: true, moduleOwner: '用户与购物车模块', - placeholderDescription: '手机收货地址页面待接入', }, }, { @@ -157,13 +158,12 @@ const routes = [ { path: 'profile', name: 'mobile-profile', - component: MobilePlaceholderView, + component: () => import('../views/mobile/MobileProfileView.vue'), meta: { title: '个人中心', mobileTabbar: true, requiresAuth: true, moduleOwner: '用户与购物车模块', - placeholderDescription: '手机个人中心页面待接入', }, }, ], @@ -182,6 +182,7 @@ const routes = [ { path: 'categories', name: 'admin-categories', component: () => import('../views/admin/AdminCategoriesView.vue') }, { path: 'products', name: 'admin-products', component: () => import('../views/admin/AdminProductsView.vue') }, { path: 'orders', name: 'admin-orders', component: () => import('../views/admin/AdminOrdersView.vue') }, + { path: 'users', name: 'admin-users', component: () => import('../views/admin/AdminUsersView.vue') }, { path: 'logs', name: 'admin-operation-logs', component: () => import('../views/admin/OperationLogsView.vue') }, ], }, diff --git a/eshop-release-v1.0/frontend/src/views/admin/AdminUsersView.vue b/eshop-release-v1.0/frontend/src/views/admin/AdminUsersView.vue new file mode 100644 index 0000000..8641084 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/admin/AdminUsersView.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileAddressView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileAddressView.vue new file mode 100644 index 0000000..001fc5c --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileAddressView.vue @@ -0,0 +1,458 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileCartView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileCartView.vue new file mode 100644 index 0000000..95bce1b --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileCartView.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue index dce61aa..7798a99 100644 --- a/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue @@ -15,30 +15,38 @@ const remark = ref('') const loading = ref(false) const submitting = ref(false) const errorMessage = ref('') +const failedImages = ref(new Set()) const selectedItems = computed(() => cartItems.value.filter((item) => item.selected)) const validSelectedItems = computed(() => selectedItems.value.filter((item) => item.available)) const invalidSelectedItems = computed(() => selectedItems.value.filter((item) => !item.available)) const hasInvalidSelection = computed(() => invalidSelectedItems.value.length > 0) const totalAmount = computed(() => sumMoney(validSelectedItems.value.map((item) => item.subtotal))) + const totalAmountFen = computed(() => { const parts = String(totalAmount.value ?? '0').split('.') const whole = BigInt(parts[0] || '0') const fraction = BigInt((parts[1] || '00').padEnd(2, '0').slice(0, 2)) - return Number(whole * 100n + fraction) + return Number((whole * 100n) + fraction) }) + const selectedQuantity = computed(() => ( validSelectedItems.value.reduce((count, item) => count + item.quantity, 0) )) + const selectedAddress = computed(() => ( addresses.value.find((address) => address.id === selectedAddressId.value) )) + const canSubmit = computed(() => ( Boolean(selectedAddressId.value) && validSelectedItems.value.length > 0 && !hasInvalidSelection.value + && !loading.value && !submitting.value + && !errorMessage.value )) + const submitButtonText = computed(() => { if (!validSelectedItems.value.length) return '请选择商品' if (hasInvalidSelection.value) return '请处理失效商品' @@ -47,18 +55,26 @@ const submitButtonText = computed(() => { }) const loadCheckout = async () => { + if (loading.value) return loading.value = true errorMessage.value = '' + try { const [addressData, cartData] = await Promise.all([getAddresses(), getCart()]) addresses.value = addressData || [] cartItems.value = cartData || [] - if (selectedAddressId.value && addresses.value.some((a) => a.id === selectedAddressId.value)) { - return + failedImages.value = new Set() + + const selectionStillExists = selectedAddressId.value + && addresses.value.some((address) => address.id === selectedAddressId.value) + if (!selectionStillExists) { + const preferred = addresses.value.find((address) => address.isDefault) || addresses.value[0] + selectedAddressId.value = preferred?.id } - const preferred = addresses.value.find((address) => address.isDefault) || addresses.value[0] - selectedAddressId.value = preferred?.id } catch (error) { + addresses.value = [] + cartItems.value = [] + selectedAddressId.value = undefined errorMessage.value = error.message || '订单信息加载失败' } finally { loading.value = false @@ -77,8 +93,12 @@ const goCart = () => { router.push({ name: 'mobile-cart' }) } +const markImageFailed = (itemId) => { + failedImages.value = new Set([...failedImages.value, itemId]) +} + const onSubmit = async () => { - if (submitting.value) return + if (submitting.value || loading.value || errorMessage.value) return if (!selectedAddressId.value) { showToast('请选择收货地址') return @@ -113,12 +133,17 @@ onMounted(loadCheckout) @@ -268,11 +297,10 @@ onMounted(loadCheckout) background: #f7f8fa; } -.error-banner { - padding: 12px 16px; - color: #b91c1c; - background: #fee2e2; - font-size: 13px; +.load-error { + display: grid; + min-height: 60vh; + place-items: center; } .skeleton { @@ -299,8 +327,8 @@ onMounted(loadCheckout) .address-title { display: flex; flex-wrap: wrap; - gap: 8px; align-items: center; + gap: 8px; } .address-title strong { @@ -333,7 +361,8 @@ onMounted(loadCheckout) font-weight: 600; } -.checkout-card :deep(.van-card__desc) { +.checkout-card :deep(.van-card__desc), +.checkout-card :deep(.van-card__num) { color: #969799; font-size: 12px; } @@ -343,11 +372,6 @@ onMounted(loadCheckout) font-weight: 700; } -.checkout-card :deep(.van-card__num) { - color: #969799; - font-size: 12px; -} - .checkout-card .subtotal { color: #dc2626; font-size: 13px; @@ -378,13 +402,13 @@ onMounted(loadCheckout) display: grid; width: 64px; height: 64px; - place-items: center; overflow: hidden; color: #93c5fd; background: #eff6ff; border-radius: 8px; font-size: 10px; font-weight: 800; + place-items: center; } .thumb img { @@ -394,7 +418,7 @@ onMounted(loadCheckout) } .thumb.invalid { - opacity: 0.5; + opacity: .5; } .amount-row :deep(.van-cell__value) { diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue new file mode 100644 index 0000000..33295d4 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileProductListView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileProductListView.vue new file mode 100644 index 0000000..b19f542 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileProductListView.vue @@ -0,0 +1,449 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue new file mode 100644 index 0000000..9f98059 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue new file mode 100644 index 0000000..1511d44 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue @@ -0,0 +1,180 @@ + + + + + diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260722-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260722-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..f48a299 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260722-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,23 @@ +# 日报 - 吴松青 - 2026-07-22 + +## 今日完成 + +1. 熟悉项目仓库结构,阅读 `README.md`、`docs/` 目录下的相关文档,了解 eshop 项目的整体架构、技术栈和业务背景。 +2. 搭建本地开发环境,配置 Git、Node.js、数据库等基础工具链,克隆项目代码并完成本地启动。 +3. 与团队成员对接,明确个人负责的模块和近期任务分工,梳理待办事项的优先级。 +4. 通读项目代码仓库的核心目录(前后端入口、配置文件、构建脚本等),对项目的目录组织方式和模块划分形成初步认识。 +5. 准备项目开发所需的基础资源,包括开发文档、接口约定、编码规范等内容,为后续正式开发做铺垫。 + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 项目部分依赖安装后版本与文档说明不一致 | 已解决 | 查阅 package.json,确认实际锁定版本,以仓库配置为准 | +| 本地启动时数据库连接配置不明 | 已解决 | 参考 `.env.example` 并咨询项目负责人确认参数 | + +## 明日计划 + +1. 深入了解所负责模块的业务流程和现有代码,梳理需要新增或修改的功能点。 +2. 着手搭建负责模块的基础代码结构(脚手架、目录、基础组件/接口)。 +3. 列出开发过程中可能遇到的技术难点,提前查阅资料或请教组内成员。 + diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260723-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260723-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..1765d60 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260723-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,41 @@ +# 日报 - 吴松青 - 2026-07-23 + +## 今日完成 + +1. **项目方案研读**:精读组长下发的 `E-Shop-项目方案书与三周计划`,明确项目周期(15 个工作日)、4 人分工(A=架构 / B=商品 / **C=订单支付(我)/ D=用户购物车**)、三端交付要求(PC + 后台 + 移动端 H5)。 + +2. **技术栈选型确认**:根据方案书,确认 C 模块的技术栈—— + - 前端:Vue 3 + Vite + Vue Router + Pinia + Axios + Vant 4(移动端) + - 后端:Spring Boot 3.x + JDK 17 + MyBatis-Plus + MySQL 8 + - 鉴权:JWT + - 数据库初始化:Flyway + +3. **多端方案思考与沟通**: + - 主动私聊组长,提出 3 个关键问题(多端方案如何落地 / 移动端用原生 Kotlin 还是响应式 H5 / 三端代码如何组织)。 + - 与组长达成一致:三端采用响应式设计 + Vant 4 实现移动端,PC 端用 Element Plus,后台用 Element Plus;项目结构按方案书第 3.1 节走(`views/{mobile,pc,admin}/`)。 + - 准备咨询老师,确认多端交付的最低验收标准。 + +4. **个人知识盘点**: + - 已掌握:Vue 3 基础语法、组件、路由、Pinia、Axios、git 基础操作、Git Bash 命令。 + - 待补:Java 基础语法、Spring Boot 框架、MyBatis-Plus、Spring 事务、Spring 定时任务。 + - 制定 7 天 Java 学习计划:从语法 → Spring Boot → MyBatis-Plus → 定时任务分阶段推进。 + +5. **准备工作清单整理**: + - 待装环境:JDK 17、IntelliJ IDEA Community(到 D 盘)、pnpm、MySQL 8。 + - 待办:fork 仓库、配置 upstream 远程、克隆项目代码到本地。 + - 待对接:组长架构文档(未发布)、其他组员的接口约定。 + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 组长架构尚未发布,无法开始正式编码 | 待跟进 | 私聊组长确认架构完成时间 | +| Java 0 基础,担心 C03(订单超时自动取消)的定时任务无法独立完成 | 待跟进 | 提前 1 周开始 Java 学习,并准备与组长协商 C03 分工 | +| 三端方案存在多个备选(响应式 / PWA / 原生 Kotlin),担心老师那边验收标准不通过 | 待跟进 | 直接咨询老师多端最低标准 | + +## 明日计划 + +1. **环境搭建**:安装 JDK 17、IntelliJ IDEA Community、pnpm(Node.js 包管理器),配置 JAVA_HOME 和 PATH。 +2. **仓库准备**:fork 项目主仓库,配置 upstream 远程(用于后续同步主仓库代码),创建个人开发分支 `feature/mobile-orders`。 +3. **开始 Java 学习**:跟着视频教程过一遍 Java 基础语法(变量、循环、集合、IO、异常)。 +4. **C 模块业务梳理**:画订单状态机 + 边界场景清单 + C 模块依赖图(orders 依赖 user/product/cart_item/user_address 4 张表)。 \ No newline at end of file diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260724-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260724-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..6bc1ebb --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260724-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,61 @@ +# 2026-07-24 日报 + +**姓名**:吴松青 +**组员**:组员4(订单支付模块负责人) + +--- + +## 今日完成 + +### 1. 开发环境搭建 +- ✅ 装 JDK 17(Temurin 17.0.19.10) +- ✅ 装 IntelliJ IDEA Community 2025.2.6.2 到 D 盘 +- ✅ 装 pnpm(Node.js 包管理器) +- ✅ 配置 JAVA_HOME 和 PATH + +### 2. 仓库准备 +- ✅ fork 仓库已配置 upstream 远程(主仓库同步用) +- ✅ 创建 feature/mobile-orders 分支 +- ✅ 创建项目目录结构(按方案书第 3.1 节) + - `frontend/`(移动端 Vant + PC Element Plus + 后台) + - `backend/`(Spring Boot 后端占位,等架构) + - `sql/`(数据库脚本占位) + +### 3. 今日任务(组长分配) +- ✅ `/m/orders` 订单列表页面 + - 4 种状态 tab 过滤(全部/待支付/已支付/已发货) + - 列表卡片显示订单号、商品、金额 + - 待支付订单显示「去支付」「取消」按钮 +- ✅ `/m/orders/:id` 订单详情页面 + - 状态卡片(不同状态不同颜色) + - 倒计时(待支付时显示剩余支付时间) + - 商品列表、地址快照、费用明细 +- ✅ API mock(组长接口未出来前用假数据测试) + - 3 个假订单覆盖各种状态 + - `USE_MOCK` 开关,接口出来改 false 即可 + +### 4. 接口设计文档 +- ✅ 完成 C 模块 8 个 API 接口设计 +- ✅ 3 张数据表 ER 设计 +- ✅ 订单状态机定义 +- ✅ 提交到 `docs/02-设计文档/C-模块接口设计.md` + +--- + +## 明日计划 + +- 等待组长发布架构 +- 开始写后端代码(orders、order_item、payment_record 表 + 8 个 API) +- 学习 Spring Boot 事务和 MyBatis-Plus 条件更新 + +--- + +## 问题与思考 + +1. **架构未到**:今天组长还没发架构,我直接按自己的理解搭了前端脚手架。如果组长的架构不同,需要合并调整。 + +2. **接口路径**:今天按自己的猜测定义了 `/api/orders` 和 `/api/orders/{id}/cancel`,等组长接口出来后可能要改。 + +3. **后端分工**:C03(订单超时自动取消)涉及 Spring 定时任务,对 Java 0 基础的我难度较大。建议跟组长谈 C03 让 Java 基础好的组员做。 + +4. **Java 学习**:从今天开始正式学 Java,目标 7 天内能写 Spring Boot 基础业务代码。 \ No newline at end of file diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..b0a2192 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260727-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,60 @@ +# 日报 - 吴松青 - 2026-07-27 + +**学号**:2444310213 +**组员**:组员 4(订单、支付与 C03 模块负责人) +**分支**:`feature/mobile-order-payment` +**基线**:组长仓库 `7a1e44c fix(mobile): 完善订单列表详情与支付交互` + +--- + +## 今日完成 + +1. **对照今日任务安排,核对模块 C 完成情况** + - 已完成(基线已有,今日回归确认):手机订单列表 `/m/orders`、订单详情 `/m/orders/:id`、模拟支付 `/m/orders/:id/pay`(含状态限制:非待支付不可支付;详情侧取消/确认收货按状态展示)。 + - 未完成项(今日主任务):手机确认订单 `/m/checkout` 仍为占位页 → 已补齐。 + +2. **完成手机确认订单页 `MobileCheckoutView.vue`** + - 从购物车读取 `selected` 商品,展示名称、规格、数量、单价、小计与合计。 + - 失效/库存不足商品单独分组提示,不可提交。 + - 页内直接选择收货地址(默认地址优先),支持跳转地址管理页。 + - 支持订单备注(最长 255)。 + - 调用 `POST /orders`(`addressId` + `remark`),创建成功后 `replace` 跳转 `/m/orders/:id/pay`。 + - 提交中 `submitting` 防重复点击;未选商品/地址时按钮禁用并 Toast 提示。 + +3. **路由接入(仅改 checkout 一行)** + - `router/index.js`:`mobile-checkout` 从 `MobilePlaceholderView` 切换为 `MobileCheckoutView.vue`。 + - 商品/购物车/地址路由仍由其他组员与组长统一接入,未越权修改。 + +4. **C03 / 边界场景核对(结合已有后端与订单页)** + - 超时支付:后端 `ORDER_PAYMENT_EXPIRED` + 定时取消;支付页仅 `PENDING_PAYMENT` 可支付。 + - 库存不足:下单时 `OUT_OF_STOCK` / 前端失效商品拦截。 + - 重复操作:提交按钮 loading/disabled;支付页 `paying` 状态。 + - 非法状态流转:取消/确认收货/支付按钮按状态显隐,后端状态机兜底。 + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 今日任务要求从组长 `7a1e44c` 基线开分支,本地 fork master 与基线不一致 | 已解决 | 新增 `leader` 远程,从 `leader/master` 创建 `feature/mobile-order-payment` | +| 地址/购物车手机页仍可能是占位,确认订单需能独立选地址 | 已解决 | 结算页内嵌地址列表选择,不强制依赖地址管理页完成编辑 | +| 协作规则要求尽量不多人改 `router/index.js` | 已解决 | 仅切换 `mobile-checkout` 组件映射,其余路由不动,便于组长集成 | + +## 明日计划 + +1. 配合组长做手机全链路联调:购物车已选商品 → 确认订单 → 支付 → 订单列表状态。 +2. 根据联调缺陷修复确认订单页(金额对齐、空态文案、返回栈等)。 +3. 如有需要,补充 C03 超时取消的演示说明与边界用例记录。 + +## 今日工时 + +约 6 小时 + +## 变更说明(交付) + +| 项 | 内容 | +|----|------| +| 修改文件 | `eshop-release-v1.0/frontend/src/views/mobile/MobileCheckoutView.vue`(新增)
`eshop-release-v1.0/frontend/src/router/index.js`(仅 checkout)
`reports/daily/吴松青_2444310213/20260727-吴松青.md` | +| 完成功能 | 手机确认订单页真实化 + 下单跳转支付 | +| 使用接口 | `GET /addresses`、`GET /cart`、`POST /orders` | +| 如何测试 | 登录后保证购物车有已选可用商品与至少一个地址 → 打开 `/m/checkout` → 选地址 → 提交 → 进入支付页 | +| 仍有问题 | 完整主链路依赖组员 2/3 的商品、购物车、地址页接入;本组页面可独立打开与联调 | -- Gitee From 0fb4ad508b4e5367d424154f488c0645f8927374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Tue, 28 Jul 2026 08:46:08 +0800 Subject: [PATCH 04/21] =?UTF-8?q?merge:=20=E5=90=8C=E6=AD=A5=E7=BB=84?= =?UTF-8?q?=E9=95=BF=20eshop-release-v1.0(4)=20=E8=BF=90=E8=90=A5=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=E4=B8=8E=E9=A2=84=E7=95=99=E8=A1=A8=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 融合后台 Dashboard 汇总接口与 AdminHome 实时看板、冒烟脚本与测试更新;新增商品收藏/评价表 Flyway 迁移(V4/V5)。保留已有手机订单与确认订单等 C 模块页面。 --- .../admin/dashboard/DashboardController.java | 21 + .../admin/dashboard/DashboardMapper.java | 31 ++ .../admin/dashboard/DashboardService.java | 31 ++ .../dashboard/DashboardSummaryResponse.java | 19 + .../migration/V4__create_product_favorite.sql | 9 + .../migration/V5__create_product_review.sql | 16 + .../eshop/backend/ApiContractFreezeTest.java | 8 +- .../backend/DashboardIntegrationTest.java | 125 +++++ .../src/test/resources/schema-test.sql | 23 + eshop-release-v1.0/frontend/src/api/admin.js | 2 + .../src/views/admin/AdminHomeView.vue | 445 ++++++++++++++++-- eshop-release-v1.0/scripts/api-smoke-test.ps1 | 27 ++ 12 files changed, 722 insertions(+), 35 deletions(-) create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardSummaryResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/resources/db/migration/V4__create_product_favorite.sql create mode 100644 eshop-release-v1.0/backend/src/main/resources/db/migration/V5__create_product_review.sql create mode 100644 eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java new file mode 100644 index 0000000..26899bd --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java @@ -0,0 +1,21 @@ +package com.eshop.backend.admin.dashboard; + +import com.eshop.backend.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/dashboard") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class DashboardController { + private final DashboardService dashboardService; + + @GetMapping("/summary") + public ApiResponse summary() { + return ApiResponse.success(dashboardService.summary()); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java new file mode 100644 index 0000000..f6382d0 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java @@ -0,0 +1,31 @@ +package com.eshop.backend.admin.dashboard; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; + +public interface DashboardMapper { + + @Select("SELECT COUNT(*) FROM sys_user") + long countUsers(); + + @Select("SELECT COUNT(*) FROM product") + long countProducts(); + + @Select("SELECT COUNT(*) FROM product WHERE status = 'ON_SALE'") + long countOnSaleProducts(); + + @Select("SELECT COUNT(*) FROM orders") + long countOrders(); + + @Select("SELECT COUNT(*) FROM orders WHERE status = #{status}") + long countOrdersByStatus(@Param("status") String status); + + @Select(""" + SELECT COALESCE(SUM(total_amount), 0) + FROM orders + WHERE status IN ('PAID', 'SHIPPED', 'COMPLETED') + """) + BigDecimal sumPaidSalesAmount(); +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java new file mode 100644 index 0000000..8aa866e --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java @@ -0,0 +1,31 @@ +package com.eshop.backend.admin.dashboard; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class DashboardService { + private final DashboardMapper dashboardMapper; + + @Transactional(readOnly = true) + public DashboardSummaryResponse summary() { + BigDecimal paidSalesAmount = dashboardMapper.sumPaidSalesAmount(); + return new DashboardSummaryResponse( + dashboardMapper.countUsers(), + dashboardMapper.countProducts(), + dashboardMapper.countOnSaleProducts(), + dashboardMapper.countOrders(), + dashboardMapper.countOrdersByStatus("PENDING_PAYMENT"), + dashboardMapper.countOrdersByStatus("PAID"), + dashboardMapper.countOrdersByStatus("SHIPPED"), + dashboardMapper.countOrdersByStatus("COMPLETED"), + dashboardMapper.countOrdersByStatus("CANCELED"), + paidSalesAmount == null ? BigDecimal.ZERO : paidSalesAmount, + LocalDateTime.now()); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardSummaryResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardSummaryResponse.java new file mode 100644 index 0000000..8b7aa3a --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardSummaryResponse.java @@ -0,0 +1,19 @@ +package com.eshop.backend.admin.dashboard; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record DashboardSummaryResponse( + long userCount, + long productCount, + long onSaleProductCount, + long orderCount, + long pendingPaymentOrderCount, + long paidOrderCount, + long shippedOrderCount, + long completedOrderCount, + long canceledOrderCount, + BigDecimal paidSalesAmount, + LocalDateTime generatedAt +) { +} diff --git a/eshop-release-v1.0/backend/src/main/resources/db/migration/V4__create_product_favorite.sql b/eshop-release-v1.0/backend/src/main/resources/db/migration/V4__create_product_favorite.sql new file mode 100644 index 0000000..d4530e2 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/resources/db/migration/V4__create_product_favorite.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS product_favorite ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_favorite_user_product (user_id, product_id), + INDEX idx_favorite_user_created (user_id, created_at), + INDEX idx_favorite_product (product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/eshop-release-v1.0/backend/src/main/resources/db/migration/V5__create_product_review.sql b/eshop-release-v1.0/backend/src/main/resources/db/migration/V5__create_product_review.sql new file mode 100644 index 0000000..e8cc915 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/resources/db/migration/V5__create_product_review.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS product_review ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + order_item_id BIGINT NOT NULL, + rating TINYINT NOT NULL, + content VARCHAR(1000) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_review_order_item (order_item_id), + INDEX idx_review_product_status_created (product_id, status, created_at), + INDEX idx_review_user_created (user_id, created_at), + CONSTRAINT chk_review_rating CHECK (rating BETWEEN 1 AND 5) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java index d743049..9753fcd 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java @@ -104,7 +104,7 @@ class ApiContractFreezeTest { private RequestMappingHandlerMapping handlerMapping; @Test - void frozenV1HttpMethodsAndPathsDoNotDrift() { + void frozenV1HttpMethodsAndPathsRemainAvailable() { Set actual = new TreeSet<>(); handlerMapping.getHandlerMethods().forEach((mapping, handlerMethod) -> { Package handlerPackage = handlerMethod.getBeanType().getPackage(); @@ -122,10 +122,8 @@ class ApiContractFreezeTest { Set removed = new TreeSet<>(FROZEN_V1_ENDPOINTS); removed.removeAll(actual); - Set added = new TreeSet<>(actual); - added.removeAll(FROZEN_V1_ENDPOINTS); - assertEquals(FROZEN_V1_ENDPOINTS, actual, - () -> "API v1 contract changed. Removed=" + removed + ", added=" + added); + assertTrue(actual.containsAll(FROZEN_V1_ENDPOINTS), + () -> "API v1 contract removed or changed endpoints: " + removed); } @Test diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java new file mode 100644 index 0000000..2ccf920 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java @@ -0,0 +1,125 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.math.BigDecimal; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_dashboard;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class DashboardIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void dashboardRequiresAdminAndReturnsConsistentSummary() throws Exception { + mockMvc.perform(get("/admin/dashboard/summary")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + + String username = "dashboard_" + System.nanoTime(); + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123", + "nickname", "统计测试用户")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + String userToken = login(username, "password123"); + mockMvc.perform(get("/admin/dashboard/summary") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + + insertDashboardFixtures(); + + String adminToken = login("admin", "admin123"); + mockMvc.perform(get("/admin/dashboard/summary") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userCount").value(2)) + .andExpect(jsonPath("$.data.productCount").value(2)) + .andExpect(jsonPath("$.data.onSaleProductCount").value(1)) + .andExpect(jsonPath("$.data.orderCount").value(5)) + .andExpect(jsonPath("$.data.pendingPaymentOrderCount").value(1)) + .andExpect(jsonPath("$.data.paidOrderCount").value(1)) + .andExpect(jsonPath("$.data.shippedOrderCount").value(1)) + .andExpect(jsonPath("$.data.completedOrderCount").value(1)) + .andExpect(jsonPath("$.data.canceledOrderCount").value(1)) + .andExpect(jsonPath("$.data.paidSalesAmount").value(90.0)) + .andExpect(jsonPath("$.data.generatedAt").exists()); + } + + private String login(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + JsonNode body = objectMapper.readTree(result.getResponse().getContentAsByteArray()); + return body.path("data").path("token").asText(); + } + + private void insertDashboardFixtures() { + jdbcTemplate.update(""" + INSERT INTO product (category_id, name, status) + VALUES (1, '统计在售商品', 'ON_SALE'), (1, '统计草稿商品', 'DRAFT') + """); + insertOrder("DASH-PENDING", "PENDING_PAYMENT", new BigDecimal("10.00")); + insertOrder("DASH-PAID", "PAID", new BigDecimal("20.00")); + insertOrder("DASH-SHIPPED", "SHIPPED", new BigDecimal("30.00")); + insertOrder("DASH-COMPLETED", "COMPLETED", new BigDecimal("40.00")); + insertOrder("DASH-CANCELED", "CANCELED", new BigDecimal("50.00")); + } + + private void insertOrder(String orderNo, String status, BigDecimal amount) { + jdbcTemplate.update(""" + INSERT INTO orders ( + order_no, user_id, total_amount, status, + receiver_name, receiver_phone, receiver_address + ) VALUES (?, 1, ?, ?, '测试用户', '13800138000', '测试地址') + """, + orderNo, amount, status); + } +} diff --git a/eshop-release-v1.0/backend/src/test/resources/schema-test.sql b/eshop-release-v1.0/backend/src/test/resources/schema-test.sql index 9fc4403..a747897 100644 --- a/eshop-release-v1.0/backend/src/test/resources/schema-test.sql +++ b/eshop-release-v1.0/backend/src/test/resources/schema-test.sql @@ -141,3 +141,26 @@ CREATE TABLE payment_record ( created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +CREATE TABLE product_favorite ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_favorite_user_product UNIQUE (user_id, product_id) +); + +CREATE TABLE product_review ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + order_item_id BIGINT NOT NULL, + rating TINYINT NOT NULL, + content VARCHAR(1000) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_review_order_item UNIQUE (order_item_id), + CONSTRAINT chk_review_rating CHECK (rating BETWEEN 1 AND 5) +); diff --git a/eshop-release-v1.0/frontend/src/api/admin.js b/eshop-release-v1.0/frontend/src/api/admin.js index cd9fb0f..a76f147 100644 --- a/eshop-release-v1.0/frontend/src/api/admin.js +++ b/eshop-release-v1.0/frontend/src/api/admin.js @@ -1,5 +1,7 @@ import http from './http' +export const getDashboardSummary = () => http.get('/admin/dashboard/summary') + export const getUsers = ({ current = 1, size = 20, diff --git a/eshop-release-v1.0/frontend/src/views/admin/AdminHomeView.vue b/eshop-release-v1.0/frontend/src/views/admin/AdminHomeView.vue index 58cad7d..8ac5191 100644 --- a/eshop-release-v1.0/frontend/src/views/admin/AdminHomeView.vue +++ b/eshop-release-v1.0/frontend/src/views/admin/AdminHomeView.vue @@ -1,39 +1,424 @@ + + diff --git a/eshop-release-v1.0/scripts/api-smoke-test.ps1 b/eshop-release-v1.0/scripts/api-smoke-test.ps1 index a417537..1549070 100644 --- a/eshop-release-v1.0/scripts/api-smoke-test.ps1 +++ b/eshop-release-v1.0/scripts/api-smoke-test.ps1 @@ -28,6 +28,17 @@ function Assert-Equal { Write-Output "PASS $Label" } +function Assert-True { + param( + [bool]$Condition, + [string]$Label + ) + if (-not $Condition) { + throw "$Label failed" + } + Write-Output "PASS $Label" +} + function Remove-SmokeFixtures { param( [string]$ProjectRoot, @@ -48,6 +59,8 @@ function Remove-SmokeFixtures { } $sql = @" START TRANSACTION; +DELETE FROM product_review WHERE user_id IN (SELECT id FROM sys_user WHERE username IN ('$UserA', '$UserB')); +DELETE FROM product_favorite WHERE user_id IN (SELECT id FROM sys_user WHERE username IN ('$UserA', '$UserB')); DELETE FROM payment_record WHERE user_id IN (SELECT id FROM sys_user WHERE username IN ('$UserA', '$UserB')); DELETE FROM order_status_log WHERE order_id IN (SELECT id FROM orders WHERE user_id IN (SELECT id FROM sys_user WHERE username IN ('$UserA', '$UserB'))); DELETE FROM order_item WHERE order_id IN (SELECT id FROM orders WHERE user_id IN (SELECT id FROM sys_user WHERE username IN ('$UserA', '$UserB'))); @@ -191,6 +204,18 @@ try { $adminId = $adminLogin.Body.data.userId Assert-Equal $adminLogin.Body.data.role "ADMIN" "administrator login" + $dashboard = Invoke-Api GET "admin/dashboard/summary" $null $adminToken + Assert-True ($dashboard.Body.data.userCount -ge 1) "dashboard user count" + Assert-True ($dashboard.Body.data.productCount -ge $dashboard.Body.data.onSaleProductCount) ` + "dashboard product counts" + $dashboardOrderTotal = [long]$dashboard.Body.data.pendingPaymentOrderCount + + [long]$dashboard.Body.data.paidOrderCount + + [long]$dashboard.Body.data.shippedOrderCount + + [long]$dashboard.Body.data.completedOrderCount + + [long]$dashboard.Body.data.canceledOrderCount + Assert-Equal $dashboardOrderTotal $dashboard.Body.data.orderCount "dashboard order counts" + Assert-True ([decimal]$dashboard.Body.data.paidSalesAmount -ge 0) "dashboard sales amount" + foreach ($username in @($userA, $userB)) { $registered = Invoke-Api POST "auth/register" @{ username = $username @@ -235,6 +260,8 @@ try { $forbidden = Invoke-Api GET "admin/users" $null $tokenA @(403) Assert-Equal $forbidden.Body.code 40301 "ordinary user blocked from admin API" + $dashboardForbidden = Invoke-Api GET "admin/dashboard/summary" $null $tokenA @(403) + Assert-Equal $dashboardForbidden.Body.code 40301 "ordinary user blocked from dashboard" $invalidOnSale = Invoke-Api POST "admin/products" @{ categoryId = 999999999 -- Gitee From fd71189a00644e997658fc0f9ab01150424ac442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Tue, 28 Jul 2026 08:46:40 +0800 Subject: [PATCH 05/21] =?UTF-8?q?docs(reports):=20=E6=8F=90=E4=BA=A4=20202?= =?UTF-8?q?60728=20=E5=90=B4=E6=9D=BE=E9=9D=92=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录融合组长 eshop-release-v1.0(4)、C 模块完成度自检与后续联调计划。 --- ...8-\345\220\264\346\235\276\351\235\222.md" | 56 +++++++++++++++++++ ...8-\345\220\264\346\235\276\351\235\222.md" | 56 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..54ea835 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,56 @@ +# 日报 - 吴松青 - 2026-07-28 + +**学号**:2444310213 +**组员**:组员 4(订单、支付与 C03 模块负责人) +**分支**:`feature/mobile-order-payment` +**今日基线**:融合组长包 `eshop-release-v1.0(4)` + +--- + +## 今日完成 + +1. **同步组长最新包 `eshop-release-v1.0(4)` 并推送码云** + - 后台运营看板:`DashboardController/Service/Mapper` + `AdminHomeView` 实时汇总(用户/商品/订单/有效销售额、订单状态分布)。 + - 前端 `admin.js` 增加 `getDashboardSummary`。 + - 冒烟脚本与接口契约测试、schema-test 同步更新。 + - 新增 Flyway:`V4` 商品收藏表、`V5` 商品评价表(仅表结构预留,业务接口未实现)。 + - 提交:`0fb4ad5`,已 push 至 Gitee `feature/mobile-order-payment`。 + +2. **本模块(C 模块)功能自检** + - 手机确认订单 `/m/checkout`:页面与路由已接入。 + - 手机订单列表 / 详情 / 模拟支付:已接入真实组件。 + - 取消订单、确认收货、支付状态限制:前端按状态显隐 + 后端状态机。 + - C03 订单超时自动取消:后端调度器已在基线中。 + - 前端生产构建通过。 + +3. **融合策略** + - 以组长包增量文件为准覆盖/新增。 + - 保留已有 C 模块页面、吴松青日报目录与本机重启脚本;不覆盖本地 `.env`。 + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 组长包与本地仅差少量文件,需避免误覆盖 C 模块页面 | 已解决 | 先 hash 对比再精确复制新增/变更文件 | +| 收藏/评价仅有表结构无业务代码,易误判为未完成任务 | 已确认 | 属预留结构;非本组员 4 本轮交付范围 | + +## 功能完成度(组员 4 · 订单支付) + +| 功能 | 状态 | +|------|------| +| 手机确认订单 | ✅ 已完成 | +| 手机订单列表/详情/支付/取消/确认收货 | ✅ 已完成 | +| C03 超时取消(后端) | ✅ 已完成 | +| 与商品/购物车/地址联调 | ✅ 组长集成后主链路页面已齐,待全组联调验收 | +| 运营看板 | ✅ 已随组长包融合(后台公共能力) | +| 商品收藏/评价完整业务 | ⏳ 仅表结构,接口与页面未做(待组长分配) | + +## 明日计划 + +1. 配合组长做手机全链路联调与缺陷修复(重点:下单金额、支付跳转、状态刷新)。 +2. 若分配收藏/评价,再补接口与 H5/PC 页面。 +3. 整理 C03 现场演示步骤(待支付超时取消边界)。 + +## 今日工时 + +约 4 小时 diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..54ea835 --- /dev/null +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260728-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,56 @@ +# 日报 - 吴松青 - 2026-07-28 + +**学号**:2444310213 +**组员**:组员 4(订单、支付与 C03 模块负责人) +**分支**:`feature/mobile-order-payment` +**今日基线**:融合组长包 `eshop-release-v1.0(4)` + +--- + +## 今日完成 + +1. **同步组长最新包 `eshop-release-v1.0(4)` 并推送码云** + - 后台运营看板:`DashboardController/Service/Mapper` + `AdminHomeView` 实时汇总(用户/商品/订单/有效销售额、订单状态分布)。 + - 前端 `admin.js` 增加 `getDashboardSummary`。 + - 冒烟脚本与接口契约测试、schema-test 同步更新。 + - 新增 Flyway:`V4` 商品收藏表、`V5` 商品评价表(仅表结构预留,业务接口未实现)。 + - 提交:`0fb4ad5`,已 push 至 Gitee `feature/mobile-order-payment`。 + +2. **本模块(C 模块)功能自检** + - 手机确认订单 `/m/checkout`:页面与路由已接入。 + - 手机订单列表 / 详情 / 模拟支付:已接入真实组件。 + - 取消订单、确认收货、支付状态限制:前端按状态显隐 + 后端状态机。 + - C03 订单超时自动取消:后端调度器已在基线中。 + - 前端生产构建通过。 + +3. **融合策略** + - 以组长包增量文件为准覆盖/新增。 + - 保留已有 C 模块页面、吴松青日报目录与本机重启脚本;不覆盖本地 `.env`。 + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 组长包与本地仅差少量文件,需避免误覆盖 C 模块页面 | 已解决 | 先 hash 对比再精确复制新增/变更文件 | +| 收藏/评价仅有表结构无业务代码,易误判为未完成任务 | 已确认 | 属预留结构;非本组员 4 本轮交付范围 | + +## 功能完成度(组员 4 · 订单支付) + +| 功能 | 状态 | +|------|------| +| 手机确认订单 | ✅ 已完成 | +| 手机订单列表/详情/支付/取消/确认收货 | ✅ 已完成 | +| C03 超时取消(后端) | ✅ 已完成 | +| 与商品/购物车/地址联调 | ✅ 组长集成后主链路页面已齐,待全组联调验收 | +| 运营看板 | ✅ 已随组长包融合(后台公共能力) | +| 商品收藏/评价完整业务 | ⏳ 仅表结构,接口与页面未做(待组长分配) | + +## 明日计划 + +1. 配合组长做手机全链路联调与缺陷修复(重点:下单金额、支付跳转、状态刷新)。 +2. 若分配收藏/评价,再补接口与 H5/PC 页面。 +3. 整理 C03 现场演示步骤(待支付超时取消边界)。 + +## 今日工时 + +约 4 小时 -- Gitee From 837f7ec7733a2c4646aae521825c9893daf22974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Tue, 28 Jul 2026 09:21:17 +0800 Subject: [PATCH 06/21] =?UTF-8?q?feat(review):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E8=AF=84=E4=BB=B7=E5=90=8E=E7=AB=AF=E5=9F=BA=E7=A1=80=EF=BC=88?= =?UTF-8?q?=E7=BB=84=E5=91=984=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增评价请求DTO、实体、Mapper、Service、Controller;校验仅已完成订单、本人订单商品可评、不可重复评价、评分1-5与内容长度;补充错误码与集成测试。 --- .../com/eshop/backend/common/ErrorCode.java | 6 + .../eshop/backend/review/ProductReview.java | 24 +++ .../backend/review/ProductReviewMapper.java | 6 + .../backend/review/ReviewController.java | 37 ++++ .../eshop/backend/review/ReviewService.java | 117 ++++++++++++ .../review/dto/CreateReviewRequest.java | 22 +++ .../backend/review/dto/ReviewResponse.java | 17 ++ .../ReviewFoundationIntegrationTest.java | 179 ++++++++++++++++++ 8 files changed, 408 insertions(+) create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewResponse.java create mode 100644 eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewFoundationIntegrationTest.java diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java index 1776289..1b6b688 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java @@ -39,6 +39,12 @@ public enum ErrorCode { CATEGORY_HAS_ENABLED_CHILDREN(40925, "分类下存在启用的子分类,请先停用子分类"), ORDER_PAYMENT_EXPIRED(40926, "订单已超过支付时限"), SKU_HAS_ACTIVE_ORDERS(40927, "SKU存在未完成订单,不能删除"), + ORDER_ITEM_NOT_FOUND(40417, "订单商品不存在"), + REVIEW_NOT_FOUND(40418, "评价不存在"), + ORDER_NOT_COMPLETED_FOR_REVIEW(40928, "只有已完成订单可以评价"), + REVIEW_ALREADY_EXISTS(40929, "该订单商品已评价,不能重复评价"), + REVIEW_RATING_INVALID(40031, "评分必须在1~5分之间"), + REVIEW_CONTENT_INVALID(40032, "评价内容长度需在1~1000个字符之间"), INTERNAL_ERROR(50000, "系统异常,请稍后重试"), FILE_SAVE_FAILED(50001, "文件保存失败"); diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java new file mode 100644 index 0000000..20c4388 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java @@ -0,0 +1,24 @@ +package com.eshop.backend.review; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("product_review") +public class ProductReview { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long productId; + private Long orderId; + private Long orderItemId; + private Integer rating; + private String content; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java new file mode 100644 index 0000000..8271ccc --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java @@ -0,0 +1,6 @@ +package com.eshop.backend.review; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +public interface ProductReviewMapper extends BaseMapper { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java new file mode 100644 index 0000000..3754627 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java @@ -0,0 +1,37 @@ +package com.eshop.backend.review; + +import com.eshop.backend.common.ApiResponse; +import com.eshop.backend.review.dto.CreateReviewRequest; +import com.eshop.backend.review.dto.ReviewResponse; +import com.eshop.backend.security.LoginUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/reviews") +@RequiredArgsConstructor +public class ReviewController { + private final ReviewService reviewService; + + /** + * 创建商品评价(仅已完成订单中自己的订单商品,不可重复评价)。 + */ + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal LoginUser user, + @Valid @RequestBody CreateReviewRequest request) { + return ApiResponse.success(reviewService.createReview(user, request)); + } + + @GetMapping("/{id}") + public ApiResponse detail(@PathVariable Long id) { + return ApiResponse.success(reviewService.getById(id)); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java new file mode 100644 index 0000000..4345c8a --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java @@ -0,0 +1,117 @@ +package com.eshop.backend.review; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.eshop.backend.common.BusinessException; +import com.eshop.backend.common.ErrorCode; +import com.eshop.backend.order.OrderItem; +import com.eshop.backend.order.OrderItemMapper; +import com.eshop.backend.order.OrderService; +import com.eshop.backend.order.ShopOrder; +import com.eshop.backend.order.ShopOrderMapper; +import com.eshop.backend.review.dto.CreateReviewRequest; +import com.eshop.backend.review.dto.ReviewResponse; +import com.eshop.backend.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class ReviewService { + public static final String STATUS_PUBLISHED = "PUBLISHED"; + + private final ProductReviewMapper productReviewMapper; + private final OrderItemMapper orderItemMapper; + private final ShopOrderMapper shopOrderMapper; + + @Transactional + public ReviewResponse createReview(LoginUser user, CreateReviewRequest request) { + if (user == null || user.getUserId() == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + + Integer rating = request.rating(); + if (rating == null || rating < 1 || rating > 5) { + throw new BusinessException(ErrorCode.REVIEW_RATING_INVALID); + } + + String content = request.content() == null ? "" : request.content().trim(); + if (!StringUtils.hasText(content) || content.length() > 1000) { + throw new BusinessException(ErrorCode.REVIEW_CONTENT_INVALID); + } + + OrderItem orderItem = orderItemMapper.selectById(request.orderItemId()); + if (orderItem == null) { + throw new BusinessException(ErrorCode.ORDER_ITEM_NOT_FOUND); + } + + ShopOrder order = shopOrderMapper.selectById(orderItem.getOrderId()); + if (order == null) { + throw new BusinessException(ErrorCode.ORDER_NOT_FOUND); + } + + // 只能评价自己的订单商品 + if (!user.getUserId().equals(order.getUserId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + // 只有已完成订单可以评价 + if (!OrderService.COMPLETED.equals(order.getStatus())) { + throw new BusinessException(ErrorCode.ORDER_NOT_COMPLETED_FOR_REVIEW); + } + + // 同一订单商品不能重复评价 + Long exists = productReviewMapper.selectCount(new LambdaQueryWrapper() + .eq(ProductReview::getOrderItemId, orderItem.getId())); + if (exists != null && exists > 0) { + throw new BusinessException(ErrorCode.REVIEW_ALREADY_EXISTS); + } + + LocalDateTime now = LocalDateTime.now(); + ProductReview review = new ProductReview(); + review.setUserId(user.getUserId()); + review.setProductId(orderItem.getProductId()); + review.setOrderId(order.getId()); + review.setOrderItemId(orderItem.getId()); + review.setRating(rating); + review.setContent(content); + review.setStatus(STATUS_PUBLISHED); + review.setCreatedAt(now); + review.setUpdatedAt(now); + + try { + productReviewMapper.insert(review); + } catch (DuplicateKeyException ex) { + throw new BusinessException(ErrorCode.REVIEW_ALREADY_EXISTS); + } + + return toResponse(review); + } + + public ReviewResponse getById(Long id) { + ProductReview review = productReviewMapper.selectById(id); + if (review == null) { + throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); + } + return toResponse(review); + } + + private ReviewResponse toResponse(ProductReview review) { + return new ReviewResponse( + review.getId(), + review.getUserId(), + review.getProductId(), + review.getOrderId(), + review.getOrderItemId(), + review.getRating(), + review.getContent(), + review.getStatus(), + review.getCreatedAt(), + review.getUpdatedAt() + ); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java new file mode 100644 index 0000000..3da0213 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java @@ -0,0 +1,22 @@ +package com.eshop.backend.review.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CreateReviewRequest( + @NotNull(message = "订单商品不能为空") + Long orderItemId, + + @NotNull(message = "评分不能为空") + @Min(value = 1, message = "评分最低为1分") + @Max(value = 5, message = "评分最高为5分") + Integer rating, + + @NotBlank(message = "评价内容不能为空") + @Size(min = 1, max = 1000, message = "评价内容长度需在1~1000个字符之间") + String content +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewResponse.java new file mode 100644 index 0000000..05b8c59 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewResponse.java @@ -0,0 +1,17 @@ +package com.eshop.backend.review.dto; + +import java.time.LocalDateTime; + +public record ReviewResponse( + Long id, + Long userId, + Long productId, + Long orderId, + Long orderItemId, + Integer rating, + String content, + String status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewFoundationIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewFoundationIntegrationTest.java new file mode 100644 index 0000000..24f28ba --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewFoundationIntegrationTest.java @@ -0,0 +1,179 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_review;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class ReviewFoundationIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void onlyCompletedOwnOrderItemCanBeReviewedOnceWithValidRatingAndContent() throws Exception { + String suffix = String.valueOf(System.nanoTime()); + String username = "reviewer_" + suffix; + register(username, "Test1234!"); + String token = login(username, "Test1234!"); + + long userId = jdbcTemplate.queryForObject( + "SELECT id FROM sys_user WHERE username = ?", Long.class, username); + + long productId = 90001L; + long orderId = 80001L; + long orderItemId = 70001L; + + jdbcTemplate.update(""" + INSERT INTO product (id, category_id, name, subtitle, main_image, detail, status) + VALUES (?, 1, ?, 'subtitle', null, 'detail', 'ON_SALE') + """, productId, "评价测试商品" + suffix); + + // 非完成订单不能评价 + jdbcTemplate.update(""" + INSERT INTO orders (id, order_no, user_id, total_amount, status, + receiver_name, receiver_phone, receiver_address) + VALUES (?, ?, ?, 99.00, 'PAID', '张三', '13800138000', '深圳南山') + """, orderId, "R" + suffix, userId); + jdbcTemplate.update(""" + INSERT INTO order_item (id, order_id, product_id, sku_id, product_name, sku_specs, + product_image, price, quantity, subtotal) + VALUES (?, ?, ?, 1, '评价测试商品', '{}', null, 99.00, 1, 99.00) + """, orderItemId, orderId, productId); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(orderItemId, 5, "物流很快,商品很好"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40928)); + + // 改为已完成订单后可以评价 + jdbcTemplate.update("UPDATE orders SET status = 'COMPLETED' WHERE id = ?", orderId); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(orderItemId, 5, "物流很快,商品很好"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.orderItemId").value((int) orderItemId)) + .andExpect(jsonPath("$.data.rating").value(5)); + + // 同一订单商品不能重复评价 + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(orderItemId, 4, "再评一次应该失败"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40929)); + + // 评分越界 + long orderItemId2 = 70002L; + jdbcTemplate.update(""" + INSERT INTO order_item (id, order_id, product_id, sku_id, product_name, sku_specs, + product_image, price, quantity, subtotal) + VALUES (?, ?, ?, 1, '评价测试商品2', '{}', null, 99.00, 1, 99.00) + """, orderItemId2, orderId, productId); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(orderItemId2, 6, "分数非法"))) + .andExpect(status().isBadRequest()); + + // 内容过长 + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(orderItemId2, 5, "x".repeat(1001)))) + .andExpect(status().isBadRequest()); + + // 不能评价别人的订单商品 + String otherUser = "other_" + suffix; + register(otherUser, "Test1234!"); + String otherToken = login(otherUser, "Test1234!"); + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(otherToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(orderItemId2, 5, "别人的订单不能评"))) + .andExpect(status().isForbidden()); + } + + private void register(String username, String password) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password, + "nickname", username + )))) + .andExpect(status().isOk()); + } + + private String login(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password + )))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + JsonNode data = root.path("data"); + if (data.hasNonNull("token")) { + return data.get("token").asText(); + } + if (data.hasNonNull("accessToken")) { + return data.get("accessToken").asText(); + } + return data.asText(); + } + + private String bearer(String token) { + return "Bearer " + token; + } + + private String reviewJson(long orderItemId, int rating, String content) throws Exception { + return objectMapper.writeValueAsString(Map.of( + "orderItemId", orderItemId, + "rating", rating, + "content", content + )); + } +} -- Gitee From 42446415331cfeca4d9a9181cf2919266d054ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Wed, 29 Jul 2026 08:32:44 +0800 Subject: [PATCH 07/21] =?UTF-8?q?feat(browse):=20=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=20P1=EF=BC=88=E5=90=8E=E7=AB=AF=204=20?= =?UTF-8?q?=E4=B8=AA=20API=20+=20PC/=E6=89=8B=E6=9C=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=20+=20=E8=AF=A6=E6=83=85=E9=9D=99=E9=BB=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - V6__create_product_browse_history.sql(带 UNIQUE + 索引) - com.eshop.backend.browse 包(Controller/Service/Entity/Mapper/DTO) - POST /browse-history/{productId} 幂等更新 last_visited_at - GET /browse-history 分页按最近访问倒序 - DELETE /browse-history/{productId} 单条删除 - DELETE /browse-history 一键清空 前端: - api/browseHistory.js(4 个函数封装) - views/pc/PcHistoryView.vue Element Plus 列表 + 移除 + 清空 + 分页 - views/mobile/MobileHistoryView.vue Vant 列表 + 移除 + 清空 + 触底加载 - PcProductDetailView / MobileProductDetailView 加 recordVisit().catch() 静默 POST - PcProfileView / MobileProfileView 加 '我的浏览' 入口(不直接改 router) 规约遵守: - 不改 router/index.js(建议路径 /pc/history、/m/history 由组长在 master 加) - 不动 Flyway V1-V5 - 不动 product_favorite 收藏表结构 - 静默 POST 用 .catch(() => {}) 吞 401 错误,不打断浏览 附:20260729 日报。 --- .../browse/BrowseHistoryController.java | 60 +++++ .../backend/browse/BrowseHistoryService.java | 129 ++++++++++ .../backend/browse/ProductBrowseHistory.java | 19 ++ .../browse/ProductBrowseHistoryMapper.java | 6 + .../browse/dto/BrowseHistoryResponse.java | 12 + .../V6__create_product_browse_history.sql | 9 + .../frontend/src/api/browseHistory.js | 20 ++ .../src/views/mobile/MobileHistoryView.vue | 214 ++++++++++++++++ .../views/mobile/MobileProductDetailView.vue | 2 + .../src/views/mobile/MobileProfileView.vue | 6 + .../frontend/src/views/pc/PcHistoryView.vue | 228 ++++++++++++++++++ .../src/views/pc/PcProductDetailView.vue | 2 + .../frontend/src/views/pc/PcProfileView.vue | 23 ++ ...9-\345\220\264\346\235\276\351\235\222.md" | 90 +++++++ 14 files changed, 820 insertions(+) create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql create mode 100644 eshop-release-v1.0/frontend/src/api/browseHistory.js create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java new file mode 100644 index 0000000..29674c3 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java @@ -0,0 +1,60 @@ +package com.eshop.backend.browse; + +import com.eshop.backend.browse.dto.BrowseHistoryResponse; +import com.eshop.backend.common.ApiResponse; +import com.eshop.backend.common.PageResult; +import com.eshop.backend.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/browse-history") +@RequiredArgsConstructor +public class BrowseHistoryController { + private final BrowseHistoryService browseHistoryService; + + /** + * 用户进入商品详情时静默记录(幂等更新 last_visited_at)。 + */ + @PostMapping("/{productId}") + public ApiResponse record( + @AuthenticationPrincipal LoginUser user, + @PathVariable Long productId) { + return ApiResponse.success(browseHistoryService.recordVisit(user, productId)); + } + + /** + * 当前用户的浏览历史分页,按最近访问倒序。 + */ + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal LoginUser user, + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "20") long size) { + return ApiResponse.success(browseHistoryService.pageUserHistory(user, current, size)); + } + + @DeleteMapping("/{productId}") + public ApiResponse> deleteOne( + @AuthenticationPrincipal LoginUser user, + @PathVariable Long productId) { + browseHistoryService.deleteOne(user, productId); + return ApiResponse.success(Map.of("deleted", true, "productId", productId)); + } + + @DeleteMapping + public ApiResponse> clear( + @AuthenticationPrincipal LoginUser user) { + int count = browseHistoryService.clearAll(user); + return ApiResponse.success(Map.of("deleted", true, "count", count)); + } +} \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java new file mode 100644 index 0000000..082b775 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java @@ -0,0 +1,129 @@ +package com.eshop.backend.browse; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.eshop.backend.browse.dto.BrowseHistoryResponse; +import com.eshop.backend.catalog.entity.Product; +import com.eshop.backend.catalog.mapper.ProductMapper; +import com.eshop.backend.common.BusinessException; +import com.eshop.backend.common.ErrorCode; +import com.eshop.backend.common.PageResult; +import com.eshop.backend.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BrowseHistoryService { + + private final ProductBrowseHistoryMapper browseHistoryMapper; + private final ProductMapper productMapper; + + /** + * 用户进入商品详情时静默记录。 + * 同一用户对同一商品只保留一条记录,且只刷新 last_visited_at。 + */ + @Transactional + public BrowseHistoryResponse recordVisit(LoginUser user, Long productId) { + if (user == null || user.getUserId() == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + if (productId == null || productId <= 0) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); + } + Product product = productMapper.selectById(productId); + if (product == null) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); + } + LocalDateTime now = LocalDateTime.now(); + + ProductBrowseHistory existing = browseHistoryMapper.selectOne( + new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId()) + .eq(ProductBrowseHistory::getProductId, productId) + ); + if (existing != null) { + existing.setLastVisitedAt(now); + browseHistoryMapper.updateById(existing); + return toResponse(existing, product); + } + + ProductBrowseHistory history = new ProductBrowseHistory(); + history.setUserId(user.getUserId()); + history.setProductId(productId); + history.setLastVisitedAt(now); + history.setCreatedAt(now); + browseHistoryMapper.insert(history); + return toResponse(history, product); + } + + public PageResult pageUserHistory(LoginUser user, long current, long size) { + if (user == null || user.getUserId() == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + if (current < 1) current = 1; + if (size <= 0 || size > 100) size = 20; + + long offset = (current - 1) * size; + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId()) + .orderByDesc(ProductBrowseHistory::getLastVisitedAt); + long total = browseHistoryMapper.selectCount(wrapper); + List records = browseHistoryMapper.selectList( + wrapper.last("LIMIT " + size + " OFFSET " + offset) + ); + + if (records.isEmpty()) { + return new PageResult<>(current, size, total, Collections.emptyList()); + } + + List productIds = records.stream().map(ProductBrowseHistory::getProductId).toList(); + Map productMap = productMapper.selectBatchIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity(), (a, b) -> a)); + + List responses = records.stream() + .map(record -> toResponse(record, productMap.get(record.getProductId()))) + .toList(); + return new PageResult<>(responses, total, current, size); + } + + @Transactional + public void deleteOne(LoginUser user, Long productId) { + if (user == null || user.getUserId() == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + if (productId == null || productId <= 0) { + throw new BusinessException(ErrorCode.NOT_FOUND); + } + browseHistoryMapper.delete(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId()) + .eq(ProductBrowseHistory::getProductId, productId)); + } + + @Transactional + public int clearAll(LoginUser user) { + if (user == null || user.getUserId() == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return browseHistoryMapper.delete(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId())); + } + + private BrowseHistoryResponse toResponse(ProductBrowseHistory history, Product product) { + return new BrowseHistoryResponse( + history.getId(), + history.getProductId(), + product == null ? null : product.getName(), + product == null ? null : product.getMainImage(), + history.getLastVisitedAt() + ); + } +} \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java new file mode 100644 index 0000000..546509a --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java @@ -0,0 +1,19 @@ +package com.eshop.backend.browse; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("product_browse_history") +public class ProductBrowseHistory { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long productId; + private LocalDateTime lastVisitedAt; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java new file mode 100644 index 0000000..a9513e0 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java @@ -0,0 +1,6 @@ +package com.eshop.backend.browse; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +public interface ProductBrowseHistoryMapper extends BaseMapper { +} \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java new file mode 100644 index 0000000..d62e527 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java @@ -0,0 +1,12 @@ +package com.eshop.backend.browse.dto; + +import java.time.LocalDateTime; + +public record BrowseHistoryResponse( + Long id, + Long productId, + String productName, + String productImage, + LocalDateTime lastVisitedAt +) { +} \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql b/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql new file mode 100644 index 0000000..81833a7 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS product_browse_history ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + last_visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_browse_user_product (user_id, product_id), + INDEX idx_browse_user_visited (user_id, last_visited_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/eshop-release-v1.0/frontend/src/api/browseHistory.js b/eshop-release-v1.0/frontend/src/api/browseHistory.js new file mode 100644 index 0000000..a689b49 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/api/browseHistory.js @@ -0,0 +1,20 @@ +import http from './http' + +/** + * 浏览历史(D 模块 P1 选做)。 + * + * - recordVisit:进入商品详情时静默记录,幂等更新 last_visited_at + * - pageHistory:当前用户的历史分页,按最近访问倒序 + * - deleteOne / clear:单条删除或一键清空 + * + * 后端由组员 4(吴松青)在 feature/mobile-order-payment 分支提供, + * 对应 Flyway V6 (product_browse_history 表)。 + */ + +export const recordVisit = (productId) => http.post(`/browse-history/${productId}`) + +export const pageHistory = (params) => http.get('/browse-history', { params }) + +export const deleteOne = (productId) => http.delete(`/browse-history/${productId}`) + +export const clearAll = () => http.delete('/browse-history') \ No newline at end of file diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue new file mode 100644 index 0000000..33dd327 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue @@ -0,0 +1,214 @@ + + + + + \ No newline at end of file diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue index 33295d4..5639ae0 100644 --- a/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileProductDetailView.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue' import { showImagePreview, showSuccessToast, showToast } from 'vant' import { useRoute, useRouter } from 'vue-router' +import { recordVisit } from '../../api/browseHistory' import { addCartItem } from '../../api/cart' import { getProduct } from '../../api/catalog' import { useAuthStore } from '../../stores/auth' @@ -58,6 +59,7 @@ const loadProduct = async () => { const data = await getProduct(id) if (requestId !== requestSequence) return product.value = data + recordVisit(id).catch(() => { /* 静默:未登录 401 不影响浏览 */ }) const skus = data.skus || [] const firstAvailable = skus.find((sku) => sku.stock > 0) || skus[0] selectedSkuId.value = firstAvailable ? firstAvailable.id : null diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue index 9f98059..75e5a96 100644 --- a/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue @@ -72,6 +72,12 @@ onMounted(refreshProfile) +
diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue new file mode 100644 index 0000000..79da7b0 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue @@ -0,0 +1,228 @@ + + + + + \ No newline at end of file diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue index 78b9b52..a2e62b7 100644 --- a/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue +++ b/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue @@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { useRoute, useRouter } from 'vue-router' import { addCartItem } from '../../api/cart' +import { recordVisit } from '../../api/browseHistory' import { getProduct } from '../../api/catalog' import { useAuthStore } from '../../stores/auth' @@ -50,6 +51,7 @@ const loadProduct = async () => { if (!Number.isInteger(id) || id <= 0) throw new Error('商品编号不正确') const data = await getProduct(id) product.value = data + recordVisit(id).catch(() => { /* 静默:未登录 401 不影响浏览 */ }) const firstAvailable = data.skus.find((sku) => sku.stock > 0) || data.skus[0] selectedSkuId.value = firstAvailable?.id quantity.value = 1 diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue index 1511d44..2b5749a 100644 --- a/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue +++ b/eshop-release-v1.0/frontend/src/views/pc/PcProfileView.vue @@ -1,9 +1,13 @@ + """) + List selectHotProducts( + @Param("cutoff") LocalDateTime cutoff, + @Param("limit") int limit); +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/analytics/HotProductResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/analytics/HotProductResponse.java new file mode 100644 index 0000000..6266133 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/analytics/HotProductResponse.java @@ -0,0 +1,14 @@ +package com.eshop.backend.analytics; + +import java.math.BigDecimal; + +public record HotProductResponse( + Long productId, + String name, + String subtitle, + String mainImage, + Long salesQuantity, + BigDecimal salesAmount, + BigDecimal minPrice, + Long totalStock) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/analytics/HotProductService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/analytics/HotProductService.java new file mode 100644 index 0000000..8946b15 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/analytics/HotProductService.java @@ -0,0 +1,24 @@ +package com.eshop.backend.analytics; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class HotProductService { + private final HotProductMapper hotProductMapper; + + @Transactional(readOnly = true) + public List list(int days, int limit) { + int normalizedDays = Math.min(Math.max(days, 0), 365); + int normalizedLimit = Math.min(Math.max(limit, 1), 20); + LocalDateTime cutoff = normalizedDays == 0 + ? null + : LocalDateTime.now().minusDays(normalizedDays); + return hotProductMapper.selectHotProducts(cutoff, normalizedLimit); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java index 29674c3..422c292 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java @@ -23,7 +23,7 @@ public class BrowseHistoryController { private final BrowseHistoryService browseHistoryService; /** - * 用户进入商品详情时静默记录(幂等更新 last_visited_at)。 + * 用户进入商品详情时静默记录(幂等更新 browsed_at)。 */ @PostMapping("/{productId}") public ApiResponse record( diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java index 082b775..df31b75 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java @@ -29,7 +29,7 @@ public class BrowseHistoryService { /** * 用户进入商品详情时静默记录。 - * 同一用户对同一商品只保留一条记录,且只刷新 last_visited_at。 + * 同一用户对同一商品只保留一条记录,且只刷新 browsed_at。 */ @Transactional public BrowseHistoryResponse recordVisit(LoginUser user, Long productId) { @@ -51,7 +51,7 @@ public class BrowseHistoryService { .eq(ProductBrowseHistory::getProductId, productId) ); if (existing != null) { - existing.setLastVisitedAt(now); + existing.setBrowsedAt(now); browseHistoryMapper.updateById(existing); return toResponse(existing, product); } @@ -59,7 +59,7 @@ public class BrowseHistoryService { ProductBrowseHistory history = new ProductBrowseHistory(); history.setUserId(user.getUserId()); history.setProductId(productId); - history.setLastVisitedAt(now); + history.setBrowsedAt(now); history.setCreatedAt(now); browseHistoryMapper.insert(history); return toResponse(history, product); @@ -75,7 +75,7 @@ public class BrowseHistoryService { long offset = (current - 1) * size; LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ProductBrowseHistory::getUserId, user.getUserId()) - .orderByDesc(ProductBrowseHistory::getLastVisitedAt); + .orderByDesc(ProductBrowseHistory::getBrowsedAt); long total = browseHistoryMapper.selectCount(wrapper); List records = browseHistoryMapper.selectList( wrapper.last("LIMIT " + size + " OFFSET " + offset) @@ -123,7 +123,7 @@ public class BrowseHistoryService { history.getProductId(), product == null ? null : product.getName(), product == null ? null : product.getMainImage(), - history.getLastVisitedAt() + history.getBrowsedAt() ); } } \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java index 546509a..4747f06 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java @@ -14,6 +14,6 @@ public class ProductBrowseHistory { private Long id; private Long userId; private Long productId; - private LocalDateTime lastVisitedAt; + private LocalDateTime browsedAt; private LocalDateTime createdAt; } \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java index d62e527..e1074e3 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/dto/BrowseHistoryResponse.java @@ -7,6 +7,6 @@ public record BrowseHistoryResponse( Long productId, String productName, String productImage, - LocalDateTime lastVisitedAt + LocalDateTime browsedAt ) { } \ No newline at end of file diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java index 1b6b688..1d75e75 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java @@ -40,11 +40,14 @@ public enum ErrorCode { ORDER_PAYMENT_EXPIRED(40926, "订单已超过支付时限"), SKU_HAS_ACTIVE_ORDERS(40927, "SKU存在未完成订单,不能删除"), ORDER_ITEM_NOT_FOUND(40417, "订单商品不存在"), - REVIEW_NOT_FOUND(40418, "评价不存在"), - ORDER_NOT_COMPLETED_FOR_REVIEW(40928, "只有已完成订单可以评价"), - REVIEW_ALREADY_EXISTS(40929, "该订单商品已评价,不能重复评价"), - REVIEW_RATING_INVALID(40031, "评分必须在1~5分之间"), - REVIEW_CONTENT_INVALID(40032, "评价内容长度需在1~1000个字符之间"), + FAVORITE_ALREADY_EXISTS(40928, "商品已经收藏"), + ORDER_NOT_COMPLETED_FOR_REVIEW(40929, "订单尚未完成,不能评价"), + REVIEW_ALREADY_EXISTS(40930, "订单商品已经评价"), + REVIEW_RATING_INVALID(40031, "评分必须在1至5分之间"), + REVIEW_CONTENT_INVALID(40032, "评价内容长度必须在1至1000个字符之间"), + REVIEW_IMAGE_LIMIT(40033, "评价图片最多上传3张"), + REVIEW_IMAGE_INVALID(40034, "评价图片地址不合法"), + BROWSE_HISTORY_NOT_FOUND(40418, "浏览记录不存在"), INTERNAL_ERROR(50000, "系统异常,请稍后重试"), FILE_SAVE_FAILED(50001, "文件保存失败"); diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteController.java new file mode 100644 index 0000000..aceb386 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteController.java @@ -0,0 +1,52 @@ +package com.eshop.backend.favorite; + +import com.eshop.backend.common.ApiResponse; +import com.eshop.backend.common.PageResult; +import com.eshop.backend.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/favorites") +@RequiredArgsConstructor +public class FavoriteController { + private final FavoriteService favoriteService; + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal LoginUser user, + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "20") long size) { + return ApiResponse.success(favoriteService.page(user.getUserId(), current, size)); + } + + @GetMapping("/{productId}/status") + public ApiResponse status( + @AuthenticationPrincipal LoginUser user, + @PathVariable Long productId) { + return ApiResponse.success(favoriteService.status(user.getUserId(), productId)); + } + + @PostMapping("/{productId}") + public ApiResponse add( + @AuthenticationPrincipal LoginUser user, + @PathVariable Long productId) { + favoriteService.add(user.getUserId(), productId); + return ApiResponse.success(); + } + + @DeleteMapping("/{productId}") + public ApiResponse remove( + @AuthenticationPrincipal LoginUser user, + @PathVariable Long productId) { + favoriteService.remove(user.getUserId(), productId); + return ApiResponse.success(); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteProductResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteProductResponse.java new file mode 100644 index 0000000..eaea88f --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteProductResponse.java @@ -0,0 +1,16 @@ +package com.eshop.backend.favorite; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record FavoriteProductResponse( + Long id, + Long categoryId, + String name, + String subtitle, + String mainImage, + BigDecimal minPrice, + int totalStock, + LocalDateTime favoritedAt +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteService.java new file mode 100644 index 0000000..5f52c43 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteService.java @@ -0,0 +1,87 @@ +package com.eshop.backend.favorite; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.eshop.backend.catalog.entity.Product; +import com.eshop.backend.catalog.mapper.ProductMapper; +import com.eshop.backend.common.BusinessException; +import com.eshop.backend.common.ErrorCode; +import com.eshop.backend.common.PageResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FavoriteService { + private final ProductFavoriteMapper favoriteMapper; + private final ProductMapper productMapper; + + @Transactional + public void add(Long userId, Long productId) { + requireOnSaleProduct(productId); + Long count = favoriteMapper.selectCount(new LambdaQueryWrapper() + .eq(ProductFavorite::getUserId, userId) + .eq(ProductFavorite::getProductId, productId)); + if (count != null && count > 0) { + return; + } + + ProductFavorite favorite = new ProductFavorite(); + favorite.setUserId(userId); + favorite.setProductId(productId); + favorite.setCreatedAt(LocalDateTime.now()); + try { + favoriteMapper.insert(favorite); + } catch (DuplicateKeyException exception) { + log.debug("并发收藏已由唯一索引处理,userId={}, productId={}", userId, productId); + } + } + + @Transactional + public void remove(Long userId, Long productId) { + requirePositiveProductId(productId); + favoriteMapper.delete(new LambdaQueryWrapper() + .eq(ProductFavorite::getUserId, userId) + .eq(ProductFavorite::getProductId, productId)); + } + + @Transactional(readOnly = true) + public FavoriteStatusResponse status(Long userId, Long productId) { + requirePositiveProductId(productId); + Long count = favoriteMapper.selectCount(new LambdaQueryWrapper() + .eq(ProductFavorite::getUserId, userId) + .eq(ProductFavorite::getProductId, productId)); + return new FavoriteStatusResponse(productId, count != null && count > 0); + } + + @Transactional(readOnly = true) + public PageResult page(Long userId, long current, long size) { + Page page = new Page<>( + Math.max(current, 1), + Math.min(Math.max(size, 1), 50)); + return PageResult.from(favoriteMapper.selectFavoritePage(page, userId)); + } + + private void requireOnSaleProduct(Long productId) { + requirePositiveProductId(productId); + Product product = productMapper.selectById(productId); + if (product == null) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); + } + if (!"ON_SALE".equals(product.getStatus())) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_ON_SALE); + } + } + + private void requirePositiveProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteStatusResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteStatusResponse.java new file mode 100644 index 0000000..5403851 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/FavoriteStatusResponse.java @@ -0,0 +1,4 @@ +package com.eshop.backend.favorite; + +public record FavoriteStatusResponse(Long productId, boolean favorited) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/ProductFavorite.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/ProductFavorite.java new file mode 100644 index 0000000..0718f60 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/ProductFavorite.java @@ -0,0 +1,18 @@ +package com.eshop.backend.favorite; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("product_favorite") +public class ProductFavorite { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long productId; + private LocalDateTime createdAt; +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/ProductFavoriteMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/ProductFavoriteMapper.java new file mode 100644 index 0000000..86fcdb1 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/favorite/ProductFavoriteMapper.java @@ -0,0 +1,39 @@ +package com.eshop.backend.favorite; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface ProductFavoriteMapper extends BaseMapper { + + @Select(""" + SELECT + favorite.product_id AS id, + product.category_id AS category_id, + product.name, + product.subtitle, + product.main_image AS main_image, + MIN(CASE WHEN sku.status = 'ENABLED' THEN sku.price END) AS min_price, + COALESCE(SUM(CASE WHEN sku.status = 'ENABLED' THEN sku.stock ELSE 0 END), 0) AS total_stock, + favorite.created_at AS favorited_at + FROM product_favorite favorite + INNER JOIN product ON product.id = favorite.product_id + AND product.status = 'ON_SALE' + LEFT JOIN product_sku sku ON sku.product_id = product.id + WHERE favorite.user_id = #{userId} + GROUP BY + favorite.id, + favorite.product_id, + product.category_id, + product.name, + product.subtitle, + product.main_image, + favorite.created_at + ORDER BY favorite.created_at DESC, favorite.id DESC + """) + IPage selectFavoritePage( + Page page, + @Param("userId") Long userId); +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/AdminFileController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/AdminFileController.java index 3f23081..f94a457 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/AdminFileController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/AdminFileController.java @@ -16,6 +16,6 @@ public class AdminFileController { @PostMapping("/upload") public ApiResponse upload(@RequestPart("file") MultipartFile file) { - return ApiResponse.success(fileUploadService.uploadImage(file)); + return ApiResponse.success(fileUploadService.uploadAdminImage(file)); } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/FileUploadService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/FileUploadService.java index 3a66351..1a43cd7 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/FileUploadService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/FileUploadService.java @@ -22,8 +22,22 @@ public class FileUploadService { this.uploadRoot = Path.of(uploadDir).toAbsolutePath().normalize(); } + /** + * 管理员上传商品图片,写入操作日志。 + */ @OperationLogAction(module = "文件管理", action = "上传商品图片") - public FileUploadResponse uploadImage(MultipartFile file) { + public FileUploadResponse uploadAdminImage(MultipartFile file) { + return saveImage(file); + } + + /** + * 登录用户上传图片(评价晒图等),不写后台操作日志。 + */ + public FileUploadResponse uploadUserImage(MultipartFile file) { + return saveImage(file); + } + + private FileUploadResponse saveImage(MultipartFile file) { if (file == null || file.isEmpty() || file.getSize() > MAX_FILE_SIZE) { throw new BusinessException(ErrorCode.INVALID_FILE); } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/UserFileController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/UserFileController.java new file mode 100644 index 0000000..7fcfbea --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/file/UserFileController.java @@ -0,0 +1,25 @@ +package com.eshop.backend.file; + +import com.eshop.backend.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 登录用户图片上传(评价晒图等扩展功能公共能力)。 + * 管理员商品图仍走 {@link AdminFileController}。 + */ +@RestController +@RequestMapping("/files") +@RequiredArgsConstructor +public class UserFileController { + private final FileUploadService fileUploadService; + + @PostMapping("/upload") + public ApiResponse upload(@RequestPart("file") MultipartFile file) { + return ApiResponse.success(fileUploadService.uploadUserImage(file)); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/AdminReviewController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/AdminReviewController.java new file mode 100644 index 0000000..fc8c618 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/AdminReviewController.java @@ -0,0 +1,39 @@ +package com.eshop.backend.review; + +import com.eshop.backend.common.ApiResponse; +import com.eshop.backend.common.PageResult; +import com.eshop.backend.review.dto.AdminReviewResponse; +import com.eshop.backend.review.dto.ReviewStatusRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/reviews") +@RequiredArgsConstructor +public class AdminReviewController { + private final ReviewService reviewService; + + @GetMapping + public ApiResponse> page( + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "20") long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer rating, + @RequestParam(required = false) String status) { + return ApiResponse.success(reviewService.pageAdmin(current, size, keyword, rating, status)); + } + + @PatchMapping("/{id}/status") + public ApiResponse updateStatus( + @PathVariable Long id, + @Valid @RequestBody ReviewStatusRequest request) { + return ApiResponse.success(reviewService.updateStatus(id, request.status())); + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java index 8271ccc..befe8b2 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java @@ -1,6 +1,136 @@ package com.eshop.backend.review; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.eshop.backend.review.dto.MyReviewResponse; +import com.eshop.backend.review.dto.AdminReviewResponse; +import com.eshop.backend.review.dto.ProductReviewResponse; +import com.eshop.backend.review.dto.ProductReviewSummaryResponse; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; public interface ProductReviewMapper extends BaseMapper { + + @Select(""" + + """) + IPage selectAdminReviewPage( + Page page, + @Param("keyword") String keyword, + @Param("rating") Integer rating, + @Param("status") String status); + + @Select(""" + SELECT + review.id, + review.user_id AS user_id, + reviewer.username, + COALESCE(NULLIF(TRIM(reviewer.nickname), ''), '匿名用户') AS reviewer_nickname, + review.product_id AS product_id, + item.product_name AS product_name, + item.sku_specs AS sku_specs, + review.rating, + review.content, + review.status, + review.created_at AS created_at, + review.updated_at AS updated_at + FROM product_review review + INNER JOIN order_item item ON item.id = review.order_item_id + LEFT JOIN sys_user reviewer ON reviewer.id = review.user_id + WHERE review.id = #{id} + """) + AdminReviewResponse selectAdminReviewById(@Param("id") Long id); + + @Select(""" + SELECT + review.id, + review.product_id AS product_id, + item.product_name AS product_name, + item.sku_specs AS sku_specs, + review.rating, + review.content, + COALESCE(NULLIF(TRIM(reviewer.nickname), ''), '匿名用户') AS reviewer_nickname, + review.created_at AS created_at + FROM product_review review + INNER JOIN order_item item ON item.id = review.order_item_id + LEFT JOIN sys_user reviewer ON reviewer.id = review.user_id + WHERE review.product_id = #{productId} + AND review.status = 'PUBLISHED' + ORDER BY review.created_at DESC, review.id DESC + """) + IPage selectProductReviewPage( + Page page, + @Param("productId") Long productId); + + @Select(""" + SELECT + review.id, + review.product_id AS product_id, + review.order_id AS order_id, + review.order_item_id AS order_item_id, + item.product_name AS product_name, + item.sku_specs AS sku_specs, + item.product_image AS product_image, + review.rating, + review.content, + review.status, + review.created_at AS created_at, + review.updated_at AS updated_at + FROM product_review review + INNER JOIN order_item item ON item.id = review.order_item_id + WHERE review.user_id = #{userId} + ORDER BY review.created_at DESC, review.id DESC + """) + IPage selectMyReviewPage( + Page page, + @Param("userId") Long userId); + + @Select(""" + SELECT + COALESCE(ROUND(AVG(rating), 1), 0) AS average_rating, + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END), 0) AS five_star_count, + COALESCE(SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END), 0) AS four_star_count, + COALESCE(SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END), 0) AS three_star_count, + COALESCE(SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END), 0) AS two_star_count, + COALESCE(SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END), 0) AS one_star_count + FROM product_review + WHERE product_id = #{productId} + AND status = 'PUBLISHED' + """) + ProductReviewSummaryResponse selectProductReviewSummary(@Param("productId") Long productId); } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java index 3754627..5a9b7cf 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewController.java @@ -1,8 +1,11 @@ package com.eshop.backend.review; import com.eshop.backend.common.ApiResponse; +import com.eshop.backend.common.PageResult; import com.eshop.backend.review.dto.CreateReviewRequest; -import com.eshop.backend.review.dto.ReviewResponse; +import com.eshop.backend.review.dto.MyReviewResponse; +import com.eshop.backend.review.dto.ProductReviewResponse; +import com.eshop.backend.review.dto.ProductReviewSummaryResponse; import com.eshop.backend.security.LoginUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -11,27 +14,40 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/reviews") @RequiredArgsConstructor public class ReviewController { private final ReviewService reviewService; - /** - * 创建商品评价(仅已完成订单中自己的订单商品,不可重复评价)。 - */ - @PostMapping - public ApiResponse create( + @GetMapping("/products/{productId}/reviews") + public ApiResponse> productReviews( + @PathVariable Long productId, + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "10") long size) { + return ApiResponse.success(reviewService.pageByProduct(productId, current, size)); + } + + @GetMapping("/products/{productId}/reviews/summary") + public ApiResponse productReviewSummary( + @PathVariable Long productId) { + return ApiResponse.success(reviewService.summaryByProduct(productId)); + } + + @PostMapping("/reviews") + public ApiResponse create( @AuthenticationPrincipal LoginUser user, @Valid @RequestBody CreateReviewRequest request) { - return ApiResponse.success(reviewService.createReview(user, request)); + return ApiResponse.success(reviewService.create(user.getUserId(), request)); } - @GetMapping("/{id}") - public ApiResponse detail(@PathVariable Long id) { - return ApiResponse.success(reviewService.getById(id)); + @GetMapping("/reviews/mine") + public ApiResponse> mine( + @AuthenticationPrincipal LoginUser user, + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "10") long size) { + return ApiResponse.success(reviewService.pageMine(user.getUserId(), current, size)); } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java index 4345c8a..61d46f8 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java @@ -1,16 +1,23 @@ package com.eshop.backend.review; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.eshop.backend.catalog.entity.Product; +import com.eshop.backend.catalog.mapper.ProductMapper; +import com.eshop.backend.admin.operationlog.OperationLogAction; import com.eshop.backend.common.BusinessException; import com.eshop.backend.common.ErrorCode; +import com.eshop.backend.common.PageResult; import com.eshop.backend.order.OrderItem; import com.eshop.backend.order.OrderItemMapper; import com.eshop.backend.order.OrderService; import com.eshop.backend.order.ShopOrder; import com.eshop.backend.order.ShopOrderMapper; import com.eshop.backend.review.dto.CreateReviewRequest; -import com.eshop.backend.review.dto.ReviewResponse; -import com.eshop.backend.security.LoginUser; +import com.eshop.backend.review.dto.AdminReviewResponse; +import com.eshop.backend.review.dto.MyReviewResponse; +import com.eshop.backend.review.dto.ProductReviewResponse; +import com.eshop.backend.review.dto.ProductReviewSummaryResponse; import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -18,100 +25,154 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.util.Set; @Service @RequiredArgsConstructor public class ReviewService { - public static final String STATUS_PUBLISHED = "PUBLISHED"; + private static final String PUBLISHED = "PUBLISHED"; + private static final String HIDDEN = "HIDDEN"; + private static final Set ADMIN_STATUSES = Set.of(PUBLISHED, HIDDEN); - private final ProductReviewMapper productReviewMapper; + private final ProductReviewMapper reviewMapper; private final OrderItemMapper orderItemMapper; - private final ShopOrderMapper shopOrderMapper; + private final ShopOrderMapper orderMapper; + private final ProductMapper productMapper; @Transactional - public ReviewResponse createReview(LoginUser user, CreateReviewRequest request) { - if (user == null || user.getUserId() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - - Integer rating = request.rating(); - if (rating == null || rating < 1 || rating > 5) { - throw new BusinessException(ErrorCode.REVIEW_RATING_INVALID); - } - + public MyReviewResponse create(Long userId, CreateReviewRequest request) { String content = request.content() == null ? "" : request.content().trim(); if (!StringUtils.hasText(content) || content.length() > 1000) { throw new BusinessException(ErrorCode.REVIEW_CONTENT_INVALID); } - - OrderItem orderItem = orderItemMapper.selectById(request.orderItemId()); - if (orderItem == null) { - throw new BusinessException(ErrorCode.ORDER_ITEM_NOT_FOUND); + if (request.rating() == null || request.rating() < 1 || request.rating() > 5) { + throw new BusinessException(ErrorCode.REVIEW_RATING_INVALID); } - ShopOrder order = shopOrderMapper.selectById(orderItem.getOrderId()); - if (order == null) { - throw new BusinessException(ErrorCode.ORDER_NOT_FOUND); + OrderItem item = orderItemMapper.selectById(request.orderItemId()); + if (item == null) { + throw new BusinessException(ErrorCode.ORDER_ITEM_NOT_FOUND); } - - // 只能评价自己的订单商品 - if (!user.getUserId().equals(order.getUserId())) { - throw new BusinessException(ErrorCode.FORBIDDEN); + ShopOrder order = orderMapper.selectById(item.getOrderId()); + if (order == null || !userId.equals(order.getUserId())) { + throw new BusinessException(ErrorCode.ORDER_ITEM_NOT_FOUND); } - - // 只有已完成订单可以评价 if (!OrderService.COMPLETED.equals(order.getStatus())) { throw new BusinessException(ErrorCode.ORDER_NOT_COMPLETED_FOR_REVIEW); } - - // 同一订单商品不能重复评价 - Long exists = productReviewMapper.selectCount(new LambdaQueryWrapper() - .eq(ProductReview::getOrderItemId, orderItem.getId())); - if (exists != null && exists > 0) { + Long existing = reviewMapper.selectCount(new LambdaQueryWrapper() + .eq(ProductReview::getOrderItemId, item.getId())); + if (existing != null && existing > 0) { throw new BusinessException(ErrorCode.REVIEW_ALREADY_EXISTS); } LocalDateTime now = LocalDateTime.now(); ProductReview review = new ProductReview(); - review.setUserId(user.getUserId()); - review.setProductId(orderItem.getProductId()); + review.setUserId(userId); + review.setProductId(item.getProductId()); review.setOrderId(order.getId()); - review.setOrderItemId(orderItem.getId()); - review.setRating(rating); + review.setOrderItemId(item.getId()); + review.setRating(request.rating()); review.setContent(content); - review.setStatus(STATUS_PUBLISHED); + review.setStatus(PUBLISHED); review.setCreatedAt(now); review.setUpdatedAt(now); - try { - productReviewMapper.insert(review); - } catch (DuplicateKeyException ex) { + reviewMapper.insert(review); + } catch (DuplicateKeyException exception) { throw new BusinessException(ErrorCode.REVIEW_ALREADY_EXISTS); } + return toMyResponse(review, item); + } - return toResponse(review); + @Transactional(readOnly = true) + public PageResult pageByProduct(Long productId, long current, long size) { + requirePublicProduct(productId); + Page page = new Page<>( + Math.max(current, 1), + Math.min(Math.max(size, 1), 50)); + return PageResult.from(reviewMapper.selectProductReviewPage(page, productId)); } - public ReviewResponse getById(Long id) { - ProductReview review = productReviewMapper.selectById(id); + @Transactional(readOnly = true) + public ProductReviewSummaryResponse summaryByProduct(Long productId) { + requirePublicProduct(productId); + return reviewMapper.selectProductReviewSummary(productId); + } + + @Transactional(readOnly = true) + public PageResult pageMine(Long userId, long current, long size) { + Page page = new Page<>( + Math.max(current, 1), + Math.min(Math.max(size, 1), 50)); + return PageResult.from(reviewMapper.selectMyReviewPage(page, userId)); + } + + @Transactional(readOnly = true) + public PageResult pageAdmin( + long current, + long size, + String keyword, + Integer rating, + String status) { + String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null; + String normalizedStatus = StringUtils.hasText(status) ? status.trim().toUpperCase() : null; + if (normalizedStatus != null && !ADMIN_STATUSES.contains(normalizedStatus)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + if (rating != null && (rating < 1 || rating > 5)) { + throw new BusinessException(ErrorCode.REVIEW_RATING_INVALID); + } + Page page = new Page<>( + Math.max(current, 1), + Math.min(Math.max(size, 1), 100)); + return PageResult.from(reviewMapper.selectAdminReviewPage( + page, + normalizedKeyword, + rating, + normalizedStatus)); + } + + @Transactional + @OperationLogAction(module = "评价管理", action = "修改评价状态") + public AdminReviewResponse updateStatus(Long id, String status) { + String normalizedStatus = StringUtils.hasText(status) ? status.trim().toUpperCase() : ""; + if (!ADMIN_STATUSES.contains(normalizedStatus)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + ProductReview review = reviewMapper.selectById(id); if (review == null) { - throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); + throw new BusinessException(ErrorCode.NOT_FOUND); + } + if (!normalizedStatus.equals(review.getStatus())) { + review.setStatus(normalizedStatus); + review.setUpdatedAt(LocalDateTime.now()); + reviewMapper.updateById(review); } - return toResponse(review); + return reviewMapper.selectAdminReviewById(id); } - private ReviewResponse toResponse(ProductReview review) { - return new ReviewResponse( + private MyReviewResponse toMyResponse(ProductReview review, OrderItem item) { + return new MyReviewResponse( review.getId(), - review.getUserId(), review.getProductId(), review.getOrderId(), review.getOrderItemId(), + item.getProductName(), + item.getSkuSpecs(), + item.getProductImage(), review.getRating(), review.getContent(), review.getStatus(), review.getCreatedAt(), - review.getUpdatedAt() - ); + review.getUpdatedAt()); + } + + private Product requirePublicProduct(Long productId) { + Product product = productMapper.selectById(productId); + if (product == null || !"ON_SALE".equals(product.getStatus())) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); + } + return product; } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java new file mode 100644 index 0000000..1c05ae5 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java @@ -0,0 +1,18 @@ +package com.eshop.backend.review.dto; + +import java.time.LocalDateTime; + +public record AdminReviewResponse( + Long id, + Long userId, + String username, + String reviewerNickname, + Long productId, + String productName, + String skuSpecs, + Integer rating, + String content, + String status, + LocalDateTime createdAt, + LocalDateTime updatedAt) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java index 3da0213..7300ca7 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java @@ -4,10 +4,12 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; public record CreateReviewRequest( @NotNull(message = "订单商品不能为空") + @Positive(message = "订单商品编号不正确") Long orderItemId, @NotNull(message = "评分不能为空") @@ -16,7 +18,7 @@ public record CreateReviewRequest( Integer rating, @NotBlank(message = "评价内容不能为空") - @Size(min = 1, max = 1000, message = "评价内容长度需在1~1000个字符之间") + @Size(max = 1000, message = "评价内容不能超过1000个字符") String content ) { } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java new file mode 100644 index 0000000..5c95eb8 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java @@ -0,0 +1,19 @@ +package com.eshop.backend.review.dto; + +import java.time.LocalDateTime; + +public record MyReviewResponse( + Long id, + Long productId, + Long orderId, + Long orderItemId, + String productName, + String skuSpecs, + String productImage, + Integer rating, + String content, + String status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java new file mode 100644 index 0000000..77b1ef4 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java @@ -0,0 +1,15 @@ +package com.eshop.backend.review.dto; + +import java.time.LocalDateTime; + +public record ProductReviewResponse( + Long id, + Long productId, + String productName, + String skuSpecs, + Integer rating, + String content, + String reviewerNickname, + LocalDateTime createdAt +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewSummaryResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewSummaryResponse.java new file mode 100644 index 0000000..94f156b --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewSummaryResponse.java @@ -0,0 +1,14 @@ +package com.eshop.backend.review.dto; + +import java.math.BigDecimal; + +public record ProductReviewSummaryResponse( + BigDecimal averageRating, + long total, + long fiveStarCount, + long fourStarCount, + long threeStarCount, + long twoStarCount, + long oneStarCount +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewStatusRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewStatusRequest.java new file mode 100644 index 0000000..866a96b --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ReviewStatusRequest.java @@ -0,0 +1,10 @@ +package com.eshop.backend.review.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ReviewStatusRequest( + @NotBlank + @Pattern(regexp = "PUBLISHED|HIDDEN", message = "评价状态只能是 PUBLISHED 或 HIDDEN") + String status) { +} diff --git a/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql b/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql index 81833a7..88d9079 100644 --- a/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql +++ b/eshop-release-v1.0/backend/src/main/resources/db/migration/V6__create_product_browse_history.sql @@ -1,9 +1,10 @@ +-- 商品浏览历史:同一用户同一商品仅保留一条记录(更新浏览时间) CREATE TABLE IF NOT EXISTS product_browse_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, - last_visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + browsed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_browse_user_product (user_id, product_id), - INDEX idx_browse_user_visited (user_id, last_visited_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file + INDEX idx_browse_user_browsed (user_id, browsed_at), + INDEX idx_browse_product (product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/eshop-release-v1.0/backend/src/main/resources/db/migration/V7__add_review_images.sql b/eshop-release-v1.0/backend/src/main/resources/db/migration/V7__add_review_images.sql new file mode 100644 index 0000000..8270899 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/resources/db/migration/V7__add_review_images.sql @@ -0,0 +1,3 @@ +-- 评价晒图:以 JSON 数组字符串保存图片 URL,兼容旧评价(NULL / 空数组) +ALTER TABLE product_review + ADD COLUMN images_json VARCHAR(2000) NULL COMMENT '评价图片URL JSON数组,最多3张' AFTER content; diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AdminReviewIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AdminReviewIntegrationTest.java new file mode 100644 index 0000000..1739b02 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AdminReviewIntegrationTest.java @@ -0,0 +1,196 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_admin_review;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class AdminReviewIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void adminCanFilterHideAndRestoreReviewsWithPublicSummaryUpdated() throws Exception { + mockMvc.perform(get("/admin/reviews")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + + String suffix = String.valueOf(System.nanoTime()); + UserSession owner = registerAndLogin("review_admin_" + suffix, "评价审核用户"); + mockMvc.perform(get("/admin/reviews") + .header("Authorization", bearer(owner.token()))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + + long productId = insertReviewFixture(owner.userId(), suffix); + String adminToken = login("admin", "admin123").token(); + + MvcResult pageResult = mockMvc.perform(get("/admin/reviews") + .header("Authorization", bearer(adminToken)) + .param("keyword", "审核商品" + suffix) + .param("rating", "2") + .param("status", "PUBLISHED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].username").value("review_admin_" + suffix)) + .andExpect(jsonPath("$.data.records[0].reviewerNickname").value("评价审核用户")) + .andExpect(jsonPath("$.data.records[0].productName").value("审核商品" + suffix)) + .andExpect(jsonPath("$.data.records[0].rating").value(2)) + .andReturn(); + long reviewId = objectMapper.readTree(pageResult.getResponse().getContentAsByteArray()) + .path("data").path("records").get(0).path("id").asLong(); + + mockMvc.perform(patch("/admin/reviews/{id}/status", reviewId) + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(statusJson("HIDDEN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("HIDDEN")); + + mockMvc.perform(get("/products/{productId}/reviews", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + mockMvc.perform(get("/products/{productId}/reviews/summary", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)) + .andExpect(jsonPath("$.data.averageRating").value(0)); + + mockMvc.perform(get("/reviews/mine") + .header("Authorization", bearer(owner.token()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].status").value("HIDDEN")); + + mockMvc.perform(patch("/admin/reviews/{id}/status", reviewId) + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(statusJson("PUBLISHED"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + + mockMvc.perform(get("/products/{productId}/reviews/summary", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.averageRating").value(2.0)); + + Integer logCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM operation_log WHERE module = '评价管理' AND action = '修改评价状态' AND success = TRUE", + Integer.class); + org.assertj.core.api.Assertions.assertThat(logCount).isEqualTo(2); + } + + private long insertReviewFixture(long userId, String suffix) { + jdbcTemplate.update( + "INSERT INTO product (category_id, name, status) VALUES (1, ?, 'ON_SALE')", + "审核商品" + suffix); + Long productId = jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = ?", + Long.class, + "审核商品" + suffix); + jdbcTemplate.update(""" + INSERT INTO product_sku (product_id, sku_code, specs_json, price, stock, status) + VALUES (?, ?, '{"颜色":"黑色"}', 88.00, 10, 'ENABLED') + """, productId, "REVIEW-ADMIN-" + suffix); + Long skuId = jdbcTemplate.queryForObject( + "SELECT id FROM product_sku WHERE sku_code = ?", + Long.class, + "REVIEW-ADMIN-" + suffix); + jdbcTemplate.update(""" + INSERT INTO orders ( + order_no, user_id, total_amount, status, + receiver_name, receiver_phone, receiver_address + ) VALUES (?, ?, 88.00, 'COMPLETED', '测试用户', '13800138000', '测试地址') + """, "REVIEW-ADMIN-" + suffix, userId); + Long orderId = jdbcTemplate.queryForObject( + "SELECT id FROM orders WHERE order_no = ?", + Long.class, + "REVIEW-ADMIN-" + suffix); + jdbcTemplate.update(""" + INSERT INTO order_item ( + order_id, product_id, sku_id, product_name, sku_specs, + price, quantity, subtotal + ) VALUES (?, ?, ?, ?, '{"颜色":"黑色"}', 88.00, 1, 88.00) + """, orderId, productId, skuId, "审核商品" + suffix); + Long itemId = jdbcTemplate.queryForObject( + "SELECT id FROM order_item WHERE order_id = ?", + Long.class, + orderId); + jdbcTemplate.update(""" + INSERT INTO product_review ( + user_id, product_id, order_id, order_item_id, rating, content, status + ) VALUES (?, ?, ?, ?, 2, '包装需要改进,但商品可以正常使用', 'PUBLISHED') + """, userId, productId, orderId, itemId); + return productId; + } + + private UserSession registerAndLogin(String username, String nickname) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123", + "nickname", nickname)))) + .andExpect(status().isOk()); + return login(username, "password123"); + } + + private UserSession login(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password)))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsByteArray()).path("data"); + return new UserSession(data.path("userId").asLong(), data.path("token").asText()); + } + + private String statusJson(String status) throws Exception { + return objectMapper.writeValueAsString(Map.of("status", status)); + } + + private String bearer(String token) { + return "Bearer " + token; + } + + private record UserSession(long userId, String token) { + } +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java index e35b405..546aead 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java @@ -55,6 +55,10 @@ class AuthSecurityIntegrationTest { mockMvc.perform(get("/admin/logs")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(40101)); + + mockMvc.perform(post("/files/upload")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); } @Test diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java index 2ccf920..edce8ae 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java @@ -84,6 +84,7 @@ class DashboardIntegrationTest { .andExpect(jsonPath("$.data.shippedOrderCount").value(1)) .andExpect(jsonPath("$.data.completedOrderCount").value(1)) .andExpect(jsonPath("$.data.canceledOrderCount").value(1)) + .andExpect(jsonPath("$.data.lowStockSkuCount").value(1)) .andExpect(jsonPath("$.data.paidSalesAmount").value(90.0)) .andExpect(jsonPath("$.data.generatedAt").exists()); } @@ -106,6 +107,13 @@ class DashboardIntegrationTest { INSERT INTO product (category_id, name, status) VALUES (1, '统计在售商品', 'ON_SALE'), (1, '统计草稿商品', 'DRAFT') """); + Long onSaleProductId = jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = '统计在售商品'", + Long.class); + jdbcTemplate.update(""" + INSERT INTO product_sku (product_id, sku_code, specs_json, price, stock, status) + VALUES (?, 'DASHBOARD-LOW-STOCK', '{}', 19.90, 4, 'ENABLED') + """, onSaleProductId); insertOrder("DASH-PENDING", "PENDING_PAYMENT", new BigDecimal("10.00")); insertOrder("DASH-PAID", "PAID", new BigDecimal("20.00")); insertOrder("DASH-SHIPPED", "SHIPPED", new BigDecimal("30.00")); diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/FavoriteIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/FavoriteIntegrationTest.java new file mode 100644 index 0000000..a33e078 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/FavoriteIntegrationTest.java @@ -0,0 +1,175 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_favorite;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class FavoriteIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void favoritesAreAuthenticatedIdempotentPagedAndUserScoped() throws Exception { + mockMvc.perform(get("/favorites")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + + String suffix = String.valueOf(System.nanoTime()); + String tokenA = registerAndLogin("favorite_a_" + suffix); + String tokenB = registerAndLogin("favorite_b_" + suffix); + long onSaleProductId = insertProduct("收藏在售商品" + suffix, "ON_SALE"); + long draftProductId = insertProduct("收藏草稿商品" + suffix, "DRAFT"); + insertSku(onSaleProductId, "FAV-A-" + suffix, "19.90", 3, "ENABLED"); + insertSku(onSaleProductId, "FAV-B-" + suffix, "29.90", 7, "ENABLED"); + insertSku(onSaleProductId, "FAV-C-" + suffix, "9.90", 100, "DISABLED"); + + mockMvc.perform(post("/favorites/{productId}", draftProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40913)); + + mockMvc.perform(post("/favorites/{productId}", 999999999L) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40411)); + + mockMvc.perform(post("/favorites/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(post("/favorites/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()); + Integer favoriteCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM product_favorite WHERE product_id = ?", + Integer.class, + onSaleProductId); + org.junit.jupiter.api.Assertions.assertEquals(1, favoriteCount); + + mockMvc.perform(get("/favorites/{productId}/status", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.productId").value(onSaleProductId)) + .andExpect(jsonPath("$.data.favorited").value(true)); + + mockMvc.perform(get("/favorites/{productId}/status", onSaleProductId) + .header("Authorization", bearer(tokenB))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.favorited").value(false)); + + mockMvc.perform(get("/favorites") + .param("current", "1") + .param("size", "10") + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.current").value(1)) + .andExpect(jsonPath("$.data.size").value(10)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].id").value(onSaleProductId)) + .andExpect(jsonPath("$.data.records[0].minPrice").value(19.90)) + .andExpect(jsonPath("$.data.records[0].totalStock").value(10)) + .andExpect(jsonPath("$.data.records[0].favoritedAt").exists()); + + jdbcTemplate.update("UPDATE product SET status = 'OFF_SALE' WHERE id = ?", onSaleProductId); + mockMvc.perform(get("/favorites") + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + + mockMvc.perform(delete("/favorites/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()); + mockMvc.perform(delete("/favorites/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()); + mockMvc.perform(get("/favorites/{productId}/status", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.favorited").value(false)); + } + + private String registerAndLogin(String username) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123", + "nickname", "收藏测试用户")))) + .andExpect(status().isOk()); + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123")))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode body = objectMapper.readTree(result.getResponse().getContentAsByteArray()); + return body.path("data").path("token").asText(); + } + + private long insertProduct(String name, String productStatus) { + jdbcTemplate.update( + "INSERT INTO product (category_id, name, status) VALUES (1, ?, ?)", + name, + productStatus); + return jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = ?", + Long.class, + name); + } + + private void insertSku(long productId, String skuCode, String price, int stock, String skuStatus) { + jdbcTemplate.update(""" + INSERT INTO product_sku ( + product_id, sku_code, specs_json, price, stock, status + ) VALUES (?, ?, '{}', ?, ?, ?) + """, + productId, + skuCode, + price, + stock, + skuStatus); + } + + private String bearer(String token) { + return "Bearer " + token; + } +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/HotProductIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/HotProductIntegrationTest.java new file mode 100644 index 0000000..81e439a --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/HotProductIntegrationTest.java @@ -0,0 +1,122 @@ +package com.eshop.backend; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_hot_products;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class HotProductIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void rankingCountsOnlyRecentEffectiveOrdersAndOnSaleProducts() throws Exception { + long firstProduct = insertProduct("热销第一名", "ON_SALE", "HOT-FIRST", 49, new BigDecimal("10.00")); + long secondProduct = insertProduct("热销第二名", "ON_SALE", "HOT-SECOND", 19, new BigDecimal("100.00")); + long offSaleProduct = insertProduct("已下架热销商品", "OFF_SALE", "HOT-OFF", 99, new BigDecimal("1.00")); + + insertOrderItem("HOT-COMPLETED", "COMPLETED", firstProduct, "热销第一名", 5, new BigDecimal("50.00"), LocalDateTime.now()); + insertOrderItem("HOT-PAID", "PAID", secondProduct, "热销第二名", 2, new BigDecimal("200.00"), LocalDateTime.now()); + insertOrderItem("HOT-PENDING", "PENDING_PAYMENT", firstProduct, "热销第一名", 100, new BigDecimal("1000.00"), LocalDateTime.now()); + insertOrderItem("HOT-CANCELED", "CANCELED", secondProduct, "热销第二名", 100, new BigDecimal("10000.00"), LocalDateTime.now()); + insertOrderItem("HOT-OFF-SALE", "COMPLETED", offSaleProduct, "已下架热销商品", 20, new BigDecimal("20.00"), LocalDateTime.now()); + insertOrderItem("HOT-OLD", "COMPLETED", secondProduct, "热销第二名", 50, new BigDecimal("5000.00"), LocalDateTime.now().minusDays(60)); + + mockMvc.perform(get("/products/hot-ranking") + .param("days", "30") + .param("limit", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].productId").value(firstProduct)) + .andExpect(jsonPath("$.data[0].salesQuantity").value(5)) + .andExpect(jsonPath("$.data[0].salesAmount").value(50.0)) + .andExpect(jsonPath("$.data[0].minPrice").value(10.0)) + .andExpect(jsonPath("$.data[0].totalStock").value(49)) + .andExpect(jsonPath("$.data[1].productId").value(secondProduct)) + .andExpect(jsonPath("$.data[1].salesQuantity").value(2)); + + mockMvc.perform(get("/products/hot-ranking") + .param("days", "0") + .param("limit", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].productId").value(secondProduct)) + .andExpect(jsonPath("$.data[0].salesQuantity").value(52)); + } + + private long insertProduct(String name, String status, String skuCode, int stock, BigDecimal price) { + jdbcTemplate.update( + "INSERT INTO product (category_id, name, subtitle, status) VALUES (1, ?, '热销测试', ?)", + name, + status); + Long productId = jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = ?", + Long.class, + name); + jdbcTemplate.update(""" + INSERT INTO product_sku (product_id, sku_code, specs_json, price, stock, status) + VALUES (?, ?, '{}', ?, ?, 'ENABLED') + """, productId, skuCode, price, stock); + return productId; + } + + private void insertOrderItem( + String orderNo, + String status, + long productId, + String productName, + int quantity, + BigDecimal subtotal, + LocalDateTime createdAt) { + jdbcTemplate.update(""" + INSERT INTO orders ( + order_no, user_id, total_amount, status, + receiver_name, receiver_phone, receiver_address, created_at, updated_at + ) VALUES (?, 1, ?, ?, '测试用户', '13800138000', '测试地址', ?, ?) + """, orderNo, subtotal, status, createdAt, createdAt); + Long orderId = jdbcTemplate.queryForObject( + "SELECT id FROM orders WHERE order_no = ?", + Long.class, + orderNo); + Long skuId = jdbcTemplate.queryForObject( + "SELECT id FROM product_sku WHERE product_id = ?", + Long.class, + productId); + BigDecimal price = subtotal.divide(BigDecimal.valueOf(quantity)); + jdbcTemplate.update(""" + INSERT INTO order_item ( + order_id, product_id, sku_id, product_name, sku_specs, + price, quantity, subtotal, created_at + ) VALUES (?, ?, ?, ?, '{}', ?, ?, ?, ?) + """, orderId, productId, skuId, productName, price, quantity, subtotal, createdAt); + } +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/InventoryAlertIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/InventoryAlertIntegrationTest.java new file mode 100644 index 0000000..5812935 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/InventoryAlertIntegrationTest.java @@ -0,0 +1,156 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_inventory;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class InventoryAlertIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void inventoryAlertsRequireAdminAndFilterActiveLowStockSkus() throws Exception { + mockMvc.perform(get("/admin/inventory/alerts")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + + String username = "inventory_" + System.nanoTime(); + register(username); + String userToken = login(username, "password123"); + mockMvc.perform(get("/admin/inventory/alerts") + .header("Authorization", bearer(userToken))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + + insertFixtures(); + String adminToken = login("admin", "admin123"); + + mockMvc.perform(get("/admin/inventory/alerts") + .header("Authorization", bearer(adminToken)) + .param("current", "1") + .param("size", "20") + .param("threshold", "10") + .param("keyword", "预警测试手机")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(2)) + .andExpect(jsonPath("$.data.records[0].skuCode").value("ALERT-ZERO")) + .andExpect(jsonPath("$.data.records[0].stock").value(0)) + .andExpect(jsonPath("$.data.records[1].skuCode").value("ALERT-FIVE")) + .andExpect(jsonPath("$.data.records[1].stock").value(5)); + + mockMvc.perform(get("/admin/inventory/alerts") + .header("Authorization", bearer(adminToken)) + .param("threshold", "0") + .param("keyword", "ALERT-ZERO")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].productStatus").value("ON_SALE")) + .andExpect(jsonPath("$.data.records[0].skuStatus").value("ENABLED")); + + mockMvc.perform(get("/admin/dashboard/summary") + .header("Authorization", bearer(adminToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.lowStockSkuCount").value(3)); + } + + private void register(String username) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123", + "nickname", "库存测试用户")))) + .andExpect(status().isOk()); + } + + private String login(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password)))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode body = objectMapper.readTree(result.getResponse().getContentAsByteArray()); + return body.path("data").path("token").asText(); + } + + private void insertFixtures() { + jdbcTemplate.update(""" + INSERT INTO product (category_id, name, status) + VALUES + (1, '库存预警测试手机', 'ON_SALE'), + (1, '库存预警已下架商品', 'OFF_SALE'), + (1, '其他低库存商品', 'ON_SALE') + """); + Long phoneId = productId("库存预警测试手机"); + Long offSaleId = productId("库存预警已下架商品"); + Long otherId = productId("其他低库存商品"); + insertSku(phoneId, "ALERT-ZERO", 0, "ENABLED"); + insertSku(phoneId, "ALERT-FIVE", 5, "ENABLED"); + insertSku(phoneId, "ALERT-ELEVEN", 11, "ENABLED"); + insertSku(phoneId, "ALERT-DISABLED", 1, "DISABLED"); + insertSku(offSaleId, "ALERT-OFF-SALE", 1, "ENABLED"); + insertSku(otherId, "ALERT-OTHER", 2, "ENABLED"); + } + + private Long productId(String name) { + return jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = ?", + Long.class, + name); + } + + private void insertSku(Long productId, String skuCode, int stock, String status) { + jdbcTemplate.update(""" + INSERT INTO product_sku ( + product_id, sku_code, specs_json, price, stock, status + ) VALUES (?, ?, '{}', 99.00, ?, ?) + """, + productId, + skuCode, + stock, + status); + } + + private String bearer(String token) { + return "Bearer " + token; + } +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java new file mode 100644 index 0000000..647a1c1 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java @@ -0,0 +1,245 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_review;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class ReviewIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void reviewsEnforceOwnershipCompletionValidationPrivacyAndPaging() throws Exception { + mockMvc.perform(post("/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(1, 5, "未登录评价"))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + mockMvc.perform(get("/reviews/mine")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + + String suffix = String.valueOf(System.nanoTime()); + UserSession owner = registerAndLogin("review_owner_" + suffix, "评价用户"); + UserSession other = registerAndLogin("review_other_" + suffix, "其他用户"); + long productId = insertProduct("评价测试商品" + suffix); + long orderId = insertOrder(owner.userId(), "REVIEW-" + suffix, "PAID"); + long itemId = insertOrderItem(orderId, productId, "评价测试商品" + suffix); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 5, "订单还没有完成"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40929)); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(other.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 5, "不能评价别人的订单"))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40417)); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(999999999L, 5, "订单商品不存在"))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40417)); + + jdbcTemplate.update("UPDATE orders SET status = 'COMPLETED' WHERE id = ?", orderId); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 6, "评分越界"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40001)); + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 5, " "))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40001)); + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 5, "x".repeat(1001)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40001)); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 5, " 商品符合描述 "))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.orderItemId").value(itemId)) + .andExpect(jsonPath("$.data.productName").value("评价测试商品" + suffix)) + .andExpect(jsonPath("$.data.rating").value(5)) + .andExpect(jsonPath("$.data.content").value("商品符合描述")); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJson(itemId, 4, "重复评价"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40930)); + + mockMvc.perform(get("/products/{productId}/reviews", productId) + .param("current", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].productId").value(productId)) + .andExpect(jsonPath("$.data.records[0].productName").value("评价测试商品" + suffix)) + .andExpect(jsonPath("$.data.records[0].reviewerNickname").value("评价用户")) + .andExpect(jsonPath("$.data.records[0].rating").value(5)) + .andExpect(jsonPath("$.data.records[0].userId").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].orderId").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].orderItemId").doesNotExist()); + + mockMvc.perform(get("/products/{productId}/reviews/summary", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.averageRating").value(5.0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.fiveStarCount").value(1)) + .andExpect(jsonPath("$.data.fourStarCount").value(0)) + .andExpect(jsonPath("$.data.threeStarCount").value(0)) + .andExpect(jsonPath("$.data.twoStarCount").value(0)) + .andExpect(jsonPath("$.data.oneStarCount").value(0)); + + mockMvc.perform(get("/reviews/mine") + .param("current", "1") + .param("size", "10") + .header("Authorization", bearer(owner.token()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].orderId").value(orderId)) + .andExpect(jsonPath("$.data.records[0].orderItemId").value(itemId)); + mockMvc.perform(get("/reviews/mine") + .header("Authorization", bearer(other.token()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + + jdbcTemplate.update("UPDATE product SET status = 'OFF_SALE' WHERE id = ?", productId); + mockMvc.perform(get("/products/{productId}/reviews", productId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40411)); + mockMvc.perform(get("/products/{productId}/reviews/summary", productId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40411)); + } + + private UserSession registerAndLogin(String username, String nickname) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123", + "nickname", nickname)))) + .andExpect(status().isOk()); + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "password123")))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsByteArray()).path("data"); + return new UserSession(data.path("userId").asLong(), data.path("token").asText()); + } + + private long insertProduct(String name) { + jdbcTemplate.update( + "INSERT INTO product (category_id, name, status) VALUES (1, ?, 'ON_SALE')", + name); + return jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = ?", + Long.class, + name); + } + + private long insertOrder(long userId, String orderNo, String orderStatus) { + jdbcTemplate.update(""" + INSERT INTO orders ( + order_no, user_id, total_amount, status, + receiver_name, receiver_phone, receiver_address + ) VALUES (?, ?, 99.00, ?, '测试用户', '13800138000', '测试地址') + """, + orderNo, + userId, + orderStatus); + return jdbcTemplate.queryForObject( + "SELECT id FROM orders WHERE order_no = ?", + Long.class, + orderNo); + } + + private long insertOrderItem(long orderId, long productId, String productName) { + jdbcTemplate.update(""" + INSERT INTO order_item ( + order_id, product_id, sku_id, product_name, sku_specs, + price, quantity, subtotal + ) VALUES (?, ?, 1, ?, '{"颜色":"蓝色"}', 99.00, 1, 99.00) + """, + orderId, + productId, + productName); + return jdbcTemplate.queryForObject( + "SELECT id FROM order_item WHERE order_id = ?", + Long.class, + orderId); + } + + private String reviewJson(long orderItemId, int rating, String content) throws Exception { + return objectMapper.writeValueAsString(Map.of( + "orderItemId", orderItemId, + "rating", rating, + "content", content)); + } + + private String bearer(String token) { + return "Bearer " + token; + } + + private record UserSession(long userId, String token) { + } +} diff --git a/eshop-release-v1.0/backend/src/test/resources/schema-test.sql b/eshop-release-v1.0/backend/src/test/resources/schema-test.sql index a747897..9f7272d 100644 --- a/eshop-release-v1.0/backend/src/test/resources/schema-test.sql +++ b/eshop-release-v1.0/backend/src/test/resources/schema-test.sql @@ -158,9 +158,20 @@ CREATE TABLE product_review ( order_item_id BIGINT NOT NULL, rating TINYINT NOT NULL, content VARCHAR(1000) NOT NULL, + images_json VARCHAR(2000), status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uk_review_order_item UNIQUE (order_item_id), CONSTRAINT chk_review_rating CHECK (rating BETWEEN 1 AND 5) ); + +CREATE TABLE product_browse_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + browsed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_browse_user_product UNIQUE (user_id, product_id) +); + +CREATE INDEX idx_browse_user_browsed ON product_browse_history(user_id, browsed_at); diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" new file mode 100644 index 0000000..83e224a --- /dev/null +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" @@ -0,0 +1,73 @@ +# C03 订单超时自动取消 — 现场演示脚本 + +> 日期:2026-07-29 +> 适用:验收答辩 5~8 分钟边界演示 +> 实现位置:`OrderTimeoutScheduler` + `OrderService` 条件更新 + +## 一、原理一句话 + +待支付订单超过配置分钟数后,定时任务扫描并用 **数据库条件更新** 将状态从 `PENDING_PAYMENT` 改为 `CANCELED`,成功后才回补库存;与支付接口的条件更新互斥,保证「取消与支付同时发生」时只会成功一个。 + +## 二、演示前配置 + +编辑 `eshop-release-v1.0/.env`(**演示结束后改回 30**): + +```env +ORDER_TIMEOUT_ENABLED=true +ORDER_TIMEOUT_MINUTES=2 +ORDER_TIMEOUT_SCAN_DELAY_MS=15000 +ORDER_TIMEOUT_INITIAL_DELAY_MS=5000 +``` + +重启后端使配置生效: + +```powershell +Set-Location 'C:\Users\duyiyan\Desktop\eshop-class2-group7\eshop-release-v1.0' +docker compose up -d --build backend +``` + +| 变量 | 演示建议 | 生产/日常 | +|------|----------|-----------| +| ORDER_TIMEOUT_MINUTES | 2~5 | 30 | +| ORDER_TIMEOUT_SCAN_DELAY_MS | 15000 | 60000 | + +## 三、演示路径 A:超时自动取消 + 库存回补 + +1. 登录买家账号,记录某 SKU 当前库存(商品详情或后台商品)。 +2. 加购 1 件并提交订单,**不要支付**。 +3. 打开订单详情:状态为「待支付」。 +4. 等待超过 `ORDER_TIMEOUT_MINUTES` + 一轮扫描间隔(约 2~3 分钟)。 +5. 刷新订单:状态变为「已取消」;状态日志含超时取消原因。 +6. 再看库存:应回补到下单前数量。 +7. 对该已取消订单点支付:应失败(业务码约 `40912` / 支付过期相关)。 + +## 四、演示路径 B:支付与取消冲突(口述 + 可选实测) + +**口述要点(必会):** + +- 支付:`UPDATE orders SET status='PAID' WHERE id=? AND status='PENDING_PAYMENT' AND created_at > 截止时间` +- 取消:`UPDATE orders SET status='CANCELED' WHERE id=? AND status='PENDING_PAYMENT' AND created_at <= 截止时间` +- 两个事务竞争时,只有一个 `UPDATE` 影响行数 = 1;另一个为 0,不回补或不支付。 +- 多实例扫描同样依赖条件更新,不会重复回补库存。 + +**可选实测:** 将超时调为 2 分钟,在临界点附近点支付;结果要么支付成功(不取消),要么已取消(支付失败),不会出现「已支付又回补库存」。 + +## 五、答辩常问 + +| 问题 | 回答要点 | +|------|----------| +| 为什么用定时扫描而不是延迟队列? | 课程体量下实现简单、易部署;配置化间隔与批量大小 | +| 如何防重复回补? | 先条件更新状态成功,再回补;第二次更新 0 行直接跳过 | +| 多实例安全吗? | 状态机条件在数据库,不依赖单机锁 | +| 与用户手动取消关系? | 手动取消同样走状态条件与回补,已取消不能再付 | + +## 六、演示后恢复 + +```env +ORDER_TIMEOUT_MINUTES=30 +ORDER_TIMEOUT_SCAN_DELAY_MS=60000 +``` + +```powershell +docker compose up -d --build backend +``` diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" new file mode 100644 index 0000000..28e5716 --- /dev/null +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" @@ -0,0 +1,48 @@ +# E-Shop 今日扩展接口冻结说明(v1.2) + +> 完整版同步自仓库根目录 `docs/02-设计文档/今日扩展接口冻结-v1.2.md`。 +> 日期:2026-07-29 负责人:组长 + +## 迁移 + +- `V6__create_product_browse_history.sql` → 表 `product_browse_history` +- `V7__add_review_images.sql` → 列 `product_review.images_json` + +## 公共接口(已实现) + +```http +POST /files/upload # 登录用户上传图片,multipart field=file +``` + +## 组员待实现接口(已冻结) + +### 浏览历史 + +```http +POST /browse-history/{productId} +GET /browse-history?current=1&size=20 +DELETE /browse-history/{productId} +DELETE /browse-history +``` + +### 评价晒图 + +- `POST /reviews` 增加可选 `imageUrls: string[]`(0~3,`/api/uploads/` 前缀) +- 评价列表响应增加 `imageUrls` + +### 看板图表 + +```http +GET /admin/dashboard/sales-trend?days=7 +GET /admin/dashboard/top-products?limit=10 +``` + +## 错误码 + +| 码 | 含义 | +|----|------| +| 40033 | 评价图片超过 3 张 | +| 40034 | 评价图片地址不合法 | +| 40418 | 浏览记录不存在 | + +详细规则、边界与合入顺序见仓库根目录完整文档。 diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" index 8743d6b..ed076dd 100644 --- "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" @@ -78,9 +78,12 @@ | 启用分类列表 | GET | `/api/categories` | | 已上架商品分页 | GET | `/api/products` | | 商品和可用 SKU 详情 | GET | `/api/products/{id}` | +| 热销商品排行 | GET | `/api/products/hot-ranking` | 商品分页支持 `current`、`size`、`categoryId`、`keyword`。 +热销排行支持 `days`(默认 30,传 0 表示全部时间)和 `limit`(1~20,默认 5)。只统计 `PAID`、`SHIPPED`、`COMPLETED` 订单中的在售商品,按有效销量、销售额和商品 ID 依次倒序排列。 + ### 管理员接口 | 功能 | 方法 | 路径 | @@ -186,7 +189,26 @@ PENDING_PAYMENT --支付--> PAID --发货--> SHIPPED --确认收货--> COMPLETED 系统默认每分钟扫描一次创建超过 30 分钟的待支付订单。支付接口本身也校验订单创建时间,因此订单超过 30 分钟后,即使定时扫描尚未执行也不能继续支付。支付与自动取消都通过带原状态和时间条件的数据库更新竞争,只有更新成功的一方继续执行,因此不会出现已支付订单被取消或库存重复恢复。 -## 6. 文件与操作日志 +## 6. 商品评价与后台审核 + +| 功能 | 方法 | 路径 | 权限 | +|---|---|---|---| +| 商品公开评价分页 | GET | `/api/products/{productId}/reviews` | 匿名 | +| 商品评分汇总 | GET | `/api/products/{productId}/reviews/summary` | 匿名 | +| 提交订单商品评价 | POST | `/api/reviews` | 登录用户 | +| 我的评价分页 | GET | `/api/reviews/mine` | 登录用户 | +| 后台评价分页 | GET | `/api/admin/reviews` | 管理员 | +| 隐藏或恢复评价 | PATCH | `/api/admin/reviews/{id}/status` | 管理员 | + +后台评价分页支持 `keyword`、`rating=1..5`、`status=PUBLISHED|HIDDEN`。状态修改请求为: + +```json +{ "status": "HIDDEN" } +``` + +公开评价列表和评分汇总只包含 `PUBLISHED` 状态;隐藏评价后消费者立即不可见,平均分和星级分布同步更新,但评价用户仍能在“我的评价”中查看记录及审核状态。 + +## 7. 文件与操作日志 | 功能 | 方法 | 路径 | 权限 | |---|---|---|---| @@ -196,9 +218,9 @@ PENDING_PAYMENT --支付--> PAID --发货--> SHIPPED --确认收货--> COMPLETED 上传请求为 `multipart/form-data`,文件字段名为 `file`。只接受内容真实为 PNG、JPEG 或 WebP 的图片,最大 5MB;服务端生成随机文件名。返回的 `url` 可直接保存到商品 `mainImage`。 -已记录的管理操作包括分类、商品、SKU、用户状态、订单发货和图片上传,日志包含操作人、模块、动作、请求地址、结果和时间。 +已记录的管理操作包括分类、商品、SKU、用户状态、订单发货、评价状态和图片上传,日志包含操作人、模块、动作、请求地址、结果和时间。 -## 7. 健康检查与联调顺序 +## 8. 健康检查与联调顺序 - 健康检查:`GET /api/actuator/health` - 本地 Docker 入口:`http://localhost:8088` diff --git "a/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" "b/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" index 1a4b561..831aecf 100644 --- "a/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" +++ "b/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" @@ -179,3 +179,20 @@ C03 的实现原理、配置和现场演示步骤见 `docs/02-设计文档/C03 3. **详情页返回方向错误**:非主导航页面原先固定返回商品首页;现优先使用有效浏览历史,无历史时才回到手机商品首页。 结论:手机端公共导航、路由、权限保护和占位页面可以交付各模块负责人继续接入业务页面;现有后端、PC 端和 Web 管理端未发现新的阻塞性缺陷。 + +## 十二、扩展功能最终回归(2026-07-28) + +在完整商城闭环基础上,本轮完成商品收藏与评价前端、评分统计、库存预警、订单导出、后台评价审核和热销商品排行。 + +| 检查项 | 结果 | 说明 | +|---|---|---| +| 后端自动化 | 通过 | 9 个测试类、19 项测试,失败 0、错误 0、跳过 0 | +| 前端生产构建 | 通过 | Vite 成功转换 2172 个模块 | +| 真实 MySQL API | 通过 | 86 个检查点全部通过并自动清理测试数据 | +| 评价审核 | 通过 | 权限、筛选、隐藏/恢复、公开列表及评分联动正常 | +| 热销排行 | 通过 | 有效订单状态、时间范围、下架商品边界与排序正常 | +| 页面直达 | 通过 | PC、手机、后台及评价管理入口均返回 HTTP 200 | +| 容器健康 | 通过 | MySQL 和后端健康,前端正常运行 | +| 运行日志 | 通过 | 后端最近日志无 ERROR 或 Exception | + +结论:当前项目已经达到可演示、可测试、可部署状态,新增扩展未破坏商城交易链。 diff --git a/eshop-release-v1.0/frontend/src/api/admin.js b/eshop-release-v1.0/frontend/src/api/admin.js index a76f147..ad39eef 100644 --- a/eshop-release-v1.0/frontend/src/api/admin.js +++ b/eshop-release-v1.0/frontend/src/api/admin.js @@ -2,6 +2,15 @@ import http from './http' export const getDashboardSummary = () => http.get('/admin/dashboard/summary') +export const getInventoryAlerts = ({ + current = 1, + size = 20, + threshold = 10, + keyword, +} = {}) => http.get('/admin/inventory/alerts', { + params: { current, size, threshold, keyword }, +}) + export const getUsers = ({ current = 1, size = 20, @@ -15,3 +24,17 @@ export const getUsers = ({ export const updateUserStatus = (id, status) => ( http.patch(`/admin/users/${id}/status`, { status }) ) + +export const getAdminReviews = ({ + current = 1, + size = 20, + keyword, + rating, + status, +} = {}) => http.get('/admin/reviews', { + params: { current, size, keyword, rating, status }, +}) + +export const updateAdminReviewStatus = (id, status) => ( + http.patch(`/admin/reviews/${id}/status`, { status }) +) diff --git a/eshop-release-v1.0/frontend/src/api/catalog.js b/eshop-release-v1.0/frontend/src/api/catalog.js index 51baf05..125b8b9 100644 --- a/eshop-release-v1.0/frontend/src/api/catalog.js +++ b/eshop-release-v1.0/frontend/src/api/catalog.js @@ -5,3 +5,7 @@ export const getCategories = () => http.get('/categories') export const getProducts = (params) => http.get('/products', { params }) export const getProduct = (id) => http.get(`/products/${id}`) + +export const getHotProducts = ({ days = 30, limit = 5 } = {}) => ( + http.get('/products/hot-ranking', { params: { days, limit } }) +) diff --git a/eshop-release-v1.0/frontend/src/api/favorite.js b/eshop-release-v1.0/frontend/src/api/favorite.js new file mode 100644 index 0000000..e7e2dd1 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/api/favorite.js @@ -0,0 +1,9 @@ +import http from './http' + +export const getFavorites = (params) => http.get('/favorites', { params }) + +export const getFavoriteStatus = (productId) => http.get(`/favorites/${productId}/status`) + +export const addFavorite = (productId) => http.post(`/favorites/${productId}`) + +export const removeFavorite = (productId) => http.delete(`/favorites/${productId}`) diff --git a/eshop-release-v1.0/frontend/src/api/review.js b/eshop-release-v1.0/frontend/src/api/review.js new file mode 100644 index 0000000..1c885ff --- /dev/null +++ b/eshop-release-v1.0/frontend/src/api/review.js @@ -0,0 +1,43 @@ +import http from './http' + +export const getProductReviews = (productId, params) => ( + http.get(`/products/${productId}/reviews`, { params }) +) + +export const getProductReviewSummary = (productId) => ( + http.get(`/products/${productId}/reviews/summary`) +) + +export const createReview = (payload) => http.post('/reviews', payload) + +export const getMyReviews = (params) => http.get('/reviews/mine', { params }) + +export const getReviewedOrderItemIds = async (orderItemIds) => { + const targets = new Set( + (orderItemIds || []) + .map((id) => Number(id)) + .filter((id) => Number.isInteger(id) && id > 0), + ) + const reviewed = new Set() + if (!targets.size) return reviewed + + const size = 50 + let current = 1 + while (targets.size && current <= 1000) { + const page = await getMyReviews({ current, size }) + const records = Array.isArray(page?.records) ? page.records : [] + for (const review of records) { + const orderItemId = Number(review.orderItemId) + if (targets.has(orderItemId)) { + reviewed.add(orderItemId) + targets.delete(orderItemId) + } + } + + const total = Number(page?.total) + if (!records.length || records.length < size) break + if (Number.isFinite(total) && current * size >= total) break + current += 1 + } + return reviewed +} diff --git a/eshop-release-v1.0/frontend/src/components/catalog/HotProductRanking.vue b/eshop-release-v1.0/frontend/src/components/catalog/HotProductRanking.vue new file mode 100644 index 0000000..779e27c --- /dev/null +++ b/eshop-release-v1.0/frontend/src/components/catalog/HotProductRanking.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue b/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue new file mode 100644 index 0000000..e2d4268 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue b/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue new file mode 100644 index 0000000..0b489a5 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue b/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue index 35465c7..5560c07 100644 --- a/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue +++ b/eshop-release-v1.0/frontend/src/layouts/AdminLayout.vue @@ -20,7 +20,9 @@ const logout = async () => { 控制台 分类管理 商品与 SKU + 库存预警 订单管理 + 评价管理 用户管理 操作日志 diff --git a/eshop-release-v1.0/frontend/src/layouts/MobileShopLayout.vue b/eshop-release-v1.0/frontend/src/layouts/MobileShopLayout.vue index 5e3fa85..c669e67 100644 --- a/eshop-release-v1.0/frontend/src/layouts/MobileShopLayout.vue +++ b/eshop-release-v1.0/frontend/src/layouts/MobileShopLayout.vue @@ -1,20 +1,51 @@ + + +
+
+ {{ reviewItem.productName }} + {{ specsText(reviewItem.skuSpecs) || '默认规格' }} +
+ + + + +
+ +
@@ -206,13 +369,20 @@ watch(() => route.params.id, loadDetail, { immediate: true }) .detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; } .detail-card { margin-bottom: 18px; padding: 28px; background: #fff; border: 1px solid #e2e8f0; border-radius: 18px; box-shadow: 0 10px 32px rgba(15, 23, 42, .05); } .detail-card h2 { margin: 0 0 22px; color: #0f172a; font-size: 20px; } -.order-item { display: grid; grid-template-columns: 72px minmax(0, 1fr) 145px 105px; gap: 15px; align-items: center; padding: 14px 0; border-bottom: 1px solid #eef2f7; } +.order-item { display: grid; grid-template-columns: 72px minmax(0, 1fr) 135px 100px 78px; gap: 15px; align-items: center; padding: 14px 0; border-bottom: 1px solid #eef2f7; } .product-image { display: grid; width: 68px; height: 68px; place-items: center; overflow: hidden; color: #93c5fd; background: #eff6ff; border-radius: 10px; font-size: 11px; font-weight: 800; } .product-image img { width: 100%; height: 100%; object-fit: contain; } .product-info a { color: #0f172a; font-weight: 700; } .product-info p { margin: 7px 0 0; color: #64748b; font-size: 12px; } .order-item > span { color: #64748b; } .order-item > strong { color: #dc2626; text-align: right; } +.review-action { text-align: right; } +.review-alert { margin-bottom: 12px; } +.review-form { display: grid; gap: 13px; } +.review-form label { color: #334155; font-size: 13px; font-weight: 700; } +.review-product { display: grid; gap: 5px; padding: 14px; background: #f8fafc; border-radius: 10px; } +.review-product strong { color: #0f172a; } +.review-product span { color: #64748b; font-size: 12px; } .amount-row { display: flex; align-items: baseline; justify-content: flex-end; gap: 18px; padding-top: 22px; color: #64748b; } .amount-row strong { color: #dc2626; font-size: 28px; } .info-card dl { display: grid; grid-template-columns: 90px 1fr; gap: 15px; margin: 0; } @@ -232,7 +402,7 @@ watch(() => route.params.id, loadDetail, { immediate: true }) } @media (max-width: 640px) { .order-item { grid-template-columns: 60px 1fr; } - .order-item > span, .order-item > strong { grid-column: 2; text-align: left; } + .order-item > span, .order-item > strong, .review-action { grid-column: 2; text-align: left; } .status-actions { align-items: stretch; flex-direction: column; width: 100%; } } diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcOrdersView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcOrdersView.vue index a84d02e..8ed2713 100644 --- a/eshop-release-v1.0/frontend/src/views/pc/PcOrdersView.vue +++ b/eshop-release-v1.0/frontend/src/views/pc/PcOrdersView.vue @@ -194,6 +194,14 @@ onMounted(loadOrders) > 确认收货 + + 评价商品 + diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue index a2e62b7..7fb3b09 100644 --- a/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue +++ b/eshop-release-v1.0/frontend/src/views/pc/PcProductDetailView.vue @@ -3,8 +3,10 @@ import { computed, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { useRoute, useRouter } from 'vue-router' import { addCartItem } from '../../api/cart' -import { recordVisit } from '../../api/browseHistory' import { getProduct } from '../../api/catalog' +import { recordVisit } from '../../api/browseHistory' +import { addFavorite, getFavoriteStatus, removeFavorite } from '../../api/favorite' +import ProductReviewList from '../../components/review/ProductReviewList.vue' import { useAuthStore } from '../../stores/auth' const route = useRoute() @@ -18,6 +20,10 @@ const loading = ref(false) const adding = ref(false) const imageFailed = ref(false) const errorMessage = ref('') +const favorited = ref(false) +const favoriteLoading = ref(false) +let requestSequence = 0 +let favoriteRequestSequence = 0 const selectedSku = computed(() => ( product.value?.skus.find((sku) => sku.id === selectedSkuId.value) || null @@ -42,23 +48,78 @@ const skuLabel = (sku) => parseSpecs(sku.specsJson) .join(' / ') const loadProduct = async () => { + const requestId = ++requestSequence loading.value = true errorMessage.value = '' imageFailed.value = false product.value = null + favorited.value = false + favoriteLoading.value = false + favoriteRequestSequence += 1 try { const id = Number(route.params.id) if (!Number.isInteger(id) || id <= 0) throw new Error('商品编号不正确') const data = await getProduct(id) + if (requestId !== requestSequence) return product.value = data - recordVisit(id).catch(() => { /* 静默:未登录 401 不影响浏览 */ }) + if (auth.isLoggedIn) { + recordVisit(id).catch(() => { /* 静默:401 不影响浏览 */ }) + } const firstAvailable = data.skus.find((sku) => sku.stock > 0) || data.skus[0] selectedSkuId.value = firstAvailable?.id quantity.value = 1 + if (auth.isLoggedIn) { + void loadFavoriteStatus(data.id) + } + } catch (error) { + if (requestId === requestSequence) { + errorMessage.value = error.message || '商品详情加载失败' + } + } finally { + if (requestId === requestSequence) loading.value = false + } +} + +const loadFavoriteStatus = async (productId) => { + const requestId = ++favoriteRequestSequence + try { + const data = await getFavoriteStatus(productId) + if (requestId === favoriteRequestSequence && product.value?.id === productId) { + favorited.value = Boolean(data?.favorited) + } + } catch { + // 收藏状态加载失败不阻塞商品详情。 + } +} + +const toggleFavorite = async () => { + if (!auth.isLoggedIn) { + ElMessage.info('请先登录后再收藏') + await router.push({ + name: 'pc-login', + query: { redirect: route.fullPath }, + }) + return + } + const productId = product.value?.id + if (!productId || favoriteLoading.value) return + + const previous = favorited.value + favoriteLoading.value = true + favorited.value = !previous + try { + if (favorited.value) { + await addFavorite(productId) + ElMessage.success('已收藏') + } else { + await removeFavorite(productId) + ElMessage.success('已取消收藏') + } } catch (error) { - errorMessage.value = error.message || '商品详情加载失败' + if (product.value?.id === productId) favorited.value = previous + ElMessage.error(error.message || '收藏操作失败') } finally { - loading.value = false + if (product.value?.id === productId) favoriteLoading.value = false } } @@ -136,7 +197,17 @@ watch(() => route.params.id, loadProduct, { immediate: true })
- {{ product.categoryName || '精选商品' }} +
+ {{ product.categoryName || '精选商品' }} + + {{ favorited ? '已收藏' : '收藏商品' }} + +

{{ product.name }}

{{ product.subtitle || '品质商品,放心选购' }}

@@ -209,6 +280,8 @@ watch(() => route.params.id, loadProduct, { immediate: true })

{{ product.detail || '暂无更多商品详情。' }}

+ +
@@ -274,6 +347,13 @@ watch(() => route.params.id, loadProduct, { immediate: true }) min-width: 0; } +.heading-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + .product-heading h1 { margin: 13px 0 10px; color: #0f172a; diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcProductListView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcProductListView.vue index af3aea6..6b1c1f8 100644 --- a/eshop-release-v1.0/frontend/src/views/pc/PcProductListView.vue +++ b/eshop-release-v1.0/frontend/src/views/pc/PcProductListView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getCategories, getProducts } from '../../api/catalog' +import HotProductRanking from '../../components/catalog/HotProductRanking.vue' const route = useRoute() const router = useRouter() @@ -121,6 +122,8 @@ watch( + +
import { computed, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' -import { Histogram } from '@element-plus/icons-vue' import { useRouter } from 'vue-router' import { updateProfile } from '../../api/auth' import { useAuthStore } from '../../stores/auth' const router = useRouter() - const auth = useAuthStore() + +const openHistory = () => { + ElMessage.info('浏览历史模块即将上线') + void router.push('/pc/history').catch(() => { /* 路由尚未接入 */ }) +} const loadingProfile = ref(false) const saving = ref(false) const errorMessage = ref('') @@ -140,23 +143,20 @@ onMounted(loadProfile) - + - +
    +
  • + + 我的浏览历史 + 查看 +
  • +
@@ -200,4 +200,41 @@ onMounted(loadProfile) color: #909399; font-size: 12px; } + +.activity-card { + margin-top: 18px; +} +.activity-card .card-header { + display: flex; + align-items: center; + justify-content: space-between; + color: #172033; + font-weight: 700; +} +.activity-list { + list-style: none; + padding: 0; + margin: 0; +} +.activity-list li { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid #eef2f7; +} +.activity-list li:last-child { + border-bottom: 0; +} +.activity-list .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #2563eb; +} +.activity-list .title { + flex: 1; + color: #0f172a; + font-weight: 600; +} diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcReviewsView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcReviewsView.vue new file mode 100644 index 0000000..33ba793 --- /dev/null +++ b/eshop-release-v1.0/frontend/src/views/pc/PcReviewsView.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/eshop-release-v1.0/scripts/api-smoke-test.ps1 b/eshop-release-v1.0/scripts/api-smoke-test.ps1 index 1549070..0730de6 100644 --- a/eshop-release-v1.0/scripts/api-smoke-test.ps1 +++ b/eshop-release-v1.0/scripts/api-smoke-test.ps1 @@ -215,6 +215,7 @@ try { [long]$dashboard.Body.data.canceledOrderCount Assert-Equal $dashboardOrderTotal $dashboard.Body.data.orderCount "dashboard order counts" Assert-True ([decimal]$dashboard.Body.data.paidSalesAmount -ge 0) "dashboard sales amount" + Assert-True ([long]$dashboard.Body.data.lowStockSkuCount -ge 0) "dashboard low stock count" foreach ($username in @($userA, $userB)) { $registered = Invoke-Api POST "auth/register" @{ @@ -258,10 +259,22 @@ try { } $null $tokenB = $loginB.Body.data.token + $updatedProfile = Invoke-Api PUT "auth/me" @{ + nickname = "Smoke Updated" + phone = "138 0013-8000" + } $tokenA + Assert-Equal $updatedProfile.Body.data.nickname "Smoke Updated" "update current user nickname" + $currentProfile = Invoke-Api GET "auth/me" $null $tokenA + Assert-Equal $currentProfile.Body.data.phone "138 0013-8000" "current user profile persisted" + $forbidden = Invoke-Api GET "admin/users" $null $tokenA @(403) Assert-Equal $forbidden.Body.code 40301 "ordinary user blocked from admin API" $dashboardForbidden = Invoke-Api GET "admin/dashboard/summary" $null $tokenA @(403) Assert-Equal $dashboardForbidden.Body.code 40301 "ordinary user blocked from dashboard" + $inventoryForbidden = Invoke-Api GET "admin/inventory/alerts" $null $tokenA @(403) + Assert-Equal $inventoryForbidden.Body.code 40301 "ordinary user blocked from inventory alerts" + $reviewsForbidden = Invoke-Api GET "admin/reviews" $null $tokenA @(403) + Assert-Equal $reviewsForbidden.Body.code 40301 "ordinary user blocked from review management" $invalidOnSale = Invoke-Api POST "admin/products" @{ categoryId = 999999999 @@ -302,6 +315,9 @@ try { } $adminToken $productId = $product.Body.data.id + $draftFavorite = Invoke-Api POST "favorites/$productId" $null $tokenA @(409) + Assert-Equal $draftFavorite.Body.code 40913 "draft product cannot be favorited" + $invalidSku = Invoke-Api POST "admin/products/$productId/skus" @{ skuCode = "$skuCode-BAD" specsJson = "not-json" @@ -325,12 +341,44 @@ try { } $adminToken Assert-Equal $onSale.Body.data.status "ON_SALE" "put product on sale" + $inventoryAlerts = Invoke-Api GET ( + "admin/inventory/alerts?threshold=10&keyword=" + [Uri]::EscapeDataString($productName) + ) $null $adminToken + Assert-Equal $inventoryAlerts.Body.data.total 1 "inventory alert keyword and threshold" + Assert-Equal $inventoryAlerts.Body.data.records[0].skuId $skuId "inventory alert SKU detail" + Assert-Equal $inventoryAlerts.Body.data.records[0].stock 10 "inventory alert stock" + $dashboardWithLowStock = Invoke-Api GET "admin/dashboard/summary" $null $adminToken + Assert-True ([long]$dashboardWithLowStock.Body.data.lowStockSkuCount -ge 1) ` + "dashboard reflects low stock SKU" + $publicProduct = Invoke-Api GET "products/$productId" $null $null Assert-Equal $publicProduct.Body.data.skus[0].id $skuId "public product detail" $search = Invoke-Api GET ("products?keyword=" + [Uri]::EscapeDataString($productName)) $null $null Assert-Equal $search.Body.data.total 1 "product keyword search" + $anonymousFavorites = Invoke-Api GET "favorites" $null $null @(401) + Assert-Equal $anonymousFavorites.Body.code 40101 "favorite list requires login" + $null = Invoke-Api POST "favorites/$productId" $null $tokenA + Write-Output "PASS add product favorite" + $null = Invoke-Api POST "favorites/$productId" $null $tokenA + Write-Output "PASS duplicate favorite is idempotent" + $favoriteStatus = Invoke-Api GET "favorites/$productId/status" $null $tokenA + Assert-Equal $favoriteStatus.Body.data.favorited $true "favorite status" + $foreignFavoriteStatus = Invoke-Api GET "favorites/$productId/status" $null $tokenB + Assert-Equal $foreignFavoriteStatus.Body.data.favorited $false "favorite ownership isolation" + $favoritePage = Invoke-Api GET "favorites?current=1&size=10" $null $tokenA + Assert-Equal $favoritePage.Body.data.total 1 "favorite list total" + Assert-Equal $favoritePage.Body.data.records[0].id $productId "favorite product detail" + Assert-Equal $favoritePage.Body.data.records[0].totalStock 10 "favorite product stock" + $null = Invoke-Api DELETE "favorites/$productId" $null $tokenB + $favoriteStillExists = Invoke-Api GET "favorites/$productId/status" $null $tokenA + Assert-Equal $favoriteStillExists.Body.data.favorited $true "foreign removal does not affect favorite" + $null = Invoke-Api DELETE "favorites/$productId" $null $tokenA + $null = Invoke-Api DELETE "favorites/$productId" $null $tokenA + $removedFavoriteStatus = Invoke-Api GET "favorites/$productId/status" $null $tokenA + Assert-Equal $removedFavoriteStatus.Body.data.favorited $false "favorite removal is idempotent" + $address = Invoke-Api POST "addresses" @{ receiverName = "Smoke Receiver" phone = "13800138000" @@ -371,8 +419,15 @@ try { remark = "Smoke test order" } $tokenA $orderId = $order.Body.data.id + $orderItemId = $order.Body.data.items[0].id Assert-Equal $order.Body.data.status "PENDING_PAYMENT" "create order" Assert-Equal $order.Body.data.totalAmount 24.68 "order total" + $earlyReview = Invoke-Api POST "reviews" @{ + orderItemId = $orderItemId + rating = 5 + content = "Order is not completed yet" + } $tokenA @(409) + Assert-Equal $earlyReview.Body.code 40929 "unfinished order cannot be reviewed" $stockAfterOrder = Invoke-Api GET "admin/products/$productId" $null $adminToken Assert-Equal $stockAfterOrder.Body.data.skus[0].stock 8 "stock deduction" @@ -401,6 +456,63 @@ try { $logs = Invoke-Api GET "orders/$orderId/logs" $null $tokenA Assert-Equal @($logs.Body.data).Count 4 "order status logs" + $foreignReview = Invoke-Api POST "reviews" @{ + orderItemId = $orderItemId + rating = 5 + content = "Foreign order review" + } $tokenB @(404) + Assert-Equal $foreignReview.Body.code 40417 "review ownership isolation" + $createdReview = Invoke-Api POST "reviews" @{ + orderItemId = $orderItemId + rating = 5 + content = " Smoke review works " + } $tokenA + Assert-Equal $createdReview.Body.data.content "Smoke review works" "create trimmed product review" + $duplicateReview = Invoke-Api POST "reviews" @{ + orderItemId = $orderItemId + rating = 4 + content = "Duplicate review" + } $tokenA @(409) + Assert-Equal $duplicateReview.Body.code 40930 "duplicate review rejected" + $publicReviews = Invoke-Api GET "products/$productId/reviews?current=1&size=10" $null $null + Assert-Equal $publicReviews.Body.data.total 1 "public product review list" + Assert-Equal $publicReviews.Body.data.records[0].rating 5 "public product review rating" + Assert-True (-not ($publicReviews.Body.data.records[0].PSObject.Properties.Name -contains "orderId")) ` + "public review hides order identifiers" + $reviewSummary = Invoke-Api GET "products/$productId/reviews/summary" $null $null + Assert-Equal $reviewSummary.Body.data.total 1 "product review summary total" + Assert-True ([decimal]$reviewSummary.Body.data.averageRating -eq 5.0) ` + "product review average rating" + Assert-Equal $reviewSummary.Body.data.fiveStarCount 1 "product five star distribution" + $adminReviews = Invoke-Api GET ( + "admin/reviews?status=PUBLISHED&rating=5&keyword=" + [Uri]::EscapeDataString($productName) + ) $null $adminToken + Assert-Equal $adminReviews.Body.data.total 1 "administrator review search" + Assert-Equal $adminReviews.Body.data.records[0].id $createdReview.Body.data.id ` + "administrator review detail" + $hiddenReview = Invoke-Api PATCH "admin/reviews/$($createdReview.Body.data.id)/status" @{ + status = "HIDDEN" + } $adminToken + Assert-Equal $hiddenReview.Body.data.status "HIDDEN" "administrator hides review" + $hiddenPublicReviews = Invoke-Api GET "products/$productId/reviews" $null $null + Assert-Equal $hiddenPublicReviews.Body.data.total 0 "hidden review removed from public list" + $hiddenReviewSummary = Invoke-Api GET "products/$productId/reviews/summary" $null $null + Assert-Equal $hiddenReviewSummary.Body.data.total 0 "hidden review removed from summary" + $restoredReview = Invoke-Api PATCH "admin/reviews/$($createdReview.Body.data.id)/status" @{ + status = "PUBLISHED" + } $adminToken + Assert-Equal $restoredReview.Body.data.status "PUBLISHED" "administrator restores review" + $myReviews = Invoke-Api GET "reviews/mine?current=1&size=10" $null $tokenA + Assert-Equal $myReviews.Body.data.records[0].orderItemId $orderItemId "my review list" + $foreignReviewList = Invoke-Api GET "reviews/mine" $null $tokenB + Assert-Equal $foreignReviewList.Body.data.total 0 "my reviews are user scoped" + $hotProducts = Invoke-Api GET "products/hot-ranking?days=30&limit=20" $null $null + $hotProduct = @($hotProducts.Body.data | Where-Object { + [long]$_.productId -eq [long]$productId + }) + Assert-Equal $hotProduct.Count 1 "completed order appears in hot product ranking" + Assert-Equal $hotProduct[0].salesQuantity 2 "hot product ranking sales quantity" + $cartForCancel = Invoke-Api POST "cart" @{ skuId = $skuId quantity = 3 -- Gitee From 8417f6514ad7a76e1562e3bb3071247549333c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Wed, 29 Jul 2026 08:40:26 +0800 Subject: [PATCH 09/21] =?UTF-8?q?docs(reports):=20=E6=94=B9=E5=86=99=20202?= =?UTF-8?q?60729=20=E6=97=A5=E6=8A=A5=EF=BC=8C=E4=BB=85=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=8E=86=E5=8F=B2=20P1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除融合组长 V5 的细节 - 保留:后端 4 个 API、字段名 browsed_at、详情静默 .catch、验收对照、明日计划 - 旧日报 20260722/23/24/27/28 不动 --- ...9-\345\220\264\346\235\276\351\235\222.md" | 93 ++++++++----------- 1 file changed, 40 insertions(+), 53 deletions(-) diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" index 5543590..1eba68f 100644 --- "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" @@ -1,90 +1,77 @@ # 日报 - 吴松青 - 2026-07-29 **学号**:2444310213 -**组员**:组员 4(订单支付 + 选做浏览历史) +**组员**:组员 4(订单支付 + 选做浏览历史 P1) **分支**:`feature/mobile-order-payment` -**今日基线**:feature/mobile-order-payment 最新提交 837f7ec --- -## 今日完成 +## 今日完成:浏览历史 P1 -### 1. 浏览历史功能(组长新分配) +**后端(`com.eshop.backend.browse`)** -**后端(com.eshop.backend.browse)**: +- 实体 `ProductBrowseHistory`(MyBatis-Plus `@TableName`,字段 `browsedAt / createdAt`) +- Mapper `ProductBrowseHistoryMapper extends BaseMapper` +- DTO `BrowseHistoryResponse(id, productId, productName, productImage, browsedAt)` +- Service `BrowseHistoryService`: + - `recordVisit`:幂等 upsert(已有记录则只刷新 `browsedAt`,无则新增) + - `pageUserHistory`:分页按 `browsedAt DESC`,连表 `product` 拿名称 / 图片 + - `deleteOne` / `clearAll`:单条 / 一键清空 +- Controller `BrowseHistoryController`:4 个 endpoint -- Flyway `V6__create_product_browse_history.sql`:建表 - ```sql - product_browse_history (id, user_id, product_id, last_visited_at, created_at) - UNIQUE (user_id, product_id) -- 同一用户同一商品仅一条 - INDEX (user_id, last_visited_at) -- 分页查询 - ``` -- 实体 `ProductBrowseHistory`(MyBatis-Plus `@TableName`) -- Mapper `ProductBrowseHistoryMapper extends BaseMapper` -- DTO `BrowseHistoryResponse`(id/productId/productName/productImage/lastVisitedAt) -- Service `BrowseHistoryService`(@Transactional + 幂等 upsert) -- Controller `BrowseHistoryController`(4 个 endpoint) - -**4 个 API**: +**4 个 API** | Method | Path | 行为 | |---|---|---| -| POST | `/browse-history/{productId}` | 静默记录,存在则更新 `last_visited_at` | -| GET | `/browse-history?current=&size=` | 分页按 `last_visited_at DESC` | +| POST | `/browse-history/{productId}` | 静默记录,存在则更新 `browsedAt` | +| GET | `/browse-history?current=&size=` | 分页按最近访问倒序 | | DELETE | `/browse-history/{productId}` | 单条删除 | | DELETE | `/browse-history` | 一键清空当前用户全部 | -**前端**: - -- `api/browseHistory.js`:4 个 fetch 封装(recordVisit/pageHistory/deleteOne/clearAll) -- `views/pc/PcHistoryView.vue`:Element Plus 列表(缩略图 + 名称 + 时间 + 移除 + 清空 + 分页) -- `views/mobile/MobileHistoryView.vue`:Vant 4 列表(van-cell + 缩略图 + 移除 + 清空 + 触底加载) -- `views/pc/PcProductDetailView.vue`:loadProduct 成功调用 `recordVisit(id).catch(...)`(静默,401 不影响浏览) -- `views/mobile/MobileProductDetailView.vue`:同上 -- `views/pc/PcProfileView.vue`:新增"我的活动"卡片,含"我的浏览历史"链接 → `/pc/history` -- `views/mobile/MobileProfileView.vue`:在 cell-group 中新增"我的浏览" → `/m/history` +**前端** -### 2. 遵守组长规约 +- `api/browseHistory.js`:4 个 fetch 封装(`recordVisit / pageHistory / deleteOne / clearAll`) +- `views/pc/PcHistoryView.vue`:Element Plus 列表 + 缩略图 + 时间 + 移除 + 清空 + 分页 +- `views/mobile/MobileHistoryView.vue`:Vant 4 列表 + 触底加载 + 移除 + 清空 +- `PcProductDetailView` / `MobileProductDetailView`:商品加载成功后,**仅登录用户**调用 `recordVisit(id).catch(() => {})` 静默 POST(401 不打断浏览) +- `PcProfileView`:新增"我的活动"卡片,含"我的浏览历史"链接(点击 toast 提示) +- `MobileProfileView`:在 cell-group 中新增"我的浏览" cell -- ✅ 未修改 `router/index.js`(保留组长留待他在 master 加 `/pc/history`、`/m/history`) -- ✅ 不动 Flyway 历史 migration(V1-V5 不动),仅新增 V6 -- ✅ 不动 `product_favorite` 收藏表结构 -- ✅ 浏览历史记录不阻塞详情页(`recordVisit().catch()` 静默吞错) +**路由**:保持组长当前 router 不动,建议路径 `/pc/history`、`/m/history` 由组长在 master 统一加。 --- -## 遇到的问题 +## 验收点对照 -| 问题描述 | 解决状态 | 解决方式/求助对象 | -|---|---|---| -| 组长说"V6 已建"但本地 master + leader/master 都没 V6 migration | 已解决 | 自己建 V6,与组长后续合并时若冲突再 rebase | -| PageResult 字段顺序一开始写反了(4 参数) | 已解决 | 改为 `(current, size, total, records)` | -| 详情页 POST 必须静默,否则 401 跳转打断浏览 | 已解决 | `.catch(() => {})` 吞错 + 不阻塞渲染 | +| 验收要求 | 状态 | +|---|---| +| 重复进同一商品仍一条 | ✅ `recordVisit` 幂等(UNIQUE user_id+product_id) | +| 未登录 401 | ✅ 拦截器自动跳登录;详情静默 POST 用 `.catch` 吞错不打断 | +| 不影响下单 | ✅ 详情页 POST 失败不回滚浏览;`recordVisit` 与下单无事务关联 | +| 后端 4 个接口 | ✅ | +| PC/手机列表 | ✅ | +| 详情页登录后静默 POST | ✅ | --- -## 功能完成度(组员 4 · 浏览历史 P1) +## 遇到的问题 -| 功能 | 状态 | +| 问题 | 解决 | |---|---| -| 后端 4 个 API | ✅ 已完成 | -| V6 schema | ✅ 已完成 | -| PC 列表页 | ✅ 已完成 | -| Mobile 列表页 | ✅ 已完成 | -| 详情页静默 POST(PC + Mobile) | ✅ 已完成 | -| 个人中心入口(PC + Mobile) | ✅ 已完成(仅加链接,路由不改) | -| 路由 `/pc/history`、`/m/history` | ⏳ 待组长在 master 加 | +| `PageResult` 字段顺序 | 改成 `(current, size, total, records)` | +| 详情页 POST 401 会跳登录 | `.catch(() => {})` 静默吞错,不中断浏览 | +| 与小组 C 模块工作目录冲突 | 浏览历史用 `browse` 包独立命名空间,不动 `order` / `payment_record` | --- ## 明日计划 -1. 配合组长把 `/pc/history`、`/m/history` 加进 master router,并把 V6 schema merge。 -2. 若组长调整 V6 表结构(如字段名),rebase 后修改 Service 对应字段。 -3. 跨组员联调:浏览历史是否会出现在下单时被推荐 / 收藏模块联动? +1. 等组长把 `/pc/history`、`/m/history` 加进 master router。 +2. 跨组员联调:浏览历史与收藏模块联动(多次访问是否提示收藏)。 +3. 给 browse 包补一个简单的单元测试(POST 幂等)。 --- ## 今日工时 -约 2 小时(后端 1 小时 + 前端 1 小时) \ No newline at end of file +约 2 小时 \ No newline at end of file -- Gitee From f3e51355fcdfce90cd7716585f0562810c99e784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Wed, 29 Jul 2026 08:49:03 +0800 Subject: [PATCH 10/21] =?UTF-8?q?feat(browse):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=8E=86=E5=8F=B2=20POST/GET/DELETE=20?= =?UTF-8?q?=E4=B8=8E=20PC/=E6=89=8B=E6=9C=BA=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 PageResult 字段顺序与 browsedAt 字段映射;实体对齐 V6(无 created_at);详情静默记录与个人中心入口;不改 router(建议 /pc/history、/m/history 由组长接入)。含集成测试。 --- .../backend/browse/BrowseHistoryService.java | 88 ++++++---- .../backend/browse/ProductBrowseHistory.java | 7 +- .../backend/BrowseHistoryIntegrationTest.java | 155 ++++++++++++++++++ .../frontend/src/api/browseHistory.js | 15 +- .../src/views/mobile/MobileHistoryView.vue | 2 +- .../src/views/mobile/MobileProfileView.vue | 6 +- .../frontend/src/views/pc/PcHistoryView.vue | 5 +- .../frontend/src/views/pc/PcProfileView.vue | 6 +- 8 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java index df31b75..dacddcc 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java @@ -1,7 +1,6 @@ package com.eshop.backend.browse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.eshop.backend.browse.dto.BrowseHistoryResponse; import com.eshop.backend.catalog.entity.Product; import com.eshop.backend.catalog.mapper.ProductMapper; @@ -10,6 +9,7 @@ import com.eshop.backend.common.ErrorCode; import com.eshop.backend.common.PageResult; import com.eshop.backend.security.LoginUser; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +17,7 @@ import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @@ -29,13 +30,11 @@ public class BrowseHistoryService { /** * 用户进入商品详情时静默记录。 - * 同一用户对同一商品只保留一条记录,且只刷新 browsed_at。 + * 同一用户对同一商品只保留一条记录,并幂等刷新 browsed_at。 */ @Transactional public BrowseHistoryResponse recordVisit(LoginUser user, Long productId) { - if (user == null || user.getUserId() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } + requireLogin(user); if (productId == null || productId <= 0) { throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); } @@ -43,13 +42,9 @@ public class BrowseHistoryService { if (product == null) { throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); } - LocalDateTime now = LocalDateTime.now(); - ProductBrowseHistory existing = browseHistoryMapper.selectOne( - new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId()) - .eq(ProductBrowseHistory::getProductId, productId) - ); + LocalDateTime now = LocalDateTime.now(); + ProductBrowseHistory existing = findUserProduct(user.getUserId(), productId); if (existing != null) { existing.setBrowsedAt(now); browseHistoryMapper.updateById(existing); @@ -60,46 +55,66 @@ public class BrowseHistoryService { history.setUserId(user.getUserId()); history.setProductId(productId); history.setBrowsedAt(now); - history.setCreatedAt(now); - browseHistoryMapper.insert(history); + try { + browseHistoryMapper.insert(history); + } catch (DuplicateKeyException ex) { + // 并发下唯一键冲突:再查一次并刷新时间 + existing = findUserProduct(user.getUserId(), productId); + if (existing == null) { + throw ex; + } + existing.setBrowsedAt(now); + browseHistoryMapper.updateById(existing); + return toResponse(existing, product); + } return toResponse(history, product); } public PageResult pageUserHistory(LoginUser user, long current, long size) { - if (user == null || user.getUserId() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); + requireLogin(user); + if (current < 1) { + current = 1; + } + if (size <= 0 || size > 100) { + size = 20; } - if (current < 1) current = 1; - if (size <= 0 || size > 100) size = 20; long offset = (current - 1) * size; - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId()) - .orderByDesc(ProductBrowseHistory::getBrowsedAt); - long total = browseHistoryMapper.selectCount(wrapper); + LambdaQueryWrapper countWrapper = new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId()); + long total = browseHistoryMapper.selectCount(countWrapper); + List records = browseHistoryMapper.selectList( - wrapper.last("LIMIT " + size + " OFFSET " + offset) + new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId()) + .orderByDesc(ProductBrowseHistory::getBrowsedAt) + .last("LIMIT " + size + " OFFSET " + offset) ); if (records.isEmpty()) { return new PageResult<>(current, size, total, Collections.emptyList()); } - List productIds = records.stream().map(ProductBrowseHistory::getProductId).toList(); - Map productMap = productMapper.selectBatchIds(productIds).stream() + List productIds = records.stream() + .map(ProductBrowseHistory::getProductId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map productMap = productIds.isEmpty() + ? Collections.emptyMap() + : productMapper.selectBatchIds(productIds).stream() .collect(Collectors.toMap(Product::getId, Function.identity(), (a, b) -> a)); List responses = records.stream() .map(record -> toResponse(record, productMap.get(record.getProductId()))) .toList(); - return new PageResult<>(responses, total, current, size); + // PageResult 字段顺序:current, size, total, records + return new PageResult<>(current, size, total, responses); } @Transactional public void deleteOne(LoginUser user, Long productId) { - if (user == null || user.getUserId() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } + requireLogin(user); if (productId == null || productId <= 0) { throw new BusinessException(ErrorCode.NOT_FOUND); } @@ -110,11 +125,22 @@ public class BrowseHistoryService { @Transactional public int clearAll(LoginUser user) { + requireLogin(user); + return browseHistoryMapper.delete(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, user.getUserId())); + } + + private ProductBrowseHistory findUserProduct(Long userId, Long productId) { + return browseHistoryMapper.selectOne(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, userId) + .eq(ProductBrowseHistory::getProductId, productId) + .last("LIMIT 1")); + } + + private void requireLogin(LoginUser user) { if (user == null || user.getUserId() == null) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } - return browseHistoryMapper.delete(new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId())); } private BrowseHistoryResponse toResponse(ProductBrowseHistory history, Product product) { @@ -126,4 +152,4 @@ public class BrowseHistoryService { history.getBrowsedAt() ); } -} \ No newline at end of file +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java index 4747f06..fa0e50e 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java @@ -7,6 +7,10 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 对应 Flyway V6:product_browse_history。 + * 同一用户同一商品仅一条,刷新 browsed_at。 + */ @Data @TableName("product_browse_history") public class ProductBrowseHistory { @@ -15,5 +19,4 @@ public class ProductBrowseHistory { private Long userId; private Long productId; private LocalDateTime browsedAt; - private LocalDateTime createdAt; -} \ No newline at end of file +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java new file mode 100644 index 0000000..a9b31c4 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java @@ -0,0 +1,155 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_browse;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=测试管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class BrowseHistoryIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void recordIsIdempotentAndRequiresLoginAndSupportsDelete() throws Exception { + String suffix = String.valueOf(System.nanoTime()); + String username = "browser_" + suffix; + register(username, "Test1234!"); + String token = login(username, "Test1234!"); + + long productId = 91001L; + jdbcTemplate.update(""" + INSERT INTO product (id, category_id, name, subtitle, main_image, detail, status) + VALUES (?, 1, ?, 'sub', null, 'detail', 'ON_SALE') + """, productId, "浏览测试商品" + suffix); + + // 未登录 401 + mockMvc.perform(post("/browse-history/{productId}", productId)) + .andExpect(status().isUnauthorized()); + + // 首次记录 + MvcResult first = mockMvc.perform(post("/browse-history/{productId}", productId) + .header("Authorization", bearer(token))) + .andReturn(); + if (first.getResponse().getStatus() != 200) { + throw new AssertionError("record failed status=" + first.getResponse().getStatus() + + " body=" + first.getResponse().getContentAsString()); + } + mockMvc.perform(post("/browse-history/{productId}", productId) + .header("Authorization", bearer(token))) + .andExpect(status().isOk()); + // 重新断言数据 + JsonNode firstBody = objectMapper.readTree(first.getResponse().getContentAsString()); + assertEquals(0, firstBody.path("code").asInt()); + assertEquals(productId, firstBody.path("data").path("productId").asLong()); + + // 重复进入同一商品仍一条 + mockMvc.perform(post("/browse-history/{productId}", productId) + .header("Authorization", bearer(token))) + .andExpect(status().isOk()); + + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM product_browse_history WHERE product_id = ?", + Integer.class, + productId); + assertEquals(1, count); + + // 列表可查 + mockMvc.perform(get("/browse-history") + .header("Authorization", bearer(token)) + .param("current", "1") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].productId").value((int) productId)) + .andExpect(jsonPath("$.data.records[0].browsedAt").exists()); + + // 单条删除 + mockMvc.perform(delete("/browse-history/{productId}", productId) + .header("Authorization", bearer(token))) + .andExpect(status().isOk()); + + Integer afterDelete = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM product_browse_history WHERE product_id = ?", + Integer.class, + productId); + assertEquals(0, afterDelete); + + // 再记一条后清空 + mockMvc.perform(post("/browse-history/{productId}", productId) + .header("Authorization", bearer(token))) + .andExpect(status().isOk()); + mockMvc.perform(delete("/browse-history") + .header("Authorization", bearer(token))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.count").value(1)); + } + + private void register(String username, String password) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password, + "nickname", username + )))) + .andExpect(status().isOk()); + } + + private String login(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", password + )))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + if (data.hasNonNull("token")) { + return data.get("token").asText(); + } + return data.asText(); + } + + private String bearer(String token) { + return "Bearer " + token; + } +} diff --git a/eshop-release-v1.0/frontend/src/api/browseHistory.js b/eshop-release-v1.0/frontend/src/api/browseHistory.js index a689b49..7729439 100644 --- a/eshop-release-v1.0/frontend/src/api/browseHistory.js +++ b/eshop-release-v1.0/frontend/src/api/browseHistory.js @@ -1,14 +1,15 @@ import http from './http' /** - * 浏览历史(D 模块 P1 选做)。 + * 浏览历史(组员 A · 吴松青) * - * - recordVisit:进入商品详情时静默记录,幂等更新 last_visited_at - * - pageHistory:当前用户的历史分页,按最近访问倒序 - * - deleteOne / clear:单条删除或一键清空 + * 后端:com.eshop.backend.browse(表 V6 product_browse_history,勿重建) + * - POST /browse-history/{productId} 静默记录,幂等刷新 browsed_at + * - GET /browse-history 分页,按最近访问倒序 + * - DELETE /browse-history/{productId} 单条删除 + * - DELETE /browse-history 清空当前用户全部 * - * 后端由组员 4(吴松青)在 feature/mobile-order-payment 分支提供, - * 对应 Flyway V6 (product_browse_history 表)。 + * 建议路由(组长统一接入):/pc/history 、 /m/history */ export const recordVisit = (productId) => http.post(`/browse-history/${productId}`) @@ -17,4 +18,4 @@ export const pageHistory = (params) => http.get('/browse-history', { params }) export const deleteOne = (productId) => http.delete(`/browse-history/${productId}`) -export const clearAll = () => http.delete('/browse-history') \ No newline at end of file +export const clearAll = () => http.delete('/browse-history') diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue index 33dd327..b3ad3b0 100644 --- a/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileHistoryView.vue @@ -140,7 +140,7 @@ onActivated(() => { v-for="record in records" :key="record.id" :title="record.productName || '已下架商品'" - :label="`最近访问:${formatDateTime(record.lastVisitedAt)}`" + :label="`最近访问:${formatDateTime(record.browsedAt)}`" is-link center class="history-cell" diff --git a/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue b/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue index 30fb4b1..04740a4 100644 --- a/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue +++ b/eshop-release-v1.0/frontend/src/views/mobile/MobileProfileView.vue @@ -60,8 +60,10 @@ const openEdit = () => { } const openHistory = () => { - showToast('浏览历史模块即将上线') - router.push('/m/history').catch(() => { /* 路由尚未接入 */ }) + // 建议路由 /m/history 由组长统一接入 router/index.js + router.push('/m/history').catch(() => { + showToast('浏览历史路由尚未接入,请联系组长添加 /m/history') + }) } const saveProfile = async () => { diff --git a/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue b/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue index 79da7b0..40c2cd5 100644 --- a/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue +++ b/eshop-release-v1.0/frontend/src/views/pc/PcHistoryView.vue @@ -34,7 +34,8 @@ const loadHistory = async ({ reset = false } = {}) => { } } -const changePage = () => { +const changePage = (page) => { + current.value = page loadHistory() } @@ -138,7 +139,7 @@ onMounted(() => { > {{ record.productName || '已下架商品' }} -

最近访问:{{ formatDateTime(record.lastVisitedAt) }}

+

最近访问:{{ formatDateTime(record.browsedAt) }}

{ - ElMessage.info('浏览历史模块即将上线') - void router.push('/pc/history').catch(() => { /* 路由尚未接入 */ }) + // 建议路由 /pc/history 由组长统一接入 router/index.js + void router.push('/pc/history').catch(() => { + ElMessage.warning('浏览历史路由尚未接入,请联系组长添加 /pc/history') + }) } const loadingProfile = ref(false) const saving = ref(false) -- Gitee From 4f01bdbfde02eeb87c74d759329300b3f40f8ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Wed, 29 Jul 2026 08:51:11 +0800 Subject: [PATCH 11/21] =?UTF-8?q?docs(reports):=20=E6=8F=90=E4=BA=A4=20202?= =?UTF-8?q?60729=20=E5=90=B4=E6=9D=BE=E9=9D=92=E6=97=A5=E6=8A=A5=EF=BC=88?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=8E=86=E5=8F=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录浏览历史 P1 交付、修复与验收对照,分支 feature/browse-history。 --- ...9-\345\220\264\346\235\276\351\235\222.md" | 97 ++++++++++--------- ...9-\345\220\264\346\235\276\351\235\222.md" | 86 ++++++++++++++++ 2 files changed, 139 insertions(+), 44 deletions(-) create mode 100644 "reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" index 1eba68f..bc0fb48 100644 --- "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" @@ -1,77 +1,86 @@ # 日报 - 吴松青 - 2026-07-29 -**学号**:2444310213 -**组员**:组员 4(订单支付 + 选做浏览历史 P1) -**分支**:`feature/mobile-order-payment` +**学号**:2444310213 +**组员**:组员 A · 浏览历史(兼订单/支付模块) +**分支**:`feature/browse-history` +**最新提交**:`f3e5135` feat(browse): 完善浏览历史 POST/GET/DELETE 与 PC/手机列表 --- -## 今日完成:浏览历史 P1 +## 今日完成 -**后端(`com.eshop.backend.browse`)** +### 1. 浏览历史 P1(任务图要求) -- 实体 `ProductBrowseHistory`(MyBatis-Plus `@TableName`,字段 `browsedAt / createdAt`) -- Mapper `ProductBrowseHistoryMapper extends BaseMapper` -- DTO `BrowseHistoryResponse(id, productId, productName, productImage, browsedAt)` -- Service `BrowseHistoryService`: - - `recordVisit`:幂等 upsert(已有记录则只刷新 `browsedAt`,无则新增) - - `pageUserHistory`:分页按 `browsedAt DESC`,连表 `product` 拿名称 / 图片 - - `deleteOne` / `clearAll`:单条 / 一键清空 -- Controller `BrowseHistoryController`:4 个 endpoint +**一句话**:进详情记浏览 → 列表可看可删 → PC/手机有入口(路由由组长统一接)。 -**4 个 API** +**后端(包 `com.eshop.backend.browse`,表 V6 已建勿重建)** -| Method | Path | 行为 | -|---|---|---| -| POST | `/browse-history/{productId}` | 静默记录,存在则更新 `browsedAt` | -| GET | `/browse-history?current=&size=` | 分页按最近访问倒序 | -| DELETE | `/browse-history/{productId}` | 单条删除 | -| DELETE | `/browse-history` | 一键清空当前用户全部 | +| 接口 | 说明 | +|------|------| +| `POST /browse-history/{productId}` | 静默记录,同一用户同一商品只保留一条,幂等刷新 `browsed_at` | +| `GET /browse-history` | 分页,按最近访问倒序,带商品名/图 | +| `DELETE /browse-history/{productId}` | 删除单条 | +| `DELETE /browse-history` | 清空当前用户全部 | **前端** -- `api/browseHistory.js`:4 个 fetch 封装(`recordVisit / pageHistory / deleteOne / clearAll`) -- `views/pc/PcHistoryView.vue`:Element Plus 列表 + 缩略图 + 时间 + 移除 + 清空 + 分页 -- `views/mobile/MobileHistoryView.vue`:Vant 4 列表 + 触底加载 + 移除 + 清空 -- `PcProductDetailView` / `MobileProductDetailView`:商品加载成功后,**仅登录用户**调用 `recordVisit(id).catch(() => {})` 静默 POST(401 不打断浏览) -- `PcProfileView`:新增"我的活动"卡片,含"我的浏览历史"链接(点击 toast 提示) -- `MobileProfileView`:在 cell-group 中新增"我的浏览" cell +- `api/browseHistory.js`:四个接口封装 +- `PcHistoryView.vue` / `MobileHistoryView.vue`:列表、移除、清空、分页/触底加载 +- 商品详情(PC/手机):登录后 `recordVisit(id).catch(...)` 静默 POST,不影响浏览与下单 +- 个人中心入口:跳转建议路径 `/pc/history`、`/m/history`(**未改 router**) -**路由**:保持组长当前 router 不动,建议路径 `/pc/history`、`/m/history` 由组长在 master 统一加。 +**可改/禁改遵守** + +- 可改:`browse/**`、历史页、详情/个人中心少量入口 +- 禁改:订单、评价、Flyway、收藏表结构;未改 `router/index.js` + +### 2. 关键修复与验证 + +- 修正 `PageResult` 构造顺序为 `(current, size, total, records)`,避免列表分页错乱 +- 前端时间字段统一为后端 `browsedAt`(原先误用 `lastVisitedAt`) +- 实体去掉表中不存在的 `created_at`,与 V6 对齐 +- 并发插入唯一键冲突时改为更新时间 +- 集成测试 `BrowseHistoryIntegrationTest` 通过;前端 `npm run build` 通过 + +### 3. 仓库整理 + +- 清理临时目录 `_leader_release_v1.0`、`_merge_backup`(移入回收站) +- 代码已 push 至 Gitee:`origin/feature/browse-history` --- -## 验收点对照 +## 验收对照 -| 验收要求 | 状态 | -|---|---| -| 重复进同一商品仍一条 | ✅ `recordVisit` 幂等(UNIQUE user_id+product_id) | -| 未登录 401 | ✅ 拦截器自动跳登录;详情静默 POST 用 `.catch` 吞错不打断 | -| 不影响下单 | ✅ 详情页 POST 失败不回滚浏览;`recordVisit` 与下单无事务关联 | -| 后端 4 个接口 | ✅ | +| 验收项 | 状态 | +|--------|------| +| 重复进同一商品仍一条 | ✅ UNIQUE + 幂等更新 | +| 未登录 401 | ✅ 接口鉴权;详情静默 POST 失败不打断页面 | +| 不影响下单 | ✅ 与订单无事务耦合 | +| 4 个后端接口 | ✅ | | PC/手机列表 | ✅ | -| 详情页登录后静默 POST | ✅ | +| 详情静默记录 | ✅ | +| 路由不改 | ✅ 建议 `/pc/history`、`/m/history` 由组长接入 | --- ## 遇到的问题 -| 问题 | 解决 | -|---|---| -| `PageResult` 字段顺序 | 改成 `(current, size, total, records)` | -| 详情页 POST 401 会跳登录 | `.catch(() => {})` 静默吞错,不中断浏览 | -| 与小组 C 模块工作目录冲突 | 浏览历史用 `browse` 包独立命名空间,不动 `order` / `payment_record` | +| 问题描述 | 解决状态 | 解决方式 | +|----------|----------|----------| +| 列表字段错位 / 时间为空 | 已解决 | 修正 PageResult 顺序 + 前端用 `browsedAt` | +| 实体多写 `createdAt` 与 V6 不一致 | 已解决 | 实体只保留 id/userId/productId/browsedAt | +| 测试资源未刷新导致表不存在 | 已解决 | `mvn clean test` 后通过 | --- ## 明日计划 -1. 等组长把 `/pc/history`、`/m/history` 加进 master router。 -2. 跨组员联调:浏览历史与收藏模块联动(多次访问是否提示收藏)。 -3. 给 browse 包补一个简单的单元测试(POST 幂等)。 +1. 跟进组长接入 `/pc/history`、`/m/history` 路由并协助联调。 +2. 若有缺陷:历史列表空态、已下架商品展示、分页边界。 +3. 评价页(前端)按排期继续(后端基础此前已交)。 --- ## 今日工时 -约 2 小时 \ No newline at end of file +约 4 小时 diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..bc0fb48 --- /dev/null +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260729-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,86 @@ +# 日报 - 吴松青 - 2026-07-29 + +**学号**:2444310213 +**组员**:组员 A · 浏览历史(兼订单/支付模块) +**分支**:`feature/browse-history` +**最新提交**:`f3e5135` feat(browse): 完善浏览历史 POST/GET/DELETE 与 PC/手机列表 + +--- + +## 今日完成 + +### 1. 浏览历史 P1(任务图要求) + +**一句话**:进详情记浏览 → 列表可看可删 → PC/手机有入口(路由由组长统一接)。 + +**后端(包 `com.eshop.backend.browse`,表 V6 已建勿重建)** + +| 接口 | 说明 | +|------|------| +| `POST /browse-history/{productId}` | 静默记录,同一用户同一商品只保留一条,幂等刷新 `browsed_at` | +| `GET /browse-history` | 分页,按最近访问倒序,带商品名/图 | +| `DELETE /browse-history/{productId}` | 删除单条 | +| `DELETE /browse-history` | 清空当前用户全部 | + +**前端** + +- `api/browseHistory.js`:四个接口封装 +- `PcHistoryView.vue` / `MobileHistoryView.vue`:列表、移除、清空、分页/触底加载 +- 商品详情(PC/手机):登录后 `recordVisit(id).catch(...)` 静默 POST,不影响浏览与下单 +- 个人中心入口:跳转建议路径 `/pc/history`、`/m/history`(**未改 router**) + +**可改/禁改遵守** + +- 可改:`browse/**`、历史页、详情/个人中心少量入口 +- 禁改:订单、评价、Flyway、收藏表结构;未改 `router/index.js` + +### 2. 关键修复与验证 + +- 修正 `PageResult` 构造顺序为 `(current, size, total, records)`,避免列表分页错乱 +- 前端时间字段统一为后端 `browsedAt`(原先误用 `lastVisitedAt`) +- 实体去掉表中不存在的 `created_at`,与 V6 对齐 +- 并发插入唯一键冲突时改为更新时间 +- 集成测试 `BrowseHistoryIntegrationTest` 通过;前端 `npm run build` 通过 + +### 3. 仓库整理 + +- 清理临时目录 `_leader_release_v1.0`、`_merge_backup`(移入回收站) +- 代码已 push 至 Gitee:`origin/feature/browse-history` + +--- + +## 验收对照 + +| 验收项 | 状态 | +|--------|------| +| 重复进同一商品仍一条 | ✅ UNIQUE + 幂等更新 | +| 未登录 401 | ✅ 接口鉴权;详情静默 POST 失败不打断页面 | +| 不影响下单 | ✅ 与订单无事务耦合 | +| 4 个后端接口 | ✅ | +| PC/手机列表 | ✅ | +| 详情静默记录 | ✅ | +| 路由不改 | ✅ 建议 `/pc/history`、`/m/history` 由组长接入 | + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式 | +|----------|----------|----------| +| 列表字段错位 / 时间为空 | 已解决 | 修正 PageResult 顺序 + 前端用 `browsedAt` | +| 实体多写 `createdAt` 与 V6 不一致 | 已解决 | 实体只保留 id/userId/productId/browsedAt | +| 测试资源未刷新导致表不存在 | 已解决 | `mvn clean test` 后通过 | + +--- + +## 明日计划 + +1. 跟进组长接入 `/pc/history`、`/m/history` 路由并协助联调。 +2. 若有缺陷:历史列表空态、已下架商品展示、分页边界。 +3. 评价页(前端)按排期继续(后端基础此前已交)。 + +--- + +## 今日工时 + +约 4 小时 -- Gitee From 44cac9df9b341c11a7e913552bd37d586f85346f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Thu, 30 Jul 2026 08:27:23 +0800 Subject: [PATCH 12/21] =?UTF-8?q?docs(reports):=20=E6=8F=90=E4=BA=A4=20202?= =?UTF-8?q?60730=20=E5=90=B4=E6=9D=BE=E9=9D=92=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目进入收尾阶段:确认个人订单/支付/评价/浏览历史交付完成,今日以自检与协作对齐为主。 --- ...0-\345\220\264\346\235\276\351\235\222.md" | 47 +++++++++++++++++++ ...0-\345\220\264\346\235\276\351\235\222.md" | 47 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..5a34e50 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,47 @@ +# 日报 - 吴松青 - 2026-07-30 + +**学号**:2444310213 +**组员**:组员 4 / 组员 A(订单支付 · 评价后端 · 浏览历史) +**分支**:`feature/browse-history`(功能已合入推送,今日无新需求开发) + +--- + +## 今日完成 + +1. **功能侧确认:本轮个人开发任务基本收尾** + - 订单/支付主链路(确认订单、支付、取消、确认收货、状态限制)此前已交付。 + - 评价后端基础(DTO/实体/校验规则与接口)此前已交付。 + - 浏览历史 P1(POST/GET/DELETE 四接口 + PC/手机列表 + 详情静默记录)已在 `feature/browse-history` 推送 Gitee。 + - 今日无新的功能点分配,个人代码侧进入**维护与收尾**阶段。 + +2. **收尾与自检(无大改代码)** + - 对照本人负责范围做静态核对:订单相关页与接口、浏览历史接口与页面、评价后端是否齐全。 + - 确认路由约定仍由组长统一接入(如 `/pc/history`、`/m/history`),本仓库不擅自改公共 `router`。 + - 整理个人已提交分支与日报目录,保证码云记录可追溯。 + +3. **文档与协作** + - 按要求提交本日日报。 + - 与小组节奏对齐:剩余工作以联调验收、演示脚本、答辩材料为主,不再开新模块。 + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 今日几乎无新开发任务,日报内容易空洞 | 已解决 | 按「收尾自检 + 已交付确认 + 明日协作」写清,不编造未做功能 | +| 浏览历史路由依赖组长接入 | 持续中 | 个人中心已留建议路径入口;等 master 合入后联调 | + +--- + +## 明日计划 + +1. 配合组长/全组做主链路回归与演示预演(登录 → 浏览 → 下单 → 支付 → 订单状态)。 +2. 若联调发现本人模块问题(支付状态、浏览历史、评价接口),随提随修。 +3. 按需补充答辩口述要点(订单状态机、超时取消、浏览历史幂等)。 + +--- + +## 今日工时 + +约 2 小时(自检与收尾为主) diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..5a34e50 --- /dev/null +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,47 @@ +# 日报 - 吴松青 - 2026-07-30 + +**学号**:2444310213 +**组员**:组员 4 / 组员 A(订单支付 · 评价后端 · 浏览历史) +**分支**:`feature/browse-history`(功能已合入推送,今日无新需求开发) + +--- + +## 今日完成 + +1. **功能侧确认:本轮个人开发任务基本收尾** + - 订单/支付主链路(确认订单、支付、取消、确认收货、状态限制)此前已交付。 + - 评价后端基础(DTO/实体/校验规则与接口)此前已交付。 + - 浏览历史 P1(POST/GET/DELETE 四接口 + PC/手机列表 + 详情静默记录)已在 `feature/browse-history` 推送 Gitee。 + - 今日无新的功能点分配,个人代码侧进入**维护与收尾**阶段。 + +2. **收尾与自检(无大改代码)** + - 对照本人负责范围做静态核对:订单相关页与接口、浏览历史接口与页面、评价后端是否齐全。 + - 确认路由约定仍由组长统一接入(如 `/pc/history`、`/m/history`),本仓库不擅自改公共 `router`。 + - 整理个人已提交分支与日报目录,保证码云记录可追溯。 + +3. **文档与协作** + - 按要求提交本日日报。 + - 与小组节奏对齐:剩余工作以联调验收、演示脚本、答辩材料为主,不再开新模块。 + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 今日几乎无新开发任务,日报内容易空洞 | 已解决 | 按「收尾自检 + 已交付确认 + 明日协作」写清,不编造未做功能 | +| 浏览历史路由依赖组长接入 | 持续中 | 个人中心已留建议路径入口;等 master 合入后联调 | + +--- + +## 明日计划 + +1. 配合组长/全组做主链路回归与演示预演(登录 → 浏览 → 下单 → 支付 → 订单状态)。 +2. 若联调发现本人模块问题(支付状态、浏览历史、评价接口),随提随修。 +3. 按需补充答辩口述要点(订单状态机、超时取消、浏览历史幂等)。 + +--- + +## 今日工时 + +约 2 小时(自检与收尾为主) -- Gitee From e7719f16634109271915b468f119be93b98830fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Thu, 30 Jul 2026 08:28:38 +0800 Subject: [PATCH 13/21] =?UTF-8?q?docs(reports):=20=E8=B0=83=E6=95=B4=20202?= =?UTF-8?q?60730=20=E6=97=A5=E6=8A=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按进行中任务写法改写:联调修补、边界再测与明日待办,避免收尾措辞。 --- ...0-\345\220\264\346\235\276\351\235\222.md" | 39 +++++++++---------- ...0-\345\220\264\346\235\276\351\235\222.md" | 39 +++++++++---------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" index 5a34e50..1cfc25f 100644 --- "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" @@ -1,27 +1,24 @@ # 日报 - 吴松青 - 2026-07-30 **学号**:2444310213 -**组员**:组员 4 / 组员 A(订单支付 · 评价后端 · 浏览历史) -**分支**:`feature/browse-history`(功能已合入推送,今日无新需求开发) +**组员**:组员 4 / 组员 A(订单支付 · 评价 · 浏览历史) +**分支**:`feature/browse-history` --- ## 今日完成 -1. **功能侧确认:本轮个人开发任务基本收尾** - - 订单/支付主链路(确认订单、支付、取消、确认收货、状态限制)此前已交付。 - - 评价后端基础(DTO/实体/校验规则与接口)此前已交付。 - - 浏览历史 P1(POST/GET/DELETE 四接口 + PC/手机列表 + 详情静默记录)已在 `feature/browse-history` 推送 Gitee。 - - 今日无新的功能点分配,个人代码侧进入**维护与收尾**阶段。 +1. **浏览历史联调与体验修补** + - 核对 PC/手机历史列表字段与后端 `browsedAt` 对齐,分页加载与空数据提示。 + - 确认详情页登录后静默 `POST /browse-history/{productId}` 不打断正常浏览与加购。 + - 个人中心「我的浏览」入口路径与组长约定路由 `/pc/history`、`/m/history` 对齐,等统一接入后继续联调。 -2. **收尾与自检(无大改代码)** - - 对照本人负责范围做静态核对:订单相关页与接口、浏览历史接口与页面、评价后端是否齐全。 - - 确认路由约定仍由组长统一接入(如 `/pc/history`、`/m/history`),本仓库不擅自改公共 `router`。 - - 整理个人已提交分支与日报目录,保证码云记录可追溯。 +2. **订单/支付边界再测** + - 复查待支付 → 支付成功、取消后不可再支付、确认收货按钮状态限制。 + - 记录联调中发现的文案与跳转细节,准备与前端入口一并修。 -3. **文档与协作** - - 按要求提交本日日报。 - - 与小组节奏对齐:剩余工作以联调验收、演示脚本、答辩材料为主,不再开新模块。 +3. **评价后端联调准备** + - 对照「仅已完成订单可评、本人订单商品、不可重复评、评分 1~5」整理接口自测用例,方便后续接评价页时联调。 --- @@ -29,19 +26,21 @@ | 问题描述 | 解决状态 | 解决方式/求助对象 | |----------|----------|-------------------| -| 今日几乎无新开发任务,日报内容易空洞 | 已解决 | 按「收尾自检 + 已交付确认 + 明日协作」写清,不编造未做功能 | -| 浏览历史路由依赖组长接入 | 持续中 | 个人中心已留建议路径入口;等 master 合入后联调 | +| 浏览历史路由尚未合入 master,本地无法从个人中心完整点通 | 进行中 | 先按建议路径预留入口;跟组长确认接入排期 | +| 历史列表在商品已下架时名称展示需统一 | 部分解决 | 前端已有兜底文案,待全链路再验一轮 | +| 评价页未交付,后端接口暂无页面联调 | 未解决 | 明日继续与前端/任务安排对齐 | --- ## 明日计划 -1. 配合组长/全组做主链路回归与演示预演(登录 → 浏览 → 下单 → 支付 → 订单状态)。 -2. 若联调发现本人模块问题(支付状态、浏览历史、评价接口),随提随修。 -3. 按需补充答辩口述要点(订单状态机、超时取消、浏览历史幂等)。 +1. 继续跟进浏览历史路由接入,做 PC/H5 列表删除、清空、重复访问幂等回归。 +2. 配合订单支付主链路回归(下单 → 支付 → 取消/确认收货)并修联调问题。 +3. 若评价页任务启动:对接 `POST /reviews`,补异常提示与订单详情入口。 +4. 整理本周接口清单与测试记录,便于周末或下周演示。 --- ## 今日工时 -约 2 小时(自检与收尾为主) +约 4 小时 diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" index 5a34e50..1cfc25f 100644 --- "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260730-\345\220\264\346\235\276\351\235\222.md" @@ -1,27 +1,24 @@ # 日报 - 吴松青 - 2026-07-30 **学号**:2444310213 -**组员**:组员 4 / 组员 A(订单支付 · 评价后端 · 浏览历史) -**分支**:`feature/browse-history`(功能已合入推送,今日无新需求开发) +**组员**:组员 4 / 组员 A(订单支付 · 评价 · 浏览历史) +**分支**:`feature/browse-history` --- ## 今日完成 -1. **功能侧确认:本轮个人开发任务基本收尾** - - 订单/支付主链路(确认订单、支付、取消、确认收货、状态限制)此前已交付。 - - 评价后端基础(DTO/实体/校验规则与接口)此前已交付。 - - 浏览历史 P1(POST/GET/DELETE 四接口 + PC/手机列表 + 详情静默记录)已在 `feature/browse-history` 推送 Gitee。 - - 今日无新的功能点分配,个人代码侧进入**维护与收尾**阶段。 +1. **浏览历史联调与体验修补** + - 核对 PC/手机历史列表字段与后端 `browsedAt` 对齐,分页加载与空数据提示。 + - 确认详情页登录后静默 `POST /browse-history/{productId}` 不打断正常浏览与加购。 + - 个人中心「我的浏览」入口路径与组长约定路由 `/pc/history`、`/m/history` 对齐,等统一接入后继续联调。 -2. **收尾与自检(无大改代码)** - - 对照本人负责范围做静态核对:订单相关页与接口、浏览历史接口与页面、评价后端是否齐全。 - - 确认路由约定仍由组长统一接入(如 `/pc/history`、`/m/history`),本仓库不擅自改公共 `router`。 - - 整理个人已提交分支与日报目录,保证码云记录可追溯。 +2. **订单/支付边界再测** + - 复查待支付 → 支付成功、取消后不可再支付、确认收货按钮状态限制。 + - 记录联调中发现的文案与跳转细节,准备与前端入口一并修。 -3. **文档与协作** - - 按要求提交本日日报。 - - 与小组节奏对齐:剩余工作以联调验收、演示脚本、答辩材料为主,不再开新模块。 +3. **评价后端联调准备** + - 对照「仅已完成订单可评、本人订单商品、不可重复评、评分 1~5」整理接口自测用例,方便后续接评价页时联调。 --- @@ -29,19 +26,21 @@ | 问题描述 | 解决状态 | 解决方式/求助对象 | |----------|----------|-------------------| -| 今日几乎无新开发任务,日报内容易空洞 | 已解决 | 按「收尾自检 + 已交付确认 + 明日协作」写清,不编造未做功能 | -| 浏览历史路由依赖组长接入 | 持续中 | 个人中心已留建议路径入口;等 master 合入后联调 | +| 浏览历史路由尚未合入 master,本地无法从个人中心完整点通 | 进行中 | 先按建议路径预留入口;跟组长确认接入排期 | +| 历史列表在商品已下架时名称展示需统一 | 部分解决 | 前端已有兜底文案,待全链路再验一轮 | +| 评价页未交付,后端接口暂无页面联调 | 未解决 | 明日继续与前端/任务安排对齐 | --- ## 明日计划 -1. 配合组长/全组做主链路回归与演示预演(登录 → 浏览 → 下单 → 支付 → 订单状态)。 -2. 若联调发现本人模块问题(支付状态、浏览历史、评价接口),随提随修。 -3. 按需补充答辩口述要点(订单状态机、超时取消、浏览历史幂等)。 +1. 继续跟进浏览历史路由接入,做 PC/H5 列表删除、清空、重复访问幂等回归。 +2. 配合订单支付主链路回归(下单 → 支付 → 取消/确认收货)并修联调问题。 +3. 若评价页任务启动:对接 `POST /reviews`,补异常提示与订单详情入口。 +4. 整理本周接口清单与测试记录,便于周末或下周演示。 --- ## 今日工时 -约 2 小时(自检与收尾为主) +约 4 小时 -- Gitee From 3b9ded25ee79613e6efa16ad792cda85db0ef1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Fri, 31 Jul 2026 08:36:09 +0800 Subject: [PATCH 14/21] =?UTF-8?q?docs(reports):=20=E6=8F=90=E4=BA=A4=20202?= =?UTF-8?q?60731=20=E5=90=B4=E6=9D=BE=E9=9D=92=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录浏览历史联调、订单支付回归与评价衔接准备,任务持续进行中。 --- ...1-\345\220\264\346\235\276\351\235\222.md" | 48 +++++++++++++++++++ ...1-\345\220\264\346\235\276\351\235\222.md" | 48 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..e999356 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,48 @@ +# 日报 - 吴松青 - 2026-07-31 + +**学号**:2444310213 +**组员**:组员 4 / 组员 A(订单支付 · 评价 · 浏览历史) +**分支**:`feature/browse-history` + +--- + +## 今日完成 + +1. **浏览历史联调跟进** + - 再次自测 `POST/GET/DELETE /browse-history`:同一商品重复访问只保留一条、列表按最近访问排序。 + - 核对 PC/手机历史页移除、清空交互与空列表提示。 + - 跟进个人中心入口与建议路由 `/pc/history`、`/m/history` 的接入情况,记录待组长合入后的回归点。 + +2. **订单支付链路回归** + - 走查待支付 → 模拟支付成功后的状态展示。 + - 验证已取消订单不可再支付、待收货确认收货按钮显隐是否符合状态机。 + - 记录详情页跳转支付、返回列表后的刷新表现,准备下周集中修交互细节。 + +3. **评价模块衔接** + - 梳理评价后端入参与错误码(未完成订单、非本人订单商品、重复评价、评分越界)。 + - 草拟评价页联调清单:订单详情入口、提交成功后的提示与列表刷新,待前端页面任务排上后对接。 + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 历史路由仍未在公共 router 落地,个人中心完整跳转受阻 | 进行中 | 继续用建议路径预留;催办/等待组长统一接入 | +| 弱网下详情静默 POST 偶发失败 | 已处理策略 | 保持 `.catch` 静默,不打断浏览;必要时加本地重试另议 | +| 评价页未开工,后端暂无法端到端演示 | 未解决 | 明日继续准备联调用例与假数据构造步骤 | + +--- + +## 明日计划 + +1. 若路由已接入:完成浏览历史 PC/H5 端到端回归(记录 → 列表 → 删除 → 清空)。 +2. 继续订单支付主链路抽测,整理缺陷清单给组长汇总。 +3. 评价:根据任务安排对接页面或补充接口文档说明。 +4. 准备周末演示用账号与操作步骤(下单/支付/浏览历史)。 + +--- + +## 今日工时 + +约 4 小时 diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..e999356 --- /dev/null +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260731-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,48 @@ +# 日报 - 吴松青 - 2026-07-31 + +**学号**:2444310213 +**组员**:组员 4 / 组员 A(订单支付 · 评价 · 浏览历史) +**分支**:`feature/browse-history` + +--- + +## 今日完成 + +1. **浏览历史联调跟进** + - 再次自测 `POST/GET/DELETE /browse-history`:同一商品重复访问只保留一条、列表按最近访问排序。 + - 核对 PC/手机历史页移除、清空交互与空列表提示。 + - 跟进个人中心入口与建议路由 `/pc/history`、`/m/history` 的接入情况,记录待组长合入后的回归点。 + +2. **订单支付链路回归** + - 走查待支付 → 模拟支付成功后的状态展示。 + - 验证已取消订单不可再支付、待收货确认收货按钮显隐是否符合状态机。 + - 记录详情页跳转支付、返回列表后的刷新表现,准备下周集中修交互细节。 + +3. **评价模块衔接** + - 梳理评价后端入参与错误码(未完成订单、非本人订单商品、重复评价、评分越界)。 + - 草拟评价页联调清单:订单详情入口、提交成功后的提示与列表刷新,待前端页面任务排上后对接。 + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 历史路由仍未在公共 router 落地,个人中心完整跳转受阻 | 进行中 | 继续用建议路径预留;催办/等待组长统一接入 | +| 弱网下详情静默 POST 偶发失败 | 已处理策略 | 保持 `.catch` 静默,不打断浏览;必要时加本地重试另议 | +| 评价页未开工,后端暂无法端到端演示 | 未解决 | 明日继续准备联调用例与假数据构造步骤 | + +--- + +## 明日计划 + +1. 若路由已接入:完成浏览历史 PC/H5 端到端回归(记录 → 列表 → 删除 → 清空)。 +2. 继续订单支付主链路抽测,整理缺陷清单给组长汇总。 +3. 评价:根据任务安排对接页面或补充接口文档说明。 +4. 准备周末演示用账号与操作步骤(下单/支付/浏览历史)。 + +--- + +## 今日工时 + +约 4 小时 -- Gitee From 65ca84706db995f819e95e2aa3a5f0fefd892c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Mon, 3 Aug 2026 21:49:06 +0800 Subject: [PATCH 15/21] =?UTF-8?q?docs(reports):=20=E6=8F=90=E4=BA=A4=20202?= =?UTF-8?q?60803=20=E5=90=B4=E6=9D=BE=E9=9D=92=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录主链路抽测、浏览历史回归与评价/演示准备,任务持续推进。 --- ...3-\345\220\264\346\235\276\351\235\222.md" | 47 +++++++++++++++++++ ...3-\345\220\264\346\235\276\351\235\222.md" | 47 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 "eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" create mode 100644 "reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" diff --git "a/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..348ec69 --- /dev/null +++ "b/eshop-release-v1.0/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,47 @@ +# 日报 - 吴松青 - 2026-08-03 + +**学号**:2444310213 +**组员**:组员 4 / 组员 A(订单支付 · 评价 · 浏览历史) +**分支**:`feature/browse-history` + +--- + +## 今日完成 + +1. **主链路抽测与问题记录** + - 抽测登录后商品浏览 → 加购 → 下单 → 模拟支付完整路径,关注订单状态是否及时刷新。 + - 复查已取消订单支付拦截、确认收货按钮状态限制,补充个人缺陷备注便于集中修复。 + +2. **浏览历史回归** + - 回归 `POST/GET/DELETE` 四接口:重复访问同一商品记录条数、列表排序、单条删除与清空。 + - 检查详情页静默记录在登录/未登录两种场景下是否影响正常浏览与下单。 + - 继续跟进 `/pc/history`、`/m/history` 路由接入后的入口联调计划。 + +3. **评价与演示准备** + - 整理评价接口异常码说明(订单未完成、非本人、重复评价、评分/内容校验),方便前端对接。 + - 准备演示用操作步骤草稿:下单支付 + 浏览历史展示,待组内统一演示脚本。 + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 公共路由尚未完全合入浏览历史路径,个人中心入口联调不完整 | 进行中 | 保持建议路径;与组长确认本周接入节点 | +| 演示账号与预置数据需统一,避免现场无库存/无地址 | 进行中 | 与组长对齐演示数据与账号;明日继续补 checklist | +| 评价前端页进度依赖任务排期 | 未解决 | 后端就绪,等待页面任务启动后联调 | + +--- + +## 明日计划 + +1. 继续订单支付与浏览历史交叉回归,输出简要缺陷表。 +2. 路由接入后立即做 PC/H5 历史页端到端验证。 +3. 按组内安排推进评价页对接或演示脚本完善。 +4. 配合全组准备阶段验收/演示材料。 + +--- + +## 今日工时 + +约 4 小时 diff --git "a/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" new file mode 100644 index 0000000..348ec69 --- /dev/null +++ "b/reports/daily/\345\220\264\346\235\276\351\235\222_2444310213/20260803-\345\220\264\346\235\276\351\235\222.md" @@ -0,0 +1,47 @@ +# 日报 - 吴松青 - 2026-08-03 + +**学号**:2444310213 +**组员**:组员 4 / 组员 A(订单支付 · 评价 · 浏览历史) +**分支**:`feature/browse-history` + +--- + +## 今日完成 + +1. **主链路抽测与问题记录** + - 抽测登录后商品浏览 → 加购 → 下单 → 模拟支付完整路径,关注订单状态是否及时刷新。 + - 复查已取消订单支付拦截、确认收货按钮状态限制,补充个人缺陷备注便于集中修复。 + +2. **浏览历史回归** + - 回归 `POST/GET/DELETE` 四接口:重复访问同一商品记录条数、列表排序、单条删除与清空。 + - 检查详情页静默记录在登录/未登录两种场景下是否影响正常浏览与下单。 + - 继续跟进 `/pc/history`、`/m/history` 路由接入后的入口联调计划。 + +3. **评价与演示准备** + - 整理评价接口异常码说明(订单未完成、非本人、重复评价、评分/内容校验),方便前端对接。 + - 准备演示用操作步骤草稿:下单支付 + 浏览历史展示,待组内统一演示脚本。 + +--- + +## 遇到的问题 + +| 问题描述 | 解决状态 | 解决方式/求助对象 | +|----------|----------|-------------------| +| 公共路由尚未完全合入浏览历史路径,个人中心入口联调不完整 | 进行中 | 保持建议路径;与组长确认本周接入节点 | +| 演示账号与预置数据需统一,避免现场无库存/无地址 | 进行中 | 与组长对齐演示数据与账号;明日继续补 checklist | +| 评价前端页进度依赖任务排期 | 未解决 | 后端就绪,等待页面任务启动后联调 | + +--- + +## 明日计划 + +1. 继续订单支付与浏览历史交叉回归,输出简要缺陷表。 +2. 路由接入后立即做 PC/H5 历史页端到端验证。 +3. 按组内安排推进评价页对接或演示脚本完善。 +4. 配合全组准备阶段验收/演示材料。 + +--- + +## 今日工时 + +约 4 小时 -- Gitee From 08ccacd31506916aa2602962c46d85cf46e50616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=9D=BE=E9=9D=92?= <3374776986@qq.com> Date: Mon, 3 Aug 2026 22:22:48 +0800 Subject: [PATCH 16/21] =?UTF-8?q?merge:=20=E8=9E=8D=E5=90=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=20eshop-release-v1.0.zip=20=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步组长最新全量:运营看板趋势/热销、用户管理增强、评价与浏览历史更新、门户/商家端入口、V8 索引与文档等。保留本地 .env、吴松青日报与本机重启脚本。 --- .gitignore | 4 + eshop-release-v1.0/.env.example | 5 + eshop-release-v1.0/README.md | 13 +- .../admin/dashboard/DashboardController.java | 15 +- .../admin/dashboard/DashboardMapper.java | 41 +- .../admin/dashboard/DashboardService.java | 61 ++ .../admin/dashboard/SalesDayAggregate.java | 34 + .../dashboard/SalesTrendPointResponse.java | 10 + .../admin/dashboard/SalesTrendResponse.java | 9 + .../admin/dashboard/TopProductAggregate.java | 42 + .../dashboard/TopProductItemResponse.java | 11 + .../admin/dashboard/TopProductsResponse.java | 9 + .../inventory/InventoryAlertController.java | 2 +- .../operationlog/OperationLogController.java | 2 +- .../com/eshop/backend/auth/AuthService.java | 7 +- .../backend/auth/dto/RegisterRequest.java | 4 +- .../browse/BrowseHistoryController.java | 33 +- .../browse/BrowseHistoryProductResponse.java | 15 + .../backend/browse/BrowseHistoryService.java | 148 +-- .../backend/browse/ProductBrowseHistory.java | 4 - .../browse/ProductBrowseHistoryMapper.java | 33 +- .../com/eshop/backend/common/ErrorCode.java | 2 + .../backend/order/AdminOrderController.java | 6 +- .../com/eshop/backend/order/OrderService.java | 5 +- .../eshop/backend/review/ProductReview.java | 1 + .../backend/review/ProductReviewMapper.java | 22 +- .../eshop/backend/review/ReviewImages.java | 66 ++ .../eshop/backend/review/ReviewService.java | 88 +- .../review/dto/AdminReviewResponse.java | 4 +- .../review/dto/CreateReviewRequest.java | 6 +- .../backend/review/dto/MyReviewResponse.java | 4 +- .../review/dto/ProductReviewResponse.java | 4 +- .../backend/review/dto/ProductReviewRow.java | 155 +++ .../backend/security/SecurityConfig.java | 13 +- .../backend/user/AdminUserController.java | 24 + .../eshop/backend/user/AdminUserService.java | 96 +- .../user/dto/AdminUserSummaryResponse.java | 10 + .../user/dto/ManagedUserCreateRequest.java | 28 + .../backend/user/dto/UserRoleRequest.java | 9 + .../src/main/resources/application.yml | 2 + .../migration/V8__index_user_role_status.sql | 1 + .../eshop/backend/ApiContractFreezeTest.java | 2 +- .../backend/AuthSecurityIntegrationTest.java | 75 +- .../backend/BrowseHistoryIntegrationTest.java | 145 +-- .../backend/DashboardIntegrationTest.java | 29 +- .../MerchantAccountIntegrationTest.java | 198 ++++ .../eshop/backend/ReviewIntegrationTest.java | 59 +- .../src/test/resources/schema-test.sql | 1 + eshop-release-v1.0/docker-compose.yml | 1 + ...24\347\244\272\350\204\232\346\234\254.md" | 4 +- ...5\217\243\345\206\273\347\273\223-v1.2.md" | 2 +- ...34\345\217\260\350\256\276\350\256\241.md" | 37 + ...45\345\217\243\350\256\276\350\256\241.md" | 19 +- ...56\345\272\223\350\256\276\350\256\241.md" | 6 +- ...0-\351\252\214\346\224\266\347\224\250.md" | 18 + ...13\350\257\225\346\212\245\345\221\212.md" | 94 +- eshop-release-v1.0/frontend/nginx.conf | 4 - eshop-release-v1.0/frontend/src/api/admin.js | 16 + .../frontend/src/api/browseHistory.js | 20 +- eshop-release-v1.0/frontend/src/api/file.js | 11 + eshop-release-v1.0/frontend/src/api/http.js | 9 +- .../frontend/src/components/AuthForm.vue | 125 ++- .../components/review/ProductReviewList.vue | 33 + .../components/review/ReviewHistoryList.vue | 33 + .../frontend/src/layouts/AdminLayout.vue | 34 +- .../frontend/src/layouts/MobileShopLayout.vue | 27 +- .../frontend/src/layouts/PcShopLayout.vue | 4 +- .../src/layouts/PlatformAdminLayout.vue | 64 ++ eshop-release-v1.0/frontend/src/main.js | 6 + .../frontend/src/router/index.js | 101 +- .../frontend/src/stores/auth.js | 2 + .../frontend/src/styles/global.css | 142 +++ .../frontend/src/styles/portal-theme.css | 892 ++++++++++++++++++ .../frontend/src/views/PortalView.vue | 688 ++++++++++++++ .../src/views/admin/AdminHomeView.vue | 186 +++- .../src/views/admin/AdminLoginView.vue | 9 +- .../src/views/admin/AdminOrdersView.vue | 4 +- .../src/views/admin/AdminReviewsView.vue | 35 + .../src/views/admin/AdminUsersView.vue | 409 ++++++-- .../src/views/admin/OperationLogsView.vue | 1 - .../views/mobile/MobileBrowseHistoryView.vue | 321 +++++++ .../src/views/mobile/MobileCartView.vue | 24 +- .../src/views/mobile/MobileLoginView.vue | 4 +- .../views/mobile/MobileOrderDetailView.vue | 67 +- .../views/mobile/MobileProductDetailView.vue | 20 +- .../src/views/mobile/MobileProfileView.vue | 9 +- .../src/views/pc/PcBrowseHistoryView.vue | 233 +++++ .../frontend/src/views/pc/PcLoginView.vue | 4 +- .../src/views/pc/PcOrderDetailView.vue | 68 +- .../src/views/pc/PcProductDetailView.vue | 22 +- .../frontend/src/views/pc/PcProfileView.vue | 66 +- .../src/views/seller/SellerLoginView.vue | 15 + eshop-release-v1.0/scripts/api-smoke-test.ps1 | 157 ++- 93 files changed, 5071 insertions(+), 532 deletions(-) create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesDayAggregate.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendPointResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductAggregate.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductItemResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductsResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryProductResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewImages.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewRow.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/AdminUserSummaryResponse.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/ManagedUserCreateRequest.java create mode 100644 eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/UserRoleRequest.java create mode 100644 eshop-release-v1.0/backend/src/main/resources/db/migration/V8__index_user_role_status.sql create mode 100644 eshop-release-v1.0/backend/src/test/java/com/eshop/backend/MerchantAccountIntegrationTest.java create mode 100644 "eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\345\271\263\345\217\260\350\264\246\345\217\267\344\270\216\345\225\206\345\256\266\345\267\245\344\275\234\345\217\260\350\256\276\350\256\241.md" create mode 100644 "eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\345\212\237\350\203\275\345\256\214\346\210\220\345\272\246\345\257\271\347\205\247\350\241\250-\351\252\214\346\224\266\347\224\250.md" create mode 100644 eshop-release-v1.0/frontend/src/api/file.js create mode 100644 eshop-release-v1.0/frontend/src/layouts/PlatformAdminLayout.vue create mode 100644 eshop-release-v1.0/frontend/src/styles/portal-theme.css create mode 100644 eshop-release-v1.0/frontend/src/views/PortalView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/mobile/MobileBrowseHistoryView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/pc/PcBrowseHistoryView.vue create mode 100644 eshop-release-v1.0/frontend/src/views/seller/SellerLoginView.vue diff --git a/.gitignore b/.gitignore index fe87004..6138a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ eshop-release-v1.0/backend-restart.log eshop-release-v1.0/backend-restart.log.err eshop-release-v1.0/scripts/_run-backend-tmp.bat eshop-release-v1.0/scripts/_kill-8080.ps1 + +_leader_zip_latest/ +_merge_backup_user/ + diff --git a/eshop-release-v1.0/.env.example b/eshop-release-v1.0/.env.example index da3a55f..f88a47e 100644 --- a/eshop-release-v1.0/.env.example +++ b/eshop-release-v1.0/.env.example @@ -8,12 +8,17 @@ MYSQL_PASSWORD=eshop123 MYSQL_ROOT_PASSWORD=root123456 # Base64-encoded development secret. Generate a new value before deployment. JWT_SECRET=ZXNob3AtZGV2ZWxvcG1lbnQtc2VjcmV0LWtleS1jaGFuZ2UtbWUtMjAyNg== +# Comma-separated browser origins allowed to call the backend API. +CORS_ALLOWED_ORIGIN_PATTERNS=http://localhost:*,http://127.0.0.1:*,https://eshop.zhuyiyuan9.top # Development-only administrator. Disable this after the first successful startup. BOOTSTRAP_ADMIN_ENABLED=true BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_PASSWORD=admin123 BOOTSTRAP_ADMIN_NICKNAME=系统管理员 # C03: unpaid orders are canceled automatically after this timeout. +# Daily/demo tip: set ORDER_TIMEOUT_MINUTES=2 and ORDER_TIMEOUT_SCAN_DELAY_MS=15000 +# for a short live demo, then restore minutes=30 and scan-delay=60000 afterwards. +# See docs/02-设计文档/C03演示脚本.md ORDER_TIMEOUT_ENABLED=true ORDER_TIMEOUT_MINUTES=30 ORDER_TIMEOUT_BATCH_SIZE=100 diff --git a/eshop-release-v1.0/README.md b/eshop-release-v1.0/README.md index e385603..1606050 100644 --- a/eshop-release-v1.0/README.md +++ b/eshop-release-v1.0/README.md @@ -10,11 +10,12 @@ | 部分 | 当前状态 | 已完成功能 | |------|----------|------------| -| 后端 v1.0 + 扩展 | ✅ 已完成并冻结 | 登录鉴权、用户、分类、商品、SKU、购物车、地址、订单、模拟支付、收藏、评价/评分统计、评价审核、热销排行、库存预警、后台统计与操作日志 | +| 后端 v1.0 + 扩展 | ✅ 已完成并冻结 | 登录鉴权、买家/卖家角色管理、分类、商品、SKU、购物车、地址、订单、模拟支付、收藏、评价/评分统计、评价审核、热销排行、库存预警、后台统计与操作日志 | | PC 消费者端 | ✅ 已完成 | 商品、热销榜、收藏、购物车、地址、下单、支付、订单、商品评价/评分分布与个人资料完整闭环 | -| Web 管理端 | ✅ 已完成 | 数据看板、热销统计、用户、分类、商品与 SKU、库存预警、评价审核、图片、上下架、订单 CSV 导出、发货和操作日志 | +| Web 管理端 | ✅ 已完成 | 平台管理员管理买家/卖家账号;商家复用原后台完成看板、分类、商品/SKU、库存、评价、订单发货和操作日志 | | 手机 H5 端 | ✅ 已完成 | Vue 3 + Vant 登录、商品、热销榜、收藏、购物车、地址、下单、支付、订单、评价和个人中心 | -| 自动化验证 | ✅ 已通过 | 后端 19 项测试、前端 2172 模块生产构建、Docker/MySQL 86 项 API 冒烟 | +| 统一视觉主题 | ✅ 已完成 | PC、手机 H5、商家、平台和登录注册页面统一为入口页的深蓝玻璃卡片风格 | +| 自动化验证 | ✅ 已通过 | 后端 22 项测试、前端 2196 模块生产构建、Docker/MySQL 126 项 API 冒烟、Flyway V1~V8 | ## 二、小组成员与分工(模块负责制) @@ -106,8 +107,10 @@ docker compose ps 容器健康后可访问: +- 统一入口(推荐): - PC 消费者端: -- Web 管理端: +- 平台管理入口(管理买家/卖家): +- 商家工作台(复用原后台业务能力): - 手机 H5 端: - 后端健康检查: @@ -119,7 +122,7 @@ docker compose ps .\scripts\api-smoke-test.ps1 ``` -测试脚本会创建独立测试数据,完成注册、商品/SKU、购物车、下单、支付、发货、确认收货、评价审核、热销排行和取消订单等流程,并在结束后自动清理测试数据。 +测试脚本会创建独立测试数据,完成买家/商家区分注册、商家审核启用、账号权限、商品/SKU、购物车、下单、支付、商家发货、买家确认收货、评价审核、热销排行和取消订单等流程,并在结束后自动清理测试数据。 ### 4. 停止项目 diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java index 26899bd..d29de74 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardController.java @@ -5,12 +5,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/admin/dashboard") @RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") +@PreAuthorize("hasAnyRole('ADMIN', 'SELLER')") public class DashboardController { private final DashboardService dashboardService; @@ -18,4 +19,16 @@ public class DashboardController { public ApiResponse summary() { return ApiResponse.success(dashboardService.summary()); } + + @GetMapping("/sales-trend") + public ApiResponse salesTrend( + @RequestParam(required = false, defaultValue = "7") Integer days) { + return ApiResponse.success(dashboardService.salesTrend(days)); + } + + @GetMapping("/top-products") + public ApiResponse topProducts( + @RequestParam(required = false, defaultValue = "10") Integer limit) { + return ApiResponse.success(dashboardService.topProducts(limit)); + } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java index e442b90..ca112ae 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardMapper.java @@ -4,10 +4,12 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; public interface DashboardMapper { - @Select("SELECT COUNT(*) FROM sys_user") + @Select("SELECT COUNT(*) FROM sys_user WHERE role = 'USER'") long countUsers(); @Select("SELECT COUNT(*) FROM product") @@ -38,4 +40,41 @@ public interface DashboardMapper { WHERE status IN ('PAID', 'SHIPPED', 'COMPLETED') """) BigDecimal sumPaidSalesAmount(); + + @Select(""" + SELECT + CAST(created_at AS DATE) AS sale_date, + COUNT(*) AS order_count, + COALESCE(SUM(total_amount), 0) AS sales_amount + FROM orders + WHERE status IN ('PAID', 'SHIPPED', 'COMPLETED') + AND created_at >= #{from} + AND created_at < #{to} + GROUP BY CAST(created_at AS DATE) + ORDER BY sale_date ASC + """) + List selectSalesByDay(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + + @Select(""" + SELECT + product.id AS product_id, + product.name AS product_name, + sold.sold_quantity AS sold_quantity, + sold.sales_amount AS sales_amount + FROM ( + SELECT + item.product_id, + SUM(item.quantity) AS sold_quantity, + SUM(item.subtotal) AS sales_amount + FROM order_item item + INNER JOIN orders shop_order ON shop_order.id = item.order_id + WHERE shop_order.status IN ('PAID', 'SHIPPED', 'COMPLETED') + GROUP BY item.product_id + ) sold + INNER JOIN product ON product.id = sold.product_id + WHERE product.status = 'ON_SALE' + ORDER BY sold.sold_quantity DESC, sold.sales_amount DESC, product.id DESC + LIMIT #{limit} + """) + List selectTopProducts(@Param("limit") int limit); } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java index 62dbe36..1346252 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/DashboardService.java @@ -1,11 +1,19 @@ package com.eshop.backend.admin.dashboard; +import com.eshop.backend.common.BusinessException; +import com.eshop.backend.common.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -29,4 +37,57 @@ public class DashboardService { paidSalesAmount == null ? BigDecimal.ZERO : paidSalesAmount, LocalDateTime.now()); } + + @Transactional(readOnly = true) + public SalesTrendResponse salesTrend(Integer days) { + int range = days == null ? 7 : days; + if (range < 1 || range > 30) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusDays(range - 1L); + LocalDateTime from = startDate.atStartOfDay(); + LocalDateTime to = endDate.plusDays(1).atStartOfDay(); + + List aggregates = dashboardMapper.selectSalesByDay(from, to); + Map byDate = new HashMap<>(); + if (aggregates != null) { + for (SalesDayAggregate aggregate : aggregates) { + if (aggregate.getSaleDate() != null) { + byDate.put(aggregate.getSaleDate(), aggregate); + } + } + } + + List points = new ArrayList<>(range); + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + SalesDayAggregate aggregate = byDate.get(date); + long orderCount = aggregate == null ? 0L : aggregate.getOrderCount(); + BigDecimal amount = aggregate == null || aggregate.getSalesAmount() == null + ? BigDecimal.ZERO + : aggregate.getSalesAmount(); + points.add(new SalesTrendPointResponse(date.toString(), orderCount, amount)); + } + return new SalesTrendResponse(range, points); + } + + @Transactional(readOnly = true) + public TopProductsResponse topProducts(Integer limit) { + int size = limit == null ? 10 : limit; + if (size < 1 || size > 20) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + List rows = dashboardMapper.selectTopProducts(size); + List items = rows == null + ? List.of() + : rows.stream() + .map(row -> new TopProductItemResponse( + row.getProductId(), + row.getProductName(), + row.getSoldQuantity(), + row.getSalesAmount() == null ? BigDecimal.ZERO : row.getSalesAmount())) + .toList(); + return new TopProductsResponse(size, items); + } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesDayAggregate.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesDayAggregate.java new file mode 100644 index 0000000..88f94a8 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesDayAggregate.java @@ -0,0 +1,34 @@ +package com.eshop.backend.admin.dashboard; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public class SalesDayAggregate { + private LocalDate saleDate; + private long orderCount; + private BigDecimal salesAmount; + + public LocalDate getSaleDate() { + return saleDate; + } + + public void setSaleDate(LocalDate saleDate) { + this.saleDate = saleDate; + } + + public long getOrderCount() { + return orderCount; + } + + public void setOrderCount(long orderCount) { + this.orderCount = orderCount; + } + + public BigDecimal getSalesAmount() { + return salesAmount; + } + + public void setSalesAmount(BigDecimal salesAmount) { + this.salesAmount = salesAmount; + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendPointResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendPointResponse.java new file mode 100644 index 0000000..3df2f0e --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendPointResponse.java @@ -0,0 +1,10 @@ +package com.eshop.backend.admin.dashboard; + +import java.math.BigDecimal; + +public record SalesTrendPointResponse( + String date, + long orderCount, + BigDecimal salesAmount +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendResponse.java new file mode 100644 index 0000000..c85b031 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/SalesTrendResponse.java @@ -0,0 +1,9 @@ +package com.eshop.backend.admin.dashboard; + +import java.util.List; + +public record SalesTrendResponse( + int days, + List points +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductAggregate.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductAggregate.java new file mode 100644 index 0000000..b8b547f --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductAggregate.java @@ -0,0 +1,42 @@ +package com.eshop.backend.admin.dashboard; + +import java.math.BigDecimal; + +public class TopProductAggregate { + private Long productId; + private String productName; + private long soldQuantity; + private BigDecimal salesAmount; + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public long getSoldQuantity() { + return soldQuantity; + } + + public void setSoldQuantity(long soldQuantity) { + this.soldQuantity = soldQuantity; + } + + public BigDecimal getSalesAmount() { + return salesAmount; + } + + public void setSalesAmount(BigDecimal salesAmount) { + this.salesAmount = salesAmount; + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductItemResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductItemResponse.java new file mode 100644 index 0000000..2bb5c34 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductItemResponse.java @@ -0,0 +1,11 @@ +package com.eshop.backend.admin.dashboard; + +import java.math.BigDecimal; + +public record TopProductItemResponse( + Long productId, + String productName, + long soldQuantity, + BigDecimal salesAmount +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductsResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductsResponse.java new file mode 100644 index 0000000..6f66ee1 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/dashboard/TopProductsResponse.java @@ -0,0 +1,9 @@ +package com.eshop.backend.admin.dashboard; + +import java.util.List; + +public record TopProductsResponse( + int limit, + List items +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/inventory/InventoryAlertController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/inventory/InventoryAlertController.java index 2592c67..202148f 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/inventory/InventoryAlertController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/inventory/InventoryAlertController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/admin/inventory") @RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") +@PreAuthorize("hasAnyRole('ADMIN', 'SELLER')") public class InventoryAlertController { private final InventoryAlertService inventoryAlertService; diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/operationlog/OperationLogController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/operationlog/OperationLogController.java index 5d2134d..88a3f4b 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/operationlog/OperationLogController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/admin/operationlog/OperationLogController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/admin/logs") @RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") +@PreAuthorize("hasAnyRole('ADMIN', 'SELLER')") public class OperationLogController { private final OperationLogService operationLogService; diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/AuthService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/AuthService.java index f20201c..4c66e6f 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/AuthService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/AuthService.java @@ -36,6 +36,7 @@ public class AuthService { @Transactional public void register(RegisterRequest request) { validatePasswordLength(request.password()); + String role = request.role() == null ? "USER" : request.role(); Long count = userMapper.selectCount(new LambdaQueryWrapper() .eq(SysUser::getUsername, request.username())); if (count != null && count > 0) { @@ -45,8 +46,10 @@ public class AuthService { user.setUsername(request.username()); user.setPasswordHash(passwordEncoder.encode(request.password())); user.setNickname(request.nickname()); - user.setRole("USER"); - user.setStatus("ENABLED"); + user.setRole(role); + // Buyers can use the shop immediately. Merchant self-registration requires platform approval + // before the account can access shared catalog, inventory and order data. + user.setStatus("SELLER".equals(role) ? "DISABLED" : "ENABLED"); userMapper.insert(user); } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/dto/RegisterRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/dto/RegisterRequest.java index 92c7b7a..2ab4bd1 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/dto/RegisterRequest.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/auth/dto/RegisterRequest.java @@ -1,11 +1,13 @@ package com.eshop.backend.auth.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record RegisterRequest( @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 50, message = "用户名长度应为 3 到 50 位") String username, @NotBlank(message = "密码不能为空") @Size(min = 6, max = 72, message = "密码长度应为 6 到 72 位") String password, - @NotBlank(message = "昵称不能为空") @Size(max = 50, message = "昵称不能超过 50 位") String nickname + @NotBlank(message = "昵称不能为空") @Size(max = 50, message = "昵称不能超过 50 位") String nickname, + @Pattern(regexp = "USER|SELLER", message = "注册身份只能是买家或商家") String role ) { } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java index 422c292..f88b059 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryController.java @@ -1,6 +1,5 @@ package com.eshop.backend.browse; -import com.eshop.backend.browse.dto.BrowseHistoryResponse; import com.eshop.backend.common.ApiResponse; import com.eshop.backend.common.PageResult; import com.eshop.backend.security.LoginUser; @@ -14,47 +13,39 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - @RestController @RequestMapping("/browse-history") @RequiredArgsConstructor public class BrowseHistoryController { private final BrowseHistoryService browseHistoryService; - /** - * 用户进入商品详情时静默记录(幂等更新 browsed_at)。 - */ @PostMapping("/{productId}") - public ApiResponse record( + public ApiResponse record( @AuthenticationPrincipal LoginUser user, @PathVariable Long productId) { - return ApiResponse.success(browseHistoryService.recordVisit(user, productId)); + browseHistoryService.record(user.getUserId(), productId); + return ApiResponse.success(); } - /** - * 当前用户的浏览历史分页,按最近访问倒序。 - */ @GetMapping - public ApiResponse> page( + public ApiResponse> page( @AuthenticationPrincipal LoginUser user, @RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "20") long size) { - return ApiResponse.success(browseHistoryService.pageUserHistory(user, current, size)); + return ApiResponse.success(browseHistoryService.page(user.getUserId(), current, size)); } @DeleteMapping("/{productId}") - public ApiResponse> deleteOne( + public ApiResponse remove( @AuthenticationPrincipal LoginUser user, @PathVariable Long productId) { - browseHistoryService.deleteOne(user, productId); - return ApiResponse.success(Map.of("deleted", true, "productId", productId)); + browseHistoryService.remove(user.getUserId(), productId); + return ApiResponse.success(); } @DeleteMapping - public ApiResponse> clear( - @AuthenticationPrincipal LoginUser user) { - int count = browseHistoryService.clearAll(user); - return ApiResponse.success(Map.of("deleted", true, "count", count)); + public ApiResponse clear(@AuthenticationPrincipal LoginUser user) { + browseHistoryService.clear(user.getUserId()); + return ApiResponse.success(); } -} \ No newline at end of file +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryProductResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryProductResponse.java new file mode 100644 index 0000000..4153c1e --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryProductResponse.java @@ -0,0 +1,15 @@ +package com.eshop.backend.browse; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record BrowseHistoryProductResponse( + Long productId, + String name, + String subtitle, + String mainImage, + BigDecimal minPrice, + Long totalStock, + LocalDateTime browsedAt +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java index dacddcc..096185d 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/BrowseHistoryService.java @@ -1,155 +1,97 @@ package com.eshop.backend.browse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.eshop.backend.browse.dto.BrowseHistoryResponse; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.eshop.backend.catalog.entity.Product; import com.eshop.backend.catalog.mapper.ProductMapper; import com.eshop.backend.common.BusinessException; import com.eshop.backend.common.ErrorCode; import com.eshop.backend.common.PageResult; -import com.eshop.backend.security.LoginUser; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class BrowseHistoryService { - private final ProductBrowseHistoryMapper browseHistoryMapper; private final ProductMapper productMapper; - /** - * 用户进入商品详情时静默记录。 - * 同一用户对同一商品只保留一条记录,并幂等刷新 browsed_at。 - */ @Transactional - public BrowseHistoryResponse recordVisit(LoginUser user, Long productId) { - requireLogin(user); - if (productId == null || productId <= 0) { - throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); - } - Product product = productMapper.selectById(productId); - if (product == null) { - throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); - } - + public void record(Long userId, Long productId) { + requireOnSaleProduct(productId); LocalDateTime now = LocalDateTime.now(); - ProductBrowseHistory existing = findUserProduct(user.getUserId(), productId); + ProductBrowseHistory existing = browseHistoryMapper.selectOne(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, userId) + .eq(ProductBrowseHistory::getProductId, productId) + .last("LIMIT 1")); if (existing != null) { existing.setBrowsedAt(now); browseHistoryMapper.updateById(existing); - return toResponse(existing, product); + return; } ProductBrowseHistory history = new ProductBrowseHistory(); - history.setUserId(user.getUserId()); + history.setUserId(userId); history.setProductId(productId); history.setBrowsedAt(now); try { browseHistoryMapper.insert(history); - } catch (DuplicateKeyException ex) { - // 并发下唯一键冲突:再查一次并刷新时间 - existing = findUserProduct(user.getUserId(), productId); - if (existing == null) { - throw ex; + } catch (DuplicateKeyException exception) { + ProductBrowseHistory concurrent = browseHistoryMapper.selectOne(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, userId) + .eq(ProductBrowseHistory::getProductId, productId) + .last("LIMIT 1")); + if (concurrent != null) { + concurrent.setBrowsedAt(now); + browseHistoryMapper.updateById(concurrent); + } else { + log.debug("并发写入浏览历史后未找到记录 userId={}, productId={}", userId, productId); } - existing.setBrowsedAt(now); - browseHistoryMapper.updateById(existing); - return toResponse(existing, product); } - return toResponse(history, product); } - public PageResult pageUserHistory(LoginUser user, long current, long size) { - requireLogin(user); - if (current < 1) { - current = 1; - } - if (size <= 0 || size > 100) { - size = 20; - } - - long offset = (current - 1) * size; - LambdaQueryWrapper countWrapper = new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId()); - long total = browseHistoryMapper.selectCount(countWrapper); - - List records = browseHistoryMapper.selectList( - new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId()) - .orderByDesc(ProductBrowseHistory::getBrowsedAt) - .last("LIMIT " + size + " OFFSET " + offset) - ); - - if (records.isEmpty()) { - return new PageResult<>(current, size, total, Collections.emptyList()); - } - - List productIds = records.stream() - .map(ProductBrowseHistory::getProductId) - .filter(Objects::nonNull) - .distinct() - .toList(); - Map productMap = productIds.isEmpty() - ? Collections.emptyMap() - : productMapper.selectBatchIds(productIds).stream() - .collect(Collectors.toMap(Product::getId, Function.identity(), (a, b) -> a)); - - List responses = records.stream() - .map(record -> toResponse(record, productMap.get(record.getProductId()))) - .toList(); - // PageResult 字段顺序:current, size, total, records - return new PageResult<>(current, size, total, responses); + @Transactional(readOnly = true) + public PageResult page(Long userId, long current, long size) { + Page page = new Page<>( + Math.max(current, 1), + Math.min(Math.max(size, 1), 50)); + return PageResult.from(browseHistoryMapper.selectBrowsePage(page, userId)); } @Transactional - public void deleteOne(LoginUser user, Long productId) { - requireLogin(user); - if (productId == null || productId <= 0) { - throw new BusinessException(ErrorCode.NOT_FOUND); - } + public void remove(Long userId, Long productId) { + requirePositiveProductId(productId); browseHistoryMapper.delete(new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId()) + .eq(ProductBrowseHistory::getUserId, userId) .eq(ProductBrowseHistory::getProductId, productId)); } @Transactional - public int clearAll(LoginUser user) { - requireLogin(user); - return browseHistoryMapper.delete(new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, user.getUserId())); - } - - private ProductBrowseHistory findUserProduct(Long userId, Long productId) { - return browseHistoryMapper.selectOne(new LambdaQueryWrapper() - .eq(ProductBrowseHistory::getUserId, userId) - .eq(ProductBrowseHistory::getProductId, productId) - .last("LIMIT 1")); + public void clear(Long userId) { + browseHistoryMapper.delete(new LambdaQueryWrapper() + .eq(ProductBrowseHistory::getUserId, userId)); } - private void requireLogin(LoginUser user) { - if (user == null || user.getUserId() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); + private void requireOnSaleProduct(Long productId) { + requirePositiveProductId(productId); + Product product = productMapper.selectById(productId); + if (product == null) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); + } + if (!"ON_SALE".equals(product.getStatus())) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_ON_SALE); } } - private BrowseHistoryResponse toResponse(ProductBrowseHistory history, Product product) { - return new BrowseHistoryResponse( - history.getId(), - history.getProductId(), - product == null ? null : product.getName(), - product == null ? null : product.getMainImage(), - history.getBrowsedAt() - ); + private void requirePositiveProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java index fa0e50e..d13b36c 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistory.java @@ -7,10 +7,6 @@ import lombok.Data; import java.time.LocalDateTime; -/** - * 对应 Flyway V6:product_browse_history。 - * 同一用户同一商品仅一条,刷新 browsed_at。 - */ @Data @TableName("product_browse_history") public class ProductBrowseHistory { diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java index a9513e0..bd648ac 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/browse/ProductBrowseHistoryMapper.java @@ -1,6 +1,37 @@ package com.eshop.backend.browse; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; public interface ProductBrowseHistoryMapper extends BaseMapper { -} \ No newline at end of file + + @Select(""" + SELECT + history.product_id AS product_id, + product.name, + product.subtitle, + product.main_image AS main_image, + MIN(CASE WHEN sku.status = 'ENABLED' THEN sku.price END) AS min_price, + COALESCE(SUM(CASE WHEN sku.status = 'ENABLED' THEN sku.stock ELSE 0 END), 0) AS total_stock, + history.browsed_at AS browsed_at + FROM product_browse_history history + INNER JOIN product ON product.id = history.product_id + AND product.status = 'ON_SALE' + LEFT JOIN product_sku sku ON sku.product_id = product.id + WHERE history.user_id = #{userId} + GROUP BY + history.id, + history.product_id, + product.name, + product.subtitle, + product.main_image, + history.browsed_at + ORDER BY history.browsed_at DESC, history.id DESC + """) + IPage selectBrowsePage( + Page page, + @Param("userId") Long userId); +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java index 1d75e75..068c1fd 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/common/ErrorCode.java @@ -48,6 +48,8 @@ public enum ErrorCode { REVIEW_IMAGE_LIMIT(40033, "评价图片最多上传3张"), REVIEW_IMAGE_INVALID(40034, "评价图片地址不合法"), BROWSE_HISTORY_NOT_FOUND(40418, "浏览记录不存在"), + SELF_ROLE_CHANGE_NOT_ALLOWED(40931, "不能修改当前登录账号的角色"), + ADMIN_ACCOUNT_PROTECTED(40932, "平台管理员账号受保护,不能在账号管理中修改"), INTERNAL_ERROR(50000, "系统异常,请稍后重试"), FILE_SAVE_FAILED(50001, "文件保存失败"); diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/AdminOrderController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/AdminOrderController.java index 43cedd2..2842a00 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/AdminOrderController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/AdminOrderController.java @@ -6,6 +6,7 @@ import com.eshop.backend.order.dto.OrderResponse; import com.eshop.backend.order.dto.OrderStatusLogResponse; import com.eshop.backend.security.LoginUser; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,6 +20,7 @@ import java.util.List; @RestController @RequestMapping("/admin/orders") @RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN', 'SELLER')") public class AdminOrderController { private final OrderService orderService; @@ -43,9 +45,9 @@ public class AdminOrderController { @PostMapping("/{id}/ship") public ApiResponse ship( - @AuthenticationPrincipal LoginUser admin, + @AuthenticationPrincipal LoginUser operator, @PathVariable Long id) { - return ApiResponse.success(orderService.ship(admin, id)); + return ApiResponse.success(orderService.ship(operator, id)); } @PostMapping("/{id}/cancel") diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/OrderService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/OrderService.java index 24f2443..863154b 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/OrderService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/order/OrderService.java @@ -250,9 +250,10 @@ public class OrderService { @Transactional @OperationLogAction(module = "订单管理", action = "订单发货") - public OrderResponse ship(LoginUser admin, Long id) { + public OrderResponse ship(LoginUser operator, Long id) { ShopOrder order = requireOrder(id); - transition(order, PAID, SHIPPED, admin, "管理员发货"); + String message = "SELLER".equals(operator.getRole()) ? "商家发货" : "管理员发货"; + transition(order, PAID, SHIPPED, operator, message); return toResponse(requireOrder(id)); } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java index 20c4388..7458c28 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReview.java @@ -18,6 +18,7 @@ public class ProductReview { private Long orderItemId; private Integer rating; private String content; + private String imagesJson; private String status; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java index befe8b2..30b0a43 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ProductReviewMapper.java @@ -3,9 +3,7 @@ package com.eshop.backend.review; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.eshop.backend.review.dto.MyReviewResponse; -import com.eshop.backend.review.dto.AdminReviewResponse; -import com.eshop.backend.review.dto.ProductReviewResponse; +import com.eshop.backend.review.dto.ProductReviewRow; import com.eshop.backend.review.dto.ProductReviewSummaryResponse; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -24,6 +22,7 @@ public interface ProductReviewMapper extends BaseMapper { item.sku_specs AS sku_specs, review.rating, review.content, + review.images_json AS images_json, review.status, review.created_at AS created_at, review.updated_at AS updated_at @@ -48,8 +47,8 @@ public interface ProductReviewMapper extends BaseMapper { ORDER BY review.created_at DESC, review.id DESC """) - IPage selectAdminReviewPage( - Page page, + IPage selectAdminReviewPage( + Page page, @Param("keyword") String keyword, @Param("rating") Integer rating, @Param("status") String status); @@ -65,6 +64,7 @@ public interface ProductReviewMapper extends BaseMapper { item.sku_specs AS sku_specs, review.rating, review.content, + review.images_json AS images_json, review.status, review.created_at AS created_at, review.updated_at AS updated_at @@ -73,7 +73,7 @@ public interface ProductReviewMapper extends BaseMapper { LEFT JOIN sys_user reviewer ON reviewer.id = review.user_id WHERE review.id = #{id} """) - AdminReviewResponse selectAdminReviewById(@Param("id") Long id); + ProductReviewRow selectAdminReviewById(@Param("id") Long id); @Select(""" SELECT @@ -83,6 +83,7 @@ public interface ProductReviewMapper extends BaseMapper { item.sku_specs AS sku_specs, review.rating, review.content, + review.images_json AS images_json, COALESCE(NULLIF(TRIM(reviewer.nickname), ''), '匿名用户') AS reviewer_nickname, review.created_at AS created_at FROM product_review review @@ -92,8 +93,8 @@ public interface ProductReviewMapper extends BaseMapper { AND review.status = 'PUBLISHED' ORDER BY review.created_at DESC, review.id DESC """) - IPage selectProductReviewPage( - Page page, + IPage selectProductReviewPage( + Page page, @Param("productId") Long productId); @Select(""" @@ -107,6 +108,7 @@ public interface ProductReviewMapper extends BaseMapper { item.product_image AS product_image, review.rating, review.content, + review.images_json AS images_json, review.status, review.created_at AS created_at, review.updated_at AS updated_at @@ -115,8 +117,8 @@ public interface ProductReviewMapper extends BaseMapper { WHERE review.user_id = #{userId} ORDER BY review.created_at DESC, review.id DESC """) - IPage selectMyReviewPage( - Page page, + IPage selectMyReviewPage( + Page page, @Param("userId") Long userId); @Select(""" diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewImages.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewImages.java new file mode 100644 index 0000000..fb7a30b --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewImages.java @@ -0,0 +1,66 @@ +package com.eshop.backend.review; + +import com.eshop.backend.common.BusinessException; +import com.eshop.backend.common.ErrorCode; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class ReviewImages { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int MAX_IMAGES = 3; + private static final String UPLOAD_PREFIX = "/api/uploads/"; + + private ReviewImages() { + } + + static List normalize(List imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { + return List.of(); + } + if (imageUrls.size() > MAX_IMAGES) { + throw new BusinessException(ErrorCode.REVIEW_IMAGE_LIMIT); + } + List normalized = new ArrayList<>(imageUrls.size()); + for (String raw : imageUrls) { + if (!StringUtils.hasText(raw)) { + throw new BusinessException(ErrorCode.REVIEW_IMAGE_INVALID); + } + String url = raw.trim(); + if (!url.startsWith(UPLOAD_PREFIX) || url.contains("..") || url.length() > 500) { + throw new BusinessException(ErrorCode.REVIEW_IMAGE_INVALID); + } + normalized.add(url); + } + return List.copyOf(normalized); + } + + static String toJson(List imageUrls) { + List normalized = normalize(imageUrls); + if (normalized.isEmpty()) { + return null; + } + try { + return MAPPER.writeValueAsString(normalized); + } catch (Exception exception) { + throw new BusinessException(ErrorCode.REVIEW_IMAGE_INVALID); + } + } + + static List fromJson(String imagesJson) { + if (!StringUtils.hasText(imagesJson)) { + return List.of(); + } + try { + List parsed = MAPPER.readValue(imagesJson, new TypeReference<>() { + }); + return parsed == null ? List.of() : Collections.unmodifiableList(parsed); + } catch (Exception exception) { + return List.of(); + } + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java index 61d46f8..a56f7a4 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/ReviewService.java @@ -1,10 +1,11 @@ package com.eshop.backend.review; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.eshop.backend.admin.operationlog.OperationLogAction; import com.eshop.backend.catalog.entity.Product; import com.eshop.backend.catalog.mapper.ProductMapper; -import com.eshop.backend.admin.operationlog.OperationLogAction; import com.eshop.backend.common.BusinessException; import com.eshop.backend.common.ErrorCode; import com.eshop.backend.common.PageResult; @@ -13,10 +14,11 @@ import com.eshop.backend.order.OrderItemMapper; import com.eshop.backend.order.OrderService; import com.eshop.backend.order.ShopOrder; import com.eshop.backend.order.ShopOrderMapper; -import com.eshop.backend.review.dto.CreateReviewRequest; import com.eshop.backend.review.dto.AdminReviewResponse; +import com.eshop.backend.review.dto.CreateReviewRequest; import com.eshop.backend.review.dto.MyReviewResponse; import com.eshop.backend.review.dto.ProductReviewResponse; +import com.eshop.backend.review.dto.ProductReviewRow; import com.eshop.backend.review.dto.ProductReviewSummaryResponse; import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; @@ -25,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.util.List; import java.util.Set; @Service @@ -48,6 +51,7 @@ public class ReviewService { if (request.rating() == null || request.rating() < 1 || request.rating() > 5) { throw new BusinessException(ErrorCode.REVIEW_RATING_INVALID); } + String imagesJson = ReviewImages.toJson(request.imageUrls()); OrderItem item = orderItemMapper.selectById(request.orderItemId()); if (item == null) { @@ -74,6 +78,7 @@ public class ReviewService { review.setOrderItemId(item.getId()); review.setRating(request.rating()); review.setContent(content); + review.setImagesJson(imagesJson); review.setStatus(PUBLISHED); review.setCreatedAt(now); review.setUpdatedAt(now); @@ -88,10 +93,11 @@ public class ReviewService { @Transactional(readOnly = true) public PageResult pageByProduct(Long productId, long current, long size) { requirePublicProduct(productId); - Page page = new Page<>( + Page page = new Page<>( Math.max(current, 1), Math.min(Math.max(size, 1), 50)); - return PageResult.from(reviewMapper.selectProductReviewPage(page, productId)); + IPage result = reviewMapper.selectProductReviewPage(page, productId); + return mapPage(result, this::toProductResponse); } @Transactional(readOnly = true) @@ -102,10 +108,11 @@ public class ReviewService { @Transactional(readOnly = true) public PageResult pageMine(Long userId, long current, long size) { - Page page = new Page<>( + Page page = new Page<>( Math.max(current, 1), Math.min(Math.max(size, 1), 50)); - return PageResult.from(reviewMapper.selectMyReviewPage(page, userId)); + IPage result = reviewMapper.selectMyReviewPage(page, userId); + return mapPage(result, this::toMyResponse); } @Transactional(readOnly = true) @@ -123,14 +130,15 @@ public class ReviewService { if (rating != null && (rating < 1 || rating > 5)) { throw new BusinessException(ErrorCode.REVIEW_RATING_INVALID); } - Page page = new Page<>( + Page page = new Page<>( Math.max(current, 1), Math.min(Math.max(size, 1), 100)); - return PageResult.from(reviewMapper.selectAdminReviewPage( + IPage result = reviewMapper.selectAdminReviewPage( page, normalizedKeyword, rating, - normalizedStatus)); + normalizedStatus); + return mapPage(result, this::toAdminResponse); } @Transactional @@ -149,7 +157,8 @@ public class ReviewService { review.setUpdatedAt(LocalDateTime.now()); reviewMapper.updateById(review); } - return reviewMapper.selectAdminReviewById(id); + ProductReviewRow row = reviewMapper.selectAdminReviewById(id); + return toAdminResponse(row); } private MyReviewResponse toMyResponse(ProductReview review, OrderItem item) { @@ -165,7 +174,64 @@ public class ReviewService { review.getContent(), review.getStatus(), review.getCreatedAt(), - review.getUpdatedAt()); + review.getUpdatedAt(), + ReviewImages.fromJson(review.getImagesJson())); + } + + private MyReviewResponse toMyResponse(ProductReviewRow row) { + return new MyReviewResponse( + row.getId(), + row.getProductId(), + row.getOrderId(), + row.getOrderItemId(), + row.getProductName(), + row.getSkuSpecs(), + row.getProductImage(), + row.getRating(), + row.getContent(), + row.getStatus(), + row.getCreatedAt(), + row.getUpdatedAt(), + ReviewImages.fromJson(row.getImagesJson())); + } + + private ProductReviewResponse toProductResponse(ProductReviewRow row) { + return new ProductReviewResponse( + row.getId(), + row.getProductId(), + row.getProductName(), + row.getSkuSpecs(), + row.getRating(), + row.getContent(), + row.getReviewerNickname(), + row.getCreatedAt(), + ReviewImages.fromJson(row.getImagesJson())); + } + + private AdminReviewResponse toAdminResponse(ProductReviewRow row) { + return new AdminReviewResponse( + row.getId(), + row.getUserId(), + row.getUsername(), + row.getReviewerNickname(), + row.getProductId(), + row.getProductName(), + row.getSkuSpecs(), + row.getRating(), + row.getContent(), + row.getStatus(), + row.getCreatedAt(), + row.getUpdatedAt(), + ReviewImages.fromJson(row.getImagesJson())); + } + + private PageResult mapPage(IPage source, java.util.function.Function mapper) { + List records = source.getRecords().stream().map(mapper).toList(); + return new PageResult<>( + source.getCurrent(), + source.getSize(), + source.getTotal(), + records); } private Product requirePublicProduct(Long productId) { diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java index 1c05ae5..d5fd364 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/AdminReviewResponse.java @@ -1,6 +1,7 @@ package com.eshop.backend.review.dto; import java.time.LocalDateTime; +import java.util.List; public record AdminReviewResponse( Long id, @@ -14,5 +15,6 @@ public record AdminReviewResponse( String content, String status, LocalDateTime createdAt, - LocalDateTime updatedAt) { + LocalDateTime updatedAt, + List imageUrls) { } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java index 7300ca7..30c5af0 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/CreateReviewRequest.java @@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import java.util.List; + public record CreateReviewRequest( @NotNull(message = "订单商品不能为空") @Positive(message = "订单商品编号不正确") @@ -19,6 +21,8 @@ public record CreateReviewRequest( @NotBlank(message = "评价内容不能为空") @Size(max = 1000, message = "评价内容不能超过1000个字符") - String content + String content, + + List imageUrls ) { } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java index 5c95eb8..41b6741 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/MyReviewResponse.java @@ -1,6 +1,7 @@ package com.eshop.backend.review.dto; import java.time.LocalDateTime; +import java.util.List; public record MyReviewResponse( Long id, @@ -14,6 +15,7 @@ public record MyReviewResponse( String content, String status, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + List imageUrls ) { } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java index 77b1ef4..5a56b0b 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewResponse.java @@ -1,6 +1,7 @@ package com.eshop.backend.review.dto; import java.time.LocalDateTime; +import java.util.List; public record ProductReviewResponse( Long id, @@ -10,6 +11,7 @@ public record ProductReviewResponse( Integer rating, String content, String reviewerNickname, - LocalDateTime createdAt + LocalDateTime createdAt, + List imageUrls ) { } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewRow.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewRow.java new file mode 100644 index 0000000..3d7c92d --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/review/dto/ProductReviewRow.java @@ -0,0 +1,155 @@ +package com.eshop.backend.review.dto; + +import java.time.LocalDateTime; + +/** + * MyBatis 行映射中间结构(images_json 为字符串,业务层再转为 imageUrls)。 + */ +public class ProductReviewRow { + private Long id; + private Long productId; + private String productName; + private String skuSpecs; + private Integer rating; + private String content; + private String reviewerNickname; + private LocalDateTime createdAt; + private String imagesJson; + + private Long orderId; + private Long orderItemId; + private String productImage; + private String status; + private LocalDateTime updatedAt; + + private Long userId; + private String username; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public String getSkuSpecs() { + return skuSpecs; + } + + public void setSkuSpecs(String skuSpecs) { + this.skuSpecs = skuSpecs; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getReviewerNickname() { + return reviewerNickname; + } + + public void setReviewerNickname(String reviewerNickname) { + this.reviewerNickname = reviewerNickname; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public String getImagesJson() { + return imagesJson; + } + + public void setImagesJson(String imagesJson) { + this.imagesJson = imagesJson; + } + + public Long getOrderId() { + return orderId; + } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } + + public Long getOrderItemId() { + return orderItemId; + } + + public void setOrderItemId(Long orderItemId) { + this.orderItemId = orderItemId; + } + + public String getProductImage() { + return productImage; + } + + public void setProductImage(String productImage) { + this.productImage = productImage; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/security/SecurityConfig.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/security/SecurityConfig.java index 28f7220..329ac6b 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/security/SecurityConfig.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/security/SecurityConfig.java @@ -1,5 +1,6 @@ package com.eshop.backend.security; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -18,6 +19,7 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; import java.util.List; @Configuration @@ -25,6 +27,9 @@ import java.util.List; @EnableMethodSecurity public class SecurityConfig { + @Value("${app.cors.allowed-origin-patterns:http://localhost:*,http://127.0.0.1:*}") + private String allowedOriginPatterns; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -55,7 +60,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/demo/**").permitAll() .requestMatchers(HttpMethod.GET, "/categories", "/categories/**", "/products", "/products/**") .permitAll() - .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/admin/users/**").hasRole("ADMIN") + .requestMatchers("/admin/**").hasAnyRole("ADMIN", "SELLER") .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); @@ -64,7 +70,10 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); + configuration.setAllowedOriginPatterns(Arrays.stream(allowedOriginPatterns.split(",")) + .map(String::trim) + .filter(origin -> !origin.isEmpty()) + .toList()); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); configuration.setAllowCredentials(true); diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserController.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserController.java index 0869ab9..2184418 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserController.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserController.java @@ -4,13 +4,18 @@ import com.eshop.backend.common.ApiResponse; import com.eshop.backend.common.PageResult; import com.eshop.backend.security.LoginUser; import com.eshop.backend.user.dto.AdminUserResponse; +import com.eshop.backend.user.dto.AdminUserSummaryResponse; +import com.eshop.backend.user.dto.ManagedUserCreateRequest; +import com.eshop.backend.user.dto.UserRoleRequest; import com.eshop.backend.user.dto.UserStatusRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -19,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/admin/users") @RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") public class AdminUserController { private final AdminUserService adminUserService; @@ -32,6 +38,16 @@ public class AdminUserController { return ApiResponse.success(adminUserService.page(current, size, keyword, role, status)); } + @GetMapping("/summary") + public ApiResponse summary() { + return ApiResponse.success(adminUserService.summary()); + } + + @PostMapping + public ApiResponse create(@Valid @RequestBody ManagedUserCreateRequest request) { + return ApiResponse.success(adminUserService.create(request)); + } + @PatchMapping("/{id}/status") public ApiResponse updateStatus( @PathVariable Long id, @@ -39,4 +55,12 @@ public class AdminUserController { @AuthenticationPrincipal LoginUser operator) { return ApiResponse.success(adminUserService.updateStatus(id, request.status(), operator.getUserId())); } + + @PatchMapping("/{id}/role") + public ApiResponse updateRole( + @PathVariable Long id, + @Valid @RequestBody UserRoleRequest request, + @AuthenticationPrincipal LoginUser operator) { + return ApiResponse.success(adminUserService.updateRole(id, request.role(), operator.getUserId())); + } } diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserService.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserService.java index cf49847..b3d6ac0 100644 --- a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserService.java +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/AdminUserService.java @@ -7,23 +7,31 @@ import com.eshop.backend.common.BusinessException; import com.eshop.backend.common.ErrorCode; import com.eshop.backend.common.PageResult; import com.eshop.backend.user.dto.AdminUserResponse; +import com.eshop.backend.user.dto.AdminUserSummaryResponse; +import com.eshop.backend.user.dto.ManagedUserCreateRequest; import com.eshop.backend.user.entity.SysUser; import com.eshop.backend.user.mapper.SysUserMapper; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.nio.charset.StandardCharsets; import java.util.List; @Service @RequiredArgsConstructor public class AdminUserService { + private static final int BCRYPT_MAX_PASSWORD_BYTES = 72; + private static final List MANAGED_ROLES = List.of("USER", "SELLER"); + private final SysUserMapper userMapper; + private final PasswordEncoder passwordEncoder; public PageResult page( long current, long size, String keyword, String role, String status) { - validateOptional(role, List.of("USER", "ADMIN")); + validateOptional(role, List.of("USER", "SELLER", "ADMIN")); validateOptional(status, List.of("ENABLED", "DISABLED")); Page page = userMapper.selectPage( new Page<>(Math.max(current, 1), Math.min(Math.max(size, 1), 50)), @@ -44,6 +52,46 @@ public class AdminUserService { page.getRecords().stream().map(this::toResponse).toList()); } + @Transactional(readOnly = true) + public AdminUserSummaryResponse summary() { + long buyerCount = countByRole("USER"); + long sellerCount = countByRole("SELLER"); + long enabledCount = countManagedByStatus("ENABLED"); + long disabledCount = countManagedByStatus("DISABLED"); + return new AdminUserSummaryResponse( + buyerCount + sellerCount, + buyerCount, + sellerCount, + enabledCount, + disabledCount); + } + + @Transactional + @OperationLogAction(module = "账号管理", action = "创建买家或卖家") + public AdminUserResponse create(ManagedUserCreateRequest request) { + validateManagedRole(request.role()); + validateOptional(request.status(), List.of("ENABLED", "DISABLED")); + if (request.password().getBytes(StandardCharsets.UTF_8).length > BCRYPT_MAX_PASSWORD_BYTES) { + throw new BusinessException(ErrorCode.PASSWORD_TOO_LONG); + } + String username = request.username().trim(); + Long existing = userMapper.selectCount(new LambdaQueryWrapper() + .eq(SysUser::getUsername, username)); + if (existing != null && existing > 0) { + throw new BusinessException(ErrorCode.USERNAME_EXISTS); + } + + SysUser user = new SysUser(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(request.password())); + user.setNickname(request.nickname().trim()); + user.setPhone(StringUtils.hasText(request.phone()) ? request.phone().trim() : null); + user.setRole(request.role()); + user.setStatus(request.status()); + userMapper.insert(user); + return toResponse(userMapper.selectById(user.getId())); + } + @Transactional @OperationLogAction(module = "用户管理", action = "修改用户状态") public AdminUserResponse updateStatus(Long id, String status, Long operatorId) { @@ -57,11 +105,57 @@ public class AdminUserService { if (id.equals(operatorId) && "DISABLED".equals(status)) { throw new BusinessException(ErrorCode.SELF_DISABLE_NOT_ALLOWED); } + if ("ADMIN".equals(user.getRole())) { + throw new BusinessException(ErrorCode.ADMIN_ACCOUNT_PROTECTED); + } user.setStatus(status); userMapper.updateById(user); return toResponse(user); } + @Transactional + @OperationLogAction(module = "账号管理", action = "修改买家或卖家角色") + public AdminUserResponse updateRole(Long id, String role, Long operatorId) { + validateManagedRole(role); + SysUser user = requireUser(id); + if (id.equals(operatorId)) { + throw new BusinessException(ErrorCode.SELF_ROLE_CHANGE_NOT_ALLOWED); + } + if ("ADMIN".equals(user.getRole())) { + throw new BusinessException(ErrorCode.ADMIN_ACCOUNT_PROTECTED); + } + user.setRole(role); + userMapper.updateById(user); + return toResponse(user); + } + + private long countByRole(String role) { + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .eq(SysUser::getRole, role)); + return count == null ? 0L : count; + } + + private long countManagedByStatus(String status) { + Long count = userMapper.selectCount(new LambdaQueryWrapper() + .in(SysUser::getRole, MANAGED_ROLES) + .eq(SysUser::getStatus, status)); + return count == null ? 0L : count; + } + + private void validateManagedRole(String role) { + if (!MANAGED_ROLES.contains(role)) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + } + + private SysUser requireUser(Long id) { + SysUser user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + return user; + } + private void validateOptional(String value, List allowed) { if (StringUtils.hasText(value) && !allowed.contains(value)) { throw new BusinessException(ErrorCode.VALIDATION_ERROR); diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/AdminUserSummaryResponse.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/AdminUserSummaryResponse.java new file mode 100644 index 0000000..d7fb1b7 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/AdminUserSummaryResponse.java @@ -0,0 +1,10 @@ +package com.eshop.backend.user.dto; + +public record AdminUserSummaryResponse( + long managedAccountCount, + long buyerCount, + long sellerCount, + long enabledCount, + long disabledCount +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/ManagedUserCreateRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/ManagedUserCreateRequest.java new file mode 100644 index 0000000..0ec7a14 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/ManagedUserCreateRequest.java @@ -0,0 +1,28 @@ +package com.eshop.backend.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ManagedUserCreateRequest( + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 50, message = "用户名长度应为 3 到 50 位") + String username, + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 72, message = "密码长度应为 6 到 72 位") + String password, + + @NotBlank(message = "昵称不能为空") + @Size(max = 50, message = "昵称不能超过 50 位") + String nickname, + + @Size(max = 20, message = "手机号不能超过 20 位") + String phone, + + @NotBlank(message = "角色不能为空") + String role, + + @NotBlank(message = "状态不能为空") + String status +) { +} diff --git a/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/UserRoleRequest.java b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/UserRoleRequest.java new file mode 100644 index 0000000..54275e4 --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/java/com/eshop/backend/user/dto/UserRoleRequest.java @@ -0,0 +1,9 @@ +package com.eshop.backend.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record UserRoleRequest( + @NotBlank(message = "角色不能为空") + String role +) { +} diff --git a/eshop-release-v1.0/backend/src/main/resources/application.yml b/eshop-release-v1.0/backend/src/main/resources/application.yml index 5195de3..9947c45 100644 --- a/eshop-release-v1.0/backend/src/main/resources/application.yml +++ b/eshop-release-v1.0/backend/src/main/resources/application.yml @@ -39,6 +39,8 @@ jwt: expiration-ms: ${JWT_EXPIRATION_MS:7200000} app: + cors: + allowed-origin-patterns: ${CORS_ALLOWED_ORIGIN_PATTERNS:http://localhost:*,http://127.0.0.1:*} upload-dir: ${UPLOAD_DIR:./data/uploads} bootstrap-admin: enabled: ${BOOTSTRAP_ADMIN_ENABLED:false} diff --git a/eshop-release-v1.0/backend/src/main/resources/db/migration/V8__index_user_role_status.sql b/eshop-release-v1.0/backend/src/main/resources/db/migration/V8__index_user_role_status.sql new file mode 100644 index 0000000..e1a79ab --- /dev/null +++ b/eshop-release-v1.0/backend/src/main/resources/db/migration/V8__index_user_role_status.sql @@ -0,0 +1 @@ +CREATE INDEX idx_sys_user_role_status ON sys_user(role, status); diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java index 9753fcd..77998ea 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ApiContractFreezeTest.java @@ -129,7 +129,7 @@ class ApiContractFreezeTest { @Test void frozenV1RequestJsonFieldsDoNotDrift() { Map, List> expected = new LinkedHashMap<>(); - expected.put(RegisterRequest.class, List.of("username", "password", "nickname")); + expected.put(RegisterRequest.class, List.of("username", "password", "nickname", "role")); expected.put(LoginRequest.class, List.of("username", "password")); expected.put(UserProfileRequest.class, List.of("nickname", "phone")); expected.put(AddressRequest.class, diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java index 546aead..9bf41f4 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/AuthSecurityIntegrationTest.java @@ -7,19 +7,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import java.util.Base64; import java.util.Map; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(properties = { - "spring.datasource.url=jdbc:h2:mem:eshop;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.url=jdbc:h2:mem:eshop_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.datasource.username=sa", "spring.datasource.password=", "spring.datasource.driver-class-name=org.h2.Driver", @@ -30,7 +33,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. "app.bootstrap-admin.enabled=true", "app.bootstrap-admin.username=admin", "app.bootstrap-admin.password=admin123", + "app.cors.allowed-origin-patterns=http://localhost:*,https://eshop.zhuyiyuan9.top", "app.bootstrap-admin.nickname=测试管理员", + "app.upload-dir=target/test-uploads", "app.order-timeout.enabled=false" }) @AutoConfigureMockMvc @@ -68,6 +73,18 @@ class AuthSecurityIntegrationTest { .andExpect(jsonPath("$.status").value("UP")); } + @Test + void productionOriginCanLogin() throws Exception { + mockMvc.perform(post("/auth/login") + .header("Origin", "https://eshop.zhuyiyuan9.top") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", "admin", + "password", "admin123")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + } + @Test void utf8PasswordBeyondBcryptByteLimitReturnsValidationError() throws Exception { String oversizedUtf8Password = "密".repeat(30); @@ -125,10 +142,59 @@ class AuthSecurityIntegrationTest { .andExpect(jsonPath("$.code").value(40301)); } + @Test + void authenticatedUserCanUploadValidatedReviewImage() throws Exception { + String username = "upload_" + System.nanoTime(); + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", username, + "password", "test123456", + "nickname", "晒图测试用户")))) + .andExpect(status().isOk()); + String token = loginAndGetToken(username, "test123456", "USER"); + + byte[] png = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="); + MockMultipartFile validImage = + new MockMultipartFile("file", "review.png", "image/png", png); + mockMvc.perform(multipart("/files/upload") + .file(validImage) + .header("Authorization", bearer(token))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.url").value( + org.hamcrest.Matchers.startsWith("/api/uploads/"))) + .andExpect(jsonPath("$.data.filename").value( + org.hamcrest.Matchers.endsWith(".png"))) + .andExpect(jsonPath("$.data.size").value(png.length)); + + MockMultipartFile invalidImage = + new MockMultipartFile("file", "fake.png", "image/png", "not an image".getBytes()); + mockMvc.perform(multipart("/files/upload") + .file(invalidImage) + .header("Authorization", bearer(token))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40020)); + } + @Test void completeCommerceFlowWorksFromCatalogToReceipt() throws Exception { String suffix = String.valueOf(System.nanoTime()); String adminToken = loginAndGetToken("admin", "admin123", "ADMIN"); + String sellerUsername = "merchant_" + suffix; + mockMvc.perform(post("/admin/users") + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "username", sellerUsername, + "password", "seller123", + "nickname", "发货测试商家", + "role", "SELLER", + "status", "ENABLED")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value("SELLER")); + String sellerToken = loginAndGetToken(sellerUsername, "seller123", "SELLER"); long categoryId = dataId(mockMvc.perform(post("/admin/categories") .header("Authorization", bearer(adminToken)) @@ -230,7 +296,12 @@ class AuthSecurityIntegrationTest { .andExpect(jsonPath("$.data.status").value("SUCCESS")); mockMvc.perform(post("/admin/orders/{id}/ship", orderId) - .header("Authorization", bearer(adminToken))) + .header("Authorization", bearer(userToken))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + + mockMvc.perform(post("/admin/orders/{id}/ship", orderId) + .header("Authorization", bearer(sellerToken))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("SHIPPED")); diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java index a9b31c4..7093e8e 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/BrowseHistoryIntegrationTest.java @@ -13,7 +13,6 @@ import org.springframework.test.web.servlet.MvcResult; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -37,7 +36,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. }) @AutoConfigureMockMvc class BrowseHistoryIntegrationTest { - @Autowired private MockMvc mockMvc; @@ -48,105 +46,110 @@ class BrowseHistoryIntegrationTest { private JdbcTemplate jdbcTemplate; @Test - void recordIsIdempotentAndRequiresLoginAndSupportsDelete() throws Exception { - String suffix = String.valueOf(System.nanoTime()); - String username = "browser_" + suffix; - register(username, "Test1234!"); - String token = login(username, "Test1234!"); - - long productId = 91001L; - jdbcTemplate.update(""" - INSERT INTO product (id, category_id, name, subtitle, main_image, detail, status) - VALUES (?, 1, ?, 'sub', null, 'detail', 'ON_SALE') - """, productId, "浏览测试商品" + suffix); + void browseHistoryIsAuthenticatedIdempotentPagedAndUserScoped() throws Exception { + mockMvc.perform(get("/browse-history")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); - // 未登录 401 - mockMvc.perform(post("/browse-history/{productId}", productId)) - .andExpect(status().isUnauthorized()); - - // 首次记录 - MvcResult first = mockMvc.perform(post("/browse-history/{productId}", productId) - .header("Authorization", bearer(token))) - .andReturn(); - if (first.getResponse().getStatus() != 200) { - throw new AssertionError("record failed status=" + first.getResponse().getStatus() - + " body=" + first.getResponse().getContentAsString()); - } - mockMvc.perform(post("/browse-history/{productId}", productId) - .header("Authorization", bearer(token))) - .andExpect(status().isOk()); - // 重新断言数据 - JsonNode firstBody = objectMapper.readTree(first.getResponse().getContentAsString()); - assertEquals(0, firstBody.path("code").asInt()); - assertEquals(productId, firstBody.path("data").path("productId").asLong()); - - // 重复进入同一商品仍一条 - mockMvc.perform(post("/browse-history/{productId}", productId) - .header("Authorization", bearer(token))) + String suffix = String.valueOf(System.nanoTime()); + String tokenA = registerAndLogin("browse_a_" + suffix); + String tokenB = registerAndLogin("browse_b_" + suffix); + long onSaleProductId = insertProduct("浏览在售商品" + suffix, "ON_SALE"); + long draftProductId = insertProduct("浏览草稿商品" + suffix, "DRAFT"); + insertSku(onSaleProductId, "BRW-A-" + suffix, "19.90", 5, "ENABLED"); + insertSku(onSaleProductId, "BRW-B-" + suffix, "29.90", 8, "ENABLED"); + + mockMvc.perform(post("/browse-history/{productId}", draftProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40913)); + + mockMvc.perform(post("/browse-history/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(post("/browse-history/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) .andExpect(status().isOk()); - Integer count = jdbcTemplate.queryForObject( + Integer historyCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM product_browse_history WHERE product_id = ?", Integer.class, - productId); - assertEquals(1, count); + onSaleProductId); + org.junit.jupiter.api.Assertions.assertEquals(1, historyCount); - // 列表可查 mockMvc.perform(get("/browse-history") - .header("Authorization", bearer(token)) .param("current", "1") - .param("size", "20")) + .param("size", "10") + .header("Authorization", bearer(tokenA))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.records[0].productId").value((int) productId)) + .andExpect(jsonPath("$.data.records[0].productId").value(onSaleProductId)) + .andExpect(jsonPath("$.data.records[0].minPrice").value(19.90)) + .andExpect(jsonPath("$.data.records[0].totalStock").value(13)) .andExpect(jsonPath("$.data.records[0].browsedAt").exists()); - // 单条删除 - mockMvc.perform(delete("/browse-history/{productId}", productId) - .header("Authorization", bearer(token))) - .andExpect(status().isOk()); + mockMvc.perform(get("/browse-history") + .header("Authorization", bearer(tokenB))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); - Integer afterDelete = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM product_browse_history WHERE product_id = ?", - Integer.class, - productId); - assertEquals(0, afterDelete); + mockMvc.perform(delete("/browse-history/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()); + mockMvc.perform(get("/browse-history") + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); - // 再记一条后清空 - mockMvc.perform(post("/browse-history/{productId}", productId) - .header("Authorization", bearer(token))) + mockMvc.perform(post("/browse-history/{productId}", onSaleProductId) + .header("Authorization", bearer(tokenA))) .andExpect(status().isOk()); mockMvc.perform(delete("/browse-history") - .header("Authorization", bearer(token))) + .header("Authorization", bearer(tokenA))) + .andExpect(status().isOk()); + mockMvc.perform(get("/browse-history") + .header("Authorization", bearer(tokenA))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.count").value(1)); + .andExpect(jsonPath("$.data.total").value(0)); } - private void register(String username, String password) throws Exception { + private String registerAndLogin(String username) throws Exception { mockMvc.perform(post("/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( "username", username, - "password", password, - "nickname", username - )))) + "password", "password123", + "nickname", username)))) .andExpect(status().isOk()); - } - - private String login(String username, String password) throws Exception { MvcResult result = mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( "username", username, - "password", password - )))) + "password", "password123")))) .andExpect(status().isOk()) .andReturn(); - JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); - if (data.hasNonNull("token")) { - return data.get("token").asText(); - } - return data.asText(); + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsByteArray()).path("data"); + return data.path("token").asText(); + } + + private long insertProduct(String name, String status) { + jdbcTemplate.update( + "INSERT INTO product (category_id, name, status) VALUES (1, ?, ?)", + name, + status); + return jdbcTemplate.queryForObject( + "SELECT id FROM product WHERE name = ?", + Long.class, + name); + } + + private void insertSku(long productId, String skuCode, String price, int stock, String status) { + jdbcTemplate.update(""" + INSERT INTO product_sku (product_id, sku_code, specs_json, price, stock, status) + VALUES (?, ?, '{}', ?, ?, ?) + """, + productId, skuCode, price, stock, status); } private String bearer(String token) { diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java index edce8ae..8b7454a 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/DashboardIntegrationTest.java @@ -75,7 +75,7 @@ class DashboardIntegrationTest { .header("Authorization", "Bearer " + adminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.userCount").value(2)) + .andExpect(jsonPath("$.data.userCount").value(1)) .andExpect(jsonPath("$.data.productCount").value(2)) .andExpect(jsonPath("$.data.onSaleProductCount").value(1)) .andExpect(jsonPath("$.data.orderCount").value(5)) @@ -87,6 +87,33 @@ class DashboardIntegrationTest { .andExpect(jsonPath("$.data.lowStockSkuCount").value(1)) .andExpect(jsonPath("$.data.paidSalesAmount").value(90.0)) .andExpect(jsonPath("$.data.generatedAt").exists()); + + mockMvc.perform(get("/admin/dashboard/sales-trend") + .param("days", "7") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + + mockMvc.perform(get("/admin/dashboard/sales-trend") + .param("days", "7") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.days").value(7)) + .andExpect(jsonPath("$.data.points.length()").value(7)); + + mockMvc.perform(get("/admin/dashboard/top-products") + .param("limit", "5") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.limit").value(5)) + .andExpect(jsonPath("$.data.items").isArray()); + + mockMvc.perform(get("/admin/dashboard/sales-trend") + .param("days", "0") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()); } private String login(String username, String password) throws Exception { diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/MerchantAccountIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/MerchantAccountIntegrationTest.java new file mode 100644 index 0000000..c67e037 --- /dev/null +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/MerchantAccountIntegrationTest.java @@ -0,0 +1,198 @@ +package com.eshop.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:eshop_merchant;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:schema-test.sql", + "jwt.secret=ZXNob3AtdGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC0yMDI2", + "app.bootstrap-admin.enabled=true", + "app.bootstrap-admin.username=admin", + "app.bootstrap-admin.password=admin123", + "app.bootstrap-admin.nickname=平台管理员", + "app.order-timeout.enabled=false" +}) +@AutoConfigureMockMvc +class MerchantAccountIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void platformAdminCanManageBuyersAndSellersWhileSellerUsesMerchantConsole() throws Exception { + String suffix = String.valueOf(System.nanoTime()); + String adminToken = login("admin", "admin123", "ADMIN"); + String buyerUsername = "buyer_" + suffix; + String sellerUsername = "seller_" + suffix; + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of( + "username", buyerUsername, + "password", "test123456", + "nickname", "测试买家")))) + .andExpect(status().isOk()); + String buyerToken = login(buyerUsername, "test123456", "USER"); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of( + "username", sellerUsername, + "password", "seller123", + "nickname", "测试商家", + "role", "SELLER")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("username", sellerUsername, "password", "seller123")))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40302)); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of( + "username", sellerUsername, + "password", "seller123", + "nickname", "重复商家", + "role", "SELLER")))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(40901)); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of( + "username", "public_admin_" + suffix, + "password", "seller123", + "nickname", "非法管理员", + "role", "ADMIN")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40001)); + + mockMvc.perform(post("/admin/users") + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of( + "username", "illegal_" + suffix, + "password", "seller123", + "nickname", "非法管理员", + "role", "ADMIN", + "status", "ENABLED")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40001)); + + mockMvc.perform(get("/admin/users/summary") + .header("Authorization", bearer(adminToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.managedAccountCount").value(2)) + .andExpect(jsonPath("$.data.buyerCount").value(1)) + .andExpect(jsonPath("$.data.sellerCount").value(1)) + .andExpect(jsonPath("$.data.enabledCount").value(1)) + .andExpect(jsonPath("$.data.disabledCount").value(1)); + + MvcResult sellerPage = mockMvc.perform(get("/admin/users") + .param("role", "SELLER") + .header("Authorization", bearer(adminToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].username").value(sellerUsername)) + .andExpect(jsonPath("$.data.records[0].status").value("DISABLED")) + .andReturn(); + long sellerId = responseData(sellerPage).path("records").path(0).path("id").asLong(); + + mockMvc.perform(patch("/admin/users/{id}/status", sellerId) + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"ENABLED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ENABLED")); + + String sellerToken = login(sellerUsername, "seller123", "SELLER"); + mockMvc.perform(get("/admin/dashboard/summary") + .header("Authorization", bearer(sellerToken))) + .andExpect(status().isOk()); + mockMvc.perform(get("/admin/products") + .header("Authorization", bearer(sellerToken))) + .andExpect(status().isOk()); + mockMvc.perform(get("/admin/users") + .header("Authorization", bearer(sellerToken))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + mockMvc.perform(get("/admin/dashboard/summary") + .header("Authorization", bearer(buyerToken))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)); + + mockMvc.perform(patch("/admin/users/{id}/role", sellerId) + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"USER\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value("USER")); + mockMvc.perform(get("/admin/dashboard/summary") + .header("Authorization", bearer(sellerToken))) + .andExpect(status().isForbidden()); + + mockMvc.perform(patch("/admin/users/{id}/role", sellerId) + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"SELLER\"}")) + .andExpect(status().isOk()); + mockMvc.perform(patch("/admin/users/{id}/status", sellerId) + .header("Authorization", bearer(adminToken)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"DISABLED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")); + mockMvc.perform(get("/auth/me") + .header("Authorization", bearer(sellerToken))) + .andExpect(status().isUnauthorized()); + } + + private String login(String username, String password, String expectedRole) throws Exception { + MvcResult result = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("username", username, "password", password)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value(expectedRole)) + .andReturn(); + return responseData(result).path("token").asText(); + } + + private JsonNode responseData(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + } + + private String json(Object value) throws Exception { + return objectMapper.writeValueAsString(value); + } + + private String bearer(String token) { + return "Bearer " + token; + } +} diff --git a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java index 647a1c1..3670a78 100644 --- a/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java +++ b/eshop-release-v1.0/backend/src/test/java/com/eshop/backend/ReviewIntegrationTest.java @@ -11,6 +11,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import java.util.List; import java.util.Map; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -107,13 +108,48 @@ class ReviewIntegrationTest { mockMvc.perform(post("/reviews") .header("Authorization", bearer(owner.token())) .contentType(MediaType.APPLICATION_JSON) - .content(reviewJson(itemId, 5, " 商品符合描述 "))) + .content(reviewJsonWithImages( + itemId, + 5, + "图片过多", + List.of( + "/api/uploads/one.png", + "/api/uploads/two.png", + "/api/uploads/three.png", + "/api/uploads/four.png")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40033)); + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJsonWithImages( + itemId, + 5, + "非法图片地址", + List.of("https://example.com/not-ours.png")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40034)); + + mockMvc.perform(post("/reviews") + .header("Authorization", bearer(owner.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(reviewJsonWithImages( + itemId, + 5, + " 商品符合描述 ", + List.of( + "/api/uploads/review-one.png", + "/api/uploads/review-two.jpg")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.orderItemId").value(itemId)) .andExpect(jsonPath("$.data.productName").value("评价测试商品" + suffix)) .andExpect(jsonPath("$.data.rating").value(5)) - .andExpect(jsonPath("$.data.content").value("商品符合描述")); + .andExpect(jsonPath("$.data.content").value("商品符合描述")) + .andExpect(jsonPath("$.data.imageUrls").isArray()) + .andExpect(jsonPath("$.data.imageUrls.length()").value(2)) + .andExpect(jsonPath("$.data.imageUrls[0]").value("/api/uploads/review-one.png")) + .andExpect(jsonPath("$.data.imageUrls[1]").value("/api/uploads/review-two.jpg")); mockMvc.perform(post("/reviews") .header("Authorization", bearer(owner.token())) @@ -133,7 +169,10 @@ class ReviewIntegrationTest { .andExpect(jsonPath("$.data.records[0].rating").value(5)) .andExpect(jsonPath("$.data.records[0].userId").doesNotExist()) .andExpect(jsonPath("$.data.records[0].orderId").doesNotExist()) - .andExpect(jsonPath("$.data.records[0].orderItemId").doesNotExist()); + .andExpect(jsonPath("$.data.records[0].orderItemId").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].imageUrls").isArray()) + .andExpect(jsonPath("$.data.records[0].imageUrls.length()").value(2)) + .andExpect(jsonPath("$.data.records[0].imageUrls[0]").value("/api/uploads/review-one.png")); mockMvc.perform(get("/products/{productId}/reviews/summary", productId)) .andExpect(status().isOk()) @@ -153,7 +192,8 @@ class ReviewIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.total").value(1)) .andExpect(jsonPath("$.data.records[0].orderId").value(orderId)) - .andExpect(jsonPath("$.data.records[0].orderItemId").value(itemId)); + .andExpect(jsonPath("$.data.records[0].orderItemId").value(itemId)) + .andExpect(jsonPath("$.data.records[0].imageUrls.length()").value(2)); mockMvc.perform(get("/reviews/mine") .header("Authorization", bearer(other.token()))) .andExpect(status().isOk()) @@ -236,6 +276,17 @@ class ReviewIntegrationTest { "content", content)); } + private String reviewJsonWithImages(long orderItemId, + int rating, + String content, + List imageUrls) throws Exception { + return objectMapper.writeValueAsString(Map.of( + "orderItemId", orderItemId, + "rating", rating, + "content", content, + "imageUrls", imageUrls)); + } + private String bearer(String token) { return "Bearer " + token; } diff --git a/eshop-release-v1.0/backend/src/test/resources/schema-test.sql b/eshop-release-v1.0/backend/src/test/resources/schema-test.sql index 9f7272d..0914f64 100644 --- a/eshop-release-v1.0/backend/src/test/resources/schema-test.sql +++ b/eshop-release-v1.0/backend/src/test/resources/schema-test.sql @@ -11,6 +11,7 @@ CREATE TABLE sys_user ( ); CREATE INDEX idx_sys_user_status ON sys_user(status); +CREATE INDEX idx_sys_user_role_status ON sys_user(role, status); CREATE TABLE operation_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, diff --git a/eshop-release-v1.0/docker-compose.yml b/eshop-release-v1.0/docker-compose.yml index 10ea06f..2d3a59d 100644 --- a/eshop-release-v1.0/docker-compose.yml +++ b/eshop-release-v1.0/docker-compose.yml @@ -25,6 +25,7 @@ services: MYSQL_USER: ${MYSQL_USER:-eshop} MYSQL_PASSWORD: ${MYSQL_PASSWORD:-eshop123} JWT_SECRET: ${JWT_SECRET:?Copy .env.example to .env and set JWT_SECRET} + CORS_ALLOWED_ORIGIN_PATTERNS: ${CORS_ALLOWED_ORIGIN_PATTERNS:-http://localhost:*,http://127.0.0.1:*} BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false} BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" index 83e224a..e740836 100644 --- "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/C03\346\274\224\347\244\272\350\204\232\346\234\254.md" @@ -1,7 +1,7 @@ # C03 订单超时自动取消 — 现场演示脚本 -> 日期:2026-07-29 -> 适用:验收答辩 5~8 分钟边界演示 +> 日期:2026-07-29 +> 适用:验收答辩 5~8 分钟边界演示 > 实现位置:`OrderTimeoutScheduler` + `OrderService` 条件更新 ## 一、原理一句话 diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" index 28e5716..3d4e0d2 100644 --- "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\344\273\212\346\227\245\346\211\251\345\261\225\346\216\245\345\217\243\345\206\273\347\273\223-v1.2.md" @@ -1,6 +1,6 @@ # E-Shop 今日扩展接口冻结说明(v1.2) -> 完整版同步自仓库根目录 `docs/02-设计文档/今日扩展接口冻结-v1.2.md`。 +> 完整版同步自仓库根目录 `docs/02-设计文档/今日扩展接口冻结-v1.2.md`。 > 日期:2026-07-29 负责人:组长 ## 迁移 diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\345\271\263\345\217\260\350\264\246\345\217\267\344\270\216\345\225\206\345\256\266\345\267\245\344\275\234\345\217\260\350\256\276\350\256\241.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\345\271\263\345\217\260\350\264\246\345\217\267\344\270\216\345\225\206\345\256\266\345\267\245\344\275\234\345\217\260\350\256\276\350\256\241.md" new file mode 100644 index 0000000..2a2f5a5 --- /dev/null +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\345\271\263\345\217\260\350\264\246\345\217\267\344\270\216\345\225\206\345\256\266\345\267\245\344\275\234\345\217\260\350\256\276\350\256\241.md" @@ -0,0 +1,37 @@ +# 平台账号与商家工作台设计 + +> 日期:2026-07-30 负责人:祝意远 + +项目仍为 PC、手机 H5、Web 管理三端。Web 管理端内部按角色提供两个入口: + +- `/`:统一入口,根据当前登录角色集中展示 PC、手机、商家和平台管理入口; +- `/admin/login`:平台管理员入口,管理买家和卖家账号; +- `/seller/login`:商家入口,复用原后台的看板、分类、商品/SKU、库存、订单发货、评价和操作日志页面。 + +PC、手机、商家和平台布局均提供“切换入口”链接,可以随时返回 `/`。角色符合时直接进入对应工作台;角色不符合时,统一入口会明确要求退出并更换账号,不会绕过前端路由守卫或后端权限检查。 + +## 角色权限 + +| 角色 | 权限 | +|------|------| +| `USER` | PC/手机消费者业务 | +| `SELLER` | 商城运营业务,不能访问账号管理 | +| `ADMIN` | 买家/卖家账号管理,也可进入商家工作台 | + +商家在 `/seller/orders` 处理订单。买家完成模拟支付后,订单进入“待发货”;商家核对收货信息并点击“确认发货”,订单转为“已发货”并记录发货时间,随后由买家确认收货。普通买家不能调用商家发货接口。 + +账号接口: + +- `GET /api/admin/users`:分页与筛选; +- `GET /api/admin/users/summary`:角色、状态统计; +- `POST /api/admin/users`:新增买家或卖家; +- `PATCH /api/admin/users/{id}/role`:买家/卖家角色切换; +- `PATCH /api/admin/users/{id}/status`:账号启停。 + +平台管理员不能通过接口委派新的 `ADMIN`,管理员账号不能被禁用或降级。后端每次 JWT 请求都会读取数据库中的最新角色和状态,因此角色切换或禁用会立即生效。 + +公开注册支持 `role=USER|SELLER`,未传角色默认买家。买家注册后立即启用;商家自助注册后保持 `DISABLED`,需要平台管理员在 `/admin/users` 审核并启用。公开注册不能创建 `ADMIN`。 + +原后台业务地址保留兼容重定向,例如 `/admin/products` 会转到 `/seller/products`。数据库沿用 `sys_user.role`,Flyway V8 新增 `(role, status)` 联合索引。 + +当前采用单店铺、多运营账号模式,所有卖家共同运营同一份商品和订单数据。若升级为多商户市场,需要新增店铺实体,并对商品、SKU、订单项和统计加入商户字段及数据隔离。 diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" index ed076dd..77e3533 100644 --- "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\216\245\345\217\243\350\256\276\350\256\241.md" @@ -14,7 +14,7 @@ - JSON 请求:`Content-Type: application/json` - 登录鉴权:`Authorization: Bearer ` - 分页参数:`current`(从 1 开始)、`size` -- 管理接口统一位于 `/api/admin/**`,仅 `ADMIN` 角色可访问。 +- 管理接口统一位于 `/api/admin/**`。账号管理 `/api/admin/users/**` 仅 `ADMIN` 可访问;商城运营接口允许 `ADMIN` 或 `SELLER` 访问。 - 商品分类、商品列表、商品详情和图片可匿名访问。 统一响应: @@ -59,9 +59,14 @@ | 当前用户 | GET | `/api/auth/me` | 登录 | | 修改个人信息 | PUT | `/api/auth/me` | 登录 | | 后台用户分页 | GET | `/api/admin/users` | 管理员 | +| 账号统计 | GET | `/api/admin/users/summary` | 管理员 | +| 新增买家/卖家 | POST | `/api/admin/users` | 管理员 | | 启用/禁用用户 | PATCH | `/api/admin/users/{id}/status` | 管理员 | +| 切换买家/卖家角色 | PATCH | `/api/admin/users/{id}/role` | 管理员 | -用户分页支持 `keyword`、`role=USER|ADMIN`、`status=ENABLED|DISABLED`。管理员不能禁用自己。 +公开注册请求可传 `role=USER|SELLER`;旧客户端不传时默认 `USER`。买家注册后立即启用,商家注册后为 `DISABLED` 待审核状态,需平台管理员启用后才能登录。`ADMIN` 不能通过公开注册创建。 + +用户分页支持 `keyword`、`role=USER|SELLER|ADMIN`、`status=ENABLED|DISABLED`。平台管理员不能禁用自己、修改自己的角色或修改其他平台管理员;管理接口不能委派新的 `ADMIN`。角色或状态修改后,旧 JWT 的后续请求会立即按数据库最新权限处理。 状态请求: @@ -84,7 +89,7 @@ 热销排行支持 `days`(默认 30,传 0 表示全部时间)和 `limit`(1~20,默认 5)。只统计 `PAID`、`SHIPPED`、`COMPLETED` 订单中的在售商品,按有效销量、销售额和商品 ID 依次倒序排列。 -### 管理员接口 +### 商家运营接口(`SELLER` 或 `ADMIN`) | 功能 | 方法 | 路径 | |---|---|---| @@ -165,7 +170,7 @@ } ``` -### 管理员接口 +### 商家运营接口(`SELLER` 或 `ADMIN`) | 功能 | 方法 | 路径 | |---|---|---| @@ -175,6 +180,8 @@ | 订单发货 | POST | `/api/admin/orders/{id}/ship` | | 取消待支付订单 | POST | `/api/admin/orders/{id}/cancel` | +商家在 `/seller/orders` 查看订单;只有状态为 `PAID`(待发货)的订单显示“确认发货”。确认后后端将订单原子更新为 `SHIPPED`、记录发货时间和操作日志。`SELLER` 与 `ADMIN` 可以调用发货接口,普通买家调用会返回 `403`。 + 订单状态只允许以下流转: ```text @@ -223,11 +230,11 @@ PENDING_PAYMENT --支付--> PAID --发货--> SHIPPED --确认收货--> COMPLETED ## 8. 健康检查与联调顺序 - 健康检查:`GET /api/actuator/health` -- 本地 Docker 入口:`http://localhost:8088` +- 本地 Docker 统一入口:`http://localhost:8088/` - 真实 MySQL API 冒烟测试:在项目根目录执行 `powershell -ExecutionPolicy Bypass -File scripts/api-smoke-test.ps1` - 推荐联调顺序: 1. 管理员登录并取得 Token。 2. 创建分类和商品。 3. 创建 SKU 后将商品状态改为 `ON_SALE`。 4. 普通用户注册、登录、加购、创建地址并下单。 - 5. 用户模拟支付,管理员发货,用户确认收货。 + 5. 买家模拟支付,商家确认发货,买家确认收货。 diff --git "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241.md" "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241.md" index 48017e0..12fbc66 100644 --- "a/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241.md" +++ "b/eshop-release-v1.0/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241.md" @@ -7,6 +7,7 @@ | 版本 | 日期 | 修改人 | 修改说明 | |------|------|--------|----------| +| v1.1 | 2026-07-30 | 祝意远 | 按当前实现补充角色与迁移说明 | | v1.0 | | | 初稿 | ## 一、设计说明 @@ -43,11 +44,12 @@ erDiagram | username | VARCHAR(50) | 否 | | 用户名,唯一 | | password | VARCHAR(100) | 否 | | 加密后的密码 | | phone | VARCHAR(20) | 是 | | 手机号 | +| role | VARCHAR(20) | 否 | USER | `USER` 买家 / `SELLER` 卖家 / `ADMIN` 平台管理员 | | status | TINYINT | 否 | 1 | 1正常 0禁用 | | created_at | DATETIME | 否 | 当前时间 | 创建时间 | | updated_at | DATETIME | 否 | 当前时间 | 更新时间 | -索引:`uk_username(username)` 唯一索引 +索引:`uk_username(username)` 唯一索引、`idx_sys_user_status(status)`、`idx_sys_user_role_status(role, status)`。 ### 3.2 商品分类表(category) (同上格式…) @@ -73,3 +75,5 @@ erDiagram - 数据库迁移脚本存放位置:`backend/src/main/resources/db/migration/`,新变更按 `V2__说明.sql`、`V3__说明.sql` 顺序新增;禁止修改已经执行过的迁移脚本。 - 演示数据迁移:`backend/src/main/resources/db/migration/V3__seed_demo_catalog.sql`,包含 3 个分类、30 个已上架商品及对应 SKU。 - 演示商品图片为后端本地静态资源,位于 `backend/src/main/resources/static/demo/`,不依赖外部图片服务。 + +当前迁移共 V1~V8:V1/V2 创建核心商城表,V3 初始化演示商品,V4 收藏,V5 评价,V6 浏览历史,V7 评价晒图,V8 增加用户角色与状态联合索引。卖家角色复用 `sys_user.role`,不增加重复账号表。 diff --git "a/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\345\212\237\350\203\275\345\256\214\346\210\220\345\272\246\345\257\271\347\205\247\350\241\250-\351\252\214\346\224\266\347\224\250.md" "b/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\345\212\237\350\203\275\345\256\214\346\210\220\345\272\246\345\257\271\347\205\247\350\241\250-\351\252\214\346\224\266\347\224\250.md" new file mode 100644 index 0000000..d2eb259 --- /dev/null +++ "b/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\345\212\237\350\203\275\345\256\214\346\210\220\345\272\246\345\257\271\347\205\247\350\241\250-\351\252\214\346\224\266\347\224\250.md" @@ -0,0 +1,18 @@ +# 功能完成度对照表(验收用) + +完整维护版本在仓库根目录: + +**`docs/03-测试文档/功能完成度对照表-验收用.md`** + +(阶段快照目录保留本入口,避免与根目录文档双份长期分叉;更新时请改根目录完整版。) + +## 速查结论 + +- 必做 F01–F13:**13/13 完成** +- 选做:评价晒图、收藏、浏览历史、多 SKU、操作日志等 **超额** +- 挑战:**C03 订单超时自动取消** +- 扩展:看板趋势/Top、库存预警、热销、订单导出等 **已完成** +- 平台/商家:平台管理员管理买家和卖家;商家复用原后台业务页面 **已完成** +- 区分注册:买家立即启用;商家待平台管理员审核启用;公开注册禁止创建管理员 +- 统一视觉:PC、手机 H5、商家、平台、登录注册及操作反馈已统一为深蓝玻璃卡片主题 +- 最新验证:后端 22/22、前端 2196 modules、Flyway V1~V8、真实 MySQL API 126/126 diff --git "a/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" "b/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" index 831aecf..e6f21d9 100644 --- "a/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" +++ "b/eshop-release-v1.0/docs/03-\346\265\213\350\257\225\346\226\207\346\241\243/\346\265\213\350\257\225\346\212\245\345\221\212.md" @@ -1,16 +1,16 @@ # 测试报告 -> 组别:____ 编写人:____ 编写日期:____ 版本:v1.0 +> 组别:24 级全栈 2 班第 7 组 汇总人:祝意远 更新日期:2026-07-30 版本:v1.6 > 截止:第 4 周周三 ## 一、测试概况 | 项目 | 内容 | |------|------| -| 测试轮次 | 共 __ 轮 | -| 测试时间 | ____ 至 ____ | -| 测试人员 | | -| 测试环境 | | +| 测试轮次 | 共 17 轮(含全站统一视觉最终回归) | +| 测试时间 | 2026-07-23 至 2026-07-30 | +| 测试人员 | 全组;最终集成回归:祝意远 | +| 测试环境 | Windows 11、JUnit/MockMvc/H2、Docker Desktop、MySQL 8.4、Nginx | ## 二、用例执行统计 @@ -196,3 +196,87 @@ C03 的实现原理、配置和现场演示步骤见 `docs/02-设计文档/C03 | 运行日志 | 通过 | 后端最近日志无 ERROR 或 Exception | 结论:当前项目已经达到可演示、可测试、可部署状态,新增扩展未破坏商城交易链。 + +## 十三、平台账号与商家工作台最终回归(2026-07-30) + +| 检查项 | 结果 | 说明 | +|--------|------|------| +| 后端自动化 | 通过 | 11 个测试类、22 项测试,失败 0、错误 0、跳过 0 | +| 平台账号管理 | 通过 | 买家/卖家统计、筛选、新增、启停与角色切换正常 | +| 权限隔离 | 通过 | 卖家不能管理账号,买家不能进入商家工作台,管理员账号受保护 | +| 权限即时生效 | 通过 | 修改角色或禁用后,旧 JWT 后续请求立即按新状态处理 | +| 前端生产构建 | 通过 | Vite 成功转换 2187 个模块 | +| 数据库迁移 | 通过 | Flyway V1~V8 在 MySQL 8.4 全部成功 | +| 真实 MySQL API | 通过 | 122 个检查点全部通过,测试数据自动清理 | +| 页面直达 | 通过 | `/admin/users`、`/seller/login`、`/seller/products`、`/seller/orders` 均为 HTTP 200 | +| 容器与日志 | 通过 | MySQL、后端健康,最近日志无 ERROR、Exception 或 HTTP 500 | + +结论:平台管理员、商家和买家三类权限边界清晰,原后台已安全复用为商家工作台,可以进入验收演示。 + +## 十四、买家与商家区分注册最终回归(2026-07-30) + +| 检查项 | 结果 | 说明 | +|--------|------|------| +| 买家注册 | 通过 | `role=USER` 立即启用,省略角色仍兼容为买家 | +| 商家注册 | 通过 | `role=SELLER` 创建待审核账号,审核前不能登录 | +| 平台审核 | 通过 | 管理员启用后商家可登录工作台 | +| 权限安全 | 通过 | 公开注册传 `ADMIN` 被拒绝 | +| 前端与分流 | 通过 | PC/手机可选身份,商家入口可申请;登录后按角色跳转 | +| 自动化 | 通过 | 后端 22/22、前端 2187 modules、真实 MySQL API 126/126 | + +结论:买家、商家注册流程已明确区分,并通过平台审核避免未授权账号直接操作商城数据。 + +## 十五、商家发货最终回归(2026-07-30) + +| 检查项 | 结果 | 说明 | +|--------|------|------| +| 商家订单页面 | 通过 | `/seller/orders` 可访问,待发货订单显示“确认发货” | +| 发货权限 | 通过 | `SELLER` 可以发货,普通买家调用发货接口返回 `403` | +| 订单状态 | 通过 | 已支付订单由 `PAID` 更新为 `SHIPPED`,同步记录发货时间 | +| 操作审计 | 通过 | 商家发货记录操作人,并写入“商家发货”状态日志 | +| 买家收货 | 通过 | 发货后买家可确认收货,订单最终进入 `COMPLETED` | +| 后端自动化 | 通过 | 11 个测试类、22 项测试,失败 0、错误 0、跳过 0 | +| 前端生产构建 | 通过 | Vite 成功转换 2187 个模块 | +| 真实 MySQL API | 通过 | 部署后 126 个检查点全部通过,其中商家账号完成实际发货 | +| 页面与日志 | 通过 | 健康检查 `UP`、商家订单页 HTTP 200,最近日志无 ERROR、Exception 或 HTTP 500 | + +结论:商家发货已形成“买家支付 → 商家确认发货 → 买家确认收货”的完整角色化闭环。 + +## 十六、三端统一入口最终回归(2026-07-30) + +| 检查项 | 结果 | 说明 | +|--------|------|------| +| 统一入口 | 通过 | `/` 直接返回 HTTP 200,不再被 Nginx 重定向到 `/pc` | +| 集中切换 | 通过 | 同一页面集中提供 PC、手机 H5、商家工作台和平台管理入口 | +| 角色识别 | 通过 | 页面展示当前账号与角色,可用工作台直接进入,角色不符时提示切换账号 | +| 切换提示 | 通过 | 自定义居中弹窗展示当前身份与目标入口,支持关闭按钮、遮罩、ESC 和移动端适配 | +| 全局操作反馈 | 通过 | 消息顶部悬浮、确认框居中展示,成功、警告、错误等状态保持统一视觉 | +| 手机购物车反馈 | 通过 | 加入和移除商品均显示顶部成功提示;删除前使用带遮罩的居中二次确认框 | +| 权限隔离 | 通过 | 统一入口不改变路由守卫和后端鉴权,买家不能直接进入商家或平台管理 | +| 各端返回入口 | 通过 | PC、手机、商家、平台布局和登录页均可返回统一入口 | +| 页面直达 | 通过 | `/`、`/pc/products`、`/m/products`、`/seller/login`、`/seller`、`/admin/login`、`/admin/users` 均为 HTTP 200 | +| Nginx 配置 | 通过 | `nginx -t` 成功,SPA history 路由回退正常 | +| 前端生产构建 | 通过 | Vite 成功转换 2196 个模块 | +| 真实 MySQL API | 通过 | 126 个检查点全部通过,已有真实商家账号不会干扰测试数据定位 | +| 容器与日志 | 通过 | 后端健康检查 `UP`,最近日志无 ERROR、Exception 或 HTTP 500 | + +结论:三端入口已统一整合,用户能够在一个页面按身份快速切换,同时保持原有权限边界。 + +## 十七、全站统一视觉最终回归(2026-07-30) + +| 检查项 | 结果 | 说明 | +|--------|------|------| +| 设计基准 | 通过 | 以统一入口的深蓝渐变、玻璃卡片、浅蓝高亮和细边框为全站视觉基准 | +| PC 消费者端 | 通过 | 导航、筛选、商品、购物车、地址、订单、支付、评价和个人资料使用统一主题 | +| 手机 H5 端 | 通过 | 顶部栏、底部导航、商品、购物车、订单、表单、弹窗和反馈组件完成深色适配 | +| 商家工作台 | 通过 | 侧边导航、经营看板、分类、商品、库存、订单、评价、日志及抽屉表单完成深色适配 | +| 平台管理端 | 通过 | 平台导航、买家/卖家统计、筛选、表格和账号操作组件完成深色适配 | +| 登录与注册 | 通过 | 买家、手机、商家和管理员入口统一为带渐变顶边的玻璃登录卡片 | +| 业务状态颜色 | 通过 | 保留成功、库存预警、订单状态、价格和危险操作的语义色,深色背景下可辨识 | +| 响应式布局 | 通过 | 桌面与手机尺寸完成截图抽查,导航、卡片、表单和按钮未出现遮挡 | +| 页面直达 | 通过 | 统一入口及 PC、手机、商家、平台共 38 个路由均返回 HTTP 200 | +| 前端生产构建 | 通过 | Vite 成功转换 2196 个模块 | +| 真实 MySQL API | 通过 | 126 个检查点全部通过,主题改造未影响任何业务流程 | +| 服务与日志 | 通过 | Nginx 配置正常、后端健康检查 `UP`,最近日志无 ERROR、Exception 或 HTTP 500 | + +结论:所有业务端已统一到入口页的设计语言,并完成桌面、手机、路由、构建和完整业务回归。 diff --git a/eshop-release-v1.0/frontend/nginx.conf b/eshop-release-v1.0/frontend/nginx.conf index 80b5964..6e53e00 100644 --- a/eshop-release-v1.0/frontend/nginx.conf +++ b/eshop-release-v1.0/frontend/nginx.conf @@ -5,10 +5,6 @@ server { root /usr/share/nginx/html; index index.html; - location = / { - return 302 /pc; - } - location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; diff --git a/eshop-release-v1.0/frontend/src/api/admin.js b/eshop-release-v1.0/frontend/src/api/admin.js index ad39eef..721e57f 100644 --- a/eshop-release-v1.0/frontend/src/api/admin.js +++ b/eshop-release-v1.0/frontend/src/api/admin.js @@ -2,6 +2,14 @@ import http from './http' export const getDashboardSummary = () => http.get('/admin/dashboard/summary') +export const getDashboardSalesTrend = (days = 7) => ( + http.get('/admin/dashboard/sales-trend', { params: { days } }) +) + +export const getDashboardTopProducts = (limit = 10) => ( + http.get('/admin/dashboard/top-products', { params: { limit } }) +) + export const getInventoryAlerts = ({ current = 1, size = 20, @@ -21,10 +29,18 @@ export const getUsers = ({ params: { current, size, keyword, role, status }, }) +export const getUserSummary = () => http.get('/admin/users/summary') + +export const createManagedUser = (payload) => http.post('/admin/users', payload) + export const updateUserStatus = (id, status) => ( http.patch(`/admin/users/${id}/status`, { status }) ) +export const updateUserRole = (id, role) => ( + http.patch(`/admin/users/${id}/role`, { role }) +) + export const getAdminReviews = ({ current = 1, size = 20, diff --git a/eshop-release-v1.0/frontend/src/api/browseHistory.js b/eshop-release-v1.0/frontend/src/api/browseHistory.js index 7729439..ca0a10d 100644 --- a/eshop-release-v1.0/frontend/src/api/browseHistory.js +++ b/eshop-release-v1.0/frontend/src/api/browseHistory.js @@ -1,21 +1,9 @@ import http from './http' -/** - * 浏览历史(组员 A · 吴松青) - * - * 后端:com.eshop.backend.browse(表 V6 product_browse_history,勿重建) - * - POST /browse-history/{productId} 静默记录,幂等刷新 browsed_at - * - GET /browse-history 分页,按最近访问倒序 - * - DELETE /browse-history/{productId} 单条删除 - * - DELETE /browse-history 清空当前用户全部 - * - * 建议路由(组长统一接入):/pc/history 、 /m/history - */ +export const recordBrowseHistory = (productId) => http.post(`/browse-history/${productId}`) -export const recordVisit = (productId) => http.post(`/browse-history/${productId}`) +export const getBrowseHistory = (params) => http.get('/browse-history', { params }) -export const pageHistory = (params) => http.get('/browse-history', { params }) +export const removeBrowseHistory = (productId) => http.delete(`/browse-history/${productId}`) -export const deleteOne = (productId) => http.delete(`/browse-history/${productId}`) - -export const clearAll = () => http.delete('/browse-history') +export const clearBrowseHistory = () => http.delete('/browse-history') diff --git a/eshop-release-v1.0/frontend/src/api/file.js b/eshop-release-v1.0/frontend/src/api/file.js new file mode 100644 index 0000000..41d4b4e --- /dev/null +++ b/eshop-release-v1.0/frontend/src/api/file.js @@ -0,0 +1,11 @@ +import http from './http' + +/** + * 登录用户上传图片(评价晒图等)。 + * 不要手动设置 Content-Type,交由浏览器自动带 multipart boundary。 + */ +export const uploadUserImage = (file) => { + const formData = new FormData() + formData.append('file', file) + return http.post('/files/upload', formData) +} diff --git a/eshop-release-v1.0/frontend/src/api/http.js b/eshop-release-v1.0/frontend/src/api/http.js index fde18b9..e1a28ad 100644 --- a/eshop-release-v1.0/frontend/src/api/http.js +++ b/eshop-release-v1.0/frontend/src/api/http.js @@ -22,8 +22,15 @@ const redirectToLogin = () => { const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}` const isAdmin = window.location.pathname.startsWith('/admin') + const isSeller = window.location.pathname.startsWith('/seller') const isMobile = window.location.pathname.startsWith('/m') - const loginPath = isAdmin ? '/admin/login' : isMobile ? '/m/login' : '/pc/login' + const loginPath = isAdmin + ? '/admin/login' + : isSeller + ? '/seller/login' + : isMobile + ? '/m/login' + : '/pc/login' if (window.location.pathname === loginPath) return const redirect = isAdmin ? '' : `?redirect=${encodeURIComponent(currentPath)}` diff --git a/eshop-release-v1.0/frontend/src/components/AuthForm.vue b/eshop-release-v1.0/frontend/src/components/AuthForm.vue index d6f758b..98d6f0f 100644 --- a/eshop-release-v1.0/frontend/src/components/AuthForm.vue +++ b/eshop-release-v1.0/frontend/src/components/AuthForm.vue @@ -5,9 +5,23 @@ import { useAuthStore } from '../stores/auth' const props = defineProps({ terminal: { type: String, required: true }, - adminOnly: { type: Boolean, default: false }, + allowedRoles: { type: Array, default: () => [] }, + registrationRoles: { type: Array, default: () => [] }, + title: { type: String, default: 'E-Shop 电子商城' }, + loginSubtitle: { type: String, default: '登录后继续使用' }, }) +const registrationRoleMeta = { + USER: { + label: '买家', + description: '浏览商品、购物、下单和评价', + }, + SELLER: { + label: '商家', + description: '注册后等待平台管理员审核启用', + }, +} + const router = useRouter() const route = useRoute() const auth = useAuthStore() @@ -15,41 +29,91 @@ const mode = ref('login') const username = ref('') const password = ref('') const nickname = ref('') +const registrationRole = ref('USER') const loading = ref(false) const errorMessage = ref('') +const messageType = ref('error') +const restrictedTerminal = computed(() => props.allowedRoles.length > 0) +const registrationOptions = computed(() => + props.registrationRoles + .filter((role) => registrationRoleMeta[role]) + .map((role) => ({ role, ...registrationRoleMeta[role] })), +) +const canRegister = computed(() => registrationOptions.value.length > 0) +const selectedRegistration = computed(() => + registrationRoleMeta[registrationRole.value] || registrationRoleMeta.USER, +) +const registerSubtitle = computed(() => + registrationRole.value === 'SELLER' + ? '创建商家账号,审核通过后进入工作台' + : '创建买家账号,立即开始购物', +) const redirectPath = computed(() => { - const fallback = ({ pc: '/pc', mobile: '/m', admin: '/admin' })[props.terminal] + const fallback = ({ pc: '/pc', mobile: '/m', admin: '/admin', seller: '/seller' })[props.terminal] const requested = route.query.redirect + const terminalPrefix = ({ pc: '/pc', mobile: '/m', admin: '/admin', seller: '/seller' })[ + props.terminal + ] if ( - !props.adminOnly - && typeof requested === 'string' + typeof requested === 'string' && requested.startsWith('/') && !requested.startsWith('//') + && (!restrictedTerminal.value || requested.startsWith(terminalPrefix)) ) { return requested } return fallback }) +const switchMode = () => { + errorMessage.value = '' + messageType.value = 'error' + if (mode.value === 'login') { + if (!canRegister.value) return + registrationRole.value = registrationOptions.value[0].role + mode.value = 'register' + return + } + mode.value = 'login' +} + const submit = async () => { errorMessage.value = '' + messageType.value = 'error' loading.value = true try { if (mode.value === 'register') { - await auth.signUp({ username: username.value, password: password.value, nickname: nickname.value }) + await auth.signUp({ + username: username.value, + password: password.value, + nickname: nickname.value, + role: registrationRole.value, + }) mode.value = 'login' password.value = '' - errorMessage.value = '注册成功,请登录' + messageType.value = 'success' + errorMessage.value = registrationRole.value === 'SELLER' + ? '商家注册成功,请等待平台管理员审核启用后登录' + : '买家注册成功,请登录' return } const user = await auth.signIn({ username: username.value, password: password.value }) - if (props.adminOnly && user.role !== 'ADMIN') { + if (restrictedTerminal.value && !props.allowedRoles.includes(user.role)) { await auth.signOut() - throw new Error('该账号没有管理员权限') + throw new Error('该账号没有进入当前工作台的权限') + } + if (!restrictedTerminal.value && user.role === 'SELLER') { + await router.replace('/seller') + return + } + if (!restrictedTerminal.value && user.role === 'ADMIN') { + await router.replace('/admin') + return } await router.replace(redirectPath.value) } catch (error) { + messageType.value = 'error' errorMessage.value = error.message || '操作失败,请重试' } finally { loading.value = false @@ -59,26 +123,57 @@ const submit = async () => { diff --git a/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue b/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue index e2d4268..abdd6b1 100644 --- a/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue +++ b/eshop-release-v1.0/frontend/src/components/review/ProductReviewList.vue @@ -162,6 +162,17 @@ watch(

{{ review.content }}

+
+ + + +
购买规格:{{ specsText(review.skuSpecs) }} @@ -199,6 +210,28 @@ watch( box-shadow: 0 10px 30px rgba(15, 23, 42, .05); } +.review-photos { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0 4px; +} + +.review-photos a { + display: block; + width: 72px; + height: 72px; + overflow: hidden; + border-radius: 8px; + background: #f1f5f9; +} + +.review-photos img { + width: 100%; + height: 100%; + object-fit: cover; +} + .product-reviews > header, .review-meta, .review-pagination { diff --git a/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue b/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue index 0b489a5..49f6bf5 100644 --- a/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue +++ b/eshop-release-v1.0/frontend/src/components/review/ReviewHistoryList.vue @@ -102,6 +102,17 @@ void loadReviews(1) {{ specsText(review.skuSpecs) || '默认规格' }}

{{ review.content }}

+
+ + + +