Repository navigation
Design
| 项目 | 内容 |
|---|---|
| 文档名称 | 在线购物系统(最小可行产品)设计文档 |
| 版本 | V1.3 |
| 日期 | 2026-10-14 |
| 上游文档 | 《在线购物系统(MVP)需求分析文档 V2.2》《需求规格说明书 V1.4》 |
| 技术栈 | Java 17 + Spring Boot 3.4;数据持久化:SQLite 数据库;前端:原生 HTML/CSS/JavaScript(无框架);开发环境:IntelliJ IDEA / VS Code |
修订记录(V1.2 → V1.3)
- 上游文档引用统一为《需求规格说明书 V1.4》。
- §3.2 设计原则修正:明确"输入字段前端预校验"职责——业务规则以后端服务层为唯一权威,前端对价格、图片、密码、字段长度等输入约束做预校验以提升体验(对齐 SRS V1.4 与实际代码)。
- §5.2、§7.2、§8 补充发布表单价格/图片、密码强度、字段长度等前端预校验设计。
修订记录(V1.1 → V1.2)
- 技术栈由 Python 3 + Flask + JSON 文件切换为 Java 17 + Spring Boot 3.4 + SQLite(Java 重构版),业务设计不变。
- §4 数据设计由
data.json结构改写为 SQLite 表结构(新增 §4.3 表结构 DDL、§4.4 ORM 映射),字段含义、枚举取值、位次计算规则与 V1.1 完全一致。 - §2.2 关键技术决策、§2.3 部署与运行、§2.4 开发工具、§3 模块划分、§6.4 并发控制、§6.5 持久化按新栈改写。
- 保留 V1.1 的架构分层、模块职责边界、API 契约、状态机、队列与口令码机制;新增 §11 数据迁移设计。
本文档依据《需求分析文档 V2.2》与《需求规格说明书 V1.4》给出系统的技术设计方案,覆盖架构、模块、数据、接口、核心机制与界面设计,作为编码实现与评审的依据。文中所有设计均可向上追溯到需求文档的功能编号(FR)、业务规则(BR)与非功能需求(NFR),追溯关系见第 9 节。
- 单商品、单卖家、买家免注册;线下交易,无支付/物流。
- 交付形态为单机可运行的课程作业:一个 Java 进程同时托管静态页面与 API,数据落本地 SQLite 文件,无需安装数据库服务、无需账号密码。
- 可移植性硬约束:把项目源码拷到另一台机器,只要该机装了 JDK 17,即可直接运行,无需安装依赖、数据库或配置连接串。为此依赖 Maven Wrapper(自带构建工具)与 SQLite(进程内数据库)。
- 商品五态状态机(在售/冻结/交易失败/恢复在售/下架)与"提交不冻结、先到先得、失败自动递补、口令码贯穿买家侧"是本期设计核心。
四层单体架构,单进程运行(见架构图):
| 层 | 组成 | 职责 |
|---|---|---|
| 页面层 | 买家首页 index.html、口令码管理(首页内视图)、卖家后台 admin.html
|
原生 HTML/JS,通过 fetch 调 API,无前端框架 |
| API 层 | Spring MVC @RestController
|
参数校验、鉴权(HandlerInterceptor)、调用服务层、统一错误格式(@ControllerAdvice) |
| 服务层 |
ShoppingService:认证、商品状态机、意向与队列 |
全部业务规则的唯一落点(状态机、队列、口令码、递补) |
| 持久层 | Spring Data JPA 仓储 + SQLite + uploads/
|
实体映射、派生查询、事务边界;图片文件存储 |
| 决策点 | 选择 | 理由 |
|---|---|---|
| 数据库 |
SQLite(sqlite-jdbc + hibernate-community-dialects) |
进程内嵌入式数据库,数据库即单个 .db 文件:零安装、零账号密码、零连接配置,拷源码即可运行,直接满足可移植性硬约束;仍支持唯一约束与事务,比 JSON 文件更可靠 |
| 表结构创建 | Hibernate ddl-auto=update 自动建表 |
无手写 DDL,首次启动即建表;实体注解即数据字典,避免文档与实现漂移 |
| ORM | Spring Data JPA + Hibernate 6 | 派生查询方法表达"按口令码查""按商品+阶段排序""计数"等需求,业务代码不拼 SQL;@Enumerated(STRING) 保证枚举可读 |
| 事务与并发 |
ShoppingService 方法标 @Transactional + 关键转移方法 synchronized
|
校验与变更在同一事务内完成,异常回滚;JVM 内单写者语义满足 NFR-01"防一货两卖" |
| 密码哈希 | Spring Security SCryptPasswordEncoder(随机盐) |
满足 BR-18 加密存储;已验证兼容旧格式 scrypt:<salt>:<hex>,迁移无需重置密码 |
| 口令码 |
SecureRandom 生成 8 位字母数字(去除易混淆字符 0/O、1/I) |
空间约 32⁸ ≈ 2⁴⁰,唯一且不可枚举(NFR-03);唯一性由数据库唯一约束兜底 |
| 会话 | 内存 ConcurrentHashMap + HttpOnly Cookie |
简单;重启后重新登录即可(买家本无会话) |
| 价格存储 | 整数"分"(如 ¥9.90 存 990) | 避免浮点误差;入参校验 >0 且最多两位小数,展示层格式化 |
- 目录:
pom.xml、mvnw/mvnw.cmd、src/main/java/、src/main/resources/、public/(静态页面)、data/shop.db(运行时自动生成)、uploads/(运行时自动生成)。 - 启动:项目根目录执行
mvnw.cmd spring-boot:run(Windows)或./mvnw spring-boot:run,监听127.0.0.1:3000;买家访问/,卖家访问/admin.html。 -
前置条件只有 JDK 17:Maven 由 Wrapper 自动下载,SQLite 驱动随依赖打包,无需安装数据库服务;
data/与uploads/由应用启动时自动创建,无需手工建目录(见 §6.6)。首次运行需联网下载依赖,之后可离线。 - 打包:
mvnw.cmd clean package→java -jar target/shopping-system-1.0.0.jar。
| 类别 | 工具 | 说明 |
|---|---|---|
| 编程语言 | Java 17 | 后端业务与 API;前端为浏览器原生 HTML/CSS/JavaScript |
| 集成开发环境 | IntelliJ IDEA / VS Code | 编码、调试与运行 |
| 构建工具 | Maven(Maven Wrapper,版本 3.9.16) | 项目自带 mvnw,目标机器无需预装 Maven |
| 持续集成 | 不使用 | MVP 规模未引入 Jenkins |
| 数据库 | SQLite(嵌入式) | 无需安装数据库服务;不使用 MySQL / PostgreSQL |
| 测试工具 | 自写 HTTP 回归脚本 | 按 SRS 第 8 章验收;未使用 JUnit / Selenium / JMeter |
| 模块 | 职责 | 对应需求 |
|---|---|---|
控制器层 controller
|
路由与参数校验、统一错误响应(@RestController + @ControllerAdvice) |
FR 全项 |
auth |
登录校验、会话签发与校验、修改密码(验原密码、强度校验、scrypt 哈希) | FR-S-01、FR-S-09 |
product |
发布商品(含图片上传校验)、查询当前商品、手动冻结/解冻、历史列表 | FR-S-02、FR-S-05、FR-S-08、FR-B-01 |
trade |
开始交易(强制取队首)、标记成功、标记失败(作废/重新排队 + 自动递补) | FR-S-04、FR-S-06、FR-S-07 |
intent |
提交意向(发口令码)、口令码查询/修改/撤销、队列与位次计算 | FR-B-02~05、FR-S-03 |
ShoppingService |
上述业务规则的唯一落点;方法标 @Transactional,关键转移加 synchronized
|
全部 BR / NFR-01 |
repository(3 个仓储) |
数据访问;以派生查询表达队首、位次计数、口令码查找等 | NFR-02 |
domain(3 实体 + 3 枚举) |
表结构映射;枚举以 AttributeConverter 存小写字面量 |
§4 |
DataMigrationRunner |
一次性导入旧 data.json;无卖家时创建默认账号 |
NFR-02、可移植性 |
ShoppingApplication |
启动期自举 data/、uploads/ 目录(§6.6) |
可移植性 |
装饰器 require_auth
|
拦截未登录的后台请求 | FR-S-01 |
- 状态变更只走服务层:API 路由不直接改数据,所有状态转移经"状态机转移函数"统一入口,前置校验非法即拒绝(返回 409),保证 BR 不被绕过。
- 业务规则集中:先到先得、冻结停收意向、失败自动递补、口令码失效判定等只在服务层出现一次,前端不做业务规则判断、只做展示。
- 输入字段前端预校验:价格(>0、两位小数)、图片(JPG/PNG、≤5MB)、新密码(≥8 位、含字母数字)、意向字段长度(姓名 ≤64、电话 ≤32)、口令码(8 位 + trim/大写规范化)等输入约束先在浏览器端校验(HTML 约束属性 + 提交前脚本检查),不通过的请求不发往服务端;服务层保留同一约束的完整校验并作为最终权威(对齐 SRS V1.4 §6.2)。
存储介质为 SQLite 数据库(单文件 <项目>/data/shop.db),表结构由 Hibernate 按实体类自动创建(ddl-auto=update),无需手写 DDL。三张表:seller(卖家账号)、product(商品)、intent(购买意向)。表结构即数据字典,字段与 V1.1 的 data.json 一一对应。
seller — 卖家账号(单条记录)
| 列名 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
integer | PK, 自增 | 主键 |
username |
varchar(64) | NOT NULL, UNIQUE(uk_seller_username) |
卖家账号名 |
password_hash |
varchar(255) | NOT NULL | 密码哈希,绝不存明文(BR-18) |
password_changed_at |
varchar(32) | 可空 | 改密时间(BR-19) |
product — 商品
| 列名 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
integer | PK, 自增 | 主键 |
name |
varchar(128) | NOT NULL | 商品名称 |
description |
varchar(2000) | NOT NULL | 商品描述 |
image |
varchar(255) | NOT NULL | 形如 /uploads/p<时间戳>-<随机>.jpg
|
price_cents |
bigint | NOT NULL | 价格以"分"存储(BR-17) |
status |
varchar(16) | NOT NULL |
active / frozen / resumed / sold
|
trading_intent_id |
bigint | 可空,无外键 | 交易中的意向 ID;null 且 frozen 即"手动冻结" |
created_at |
varchar(32) | NOT NULL | 发布时间(BR-19) |
frozen_at |
varchar(32) | 可空 | 进入冻结的时间(BR-19) |
result |
varchar(32) | 可空 | 交易结果,成功时为 success
|
result_at |
varchar(32) | 可空 | 标记交易结果的时间(BR-19) |
索引:idx_product_status (status) —— 支撑"当前唯一未售出商品"的查询。
intent — 购买意向
| 列名 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
integer | PK, 自增 | 主键 |
product_id |
bigint | NOT NULL | 所属商品(无数据库外键,见 §4.3) |
name |
varchar(64) | NOT NULL | 买家姓名(可凭口令码改,BR-12) |
phone |
varchar(32) | NOT NULL | 联系电话(可凭口令码改,BR-12) |
passcode |
varchar(16) | NOT NULL, UNIQUE(列级,内联进建表语句) | 8 位口令码,买家唯一凭证(BR-13) |
submitted_at |
varchar(32) | NOT NULL | 提交时间,决定位次,不可变 |
stage |
varchar(16) | NOT NULL |
queued / trading / closed
|
outcome |
varchar(16) | 可空 |
success / voided / requeued / cancelled / unsold
|
索引:idx_intent_product (product_id);idx_intent_queue (product_id, stage, submitted_at, id) —— 后者直接服务队首选取与位次计数。
字段说明:status 取 active 在售 / frozen 冻结 / resumed 恢复在售 / sold 下架;stage 取 queued 排队中 / trading 交易中 / closed 已结束;outcome 取 success 成功 / voided 作废 / requeued 重新排队 / cancelled 撤销 / unsold 未成交。价格以"分"存储(BR-17),密码只存哈希(BR-18),各时间戳字段满足 BR-19。
-
五态与落库:"交易失败"是过渡状态、不停留(V2.2 §3.1),在标记失败的同一次请求内完成意向处置与分流,因此不落库;持久化的状态枚举为
active / frozen / resumed / sold四值。resumed与active行为等价,单列只为保留"失败后重新上架"的来历。 -
交易中判定:
status=frozen且tradingIntentId≠null→ 自动冻结(交易中);tradingIntentId=null→ 手动冻结。同一时刻至多一条stage=trading的意向(NFR-01)。 -
队列与位次:不存位次字段。某意向位次 = 同商品下
stage=queued且(submittedAt, id)排序在前的条数 + 1;队首 = 其中排在最小者。由countByProductIdAndStage与findFirstBy...OrderBySubmittedAtAscIdAsc两条派生查询完成,无需全表加载。 -
重新排队落库(V2.2 §9-1):旧意向置
stage=closed, outcome=requeued,旧口令码随之失效;新建意向记录(复制最终姓名/电话、新口令码、submittedAt=当前时间)自然排到队尾,新口令码由卖家线下告知买家。 - 历史留痕边界(BR-16):意向记录只在修改时覆盖写姓名/电话,不留旧值;意向无修改/撤销时间戳字段;商品与账号仅保留 4.1 列出的时间戳字段。
-
会话不持久化:登录会话存内存
ConcurrentHashMap,重启失效需重新登录(范围外功能"退出登录"不做)。 - 图片:随机文件名防覆盖与猜测;商品删除不存在,图片随历史永久保留。
| 约束 | 是否建立 | 理由 |
|---|---|---|
intent.passcode UNIQUE |
✅ 建立且实测生效 | 口令码是买家唯一凭证,数据库层兜底防重复发放(BR-13)。用列级 unique = true 而非 @Table(uniqueConstraints=…):后者在 Hibernate 6 + SQLite 下会走"建唯一索引"路径,而该索引不被 UniqueConstraintSchemaGenerator 识别,导致建表后又被 DROP 掉,约束形同虚设。列级写法直接把 unique 内联进 CREATE TABLE。 |
product / seller 上的其他 UNIQUE |
❌ 不建 | 单卖家、单未售出商品由服务层前置校验保证,业务上不会并发插入同用户名 |
intent.product_id → product.id FK |
❌ 刻意不建 | SQLite 的 JDBC 驱动默认 foreign_keys=OFF,外键不参与实际执行,反而让 ddl-auto=update 每轮启动都去比对约束。一致性由服务层保证:意向只会挂在已存在的未售出商品上,且商品永不被删除 |
product.trading_intent_id → intent.id FK |
❌ 刻意不建 | 同样原因;且"标记失败"过程中存在"商品仍指向已终结意向"的合法中间态(SRS §3.3),硬外键会挡住它 |
| 枚举列 CHECK 约束 | ❌ 刻意不建 | Hibernate 为枚举生成 check (status in (...)),而 SQLite 不支持 ALTER TABLE ADD CONSTRAINT,后续增删枚举值会使 ddl-auto=update 失败。改以 AttributeConverter 存字符串,取值范围由 Java 枚举保证 |
上表两次"实战踩坑"结论均以实际数据库文件为准:
@Table(uniqueConstraints=…)生成的约束会被 Hibernate 6 自己删除;@ManyToOne @JoinColumn生成的 FK 对 SQLite 无实际意义。两者都已改为"服务层保证 + 必要处用列级约束"。
| 实体类 | 表 | 关键映射 |
|---|---|---|
ProductEntity |
product |
@Enumerated 替换为 ProductStatus.Persister(AttributeConverter);枚举只存 varchar(16) 字面量,无 CHECK |
IntentEntity |
intent |
productId 为普通 Long 列(不建关联、不建外键);passcode 用列级 unique = true
|
SellerEntity |
seller |
passwordHash 映射 password_hash
|
仓储接口(Spring Data JPA 派生查询):
| 仓储 | 方法 | 用途 |
|---|---|---|
ProductRepository |
findFirstByStatusNotOrderByIdDesc(sold) |
取当前唯一未售出商品 |
existsByStatusNot(sold) |
发布前置校验(至多一件未售出) | |
findByStatusOrderByIdDesc(sold) |
历史商品列表(含 sold) | |
IntentRepository |
findByPasscode |
口令码查询(唯一约束支撑) |
findByProductIdOrderBySubmittedAtAscIdAsc |
队列按先到先得排序 | |
findByProductIdAndStageOrderBySubmittedAtAscIdAsc |
队首选取 | |
existsByPasscode |
生成口令码时防重 | |
countByProductIdAndStage |
位次计算 | |
SellerRepository |
findByUsername / findTheSeller()
|
卖家账号读取 |
统一约定:JSON 请求/响应;错误返回 { "error": "<错误码>" };状态冲突返回 HTTP 409,参数错误 400,未登录 401,口令码无效 403。
| 方法 | 路径 | 请求体 | 成功响应 | 关键规则 |
|---|---|---|---|---|
| GET | /api/product |
— | 当前商品(名称/描述/图片/价格)+ status + buyable
|
冻结时也返回(前端置灰并标识);无未售出商品返回 { "product": null }
|
| POST | /api/intents |
{name, phone} |
{passcode} |
仅 buyable(active/resumed)时接受,否则 409;两项必填;生成独立记录+口令码 |
| POST | /api/intents/query |
{passcode} |
{stage, position} |
位次按 4.2-3 计算;trading 时返回"交易中";意向 closed 或商品 sold → 403 口令码已失效 |
| PUT | /api/intents |
{passcode, name, phone} |
{ok:true} |
仅口令码有效且意向未结束;只改两项、不动 submittedAt(BR-12) |
| POST | /api/intents/cancel |
{passcode} |
{ok:true} |
置 closed/cancelled,位次前移自动成立;不改商品状态(BR-09) |
| 方法 | 路径 | 请求体 | 成功响应 | 关键规则 |
|---|---|---|---|---|
| POST | /api/auth/login |
{username, password} |
种 Cookie | scrypt 校验 |
| POST | /api/auth/password |
{oldPassword, newPassword} |
{ok:true} |
验原密码;新密码 ≥8 位且含字母+数字,否则 400;更新 passwordChangedAt
|
| POST | /api/products |
multipart:name/description/price/image | {id} |
前端先预校验价格(>0 两位小数)与图片(JPG/PNG ≤5MB),不合格不发请求(§7.2);服务端前置:无未售出商品,否则 409;图片仅 JPG/PNG ≤5MB 一张;价格 >0 两位小数转分;status=active
|
| GET | /api/admin/product |
— | 当前商品 + 交易中意向详情 | 供后台仪表盘 |
| GET | /api/admin/intents |
— | 当前商品队列(位次/姓名/电话/提交时间/stage),含已撤销交易中买家的标记 | FR-S-03 |
| POST | /api/admin/freeze |
— | {ok:true} |
active/resumed→frozen(tradingIntentId=null) |
| POST | /api/admin/unfreeze |
— | {ok:true} |
frozen→active;不动任何意向(BR-06) |
| POST | /api/admin/trade/start |
— | {intentId, name, phone} |
系统强制取队首,不可指定;商品→frozen 且绑定 tradingIntentId;队列空则 409 |
| POST | /api/admin/trade/success |
— | {ok:true} |
需存在 trading 意向;商品→sold;trading 意向→success;其余未结束意向→closed/unsold;全部口令码因 closed/sold 自然失效;不可撤销,前端二次确认 |
| POST | /api/admin/trade/fail |
{disposition:"void"|"requeue"} |
{ok:true},requeue 时附 {newPasscode}
|
处置当前 trading 意向;随后队列非空→队首自动转 trading(自动递补)、商品保持 frozen 并更新 frozenAt;队空→商品 resumed;记 resultAt;若当前 trading 意向已被买家撤销,按 void 处理 |
| GET | /api/admin/history |
— | sold 商品列表,各附完整意向名单(姓名/电话/提交时间/outcome) | FR-S-08、BR-15 |
- 校验:商品 frozen 且存在 trading 意向,否则 409。
- 处置:
void→ 该意向closed/voided;requeue→ 该意向closed/requeued,新建记录发新口令码。 - 分流:取新队首——存在 → 转
trading、商品保持frozen;不存在 → 商品resumed。 - 记
resultAt,save_db()原子落盘,一次性返回结果。
第 2~4 步在同一请求处理过程内顺序完成,是"交易失败"过渡态的实现形态(4.2-1)。
- 单一转移函数
transition(product, event, payload):内部分支校验"当前状态 × 事件"合法性,非法即抛 409;合法则修改状态、联动意向、写时间戳、落盘。 - 事件集:
PUBLISH / SUBMIT_INTENT / START_TRADE / FREEZE / UNFREEZE / MARK_SUCCESS / MARK_FAIL / CANCEL_INTENT。 -
SUBMIT_INTENT、CANCEL_INTENT不改变商品状态(BR-01、BR-09),仅操作意向表。
- "开始交易"接口不接受指定买家参数,从数据源上杜绝跳选(BR-04)。
- 递补与"开始交易"复用同一段"取队首并绑定"逻辑,保证两种入口行为一致(BR-05)。
- 生成:
SecureRandom在 32 字符字母表(去除 0/O、1/I)取 8 位,生成后查重(BR 唯一性),数据库列级 UNIQUE 兜底。 - 失效判定不集中维护:查询/修改/撤销时实时检查"意向 closed 或所属商品 sold"即拒绝,自然覆盖 BR-13 全部失效时机(成功/作废/撤销/未成交/下架/重新排队)。
- 页面只展示一次(提交成功页),响应中不回显队列里他人的任何信息(NFR-03)。
- 至多一件未售出商品:发布前置校验(
existsByStatusNot(sold))+ 售出后才允许再发布。 - 至多一条 trading:绑定前校验当前无 trading。
- 所有"校验 + 变更"在同一
@Transactional方法内顺序完成,关键转移方法加synchronized保证 JVM 内单写者语义,请求间无交错执行。
- 启动:Hibernate 按实体自动建表(
ddl-auto=update);若数据库为空则DataMigrationRunner创建默认卖家账号。 - 每次成功写操作在同一事务内提交落库;异常回滚,不产生半成品状态。
- 重启后商品、意向、口令码、历史全部恢复;仅会话需重新登录。
SQLite 驱动只创建数据库文件,不创建其父目录;目录不存在会直接抛
path to './data/shop.db': '...\data' does not exist 并导致启动失败。全新克隆的项目里没有 data/,因此启动时必须自行创建。
实现要点:
- 监听
ApplicationEnvironmentPreparedEvent,在 JPAEntityManagerFactory构建之前创建目录。 不可放在SpringApplication.run(...)之后——run()在上下文刷新阶段就已完成 DDL 执行,晚于此的一切都来不及。 - 目录从已解析的配置读取,因此
SHOP_DB_FILE/SHOP_UPLOAD_DIR指定的自定义路径同样生效。 - 同时创建数据库文件父目录与图片上传目录;
:memory:等非落盘 URL 跳过。
三个视图区域:
-
商品区:主图、名称、描述、价格(¥xx.xx)。
- 在售/恢复在售:显示"我要购买"表单(姓名、联系电话,均必填)。
- 冻结:商品正常展示 + "已被预定/暂停接受意向"标识,表单隐藏(V2.2 §9-4)。
- 无商品:提示"暂无在售商品"。
- 提交成功区:大号展示口令码 + "请立即保存,遗失无法找回"提示;提供"复制"按钮。
- 口令码管理区:输入口令码 → 显示排队位次或"交易中";提供修改姓名/电话表单与"撤销意向"按钮(撤销前弹确认)。
- 登录视图:用户名 + 密码;失败提示不区分账号错还是密码错。
-
仪表盘(登录后):
- 当前商品卡片:信息 + 状态徽章(在售/冻结-交易中/冻结-手动/恢复在售)+ 按状态显示的操作按钮(发布/开始交易/手动冻结/手动解冻/标记成功/标记失败)。
- "标记失败"弹出二选一:作废 / 重新排队;选重新排队则结果页显著展示新口令码,提示卖家线下转交买家。
- "标记成功"二次确认弹窗,文案明示"不可撤销"(FR-S-06)。
- 交易中买家若已自行撤销,队列中该行置灰标记"买家已撤销"。
- 意向队列表:位次、姓名、电话、提交时间、状态。
- 历史商品:可展开列表,每个商品附完整意向名单(姓名/电话/提交时间/结局)。
-
发布商品表单:价格输入用
type=number(min 0.01 / step 0.01)+ 提交前脚本校验(>0、最多两位小数、上限 9999999.99);图片选择用accept过滤格式 + 提交前脚本校验类型与大小(JPG/PNG、≤5MB);名称/描述按数据字典限长(128/2000)。校验不通过即时提示且不发请求(SRS V1.4 §6.2)。 -
修改密码表单:新密码输入加
minlength+pattern(≥8 位、含字母数字),提交前再做脚本预校验;服务端校验为最终权威。
| 威胁 | 对策 | 对应需求 |
|---|---|---|
| 密码泄露 | scrypt + 随机盐,只存哈希;登录失败不透露具体原因 | BR-18 |
| 弱密码 | 改密强制 ≥8 位且字母+数字(前后端双重校验) | FR-S-09 |
| 未授权访问后台 | 全部 /api/admin/*、/api/auth/password、/api/products 经 require_auth;Cookie HttpOnly |
FR-S-01 |
| 口令码被枚举 | 32⁸ 随机空间 + 全匹配查询 + 无效/失效统一 403 不区分 | NFR-03 |
| 买家信息泄露 | 队列与联系方式仅后台接口返回;买家侧接口按口令码只返回本人的位次与阶段 | NFR-03 |
| 恶意上传 | 前端预校验(accept 过滤 + 脚本检查类型/大小)+ 服务端限制单文件 ≤5MB、MIME 白名单 JPG/PNG、随机文件名 | BR-17 |
| 恶意灌意向 | 本版本按需求方口径不做限制,仅在此登记为已知风险 | V2.2 §8 范围外 |
| 需求 | 设计落点 |
|---|---|
| FR-B-01 浏览商品 | GET /api/product;买家首页三态展示(§7.1) |
| FR-B-02 提交意向 | POST /api/intents;口令码生成(§6.3) |
| FR-B-03/04/05 查/改/撤 | intents query/PUT/cancel;失效判定(§6.3) |
| FR-S-01 登录 | auth 模块 + require_auth(§8) |
| FR-S-02 发布商品 | POST /api/products;图片/价格校验(§5.2) |
| FR-S-03 查看队列 | GET /api/admin/intents;位次计算(§4.2-3) |
| FR-S-04 开始交易 | trade/start 强制取队首(§6.2) |
| FR-S-05 手动冻结/解冻 | freeze/unfreeze;不碰意向(§5.2) |
| FR-S-06 标记成功 | trade/success;不可逆 + 落选者 unsold(§5.2) |
| FR-S-07 标记失败 | trade/fail;作废/重新排队 + 自动递补(§5.3) |
| FR-S-08 历史商品 | GET /api/admin/history(BR-15/16) |
| FR-S-09 修改密码 | auth/password;强度规则(§8) |
| BR-01~19 | 状态机转移函数(§6.1)+ 各接口"关键规则"列(§5) |
| NFR-01 防一货两卖 | 事务内顺序处理 + synchronized + 状态校验(§6.4) |
| NFR-02 持久化 | SQLite 事务提交(§6.5);表由实体自动创建(§4.1) |
| NFR-03 安全 | §8 全表 |
| NFR-04 可用性 | 免注册三页面流程(§7) |
早期原型 shop-mvp 与组员实现 se-course-project 均为 Node.js/Express 技术栈的"提交即冻结"旧模型验证版本;其后有一版 Python + Flask + data.json 实现(本文档 V1.1)。版本 V1.2 按课程工具要求切换为 Java 17 + Spring Boot 3.4 + SQLite,但数据层结构(§4)与接口设计(§5)与语言无关,业务设计保持完全一致:状态机、队列、口令码、递补规则一字未改,前端页面亦未改动(原 /api/* 路径与 JSON 响应结构全量保留)。
为让旧版 data.json 数据不丢失,Java 版内置一次性迁移(DataMigrationRunner,实现 ApplicationRunner)。
触发条件(三个条件同时满足才执行)
-
seller、product、intent三张表全部为空; - 配置的旧数据文件(默认
./data.json,可用SHOP_DATA_FILE覆盖)存在。
任一条件不满足即跳过,绝不覆盖已有数据,因此该逻辑可安全地留在代码中长期存在。
迁移内容与规则
| 迁移对象 | 规则 |
|---|---|
| 卖家 |
passwordHash 原样复制(不重新哈希),因此旧密码继续可用;passwordChangedAt 一并保留 |
| 商品 | 显式指定 id,保持与原文件一致;status 按字面量解析为枚举 |
| 意向 | 显式指定 id 与 productId,保持关联与位次语义不变;stage / outcome 为 null 时正确置空 |
无旧文件时的初始化
- 若数据库中不存在任何卖家账号,创建默认账号
admin,密码admin1234(可用环境变量DEFAULT_SELLER_PASSWORD覆盖),口令以 scrypt 哈希存储。
密码兼容性 系统同时支持两种哈希格式的校验,因此无论数据是"迁移来的"还是"新建的"都能登录:
- 新格式:Spring Security
SCryptPasswordEncoder输出($100801$<salt>$<hash>); - 旧格式:
data.json中的scrypt:<salt>:<hex>(BouncyCastle 参数)。