import 'dart:convert'; import 'dart:io'; import 'dart:async'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import 'config/app_config.dart'; import 'util/vips.dart'; import 'domain/repositories/photo_repository.dart'; import 'domain/repositories/album_repository.dart'; final _config = AppConfig(); final vips = Vips(cacheDir: _config.cacheDir); final albumRepository = AlbumRepository(basePath: _config.dataDir); final photoRepository = PhotoRepository(_config.dataDir); Response jsonResponse(dynamic data) { return Response.ok( jsonEncode(data), headers: {'content-type': 'application/json'}, ); } /// 路由依赖注入容器 /// /// 允许在测试中使用 mock 或临时目录,而不是全局单例。 class AppRouter { final AlbumRepository albumRepository; final PhotoRepository photoRepository; final Vips vips; AppRouter({ AlbumRepository? albumRepository, PhotoRepository? photoRepository, Vips? vips, }) : albumRepository = albumRepository ?? AlbumRepository(basePath: _config.dataDir), photoRepository = photoRepository ?? PhotoRepository(_config.dataDir), vips = vips ?? Vips(cacheDir: _config.cacheDir); /// 创建 API v1 版本的路由 Router createApiV1Router() { final router = Router() ..get('/', _rootHandler) ..get('/album', (req) => _listAlbumHandler(req, albumRepository)) ..get('/album/', (req) => _getAlbumHandler(req, albumRepository)) ..get( '/album//photo', (req) => _getPhotosOfAlbumHandler(req, photoRepository), ) ..get('/photo/', (req) => _getPhotoHandler(req, photoRepository)) ..get( '/photo//file', (req) => _getPhotoFileHandler(req, photoRepository), ) ..get( '/photo//preview', (req) => _getPhotoPreviewHandler(req, photoRepository, vips), ) ..get('/echo/', _echoHandler); return router; } /// 创建主路由器,将 API v1 挂载到 /api/v1 路径下 Router createRouter() { final router = Router(); router.mount('/api/v1', createApiV1Router().call); return router; } } /// 创建 API v1 版本的路由(使用全局单例,保持向后兼容) Router createApiV1Router() { return AppRouter().createApiV1Router(); } /// 创建主路由器,将 API v1 挂载到 /api/v1 路径下(使用全局单例,保持向后兼容) Router createRouter() { return AppRouter().createRouter(); } Response _rootHandler(Request req) { return Response.ok('Hello, World!\n'); } Response _echoHandler(Request request) { final message = request.params['message']; return Response.ok('$message\n'); } Future _listAlbumHandler(Request req, AlbumRepository repo) async { final albums = await repo.getAllAlbums(); final albumList = albums .map( (a) => { 'id': a.id, 'name': a.name, 'createdAt': a.createdAt.toIso8601String(), 'updatedAt': a.updatedAt.toIso8601String(), }, ) .toList(); return jsonResponse(albumList); } Future _getAlbumHandler(Request req, AlbumRepository repo) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing album id'); } final id = int.tryParse(idParam); if (id == null) { return Response.badRequest(body: 'Invalid album id: $idParam'); } final album = await repo.getAlbumById(id); if (album == null) { return Response.notFound('Album not found'); } return jsonResponse({'id': album.id, 'name': album.name}); } Future _getPhotoHandler(Request req, PhotoRepository repo) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing photo id'); } final id = int.tryParse(idParam); if (id == null) { return Response.badRequest(body: 'Invalid photo id: $idParam'); } final photo = await repo.getPhotoById(id); if (photo == null) { return Response.notFound('Photo not found'); } return jsonResponse(photo); } Future _getPhotoFileHandler(Request req, PhotoRepository repo) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing photo id'); } final id = int.tryParse(idParam); if (id == null) { return Response.badRequest(body: 'Invalid photo id: $idParam'); } final photo = await repo.getPhotoById(id); if (photo == null) { return Response.notFound('Photo not found'); } final filePath = photo.filePath; final file = File(filePath); if (!await file.exists()) { return Response.notFound('File not found'); } // ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流 final streamController = StreamController>(); final fileStream = file.openRead(); // 订阅文件流,并将数据转发到响应流 final subscription = fileStream.listen( streamController.add, onError: streamController.addError, onDone: streamController.close, cancelOnError: true, ); // 当客户端取消订阅时(断开连接),取消文件流订阅并关闭文件 streamController.onCancel = () { subscription.cancel(); return Future.value(); }; return Response.ok( streamController.stream, headers: { 'Content-Type': photo.mimeType, 'Content-Length': photo.fileSize.toString(), }, ); } Future _getPhotosOfAlbumHandler( Request req, PhotoRepository repo, ) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing album id'); } final albumId = int.tryParse(idParam); if (albumId == null) { return Response.badRequest(body: 'Invalid album id: $idParam'); } final photos = await repo.getPhotosByAlbumId(albumId); return jsonResponse(photos); } Future _getPhotoPreviewHandler( Request req, PhotoRepository repo, Vips vips, ) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing photo id'); } final id = int.tryParse(idParam); if (id == null) { return Response.badRequest(body: 'Invalid photo id: $idParam'); } final photo = await repo.getPhotoById(id); if (photo == null) { return Response.notFound('Photo not found'); } // 从查询参数获取宽度和高度 final w = int.tryParse(req.url.queryParameters['w'] ?? ''); final h = int.tryParse(req.url.queryParameters['h'] ?? ''); final file = await vips.generatePreview(photo.filePath, w: w, h: h); if (!await file.exists()) { return Response.notFound('File not found'); } // ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流 final streamController = StreamController>(); final fileStream = file.openRead(); // 订阅文件流,并将数据转发到响应流 final subscription = fileStream.listen( streamController.add, onError: streamController.addError, onDone: streamController.close, cancelOnError: true, ); // 当客户端取消订阅时(断开连接),取消文件流订阅并关闭文件 streamController.onCancel = () { subscription.cancel(); return Future.value(); }; return Response.ok( streamController.stream, headers: { 'Content-Type': 'image/webp', 'Content-Length': file.statSync().size.toString(), }, ); }