解决登录和oss问题
This commit is contained in:
1 parent
30c2e44dc0
commit
cd007e2f6a
15 files changed
+154
-12
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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://<bucket>.oss-cn-<region>.aliyuncs.com`;不要包含对象路径。
|
||||
- 真实密钥只放在本地 `.env` 或部署平台 Secret 中,不提交到 Git。
|
||||
|
||||
菜单管理接口 `GET/POST/PATCH/DELETE /api/admin/system/menus` 维护目录、页面和按钮的父子树。服务端校验父级存在、禁止按钮作为父级、禁止循环归属和重复权限标识;具体字段规则以 [`docs/admin-api-requirements.md`](../docs/admin-api-requirements.md) 为准。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user