282 lines
7.9 KiB
Dart
282 lines
7.9 KiB
Dart
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/<id>', (req) => _getAlbumHandler(req, albumRepository))
|
|
..get(
|
|
'/album/<id>/photo',
|
|
(req) => _getPhotosOfAlbumHandler(req, photoRepository),
|
|
)
|
|
..get('/photo/<id>', (req) => _getPhotoHandler(req, photoRepository))
|
|
..get(
|
|
'/photo/<id>/file',
|
|
(req) => _getPhotoFileHandler(req, photoRepository),
|
|
)
|
|
..get(
|
|
'/photo/<id>/preview',
|
|
(req) => _getPhotoPreviewHandler(req, photoRepository, vips),
|
|
)
|
|
..get('/echo/<message>', _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<Response> _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<Response> _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<Response> _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.toJson());
|
|
}
|
|
|
|
Future<Response> _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<List<int>>();
|
|
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<Response> _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 page = int.tryParse(req.url.queryParameters['page'] ?? '1') ?? 1;
|
|
final size = int.tryParse(req.url.queryParameters['size'] ?? '20') ?? 20;
|
|
|
|
// 获取排序参数
|
|
final sortBy = req.url.queryParameters['sort'] ?? 'fileName';
|
|
final order = req.url.queryParameters['order'] ?? 'asc';
|
|
|
|
final result = await repo.getPhotosByAlbumIdPaged(
|
|
albumId,
|
|
page: page,
|
|
size: size,
|
|
sortBy: sortBy,
|
|
order: order,
|
|
);
|
|
|
|
return jsonResponse({
|
|
'items': result.items.map((p) => p.toJson()).toList(),
|
|
'total': result.total,
|
|
'page': result.page,
|
|
'size': result.size,
|
|
});
|
|
}
|
|
|
|
Future<Response> _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<List<int>>();
|
|
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(),
|
|
},
|
|
);
|
|
}
|