一、项目背景:为什么做这个系统?

拼豆(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:0010: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)]。我们用 pycryptodomeDerSequence 解析到第 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)

两个关键设计:

  1. 防重复发放:用 existing_ids 集合判断,避免同一门槛触发多次发放
  2. 懒创建优惠券模板:如果对应门槛的优惠券模板不存在,先创建模板再发放

七(补充)、库存管理模块:自动状态计算 + 使用记录追踪

拼豆库存管理是一个有业务深度的模块——每种颜色的拼豆都有库存,需要自动判断状态(充足/预警/缺货),并记录每次使用和补货。

库存状态自动计算

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_typeoperator_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_CONTROLisv.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?

  1. 看日志:开启 SQLAlchemy 的 echo=True,观察是否有大量重复的 SELECT 语句
  2. 用工具SQLAlchemycounts 插件可以统计查询次数
  3. 手动测试:在接口里加个计时器,对比优化前后的响应时间
# 开启 SQL 日志
engine = create_engine(DATABASE_URL, echo=True)

# 或者用更优雅的方式——只打印慢查询
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)

反思:ORM 的"便利性"会隐藏 SQL 执行细节。joinedloadlazy=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 兜底

十二、总结

这个项目虽然业务场景小(拼豆体验店),但技术含量不低——涵盖了:

  1. 状态机设计:7 状态订单 + 5 状态预约,每个转换带连锁副作用
  2. AI 工作流集成:URL 自适应 + 降级兜底 + 脏数据清洗
  3. 支付对接:PKCS#8/PKCS#1 密钥转换 + 双通道确认
  4. 认证安全:JWT 依赖注入链 + bcrypt 截断保护
  5. 短信封装:错误码聚合 + 懒加载 + 模拟降级
  6. 前端工程:Vue 3 Composition API + 路由守卫 + Pinia 持久化

如果你也在做类似的预约/支付系统,希望这篇实战记录能帮到你。欢迎评论区交流!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐