2a107a42f3
- New DB models: credit_packages, subscription_plans, user_credits, credit_consumptions, credit_purchases - CreditService: balance, deduct, add_credits, grant_free_trial, history - User API: /api/v1/credits/* (balance/history/packages/purchase/subscribe) - Admin API: /api/v1/admin/credit-* (CRUD packages/plans, user credits, consumptions) - PaymentService.create_credit_order + handle_callback for credit purchases - Credit deduction on: discovery, translate, marketing, ai_chat, followup - Free trial 30 credits on registration - Documentation: docs/CREDIT_SYSTEM.md
230 lines
7.4 KiB
Python
230 lines
7.4 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from typing import Optional, List
|
|
from app.database import get_db
|
|
from app.services.product import ProductService
|
|
from app.services import export
|
|
from app.services.usage import UsageService
|
|
from app.api.v1.deps import get_current_user_id
|
|
from pydantic import BaseModel
|
|
import io
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import openpyxl
|
|
HAS_OPENPYXL = True
|
|
except ImportError:
|
|
HAS_OPENPYXL = False
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class ProductCreate(BaseModel):
|
|
name: str
|
|
name_en: Optional[str] = None
|
|
description: Optional[str] = None
|
|
description_en: Optional[str] = None
|
|
category: Optional[str] = None
|
|
price: Optional[str] = None
|
|
price_unit: Optional[str] = "USD"
|
|
moq: Optional[str] = None
|
|
keywords: Optional[list] = []
|
|
specifications: Optional[dict] = {}
|
|
images: Optional[list] = []
|
|
|
|
|
|
class ProductUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
name_en: Optional[str] = None
|
|
description: Optional[str] = None
|
|
description_en: Optional[str] = None
|
|
category: Optional[str] = None
|
|
price: Optional[str] = None
|
|
price_unit: Optional[str] = None
|
|
moq: Optional[str] = None
|
|
keywords: Optional[list] = None
|
|
specifications: Optional[dict] = None
|
|
images: Optional[list] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
@router.get("")
|
|
async def list_products(
|
|
category: Optional[str] = None,
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(20, ge=1, le=100),
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
service = ProductService(db)
|
|
return await service.list_products(user_id, category, page, size)
|
|
|
|
|
|
@router.get("/export/csv")
|
|
async def export_products(
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
service = ProductService(db)
|
|
result = await service.list_products(user_id, None, 1, 9999)
|
|
items = result.get("items", [])
|
|
csv_bytes = export.export_products_csv(items)
|
|
return Response(
|
|
content=csv_bytes,
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=products.csv"},
|
|
)
|
|
|
|
|
|
@router.get("/export/xlsx")
|
|
async def export_products_xlsx(
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
service = ProductService(db)
|
|
result = await service.list_products(user_id, None, 1, 9999)
|
|
items = result.get("items", [])
|
|
xlsx_bytes = export.export_products_xlsx(items)
|
|
return Response(
|
|
content=xlsx_bytes,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": "attachment; filename=products.xlsx"},
|
|
)
|
|
|
|
|
|
@router.post("/import")
|
|
async def import_products(
|
|
file: UploadFile = File(...),
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
from app.services.product import ProductService
|
|
|
|
from app.config import settings
|
|
|
|
MAX_UPLOAD_SIZE = settings.MAX_UPLOAD_SIZE
|
|
|
|
filename = file.filename or "unknown"
|
|
file_size = 0
|
|
content = b""
|
|
while True:
|
|
chunk = await file.read(8192)
|
|
if not chunk:
|
|
break
|
|
file_size += len(chunk)
|
|
if file_size > MAX_UPLOAD_SIZE:
|
|
raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB")
|
|
content += chunk
|
|
|
|
service = ProductService(db)
|
|
filename_lower = filename.lower()
|
|
|
|
if filename.endswith(".xlsx"):
|
|
if not HAS_OPENPYXL:
|
|
raise HTTPException(status_code=501, detail="openpyxl not installed, XLSX import unavailable")
|
|
wb = openpyxl.load_workbook(io.BytesIO(content))
|
|
ws = wb.active
|
|
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
|
records = []
|
|
for row in rows:
|
|
if not row[0]:
|
|
continue
|
|
records.append({
|
|
"name": str(row[0] or ""),
|
|
"name_en": str(row[1] or ""),
|
|
"category": str(row[2] or ""),
|
|
"description": str(row[3] or ""),
|
|
"price": str(row[4] or ""),
|
|
"price_unit": str(row[5] or "USD") if len(row) > 5 else "USD",
|
|
"moq": str(row[6] or "") if len(row) > 6 else "",
|
|
"keywords": [k.strip() for k in str(row[7] or "").split(",") if k.strip()] if len(row) > 7 else [],
|
|
})
|
|
elif filename.endswith(".csv"):
|
|
import csv
|
|
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
|
|
records = []
|
|
for row in reader:
|
|
name = row.get("名称", row.get("name", "")).strip()
|
|
if not name:
|
|
continue
|
|
records.append({
|
|
"name": name,
|
|
"name_en": row.get("英文名", row.get("name_en", "")),
|
|
"category": row.get("分类", row.get("category", "")),
|
|
"description": row.get("描述", row.get("description", "")),
|
|
"price": row.get("价格", row.get("price", "")),
|
|
"price_unit": row.get("货币", row.get("price_unit", "USD")),
|
|
"moq": row.get("MOQ", row.get("moq", "")),
|
|
"keywords": [k.strip() for k in row.get("关键词", row.get("keywords", "")).split(",") if k.strip()],
|
|
})
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
|
|
|
|
imported = 0
|
|
errors = []
|
|
for rec in records:
|
|
try:
|
|
await service.create_product(user_id, rec)
|
|
imported += 1
|
|
except Exception as e:
|
|
errors.append(f"{rec.get('name', '')}: {str(e)}")
|
|
|
|
return {"imported": imported, "errors": errors}
|
|
|
|
|
|
@router.get("/{product_id}")
|
|
async def get_product(
|
|
product_id: str,
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
service = ProductService(db)
|
|
product = await service.get_product(user_id, product_id)
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
return product
|
|
|
|
|
|
@router.post("")
|
|
async def create_product(
|
|
data: ProductCreate,
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
usage = UsageService(db)
|
|
ok, msg = await usage.check_quota(user_id, "create_product")
|
|
if not ok:
|
|
raise HTTPException(status_code=429, detail=msg)
|
|
service = ProductService(db)
|
|
product = await service.create_product(user_id, data.dict())
|
|
await usage.record_usage(user_id, "create_product")
|
|
return product
|
|
|
|
|
|
@router.patch("/{product_id}")
|
|
async def update_product(
|
|
product_id: str,
|
|
data: ProductUpdate,
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
service = ProductService(db)
|
|
product = await service.update_product(user_id, product_id, data.dict(exclude_unset=True))
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
return product
|
|
|
|
|
|
@router.delete("/{product_id}")
|
|
async def delete_product(
|
|
product_id: str,
|
|
user_id: str = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
service = ProductService(db)
|
|
deleted = await service.delete_product(user_id, product_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
return {"message": "Product deleted"} |