703 lines
28 KiB
Python
703 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""市场子应用接口层:目录公开读 / 消费(定价购买)/ admin 管理。
|
||
|
||
- 公开读:``/market/*``(游客可访问,bundle 下载按商品定价校验归属)。
|
||
- 消费:``/market/orders/*``(opc_member 登录,复用 app.pay 微信「小程序码→小程序内支付」)。
|
||
- 管理:``/market/admin/*``(operator + menu:admin_market,配合 admin-portal「市场管理」)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import secrets
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||
from fastapi.responses import JSONResponse, RedirectResponse
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import func, select
|
||
|
||
from ..api.dependencies import Database, get_current_user, get_db, optional_current_user
|
||
from ..infrastructure.models import MarketCategory, MarketItem
|
||
from ..infrastructure.oss import oss
|
||
from ..infrastructure.repositories import utcnow_iso
|
||
from ..rbac import require_permission, require_roles
|
||
from . import categories, service
|
||
|
||
logger = logging.getLogger("market.api")
|
||
|
||
router = APIRouter(prefix="/market", tags=["market"])
|
||
admin_router = APIRouter(prefix="/market/admin", tags=["market-admin"])
|
||
|
||
|
||
async def _audit(db, user: dict, action: str, item_id: str, detail: str = "") -> None:
|
||
"""审计写入(随请求事务提交,确保落库)。"""
|
||
try:
|
||
await db.audit.add(
|
||
action=action, resource="market_item", resource_id=item_id,
|
||
detail=detail[:500], user_id=user.get("id", ""))
|
||
await db.session.commit()
|
||
except Exception: # noqa: BLE001
|
||
await db.session.rollback()
|
||
|
||
|
||
def _normalize_slug(slug: str, name: str, fallback: str = "") -> str:
|
||
"""标识规范化:统一为 ``owner/name`` 双段结构。
|
||
|
||
- 为空时回退 fallback(有值)或名称 name;
|
||
- 不含 ``/`` 时自动补 ``pineagents/`` 前缀(平台默认发布方),
|
||
避免单段 slug 在下载链路(owner/name 拆分)产生 ``undefined``。
|
||
"""
|
||
s = (slug or fallback or name or "").strip().strip("/")
|
||
if not s:
|
||
raise HTTPException(status_code=400, detail="标识不能为空")
|
||
if "/" not in s:
|
||
s = f"pineagents/{s}"
|
||
return s
|
||
|
||
|
||
# ===========================================================================
|
||
# 公开读
|
||
# ===========================================================================
|
||
@router.get("/overview", summary="市场总览(各类型数量/最新/精选)")
|
||
async def market_overview(db: Database = Depends(get_db)):
|
||
async def count(t: str) -> int:
|
||
v = await db.session.execute(
|
||
select(func.count()).select_from(MarketItem).where(
|
||
MarketItem.item_type == t, MarketItem.status == "published")
|
||
)
|
||
return int(v.scalar() or 0)
|
||
|
||
return {
|
||
"skills": await count("skill"),
|
||
"plugins": await count("plugin"),
|
||
"apps": await count("app"),
|
||
"featured": (await service.list_items(db, featured_only=True, page_size=6))["items"],
|
||
"latest": (await service.list_items(db, page_size=6))["items"],
|
||
}
|
||
|
||
|
||
@router.get("/items", summary="目录列表(仅 published,游客可访问)")
|
||
async def market_items(
|
||
item_type: str | None = None,
|
||
q: str = "", category: str = "", featured: int = 0, region: str = "",
|
||
page: int = 1, page_size: int = 50,
|
||
db: Database = Depends(get_db),
|
||
user: dict | None = Depends(optional_current_user),
|
||
):
|
||
if item_type and not categories.valid_type(item_type):
|
||
raise HTTPException(status_code=400, detail="item_type 仅支持 skill/plugin/app")
|
||
data = await service.list_items(
|
||
db, item_type=item_type, q=q, category=category,
|
||
featured_only=bool(featured), region=region, status="published",
|
||
page=page, page_size=page_size,
|
||
)
|
||
if user:
|
||
owned = set(await service.my_purchases(db, user["id"]))
|
||
for it in data["items"]:
|
||
it["owned"] = it["id"] in owned
|
||
return data
|
||
|
||
|
||
@router.get("/items/{item_id}", summary="商品详情")
|
||
async def market_item_detail(
|
||
item_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict | None = Depends(optional_current_user),
|
||
):
|
||
try:
|
||
item = await service.get_item_or_404(db, item_id)
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
if user:
|
||
item["owned"] = await service.has_purchase(db, user["id"], item_id)
|
||
# 详情浏览量 +1(异步不阻塞响应)
|
||
try:
|
||
await service.bump_stats(db, item_id, views=1)
|
||
except Exception:
|
||
pass
|
||
return item
|
||
|
||
|
||
@router.get("/items/{item_id}/bundle", summary="安装包(skill=JSON;plugin/app=OSS 直链;付费需已购)")
|
||
async def market_item_bundle(
|
||
item_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict | None = Depends(optional_current_user),
|
||
):
|
||
try:
|
||
item = await service.get_item_or_404(db, item_id)
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
if item["status"] != "published":
|
||
raise HTTPException(status_code=403, detail="商品未上架")
|
||
if item["price_fen"] > 0:
|
||
if user is None:
|
||
raise HTTPException(status_code=401, detail="请先登录")
|
||
owned = await service.has_purchase(db, user["id"], item_id)
|
||
if not owned:
|
||
raise HTTPException(status_code=403, detail="未购买该商品,请先完成购买")
|
||
if item["item_type"] == "skill":
|
||
# skill 下载量 +1
|
||
try:
|
||
await service.bump_stats(db, item_id, downloads=1)
|
||
except Exception:
|
||
pass
|
||
return JSONResponse(service.bundle_payload(item))
|
||
# plugin / app:OSS 直链
|
||
if not item["bundle_url"]:
|
||
raise HTTPException(status_code=404, detail="安装包未上传")
|
||
key = item["bundle_url"].removeprefix("/oss/")
|
||
if key == item["bundle_url"]:
|
||
# 本地降级存储(/uploads/...)→ 直接重定向
|
||
try:
|
||
await service.bump_stats(db, item_id, downloads=1)
|
||
except Exception:
|
||
pass
|
||
return RedirectResponse(url=item["bundle_url"], status_code=302)
|
||
url = await oss.download_url(key)
|
||
resp = RedirectResponse(url=url, status_code=302)
|
||
resp.headers["X-Checksum-Sha256"] = item.get("bundle_sha256", "")
|
||
# 下载量 +1
|
||
try:
|
||
await service.bump_stats(db, item_id, downloads=1)
|
||
except Exception:
|
||
pass
|
||
return resp
|
||
|
||
|
||
@router.get("/categories", summary="分类字典")
|
||
async def market_categories(
|
||
item_type: str | None = None,
|
||
lang: str = "zh",
|
||
db: Database = Depends(get_db),
|
||
):
|
||
return await categories.list_categories(db, item_type, lang)
|
||
|
||
|
||
# ===========================================================================
|
||
# 消费:定价购买(复用 app.pay 微信「小程序码 → 小程序内支付」)
|
||
# ===========================================================================
|
||
class PurchaseRequest(BaseModel):
|
||
item_id: str = Field(..., description="商品 id")
|
||
item_type: str = "plugin"
|
||
|
||
|
||
@router.post("/orders", summary="创建购买订单(mp:桌面出小程序码,扫码进小程序支付)")
|
||
async def market_create_order(
|
||
body: PurchaseRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
try:
|
||
return await service.create_purchase_order(db, user, item_id=body.item_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
except RuntimeError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||
|
||
|
||
@router.get("/orders/{order_no}/pay-params", summary="小程序扫码确认支付(JSAPI 参数,支持代付)")
|
||
async def market_pay_params(
|
||
order_no: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
"""支持代付:任意登录用户均可为该订单支付,商品发放到订单创建者。"""
|
||
try:
|
||
return await service.build_pay_params(db, user, order_no)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
except RuntimeError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||
|
||
|
||
@router.post("/orders/{order_no}/status", summary="查询购买订单状态(含对账补单,支持代付查询)")
|
||
async def market_order_status(
|
||
order_no: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
try:
|
||
return await service.query_status(db, user, order_no)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
|
||
@router.get("/purchases", summary="我的已购商品")
|
||
async def market_my_purchases(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
ids = await service.my_purchases(db, user["id"])
|
||
items = []
|
||
for iid in ids:
|
||
it = await service.get_item(db, iid)
|
||
if it:
|
||
items.append({"id": it["id"], "item_type": it["item_type"], "name": it["name"],
|
||
"icon_url": it["icon_url"], "version": it["version"], "free": it["free"]})
|
||
return {"items": items}
|
||
|
||
|
||
@router.get("/purchases/check", summary="检查是否已购(供桌面端安装前判断)")
|
||
async def market_purchase_check(
|
||
item_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return {"owned": await service.has_purchase(db, user["id"], item_id)}
|
||
|
||
|
||
# ===========================================================================
|
||
# admin 管理(operator)
|
||
# ===========================================================================
|
||
class ItemCreateRequest(BaseModel):
|
||
item_type: str = Field(..., description="skill|plugin|app")
|
||
slug: str = ""
|
||
name: str = Field(..., description="展示名")
|
||
description: str = ""
|
||
version: str = "1.0.0"
|
||
author: str = ""
|
||
owner: str = ""
|
||
icon_url: str = ""
|
||
cover_url: str = ""
|
||
category: str = ""
|
||
tags: list[str] = []
|
||
price_fen: int = Field(default=0, ge=0, description="定价(分),0=免费")
|
||
bundle: dict = {}
|
||
|
||
|
||
class ItemUpdateRequest(ItemCreateRequest):
|
||
pass
|
||
|
||
|
||
class CategoryCreateRequest(BaseModel):
|
||
item_type: str = Field(..., description="skill|plugin|app")
|
||
key: str = Field(..., description="稳定编码(商品分类存储值)")
|
||
label_zh: str = ""
|
||
label_en: str = ""
|
||
sort: int = 0
|
||
|
||
|
||
class CategoryUpdateRequest(BaseModel):
|
||
key: str | None = None
|
||
label_zh: str | None = None
|
||
label_en: str | None = None
|
||
sort: int | None = None
|
||
enabled: int | None = None
|
||
|
||
|
||
@admin_router.get("/items", summary="管理列表(全状态)")
|
||
async def admin_items(
|
||
item_type: str | None = None, status: str | None = None, q: str = "",
|
||
category: str = "", page: int = 1, page_size: int = 50,
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
data = await service.list_items(db, item_type=item_type, q=q, category=category, status=status or "", page=page, page_size=page_size)
|
||
stmt = select(func.count()).select_from(MarketItem)
|
||
if item_type:
|
||
stmt = stmt.where(MarketItem.item_type == item_type)
|
||
if status:
|
||
stmt = stmt.where(MarketItem.status == status)
|
||
if category:
|
||
stmt = stmt.where(MarketItem.category == category)
|
||
if q:
|
||
like = f"%{q}%"
|
||
stmt = stmt.where((MarketItem.name.like(like)) | (MarketItem.description.like(like)) | (MarketItem.slug.like(like)))
|
||
data["total"] = int((await db.session.execute(stmt)).scalar() or 0)
|
||
return data
|
||
|
||
|
||
@admin_router.post("/items", summary="新建条目(draft)")
|
||
async def admin_create_item(
|
||
body: ItemCreateRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
if not categories.valid_type(body.item_type):
|
||
raise HTTPException(status_code=400, detail="item_type 仅支持 skill/plugin/app")
|
||
if not await categories.valid_category(db, body.item_type, body.category):
|
||
raise HTTPException(status_code=400, detail="分类不属于该类型,请先维护分类字典")
|
||
# plugin/app 标识不允许手工设置:由 zip 上传时解析(覆盖此占位值);
|
||
# 此处不强制组织前缀,避免产生与插件内部 id 不一致的误导性标识。
|
||
if body.item_type == "skill":
|
||
slug = _normalize_slug(body.slug, body.name)
|
||
else:
|
||
slug = (body.slug or "").strip().strip("/")
|
||
try:
|
||
item = await service.create_item(
|
||
db, item_type=body.item_type, slug=slug, name=body.name,
|
||
publisher_id=user["id"], description=body.description, version=body.version,
|
||
author=body.author, owner=body.owner, icon_url=body.icon_url,
|
||
cover_url=body.cover_url, category=body.category,
|
||
tags=body.tags, price_fen=body.price_fen, bundle=body.bundle,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=400, detail=f"创建失败:{exc}") from exc
|
||
await _audit(db, user, "market.create", item["id"], body.name)
|
||
return item
|
||
|
||
|
||
@admin_router.get("/items/{item_id}", summary="管理详情")
|
||
async def admin_item_detail(
|
||
item_id: str,
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
try:
|
||
return await service.get_item_or_404(db, item_id)
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
|
||
|
||
@admin_router.put("/items/{item_id}", summary="编辑条目")
|
||
async def admin_update_item(
|
||
item_id: str, body: ItemUpdateRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
existing = await service.get_item(db, item_id)
|
||
if existing is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
if not await categories.valid_category(db, existing["item_type"], body.category):
|
||
raise HTTPException(status_code=400, detail="分类不属于该类型,请先维护分类字典")
|
||
# plugin/app 标识由 zip 解析、不允许手工设置:编辑时保持已解析的标识不变(仅 skill 可改)。
|
||
if existing["item_type"] == "skill":
|
||
slug = _normalize_slug(body.slug, body.name, existing["slug"])
|
||
else:
|
||
slug = existing["slug"]
|
||
try:
|
||
item = await service.update_item(
|
||
db, item_id, slug=slug, name=body.name, description=body.description, version=body.version,
|
||
author=body.author, owner=body.owner, icon_url=body.icon_url,
|
||
cover_url=body.cover_url, category=body.category,
|
||
tags=body.tags, price_fen=body.price_fen, bundle=body.bundle,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=400, detail=f"更新失败:{exc}") from exc
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
await _audit(db, user, "market.update", item_id, body.name)
|
||
return item
|
||
|
||
|
||
@admin_router.delete("/items/{item_id}", summary="删除条目")
|
||
async def admin_delete_item(
|
||
item_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
ok = await service.delete_item(db, item_id)
|
||
if not ok:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
await _audit(db, user, "market.delete", item_id)
|
||
return {"ok": True}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 分类管理(admin 端弹窗维护:增删改 + 排序/启停)
|
||
# ---------------------------------------------------------------------------
|
||
@admin_router.get("/categories", summary="分类列表(admin,含停用项)")
|
||
async def admin_list_categories(
|
||
item_type: str | None = None,
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
return await categories.list_categories(db, item_type, "zh")
|
||
|
||
|
||
@admin_router.post("/categories", summary="新建分类")
|
||
async def admin_create_category(
|
||
body: CategoryCreateRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
if not categories.valid_type(body.item_type):
|
||
raise HTTPException(status_code=400, detail="item_type 仅支持 skill/plugin/app")
|
||
key = (body.key or "").strip()
|
||
if not key:
|
||
raise HTTPException(status_code=400, detail="key(分类编码)不能为空")
|
||
dup = await db.session.scalar(
|
||
select(MarketCategory.id).where(
|
||
MarketCategory.item_type == body.item_type,
|
||
MarketCategory.key == key,
|
||
).limit(1)
|
||
)
|
||
if dup:
|
||
raise HTTPException(status_code=400, detail="该分类编码已存在")
|
||
now = utcnow_iso()
|
||
row = MarketCategory(
|
||
id=f"mc_{secrets.token_hex(8)}", item_type=body.item_type, key=key,
|
||
label_zh=body.label_zh or "", label_en=body.label_en or "",
|
||
sort=body.sort, enabled=1, created_at=now, updated_at=now,
|
||
)
|
||
db.session.add(row)
|
||
await db.session.commit()
|
||
await _audit(db, user, "market.category.create", row.id, f"{body.item_type}:{key}")
|
||
return categories._row(row)
|
||
|
||
|
||
@admin_router.put("/categories/{cat_id}", summary="编辑分类")
|
||
async def admin_update_category(
|
||
cat_id: str, body: CategoryUpdateRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
row = await db.session.get(MarketCategory, cat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="分类不存在")
|
||
if body.key is not None:
|
||
key = body.key.strip()
|
||
if not key:
|
||
raise HTTPException(status_code=400, detail="key(分类编码)不能为空")
|
||
dup = await db.session.scalar(
|
||
select(MarketCategory.id).where(
|
||
MarketCategory.item_type == row.item_type,
|
||
MarketCategory.key == key,
|
||
MarketCategory.id != cat_id,
|
||
).limit(1)
|
||
)
|
||
if dup:
|
||
raise HTTPException(status_code=400, detail="该分类编码已存在")
|
||
row.key = key
|
||
if body.label_zh is not None:
|
||
row.label_zh = body.label_zh
|
||
if body.label_en is not None:
|
||
row.label_en = body.label_en
|
||
if body.sort is not None:
|
||
row.sort = body.sort
|
||
if body.enabled is not None:
|
||
row.enabled = 1 if body.enabled else 0
|
||
row.updated_at = utcnow_iso()
|
||
await db.session.commit()
|
||
await _audit(db, user, "market.category.update", cat_id, row.key)
|
||
return categories._row(row)
|
||
|
||
|
||
@admin_router.delete("/categories/{cat_id}", summary="删除分类")
|
||
async def admin_delete_category(
|
||
cat_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
row = await db.session.get(MarketCategory, cat_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="分类不存在")
|
||
used = await db.session.scalar(
|
||
select(MarketItem.id).where(
|
||
MarketItem.item_type == row.item_type,
|
||
MarketItem.category == row.key,
|
||
).limit(1)
|
||
)
|
||
if used:
|
||
raise HTTPException(status_code=400, detail="该分类下存在商品,无法删除;可先停用")
|
||
await db.session.delete(row)
|
||
await db.session.commit()
|
||
await _audit(db, user, "market.category.delete", cat_id, row.key)
|
||
return {"ok": True}
|
||
|
||
|
||
@admin_router.post("/items/{item_id}/submit", summary="提交审核 draft→pending")
|
||
async def admin_submit_item(
|
||
item_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
item = await service.set_status(db, item_id, "pending")
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
await _audit(db, user, "market.submit", item_id)
|
||
return item
|
||
|
||
|
||
class AuditRequest(BaseModel):
|
||
action: str = Field(..., description="approve|reject")
|
||
reason: str = ""
|
||
|
||
|
||
@admin_router.post("/items/{item_id}/audit", summary="审核 approve/reject")
|
||
async def admin_audit_item(
|
||
item_id: str, body: AuditRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
if body.action not in ("approve", "reject"):
|
||
raise HTTPException(status_code=400, detail="action 仅支持 approve/reject")
|
||
try:
|
||
item = await service.set_status(
|
||
db, item_id, "published" if body.action == "approve" else "rejected",
|
||
reason=body.reason,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
await _audit(db, user, f"market.{body.action}", item_id, body.reason)
|
||
return item
|
||
|
||
|
||
class StatusRequest(BaseModel):
|
||
status: str = Field(..., description="published|offline")
|
||
|
||
|
||
@admin_router.post("/items/{item_id}/status", summary="上架/下架")
|
||
async def admin_set_status(
|
||
item_id: str, body: StatusRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
if body.status not in ("published", "offline"):
|
||
raise HTTPException(status_code=400, detail="status 仅支持 published/offline")
|
||
try:
|
||
item = await service.set_status(db, item_id, body.status)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
await _audit(db, user, f"market.{body.status}", item_id)
|
||
return item
|
||
|
||
|
||
class FeaturedRequest(BaseModel):
|
||
featured: bool = True
|
||
|
||
|
||
@admin_router.post("/items/{item_id}/featured", summary="设置/取消精选")
|
||
async def admin_set_featured(
|
||
item_id: str, body: FeaturedRequest,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
item = await service.set_featured(db, item_id, body.featured)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
await _audit(db, user, "market.featured", item_id, str(body.featured))
|
||
return item
|
||
|
||
|
||
_IMAGE_MIME = {
|
||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
||
".ico": "image/x-icon",
|
||
}
|
||
|
||
|
||
@admin_router.post("/items/upload-image", summary="上传图标/封面图(kind=icon|cover,OSS market/images/)")
|
||
async def admin_upload_image(
|
||
kind: str = "icon",
|
||
file: UploadFile = File(...),
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
"""上传商品图标(icon)或封面图(cover)到 OSS,返回稳定相对路径供表单回填。"""
|
||
data = await file.read()
|
||
if not data:
|
||
raise HTTPException(status_code=400, detail="空文件")
|
||
if len(data) > 5 * 1024 * 1024:
|
||
raise HTTPException(status_code=400, detail="图片超过 5MB")
|
||
if kind not in ("icon", "cover"):
|
||
raise HTTPException(status_code=400, detail="kind 仅支持 icon/cover")
|
||
ext = Path(file.filename or "").suffix.lower() or ".png"
|
||
if ext not in _IMAGE_MIME:
|
||
raise HTTPException(status_code=400, detail=f"不支持的图片类型 {ext}")
|
||
key = f"market/images/{kind}/{secrets.token_hex(10)}{ext}"
|
||
path = await oss.upload(key, data, content_type=_IMAGE_MIME[ext])
|
||
await _audit(db, user, "market.image", key, f"{kind} {len(data)}B")
|
||
return {"ok": True, "url": path, "kind": kind, "size": len(data)}
|
||
|
||
|
||
@admin_router.post("/items/upload", summary="上传安装包 zip(skill 解析 SKILL.md;plugin/app 存 zip 到 OSS)")
|
||
async def admin_upload_bundle(
|
||
item_id: str,
|
||
file: UploadFile = File(...),
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_permission("menu:admin_market")),
|
||
):
|
||
data = await file.read()
|
||
if len(data) > 200 * 1024 * 1024:
|
||
raise HTTPException(status_code=400, detail="安装包超过 200MB")
|
||
if not data:
|
||
raise HTTPException(status_code=400, detail="空文件")
|
||
item = await db.session.get(MarketItem, item_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="商品不存在")
|
||
|
||
sha256 = service.compute_sha256(data)
|
||
if item.item_type == "skill":
|
||
# 解析 zip 内 SKILL.md → bundle_json(内嵌,无需落 OSS)
|
||
bundle_json = _parse_skill_bundle(data)
|
||
if not bundle_json:
|
||
raise HTTPException(status_code=400, detail="技能包内未找到 SKILL.md")
|
||
item.bundle_json = json.dumps(bundle_json, ensure_ascii=False)
|
||
item.bundle_sha256 = sha256
|
||
await db.session.commit()
|
||
else:
|
||
# plugin / app:zip 落 OSS
|
||
# 标识不允许手工设置:必须从 zip 内 plugin.json 的 id 解析,覆盖创建时的占位 slug
|
||
# (避免服务端组织前缀与插件内部 id 不一致,导致桌面端无法识别已安装插件/应用)。
|
||
manifest = _parse_manifest(data)
|
||
manifest_id = ""
|
||
if manifest:
|
||
manifest_id = str(manifest.get("id") or "").strip().strip("/")
|
||
item.bundle_json = json.dumps({"manifest": manifest}, ensure_ascii=False)
|
||
if manifest_id:
|
||
item.slug = manifest_id
|
||
slug = (item.slug or item.id).replace("/", "-")
|
||
key = f"market/{item.item_type}/{slug}-{item.version}.zip"
|
||
path = await oss.upload(key, data, content_type="application/zip")
|
||
item.bundle_url = path
|
||
item.bundle_sha256 = sha256
|
||
await db.session.commit()
|
||
await _audit(db, user, "market.upload", item_id, f"{len(data)}B sha256={sha256[:12]}")
|
||
return {"ok": True, "item_id": item_id, "bundle_sha256": sha256, "bundle_url": item.bundle_url}
|
||
|
||
|
||
def _parse_skill_bundle(data: bytes) -> dict:
|
||
"""读取 zip 内 SKILL.md(frontmatter + content)→ bundle 结构。"""
|
||
import zipfile
|
||
import re
|
||
from io import BytesIO
|
||
try:
|
||
with zipfile.ZipFile(BytesIO(data)) as zf:
|
||
names = zf.namelist()
|
||
skill_md = next((n for n in names if n.rstrip("/").endswith("SKILL.md") and not n.startswith("__MACOSX")), None)
|
||
if not skill_md:
|
||
return {}
|
||
content = zf.read(skill_md).decode("utf-8", errors="replace")
|
||
files = {}
|
||
for n in names:
|
||
if n.startswith("__MACOSX") or n.endswith("/") or n.endswith((".DS_Store", "Thumbs.db")):
|
||
continue
|
||
files[n] = zf.read(n).decode("utf-8", errors="replace")
|
||
# 从 frontmatter 解析技能名称(优先),其次用 zip 内目录名,最后用文件名
|
||
name = ""
|
||
fm_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
|
||
if fm_match:
|
||
for line in fm_match.group(1).splitlines():
|
||
if line.strip().startswith("name:"):
|
||
name = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||
break
|
||
if not name:
|
||
# zip 路径形如 "my-skill/SKILL.md" → 取目录名 "my-skill";根目录 "SKILL.md" → 留空
|
||
parts = skill_md.rsplit("/", 1)
|
||
name = parts[0] if len(parts) > 1 else ""
|
||
return {"name": name, "content": content, "files": files}
|
||
except zipfile.BadZipFile:
|
||
return {}
|
||
|
||
|
||
def _parse_manifest(data: bytes) -> dict:
|
||
"""读取 zip 内 plugin.json → manifest 摘要。"""
|
||
import zipfile
|
||
from io import BytesIO
|
||
try:
|
||
with zipfile.ZipFile(BytesIO(data)) as zf:
|
||
names = zf.namelist()
|
||
pj = next((n for n in names if n.rstrip("/").endswith("plugin.json") and not n.startswith("__MACOSX")), None)
|
||
if not pj:
|
||
return {}
|
||
return json.loads(zf.read(pj).decode("utf-8", errors="replace"))
|
||
except (zipfile.BadZipFile, ValueError, TypeError):
|
||
return {}
|