diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index f5aa8123..0eeecbbe 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -5,6 +5,7 @@ import { Put, Body, Param, + Query, UseGuards, Headers, UseInterceptors, @@ -19,6 +20,7 @@ import { ApiResponse, ApiConsumes, ApiBody, + ApiQuery, } from '@nestjs/swagger'; import { UserApplicationService } from '@/application/services/user-application.service'; import { StorageService } from '@/infrastructure/external/storage/storage.service'; @@ -597,4 +599,70 @@ export class UserAccountController { avatarUrl: result.url, }; } + + @Post('upload-image') + @ApiBearerAuth() + @ApiOperation({ summary: '上传通用图片' }) + @ApiConsumes('multipart/form-data') + @ApiQuery({ + name: 'category', + description: '图片分类 (如 office-photos)', + example: 'office-photos', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '图片文件 (支持 jpg, png, gif, webp, 最大10MB)', + }, + }, + }, + }) + @ApiResponse({ status: 200, description: '上传成功,返回图片URL' }) + @UseInterceptors(FileInterceptor('file')) + async uploadImage( + @CurrentUser() user: CurrentUserData, + @Query('category') category: string, + @UploadedFile() file: Express.Multer.File, + ) { + // 验证分类 + if (!category || category.trim() === '') { + throw new BadRequestException('请指定图片分类'); + } + + // 验证文件是否存在 + if (!file) { + throw new BadRequestException('请选择要上传的图片'); + } + + // 验证文件类型 + if (!this.storageService.isValidImageType(file.mimetype)) { + throw new BadRequestException( + '不支持的图片格式,请使用 jpg, png, gif 或 webp', + ); + } + + // 验证文件大小 + if (file.size > this.storageService.maxImageSize) { + throw new BadRequestException('图片大小不能超过 10MB'); + } + + // 上传文件 + const result = await this.storageService.uploadImage( + user.userId, + category.trim(), + file.buffer, + file.mimetype, + ); + + return { + message: '上传成功', + imageUrl: result.url, + key: result.key, + size: result.size, + }; + } } diff --git a/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts b/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts index 3b7c847c..8a3efd7a 100644 --- a/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts +++ b/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts @@ -218,4 +218,58 @@ export class StorageService implements OnModuleInit { get maxAvatarSize(): number { return 5 * 1024 * 1024; } + + /** + * 最大通用图片文件大小 (10MB) + */ + get maxImageSize(): number { + return 10 * 1024 * 1024; + } + + /** + * 上传通用图片 + * + * 用于办公室照片等通用图片上传 + * + * @param userId 用户ID + * @param category 分类 (如 'office-photos') + * @param buffer 文件内容 + * @param contentType MIME类型 (image/jpeg, image/png, etc.) + * @returns 上传结果 + */ + async uploadImage( + userId: string, + category: string, + buffer: Buffer, + contentType: string, + ): Promise { + // 使用 avatars bucket 存储所有用户图片 + // 路径格式: userId/category/uuid.ext + const extension = this.getExtensionFromContentType(contentType); + const key = `${userId}/${category}/${uuidv4()}${extension}`; + + try { + await this.client.putObject( + this.bucketAvatars, + key, + buffer, + buffer.length, + { 'Content-Type': contentType }, + ); + + const url = `${this.publicUrl}/${this.bucketAvatars}/${key}`; + + this.logger.log(`Image uploaded: ${key} (${buffer.length} bytes)`); + + return { + key, + url, + size: buffer.length, + contentType, + }; + } catch (error) { + this.logger.error(`Failed to upload image: ${error.message}`); + throw new Error(`文件上传失败: ${error.message}`); + } + } } diff --git a/frontend/mobile-app/lib/core/constants/api_endpoints.dart b/frontend/mobile-app/lib/core/constants/api_endpoints.dart index bcc6ffc6..97382f8c 100644 --- a/frontend/mobile-app/lib/core/constants/api_endpoints.dart +++ b/frontend/mobile-app/lib/core/constants/api_endpoints.dart @@ -80,6 +80,8 @@ class ApiEndpoints { static const String myAuthorizations = '$authorizations/my'; // 获取我的授权列表 static const String myCommunityHierarchy = '$authorizations/my/community-hierarchy'; // 获取社区层级 static const String stickmanRanking = '$authorizations/ranking/stickman'; // 火柴人排名 + static const String selfApplyStatus = '$authorizations/self-apply/status'; // 获取自助申请状态 + static const String selfApply = '$authorizations/self-apply'; // 自助申请授权 // Telemetry (-> Reporting Service) static const String telemetry = '/telemetry'; diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 2eeb6e13..51c24b1f 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -1214,6 +1214,89 @@ class AccountService { } } + /// 上传通用图片 + /// + /// [imageFile] - 图片文件 + /// [category] - 图片分类 (如 'office-photos') + /// 返回图片URL + Future uploadImage(File imageFile, String category) async { + debugPrint('$_tag uploadImage() - 开始上传图片'); + debugPrint('$_tag uploadImage() - 文件路径: ${imageFile.path}, 分类: $category'); + + try { + // 获取文件名和MIME类型 + final fileName = imageFile.path.split('/').last; + final extension = fileName.split('.').last.toLowerCase(); + + String mimeType; + switch (extension) { + case 'jpg': + case 'jpeg': + mimeType = 'image/jpeg'; + break; + case 'png': + mimeType = 'image/png'; + break; + case 'gif': + mimeType = 'image/gif'; + break; + case 'webp': + mimeType = 'image/webp'; + break; + default: + mimeType = 'image/jpeg'; + } + + debugPrint('$_tag uploadImage() - 文件名: $fileName, MIME: $mimeType'); + + // 创建 FormData + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + imageFile.path, + filename: fileName, + contentType: DioMediaType.parse(mimeType), + ), + }); + + // 调用 API + debugPrint('$_tag uploadImage() - 调用 POST /user/upload-image?category=$category'); + final response = await _apiClient.post( + '/user/upload-image', + data: formData, + queryParameters: {'category': category}, + ); + debugPrint('$_tag uploadImage() - API 响应状态码: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('上传图片失败: 空响应'); + } + + final responseData = response.data as Map; + debugPrint('$_tag uploadImage() - responseData: $responseData'); + + final data = responseData['data'] as Map?; + if (data == null) { + throw const ApiException('上传图片失败: 响应数据格式错误'); + } + + final imageUrl = data['imageUrl'] as String?; + debugPrint('$_tag uploadImage() - 图片URL: $imageUrl'); + + if (imageUrl == null || imageUrl.isEmpty) { + throw const ApiException('上传图片失败: 未返回图片URL'); + } + + return imageUrl; + } on ApiException catch (e) { + debugPrint('$_tag uploadImage() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag uploadImage() - 未知异常: $e'); + debugPrint('$_tag uploadImage() - 堆栈: $stackTrace'); + throw ApiException('上传图片失败: $e'); + } + } + /// 获取我的资料(从服务器) Future> getMyProfile() async { debugPrint('$_tag getMyProfile() - 获取我的资料'); diff --git a/frontend/mobile-app/lib/core/services/authorization_service.dart b/frontend/mobile-app/lib/core/services/authorization_service.dart index b3d6ca02..8bccea38 100644 --- a/frontend/mobile-app/lib/core/services/authorization_service.dart +++ b/frontend/mobile-app/lib/core/services/authorization_service.dart @@ -312,6 +312,122 @@ class CommunityHierarchy { } } +/// 自助申请授权类型 +enum SelfApplyAuthorizationType { + community, + cityTeam, + provinceTeam, +} + +extension SelfApplyAuthorizationTypeExtension on SelfApplyAuthorizationType { + String get value { + switch (this) { + case SelfApplyAuthorizationType.community: + return 'COMMUNITY'; + case SelfApplyAuthorizationType.cityTeam: + return 'CITY_TEAM'; + case SelfApplyAuthorizationType.provinceTeam: + return 'PROVINCE_TEAM'; + } + } + + String get displayName { + switch (this) { + case SelfApplyAuthorizationType.community: + return '社区'; + case SelfApplyAuthorizationType.cityTeam: + return '市团队'; + case SelfApplyAuthorizationType.provinceTeam: + return '省团队'; + } + } +} + +/// 待审核申请信息 +class PendingApplication { + final String applicationId; + final String type; + final DateTime appliedAt; + + PendingApplication({ + required this.applicationId, + required this.type, + required this.appliedAt, + }); + + factory PendingApplication.fromJson(Map json) { + return PendingApplication( + applicationId: json['applicationId'] ?? '', + type: json['type'] ?? '', + appliedAt: json['appliedAt'] != null + ? DateTime.parse(json['appliedAt']) + : DateTime.now(), + ); + } +} + +/// 用户授权申请状态响应 +class UserAuthorizationStatusResponse { + final bool hasPlanted; + final int plantedCount; + final List existingAuthorizations; + final List pendingApplications; + + UserAuthorizationStatusResponse({ + required this.hasPlanted, + required this.plantedCount, + required this.existingAuthorizations, + required this.pendingApplications, + }); + + factory UserAuthorizationStatusResponse.fromJson(Map json) { + return UserAuthorizationStatusResponse( + hasPlanted: json['hasPlanted'] ?? false, + plantedCount: json['plantedCount'] ?? 0, + existingAuthorizations: (json['existingAuthorizations'] as List?) + ?.map((e) => e.toString()) + .toList() ?? [], + pendingApplications: (json['pendingApplications'] as List?) + ?.map((e) => PendingApplication.fromJson(e as Map)) + .toList() ?? [], + ); + } +} + +/// 自助申请授权响应 +class SelfApplyAuthorizationResponse { + final String applicationId; + final String status; + final String type; + final DateTime appliedAt; + final DateTime? reviewedAt; + final String? reviewNote; + + SelfApplyAuthorizationResponse({ + required this.applicationId, + required this.status, + required this.type, + required this.appliedAt, + this.reviewedAt, + this.reviewNote, + }); + + factory SelfApplyAuthorizationResponse.fromJson(Map json) { + return SelfApplyAuthorizationResponse( + applicationId: json['applicationId'] ?? '', + status: json['status'] ?? 'PENDING', + type: json['type'] ?? '', + appliedAt: json['appliedAt'] != null + ? DateTime.parse(json['appliedAt']) + : DateTime.now(), + reviewedAt: json['reviewedAt'] != null + ? DateTime.parse(json['reviewedAt']) + : null, + reviewNote: json['reviewNote'], + ); + } +} + /// 火柴人排名响应 class StickmanRankingResponse { final String id; @@ -480,4 +596,109 @@ class AuthorizationService { rethrow; } } + + /// 获取用户授权申请状态 + /// + /// 返回用户是否已认种、认种棵数、已有授权和待审核申请 + /// 调用 GET /authorizations/self-apply/status + Future getSelfApplyStatus() async { + try { + debugPrint('获取授权申请状态...'); + final response = await _apiClient.get(ApiEndpoints.selfApplyStatus); + + if (response.statusCode == 200) { + final responseData = response.data; + Map? data; + if (responseData is Map) { + if (responseData.containsKey('data')) { + data = responseData['data'] as Map?; + } else { + data = responseData; + } + } + + if (data != null) { + final status = UserAuthorizationStatusResponse.fromJson(data); + debugPrint('授权申请状态获取成功: hasPlanted=${status.hasPlanted}, plantedCount=${status.plantedCount}'); + return status; + } + throw Exception('授权申请状态数据格式错误'); + } + + throw Exception('获取授权申请状态失败'); + } catch (e) { + debugPrint('获取授权申请状态失败: $e'); + rethrow; + } + } + + /// 自助申请授权 + /// + /// [type] 申请类型(社区、市团队、省团队) + /// [officePhotoUrls] 办公室照片URL列表(至少2张,最多6张) + /// [communityName] 社区名称(申请社区时必填) + /// [provinceCode] 省份代码(申请省团队时必填) + /// [provinceName] 省份名称(申请省团队时必填) + /// [cityCode] 城市代码(申请市团队时必填) + /// [cityName] 城市名称(申请市团队时必填) + Future selfApplyAuthorization({ + required SelfApplyAuthorizationType type, + required List officePhotoUrls, + String? communityName, + String? provinceCode, + String? provinceName, + String? cityCode, + String? cityName, + }) async { + try { + debugPrint('自助申请授权: type=${type.value}'); + + final body = { + 'type': type.value, + 'officePhotoUrls': officePhotoUrls, + }; + + // 根据申请类型添加必填字段 + if (type == SelfApplyAuthorizationType.community && communityName != null) { + body['communityName'] = communityName; + } + if (type == SelfApplyAuthorizationType.provinceTeam) { + if (provinceCode != null) body['provinceCode'] = provinceCode; + if (provinceName != null) body['provinceName'] = provinceName; + } + if (type == SelfApplyAuthorizationType.cityTeam) { + if (cityCode != null) body['cityCode'] = cityCode; + if (cityName != null) body['cityName'] = cityName; + } + + final response = await _apiClient.post( + ApiEndpoints.selfApply, + data: body, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = response.data; + Map? data; + if (responseData is Map) { + if (responseData.containsKey('data')) { + data = responseData['data'] as Map?; + } else { + data = responseData; + } + } + + if (data != null) { + final result = SelfApplyAuthorizationResponse.fromJson(data); + debugPrint('自助申请授权成功: applicationId=${result.applicationId}'); + return result; + } + throw Exception('申请响应数据格式错误'); + } + + throw Exception('自助申请授权失败'); + } catch (e) { + debugPrint('自助申请授权失败: $e'); + rethrow; + } + } } diff --git a/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart b/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart index bf5cc0a0..103b4689 100644 --- a/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart +++ b/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/authorization_service.dart'; /// 授权类型枚举 enum AuthorizationType { @@ -84,18 +86,15 @@ class _AuthorizationApplyPageState }); try { - // TODO: 调用API获取用户认种状态和已有授权 - // final authService = ref.read(authorizationServiceProvider); - // final status = await authService.getUserAuthorizationStatus(); - - // 模拟数据 - await Future.delayed(const Duration(milliseconds: 500)); + // 调用API获取用户认种状态和已有授权 + final authService = ref.read(authorizationServiceProvider); + final status = await authService.getSelfApplyStatus(); if (mounted) { setState(() { - _hasPlanted = true; // 模拟已认种 - _plantedCount = 10; // 模拟认种10棵 - _existingAuthorizations = []; // 模拟无已有授权 + _hasPlanted = status.hasPlanted; + _plantedCount = status.plantedCount; + _existingAuthorizations = status.existingAuthorizations; _isLoading = false; }); } @@ -206,15 +205,38 @@ class _AuthorizationApplyPageState }); try { - // TODO: 调用API提交申请 - // final authService = ref.read(authorizationServiceProvider); - // await authService.submitAuthorizationApplication( - // type: _selectedType!, - // officePhotos: _officePhotos, - // ); + final accountService = ref.read(accountServiceProvider); + final authService = ref.read(authorizationServiceProvider); - // 模拟请求 - await Future.delayed(const Duration(seconds: 2)); + // 1. 上传所有照片获取URL列表 + final photoUrls = []; + for (int i = 0; i < _officePhotos.length; i++) { + final url = await accountService.uploadImage( + _officePhotos[i], + 'office-photos', + ); + photoUrls.add(url); + } + + // 2. 将 AuthorizationType 转换为 SelfApplyAuthorizationType + SelfApplyAuthorizationType selfApplyType; + switch (_selectedType!) { + case AuthorizationType.community: + selfApplyType = SelfApplyAuthorizationType.community; + break; + case AuthorizationType.cityTeam: + selfApplyType = SelfApplyAuthorizationType.cityTeam; + break; + case AuthorizationType.provinceTeam: + selfApplyType = SelfApplyAuthorizationType.provinceTeam; + break; + } + + // 3. 提交授权申请 + await authService.selfApplyAuthorization( + type: selfApplyType, + officePhotoUrls: photoUrls, + ); if (mounted) { setState(() {