feat: v1.0 AI炫图工作台上线+业务做实+按需裁剪+主题精致化

- AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角,
  seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块
- 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数,
  报价单转订单携带图纸
- 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览
- 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级
- 全接口回归通过, 移除项405确认
This commit is contained in:
HHY
2026-09-20 13:15:31 -07:00
parent 453decf2e4
commit a20f3df854
15 changed files with 566 additions and 214 deletions
+25 -7
View File
@@ -28,14 +28,27 @@ async def login(body: LoginRequest, db: DbSession):
}, message="登录成功")
class RegisterBody(BaseModel):
phone_number: str
password: str
invite_code: str = ""
@router.post("/register")
async def register(body: LoginRequest, db: DbSession):
"""注册并直接返回 token。"""
async def register(body: RegisterBody, db: DbSession):
"""注册(支持邀请码,真实记录邀请关系)。"""
service = AuthService(db)
try:
user = await service.register(body.phone_number, body.password)
except ValueError as e:
return fail(str(e))
if body.invite_code.startswith("HMC"):
try:
inviter_id = int(body.invite_code[3:])
if inviter_id != user.id:
user.invited_by = inviter_id
except ValueError:
pass
from huamenchuang.core.security import create_token
return ok(data={"token": create_token(user.id), "user_id": user.id}, message="注册成功")
@@ -86,11 +99,16 @@ async def change_password(body: PasswordBody, user: CurrentUser, db: DbSession):
@router.get("/invitation")
async def invitation_info(user: CurrentUser):
"""邀请信息(对齐原站 personal-center/invitation 返利)。"""
async def invitation_info(user: CurrentUser, db: DbSession):
"""邀请信息(真实计数:被邀请人 = invited_by 指向当前用户的账户)。"""
from sqlalchemy import func, select
from huamenchuang.models.user import User
invited = (await db.execute(
select(func.count()).select_from(User).where(User.invited_by == user.id)
)).scalar_one()
return ok(data={
"invite_code": f"HMC{user.id:06d}",
"invited_count": 0,
"bounty": "0.00",
"rule": "好友通过邀请码注册并购买后,可获得相应奖励",
"invited_count": invited,
"bounty": f"{invited * 20:.2f}",
"rule": "好友通过邀请码注册后,每位奖励 ¥20(购买后可抵扣)",
})
+30 -109
View File
@@ -1,133 +1,47 @@
"""平台扩展路由:验证码登录、支付、渲染设置、下料方案、设备、分享、文章。"""
"""平台扩展路由:渲染设置、下料方案、设备、分享、文章、AI 炫图风格配置。"""
import datetime
import random
from fastapi import APIRouter, Depends
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
from sqlalchemy import desc, 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.core.response import fail, ok
from huamenchuang.models.contract import Contract
from huamenchuang.models.extend import Article, CompanyParam, Device, PayOrder, SmsCode
from huamenchuang.models.extend import Article, CompanyParam, Device
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))
# ---------------- 渲染设置 + 简易渲染参数 ----------------
# ---------------- 渲染设置 + AI 炫图风格配置 ----------------
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}$")
ai_style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$")
ai_time: str = Field(default="day", pattern="^(day|dusk|night)$")
async def _get_param(db, company_id: int, key: str):
stmt = select(CompanyParam).where(CompanyParam.company_id == company_id, CompanyParam.key == key)
return (await db.execute(stmt)).scalar_one_or_none()
@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"})
p = await _get_param(db, user.company_id, "render_setting")
defaults = {"wall_color": "#f5f0e8", "scene": "indoor", "glass_tint": "#cfe3ee",
"ai_style": "modern", "ai_time": "day"}
return ok(data={**defaults, **(_json.loads(p.value) if p else {})})
@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()
p = await _get_param(db, user.company_id, "render_setting")
if p is None:
p = CompanyParam(company_id=user.company_id, key="render_setting")
db.add(p)
@@ -176,6 +90,16 @@ async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
message="下料方案已生成")
@router.get("/cut_solution/{contract_id}")
async def get_cut_solution(contract_id: int, user: CurrentUser, db: DbSession):
"""读取已保存的下料方案参数。"""
p = await _get_param(db, user.company_id, f"cut_solution_{contract_id}")
if p is None:
return fail("尚未生成下料方案")
import json as _json
return ok(data=_json.loads(p.value))
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()
@@ -239,19 +163,16 @@ def make_share_token(order_id: int) -> str:
def parse_share_token(token: str) -> int | None:
import base64
import base64, hmac as _hmac
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
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 公开只读订单摘要(无需登录)。"""
+26 -1
View File
@@ -15,6 +15,7 @@ class QuoteRequest(BaseModel):
template_id: int | None = Field(default=None, description="或直接指定样板")
order_id: int | None = Field(default=None, description="或对已有订单报价")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
@@ -32,14 +33,38 @@ async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
return None
async def _price_book(db, company_id: int) -> dict | None:
"""从产品价格本推导报价参数:各类材料均价。"""
from sqlalchemy import func, select
from huamenchuang.models.business import Product
rows = (await db.execute(
select(Product.material, func.avg(Product.price))
.where(Product.company_id == company_id, Product.enabled == 1)
.group_by(Product.material))).all()
if not rows:
return None
avg = {m: float(p or 0) for m, p in rows}
prices: dict = {}
if avg.get("profile"):
prices["frame"] = prices["mullion"] = prices["sash"] = round(avg["profile"], 2)
if avg.get("glass"):
prices["glass"] = round(avg["glass"], 2)
if avg.get("hardware"):
prices["hardware"] = round(avg["hardware"], 2)
return prices or None
@router.post("")
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。"""
drawing = await _resolve_drawing(db, body)
if drawing is None:
return fail("未找到图纸数据")
prices = body.prices
if prices is None and body.use_price_book:
prices = await _price_book(db, user.company_id)
try:
result = calc_quote(drawing, body.prices)
result = calc_quote(drawing, prices)
except Exception:
return fail("图纸数据解析失败")
return ok(data=result, message="报价成功")