feat: 增强移动端版本管理功能

## admin-service
- 添加 APK/IPA 预解析 API (/api/v1/versions/parse)
- 添加断点续传下载控制器 (/api/v1/downloads/:filename)
- 配置 uploads volume 持久化存储
- 下载 URL 从 /uploads 改为 /downloads (支持 Range 请求)

## mobile-upgrade (前端)
- 上传文件后自动解析并填充版本信息
- 添加 ParsedPackageInfo 类型和 parsePackage API

## mobile-app (Flutter)
- DownloadManager 支持断点续传 (HTTP Range)
- 添加临时文件管理和清理功能
- 添加构建脚本自动增加版本号 (scripts/build.sh)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Developer 2025-12-03 06:57:26 -08:00
parent 8932d87df7
commit f8607ce0b2
12 changed files with 584 additions and 26 deletions

View File

@ -26,6 +26,11 @@ services:
- REDIS_PORT=6379
- REDIS_PASSWORD=
- REDIS_DB=9
# File Storage
- UPLOAD_DIR=/app/uploads
- BASE_URL=${BASE_URL:-https://rwaapi.szaiai.com/api/v1}
volumes:
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
@ -82,6 +87,8 @@ volumes:
name: admin-service-postgres-data
redis_data:
name: admin-service-redis-data
uploads_data:
name: admin-service-uploads-data
networks:
admin-network:

View File

@ -0,0 +1,106 @@
import {
Controller,
Get,
Param,
Res,
Req,
NotFoundException,
Logger,
} from '@nestjs/common'
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'
import { Request, Response } from 'express'
import { ConfigService } from '@nestjs/config'
import * as fs from 'fs'
import * as path from 'path'
@ApiTags('Downloads')
@Controller('downloads')
export class DownloadController {
private readonly logger = new Logger(DownloadController.name)
private readonly uploadDir: string
constructor(private readonly configService: ConfigService) {
this.uploadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads'
}
@Get(':filename')
@ApiOperation({ summary: '下载文件 (支持断点续传)' })
@ApiParam({ name: 'filename', description: '文件名' })
@ApiResponse({ status: 200, description: '文件内容' })
@ApiResponse({ status: 206, description: '部分内容 (断点续传)' })
@ApiResponse({ status: 404, description: '文件不存在' })
@ApiResponse({ status: 416, description: 'Range 不满足' })
async downloadFile(
@Param('filename') filename: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
// 安全检查:防止路径遍历攻击
const sanitizedFilename = path.basename(filename)
const filePath = path.join(this.uploadDir, sanitizedFilename)
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
throw new NotFoundException('文件不存在')
}
const stat = fs.statSync(filePath)
const fileSize = stat.size
const range = req.headers.range
// 设置通用响应头
const ext = path.extname(filename).toLowerCase()
const mimeTypes: Record<string, string> = {
'.apk': 'application/vnd.android.package-archive',
'.ipa': 'application/octet-stream',
}
const contentType = mimeTypes[ext] || 'application/octet-stream'
// 设置缓存和下载头
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Content-Type', contentType)
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(sanitizedFilename)}"`,
)
res.setHeader('Cache-Control', 'public, max-age=86400') // 缓存1天
res.setHeader('ETag', `"${stat.mtime.getTime()}-${fileSize}"`)
res.setHeader('Last-Modified', stat.mtime.toUTCString())
if (range) {
// 断点续传请求
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
// 验证 Range 有效性
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416)
res.setHeader('Content-Range', `bytes */${fileSize}`)
res.end()
return
}
const chunkSize = end - start + 1
this.logger.log(
`Range request: ${filename}, bytes ${start}-${end}/${fileSize}`,
)
res.status(206)
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`)
res.setHeader('Content-Length', chunkSize)
const stream = fs.createReadStream(filePath, { start, end })
stream.pipe(res)
} else {
// 完整文件请求
this.logger.log(`Full download: ${filename}, size: ${fileSize}`)
res.setHeader('Content-Length', fileSize)
const stream = fs.createReadStream(filePath)
stream.pipe(res)
}
}
}

View File

@ -43,6 +43,7 @@ import { UploadVersionHandler } from '@/application/commands/upload-version/uplo
import { UploadVersionCommand } from '@/application/commands/upload-version/upload-version.command'
import { Platform } from '@/domain/enums/platform.enum'
import { AppVersion } from '@/domain/entities/app-version.entity'
import { PackageParserService } from '@/infrastructure/parsers/package-parser.service'
// Maximum file size: 500MB
const MAX_FILE_SIZE = 500 * 1024 * 1024
@ -59,6 +60,7 @@ export class VersionController {
private readonly deleteVersionHandler: DeleteVersionHandler,
private readonly toggleVersionHandler: ToggleVersionHandler,
private readonly uploadVersionHandler: UploadVersionHandler,
private readonly packageParserService: PackageParserService,
) {}
private toVersionDto(version: AppVersion): VersionDto {
@ -196,6 +198,77 @@ export class VersionController {
return { success: true }
}
@Post('parse')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '解析APK/IPA包信息 (不保存)' })
@ApiBearerAuth()
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file', 'platform'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'APK或IPA安装包文件',
},
platform: {
type: 'string',
enum: ['android', 'ios'],
description: '平台',
},
},
},
})
@ApiResponse({
status: 200,
description: '解析成功',
schema: {
type: 'object',
properties: {
packageName: { type: 'string', description: '包名' },
versionCode: { type: 'number', description: '版本号' },
versionName: { type: 'string', description: '版本名称' },
minSdkVersion: { type: 'string', description: '最低SDK版本' },
targetSdkVersion: { type: 'string', description: '目标SDK版本' },
},
},
})
@ApiResponse({ status: 400, description: '解析失败' })
async parsePackage(
@UploadedFile(
new ParseFilePipe({
validators: [new MaxFileSizeValidator({ maxSize: MAX_FILE_SIZE })],
fileIsRequired: true,
}),
)
file: Express.Multer.File,
@Body('platform') platform: Platform,
): Promise<{
packageName: string
versionCode: number
versionName: string
minSdkVersion?: string
targetSdkVersion?: string
}> {
// Validate file extension
const ext = file.originalname.toLowerCase().split('.').pop()
if (platform === Platform.ANDROID && ext !== 'apk') {
throw new BadRequestException('Android平台只能上传APK文件')
}
if (platform === Platform.IOS && ext !== 'ipa') {
throw new BadRequestException('iOS平台只能上传IPA文件')
}
const parsed = await this.packageParserService.parsePackage(file.buffer, platform)
if (!parsed) {
throw new BadRequestException('无法解析安装包信息,请确认文件格式正确')
}
return parsed
}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '上传APK/IPA并创建版本 (管理员)' })

View File

@ -20,6 +20,7 @@ import { UploadVersionHandler } from './application/commands/upload-version/uplo
import { VersionController } from './api/controllers/version.controller';
import { MobileVersionController } from './api/controllers/mobile-version.controller';
import { HealthController } from './api/controllers/health.controller';
import { DownloadController } from './api/controllers/download.controller';
@Module({
imports: [
@ -33,7 +34,7 @@ import { HealthController } from './api/controllers/health.controller';
serveRoot: '/uploads',
}),
],
controllers: [VersionController, MobileVersionController, HealthController],
controllers: [VersionController, MobileVersionController, HealthController, DownloadController],
providers: [
PrismaService,
AppVersionMapper,

View File

@ -62,7 +62,7 @@ export class FileStorageService {
path: filePath,
size: buffer.length,
sha256,
url: `${this.baseUrl}/uploads/${filename}`,
url: `${this.baseUrl}/downloads/${filename}`,
}
}

View File

@ -19,6 +19,7 @@ typedef DownloadProgressCallback = void Function(int received, int total);
///
/// APK
///
class DownloadManager {
final Dio _dio = Dio();
CancelToken? _cancelToken;
@ -27,7 +28,7 @@ class DownloadManager {
///
DownloadStatus get status => _status;
/// APK
/// APK
/// [url] HTTPS
/// [sha256Expected] SHA-256
/// [onProgress] (, )
@ -50,32 +51,78 @@ class DownloadManager {
// 使
final dir = await getApplicationDocumentsDirectory();
final savePath = '${dir.path}/app_update.apk';
final tempPath = '${dir.path}/app_update.apk.tmp';
final file = File(savePath);
final tempFile = File(tempPath);
//
//
int downloadedBytes = 0;
if (await tempFile.exists()) {
downloadedBytes = await tempFile.length();
debugPrint('Found partial download: $downloadedBytes bytes');
}
//
if (await file.exists()) {
await file.delete();
}
debugPrint('Downloading APK to: $savePath');
debugPrint('Downloading APK to: $savePath (resume from: $downloadedBytes bytes)');
await _dio.download(
// 使
final response = await _dio.get<ResponseBody>(
url,
savePath,
cancelToken: _cancelToken,
onReceiveProgress: (received, total) {
if (total != -1) {
final progress = (received / total * 100).toStringAsFixed(0);
debugPrint('Download progress: $progress%');
onProgress?.call(received, total);
}
},
options: Options(
responseType: ResponseType.stream,
receiveTimeout: const Duration(minutes: 10),
sendTimeout: const Duration(minutes: 10),
headers: downloadedBytes > 0
? {'Range': 'bytes=$downloadedBytes-'}
: null,
),
);
//
int totalBytes = 0;
final contentLength = response.headers.value('content-length');
final contentRange = response.headers.value('content-range');
if (contentRange != null) {
// : "bytes 1000-9999/10000"
final match = RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange);
if (match != null) {
totalBytes = int.parse(match.group(1)!);
}
} else if (contentLength != null) {
totalBytes = int.parse(contentLength) + downloadedBytes;
}
debugPrint('Total file size: $totalBytes bytes');
//
final sink = tempFile.openWrite(mode: FileMode.append);
int receivedBytes = downloadedBytes;
try {
await for (final chunk in response.data!.stream) {
sink.add(chunk);
receivedBytes += chunk.length;
if (totalBytes > 0) {
final progress = (receivedBytes / totalBytes * 100).toStringAsFixed(0);
debugPrint('Download progress: $progress%');
onProgress?.call(receivedBytes, totalBytes);
}
}
await sink.flush();
} finally {
await sink.close();
}
//
await tempFile.rename(savePath);
debugPrint('Download completed');
_status = DownloadStatus.verifying;
@ -136,17 +183,37 @@ class DownloadManager {
}
}
/// APK
/// APK
Future<void> cleanupDownloadedApk() async {
try {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/app_update.apk');
final tempFile = File('${dir.path}/app_update.apk.tmp');
if (await file.exists()) {
await file.delete();
debugPrint('Cleaned up downloaded APK');
}
if (await tempFile.exists()) {
await tempFile.delete();
debugPrint('Cleaned up temporary APK file');
}
} catch (e) {
debugPrint('Cleanup failed: $e');
}
}
///
Future<void> clearPartialDownload() async {
try {
final dir = await getApplicationDocumentsDirectory();
final tempFile = File('${dir.path}/app_update.apk.tmp');
if (await tempFile.exists()) {
await tempFile.delete();
debugPrint('Cleared partial download');
}
} catch (e) {
debugPrint('Clear partial download failed: $e');
}
}
}

View File

@ -0,0 +1,116 @@
# =============================================================================
# Flutter APK 构建脚本(自动增加构建号)- Windows PowerShell 版本
# =============================================================================
# 用法:
# .\scripts\build.ps1 # 构建 release APK自动增加构建号
# .\scripts\build.ps1 -NoBump # 构建但不增加构建号
# .\scripts\build.ps1 -SetBuild 100 # 设置指定构建号
# =============================================================================
param(
[switch]$NoBump,
[int]$SetBuild = 0
)
$ErrorActionPreference = "Stop"
# 获取脚本目录
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectDir = Split-Path -Parent $ScriptDir
$PubspecFile = Join-Path $ProjectDir "pubspec.yaml"
function Write-Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Blue }
function Write-Success { param($msg) Write-Host "[OK] $msg" -ForegroundColor Green }
function Write-Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow }
# 读取当前版本
function Get-CurrentVersion {
$content = Get-Content $PubspecFile -Raw
if ($content -match "version:\s*(.+)") {
return $Matches[1].Trim()
}
return "1.0.0+1"
}
# 解析版本号
function Parse-Version {
param($version)
$parts = $version -split '\+'
return @{
Name = $parts[0]
Build = [int]$parts[1]
}
}
# 更新版本号
function Update-Version {
param($newVersion)
$content = Get-Content $PubspecFile -Raw
$content = $content -replace "version:\s*.+", "version: $newVersion"
Set-Content $PubspecFile $content -NoNewline
}
# 主函数
function Main {
Set-Location $ProjectDir
# 获取当前版本
$currentVersion = Get-CurrentVersion
$version = Parse-Version $currentVersion
Write-Info "当前版本: $currentVersion"
Write-Info "版本名: $($version.Name), 构建号: $($version.Build)"
# 更新构建号
$buildNumber = $version.Build
if ($SetBuild -gt 0) {
$buildNumber = $SetBuild
Write-Info "设置构建号为: $buildNumber"
} elseif (-not $NoBump) {
$buildNumber = $buildNumber + 1
Write-Info "自动增加构建号为: $buildNumber"
}
$newVersion = "$($version.Name)+$buildNumber"
if ($currentVersion -ne $newVersion) {
Update-Version $newVersion
Write-Success "版本更新为: $newVersion"
}
# 清理并获取依赖
Write-Info "获取依赖..."
flutter pub get
# 构建 APK
Write-Info "构建 Release APK..."
flutter build apk --release
# 输出结果
$apkPath = Join-Path $ProjectDir "build\app\outputs\flutter-apk\app-release.apk"
if (Test-Path $apkPath) {
$apkSize = (Get-Item $apkPath).Length / 1MB
Write-Success "构建完成!"
Write-Host ""
Write-Host "APK 信息:"
Write-Host " 版本: $newVersion"
Write-Host " 路径: $apkPath"
Write-Host " 大小: $([math]::Round($apkSize, 2)) MB"
Write-Host ""
# 复制到 publish 目录
$publishDir = Join-Path $ProjectDir "publish"
if (-not (Test-Path $publishDir)) {
New-Item -ItemType Directory -Path $publishDir | Out-Null
}
$publishPath = Join-Path $publishDir "rwa-durian-$($version.Name)-$buildNumber.apk"
Copy-Item $apkPath $publishPath
Write-Success "已复制到: $publishPath"
} else {
Write-Warn "APK 文件未找到"
exit 1
}
}
Main

View File

@ -0,0 +1,130 @@
#!/bin/bash
# =============================================================================
# Flutter APK 构建脚本(自动增加构建号)
# =============================================================================
# 用法:
# ./scripts/build.sh # 构建 release APK自动增加构建号
# ./scripts/build.sh --no-bump # 构建但不增加构建号
# ./scripts/build.sh --set 100 # 设置指定构建号
# =============================================================================
set -e
# 颜色
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
# 获取脚本目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PUBSPEC_FILE="$PROJECT_DIR/pubspec.yaml"
# 读取当前版本
get_current_version() {
grep "^version:" "$PUBSPEC_FILE" | sed 's/version: //'
}
# 解析版本号
parse_version() {
local version="$1"
VERSION_NAME=$(echo "$version" | cut -d'+' -f1)
BUILD_NUMBER=$(echo "$version" | cut -d'+' -f2)
}
# 更新版本号
update_version() {
local new_version="$1"
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s/^version:.*/version: $new_version/" "$PUBSPEC_FILE"
else
# Linux
sed -i "s/^version:.*/version: $new_version/" "$PUBSPEC_FILE"
fi
}
# 主函数
main() {
cd "$PROJECT_DIR"
# 获取当前版本
CURRENT_VERSION=$(get_current_version)
parse_version "$CURRENT_VERSION"
log_info "当前版本: $CURRENT_VERSION"
log_info "版本名: $VERSION_NAME, 构建号: $BUILD_NUMBER"
# 处理参数
BUMP_VERSION=true
NEW_BUILD_NUMBER=""
while [[ $# -gt 0 ]]; do
case $1 in
--no-bump)
BUMP_VERSION=false
shift
;;
--set)
NEW_BUILD_NUMBER="$2"
shift 2
;;
*)
shift
;;
esac
done
# 更新构建号
if [ -n "$NEW_BUILD_NUMBER" ]; then
BUILD_NUMBER="$NEW_BUILD_NUMBER"
log_info "设置构建号为: $BUILD_NUMBER"
elif [ "$BUMP_VERSION" = true ]; then
BUILD_NUMBER=$((BUILD_NUMBER + 1))
log_info "自动增加构建号为: $BUILD_NUMBER"
fi
NEW_VERSION="${VERSION_NAME}+${BUILD_NUMBER}"
if [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then
update_version "$NEW_VERSION"
log_success "版本更新为: $NEW_VERSION"
fi
# 清理并获取依赖
log_info "获取依赖..."
flutter pub get
# 构建 APK
log_info "构建 Release APK..."
flutter build apk --release
# 输出结果
APK_PATH="$PROJECT_DIR/build/app/outputs/flutter-apk/app-release.apk"
if [ -f "$APK_PATH" ]; then
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
log_success "构建完成!"
echo ""
echo "APK 信息:"
echo " 版本: $NEW_VERSION"
echo " 路径: $APK_PATH"
echo " 大小: $APK_SIZE"
echo ""
# 复制到 publish 目录
PUBLISH_DIR="$PROJECT_DIR/publish"
mkdir -p "$PUBLISH_DIR"
cp "$APK_PATH" "$PUBLISH_DIR/rwa-durian-${VERSION_NAME}-${BUILD_NUMBER}.apk"
log_success "已复制到: $PUBLISH_DIR/rwa-durian-${VERSION_NAME}-${BUILD_NUMBER}.apk"
else
log_warn "APK 文件未找到"
exit 1
fi
}
main "$@"

View File

@ -58,3 +58,11 @@ export interface VersionListFilter {
platform?: Platform
includeDisabled?: boolean
}
export interface ParsedPackageInfo {
packageName: string
versionCode: number
versionName: string
minSdkVersion?: string
targetSdkVersion?: string
}

View File

@ -4,6 +4,8 @@ import {
UpdateVersionInput,
UploadVersionInput,
VersionListFilter,
ParsedPackageInfo,
Platform,
} from '../entities/version'
export interface IVersionRepository {
@ -14,4 +16,5 @@ export interface IVersionRepository {
delete(id: string): Promise<void>
toggle(id: string, isEnabled: boolean): Promise<void>
upload(input: UploadVersionInput): Promise<AppVersion>
parsePackage(file: File, platform: Platform): Promise<ParsedPackageInfo>
}

View File

@ -6,6 +6,8 @@ import {
UploadVersionInput,
VersionListFilter,
IVersionRepository,
ParsedPackageInfo,
Platform,
} from '@/domain'
import { apiClient } from '../http/api-client'
@ -87,6 +89,24 @@ export class VersionRepositoryImpl implements IVersionRepository {
)
return response.data
}
async parsePackage(file: File, platform: Platform): Promise<ParsedPackageInfo> {
const formData = new FormData()
formData.append('file', file)
formData.append('platform', platform)
const response = await this.client.post<ParsedPackageInfo>(
'/api/v1/versions/parse',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 120000, // 2 minutes for parsing
}
)
return response.data
}
}
export const versionRepository = new VersionRepositoryImpl()

View File

@ -3,6 +3,7 @@
import { useState, useRef } from 'react'
import { Platform } from '@/domain'
import { useVersionActions } from '@/application'
import { versionRepository } from '@/infrastructure/repositories/version-repository-impl'
interface UploadModalProps {
onClose: () => void
@ -14,6 +15,7 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isParsing, setIsParsing] = useState(false)
const [formData, setFormData] = useState({
platform: 'android' as Platform,
versionName: '',
@ -25,16 +27,38 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
// Auto-detect platform from file extension
if (selectedFile.name.endsWith('.apk')) {
setFormData((prev) => ({ ...prev, platform: 'android' }))
} else if (selectedFile.name.endsWith('.ipa')) {
setFormData((prev) => ({ ...prev, platform: 'ios' }))
}
if (!selectedFile) return
setFile(selectedFile)
setError(null)
// Auto-detect platform from file extension
let detectedPlatform: Platform = 'android'
if (selectedFile.name.endsWith('.apk')) {
detectedPlatform = 'android'
} else if (selectedFile.name.endsWith('.ipa')) {
detectedPlatform = 'ios'
}
setFormData((prev) => ({ ...prev, platform: detectedPlatform }))
// Auto-parse package info
setIsParsing(true)
try {
const parsed = await versionRepository.parsePackage(selectedFile, detectedPlatform)
setFormData((prev) => ({
...prev,
platform: detectedPlatform,
versionName: parsed.versionName || prev.versionName,
buildNumber: parsed.versionCode?.toString() || prev.buildNumber,
minOsVersion: parsed.minSdkVersion || prev.minOsVersion,
}))
} catch (err) {
console.error('Failed to parse package:', err)
// Don't show error, just let user fill in manually
} finally {
setIsParsing(false)
}
}
@ -120,6 +144,9 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
<p className="text-gray-500 text-sm">
{(file.size / (1024 * 1024)).toFixed(2)} MB
</p>
{isParsing && (
<p className="text-blue-500 text-sm mt-1">...</p>
)}
</div>
) : (
<div>
@ -238,9 +265,9 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
<button
type="submit"
className="btn btn-primary"
disabled={isSubmitting}
disabled={isSubmitting || isParsing}
>
{isSubmitting ? '上传中...' : '上传'}
{isParsing ? '解析中...' : isSubmitting ? '上传中...' : '上传'}
</button>
</div>
</form>