feat: integrate real API for authorization apply page

Changes:
- Add generic image upload API endpoint (POST /user/upload-image)
- Add uploadImage method in StorageService for backend
- Add uploadImage method in AccountService for frontend
- Add selfApplyStatus and selfApplyAuthorization methods in AuthorizationService
- Replace mock data with real API calls in authorization apply page
- Add API endpoints for self-apply status and self-apply authorization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-21 22:01:28 -08:00
parent 5904f2f84d
commit ecf3755227
6 changed files with 467 additions and 17 deletions

View File

@ -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,
};
}
}

View File

@ -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<UploadResult> {
// 使用 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}`);
}
}
}

View File

@ -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';

View File

@ -1214,6 +1214,89 @@ class AccountService {
}
}
///
///
/// [imageFile] -
/// [category] - ( 'office-photos')
/// URL
Future<String> 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<String, dynamic>;
debugPrint('$_tag uploadImage() - responseData: $responseData');
final data = responseData['data'] as Map<String, dynamic>?;
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<Map<String, dynamic>> getMyProfile() async {
debugPrint('$_tag getMyProfile() - 获取我的资料');

View File

@ -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<String, dynamic> 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<String> existingAuthorizations;
final List<PendingApplication> pendingApplications;
UserAuthorizationStatusResponse({
required this.hasPlanted,
required this.plantedCount,
required this.existingAuthorizations,
required this.pendingApplications,
});
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
return UserAuthorizationStatusResponse(
hasPlanted: json['hasPlanted'] ?? false,
plantedCount: json['plantedCount'] ?? 0,
existingAuthorizations: (json['existingAuthorizations'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ?? [],
pendingApplications: (json['pendingApplications'] as List<dynamic>?)
?.map((e) => PendingApplication.fromJson(e as Map<String, dynamic>))
.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<String, dynamic> 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<UserAuthorizationStatusResponse> getSelfApplyStatus() async {
try {
debugPrint('获取授权申请状态...');
final response = await _apiClient.get(ApiEndpoints.selfApplyStatus);
if (response.statusCode == 200) {
final responseData = response.data;
Map<String, dynamic>? data;
if (responseData is Map<String, dynamic>) {
if (responseData.containsKey('data')) {
data = responseData['data'] as Map<String, dynamic>?;
} 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列表26
/// [communityName]
/// [provinceCode]
/// [provinceName]
/// [cityCode]
/// [cityName]
Future<SelfApplyAuthorizationResponse> selfApplyAuthorization({
required SelfApplyAuthorizationType type,
required List<String> officePhotoUrls,
String? communityName,
String? provinceCode,
String? provinceName,
String? cityCode,
String? cityName,
}) async {
try {
debugPrint('自助申请授权: type=${type.value}');
final body = <String, dynamic>{
'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<String, dynamic>? data;
if (responseData is Map<String, dynamic>) {
if (responseData.containsKey('data')) {
data = responseData['data'] as Map<String, dynamic>?;
} 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;
}
}
}

View File

@ -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 = <String>[];
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(() {