feat: face image

This commit is contained in:
xtaodada 2024-11-08 19:57:23 +08:00
parent 22014a7b0e
commit 65b3a7074b
Signed by: xtaodada
GPG Key ID: 4CBB3F4FA8C85659
17 changed files with 726 additions and 57 deletions

View File

@ -36,6 +36,7 @@ def import_models():
"""导入我们所有的 models使 alembic 可以自动对比 db scheme 创建 migration revision"""
for pkg in scan_models():
try:
print(f"导入 {pkg}")
import_module(pkg) # 导入 models
except Exception as e: # pylint: disable=W0703
print(

View File

@ -0,0 +1,63 @@
"""face_image
Revision ID: a41d2f3443c8
Revises: 1d50ca81be81
Create Date: 2024-11-08 16:20:23.106957
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision = "a41d2f3443c8"
down_revision = "1d50ca81be81"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"face_image",
sa.Column("delete_time", sa.DateTime(), nullable=True),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column("create_time", sa.DateTime(), nullable=False),
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"image",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=False,
),
sa.Column("is_approved", sa.Boolean(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_face_image_create_time"),
"face_image",
["create_time"],
unique=False,
)
op.create_index(
op.f("ix_face_image_update_time"),
"face_image",
["update_time"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_face_image_update_time"), table_name="face_image")
op.drop_index(op.f("ix_face_image_create_time"), table_name="face_image")
op.drop_table("face_image")
# ### end Alembic commands ###

View File

@ -16,6 +16,13 @@
<span v-else>{{not}}</span>
</template>
</el-table-column>
<!-- 自定义普通td -->
<el-table-column v-else-if="type == 'custom-text'" :label="name" :width="width" :min-width="minWidth">
<template slot-scope="s">
<span v-if="s.row[prop]">{{jv[1]}}</span>
<span v-else>{{jv[2]}}</span>
</template>
</el-table-column>
<!-- num 数字 -->
<el-table-column v-else-if="type == 'num'" :label="name" :width="width" :min-width="minWidth" class-name="tc-num">
<template slot-scope="s">

View File

@ -28,6 +28,16 @@ var menuList = [
},
{
id: '2',
name: '人脸信息',
icon: 'el-icon-search',
info: '人脸信息',
childList: [
{id: '2-1-1', name: '人脸图片列表', icon: 'el-icon-collection-tag', url: 'sa-view/face/swiper-list.html'},
{id: '2-2-1', name: '人脸图片添加', icon: 'el-icon-plus', url: 'sa-view/face/swiper-add.html'},
]
},
{
id: '10',
name: '各种示例',
icon: 'el-icon-document-remove',
info: '增删改查各种常用组件示例',
@ -39,7 +49,7 @@ var menuList = [
]
},
{
id: '3',
id: '4',
name: '首页设置',
icon: 'el-icon-search',
info: '首页的一些设置',
@ -134,58 +144,4 @@ var menuList = [
isBlank: true,
isShow: false// 隐藏
},
// ========= jq22搜集 ================
{
id: '111',
name: 'jq22搜集',
icon: 'el-icon-link',
info: '示例:外部链接',
childList: [
{
id: '110',
name: '大屏展示',
icon: 'el-icon-link',
info: '大屏展示页',
childList: [
{id: '110-1', name: '大屏1', url: 'http://www.jq22.com/demo/estszjcmoban202008030007/'}, // 原作者http://www.jq22.com/jquery-info23260
{id: '110-2', name: '大屏2', url: 'http://www.jq22.com/demo/estjkdsj202007301414/'}, // 原作者http://www.jq22.com/jquery-info23247
{id: '110-3', name: '大屏3', url: 'http://www.jq22.com/demo/jquerygndsjmoban202007212350/'}, // 原作者http://www.jq22.com/jquery-info23239
{id: '110-4', name: '大屏4', url: 'http://www.jq22.com/demo/jqueryEchartsny202006151033/'}, // 原作者http://www.jq22.com/jquery-info23114
{id: '110-5', name: '大屏5', url: 'http://www.jq22.com/demo/echartsdindanmoban202007302202/'}, // 原作者http://www.jq22.com/jquery-info23202
{id: '110-6', name: '大屏6', url: 'http://www.jq22.com/demo/echartssjmoban202005210009/'}, // 原作者http://www.jq22.com/jquery-info23047
{id: '110-7', name: '大屏7', url: 'http://www.jq22.com/demo/echartsdsj202002251026/'}, // 原作者http://www.jq22.com/jquery-info22826
{id: '110-8', name: '大屏8', url: 'http://www.jq22.com/demo/echartswldsj201912112223/'}, // 原作者http://www.jq22.com/jquery-info22636
],
},
{id: '111-1', name: '图片切换', url: 'http://www.jq22.com/demo/jQueryTpqh201804012309/'}, // 原作者https://www.jq22.com/jquery-info18534
{id: '111-2', name: '3D旋转特效', url: 'http://www.jq22.com/demo/jQueryCss3D201710241004/'}, // 原作者https://www.jq22.com/jquery-info16495
{id: '111-3', name: 'canvas炫酷星空', url: 'http://www.jq22.com/demo/warpDrive201712211120/index.html'}, // 原作者https://www.jq22.com/jquery-info17456
{id: '111-4', name: 'H5碰撞小球', url: 'http://www.jq22.com/demo/html5Pzxq201712242209/'}, // 原作者https://www.jq22.com/jquery-info17482
{id: '111-5', name: '网页画板', url: 'http://www.jq22.com/demo/Mapping201802252341/'}, // 原作者https://www.jq22.com/jquery-info18172
{id: '111-6', name: '简约富文本编辑器', url: 'http://www.jq22.com/demo/jquery-notebook-master/'}, // 原作者https://www.jq22.com/jquery-info345
{id: '111-7', name: '水滴特效', url: 'http://www.jq22.com/demo/jquery-shuidi20151123/'}, // 原作者https://www.jq22.com/jquery-info4835
{id: '111-8', name: '图片放大', url: 'http://www.jq22.com/demo/jQueryJpg201708110048/'}, // 原作者http://www.jq22.com/jquery-info15264
{id: '111-9', name: '3D云', url: 'http://www.jq22.com/demo/jquery-cloud-141217202931/'}, // 原作者http://www.jq22.com/jquery-info1325
{id: '111-10', name: '3D选择图片', url: 'http://www.jq22.com/demo/jquery-3d20150831/'}, // 原作者http://www.jq22.com/jquery-info4000
{id: '111-11', name: '蜘蛛纸牌', url: 'http://www.jq22.com/demo/jqueryspider201809140137/'}, // 原作者http://www.jq22.com/jquery-info20047
{id: '111-12', name: '大转盘', url: 'http://www.jq22.com/demo/jquerylocal201912122316/'}, // 原作者http://www.jq22.com/jquery-info22646
{id: '111-13', name: '旋转地球', url: 'http://www.jq22.com/demo/earth201810300101/'}, // 原作者http://www.jq22.com/jquery-info20328
{id: '111-14', name: '下雨动画', url: 'http://www.jq22.com/demo/html5-canvas-rain201710252014/'}, // 原作者http://www.jq22.com/jquery-info16518
{id: '111-15', name: '绚丽星空', url: 'http://www.jq22.com/demo/jQuery3dxk201710142249/'}, // 原作者http://www.jq22.com/jquery-info16294
{id: '111-16', name: '3d波浪墙', url: 'http://www.jq22.com/demo/voxels-liquid201704112355/'}, // 原作者http://www.jq22.com/jquery-info13400
{id: '111-17', name: '元素周期表', url: 'http://www.jq22.com/demo/jquery-3D20151113/'}, // 原作者http://www.jq22.com/jquery-info4710
{id: '111-18', name: '旋转相册', url: 'http://www.jq22.com/demo/tikm202006072243/'}, // 原作者http://www.jq22.com/jquery-info23116
{id: '111-19', name: '装逼专用', url: 'http://www.jq22.com/demo/canvaslxy202003192234/'}, // 原作者http://www.jq22.com/jquery-info22793
{id: '111-20', name: '3D粒子文字', url: 'http://www.jq22.com/demo/3dwz201912102124/'}, // 原作者http://www.jq22.com/jquery-info22631
{id: '111-21', name: '多面立方体', url: 'http://www.jq22.com/demo/threelft201905080117/'}, // 原作者http://www.jq22.com/demo/threelft201905080117/
{id: '111-22', name: '常见配色', url: 'http://www.jq22.com/demo/jQueryColour202008050020/'}, // 原作者http://www.jq22.com/jquery-info23262
{id: '111-23', name: '音量调节', url: 'http://www.jq22.com/demo/AdjustVolume202005122241/'}, // 原作者http://www.jq22.com/jquery-info23045
{id: '111-24', name: '重力下落', url: 'http://www.jq22.com/demo/gamecaisse202005220040/'}, // 原作者http://www.jq22.com/jquery-info23074
{id: '111-25', name: '表情匹配', url: 'http://www.jq22.com/demo/emojimatchgame201907170050/dist/'}, // 原作者http://www.jq22.com/jquery-info21952
]
},
]

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html>
<head>
<title>人脸图片 - 添加/修改</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
<!-- 所有的 css js 资源 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.13.0/lib/theme-chalk/index.css">
<link rel="stylesheet" href="../../static/sa.css">
<script src="https://unpkg.com/vue@2.6.10/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui@2.13.0/lib/index.js"></script>
<script src="https://unpkg.com/http-vue-loader@1.4.2/src/httpVueLoader.js"></script>
<script src="https://unpkg.com/jquery@3.4.1/dist/jquery.js"></script>
<script src="https://www.layuicdn.com/layer-v3.1.1/layer.js"></script>
<script src="../../static/sa.js"></script>
<script src="../../static/kj/upload-util.js"></script>
<style type="text/css">
.td-img{width: 200px; height: 200px; cursor: pointer; vertical-align: middle;}
.c-panel .el-form .c-label{width: 5em !important;}
.c-panel .el-form .el-input{width: 400px;}
</style>
</head>
<body>
<div class="vue-box" :class="{sbot: id}" style="display: none;" :style="'display: block;'">
<!-- ------- 内容部分 ------- -->
<div class="s-body">
<div class="c-panel">
<div class="c-title">人脸图片添加</div>
<el-form>
<sa-item name="图片" br>
<img :src="imageUrl" class="td-img" @click="sa.showImage(imageUrl)" >
<el-link type="primary" @click="sa.uploadInput(beforeAvatarUpload)">上传</el-link>
</sa-item>
<sa-item name="" class="s-ok" br>
<el-button type="primary" icon="el-icon-plus" @click="ok()">保存</el-button>
</sa-item>
</el-form>
</div>
</div>
<!-- ------- 底部按钮 ------- -->
<div class="s-foot">
<el-button type="primary" @click="ok()">确定</el-button>
<el-button @click="sa.closeCurrIframe()">取消</el-button>
</div>
</div>
<script>
var app = new Vue({
components: {
"sa-item": httpVueLoader('../../sa-frame/com/sa-item.vue'),
"sa-info": httpVueLoader('../../sa-frame/com/sa-info.vue'),
},
el: '.vue-box',
data: {
id: sa.p('id', 0), // 获取超链接中的id参数0=添加非0=修改)
imagePath: '',
imageUrl: '',
},
methods: {
handleAvatarSuccess(res, file) {
this.imagePath = res.data
this.imageUrl = this.getFaceUrl(res.data);
},
beforeAvatarUpload(file) {
const isJPG = file.type === 'image/jpeg';
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isJPG) {
sa.alert('上传头像图片只能是 JPG 格式!');
return false;
}
if (!isLt2M) {
sa.alert('上传头像图片大小不能超过 2MB!');
return false;
}
return this.handleAvatarUpload(file);
},
getFaceUrl(path) {
return sa.cfg.api_url + path;
},
handleAvatarUpload(file) {
var formData = new FormData();
formData.append('file', file);
sa.ajax("/face/image/upload", formData, function (res) {
this.handleAvatarSuccess(res, file);
}.bind(this), {processData: false, contentType: false})
},
// 提交数据
ok: function(){
// 验证
if (this.imagePath === '' || this.imageUrl === '') {
return sa.alert('请上传图片');
}
// 开始增加或修改
if(this.id <= 0) { // 添加
sa.ajax('/face/image/create', {image: this.imagePath}, function(res){
sa.alert('增加成功', function() {
if(parent.app) {
parent.app.dataList.push(res.data);
parent.app.dataList[parent.app.dataList.length - 1].nid = parent.app.dataList.length;
parent.sa.f5TableHeight(); // 刷新表格高度
sa.closeCurrIframe(); // 关闭本页
} else {
this.clean()
}
}.bind(this));
}.bind(this), {});
} else { // 修改
sa.ajax('/face/image/update', {fid: this.id, image: this.imagePath}, function(res){
sa.alert('修改成功', this.clean);
}.bind(this), {});
}
},
// 添加/修改 完成后的动作
clean: function() {
if(this.id == 0) {
this.imagePath = '';
this.imageUrl = '';
} else {
parent.app.f5(); // 刷新父页面列表
sa.closeCurrIframe(); // 关闭本页
}
}
},
mounted: function(){
// 初始化数据
if(this.id <= 0) {
} else {
sa.ajax('/face/image/get_by_id', {fid: this.id}, function(res) {
this.imageUrl = this.getFaceUrl(res.data.image);
}.bind(this), {});
}
}
})
</script>
</body>
</html>

View File

@ -0,0 +1,127 @@
<!DOCTYPE html>
<html>
<head>
<title>人脸图片列表</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
<!-- 所有的 css & js 资源 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.13.0/lib/theme-chalk/index.css">
<link rel="stylesheet" href="../../static/sa.css">
<script src="https://unpkg.com/vue@2.6.10/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui@2.13.0/lib/index.js"></script>
<script src="https://unpkg.com/http-vue-loader@1.4.2/src/httpVueLoader.js"></script>
<script src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script>
<script src="https://www.layuicdn.com/layer-v3.1.1/layer.js"></script>
<script src="../../static/sa.js"></script>
<style>
.td-img{width: 90px; height: 90px; cursor: pointer; vertical-align: middle;}
.c-panel-add .td-img{width: 200px;}
.c-panel-add .el-form .el-input{width: 200px;}
</style>
</head>
<body>
<div class="vue-box" style="display: none;" :style="'display: block;'">
<div class="c-panel">
<!-- ------------- 检索参数 ------------- -->
<!-- <div class="c-title">检索参数</div>-->
<!-- <el-form @submit.native.prevent >-->
<!-- <sa-item name="标题搜索" v-model="p.title"></sa-item>-->
<!-- <el-button type="primary" icon="el-icon-search" @click="p.pageNo = 1; f5()">查询</el-button>-->
<!-- </el-form>-->
<!-- ------------- 快捷curd按钮 ------------- -->
<sa-item type="fast-btn" show="add,delete,export,reset"></sa-item>
<!-- ------------- 数据列表 ------------- -->
<el-table class="data-table" ref="data-table" :data="dataList" size="small">
<sa-td type="selection"></sa-td>
<sa-td name="编号" prop="nid" width="70px"></sa-td>
<el-table-column label="图片" width="400px">
<template slot-scope="s">
<img :src="getFaceUrl(s.row.image)" class="td-img" title="点击预览" @click="sa.showImage(getFaceUrl(s.row.image), '50%')">
<span style="color: #666; padding-left: 0.5em;"> 点击预览</span>
</template>
</el-table-column>
<sa-td name="状态" prop="is_approved" type="custom-text" :jv="{1: '已通过审核', 2: '审核中'}"></sa-td>
<sa-td name="创建时间" prop="create_time" type="datetime"></sa-td>
<el-table-column label="操作" width="180px">
<template slot-scope="s">
<el-button class="c-btn" type="primary" icon="el-icon-edit" @click="update(s.row)">修改</el-button>
<el-button class="c-btn" type="danger" icon="el-icon-delete" @click="del(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- ------------- 分页 ------------- -->
<!-- <sa-item type="page" :curr.sync="p.pageNo" :size.sync="p.pageSize" :total="dataCount" @change="f5()"></sa-item>-->
</div>
</div>
<!-- 模拟数据 -->
<!-- <script src="mock-data-list.js"></script>-->
<script>
var app = new Vue({
components: {
"sa-item": httpVueLoader('../../sa-frame/com/sa-item.vue'),
"sa-td": httpVueLoader('../../sa-frame/com/sa-td.vue'),
},
el: '.vue-box',
data: {
sa: sa, // 超级对象
dataList: [], // 数据集合
},
methods: {
getFaceUrl(path) {
return sa.cfg.api_url + "/face/view/" + path;
},
// 刷新
f5: function(){
sa.ajax('/face/image/get_me', undefined, function(res){
this.dataList = res.data; // 数据
for (let i = 0; i < this.dataList.length; i++) {
this.dataList[i].nid = i + 1;
}
sa.f5TableHeight(); // 刷新表格高度
}.bind(this), {type: 'get'});
},
// 保存
add: function(){
sa.showIframe('新增数据', 'swiper-add.html?id=-1', '580px', '450px');
},
// 修改
update: function(data){
sa.showIframe('修改数据', 'swiper-add.html?id=' + data.id, '580px', '450px');
},
// 删除
del: function(data){
sa.confirm('是否删除,此操作不可撤销', function(){
sa.ajax('/face/image/delete', {fid: data.id}, function(res){
sa.ok('删除成功');
sa.arrayDelete(this.dataList, data);
sa.f5TableHeight(); // 刷新表格高度
}.bind(this), {})
}.bind(this))
},
// 批量删除
deleteByIds: function() {
// 获取选中元素的id列表
let selection = this.$refs['data-table'].selection;
let ids = sa.getArrayField(selection, 'id');
if(selection.length == 0) {
return sa.msg('请至少选择一条数据')
}
// 提交删除
sa.confirm('是否批量删除选中数据?此操作不可撤销', function() {
sa.ajax('/face/image/delete_by_ids', {fids: ids}, function(res) {
sa.arrayDelete(this.dataList, selection);
sa.ok('删除成功');
sa.f5TableHeight(); // 刷新表格高度
}.bind(this), {})
}.bind(this));
},
},
created: function(){
this.f5();
sa.onInputEnter(); // 监听输入框的回车事件,执行查询
}
})
</script>
</body>
</html>

View File

@ -36,6 +36,18 @@ sa.uploadApk = function(successCB) {
sa.uploadFile = function(successCB) {
sa.uploadFn(upload_cfg.upload_file_url, successCB);
}
sa.uploadInput = function (successCB) {
var fileInput = document.createElement("input"); //创建input
fileInput.type = "file"; //设置类型为file
fileInput.id = 'uploadfile-' + randomString(12);
fileInput.style.display = 'none';
fileInput.onchange = function(evt) {
successCB(evt.target.files[0]);
}
// 添加到body并触发其点击事件
document.body.appendChild(fileInput);
document.querySelector('#' + fileInput.id).click();
}
// 上传的内部函数 (要上传到的地址,成功的回调)
sa.uploadFn = function(url, successCB) {
// 创建input

169
src/route/face_image.py Normal file
View File

@ -0,0 +1,169 @@
from fastapi import UploadFile, File
from fastapi_amis_admin.crud import BaseApiOut
from starlette import status
from starlette.exceptions import HTTPException
from starlette.requests import Request
from typing import TYPE_CHECKING
from starlette.responses import FileResponse
from src.plugin import handler
from src.plugin.plugin import Plugin
from src.services.face_image.schemas import (
FaceImageCreate,
FaceImageDelete,
FaceImageUpdate,
FaceImageDeleteIds,
)
from src.services.face_image.services import FaceImageServices
from src.services.users.services import UserRoleServices
from src.utils.upload_file import save_face_image, get_face_image, check_face_image
if TYPE_CHECKING:
from src.services.users.models import UserModel as User
class FaceImageRoutes(Plugin):
_prefix = "/face"
def __init__(
self,
face_image_services: FaceImageServices,
user_role_services: UserRoleServices,
):
self.face_image_services = face_image_services
self.user_role_services = user_role_services
self.avatar_path = "/face/view/"
@handler.get("/image/get_me", student=True, out=True)
async def get_me_face_images(self, request: Request):
user: "User" = request.user
try:
req = await self.face_image_services.get_by_user_id(user_id=user.id)
return BaseApiOut(code=0, msg="查询成功", data=req)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error Execute SQL{e}",
) from e
@handler.get("/view/{uid}/{file_path}", student=True, out=True)
async def get_face_image(self, request: Request, uid: int, file_path: str):
can_see = False
if await self.user_role_services.is_admin(request.user.username):
can_see = True
if request.user.id == uid:
can_see = True
if not can_see:
return BaseApiOut(status=500, msg="无权查看他人人脸")
path = await get_face_image(uid, file_path)
if not path:
return BaseApiOut(status=500, msg="文件不存在")
return FileResponse(path)
@handler.post("/image/upload", student=True, out=True)
async def upload_face_image(self, request: Request, file: UploadFile = File(...)):
user: "User" = request.user
path = await save_face_image(user.id, file)
real_path = self.avatar_path + str(user.id) + "/" + path
return BaseApiOut(code=0, msg="上传成功", data=real_path)
@handler.post("/image/get_by_id", student=True, out=True)
async def get_face_image_by_id(self, request: Request, data: FaceImageDelete):
user: "User" = request.user
can_query = await self.user_role_services.is_admin(request.user.username)
try:
data = await self.face_image_services.get_by_fid(data.fid)
if not data:
return BaseApiOut(status=500, msg="图片不存在")
if data.user_id != user.id and not can_query:
return BaseApiOut(status=500, msg="人脸地址错误")
data.image = self.avatar_path + data.image
return BaseApiOut(code=0, msg="查询成功", data=data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error Execute SQL{e}",
) from e
@handler.post("/image/create", student=True, out=True)
async def create_face_image(self, request: Request, data: FaceImageCreate):
user: "User" = request.user
image = data.image
if not image.startswith(self.avatar_path):
return BaseApiOut(status=500, msg="人脸地址错误")
path = image[len(self.avatar_path) :]
if not await check_face_image(user.id, path):
return BaseApiOut(status=500, msg="人脸不存在")
try:
data = await self.face_image_services.upload_by_user_id(user.id, path)
return BaseApiOut(code=0, msg="新增成功", data=data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error Execute SQL{e}",
) from e
@handler.post("/image/delete", student=True, out=True)
async def delete_face_image(self, request: Request, data: FaceImageDelete):
user: "User" = request.user
fid = data.fid
can_delete = await self.user_role_services.is_admin(request.user.username)
try:
data = await self.face_image_services.get_by_fid(fid)
if not data:
return BaseApiOut(status=500, msg="图片不存在")
if data.user_id != user.id and not can_delete:
return BaseApiOut(status=500, msg="人脸地址错误")
new = await self.face_image_services.delete_image(data)
return BaseApiOut(code=0, msg="删除成功", data=new)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error Execute SQL{e}",
) from e
@handler.post("/image/delete_by_ids", student=True, out=True)
async def delete_face_image_by_ids(
self, request: Request, data: FaceImageDeleteIds
):
user: "User" = request.user
fids = data.fids
can_delete = await self.user_role_services.is_admin(request.user.username)
try:
datas = await self.face_image_services.get_by_ids(fids)
if not datas:
return BaseApiOut(status=500, msg="图片不存在")
for data in datas:
if data.user_id != user.id and not can_delete:
return BaseApiOut(status=500, msg="人脸地址错误")
await self.face_image_services.delete_images(datas)
return BaseApiOut(code=0, msg="删除成功")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error Execute SQL{e}",
) from e
@handler.post("/image/update", student=True, out=True)
async def update_face_image(self, request: Request, data: FaceImageUpdate):
user: "User" = request.user
image = data.image
fid = data.fid
path = image[len(self.avatar_path) :]
if not await check_face_image(user.id, path):
return BaseApiOut(status=500, msg="人脸不存在")
can_edit = await self.user_role_services.is_admin(request.user.username)
try:
data = await self.face_image_services.get_by_fid(fid)
if not data:
return BaseApiOut(status=500, msg="图片不存在")
if data.user_id != user.id and not can_edit:
return BaseApiOut(status=500, msg="人脸地址错误")
new = await self.face_image_services.edit_by_user_id(fid, path)
return BaseApiOut(code=0, msg="更新成功", data=new)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error Execute SQL{e}",
) from e

View File

View File

@ -0,0 +1,14 @@
from fastapi_user_auth.mixins.models import PkMixin, CUDTimeMixin
from sqlmodel import Field
class FaceImageModel(PkMixin, CUDTimeMixin, table=True):
__tablename__ = "face_image"
image: str = Field(title="Image", max_length=255, nullable=False)
is_approved: bool = Field(default=False, title="Approved")
user_id: int = Field(title="ID", nullable=False)
@property
def is_active(self) -> bool:
return not self.delete_time and self.is_approved

View File

@ -0,0 +1,54 @@
from typing import Optional, Sequence
from persica.factory.component import AsyncInitializingComponent
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import select, col
from src.core.database import Database
from src.services.face_image.models import FaceImageModel
class FaceImageRepo(AsyncInitializingComponent):
__order__ = 1
def __init__(self, database: Database):
self.engine = database.engine
async def get_by(self, fid: Optional[int]) -> Optional[FaceImageModel]:
async with AsyncSession(self.engine) as session:
query = select(FaceImageModel)
if fid is not None:
query = query.where(FaceImageModel.id == fid)
r = await session.exec(query)
return r.first()
async def get_by_ids(self, fids: Sequence[int]) -> Sequence[FaceImageModel]:
async with AsyncSession(self.engine) as session:
query = select(FaceImageModel)
query = query.where(col(FaceImageModel.id).in_(fids))
r = await session.exec(query)
return r.all()
async def get_all(
self,
user_id: Optional[int] = None,
is_approved: Optional[bool] = None,
ignore_deleted: bool = True,
) -> Sequence[FaceImageModel]:
async with AsyncSession(self.engine) as session:
query = select(FaceImageModel)
if user_id is not None:
query = query.where(FaceImageModel.user_id == user_id)
if is_approved is not None:
query = query.where(FaceImageModel.is_approved == is_approved)
if ignore_deleted:
query = query.where(col(FaceImageModel.delete_time).is_(None))
r = await session.exec(query)
return r.all()
async def add_face_image(self, image: "FaceImageModel") -> "FaceImageModel":
async with AsyncSession(self.engine) as session:
session.add(image)
await session.commit()
await session.refresh(image)
return image

View File

@ -0,0 +1,19 @@
from typing import List
from pydantic import BaseModel
class FaceImageCreate(BaseModel):
image: str
class FaceImageDelete(BaseModel):
fid: int
class FaceImageDeleteIds(BaseModel):
fids: List[int]
class FaceImageUpdate(FaceImageCreate, FaceImageDelete):
pass

View File

@ -0,0 +1,55 @@
from datetime import datetime
from persica.factory.component import AsyncInitializingComponent
from typing_extensions import Optional
from .models import FaceImageModel
from .repositories import FaceImageRepo
class FaceImageServices(AsyncInitializingComponent):
__order__ = 1
def __init__(self, repo: FaceImageRepo):
self.repo = repo
async def get_by_fid(self, fid: int):
return await self.repo.get_by(fid)
async def get_by_ids(self, fids: list[int]):
return await self.repo.get_by_ids(fids)
async def get_by_user_id(self, user_id: int, ignore_deleted: bool = True):
return await self.repo.get_all(user_id=user_id, ignore_deleted=ignore_deleted)
async def get_all(
self, approved: Optional[bool] = None, ignore_deleted: bool = True
):
return await self.repo.get_all(
is_approved=approved, ignore_deleted=ignore_deleted
)
async def upload_by_user_id(self, user_id: int, image: str) -> FaceImageModel:
new = FaceImageModel(image=image, user_id=user_id)
return await self.repo.add_face_image(new)
async def delete_image(self, image: FaceImageModel):
image.delete_time = datetime.now()
return await self.repo.add_face_image(image)
async def delete_images(self, images: list[FaceImageModel]):
now = datetime.now()
for image in images:
image.delete_time = now
await self.repo.add_face_image(image)
async def edit_by_user_id(self, rid: int, image: str) -> FaceImageModel:
old = await self.repo.get_by(rid)
old.image = image
old.is_approved = False
return await self.repo.add_face_image(old)
async def approve_image(self, rid: int) -> FaceImageModel:
image = await self.repo.get_by(rid)
image.is_approved = True
return await self.repo.add_face_image(image)

View File

@ -4,6 +4,15 @@ from fastapi_amis_admin.models.fields import Field
from fastapi_user_auth.auth.models import BaseUser, Role as RoleModel
from sqlmodel import SQLModel
__all__ = [
"UserModel",
"RoleModel",
"StudentIdMixin",
"PhoneMixin",
"RealNameMixin",
"SexMixin",
]
class StudentIdMixin(SQLModel):
student_id: Optional[str] = Field("", title="学号", max_length=15)

View File

@ -1,4 +1,4 @@
from typing import Optional, List, Sequence
from typing import Optional, Sequence
from fastapi_user_auth.auth import Auth
from fastapi_user_auth.auth.backends.redis import RedisTokenStore

View File

@ -9,3 +9,6 @@ DATA_PATH = PROJECT_ROOT / "data"
DATA_PATH.mkdir(exist_ok=True)
AVATAR_DATA_PATH = DATA_PATH / "avatar"
AVATAR_DATA_PATH.mkdir(exist_ok=True)
FACE_IMAGE_DATA_PATH = DATA_PATH / "face_image"
FACE_IMAGE_DATA_PATH.mkdir(exist_ok=True)

View File

@ -5,7 +5,7 @@ from typing import Optional
import aiofiles
from fastapi import UploadFile
from ._path import AVATAR_DATA_PATH
from ._path import AVATAR_DATA_PATH, FACE_IMAGE_DATA_PATH
from ..errors import ProjectBaseError
AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
@ -51,3 +51,45 @@ async def save_avatar(uid: int, file: UploadFile) -> str:
async with aiofiles.open(file_path, "wb") as f:
await f.write(file_data)
return name
async def check_face_image(uid: int, uri_path: str) -> bool:
try:
real_uid, file_path = uri_path.split("/")
if int(real_uid) != uid:
return False
path = FACE_IMAGE_DATA_PATH / f"{uid}" / f"{file_path}"
return path.exists()
except ValueError:
return False
async def get_face_image(uid: int, file_path: str) -> Optional[Path]:
path = FACE_IMAGE_DATA_PATH / f"{uid}" / f"{file_path}"
if not path.exists():
return None
return path
async def save_face_image(uid: int, file: UploadFile) -> str:
filename = file.filename.lower() if file.filename else ""
if not filename or not filename.endswith(".jpg"):
raise ProjectBaseError("请上传 jpg 格式的文件")
path = FACE_IMAGE_DATA_PATH / f"{uid}"
path.mkdir(exist_ok=True)
file_data = await file.read()
if len(file_data) > AVATAR_MAX_FILE_SIZE:
raise ProjectBaseError("文件过大请上传小于5MB的文件")
name = (
hashlib.md5(file_data).hexdigest() + ".jpg"
) # 使用md5作为文件名以免同一个文件多次写入
file_path = path / name
if file_path.exists():
return name
async with aiofiles.open(file_path, "wb") as f:
await f.write(file_data)
return name