From cd007e2f6a787381f89ed1a3f1b95275467c45b0 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 28 Aug 2026 09:52:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E7=99=BB=E5=BD=95=E5=92=8Cos?= =?UTF-8?q?s=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WonderQ-Admin/.env.example | 1 + WonderQ-Admin/README.md | 5 +- WonderQ-Admin/app/config.py | 1 + WonderQ-Admin/app/media_urls.py | 32 +++++++++- WonderQ-Admin/app/redis_session.py | 6 +- WonderQ-Admin/app/routers/admin.py | 10 +++- WonderQ-Admin/docker-compose.yml | 1 + WonderQ-Admin/tests/test_config.py | 2 + .../tests/test_docker_compose_config.py | 6 ++ WonderQ-Admin/tests/test_media_urls.py | 24 ++++++++ WonderQ-Admin/tests/test_oss_upload.py | 58 +++++++++++++++++++ WonderQ-Admin/tests/test_redis_session.py | 14 +++++ docs/admin-api-requirements.md | 2 +- docs/module-config-api.md | 2 +- docs/public-api.md | 2 +- 15 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 WonderQ-Admin/tests/test_oss_upload.py create mode 100644 WonderQ-Admin/tests/test_redis_session.py diff --git a/WonderQ-Admin/.env.example b/WonderQ-Admin/.env.example index 47a8e7b..040a112 100644 --- a/WonderQ-Admin/.env.example +++ b/WonderQ-Admin/.env.example @@ -14,6 +14,7 @@ CORS_ORIGINS="*" OSS_ACCESS_KEY_ID="your-oss-access-key-id" OSS_ACCESS_KEY_SECRET="your-oss-access-key-secret" OSS_ENDPOINT="your-oss-endpoint" +OSS_PUBLIC_BASE_URL="https://your-oss-bucket.your-oss-public-endpoint" OSS_BUCKET_NAME="your-oss-bucket-name" WECHAT_MINIAPP_APPID="your-miniapp-appid" WECHAT_MINIAPP_SECRET="your-miniapp-secret" diff --git a/WonderQ-Admin/README.md b/WonderQ-Admin/README.md index 3c88922..40244b2 100644 --- a/WonderQ-Admin/README.md +++ b/WonderQ-Admin/README.md @@ -22,7 +22,10 @@ Copy-Item .env.example .env - `ADMIN_PERMISSION_CACHE_SECONDS`:管理员权限菜单缓存秒数,默认 300。 - `ADMIN_LOGIN_RATE_LIMIT` / `ADMIN_LOGIN_RATE_WINDOW_SECONDS`:登录限流窗口,默认每个 IP+账号 60 秒最多 5 次。 - `ADMIN_ACCESS_EXPIRES_MINUTES`、`ADMIN_REFRESH_EXPIRES_DAYS`:管理员访问令牌和刷新令牌有效期。 -- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET_NAME`:填写实际 OSS 配置;真实密钥只放在 `.env` 或部署平台密钥中,不提交到 Git。 +- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_BUCKET_NAME`:填写实际 OSS 认证与 Bucket 配置。 +- `OSS_ENDPOINT`:后端上传连接地址;ACK 与 OSS 同地域时使用内网 Endpoint。 +- `OSS_PUBLIC_BASE_URL`:浏览器可访问的完整 Bucket 公网根地址或已绑定 OSS 的自定义域名,例如 `https://.oss-cn-.aliyuncs.com`;不要包含对象路径。 +- 真实密钥只放在本地 `.env` 或部署平台 Secret 中,不提交到 Git。 菜单管理接口 `GET/POST/PATCH/DELETE /api/admin/system/menus` 维护目录、页面和按钮的父子树。服务端校验父级存在、禁止按钮作为父级、禁止循环归属和重复权限标识;具体字段规则以 [`docs/admin-api-requirements.md`](../docs/admin-api-requirements.md) 为准。 diff --git a/WonderQ-Admin/app/config.py b/WonderQ-Admin/app/config.py index 3c07cc6..c0e7fe1 100644 --- a/WonderQ-Admin/app/config.py +++ b/WonderQ-Admin/app/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): oss_access_key_id: str | None = Field(default=None) oss_access_key_secret: str | None = Field(default=None) oss_endpoint: str | None = Field(default=None) + oss_public_base_url: str | None = Field(default=None) oss_bucket_name: str | None = Field(default=None) wechat_miniapp_appid: str | None = Field(default=None) wechat_miniapp_secret: str | None = Field(default=None) diff --git a/WonderQ-Admin/app/media_urls.py b/WonderQ-Admin/app/media_urls.py index 3ddcd68..4d308b4 100644 --- a/WonderQ-Admin/app/media_urls.py +++ b/WonderQ-Admin/app/media_urls.py @@ -24,6 +24,24 @@ def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]: return parsed.scheme, host +def normalized_public_base_url(base_url: str) -> tuple[str, str]: + raw = base_url.strip().rstrip("/") + if "://" not in raw: + raw = f"https://{raw}" + parsed = urlsplit(raw) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise ValueError("invalid OSS public base URL") + return parsed.scheme, parsed.netloc + + def sign_oss_get_url( url: str, *, @@ -32,10 +50,17 @@ def sign_oss_get_url( endpoint: str, bucket: str, expires_at: int, + public_base_url: str | None = None, ) -> str: - scheme, expected_host = normalized_oss_host(endpoint, bucket) + source_scheme, source_host = normalized_oss_host(endpoint, bucket) + public_scheme, public_host = ( + normalized_public_base_url(public_base_url) + if public_base_url and public_base_url.strip() + else (source_scheme, source_host) + ) parsed = urlsplit(url) - if parsed.scheme != scheme or parsed.netloc.lower() != expected_host.lower(): + configured_hosts = {source_host.lower(), public_host.lower()} + if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in configured_hosts: return url path = parsed.path or "/" @@ -60,7 +85,7 @@ def sign_oss_get_url( ("Signature", signature), ] ) - return urlunsplit((scheme, expected_host, path, urlencode(query), "")) + return urlunsplit((public_scheme, public_host, path, urlencode(query), "")) def resolve_media_url(url: str | None) -> str | None: @@ -85,6 +110,7 @@ def resolve_media_url(url: str | None) -> str | None: endpoint=settings.oss_endpoint, bucket=settings.oss_bucket_name, expires_at=int(time()) + MEDIA_URL_EXPIRES_SECONDS, + public_base_url=getattr(settings, "oss_public_base_url", None), ) except (TypeError, ValueError): return url diff --git a/WonderQ-Admin/app/redis_session.py b/WonderQ-Admin/app/redis_session.py index 30b4bd5..d103edd 100644 --- a/WonderQ-Admin/app/redis_session.py +++ b/WonderQ-Admin/app/redis_session.py @@ -182,6 +182,8 @@ class InMemoryAdminSessionStore: class RedisAdminSessionStore: prefix = "wonderq:admin" + # WATCH/MULTI/EXEC requires every transaction key to share one Redis Cluster slot. + session_slot_tag = "{wonderq-admin-session}" def __init__(self) -> None: try: @@ -194,10 +196,10 @@ class RedisAdminSessionStore: raise RedisUnavailableError("Redis 会话存储不可用") from exc def _session_key(self, session_id: str) -> str: - return f"{self.prefix}:session:{session_id}" + return f"{self.prefix}:{self.session_slot_tag}:session:{session_id}" def _refresh_key(self, refresh_hash: str) -> str: - return f"{self.prefix}:refresh:{refresh_hash}" + return f"{self.prefix}:{self.session_slot_tag}:refresh:{refresh_hash}" def _permission_key(self, user_id: str) -> str: return f"{self.prefix}:permission:{user_id}" diff --git a/WonderQ-Admin/app/routers/admin.py b/WonderQ-Admin/app/routers/admin.py index fb19ab8..ba53d0a 100644 --- a/WonderQ-Admin/app/routers/admin.py +++ b/WonderQ-Admin/app/routers/admin.py @@ -58,7 +58,7 @@ from ..schemas import ( DetailPatch, ) from ..seed import create_media -from ..media_urls import resolve_media_url +from ..media_urls import normalized_public_base_url, resolve_media_url from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, media_model_dict, model_dict from .shared import site_config @@ -168,6 +168,7 @@ def oss_settings(): "OSS_ACCESS_KEY_ID": settings.oss_access_key_id, "OSS_ACCESS_KEY_SECRET": settings.oss_access_key_secret, "OSS_ENDPOINT": settings.oss_endpoint, + "OSS_PUBLIC_BASE_URL": settings.oss_public_base_url, "OSS_BUCKET_NAME": settings.oss_bucket_name, } missing = [key for key, value in values.items() if not (value and value.strip())] @@ -193,6 +194,10 @@ def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) -> settings = oss_settings() bucket = settings.oss_bucket_name.strip() scheme, host = normalized_oss_host(settings.oss_endpoint, bucket) + try: + public_scheme, public_host = normalized_public_base_url(settings.oss_public_base_url) + except (TypeError, ValueError): + media_error(503, "OSS 公网访问地址配置不正确", "MEDIA_STORAGE_INVALID_PUBLIC_URL") date = formatdate(usegmt=True) canonical_resource = f"/{bucket}/{key}" string_to_sign = f"PUT\n\n{mime_type}\n{date}\n{canonical_resource}" @@ -219,7 +224,7 @@ def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) -> media_error(502, "OSS 上传失败", "MEDIA_STORAGE_UPLOAD_FAILED") finally: connection.close() - return urlunsplit((scheme, host, f"/{quoted_key}", "", "")) + return urlunsplit((public_scheme, public_host, f"/{quoted_key}", "", "")) def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id: str | None = None, after=None, before=None) -> None: @@ -1551,4 +1556,3 @@ def upload_media_asset( db.commit() return success_response(media_model_dict(asset), status_code=status.HTTP_201_CREATED) - diff --git a/WonderQ-Admin/docker-compose.yml b/WonderQ-Admin/docker-compose.yml index ec9e8fb..92c07c3 100644 --- a/WonderQ-Admin/docker-compose.yml +++ b/WonderQ-Admin/docker-compose.yml @@ -14,6 +14,7 @@ services: OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-} OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-} OSS_ENDPOINT: ${OSS_ENDPOINT:-} + OSS_PUBLIC_BASE_URL: ${OSS_PUBLIC_BASE_URL:-} OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-} ports: - "4000:4000" diff --git a/WonderQ-Admin/tests/test_config.py b/WonderQ-Admin/tests/test_config.py index 4636f8a..21ecb84 100644 --- a/WonderQ-Admin/tests/test_config.py +++ b/WonderQ-Admin/tests/test_config.py @@ -5,6 +5,7 @@ def test_oss_settings_are_loaded_from_prefixed_environment(monkeypatch): monkeypatch.setenv("OSS_ACCESS_KEY_ID", "example-access-key-id") monkeypatch.setenv("OSS_ACCESS_KEY_SECRET", "example-access-key-secret") monkeypatch.setenv("OSS_ENDPOINT", "oss-cn-example.aliyuncs.com") + monkeypatch.setenv("OSS_PUBLIC_BASE_URL", "https://example-bucket.oss-cn-example.aliyuncs.com") monkeypatch.setenv("OSS_BUCKET_NAME", "example-bucket") settings = Settings(_env_file=None) @@ -12,6 +13,7 @@ def test_oss_settings_are_loaded_from_prefixed_environment(monkeypatch): assert settings.oss_access_key_id == "example-access-key-id" assert settings.oss_access_key_secret == "example-access-key-secret" assert settings.oss_endpoint == "oss-cn-example.aliyuncs.com" + assert settings.oss_public_base_url == "https://example-bucket.oss-cn-example.aliyuncs.com" assert settings.oss_bucket_name == "example-bucket" diff --git a/WonderQ-Admin/tests/test_docker_compose_config.py b/WonderQ-Admin/tests/test_docker_compose_config.py index aea1b89..20d1567 100644 --- a/WonderQ-Admin/tests/test_docker_compose_config.py +++ b/WonderQ-Admin/tests/test_docker_compose_config.py @@ -9,3 +9,9 @@ def test_api_compose_forwards_wechat_miniapp_settings_from_host_environment(): assert "WECHAT_MINIAPP_APPID: ${WECHAT_MINIAPP_APPID:-}" in compose assert "WECHAT_MINIAPP_SECRET: ${WECHAT_MINIAPP_SECRET:-}" in compose + + +def test_api_compose_forwards_oss_public_base_url_from_host_environment(): + compose = COMPOSE_PATH.read_text(encoding="utf-8") + + assert "OSS_PUBLIC_BASE_URL: ${OSS_PUBLIC_BASE_URL:-}" in compose diff --git a/WonderQ-Admin/tests/test_media_urls.py b/WonderQ-Admin/tests/test_media_urls.py index 69c308e..7c655e8 100644 --- a/WonderQ-Admin/tests/test_media_urls.py +++ b/WonderQ-Admin/tests/test_media_urls.py @@ -40,6 +40,7 @@ def test_resolve_media_url_only_signs_the_configured_oss_host(monkeypatch): oss_access_key_id="test-access-key", oss_access_key_secret="test-access-secret", oss_endpoint="oss-cn-guangzhou.aliyuncs.com", + oss_public_base_url="https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com", oss_bucket_name="one-feel-ota-data", ), ) @@ -55,6 +56,29 @@ def test_resolve_media_url_only_signs_the_configured_oss_host(monkeypatch): assert media_urls.resolve_media_url(external_url) == external_url +def test_resolve_media_url_rewrites_legacy_internal_oss_host_before_signing(monkeypatch): + monkeypatch.setattr( + media_urls, + "get_settings", + lambda: SimpleNamespace( + oss_access_key_id="test-access-key", + oss_access_key_secret="test-access-secret", + oss_endpoint="oss-cn-guangzhou-internal.aliyuncs.com", + oss_public_base_url="https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com", + oss_bucket_name="one-feel-ota-data", + ), + ) + monkeypatch.setattr(media_urls, "time", lambda: 1_800_000_000) + legacy_url = "https://one-feel-ota-data.oss-cn-guangzhou-internal.aliyuncs.com/admin/image.webp" + + signed = media_urls.resolve_media_url(legacy_url) + parsed = urlsplit(signed) + + assert parsed.netloc == "one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com" + assert parsed.path == "/admin/image.webp" + assert parse_qs(parsed.query)["OSSAccessKeyId"] == ["test-access-key"] + + def test_resolve_media_fields_signs_common_image_fields(monkeypatch): monkeypatch.setattr( "app.serializers.resolve_media_url", diff --git a/WonderQ-Admin/tests/test_oss_upload.py b/WonderQ-Admin/tests/test_oss_upload.py new file mode 100644 index 0000000..fc1bdc8 --- /dev/null +++ b/WonderQ-Admin/tests/test_oss_upload.py @@ -0,0 +1,58 @@ +from io import BytesIO +from types import SimpleNamespace + +from app.routers import admin as admin_router + + +class FakeOssResponse: + status = 200 + + def read(self, _size: int) -> bytes: + return b"" + + +class FakeOssConnection: + instances: list["FakeOssConnection"] = [] + + def __init__(self, host: str, timeout: int): + self.host = host + self.timeout = timeout + self.request_path: str | None = None + self.__class__.instances.append(self) + + def request(self, _method, path, **_kwargs) -> None: + self.request_path = path + + def getresponse(self) -> FakeOssResponse: + return FakeOssResponse() + + def close(self) -> None: + pass + + +def test_upload_uses_internal_endpoint_but_returns_public_url(monkeypatch): + FakeOssConnection.instances.clear() + monkeypatch.setattr( + admin_router, + "oss_settings", + lambda: SimpleNamespace( + oss_access_key_id="test-access-key", + oss_access_key_secret="test-access-secret", + oss_endpoint="oss-cn-guangzhou-internal.aliyuncs.com", + oss_public_base_url="https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com", + oss_bucket_name="one-feel-ota-data", + ), + ) + monkeypatch.setattr(admin_router.http.client, "HTTPSConnection", FakeOssConnection) + + url = admin_router.upload_image_to_oss( + BytesIO(b"image"), + "admin/general/image.webp", + "image/webp", + 5, + ) + + connection = FakeOssConnection.instances[0] + assert connection.host == "one-feel-ota-data.oss-cn-guangzhou-internal.aliyuncs.com" + assert connection.request_path == "/admin/general/image.webp" + assert url == "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/admin/general/image.webp" diff --git a/WonderQ-Admin/tests/test_redis_session.py b/WonderQ-Admin/tests/test_redis_session.py new file mode 100644 index 0000000..33c8ef1 --- /dev/null +++ b/WonderQ-Admin/tests/test_redis_session.py @@ -0,0 +1,14 @@ +from redis.cluster import key_slot + +from app.redis_session import RedisAdminSessionStore, hash_refresh_token + + +def test_redis_session_rotation_keys_share_cluster_slot(): + store = RedisAdminSessionStore.__new__(RedisAdminSessionStore) + keys = [ + store._session_key("session-1"), + store._refresh_key(hash_refresh_token("refresh-1")), + store._refresh_key(hash_refresh_token("refresh-2")), + ] + + assert len({key_slot(key.encode("utf-8")) for key in keys}) == 1 diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index 545125b..700a675 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -80,7 +80,7 @@ `GET /api/admin/media-assets?pageNum=1&pageSize=20` 按创建时间倒序分页返回素材,`pageSize` 最大为 `200`,响应为 `data: { items, total, pageNum, pageSize }`。管理端翻页时必须重新请求接口,不在前端截取固定数量的素材。 -媒体上传成功响应中的 `data.url` 是可直接用于图片回显的 HTTP(S) URL。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。 +媒体上传成功响应中的 `data.url` 是可直接用于图片回显的公网 HTTP(S) URL。服务端通过 `OSS_ENDPOINT` 连接 OSS 上传,通过 `OSS_PUBLIC_BASE_URL` 生成并持久化客户端访问地址;ACK 可使用内网 Endpoint 上传,但不得把内网 Host 返回给客户端。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。历史记录中的同 Bucket 内网 URL 会在响应时切换到公网 Host 后重新签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。 ## 站点模块 diff --git a/docs/module-config-api.md b/docs/module-config-api.md index 765032a..e5eb8c3 100644 --- a/docs/module-config-api.md +++ b/docs/module-config-api.md @@ -52,7 +52,7 @@ - `GET /api/admin/site-config` 返回启用和停用的完整记录,前端负责显示状态。 - `sortOrder` 为从 `0` 开始的非负整数,后端负责重新规范化。 -- 图片字段保存最终 HTTP(S) URL,不接受 base64;管理端上传组件通过媒体上传接口先取得 URL,再提交配置。OSS 私有读场景下,接口响应会为 OSS 图片 URL 临时追加短时 GET 签名,供管理端和 Public API 回显;后端会在每次响应时重新签名,签名参数不应由客户端自行拼接或长期缓存。 +- 图片字段保存最终公网 HTTP(S) URL,不接受 base64;管理端上传组件通过媒体上传接口先取得 URL,再提交配置。后端使用 `OSS_ENDPOINT` 上传、使用 `OSS_PUBLIC_BASE_URL` 生成客户端访问地址,不能持久化 OSS 内网 Host。OSS 私有读场景下,接口响应会为 OSS 图片 URL 临时追加短时 GET 签名,供管理端和 Public API 回显;后端会在每次响应时重新签名,签名参数不应由客户端自行拼接或长期缓存。历史内网 URL 在响应时会自动切换到公网 Host。 - `POST /api/admin/media-assets/upload` 只创建素材库记录,不会自动修改 `heroSlides` 或 `vehicleOptions`。将图片用于首页配置时,必须把返回的 `data.url` 写入对应编辑表单,并继续提交对应的 `POST` 或 `PATCH /api/admin/site-config/{module}/{id}`;成功后才会在 `GET /api/admin/site-config` 中返回该图片。 - 首页玩法推荐、团队共创和极境视界保留独立的新增、编辑、删除、启停和排序能力,保存后由 Public API 直接提供给 MiniAPP。 - 旧需求页主视觉、特色卡片、需求表单、体验推荐、用车服务说明等表结构已由后续 Alembic 迁移删除,不能在新代码中重新声明或调用。 diff --git a/docs/public-api.md b/docs/public-api.md index df17a05..28691b8 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -307,7 +307,7 @@ type HomeWildArchive = { 站点模块通常包含 `id`、`createdAt`、`updatedAt`、`isActive` 和 `sortOrder`。客户端按 `sortOrder` 消费排序模块,不依赖固定 ID。 -图片字段(如 `image`、`gallery`、`avatar`、`qrImage`)始终返回可直接请求的 HTTP(S) URL。OSS 配置为私有读时,WonderQ-Admin 会在响应中生成短时 GET 签名 URL;客户端应直接使用返回值,不应持久化或自行修改签名参数。 +图片字段(如 `image`、`gallery`、`avatar`、`qrImage`)始终返回可直接请求的公网 HTTP(S) URL。OSS 上传可使用内网 Endpoint,但响应地址统一使用 `OSS_PUBLIC_BASE_URL`;历史内网 URL 会在响应时自动切换到公网 Host。OSS 配置为私有读时,WonderQ-Admin 会在响应中生成短时 GET 签名 URL;客户端应直接使用返回值,不应持久化或自行修改签名参数。 旧需求页主视觉、特色卡片和需求表单已移除;需求页面只通过 `POST /api/public/leads` 提交实时线索,不再读取已删除的站点配置表。