生成ca证书
bash
#!/bin/bash
# 配置(只改这3行:服务器IP/域名、证书目录)
SERVER_IP="192.168.31.218" # 你的Flask服务器IP(鸿蒙客户端连接用)
# CERT_DIR=$(pwd)"/certs" # 证书保存目录
CCC=$(pwd)
echo "--CERT_DIR=-"${CCC}
CERT_DIR=$(pwd)"/certs" # 证书保存目录
KEY_SIZE=2048 # 密钥长度(不用改)
# 创建目录
echo "CERT_DIR=-"${CERT_DIR}
if [ -d "${CERT_DIR}" ]
then
rm -rf "${CERT_DIR}"
fi
mkdir -p $CERT_DIR && cd $CERT_DIR
# 1. 生成CA根证书(ca.key + ca.crt)
openssl genrsa -out ca.key $KEY_SIZE
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=MyCA"
# 2. 生成服务端证书(server.key + server.crt)
openssl genrsa -out server.key $KEY_SIZE
openssl req -new -key server.key -out server.csr -subj "/CN=$SERVER_IP" -addext "subjectAltName=IP:$SERVER_IP"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -sha256 -days 1825
# 清理中间文件
#rm -f server.csr ca.srl
echo "证书生成完成!路径:$(pwd)"
echo "文件:ca.crt(鸿蒙信任)、server.key(Flask私钥)、server.crt(Flask证书)"
服务器代码
python
from flask import Flask, request, jsonify, Response, send_from_directory
import json
import os
from datetime import datetime
import mimetypes
app = Flask(__name__)
# 配置视频文件存储目录(可自定义绝对路径,如 "D:/videos")
VIDEO_FOLDER = os.path.join(os.path.dirname(__file__), "videos")
# 如果目录不存在,自动创建
if not os.path.exists(VIDEO_FOLDER):
os.makedirs(VIDEO_FOLDER)
def getTime():
# 1. 获取当前时间(包含日期和时间)的字符串,格式:年-月-日 时:分:秒
current = datetime.now()
timeStr = current.strftime("%Y-%m-%d %H:%M:%S")
return timeStr
@app.route('/download/video/<filename>', methods=['GET'])
def download_video_basic(filename):
"""
基础视频下载接口
:param filename: 视频文件名(含后缀,如 test.mp4、demo.mov)
:return: 视频文件下载响应
"""
try:
print('download_video_basic {0}, filename={1}'.format(getTime(), filename))
params = request.args.to_dict() # 转为字典,方便处理
print('download_video_basic {0}, params={1}'.format(getTime(), params))
# send_from_directory:从指定目录返回文件下载
# as_attachment=True:强制浏览器下载文件(而非在线预览)
return send_from_directory(
directory=VIDEO_FOLDER,
path=filename,
as_attachment=True,
# 可选:指定MIME类型,优化浏览器识别
mimetype='video/mp4' # 支持 mp4/mov/webm 等,可根据实际后缀调整
)
except FileNotFoundError:
return f"错误:视频文件 {filename} 不存在", 404
except Exception as e:
return f"下载失败:{str(e)}", 500
def generate_video_stream(file_path, chunk_size=1024*1024):
"""
视频文件流式读取生成器
:param file_path: 视频文件绝对路径
:param chunk_size: 每次读取的块大小(默认1MB,可调整)
:return: 逐块返回文件数据
"""
with open(file_path, 'rb') as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield chunk
@app.route('/download/large_video/<filename>', methods=['GET'])
def download_large_video(filename):
"""
大视频文件流式下载接口(支持断点续传)
:param filename: 视频文件名(含后缀)
:return: 流式响应
"""
# 拼接视频文件绝对路径
file_path = os.path.join(VIDEO_FOLDER, filename)
# 校验文件是否存在
if not os.path.exists(file_path):
return f"错误:视频文件 {filename} 不存在", 404
# 获取文件大小
file_size = os.path.getsize(file_path)
# 自动识别视频MIME类型
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type:
mime_type = 'video/mp4' # 默认MIME类型
# 处理断点续传(Range 请求头)
range_header = request.headers.get('Range')
if range_header:
# 解析 Range:如 bytes=0-1023
start = int(range_header.split('=')[1].split('-')[0])
end = file_size - 1 # 默认读取到文件末尾
# 构造响应头:支持断点续传
response_headers = {
'Content-Range': f'bytes {start}-{end}/{file_size}',
'Accept-Ranges': 'bytes',
'Content-Length': str(end - start + 1),
'Content-Type': mime_type,
'Content-Disposition': f'attachment; filename="{filename}"' # 强制下载
}
# 生成部分文件流
def partial_stream():
with open(file_path, 'rb') as f:
f.seek(start) # 移动到断点起始位置
while True:
chunk = f.read(1024*1024)
if not chunk:
break
yield chunk
# 返回 206 Partial Content(断点续传响应状态码)
return Response(partial_stream(), status=206, headers=response_headers)
else:
# 普通流式下载(无断点续传)
response_headers = {
'Content-Length': str(file_size),
'Content-Type': mime_type,
'Content-Disposition': f'attachment; filename="{filename}"'
}
return Response(
generate_video_stream(file_path),
status=200,
headers=response_headers
)
# 1. GET 接口(用于查询/获取数据)
@app.route('/get', methods=['GET'])
def receive_get():
# 获取 GET 参数(支持 URL 问号后传参,如 /get?name=test&age=20)
params = request.args.to_dict() # 转为字典,方便处理
print('/get {0}, params={1}'.format(getTime(), params))
return jsonify({
"success": True,
"method": "GET",
"received_params": params,
"tip": "GET 参数示例:/get?key1=value1&key2=value2"
}), 200
# 2. POST 接口(用于提交/发送数据)
@app.route('/post', methods=['POST'])
def receive_post():
# 仅支持 JSON 格式(简化逻辑,常用场景)
if request.is_json:
data = request.get_json()
print('receive_post data={0}'.format(json.dump(data)))
return jsonify({
"success": True,
"method": "POST",
"received_data": data
}), 200
# 非法格式返回错误
return jsonify({
"success": False,
"method": "POST",
"error": "仅支持 JSON 格式,请设置 Content-Type: application/json"
}), 400
if __name__ == '__main__':
# 启动 HTTPS 服务器(指定证书和私钥)
CA_ROOT='../ca/certs/'
CST = CA_ROOT + "server.crt"
KEY = CA_ROOT + "server.key"
print('main CST={0}, KEY={1}, {2}, {3}'.format(CST, KEY, os.path.exists(CST), os.path.exists(KEY)))
app.run(host='0.0.0.0', # 允许局域网访问
port=5000, # HTTPS 端口(测试用 5000,生产可改 443)
ssl_context=(CST, KEY), # 证书+私钥路径
debug=True # 生产环境关闭 debug
)
客户端代码
typescript
import { rcp } from "@kit.RemoteCommunicationKit";
import { LoggerUtil } from "../utils/LoggerUtil";
import { url } from "@kit.ArkTS";
const BASE_URL: string = "https://192.168.31.218:5000";
export interface IReqestParams {
path: string;
method?: 'GET' | "POST" | "PUT" | "DELETE";
data?: rcp.RequestContent;
contentType?: rcp.ContentType;
}
const TAG: string = "HttpClient: ";
type DataType = string | number | undefined | null;
export class HttpClient {
combine(baseURL: string, path: string, params: Record<string, DataType>): string {
let prefix: string = baseURL + path;
if (params) {
let buf = new url.URLParams();
Object.keys(params).forEach((key: string) => {
let value = params[key];
if (typeof value === "string") {
buf.append(key, value);
} else if (value === undefined) {
buf.append(key, 'undefined');
} else if (value === null) {
buf.append(key, 'null');
} else if (typeof value === "number") {
buf.append(key, String(value));
} else if (typeof value === 'object'){
buf.append(key, JSON.stringify(value));
}
else {
buf.append(key, String(value));
}
});
let content: string = buf.toString();
if (content && content.length > 0) {
return prefix + "?" + content;
}
}
return prefix;
}
request<R>(params: IReqestParams): Promise<R> {
let hint: string = "request: "
return new Promise((resolve, reject) => {
let url: string = BASE_URL + params.path;
if (params.method === "GET") {
url = this.combine(BASE_URL, params.path, params.data as Record<string, DataType>);
}
let request: rcp.Request = new rcp.Request(url, params.method)
request.headers = {
accept: 'application/json',
"content-type": params.contentType ?? 'application/json'
};
if (params.method !== 'GET') {
request.content = params.data;
}
request.configuration = {
security: { remoteValidation: 'skip' }
};
let session: rcp.Session = rcp.createSession();
session.fetch(request)
.then((response: rcp.Response) => {
LoggerUtil.info(TAG, hint, "then response = ", response?.toJSON());
session.close();
resolve(response as R);
})
.catch((error: BusinessError) => {
LoggerUtil.error(TAG, hint, "error code = ", error?.code, ", message = ", error?.message);
session.close();
reject(error);
})
});
}
upload<R>(path: string, data: rcp.MultipartForm): Promise<R> {
return this.request({
path: path,
method: "POST",
data: data,
contentType: 'multipart/form-data'
})
}
}
测试文件
typescript
import { IBestUploaderFile } from '@ibestservices/ibest-ui-v2';
import { HttpClient } from '../common/HttpClient';
import { rcp } from '@kit.RemoteCommunicationKit';
import { LoggerUtil } from '../utils/LoggerUtil';
import { FileUtil } from '@pura/harmony-utils';
import fs from '@ohos.file.fs';
import { picker } from '@kit.CoreFileKit';
const TAG: string = "LoggerUtil: Index"
@Entry
@ComponentV2
struct Index {
@Local uploadFiles: IBestUploaderFile[] = [];
httpClient: HttpClient = new HttpClient();
aboutToAppear(): void {
}
async saveLocalPhotoAlbum(url: string, fileName: string) {
let saveUris: Array<string> = [];
try {
const photoSaveOptions = new picker.PhotoSaveOptions(); // 创建文件管理器保存选项实例
photoSaveOptions.newFileNames = [fileName];
const photoViewPicker = new picker.PhotoViewPicker();
try {
let photoSaveResult = await photoViewPicker.save(photoSaveOptions);
if (photoSaveResult !== undefined) {
saveUris = photoSaveResult;
console.info('photoViewPicker.save to file succeed and uris are:' + photoSaveResult);
}
} catch (error) {
let err: BusinessError = error as BusinessError;
LoggerUtil.error(TAG,
`[picker] Invoke photoViewPicker.save failed, code is ${err.code}, message is ${err.message}`);
}
} catch (error) {
let err: BusinessError = error as BusinessError;
LoggerUtil.error(TAG, "[picker] photoViewPickerSave error = " + JSON.stringify(err));
}
try {
let photoSelect = fs.openSync(url, fs.OpenMode.READ_ONLY);
let photoSave = fs.openSync(saveUris[0], fs.OpenMode.WRITE_ONLY);
fs.copyFileSync(photoSelect.fd, photoSave.fd);
fs.close(photoSelect);
fs.close(photoSave);
} catch (error) {
let err: BusinessError = error as BusinessError;
LoggerUtil.info(TAG, "[picker] Photo Save error = " + JSON.stringify(err));
}
}
build() {
Column({ space: 16 }) {
Button("get")
.onClick(() => {
this.httpClient.request<rcp.Response>({
path: "/get",
method: "GET",
data: { "k1": "v1", "k2": "v2", "k3": "v3" },
contentType: 'application/x-www-form-urlencoded'
})
.then((response: rcp.Response) => {
LoggerUtil.info(TAG, "GET response = ", response?.toJSON());
})
.catch((error: BusinessError) => {
LoggerUtil.error(TAG, "error ", error?.message);
})
});
Button("post upload")
.onClick(() => {
this.httpClient.request<rcp.Response>({
path: "/post",
method: "POST",
data: "key1=v1&k2=v2",
contentType: 'multipart/form-data'
})
.then((response: rcp.Response) => {
LoggerUtil.info(TAG, "GET response = ", response?.toJSON());
})
.catch((error: BusinessError) => {
LoggerUtil.error(TAG, "error ", error?.message);
})
});
Button("download small video")
.onClick(() => {
// /download/video/22mluan.mp4
let fileName: string = "22mluan.mp4"
this.httpClient.request<rcp.Response>({
path: "/download/video/" + fileName,
method: "GET",
// data: {filename: "22mluan.mp4"},
contentType: 'multipart/form-data'
})
.then(async (response: rcp.Response) => {
let len = response.body?.byteLength
LoggerUtil.info(TAG, "GET response = ", JSON.stringify(response?.toJSON()));
LoggerUtil.info(TAG, "GET len = ", len);
let root: string = this.getUIContext().getHostContext()?.filesDir + "/videos/";
LoggerUtil.info(TAG, " BEFORE isExist = ", FileUtil.accessSync(root));
if (FileUtil.accessSync(root)) {
FileUtil.rmdirSync(root);
}
FileUtil.mkdirSync(root, true);
LoggerUtil.info(TAG, " after isExist = ", FileUtil.accessSync(root));
let path: string = root + fileName;
FileUtil.writeEasy(path, response.body);
LoggerUtil.info(TAG, " after isFileExist = ", FileUtil.isFile(path));
await this.saveLocalPhotoAlbum(path, fileName);
LoggerUtil.info(TAG, "OK after isFileExist = ", FileUtil.isFile(path));
})
.catch((error: BusinessError) => {
LoggerUtil.error(TAG, "error ", error?.message);
})
});
}.width('100%')
.height('100%')
}
}