1111from fastapi import APIRouter , Form , Request , UploadFile , File , Depends , HTTPException
1212from pydantic import BaseModel , ValidationError
1313from starlette import status
14+ from tortoise .expressions import Case , F , Q , When
1415
1516from apps .admin .dependencies import share_required_login
1617from apps .base .models import FileCodes , UploadChunk , PresignUploadSession
2930from core .response import APIResponse
3031from core .settings import settings
3132from core .storage import storages , FileStorageInterface
32- from core .utils import get_select_token , get_now , sanitize_filename
33+ from core .utils import (
34+ get_file_url as get_proxy_file_url ,
35+ get_select_token ,
36+ get_now ,
37+ sanitize_filename ,
38+ )
3339
3440share_api = APIRouter (prefix = "/share" , tags = ["分享" ])
3541
@@ -211,11 +217,25 @@ async def get_code_file_by_code(
211217 return True , file_code
212218
213219
214- async def update_file_usage (file_code : FileCodes ) -> None :
215- file_code .used_count += 1
216- if file_code .expired_count > 0 :
217- file_code .expired_count -= 1
218- await file_code .save ()
220+ async def consume_file_usage (file_code : FileCodes ) -> bool :
221+ """原子校验分享状态并记录一次实际领取。"""
222+ now = await get_now ()
223+ eligible = (
224+ Q (expired_count__gt = 0 )
225+ | Q (expired_count__lt = 0 , expired_at__gt = now )
226+ | Q (expired_count__lt = 0 , expired_at = None )
227+ )
228+ updated = await FileCodes .filter (Q (id = file_code .id ) & eligible ).update (
229+ expired_count = Case (
230+ When (expired_count__gt = 0 , then = F ("expired_count" ) - 1 ),
231+ default = F ("expired_count" ),
232+ ),
233+ used_count = F ("used_count" ) + 1 ,
234+ )
235+ if not updated :
236+ return False
237+ await file_code .refresh_from_db ()
238+ return True
219239
220240
221241def build_file_metadata (file_code : FileCodes ) -> dict :
@@ -242,9 +262,13 @@ async def build_select_detail(
242262 file_code : FileCodes , file_storage : FileStorageInterface
243263) -> dict :
244264 metadata = build_file_metadata (file_code )
245- download_url = (
246- None if file_code .text is not None else await file_storage .get_file_url (file_code )
247- )
265+ if file_code .text is not None :
266+ download_url = None
267+ elif file_code .expired_count >= 0 :
268+ # 有次数限制的文件必须经过下载接口,第三方直链无法阻止重复使用。
269+ download_url = await get_proxy_file_url (file_code .code )
270+ else :
271+ download_url = await file_storage .get_file_url (file_code )
248272 content = file_code .text if file_code .text is not None else None
249273 return {
250274 ** metadata ,
@@ -255,24 +279,28 @@ async def build_select_detail(
255279
256280
257281@share_api .get ("/metadata/" )
258- async def get_file_metadata (code : str , ip : str = Depends (ip_limit ["error " ])):
282+ async def get_file_metadata (code : str , ip : str = Depends (ip_limit ["metadata " ])):
259283 has , file_code = await get_code_file_by_code (code )
260284 if not has :
261- ip_limit ["error " ].add_ip (ip )
285+ ip_limit ["metadata " ].add_ip (ip )
262286 return APIResponse (code = 404 , detail = file_code )
263287
264288 assert isinstance (file_code , FileCodes )
289+ ip_limit ["metadata" ].add_ip (ip )
265290 return APIResponse (detail = build_file_metadata (file_code ))
266291
267292
268293@share_api .post ("/metadata/" )
269- async def post_file_metadata (data : SelectFileModel , ip : str = Depends (ip_limit ["error" ])):
294+ async def post_file_metadata (
295+ data : SelectFileModel , ip : str = Depends (ip_limit ["metadata" ])
296+ ):
270297 has , file_code = await get_code_file_by_code (data .code )
271298 if not has :
272- ip_limit ["error " ].add_ip (ip )
299+ ip_limit ["metadata " ].add_ip (ip )
273300 return APIResponse (code = 404 , detail = file_code )
274301
275302 assert isinstance (file_code , FileCodes )
303+ ip_limit ["metadata" ].add_ip (ip )
276304 return APIResponse (detail = build_file_metadata (file_code ))
277305
278306
@@ -285,7 +313,8 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])):
285313 return APIResponse (code = 404 , detail = file_code )
286314
287315 assert isinstance (file_code , FileCodes )
288- await update_file_usage (file_code )
316+ if not await consume_file_usage (file_code ):
317+ return APIResponse (code = 404 , detail = "文件已过期" )
289318 return await file_storage .get_file_response (file_code )
290319
291320
@@ -298,8 +327,16 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
298327 return APIResponse (code = 404 , detail = file_code )
299328
300329 assert isinstance (file_code , FileCodes )
301- await update_file_usage (file_code )
302- return APIResponse (detail = await build_select_detail (file_code , file_storage ))
330+ detail = await build_select_detail (file_code , file_storage )
331+ download_url = detail .get ("download_url" )
332+ consumes_on_download = isinstance (download_url , str ) and download_url .startswith (
333+ "/share/download?"
334+ )
335+ if not consumes_on_download and not await consume_file_usage (file_code ):
336+ return APIResponse (code = 404 , detail = "文件已过期" )
337+ if not consumes_on_download :
338+ detail .update (build_file_metadata (file_code ))
339+ return APIResponse (detail = detail )
303340
304341
305342@share_api .get ("/download" )
@@ -309,10 +346,12 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
309346 if await get_select_token (normalized_code ) != key :
310347 ip_limit ["error" ].add_ip (ip )
311348 raise HTTPException (status_code = 403 , detail = "下载鉴权失败" )
312- has , file_code = await get_code_file_by_code (normalized_code , False )
349+ has , file_code = await get_code_file_by_code (normalized_code )
313350 if not has :
314- return APIResponse (code = 404 , detail = "文件不存在" )
351+ return APIResponse (code = 404 , detail = file_code )
315352 assert isinstance (file_code , FileCodes )
353+ if not await consume_file_usage (file_code ):
354+ return APIResponse (code = 404 , detail = "文件已过期" )
316355 return (
317356 APIResponse (detail = file_code .text )
318357 if file_code .text
0 commit comments