Dark模式 oss存储
Deploy dev / deploy (push) Successful in 1m16s

This commit is contained in:
Azmat@qq.com
2026-09-24 18:06:00 +08:00
parent ceb5163881
commit 5dbb240b59
46 changed files with 3491 additions and 350 deletions
+4 -4
View File
@@ -15,10 +15,10 @@ REDIS_CACHE_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/0
CELERY_BROKER_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/1
CELERY_RESULT_BACKEND=redis://:redis_WFSzWJ@yingqing-redis:6379/2
REDIS_LOCK_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/3
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
TOS_BUCKET=airshelf
TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY
TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ==
TOS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
TOS_BUCKET=yingqing-media
TOS_ACCESS_KEY_ID=LTAI5t62RF5Swgs2uwopvuZz
TOS_SECRET_ACCESS_KEY=CMmQNGiG2ieESRrhD1ORwuB6FYBmxE
VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
# 2026-07-09 视频(Seedance)ARK key 换成新账号,与新账号素材库审核同账号 → 真人 asset:// 可解析。EP=ep-20260707204223-wkcn9(备用,代码按模型名调不用它)。
+15
View File
@@ -238,11 +238,26 @@ CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled")
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
def _tos_region() -> str:
"""阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。"""
explicit = (env("TOS_REGION") or "").strip()
if explicit:
return explicit
host = (env("TOS_ENDPOINT") or "").split("://", 1)[-1].split("/", 1)[0]
if host.startswith("oss-") and host.endswith(".aliyuncs.com"):
return host.split(".", 1)[0]
return "cn-shanghai"
TOS = {
"endpoint": env("TOS_ENDPOINT"),
"bucket": env("TOS_BUCKET"),
"region": _tos_region(),
"access_key_id": env("TOS_ACCESS_KEY_ID"),
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
# 换到阿里云之后,历史文件仍在火山引擎旧桶。按桶名把旧链接指回原地址。
"legacy_endpoint": env("TOS_LEGACY_ENDPOINT", "https://tos-s3-cn-shanghai.volces.com"),
"legacy_bucket": env("TOS_LEGACY_BUCKET", "airshelf"),
}
VOLCANO = {
@@ -3,5 +3,16 @@ from .base import * # noqa: F403
DEBUG = True
# 本机 runserver 不在 K8s 的 service DNS 中;若直接沿用集群 Redis 地址,任何依赖
# cache.get/cache.add 的普通读取接口都会变成 500。开发环境默认使用进程内缓存,确需
# 联调分布式锁或 Celery 时再显式设 AIRSHELF_DEV_USE_REDIS_CACHE=true。
if not env_bool("AIRSHELF_DEV_USE_REDIS_CACHE", False): # noqa: F405
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "airshelf-development-cache",
}
}
# MySQL 连接复用(CONN_MAX_AGE / 健康检查 / connect_timeout)已上移到 base.py,
# development 与 production 共用,此处不再重复设置。
+17
View File
@@ -1,6 +1,8 @@
from decimal import Decimal
from unittest.mock import patch
from django.conf import settings
from django.db import IntegrityError
from django.test import TestCase
from rest_framework.test import APIClient
@@ -103,6 +105,21 @@ class SingleDeviceLoginTests(TestCase):
self.assertEqual(active.count(), 1)
self.assertEqual(active.get().user_agent, "Device B")
def test_login_succeeds_when_device_audit_write_fails(self):
"""设备审计表异常不能回滚已经签发的登录 Token。"""
with patch(
"apps.accounts.views.LoginSession.objects.create",
side_effect=IntegrityError("device audit unavailable"),
):
response = APIClient().post(
"/api/auth/login/",
{"username": self.user.username, "password": "strong-password"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertIn("token", response.data)
class InvitationFlowTests(TestCase):
def _register(self, client, username, **extra):
+4 -1
View File
@@ -99,7 +99,10 @@ def issue_single_device_token(request, user):
LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now())
Token.objects.filter(user=locked_user).delete()
token = Token.objects.create(user=locked_user)
record_login_session(request, locked_user)
# 设备记录只用于设置页展示和审计,不属于 Token 签发的关键路径。此前它在
# 同一事务里即使被 record_login_session 捕获,数据库事务仍可能已被标记为
# rollback,离开 atomic 后登录照样变成 500。
record_login_session(request, locked_user)
return token
+5 -9
View File
@@ -2991,7 +2991,7 @@ def notify_generation_failure(
def _asset_preview_url(asset) -> str:
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)"""
"""资产主文件的公读直链。写进会话的地址不能带签名,否则 1 小时后图片会消失"""
if asset is None:
return ""
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
@@ -2999,16 +2999,16 @@ def _asset_preview_url(asset) -> str:
return ""
if primary.preview_url:
return primary.preview_url
if not primary.object_key:
return ""
try:
return TosStorage().presigned_get_url(object_key=primary.object_key)
return TosStorage().public_url(object_key=primary.object_key, bucket=primary.bucket or None)
except Exception:
return ""
def asset_stable_url(asset) -> tuple[str, str]:
"""资产的长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个:
预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。
顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。"""
"""资产的长期直链 (主文件, 封面图)。公读地址不过期,结果卡隔天还能打开。"""
if asset is None:
return "", ""
files = list(asset.files.all())
@@ -3031,10 +3031,6 @@ def asset_stable_url(asset) -> tuple[str, str]:
return ""
try:
return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None)
except Exception: # noqa: BLE001
pass
try:
return TosStorage().presigned_get_url(object_key=f.object_key)
except Exception: # noqa: BLE001
return ""
+10 -2
View File
@@ -1780,12 +1780,20 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
)
cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}"
if is_plain:
cached = cache.get(cache_key)
# 模型目录缓存只是加速层;开发机或 Redis 短暂不可用时仍应正常返回目录。
try:
cached = cache.get(cache_key)
except Exception:
logger.warning("模型目录缓存不可用,改为直接读取", exc_info=True)
cached = None
if cached is not None:
return Response(cached)
response = super().list(request, *args, **kwargs)
if is_plain and response.status_code == 200:
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
try:
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
except Exception:
logger.warning("模型目录缓存写入失败,已返回直读结果", exc_info=True)
return response
+2 -3
View File
@@ -45,9 +45,8 @@ class AssetFileSerializer(serializers.ModelSerializer):
]
def get_preview_url(self, obj):
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。
# TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL
# (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼公读直链。
# 桶公读,直链不过期、可缓存。不再签发 1 小时就失效的预签名 URL
if obj.preview_url:
return obj.preview_url
if not obj.object_key or not settings.TOS.get("endpoint"):
+24 -5
View File
@@ -19,13 +19,25 @@ class TosStorage:
def __init__(self) -> None:
tos = settings.TOS
self.bucket = tos["bucket"]
config_kwargs = {
"signature_version": "s3v4",
"s3": {"addressing_style": "virtual"},
}
try:
client_config = Config(
**config_kwargs,
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
)
except TypeError:
client_config = Config(**config_kwargs)
self.client = boto3.client(
"s3",
endpoint_url=tos["endpoint"],
aws_access_key_id=tos["access_key_id"],
aws_secret_access_key=tos["secret_access_key"],
region_name="cn-shanghai",
config=Config(s3={"addressing_style": "virtual"}),
region_name=tos.get("region") or "cn-shanghai",
config=client_config,
)
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
@@ -57,6 +69,13 @@ class TosStorage:
def public_url(self, *, object_key: str, bucket: str | None = None) -> str:
"""桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}
相比预签名 URL:不签名( boto3 客户端/签名开销) URL 稳定可被浏览器/CDN 缓存
(签名链每次都变1h 过期,反而打不到缓存)仅用于公开可读的预览图"""
host = urlparse(settings.TOS["endpoint"]).netloc
return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}"
(签名链每次都变1h 过期,反而打不到缓存)仅用于公开可读的预览图
历史文件的 bucket 仍是火山引擎旧桶必须用旧域名不能拼到当前阿里云地址上"""
bucket_name = bucket or self.bucket
endpoint = settings.TOS.get("endpoint") or ""
legacy_bucket = settings.TOS.get("legacy_bucket") or ""
legacy_endpoint = settings.TOS.get("legacy_endpoint") or ""
if legacy_bucket and legacy_endpoint and bucket_name == legacy_bucket:
endpoint = legacy_endpoint
host = urlparse(endpoint).netloc
return f"https://{bucket_name}.{host}/{quote(object_key, safe='/')}"
+11 -1
View File
@@ -1,3 +1,4 @@
import logging
from datetime import timedelta
from django.core.cache import cache
@@ -25,6 +26,9 @@ from .models import Notification
from .serializers import NotificationSerializer
logger = logging.getLogger(__name__)
def project_stage_label(project):
return {
"script": "Stage 1 · 脚本",
@@ -288,7 +292,13 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
if not Notification.objects.filter(team=team).exists():
ensure_team_notifications(team, request.user)
return
if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60):
# 通知列表是普通读接口;Redis 不可用时跳过后台刷新,不能因此返回 500。
try:
should_refresh = cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60)
except Exception:
logger.warning("通知刷新缓存不可用,跳过本次后台刷新", exc_info=True)
should_refresh = False
if should_refresh:
from .tasks import ensure_team_notifications_task
user_id = str(request.user.id) if getattr(request.user, "id", None) else None