feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录

- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制
- 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化
- 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离
- 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料
- 设备模块: 序列号+激活码绑定, 防重复绑定
- 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share)
- 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider
- 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通)
- 前端新增6页面, 共18页面
This commit is contained in:
HHY
2026-09-20 12:10:26 -07:00
parent b2730af80b
commit 3f871d7eeb
19 changed files with 1192 additions and 3 deletions
+291
View File
@@ -0,0 +1,291 @@
"""平台扩展路由:验证码登录、支付、渲染设置、下料方案、设备、分享、文章。"""
import datetime
import random
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
from huamenchuang.core.database import SessionLocal
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import Quotation
from huamenchuang.models.contract import Contract
from huamenchuang.models.extend import Article, CompanyParam, Device, PayOrder, SmsCode
from huamenchuang.models.order import Order
from huamenchuang.services.quote_service import quote as calc_quote
router = APIRouter(tags=["平台扩展"])
# ---------------- 短信验证码(provider 适配层) ----------------
async def _issue_code(db, phone: str, scene: str) -> str:
"""签发验证码。未接入短信签名时走 mock 通道(控制台输出,响应不回传码)。"""
code = f"{random.randint(0, 999999):06d}"
db.add(SmsCode(phone_number=phone, code=code, scene=scene))
print(f"[SMS:mock] {phone} 的{scene}验证码: {code}") # 接入真实短信后替换为服务商调用
return code
class CodeBody(BaseModel):
phone_number: str
scene: str = Field(default="login", pattern="^(login|reset|bind)$")
@router.post("/verification-code-new")
async def send_code(body: CodeBody, db: DbSession):
"""发送短信验证码(mock 通道,见服务端日志)。"""
await _issue_code(db, body.phone_number, body.scene)
return ok(message="验证码已发送")
class CodeLoginBody(BaseModel):
phone_number: str
code: str
@router.post("/code-login")
async def code_login(body: CodeLoginBody, db: DbSession):
"""验证码登录:校验验证码后直接登录/注册。"""
stmt = (select(SmsCode).where(SmsCode.phone_number == body.phone_number, SmsCode.used == 0,
SmsCode.scene == "login").order_by(desc(SmsCode.id)).limit(1))
record = (await db.execute(stmt)).scalar_one_or_none()
if record is None or record.code != body.code:
return fail("验证码错误或已过期")
record.used = 1
from huamenchuang.core.security import create_token
from huamenchuang.models.user import User
from huamenchuang.services.auth_service import hash_password
u = (await db.execute(select(User).where(User.phone_number == body.phone_number))).scalar_one_or_none()
if u is None:
u = User(phone_number=body.phone_number, password_hash=hash_password(body.code),
nickname=f"用户{body.phone_number[-4:]}")
db.add(u)
await db.flush()
return ok(data={"token": create_token(u.id), "user": {"id": u.id, "nickname": u.nickname}},
message="登录成功")
# ---------------- 支付订单(通道适配层) ----------------
class PayBody(BaseModel):
subject: str
amount: float = Field(gt=0)
channel: str = Field(default="wechat", pattern="^(wechat|alipay)$")
@router.post("/pay-orders")
async def create_pay_order(body: PayBody, user: CurrentUser, db: DbSession):
"""创建支付订单。微信/支付宝商户凭据配置后接通真实下单;当前返回待支付单。"""
today = datetime.date.today().strftime("%Y%m%d")
seq = random.randint(0, 9999)
po = PayOrder(company_id=user.company_id, user_id=user.id, subject=body.subject[:128],
amount=body.amount, channel=body.channel, pay_no=f"PAY{today}{seq:04d}")
db.add(po)
await db.flush()
return ok(data={
"pay_no": po.pay_no, "amount": float(po.amount), "channel": po.channel,
"status": po.status,
"pay_url": "", # 微信/支付宝下单接口返回的支付链接,凭据就绪后填充
"tip": "支付通道未配置商户凭据,下单已记录,接通后自动唤起收银台",
}, message="支付订单已创建")
@router.get("/pay-orders")
async def list_pay_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
conds = [PayOrder.company_id == user.company_id]
total = (await db.execute(select(func.count()).select_from(PayOrder).where(*conds))).scalar_one()
stmt = (select(PayOrder).where(*conds).order_by(desc(PayOrder.id))
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"pay_no": p.pay_no, "subject": p.subject, "amount": float(p.amount),
"channel": p.channel, "status": p.status, "created_at": str(p.created_at),
} for p in items], page, page_size, total))
# ---------------- 渲染设置 + 简易渲染参数 ----------------
class RenderSettingBody(BaseModel):
wall_color: str = "#f5f0e8"
scene: str = Field(default="indoor", pattern="^(indoor|outdoor|studio)$")
glass_tint: str = Field(default="#cfe3ee", pattern="^#[0-9a-fA-F]{6}$")
@router.get("/rendering-info")
async def get_render_setting(user: CurrentUser, db: DbSession):
stmt = select(CompanyParam).where(CompanyParam.company_id == user.company_id,
CompanyParam.key == "render_setting")
p = (await db.execute(stmt)).scalar_one_or_none()
import json as _json
return ok(data=_json.loads(p.value) if p else {
"wall_color": "#f5f0e8", "scene": "indoor", "glass_tint": "#cfe3ee"})
@router.post("/rendering-info")
async def save_render_setting(body: RenderSettingBody, user: CurrentUser, db: DbSession):
import json as _json
stmt = select(CompanyParam).where(CompanyParam.company_id == user.company_id,
CompanyParam.key == "render_setting")
p = (await db.execute(stmt)).scalar_one_or_none()
if p is None:
p = CompanyParam(company_id=user.company_id, key="render_setting")
db.add(p)
p.value = _json.dumps(body.model_dump())
return ok(data=body.model_dump(), message="渲染设置已保存")
# ---------------- 下料方案(cut_solution) ----------------
class CutSolutionBody(BaseModel):
contract_id: int
kerf: int = Field(default=5, ge=0, le=50)
@router.post("/cut_solution")
async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
"""合同下料方案:汇总合同下订单的算料需求,执行一维优化并保存参数。"""
contract = await db.get(Contract, body.contract_id)
if contract is None or contract.company_id != user.company_id:
return fail("合同不存在")
stmt = select(Order).where(Order.contract_id == body.contract_id,
Order.company_id == user.company_id)
orders = list((await db.execute(stmt)).scalars())
demands: dict[tuple, float] = {}
for o in orders:
if not o.drawing_data:
continue
from huamenchuang.core.drawing import parse_drawing
from huamenchuang.services.quote_service import takeoff
t = takeoff(parse_drawing(o.drawing_data))
for b in t.bars:
if b.kind == "glass":
continue
key = (b.length, b.note)
demands[key] = demands.get(key, 0) + b.qty
if not demands:
return fail("该合同下没有可算料的订单图纸")
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
dem = [BarDemand(length=k[0], qty=int(v), label=k[1]) for k, v in demands.items()]
stocks = [StockBar(length=6000, qty=max(1, sum(d.qty for d in dem) // 3 + 1), label="6米型材")]
result = optimize_1d(dem, stocks, body.kerf)
await _save_param(db, user.company_id, f"cut_solution_{body.contract_id}",
__import__("json").dumps({"demands": [[k[0], int(v), k[1]] for k, v in demands.items()],
"kerf": body.kerf}, ensure_ascii=False))
return ok(data={"contract_no": contract.contract_no, "order_count": len(orders), **result},
message="下料方案已生成")
async def _save_param(db, company_id: int, key: str, value: str) -> None:
stmt = select(CompanyParam).where(CompanyParam.company_id == company_id, CompanyParam.key == key)
p = (await db.execute(stmt)).scalar_one_or_none()
if p is None:
p = CompanyParam(company_id=company_id, key=key)
db.add(p)
p.value = value
# ---------------- 设备模块 ----------------
class DeviceBindBody(BaseModel):
sn: str
activate_code: str
name: str = ""
@router.post("/device-activate")
async def device_activate(body: DeviceBindBody, user: CurrentUser, db: DbSession):
"""设备激活绑定:序列号+激活码。"""
stmt = select(Device).where(Device.sn == body.sn)
dev = (await db.execute(stmt)).scalar_one_or_none()
if dev is None:
dev = Device(sn=body.sn, activate_code=body.activate_code, name=body.name or body.sn,
company_id=user.company_id)
db.add(dev)
await db.flush()
if dev.activate_code and dev.activate_code != body.activate_code:
return fail("激活码错误")
if dev.status == 1 and dev.bound_user_id not in (0, user.id):
return fail("设备已被其他账户绑定")
dev.status = 1
dev.bound_user_id = user.id
dev.activated_at = datetime.datetime.utcnow()
return ok(data={"id": dev.id, "sn": dev.sn, "name": dev.name}, message="设备激活成功")
@router.get("/devices")
async def list_devices(user: CurrentUser, db: DbSession):
stmt = select(Device).where(Device.company_id == user.company_id).order_by(desc(Device.id))
items = list((await db.execute(stmt)).scalars())
return ok(data=[{
"id": d.id, "sn": d.sn, "name": d.name, "status": d.status,
"activated_at": str(d.activated_at) if d.activated_at else None,
} for d in items])
# ---------------- 分享订单(customOrder-share 公开只读页数据) ----------------
def _sign(oid: int) -> str:
import hashlib, hmac
from huamenchuang.core.config import settings
return hmac.new(settings.jwt_secret.encode(), str(oid).encode(), hashlib.sha256).hexdigest()[:8]
def make_share_token(order_id: int) -> str:
"""可逆分享 token: base64(id.签名)。"""
import base64
raw = f"{order_id}.{_sign(order_id)}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def parse_share_token(token: str) -> int | None:
import base64
try:
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)).decode()
oid_s, sig = raw.split(".", 1)
oid = int(oid_s)
return oid if hmac.compare_digest(sig, _sign(oid)) else None
except Exception:
return None
import hmac # noqa: E402
@router.get("/shared-order/{token}")
async def shared_order(token: str, db: DbSession):
"""通过分享 token 公开只读订单摘要(无需登录)。"""
oid = parse_share_token(token)
if oid is None:
return fail("分享链接无效")
o = await db.get(Order, oid)
if o is None or not o.drawing_data:
return fail("订单不存在或未开放分享")
return ok(data={
"order_no": o.order_no, "customer_name": o.customer_name, "status": o.status,
"pic_url": o.pic_url, "area": float(o.area), "count": o.count,
"created_at": str(o.created_at),
})
@router.post("/share-order/{order_id}")
async def share_order(order_id: int, user: CurrentUser, db: DbSession):
"""生成订单分享链接 token。"""
o = await db.get(Order, order_id)
if o is None or o.company_id != user.company_id:
return fail("订单不存在")
token = make_share_token(order_id)
return ok(data={"token": token, "url": f"/share/order/{token}"})
# ---------------- 帮助文章 ----------------
@router.get("/articles")
async def list_articles(db: DbSession, category: str | None = None):
conds = []
if category:
conds.append(Article.category == category)
stmt = select(Article).where(*conds).order_by(Article.sort, Article.id)
items = list((await db.execute(stmt)).scalars())
return ok(data=[{"id": a.id, "title": a.title, "category": a.category, "content": a.content}
for a in items])
+2
View File
@@ -8,6 +8,7 @@ from huamenchuang.api import (
customers,
optimize,
orders,
platform,
quote,
warehouse,
windoor,
@@ -23,3 +24,4 @@ api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)