feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录
- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制 - 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化 - 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离 - 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料 - 设备模块: 序列号+激活码绑定, 防重复绑定 - 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share) - 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider - 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通) - 前端新增6页面, 共18页面
This commit is contained in:
@@ -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])
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""扩展业务模型:支付单、设备、公司参数、文章、验证码。"""
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from huamenchuang.core.database import Base
|
||||
|
||||
|
||||
class PayOrder(Base):
|
||||
"""支付订单(支付通道适配层:微信/支付宝凭据就绪后接通)。"""
|
||||
|
||||
__tablename__ = "pay_orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
pay_no: Mapped[str] = mapped_column(String(32), unique=True) # PAY+日期+流水
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
user_id: Mapped[int] = mapped_column(default=0)
|
||||
subject: Mapped[str] = mapped_column(String(128), default="") # 商品名
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
channel: Mapped[str] = mapped_column(String(16), default="wechat") # wechat/alipay
|
||||
status: Mapped[int] = mapped_column(default=0) # 0待支付 1已支付 2已取消
|
||||
out_trade_no: Mapped[str] = mapped_column(String(64), default="") # 三方流水号
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
paid_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
"""设备(对齐目标站 device-activate/deviceDocking:激活码绑定)。"""
|
||||
|
||||
__tablename__ = "devices"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
name: Mapped[str] = mapped_column(String(64), default="")
|
||||
sn: Mapped[str] = mapped_column(String(64), unique=True) # 设备序列号
|
||||
activate_code: Mapped[str] = mapped_column(String(32), default="")
|
||||
status: Mapped[int] = mapped_column(default=0) # 0未激活 1已激活 2已停用
|
||||
bound_user_id: Mapped[int] = mapped_column(default=0)
|
||||
activated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class CompanyParam(Base):
|
||||
"""公司级参数存储(对齐原站 find/update_company_param_value)。"""
|
||||
|
||||
__tablename__ = "company_params"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class Article(Base):
|
||||
"""帮助文章(对齐原站 articleList / 使用指南)。"""
|
||||
|
||||
__tablename__ = "articles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(128))
|
||||
category: Mapped[str] = mapped_column(String(32), default="常见问题", index=True)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
sort: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class SmsCode(Base):
|
||||
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
|
||||
|
||||
__tablename__ = "sms_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), index=True)
|
||||
code: Mapped[str] = mapped_column(String(8))
|
||||
scene: Mapped[str] = mapped_column(String(16), default="login") # login/reset/bind
|
||||
used: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
@@ -80,6 +80,7 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
|
||||
for b in avail if b["pieces"]
|
||||
]
|
||||
total_demand = sum(p.length for p in pieces)
|
||||
placed_demand = total_demand - sum(p.length for p in unplaced)
|
||||
total_stock = sum(p.stock_length for p in plans)
|
||||
return {
|
||||
"plans": [
|
||||
@@ -98,7 +99,8 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
|
||||
"stock_bars_used": len(plans),
|
||||
"total_demand_mm": total_demand,
|
||||
"total_stock_mm": total_stock,
|
||||
"utilization": round(total_demand / total_stock, 4) if total_stock else 0,
|
||||
# 利用率 = 已排入件长度 / 已用原料长度(恒 ≤ 1)
|
||||
"utilization": round(placed_demand / total_stock, 4) if total_stock else 0,
|
||||
"kerf_mm": kerf,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user