Skip to content

Commit b99e33b

Browse files
author
dawnstamp
committed
fix(寄件): 取消独立存储配置并复用系统路径
寄件码只保存授权信息,新上传直接使用系统存储及原路径生成器;移除自定义存储目录接口和重复校验。迁移保留授权计数及历史文件、进行中会话的位置。 验证:原有后端测试 114 项通过、2 项跳过;内存数据库检查迁移幂等、系统配置跟随及旧文件定位;未新增测试文件。
1 parent 6ca8f80 commit b99e33b

11 files changed

Lines changed: 76 additions & 156 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""删除寄件码独立存储配置;既有文件和上传会话的位置不变。"""
2+
3+
from tortoise.transactions import in_transaction
4+
5+
6+
async def migrate():
7+
# 重建仅授权数据的表,保留主键、计数和自增序列,不搬动任何已保存的文件。
8+
async with in_transaction() as conn:
9+
columns = {row["name"] for row in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")}
10+
if not {"storage_type", "target_path"} & columns:
11+
return
12+
sequences = await conn.execute_query_dict("SELECT seq FROM sqlite_sequence WHERE name = 'deliverycode'")
13+
sequence = sequences[0]["seq"] if sequences else 0
14+
await conn.execute_query('''
15+
CREATE TABLE deliverycode_system_storage (
16+
id INTEGER PRIMARY KEY AUTOINCREMENT,
17+
code_value VARCHAR(64) NULL UNIQUE,
18+
auth_version INT NOT NULL DEFAULT 1,
19+
name VARCHAR(100) NOT NULL,
20+
note VARCHAR(2000) NOT NULL DEFAULT '',
21+
tags JSON NOT NULL DEFAULT '[]',
22+
expires_at TIMESTAMP NOT NULL,
23+
max_uploads INT NOT NULL,
24+
used_count INT NOT NULL DEFAULT 0,
25+
reserved_count INT NOT NULL DEFAULT 0,
26+
enabled INT NOT NULL DEFAULT 1,
27+
deleted INT NOT NULL DEFAULT 0,
28+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
29+
)
30+
''')
31+
await conn.execute_query('''
32+
INSERT INTO deliverycode_system_storage (
33+
id, code_value, auth_version, name, note, tags, expires_at,
34+
max_uploads, used_count, reserved_count, enabled, deleted, created_at
35+
) SELECT id, code_value, auth_version, name, note, tags, expires_at,
36+
max_uploads, used_count, reserved_count, enabled, deleted, created_at
37+
FROM deliverycode
38+
''')
39+
await conn.execute_query("DROP TABLE deliverycode")
40+
await conn.execute_query("ALTER TABLE deliverycode_system_storage RENAME TO deliverycode")
41+
# 不能因历史行曾被删除而复用旧 ID,避免错误接回历史收件关联。
42+
await conn.execute_query("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'deliverycode'", [sequence])

‎apps/base/models.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,6 @@ class DeliveryCode(models.Model):
119119
name = fields.CharField(max_length=100)
120120
note = fields.CharField(max_length=2000, default="")
121121
tags = fields.JSONField(default=list)
122-
# system 仅标记寄件码跟随设置,实际收件记录始终保存解析后的存储类型与目录。
123-
storage_type = fields.CharField(max_length=20)
124-
target_path = fields.CharField(max_length=200)
125122
expires_at = fields.DatetimeField()
126123
max_uploads = fields.IntField()
127124
used_count = fields.IntField(default=0)

‎apps/base/upload_access.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from apps.delivery.services import upload_identity, active_code
1313
from core.settings import settings
1414
from core.storage import storages
15-
from apps.delivery.storage import get_storage
1615
from core.utils import get_now
1716

1817

@@ -89,7 +88,7 @@ async def prepare_upload(access, file_name, file_size, upload_id):
8988

9089
async def upload_storage(access=None):
9190
if access is not None and access.record is not None:
92-
return await get_storage(access.record.storage_type)
91+
return storages[access.record.storage_type]()
9392
return storages[settings.file_storage]()
9493

9594

‎apps/base/upload_sessions.py‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,14 @@
1313
from apps.base.utils import build_file_path
1414
from core.logger import logger
1515
from core.settings import settings
16-
from core.storage import StoredFile
17-
from apps.delivery.storage import get_storage, validate_storage_config
18-
from core.utils import get_now, sanitize_filename
16+
from core.storage import StoredFile, storages
17+
from core.utils import get_now
1918

2019
STALE_SECONDS = 7200
2120

2221

2322
async def reserve_slot(code_id, version, file_name, upload_id):
2423
"""次数原子预占与会话创建同事务;实际字节随后由共用配额入口预留。"""
25-
filename = await sanitize_filename((file_name or "file").replace("\\", "/").split("/")[-1])
26-
filename = filename.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "file"
2724
async with in_transaction() as conn:
2825
now = await get_now()
2926
p = _sql_placeholders(3)
@@ -35,18 +32,13 @@ async def reserve_slot(code_id, version, file_name, upload_id):
3532
)
3633
if changed != 1:
3734
raise HTTPException(409, "寄件码已失效或没有剩余上传次数")
38-
code = await DeliveryCode.get(id=code_id).using_db(conn)
39-
storage_type, path = code.storage_type, code.target_path
40-
if storage_type == "system":
41-
storage_type = settings.file_storage
42-
path, *_ = await build_file_path("delivery", upload_id)
43-
validate_storage_config(storage_type)
35+
# 每次新寄件都沿用原系统的存储设置和路径生成器,不读取寄件码独立配置。
36+
storage_type = settings.file_storage
37+
path, _, _, stored_name, _ = await build_file_path(file_name or "file", upload_id)
4438
token = "d_" + upload_id
45-
if code.storage_type != "system":
46-
path = f"{path}/{token}"
4739
return await StorageReservation.create(
4840
token=token, size=0, delivery_id=code_id, auth_version=version,
49-
filename=filename, stored_name=token + "_" + filename,
41+
filename=stored_name, stored_name=stored_name,
5042
file_path=path, storage_type=storage_type,
5143
expires_at=now + timedelta(seconds=STALE_SECONDS), using_db=conn,
5244
)
@@ -87,7 +79,7 @@ async def clean_reservation(record_id):
8779
if record is None:
8880
return
8981
try:
90-
storage = await get_storage(record.storage_type)
82+
storage = storages[record.storage_type]()
9183
if record.stored_name:
9284
path = f"{record.file_path}/{record.stored_name}"
9385
await storage.clean_chunks(record.token, path)
@@ -131,7 +123,7 @@ async def cleanup_once():
131123
tokens = await UploadChunk.filter(chunk_index=-1, upload_id__startswith="d_").limit(100).values_list("upload_id", flat=True)
132124
for share in await FileCodes.filter(upload_id__in=tokens):
133125
try:
134-
storage = await get_storage(share.storage_type)
126+
storage = storages[share.storage_type]()
135127
await storage.clean_chunks(share.upload_id, await share.get_file_path())
136128
await UploadChunk.filter(upload_id=share.upload_id).delete()
137129
except Exception:

‎apps/delivery/schemas.py‎

Lines changed: 3 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""寄件参数校验,所有路径都相对于已配置的存储根目录。"""
1+
"""只校验寄件授权参数,存储和文件规则由系统设置统一控制。"""
22

33
import re
44
from datetime import datetime, timezone, timedelta
@@ -7,34 +7,16 @@
77
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
88

99
from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags
10-
from core.path_validation import validate_storage_directory
1110

1211

1312
class DeliveryCodeConfig(BaseModel):
14-
"""创建和编辑共用配置校验,编辑请求不能改动口令或计数。"""
13+
"""寄件授权的公共字段,文件和存储配置不在此模型中。"""
1514
# 禁止静默接受 owner_id 等越权字段,未来多用户必须由服务端身份决定归属。
1615
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
1716
name: str = Field(min_length=1, max_length=100)
18-
# system 表示上传开始时跟随全站配置;旧记录的明确存储值继续保留。
19-
storage_type: str = "system"
20-
target_path: str = Field(default="", max_length=200)
2117
expires_at: datetime
2218
max_uploads: int = Field(default=1, ge=1, le=100000)
2319

24-
@field_validator("storage_type")
25-
@classmethod
26-
def validate_storage(cls, value):
27-
# 配置入口与设置页保持一致;历史收件的存储驱动仍保留供读取和清理。
28-
if value not in {"system", "local", "s3", "webdav"}:
29-
raise ValueError("不支持的存储类型")
30-
return value
31-
32-
@field_validator("target_path")
33-
@classmethod
34-
def validate_path(cls, value):
35-
# 目录校验与系统设置共用,避免同一路径在两个入口产生不同结果。
36-
return validate_storage_directory(value, allow_empty=True, max_length=200)
37-
3820
@field_validator("expires_at")
3921
@classmethod
4022
def validate_expiry(cls, value):
@@ -45,15 +27,6 @@ def validate_expiry(cls, value):
4527
raise ValueError("有效期必须晚于当前时间")
4628
return value
4729

48-
@model_validator(mode="after")
49-
def validate_storage_mode(self):
50-
# 跟随系统时不保存隐藏表单中残留的自定义目录。
51-
if self.storage_type == "system":
52-
self.target_path = ""
53-
elif not self.target_path:
54-
raise ValueError("自定义存储位置必须填写目标目录")
55-
return self
56-
5730

5831
class CreateDeliveryCode(DeliveryCodeConfig):
5932
# 新建自定义寄件码最多 32 位;自动生成仍固定为 16 位。
@@ -85,26 +58,12 @@ class UpdateDeliveryCode(BaseModel):
8558

8659
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
8760
name: str | None = Field(default=None, min_length=1, max_length=100)
88-
storage_type: str | None = None
89-
target_path: str | None = Field(default=None, max_length=200)
9061
expires_at: datetime | None = None
9162
max_uploads: int | None = Field(default=None, ge=1, le=100000)
9263
code: str = Field(default="", max_length=32)
9364
note: str | None = Field(default=None, max_length=2000)
9465
tags: list[str] | None = None
9566

96-
@field_validator("storage_type")
97-
@classmethod
98-
def validate_storage(cls, value):
99-
if value is not None and value not in {"system", "local", "s3", "webdav"}:
100-
raise ValueError("不支持的存储类型")
101-
return value
102-
103-
@field_validator("target_path")
104-
@classmethod
105-
def validate_path(cls, value):
106-
return None if value is None else validate_storage_directory(value, allow_empty=True, max_length=200)
107-
10867
@field_validator("code")
10968
@classmethod
11069
def validate_code(cls, value):
@@ -131,7 +90,7 @@ def normalize_tags(cls, value):
13190
@model_validator(mode="after")
13291
def reject_explicit_null(self):
13392
# 可选字段表示可以省略,不能用 null 意外清空已有配置。
134-
forbidden = {"name", "storage_type", "target_path", "expires_at", "max_uploads", "note", "tags"}
93+
forbidden = {"name", "expires_at", "max_uploads", "note", "tags"}
13594
if any(field in self.model_fields_set and getattr(self, field) is None for field in forbidden):
13695
raise ValueError("编辑字段不能为 null;请省略不修改的字段")
13796
return self

‎apps/delivery/services.py‎

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
from apps.admin.dependencies import create_token, verify_token
1212
from apps.base.models import DeliveryCode, StorageReservation
1313
from apps.base.quota import _sql_placeholders
14-
from apps.delivery.storage import validate_storage_config
15-
from core.settings import settings
14+
from apps.base.setup_wizard import build_public_config
1615
from core.utils import get_now
1716

1817
TOKEN_TTL = 900
@@ -46,12 +45,10 @@ async def active_code(code_id: int) -> DeliveryCode:
4645

4746
async def create_code(data):
4847
"""创建时保存原文,便于管理员后续查看;访客响应仍不提供任何口令列表。"""
49-
validate_storage_config(settings.file_storage if data.storage_type == "system" else data.storage_type)
5048
code = data.code or "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16))
5149
try:
5250
record = await DeliveryCode.create(
5351
code_value=code, name=data.name,
54-
storage_type=data.storage_type, target_path=data.target_path,
5552
expires_at=data.expires_at, max_uploads=data.max_uploads,
5653
note=data.note, tags=data.tags,
5754
)
@@ -73,10 +70,10 @@ async def code_summary(record, *, include_code=False):
7370
elif record.expires_at <= now:
7471
state = "expired"
7572
return {
76-
"id": record.id, "name": record.name, "storage_type": record.storage_type,
73+
"id": record.id, "name": record.name,
7774
**({"code": record.code_value} if include_code else {}),
7875
"note": record.note, "tags": record.tags if isinstance(record.tags, list) else [],
79-
"target_path": record.target_path, "expires_at": record.expires_at,
76+
"expires_at": record.expires_at,
8077
"max_uploads": record.max_uploads, "used_count": record.used_count,
8178
"reserved_count": record.reserved_count, "enabled": record.enabled,
8279
"deleted": record.deleted, "status": state, "created_at": record.created_at,
@@ -98,14 +95,16 @@ async def verify_code(code: str):
9895

9996
async def session_summary(record):
10097
"""验证与续期使用相同白名单响应,不泄露路径、存储密钥或管理员授权。"""
98+
# 老版寄件页面仍读取这些字段;值统一取自原公共配置构建函数,不维护独立规则。
99+
config = build_public_config()
100+
upload_keys = ("upload_size", "allowed_file_types", "expire_style", "max_save_seconds", "enable_chunk")
101101
return {
102102
"token": create_token({"purpose": "delivery", "delivery_id": record.id,
103103
"delivery_version": record.auth_version}, expires_in=TOKEN_TTL),
104104
"expires_in": TOKEN_TTL, "name": record.name,
105105
"remaining": max(0, record.max_uploads - record.used_count - record.reserved_count),
106-
"expires_at": record.expires_at, "upload_size": settings.upload_size,
107-
"allowed_file_types": settings.allowed_file_types, "expire_style": settings.expire_style,
108-
"max_save_seconds": settings.max_save_seconds, "enable_chunk": settings.enable_chunk,
106+
"expires_at": record.expires_at,
107+
**{key: config[key] for key in upload_keys},
109108
}
110109

111110

@@ -135,18 +134,6 @@ async def update_code(code_id, data):
135134
max_uploads = changes.get("max_uploads", record.max_uploads)
136135
if record.used_count + record.reserved_count > max_uploads:
137136
raise HTTPException(409, "上传总次数不能小于已使用次数与上传中占用次数之和,请刷新后重试")
138-
storage_requested = "storage_type" in changes or "target_path" in changes
139-
storage_type = changes.get("storage_type", record.storage_type)
140-
target_path = changes.get("target_path", record.target_path)
141-
if storage_type == "system":
142-
target_path = ""
143-
elif not target_path:
144-
raise HTTPException(400, "自定义存储位置必须填写目标目录")
145-
# 整理名称或元数据时不依赖当前后端配置;仅实际改存储设置才重新校验。
146-
if storage_requested and (storage_type != record.storage_type or target_path != record.target_path):
147-
validate_storage_config(settings.file_storage if storage_type == "system" else storage_type)
148-
changes["storage_type"] = storage_type
149-
changes["target_path"] = target_path
150137
if new_code and new_code != record.code_value:
151138
# 版本由数据库递增,两个改码请求并发时任一旧令牌都不会被错误复用。
152139
changes.update(code_value=new_code, auth_version=True)
@@ -173,12 +160,10 @@ async def update_code(code_id, data):
173160
return await code_summary(record)
174161

175162

176-
async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_type="all", tag="", sort_by="created_at", sort_order="desc"):
163+
async def list_codes(*, page=1, page_size=20, keyword="", status="all", tag="", sort_by="created_at", sort_order="desc"):
177164
"""筛选、计数、排序和分页全部在数据库执行,列表不返回口令原文。"""
178165
if status not in {"all", "active", "disabled", "expired", "exhausted"}:
179166
raise HTTPException(400, "不支持的寄件码状态筛选")
180-
if storage_type not in {"all", "system", "local", "s3", "webdav"}:
181-
raise HTTPException(400, "不支持的存储类型筛选")
182167
if sort_by not in {"created_at", "expires_at", "name", "used_count", "max_uploads"} or sort_order not in {"asc", "desc"}:
183168
raise HTTPException(400, "不支持的排序方式")
184169
query = DeliveryCode.filter(deleted=False)
@@ -191,8 +176,6 @@ async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_
191176
query = query.filter(enabled=True, expires_at__lte=now, used_count__lt=F("max_uploads"))
192177
elif status == "active":
193178
query = query.filter(enabled=True, expires_at__gt=now, used_count__lt=F("max_uploads"))
194-
if storage_type != "all":
195-
query = query.filter(storage_type=storage_type)
196179
if keyword.strip():
197180
query = query.filter(Q(name__icontains=keyword.strip()) | Q(note__icontains=keyword.strip()))
198181
if tag.strip():
@@ -206,7 +189,7 @@ async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_
206189
order = ("-" if sort_order == "desc" else "") + sort_by
207190
# 只选取管理展示字段,口令原文仅由单独管理接口按需返回。
208191
records = await query.order_by(order, "-id").offset((page - 1) * page_size).limit(page_size).only(
209-
"id", "name", "note", "tags", "storage_type", "target_path", "expires_at",
192+
"id", "name", "note", "tags", "expires_at",
210193
"max_uploads", "used_count", "reserved_count", "enabled", "deleted", "created_at",
211194
)
212195
return {"items": [await code_summary(record) for record in records], "total": total}

‎apps/delivery/storage.py‎

Lines changed: 0 additions & 30 deletions
This file was deleted.

‎apps/delivery/views.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ async def refresh(authorization: str | None = Header(default=None)):
4747
@admin_api.get("/codes")
4848
async def list_codes(
4949
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str = "",
50-
status: str = "all", storage_type: str = "all", tag: str = "", sort_by: str = "created_at", sort_order: str = "desc",
50+
status: str = "all", tag: str = "", sort_by: str = "created_at", sort_order: str = "desc",
5151
):
5252
return APIResponse(detail=await services.list_codes(
53-
page=page, page_size=page_size, keyword=keyword, status=status, storage_type=storage_type,
53+
page=page, page_size=page_size, keyword=keyword, status=status,
5454
tag=tag, sort_by=sort_by, sort_order=sort_order,
5555
))
5656

0 commit comments

Comments
 (0)