feat(个人主页): 审核流程+OSS存储+能力数据公开+URL模式强制无能力

- 迁移0053: html_url/review_status/review_note/submitted_at/reviewed_at
- HTML保存时上传OSS,数据库存html_url;URL模式只存embed_url
- URL(embed)模式强制capabilities为空(第三方网页无法接入平台能力)
- HTML模式选择能力后警告:必须真实接入,否则下架+永久关闭功能
- 上架流程: 保存草稿→提交审核→审核通过→上架;修改已上架内容自动下架
- GET /pages/{user_id} 根据capabilities返回公开的能力数据(订单/证书)
- postMessage桥注入capabilityData给HTML
- 运营端审核接口: /pages/admin/review/list + /pages/admin/{user_id}/review
- OSS新增download方法
This commit is contained in:
Pine
2026-09-04 23:51:47 +08:00
parent fbad02d823
commit 1ec34c0a5c
5 changed files with 276 additions and 81 deletions
@@ -0,0 +1,29 @@
"""user_pages: html_url + 审核字段
Revision ID: 0053
Revises: 0052
Create Date: 2026-09-04
"""
from alembic import op
import sqlalchemy as sa
revision = "0053"
down_revision = "0052"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("user_pages", sa.Column("html_url", sa.String(length=512), nullable=True))
op.add_column("user_pages", sa.Column("review_status", sa.String(length=32), nullable=False, server_default="draft"))
op.add_column("user_pages", sa.Column("review_note", sa.Text(), nullable=True))
op.add_column("user_pages", sa.Column("submitted_at", sa.String(length=64), nullable=True))
op.add_column("user_pages", sa.Column("reviewed_at", sa.String(length=64), nullable=True))
def downgrade():
op.drop_column("user_pages", "reviewed_at")
op.drop_column("user_pages", "submitted_at")
op.drop_column("user_pages", "review_note")
op.drop_column("user_pages", "review_status")
op.drop_column("user_pages", "html_url")
+183 -72
View File
@@ -1,11 +1,12 @@
"""个人主页(OPC 公开名片页)接口。
- GET /pages/{user_id} 公开渲染数据:页面内容 + 身份条(昵称/头像/信用/徽章)
+ 能力接入说明(capabilities 非空 → connected 接入说明;空 → unverified 提醒访客核实)。
未设置个人主页或他人访问未上架页 → 返回 page=null + default(人才档案 + 已上架服务)。
- GET /pages/me 我的个人主页(含发布状态)
- PUT /pages/me 保存个人主页(html 源码 或 embed 第三方网页 + 能力声明
- POST /pages/me/publish 上架/下架
- GET /pages/{user_id} 公开渲染数据:页面内容 + 身份条 + 接入的公开能力数据
- GET /pages/me 我的个人主页(含审核状态)
- PUT /pages/me 保存个人主页(HTML 上传 OSS / URL 模式强制无能力)
- POST /pages/me/submit 提交审核
- POST /pages/me/publish 上架/下架(仅审核通过可上架
- GET /admin/pages/review 运营端:待审核列表
- POST /admin/pages/{user_id}/review 运营端:审核通过/拒绝
"""
from __future__ import annotations
@@ -14,31 +15,26 @@ from sqlalchemy import func, select
from ..dependencies import Database, get_current_user, get_db
from ...infrastructure.models import MarketItem, TrainingCertificate
from ...infrastructure.oss import oss, resolve_url
from ...infrastructure.repositories import utcnow_iso
router = APIRouter(prefix="/pages", tags=["pages"])
_MAX_HTML = 2 * 1024 * 1024 # 自写 HTML 上限 2MB
_ALLOWED_CAPS = {"orders", "agents_dev", "agents_use", "certs"}
# ── 徽章规则(派生计算,不落库)───────────────────────────────────────────
async def _compute_badges(db: Database, user_id: str) -> list[dict]:
"""根据平台既有数据派生用户徽章集合(规则可扩展)。"""
badges: list[dict] = []
# 1) OPC 认证徽章:存在 active 认证记录
certs = await db.certifications.list(user_id=user_id, status="active")
if certs:
badges.append({
"code": "opc_certified", "name": "OPC 认证",
"desc": "已完成 OPC 实名认证", "icon": "shield", "color": "#3b6fd4",
})
badges.append({"code": "opc_certified", "name": "OPC 认证",
"desc": "已完成 OPC 实名认证", "icon": "shield", "color": "#3b6fd4"})
else:
badges.append({
"code": "opc_member", "name": "OPC 成员",
"desc": "平台注册成员", "icon": "user", "color": "#8a8f98",
})
badges.append({"code": "opc_member", "name": "OPC 成员",
"desc": "平台注册成员", "icon": "user", "color": "#8a8f98"})
# 2) 信用等级徽章(credit_score 分段)
opc_profile = await db.opc_profiles.get(user_id)
credit = (opc_profile or {}).get("credit_score", 80) or 80
if credit >= 100:
@@ -51,39 +47,28 @@ async def _compute_badges(db: Database, user_id: str) -> list[dict]:
tier = {"code": "credit_rising", "name": "信用成长中", "desc": f"信用分 {credit},持续履约可提升", "icon": "sprout", "color": "#2e9e63"}
badges.append(tier)
# 3) 持证徽章:结业证书
cert_count = (await db.session.scalar(
select(func.count()).select_from(TrainingCertificate).where(TrainingCertificate.user_id == user_id)
)) or 0
if cert_count > 0:
badges.append({
"code": "cert_holder", "name": "持证学员", "desc": f"已获得 {cert_count} 份结业证书",
"icon": "certificate", "color": "#2e9e63",
})
badges.append({"code": "cert_holder", "name": "持证学员", "desc": f"已获得 {cert_count} 份结业证书",
"icon": "certificate", "color": "#2e9e63"})
# 4) 人才名片徽章:人才档案在架
talent = await db.hall_talents.get(user_id)
if talent and talent.get("published"):
badges.append({
"code": "talent_on_shelf", "name": "人才在架", "desc": "人才市场公开名片已上架",
"icon": "briefcase", "color": "#8b5cf6",
})
badges.append({"code": "talent_on_shelf", "name": "人才在架", "desc": "人才市场公开名片已上架",
"icon": "briefcase", "color": "#8b5cf6"})
# 5) 创作者徽章:发布过市场应用/技能/插件
item_count = (await db.session.scalar(
select(func.count()).select_from(MarketItem).where(MarketItem.owner == user_id)
)) or 0
if item_count > 0:
badges.append({
"code": "creator", "name": "内容创作者", "desc": f"已发布 {item_count} 个应用/技能",
"icon": "code", "color": "#3b6fd4",
})
badges.append({"code": "creator", "name": "内容创作者", "desc": f"已发布 {item_count} 个应用/技能",
"icon": "code", "color": "#3b6fd4"})
return badges
def _public_profile(user: dict, badges: list[dict], credit: int | None) -> dict:
from ...infrastructure.oss import resolve_url
return {
"nickname": user.get("nickname") or user.get("username") or "OPC 用户",
"avatar": resolve_url(user.get("avatar") or ""),
@@ -92,6 +77,59 @@ def _public_profile(user: dict, badges: list[dict], credit: int | None) -> dict:
}
# ── 接入能力的公开数据(用户选择接入即代表公开,无需权限)──────────────────
async def _fetch_capability_data(db: Database, user_id: str, capabilities: list[dict]) -> dict:
"""根据用户声明接入的能力,返回对应的公开数据。"""
data: dict = {}
keys = {c.get("key") for c in capabilities if isinstance(c, dict)}
if "orders" in keys:
try:
all_orders = await db.service_orders.list(opc_id=user_id)
data["orders"] = [
{
"id": o.get("id"), "serviceId": o.get("serviceId"),
"buyerName": o.get("buyerName"), "price": o.get("price"),
"status": o.get("status"), "createdAt": o.get("createdAt"),
}
for o in all_orders if o.get("status") == "completed"
][:50]
except Exception:
data["orders"] = []
if "certs" in keys:
try:
certs = await db.certifications.list(user_id=user_id, status="active")
data["certs"] = [
{"id": c.get("id"), "certType": c.get("cert_type"),
"level": c.get("level"), "approvedAt": c.get("approved_at")}
for c in certs
]
except Exception:
data["certs"] = []
if "agents_dev" in keys:
data["agents_dev"] = []
if "agents_use" in keys:
data["agents_use"] = []
return data
# ── HTML 内容:优先从 OSS 读取,兼容旧数据 html_content ────────────────────
async def _load_html_content(page: dict) -> str:
html_url = page.get("htmlUrl") or ""
if html_url:
try:
key = html_url[len("/oss/"):] if html_url.startswith("/oss/") else html_url
raw = await oss.download(key)
if raw:
return raw.decode("utf-8", errors="replace")
except Exception:
pass
return page.get("htmlContent") or ""
# ── 我的个人主页(字面路径必须先于 /{user_id} 注册)────────────────────────
@router.get("/me", summary="我的个人主页")
async def get_my_page(db: Database = Depends(get_db), user: dict = Depends(get_current_user)):
@@ -104,24 +142,80 @@ async def get_my_page(db: Database = Depends(get_db), user: dict = Depends(get_c
async def save_my_page(body: dict = Body(...), db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
patch = _normalize_page_input(body)
page_type = str(body.get("pageType") or "html").strip()
if page_type not in ("html", "embed"):
raise HTTPException(status_code=400, detail="pageType 仅支持 html 或 embed")
patch: dict = {"page_type": page_type, "review_status": "draft"}
if page_type == "html":
html = str(body.get("htmlContent") or "")
if len(html.encode("utf-8")) > _MAX_HTML:
raise HTTPException(status_code=400, detail="HTML 内容超过 2MB 上限")
# HTML 上传到 OSS,数据库存相对路径
key = f"user_pages/{uid}.html"
try:
html_url = await oss.upload(key, html.encode("utf-8"), content_type="text/html; charset=utf-8")
except Exception as e:
raise HTTPException(status_code=500, detail=f"HTML 存储失败: {e}")
patch["html_url"] = html_url
patch["html_content"] = html # 兼容旧字段
patch["embed_url"] = ""
# 能力选择(仅 HTML 模式允许)
caps = body.get("capabilities") or []
if not isinstance(caps, list):
raise HTTPException(status_code=400, detail="capabilities 需为数组")
clean = [{"key": str(c.get("key", "")), "name": str(c.get("name", "")),
"desc": str(c.get("desc", "")), "scope": str(c.get("scope", ""))}
for c in caps if isinstance(c, dict) and c.get("key") in _ALLOWED_CAPS]
patch["capabilities"] = clean
else:
# embed 模式:强制无平台能力接入(第三方网页无法接入我们的能力)
url = str(body.get("embedUrl") or "").strip()
if not url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="embedUrl 需为 http(s) 地址")
patch["embed_url"] = url
patch["html_url"] = ""
patch["html_content"] = ""
patch["capabilities"] = []
# 修改后自动下架(需重新审核)
patch["published"] = False
page = await db.user_pages.upsert(uid, patch)
return {"ok": True, "page": page}
@router.post("/me/submit", summary="提交审核")
async def submit_for_review(db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
page = await db.user_pages.get(uid)
if not page:
raise HTTPException(status_code=404, detail="请先创建个人主页")
if page.get("pageType") == "embed" and not page.get("embedUrl"):
raise HTTPException(status_code=400, detail="请先填写第三方网页地址")
if page.get("pageType") == "html" and not (page.get("htmlUrl") or page.get("htmlContent")):
raise HTTPException(status_code=400, detail="请先编写 HTML 内容")
page = await db.user_pages.upsert(uid, {"review_status": "pending", "submitted_at": utcnow_iso()})
return {"ok": True, "page": page, "reviewStatus": "pending"}
@router.post("/me/publish", summary="上架/下架个人主页")
async def publish_my_page(body: dict = Body(default={}), db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
published = bool(body.get("published", True))
page = await db.user_pages.set_published(uid, published)
page = await db.user_pages.get(uid)
if page is None:
raise HTTPException(status_code=404, detail="请先创建个人主页再上架")
if published and page.get("reviewStatus") != "approved":
raise HTTPException(status_code=400, detail="个人主页需审核通过后才能上架")
page = await db.user_pages.set_published(uid, published)
return {"ok": True, "page": page, "published": published}
# ── 公开渲染数据(人才市场等入口跳转查看)───────────────────────────────
@router.get("/{user_id}", summary="获取用户个人主页(公开渲染数据;未设置时返回默认名片数据")
@router.get("/{user_id}", summary="获取用户个人主页(公开渲染数据)")
async def get_user_page(user_id: str, db: Database = Depends(get_db),
viewer: dict = Depends(get_current_user)):
page = await db.user_pages.get(user_id)
@@ -133,21 +227,30 @@ async def get_user_page(user_id: str, db: Database = Depends(get_db),
opc_profile = await db.opc_profiles.get(user_id)
credit = (opc_profile or {}).get("credit_score")
# 已上架(或本人自己的页)→ 渲染用户自写页面
# 已上架(或本人自己的页)→ 渲染用户页面
if page and (page.get("published") or is_self):
capabilities = page.get("capabilities") or []
# embed 模式强制无能力
if page.get("pageType") == "embed":
capabilities = []
# 接入的公开能力数据(用户选择接入即代表公开)
capability_data = await _fetch_capability_data(db, user_id, capabilities)
html_content = await _load_html_content(page) if page.get("pageType") == "html" else ""
notice = {
"type": "connected" if capabilities else "unverified",
"capabilities": capabilities,
"text": ("本页面已接入平台数据能力,所展示数据为平台实时数据" if capabilities
"text": ("本页面已接入平台数据能力,所展示数据为平台实时公开数据" if capabilities
else "本页面内容由用户自行编写,平台未对其中数据与信息进行核验,请注意核实"),
}
return {
"page": {
"pageType": page.get("pageType"),
"htmlContent": page.get("htmlContent") if page.get("pageType") == "html" else "",
"htmlContent": html_content,
"htmlUrl": page.get("htmlUrl"),
"embedUrl": page.get("embedUrl") if page.get("pageType") == "embed" else "",
"capabilities": capabilities,
"capabilityData": capability_data,
"reviewStatus": page.get("reviewStatus"),
"published": page.get("published"),
},
"profile": _public_profile(user, badges, credit),
@@ -155,12 +258,11 @@ async def get_user_page(user_id: str, db: Database = Depends(get_db),
"ownerIsSelf": is_self,
}
# 未设置个人主页 / 他人访问未上架页 → 渲染平台默认名片(基础资料 + 已上架服务)
# 未设置个人主页 / 他人访问未上架页 → 平台默认名片
talent = await db.hall_talents.get(user_id)
services = await db.hall_services.list(status="published", opc_id=user_id)
notice = {
"type": "unverified",
"capabilities": [],
"type": "unverified", "capabilities": [],
"text": "该用户尚未自定义个人主页,以下内容为平台公开信息,请注意核实",
}
return {
@@ -168,34 +270,43 @@ async def get_user_page(user_id: str, db: Database = Depends(get_db),
"profile": _public_profile(user, badges, credit),
"notice": notice,
"ownerIsSelf": is_self,
"default": {
"talent": talent,
"services": services,
},
"default": {"talent": talent, "services": services},
}
def _normalize_page_input(body: dict) -> dict:
page_type = str(body.get("pageType") or "html").strip()
if page_type not in ("html", "embed"):
raise HTTPException(status_code=400, detail="pageType 仅支持 html 或 embed")
patch: dict = {"page_type": page_type}
if page_type == "html":
html = str(body.get("htmlContent") or "")
if len(html.encode("utf-8")) > _MAX_HTML:
raise HTTPException(status_code=400, detail="HTML 内容超过 2MB 上限")
patch["html_content"] = html
else:
url = str(body.get("embedUrl") or "").strip()
if not url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="embedUrl 需为 http(s) 地址")
patch["embed_url"] = url
if "capabilities" in body:
caps = body.get("capabilities") or []
if not isinstance(caps, list):
raise HTTPException(status_code=400, detail="capabilities 需为数组")
clean = [{"key": str(c.get("key", "")), "name": str(c.get("name", "")),
"desc": str(c.get("desc", "")), "scope": str(c.get("scope", ""))}
for c in caps if isinstance(c, dict) and c.get("key")]
patch["capabilities"] = clean
return patch
# ── 运营端:审核 ────────────────────────────────────────────────────────
@router.get("/admin/review/list", summary="运营端:待审核个人主页列表")
async def review_list(db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "operator"):
raise HTTPException(status_code=403, detail="无权限")
pages = await db.user_pages.list_pending()
result = []
for p in pages:
u = (await db.users.get_by_id(p["userId"])) or {}
result.append({
**p,
"nickname": u.get("nickname") or u.get("username"),
"avatar": resolve_url(u.get("avatar") or ""),
})
return {"pages": result}
@router.post("/admin/{user_id}/review", summary="运营端:审核个人主页")
async def review_page(user_id: str, body: dict = Body(...),
db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "operator"):
raise HTTPException(status_code=403, detail="无权限")
approved = bool(body.get("approved", False))
note = str(body.get("note") or "")
patch = {
"review_status": "approved" if approved else "rejected",
"review_note": note,
"reviewed_at": utcnow_iso(),
}
# 拒绝时自动下架
if not approved:
patch["published"] = False
page = await db.user_pages.upsert(user_id, patch)
return {"ok": True, "page": page}
+40 -5
View File
@@ -26,6 +26,12 @@ from .models import (
from .repositories import new_id, utcnow_iso
def _resolve_media_urls(*urls):
"""统一把对象路径/裸 CDN 链补全为可访问直链(空值原样返回)。"""
from .oss import resolve_url
return [resolve_url(u) for u in urls]
def _loads(s: str, default):
try:
v = json.loads(s or "")
@@ -138,10 +144,14 @@ class OpcServiceRepository:
@staticmethod
def _to_dict(s: OpcService) -> dict:
gallery = _loads(s.gallery_json, [])
resolved = _resolve_media_urls(s.cover, *gallery)
cover = resolved[0] if resolved else ""
gallery = resolved[1:] if len(resolved) > 1 else []
return {
"id": s.id, "opcId": s.opc_id, "opcName": s.opc_name, "title": s.title,
"category": s.category, "description": s.description, "cover": s.cover,
"gallery": _loads(s.gallery_json, []), "price": s.price,
"category": s.category, "description": s.description, "cover": cover,
"gallery": gallery, "price": s.price,
"deliveryDays": s.delivery_days, "tags": s.tags, "status": s.status,
"rating": s.rating, "orderCount": s.order_count,
"createdAt": s.created_at, "updatedAt": s.updated_at,
@@ -232,9 +242,10 @@ class TalentProfileRepository:
@staticmethod
def _to_dict(t: TalentProfile) -> dict:
avatar = _resolve_media_urls(t.avatar)[0]
return {
"userId": t.user_id, "talentType": t.talent_type,
"displayName": t.display_name, "avatar": t.avatar,
"displayName": t.display_name, "avatar": avatar,
"headline": t.headline, "bio": t.bio,
"fields": _loads(t.fields_json, []), "skills": _loads(t.skills_json, []),
"workYears": t.work_years, "education": t.education, "region": t.region,
@@ -297,10 +308,14 @@ class CommunityPostRepository:
@staticmethod
def _to_dict(p: CommunityPost) -> dict:
images = _loads(p.images_json, [])
resolved = _resolve_media_urls(p.avatar, *images)
avatar = resolved[0] if resolved else ""
images = resolved[1:] if len(resolved) > 1 else []
return {
"id": p.id, "authorId": p.author_id, "authorName": p.author_name,
"avatar": p.avatar, "topic": p.topic, "title": p.title, "content": p.content,
"images": _loads(p.images_json, []), "likeCount": p.like_count,
"avatar": avatar, "topic": p.topic, "title": p.title, "content": p.content,
"images": images, "likeCount": p.like_count,
"commentCount": p.comment_count, "status": p.status, "pinned": bool(p.pinned),
"createdAt": p.created_at, "updatedAt": p.updated_at,
}
@@ -612,8 +627,13 @@ class UserPageRepository:
"userId": p.user_id,
"pageType": p.page_type,
"htmlContent": p.html_content,
"htmlUrl": p.html_url,
"embedUrl": p.embed_url,
"capabilities": json.loads(p.capabilities_json or "[]"),
"reviewStatus": p.review_status or "draft",
"reviewNote": p.review_note or "",
"submittedAt": p.submitted_at or "",
"reviewedAt": p.reviewed_at or "",
"published": bool(p.published),
"createdAt": p.created_at,
"updatedAt": p.updated_at,
@@ -633,10 +653,20 @@ class UserPageRepository:
row.page_type = patch["page_type"]
if "html_content" in patch:
row.html_content = patch["html_content"] or ""
if "html_url" in patch:
row.html_url = patch["html_url"] or ""
if "embed_url" in patch:
row.embed_url = patch["embed_url"] or ""
if "capabilities" in patch:
row.capabilities_json = json.dumps(patch["capabilities"] or [], ensure_ascii=False)
if "review_status" in patch:
row.review_status = patch["review_status"] or "draft"
if "review_note" in patch:
row.review_note = patch["review_note"] or ""
if "submitted_at" in patch:
row.submitted_at = patch["submitted_at"] or ""
if "reviewed_at" in patch:
row.reviewed_at = patch["reviewed_at"] or ""
if "published" in patch:
row.published = bool(patch["published"])
row.updated_at = now
@@ -645,3 +675,8 @@ class UserPageRepository:
async def set_published(self, user_id: str, published: bool) -> dict | None:
return await self.upsert(user_id, {"published": published})
async def list_pending(self) -> list[dict]:
stmt = select(UserPage).where(UserPage.review_status == "pending").order_by(UserPage.submitted_at.asc())
rows = (await self.session.execute(stmt)).scalars().all()
return [self._to_dict(r) for r in rows]
+11 -4
View File
@@ -1796,17 +1796,24 @@ class UserPage(Base):
"""个人主页(OPC 公开名片页):每位用户可自写 HTML 或嵌入第三方网页。
- page_type: html 自写 HTML(沙箱渲染)| embed 第三方网页地址(iframe
- capabilities_json: 接入的平台能力声明 [{key,name,desc,scope}],接入后
底部展示接入说明;未接入则提醒访客核实数据。
- published: 是否上架对外展示(人才市场等入口仅展示已发布页)。
- html_url: HTML 模式下,HTML 文件在 OSS 的相对路径(/oss/...);数据库不存 HTML 源码
- embed_url: embed 模式下的第三方网页 https 地址
- capabilities_json: 接入的平台能力声明 [{key,name,desc,scope}]embed 模式强制为空
- review_status: draft 草稿 | pending 待审核 | approved 已通过 | rejected 已拒绝
- published: 是否上架对外展示(仅 approved 状态可上架)
"""
__tablename__ = "user_pages"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
page_type: Mapped[str] = mapped_column(String, default="html") # html | embed
html_content: Mapped[str] = mapped_column(Text, default="") # 自写 HTML 源码
html_content: Mapped[str] = mapped_column(Text, default="") # 兼容旧数据,新数据存 OSS
html_url: Mapped[str] = mapped_column(String, default="") # OSS 相对路径 /oss/...
embed_url: Mapped[str] = mapped_column(String, default="") # 第三方网页 https 地址
capabilities_json: Mapped[str] = mapped_column(Text, default="[]")
review_status: Mapped[str] = mapped_column(String, default="draft") # draft|pending|approved|rejected
review_note: Mapped[str] = mapped_column(Text, default="")
submitted_at: Mapped[str] = mapped_column(String, default="")
reviewed_at: Mapped[str] = mapped_column(String, default="")
published: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[str] = mapped_column(String, default="")
updated_at: Mapped[str] = mapped_column(String, default="")
+13
View File
@@ -96,6 +96,19 @@ class OSS:
return self.cdn_url(key)
return await self.presigned_get(key, expires)
async def download(self, key: str) -> bytes | None:
"""直接读取对象内容(字节)。OSS 模式走 get_object,本地模式读文件。"""
key = self.clean_key(key)
if self.enabled:
client = await self._get_client()
resp = await client.get_object(Bucket=self.bucket, Key=key)
async with resp["Body"] as body:
return await body.read()
dest = self.local_dir / key
if dest.exists():
return dest.read_bytes()
return None
# ---- CDN URL 鉴权(阿里云「鉴权方式A」)----
# 「永久有效」实现:鉴权时间戳取 32 位最大值(2038-01),叠加控制台有效期内长期可访问