diff --git a/alembic/versions/0053_user_pages_review.py b/alembic/versions/0053_user_pages_review.py new file mode 100644 index 0000000..0f89063 --- /dev/null +++ b/alembic/versions/0053_user_pages_review.py @@ -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") diff --git a/app/api/routers/rbac_pages.py b/app/api/routers/rbac_pages.py index 672e1c1..65a331e 100644 --- a/app/api/routers/rbac_pages.py +++ b/app/api/routers/rbac_pages.py @@ -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} diff --git a/app/infrastructure/hall_repositories.py b/app/infrastructure/hall_repositories.py index fc8c929..b6b012a 100644 --- a/app/infrastructure/hall_repositories.py +++ b/app/infrastructure/hall_repositories.py @@ -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] diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 3f58a6d..670f0ae 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -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="") diff --git a/app/infrastructure/oss.py b/app/infrastructure/oss.py index 926f4c7..0ed6061 100644 --- a/app/infrastructure/oss.py +++ b/app/infrastructure/oss.py @@ -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),叠加控制台有效期内长期可访问