从0到1实战:用 Vue3 + FastAPI 搭建线上预约系统(附 Dify AI 工作流集成)
一、项目背景:为什么做这个系统?
拼豆(Perler Beads)是一种手工拼图艺术,线下体验店通常有 10-15个工位,顾客预约到店后选择图案、拼豆制作。传统预约靠微信/电话,存在:
- 工位冲突:同一时段多个顾客预约同一工位
- 流程混乱:预约→到店→制作→离店,没有标准化状态管理
- 无数据沉淀:顾客偏好、消费记录、库存消耗全靠人工记忆
"智约拼豆"就是为了解决这些痛点而设计的全栈系统。
二、系统架构设计
2.1 整体架构
用户(手机浏览器)
↓
Nginx(反向代理 /api → FastAPI, 静态文件 → Vue SPA)
↓
FastAPI(Uvicorn 8005)
├─ 9 个路由模块(users / stations / reservations / orders / products / coupons / inventory / ai / admin)
├─ JWT 认证层(bcrypt + HS256)
├─ Dify AI 工作流(创意推荐 + 分步教程)
├─ 支付宝 PC 网页支付(RSA2 签名)
└─ 阿里云短信验证码
↓
MySQL 8.0(12 张业务表)
2.2 技术选型理由
| 层 | 选型 | 为什么 |
|---|---|---|
| 前端框架 | Vue 3 | Composition API + <script setup> 写法简洁,移动端生态丰富 |
| UI 组件库 | Vant 4 | 有赞出品,50+ 移动端组件,轻量好用 |
| 状态管理 | Pinia 2 | 替代 Vuex,TypeScript 友好,无 mutations 噪音 |
| 后端框架 | FastAPI | 自动生成 OpenAPI 文档、异步支持、Pydantic 校验一体化 |
| ORM | SQLAlchemy 2.0 | 声明式模型 + 异步会话,生态成熟 |
| 数据校验 | Pydantic 2.5 | 运行时类型校验,与 FastAPI 深度绑定 |
| 数据库 | MySQL 8.0 | 支持 JSON 类型、ENUM 状态字段、事务安全 |
二(补充)、数据库设计:12 张业务表核心设计决策
数据库设计是预约系统的地基,这里分享几个关键设计决策。
核心设计原则
1. 用 ENUM 类型存状态(而非外键字典表)
订单的 7 个状态、预约的 5 个状态、工位的 3 个状态,全部用 MySQL ENUM 类型存储。为什么不建字典表?因为状态值是固定的、有限的,ENUM 比 JOIN 字典表快 30% 以上,且代码可读性更好。
2. User.total_spent 冗余字段
每次查询用户累计消费都要 SUM 订单表?太慢了。在 users 表里加一个 total_spent 字段,订单完成时累加,查询会员等级时直接读这个字段,避免每次 SUM 全表扫描。
3. BeadInventory 用触发器自动计算状态
库存状态(sufficient/warning/out)不靠定时任务轮询,而是用 MySQL 触发器在每次库存变动时自动计算,保证实时性。
12 张业务表核心字段一览
| 表名 | 核心字段 | 设计理由 |
|---|---|---|
| users | phone, password_hash, role, total_spent | total_spent 冗余,加速会员等级判断 |
| stations | name, status(ENUM: idle/occupied/maintenance) | ENUM 比外键表快,状态值固定 |
| reservations | user_id, station_id, start_time, end_time, status(ENUM) | 双字段索引(start_time, end_time),支持时间冲突查询 |
| orders | user_id, station_id, status(ENUM: 7值), total_amount | 状态机核心载体,每个状态变更触发连锁副作用 |
| order_items | order_id, product_id, quantity, price | 订单明细,支持多商品组合 |
| products | name, type, price, stock | 拼豆材料/成品统一管理 |
| bead_inventory | bead_id, quantity, status(ENUM) | 触发器自动计算状态,无需定时任务 |
| inventory_records | bead_id, change_type, quantity, operator_id | 库存变动审计,可追溯每次操作 |
| restock_records | bead_id, quantity, supplier, operator_id | 补货记录,支持供应商管理 |
| coupons | name, discount, threshold, valid_days | 优惠券模板,不存储用户关联 |
| user_coupons | user_id, coupon_id, status(ENUM: available/used/expired) | 多状态券,避免删除原始券模板 |
| spending_rewards | user_id, threshold, coupon_id, issued_at | 满额送券发放记录,防重复发放 |
关键索引设计
-- 预约时间冲突查询的核心索引
CREATE INDEX idx_reservations_time ON reservations(station_id, start_time, end_time, status);
-- 订单状态查询
CREATE INDEX idx_orders_status ON orders(user_id, status);
-- 库存预警查询
CREATE INDEX idx_inventory_status ON bead_inventory(status, quantity);
为什么不用外键?
项目中所有表都没有物理外键(FOREIGN KEY),原因:
- 写入性能:外键每次插入/更新都要检查引用完整性,高并发场景下是瓶颈
- 删除灵活性:物理外键让删除操作必须严格按顺序,开发调试不方便
- ORM 层面约束:SQLAlchemy 的
relationship已经提供了逻辑关联,代码层面保证数据一致性
三、核心业务状态机设计(最值得讲的架构决策)
这是本项目中技术含量最高的部分——订单和预约各有独立的状态机,且两者联动。
3.1 订单 7 状态流转
pending → paid → arrived → in_progress → departed → completed
↓
cancelled(可从 pending/paid 取消)
每个状态转换不是简单的字段更新,而是触发连锁副作用:
| 状态变更 | 触发的连锁操作 |
|---|---|
pay |
关联预约状态 → confirmed;调用支付宝生成支付链接 |
arrive |
管理员确认到店;关联预约 → active |
start |
分配工位 → occupied;记录 started_at 时间戳 |
depart |
记录 ended_at;工位仍 occupied |
complete |
释放工位 → idle;累加 total_spent;触发满额送券检查 |
cancel |
退回优惠券 → available;释放工位 → idle;预约 → pending |
关键代码(orders.py 中每个端点都做前置状态校验):
@router.put("/orders/{order_id}/start")
def start_order(order_id: int, current_user: User = Depends(get_current_admin), db: Session = Depends(get_db)):
order = db.query(Order).filter(Order.id == order_id).first()
# 严格的状态前置检查
if order.status not in ["paid", "arrived"]:
raise HTTPException(400, detail="只能开始已支付或已到店的订单")
order.status = "in_progress"
order.started_at = datetime.now()
# 连锁副作用:标记工位占用
station = db.query(Station).filter(Station.id == order.station_id).first()
station.status = "occupied"
db.commit()
这种设计本质上是一种领域驱动思路——订单模块不仅仅是 CRUD,而是整个业务的中枢协调器。
3.2 预约 5 状态流转 + 时间冲突检测
pending → confirmed → active → completed
↓
cancelled(可从 pending/confirmed 取消)
时间冲突检测算法(这是预约系统的核心算法):
# 两段时间重叠的充分必要条件:s1 < e2 AND e1 > s2
existing = db.query(Reservation).filter(
Reservation.station_id == station_id,
Reservation.status.in_(["pending", "confirmed"]),
Reservation.start_time < new_end_time, # 新预约结束 > 已有开始
Reservation.end_time > new_start_time # 新预约开始 < 已有结束
).first()
| 已有预约 | 新预约 | 重叠? |
|---|---|---|
| 10:00-12:00 | 11:00-13:00 | YES |
| 10:00-12:00 | 12:00-14:00 | NO(恰好衔接,不重叠) |
| 10:00-12:00 | 09:00-10:00 | NO |
这个算法的核心是两个不等式,数学上等价于"两段区间的交集非空"。特别注意 12:00-14:00 与 10:00-12:00 不重叠——用的是严格不等号 < / >,保证了"恰好衔接"的场景不被误判。
四、Dify AI 工作流集成(最有话题度的部分)
4.1 整体架构
系统集成了 Dify 工作流平台,提供两个 AI 功能:
- 创意推荐:根据顾客偏好(IP、风格、难度)推荐拼豆图案配色方案
- 分步教程:为选定的图案生成制作步骤指南
前端请求 → FastAPI /api/ai/creative 或 /api/ai/tutorial
↓
检查 DIFY_ENABLED 环境变量
├─ True → httpx.AsyncClient 调用 Dify Workflow API
│ ↓ 成功 → _normalize_creative_result() 清洗数据 → 返回
│ ↓ 异常 → fallback 到本地模板数据 + _fallback=True 标记
└─ False → 直接返回本地 30+ 热门 IP 模板数据(演示模式)
4.2 URL 自适应路由(兼容三种 Dify 部署形态)
if "/workflows/run" in DIFY_API_URL:
# Dify Cloud 新版:workflow_id 放 body
data["workflow_id"] = workflow_id
elif "api.dify.ai" in DIFY_API_URL:
# Dify Cloud 旧版:workflow_id 放 URL 路径
url = f"https://api.dify.ai/v1/workflows/{workflow_id}/run"
else:
# 自建 Dify:自定义域名拼接
url = f"{DIFY_API_URL.rstrip('/')}/workflows/run"
data["workflow_id"] = workflow_id
这个设计兼容了 Dify 的三种常见部署方式。在实际项目中,开发环境用 Dify Cloud,生产环境可能用自建 Dify,URL 自适应避免了每次切换环境都要改代码。
4.3 脏数据清洗管道(120 行的 normalize 函数)
Dify 工作流的输出格式非常不稳定——同样的工作流,可能返回:
// 格式1:直接 dict
{"name": "小黄人", "colors": ["黄色", "蓝色"], "difficulty": "简单"}
// 格式2:text 数组(Dify 经典格式)
{"text": [{"name": "小黄人"}, {"colors": ["黄色"]}], ...}
// 格式3:纯文本里嵌 JSON
{"text": "{\"name\": \"小黄人\", \"colors\": ...}"}
// 格式4:字段名不一致
{"materials": ["黄色拼豆", "蓝色拼豆"], "steps_text": "..."}
_normalize_creative_result() 函数逐层拆解,统一归约为前端需要的标准格式:
def _normalize_creative_result(raw_data):
result = raw_data
# 1. 如果是 {"text": [...]} 数组格式,逐元素合并
if isinstance(result, dict) and "text" in result:
if isinstance(result["text"], list):
merged = {}
for item in result["text"]:
if isinstance(item, dict):
merged.update(item)
result = merged
# 2. 如果是纯文本,提取内嵌 JSON
elif isinstance(result["text"], str):
text = result["text"]
json_start = text.find("{")
json_end = text.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
result = json.loads(text[json_start:json_end])
# 3. 字段映射:materials → colors
if "materials" in result and "colors" not in result:
result["colors"] = result.pop("materials")
# 4. 填充默认值
result.setdefault("name", "创意拼豆作品")
result.setdefault("difficulty", "简单")
return result
这 120 行代码是我对接 Dify 过程中踩坑最多的地方——Dify 工作流的输出格式几乎每次调整都会变,如果没有这个清洗管道,前端渲染会反复崩溃。
4.4 API Key 防呆检查
try:
DIFY_API_KEY.encode("ascii")
except UnicodeEncodeError:
raise HTTPException(detail="DIFY_API_KEY 包含非ASCII字符,请检查是否误填了应用名称")
这个小细节救了我好几次——Dify 控制台的应用名称是中文的,很多人(包括我自己)一开始会把中文应用名当成 API Key 填进去,导致莫名其妙的 401 错误。提前拦截并给出中文指引,比在日志里翻 401 好太多了。
五、支付宝支付对接实战
5.1 PKCS#8 → PKCS#1 私钥转换
支付宝开放平台下载的私钥是 PKCS#8 格式,但 alipay-sdk-python 3.x 要求 PKCS#1 格式。这是一个非常隐晦的坑——SDK 文档里几乎没有提及。
def _convert_pkcs8_to_pkcs1(self, pkcs8_key: str) -> str:
import base64
from Crypto.Util.asn1 import DerSequence, DerOctetString
der = base64.b64decode(pkcs8_key_b64)
seq = DerSequence()
seq.decode(der)
# PKCS#8 结构:[version, algorithmIdentifier, privateKeyOctet]
# 第3个元素就是 PKCS#1 裸 DER
pkcs1_octet = seq[2]
octet = DerOctetString()
octet.decode(pkcs1_octet)
pkcs1_der = octet.payload
return base64.b64encode(pkcs1_der).decode('ascii')
原理:PKCS#8 是一个 ASN.1 包装结构,格式为 [版本号, 算法标识, OctetString(内含PKCS#1)]。我们用 pycryptodome 的 DerSequence 解析到第 3 个元素,再用 DerOctetString 剥出内含的 PKCS#1 裸 DER。这纯粹是密码学层面的格式转换。
转换失败时的容错设计:
try:
config.app_private_key = self._convert_pkcs8_to_pkcs1(self.private_key)
except Exception as e:
print(f"密钥转换失败,使用原始格式:{str(e)}")
config.app_private_key = self.private_key # 回退到原始格式
5.2 双通道支付状态确认
系统同时支持两种支付状态确认方式:
- 被动回调:
POST /api/orders/alipay/notify— 支付宝异步通知,先验签再更新订单 - 主动查询:
GET /api/orders/{id}/payment-status— 前端轮询,调用支付宝查单接口
# 被动回调验签
@router.post("/alipay/notify")
async def alipay_notify(request: Request, db: Session = Depends(get_db)):
form_data = await request.form()
sign = form_data.get("sign")
# RSA2 签名验证
if not alipay_service.verify_signature(dict(form_data), sign):
return "fail"
# 更新订单状态为 paid
...
# 主动查询(前端轮询)
@router.get("/orders/{order_id}/payment-status")
def check_payment(order_id: int, db: Session = Depends(get_db)):
result = alipay_service.query_payment(out_trade_no=str(order_id))
if result.get("trade_status") == "TRADE_SUCCESS":
# 更新订单...
return {"status": "paid"}
这种双通道设计保证了支付状态的最终一致性——即使异步回调因网络问题丢失,前端轮询也能兜底确认。
六、JWT 认证设计
6.1 bcrypt 密码处理 + 72 字节截断
def hash_password(password: str) -> str:
# bcrypt 有 72 字节输入限制,超长密码会被静默截断
# 显式截断是好的工程实践——避免"用户以为密码很长很安全"的隐患
password = password[:72]
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
这个截断是很多人忽略的细节。bcrypt 算法只处理前 72 字节,后面的内容直接丢弃。如果不做显式截断,一个 100 字节的密码和它的前 72 字节版本在 bcrypt 眼里是一样的——用户以为密码很长很安全,实际上只有前 72 字节有效。
6.2 FastAPI 依赖注入链
# 认证层:从 JWT 解析用户
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user = db.query(User).filter(User.id == payload.get("sub")).first()
if not user:
raise HTTPException(401, "用户不存在")
return user
# 鉴权层:叠加角色检查
async def get_current_admin(current_user: User = Depends(get_current_user)):
if current_user.role != "admin":
raise HTTPException(403, "需要管理员权限")
return current_user
get_current_admin 基于 get_current_user 的结果叠加角色检查,本质是"认证"和"鉴权"的分层解耦。这是 FastAPI 最优雅的特性之一。
七、阶梯满额送券系统
SPENDING_REWARD_RULES = [
{"threshold": 200, "coupon_discount": 20}, # 满200送20元券
{"threshold": 500, "coupon_discount": 50}, # 满500送50元券
{"threshold": 1000, "coupon_discount": 100}, # 满1000送100元券
]
def check_and_issue_reward_coupon(user, total_amount, db):
for rule in SPENDING_REWARD_RULES:
if total_amount >= rule["threshold"]:
# 防重复发放:检查已有优惠券 ID
existing_ids = {uc.coupon_id for uc in user.user_coupons}
if coupon.id not in existing_ids:
user_coupon = UserCoupon(
user_id=user.id, coupon_id=coupon.id, status="available")
db.add(user_coupon)
两个关键设计:
- 防重复发放:用
existing_ids集合判断,避免同一门槛触发多次发放 - 懒创建优惠券模板:如果对应门槛的优惠券模板不存在,先创建模板再发放
七(补充)、库存管理模块:自动状态计算 + 使用记录追踪
拼豆库存管理是一个有业务深度的模块——每种颜色的拼豆都有库存,需要自动判断状态(充足/预警/缺货),并记录每次使用和补货。
库存状态自动计算
class BeadInventory(Base):
__tablename__ = "bead_inventory"
id = Column(Integer, primary_key=True)
bead_id = Column(Integer, ForeignKey("beads.id"))
quantity = Column(Integer, default=0)
status = Column(ENUM("sufficient", "warning", "out"), default="sufficient")
# 触发器:每次 quantity 更新时自动计算 status
# 用 SQL 事件而非 Python 逻辑,保证数据一致性
"""
CREATE TRIGGER trg_bead_inventory_status
BEFORE UPDATE ON bead_inventory
FOR EACH ROW
BEGIN
IF NEW.quantity <= 0 THEN
SET NEW.status = 'out';
ELSEIF NEW.quantity < 50 THEN
SET NEW.status = 'warning';
ELSE
SET NEW.status = 'sufficient';
END IF;
END;
"""
为什么用触发器而不是 Python 代码? 因为库存变动可能来自多个入口(订单消耗、手动调整、补货),用触发器保证无论从哪个入口修改,状态计算逻辑都一致,不会出现"某个入口忘了更新状态"的 bug。
使用记录追踪(审计日志)
class InventoryRecord(Base):
__tablename__ = "inventory_records"
id = Column(Integer, primary_key=True)
bead_id = Column(Integer, ForeignKey("beads.id"))
change_type = Column(ENUM("order_consume", "restock", "manual_adjust", "waste"))
quantity = Column(Integer) # 正数=增加,负数=减少
operator_id = Column(Integer, ForeignKey("users.id"))
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True)
created_at = Column(DateTime, default=datetime.now)
每条库存变动都记录 change_type 和 operator_id,支持追溯:
- order_consume:订单消耗,关联
order_id,可查某个订单用了哪些材料 - restock:补货入库,记录供应商和批次
- manual_adjust:盘点调整,需要管理员备注原因
- waste:损耗报废,用于统计损耗率
补货管理
class RestockRecord(Base):
__tablename__ = "restock_records"
id = Column(Integer, primary_key=True)
bead_id = Column(Integer, ForeignKey("beads.id"))
quantity = Column(Integer)
supplier = Column(String(100))
unit_price = Column(Decimal(10, 2))
operator_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.now)
补货记录支持供应商管理和成本核算,方便月底对账。
库存预警 API
@router.get("/inventory/warnings")
def get_inventory_warnings(db: Session = Depends(get_db)):
"""获取所有库存预警和缺货的拼豆"""
warnings = db.query(BeadInventory).filter(
BeadInventory.status.in_(["warning", "out"])
).all()
return [
{
"bead_id": w.bead_id,
"bead_name": w.bead.name,
"quantity": w.quantity,
"status": w.status,
"last_restock": get_last_restock_date(w.bead_id, db)
}
for w in warnings
]
这个接口每天定时调用,推送到管理员手机,实现主动预警而非被动发现。
八、阿里云短信的错误码聚合
阿里云短信 SDK 的错误码非常多(isv.BUSINESS_LIMIT_CONTROL、isv.SMS_SIGNATURE_ILLEGAL 等),这里做了四类聚合 + 中文友好提示:
if "FREQUENCY" in code_str.upper():
raise RuntimeError("发送过于频繁,请稍后重试")
elif "SIGN" in code_str.upper() or "签名" in msg:
raise RuntimeError("短信签名未配置或未审核通过")
elif "TEMPLATE" in code_str.upper() or "模板" in msg:
raise RuntimeError("短信模板错误,请检查模板内容")
else:
raise RuntimeError(f"短信发送失败: {msg or code_str}")
加上懒加载设计(SDK 只在首次发送时加载),未安装时给出精确 pip 命令:
try:
from alibabacloud_dypnsapi20170525.client import Client
except ImportError:
raise RuntimeError("短信SDK未安装,请执行:pip install alibabacloud-dypnsapi20170525")
九、前端路由守卫设计
Vue Router 的 beforeEach 守卫实现了三层权限控制:
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
if (requiresAuth && !userStore.token) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else if (requiresAdmin && userStore.role !== 'admin') {
next({ path: '/' }) // 非管理员跳首页
} else {
next()
}
})
19 个路由全部使用懒加载(() => import(...)),首屏只加载当前页面组件,减小初始 bundle 体积。
十、踩坑记录 & 技术反思
10.1 Dify 输出格式不稳定
问题:Dify 工作流调整后,输出格式从 dict 变成 string,前端直接崩溃。
解决:写了 120 行 _normalize_creative_result() 清洗管道,兼容 8 种返回格式。后续 Dify 调整不再影响前端渲染。
反思:对接第三方 AI 平台时,永远不要假设返回格式稳定。必须写清洗层。
10.2 支付宝私钥格式陷阱
问题:支付宝开放平台下载的私钥是 PKCS#8,但 SDK 3.x 要求 PKCS#1。错误提示是"签名失败",完全看不出是格式问题。
解决:用 pycryptodome 做 ASN.1 层面的格式转换,并加了容错回退。
反思:支付对接的坑往往不在业务逻辑,而在密钥格式、编码方式、沙箱/正式环境切换这些底层细节。
10.3 JWT 不存储 role
问题:最初在 JWT payload 中存储了 role,但管理员修改用户角色后,旧 Token 里的 role 仍是旧值。
解决:Token 只存 sub(用户ID),每次请求都从数据库实时查 role。
反思:JWT 是无状态的,适合存"不变的声明"(用户ID),不适合存"可能变更的事实"(角色、权限)。
10.4 bcrypt 72 字节截断
问题:测试时发现一个 80 字符密码和它的前 72 字符版本登录结果相同。
解决:显式截断 password[:72],让行为透明可预期。
反思:安全算法的隐式行为(静默截断)比显式行为(报错)更危险,因为前者不会引起注意。
10.5 N+1 查询问题(性能优化实战)
问题:admin.py 中查询订单列表时,每查一个订单都要额外查询关联的用户和工位信息。
# 错误写法:N+1 查询
orders = db.query(Order).filter(Order.status == "paid").all()
for order in orders:
user = db.query(User).filter(User.id == order.user_id).first() # N次查询!
station = db.query(Station).filter(Station.id == order.station_id).first() # N次查询!
当订单数量为 100 时,这个接口会执行 1(主查询)+ 100(用户)+ 100(工位)= 201 次 SQL 查询。页面加载时间从 50ms 飙升到 3 秒以上。
解决:用 SQLAlchemy 的 joinedload 一次性 JOIN 加载关联数据。
from sqlalchemy.orm import joinedload
# 正确写法:一次查询搞定
orders = db.query(Order).options(
joinedload(Order.user),
joinedload(Order.station)
).filter(Order.status == "paid").all()
# 此时访问 order.user 和 order.station 不会触发额外查询
for order in orders:
print(f"{order.user.phone} - {order.station.name}")
如何发现 N+1?
- 看日志:开启 SQLAlchemy 的
echo=True,观察是否有大量重复的 SELECT 语句 - 用工具:
SQLAlchemy的counts插件可以统计查询次数 - 手动测试:在接口里加个计时器,对比优化前后的响应时间
# 开启 SQL 日志
engine = create_engine(DATABASE_URL, echo=True)
# 或者用更优雅的方式——只打印慢查询
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
反思:ORM 的"便利性"会隐藏 SQL 执行细节。joinedload 和 lazy=True 的区别,是每个 FastAPI 开发者必须理解的核心概念。建议在开发环境开启 SQL 日志,养成"写完接口看一眼 SQL 执行次数"的习惯。
十一、部署方案
推荐 Docker Compose 一键部署:
services:
backend:
build: ./backend
ports: ["8005:8005"]
environment:
- DATABASE_URL=mysql+pymysql://root:xxx@db:3306/pindou
- DIFY_API_KEY=app-xxx
- ALIPAY_APP_ID=xxx
depends_on: [db]
frontend:
build: ./frontend
ports: ["80:80"]
depends_on: [backend]
db:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=xxx
- MYSQL_DATABASE=pindou
volumes: ["mysql_data:/var/lib/mysql"]
nginx:
image: nginx:alpine
ports: ["80:80"]
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- frontend_dist:/usr/share/nginx/html
Nginx 配置要点:
/api路径反向代理到backend:8005- 其他路径返回 Vue SPA 静态文件
- SPA 路由需要
try_files $uri /index.html兜底
十二、总结
这个项目虽然业务场景小(拼豆体验店),但技术含量不低——涵盖了:
- 状态机设计:7 状态订单 + 5 状态预约,每个转换带连锁副作用
- AI 工作流集成:URL 自适应 + 降级兜底 + 脏数据清洗
- 支付对接:PKCS#8/PKCS#1 密钥转换 + 双通道确认
- 认证安全:JWT 依赖注入链 + bcrypt 截断保护
- 短信封装:错误码聚合 + 懒加载 + 模拟降级
- 前端工程:Vue 3 Composition API + 路由守卫 + Pinia 持久化
如果你也在做类似的预约/支付系统,希望这篇实战记录能帮到你。欢迎评论区交流!
更多推荐




所有评论(0)