Files
server-core/app/market/routers.py
T

703 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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=JSONplugin/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 / appOSS 直链
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|coverOSS 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="上传安装包 zipskill 解析 SKILL.mdplugin/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 / appzip 落 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.mdfrontmatter + 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 {}