Files
zkxw/backend/app/crud/user.py
T

755 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户 CRUD(sys_user + sys_user_role + sys_user_post)"""
from __future__ import annotations
import math
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Tuple, Union
import bcrypt
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.utils import generate_uuid
from app.crud.base import CRUDBase
from app.models.models import SysUser, SysUserRole, SysUserPost, SysDept
def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
class _UserCRUDCompat(CRUDBase[SysUser]):
def get_by_user_no(self, db: Session, user_no: str, tenant_no: Optional[str] = None) -> Optional[SysUser]:
tenant = tenant_no or settings.DEFAULT_TENANT_NO
un = (user_no or "").strip()
if not un:
return None
return (
db.query(SysUser)
.filter(and_(SysUser.user_no == un, SysUser.tenant_no == tenant, SysUser.del_flag == 0))
.first()
)
def get_by_wx_openid(self, db: Session, wx_openid: str) -> Optional[SysUser]:
oid = (wx_openid or "").strip()
if not oid:
return None
return (
db.query(SysUser)
.filter(SysUser.wx_openid == oid, SysUser.del_flag == 0)
.first()
)
def get_by_wx_mp_openid(self, db: Session, wx_mp_openid: str) -> Optional[SysUser]:
oid = (wx_mp_openid or "").strip()
if not oid:
return None
return (
db.query(SysUser)
.filter(SysUser.wx_mp_openid == oid, SysUser.del_flag == 0)
.first()
)
def list_active_by_phone_no(self, db: Session, phone_no: str) -> List[SysUser]:
phone = (phone_no or "").strip()
if not phone:
return []
return (
db.query(SysUser)
.filter(
SysUser.phone_no == phone,
SysUser.del_flag == 0,
SysUser.status == "1",
)
.all()
)
user_crud = _UserCRUDCompat(SysUser)
def _row_to_dict(u: SysUser, dept_name: str = "") -> Dict[str, Any]:
return {
"id": u.id,
"deptId": u.dept_id or "",
"supervisorUserId": getattr(u, "supervisor_user_id", None) or "",
"userNo": u.user_no,
"nickName": u.nick_name,
"email": u.email or "",
"phoneNo": u.phone_no or "",
"hireDate": u.hire_date.strftime("%Y-%m-%d") if getattr(u, "hire_date", None) else "",
"probationMonths": (
u.probation_months if getattr(u, "probation_months", None) is not None else None
),
"lastWorkDay": u.last_work_day.strftime("%Y-%m-%d")
if getattr(u, "last_work_day", None)
else "",
"sex": u.sex or "0",
"avatar": u.avatar or "",
"status": u.status,
"tenantNo": u.tenant_no,
"orderNum": u.order_num if u.order_num is not None else 0,
"remark": u.remark or "",
"createTime": u.create_time.strftime("%Y-%m-%d %H:%M:%S") if u.create_time else "",
"updateTime": u.update_time.strftime("%Y-%m-%d %H:%M:%S") if u.update_time else "",
"deptName": dept_name,
}
def list_users(
db: Session,
*,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
dept_id: Optional[str] = None,
tenant_no: Optional[str] = None,
status: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
q0 = db.query(SysUser).filter(SysUser.del_flag == 0)
if tenant_no:
q0 = q0.filter(SysUser.tenant_no == tenant_no)
if dept_id:
q0 = q0.filter(SysUser.dept_id == dept_id)
if status:
q0 = q0.filter(SysUser.status == status)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
SysUser.user_no.like(like),
SysUser.nick_name.like(like),
SysUser.phone_no.like(like),
)
)
return q0
total = _filtered().order_by(None).with_entities(func.count(SysUser.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(SysUser.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
# batch resolve dept names
dept_ids = list({r.dept_id for r in rows if r.dept_id})
dept_map: Dict[str, str] = {}
if dept_ids:
dept_rows = db.query(SysDept).filter(SysDept.id.in_(dept_ids)).all()
dept_map = {d.id: d.dept_name for d in dept_rows}
return [_row_to_dict(r, dept_name=dept_map.get(r.dept_id or "", "")) for r in rows], int(total)
def get_user(db: Session, user_id: str) -> Optional[SysUser]:
return (
db.query(SysUser)
.filter(and_(SysUser.id == user_id, SysUser.del_flag == 0))
.first()
)
def list_post_ids_for_user(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> List[str]:
"""用户当前有效职位 ID 列表。"""
query = db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
)
tenant = (tenant_no or "").strip()
if tenant:
query = query.filter(SysUserPost.tenant_no == tenant)
return [
r.post_id
for r in query.all()
]
def set_user_post_ids_only(
db: Session,
user_id: str,
tenant_no: str,
post_ids: List[str],
updater_id: Optional[str],
) -> None:
"""仅替换 sys_user_post,不影响 sys_user_role。"""
now = datetime.now()
uniq = [p for p in dict.fromkeys(post_ids or []) if p]
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
for pid in uniq:
db.add(
SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
def list_user_ids_bound_to_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
rows = (
db.query(SysUserPost.user_id)
.join(
SysUser,
and_(
SysUser.id == SysUserPost.user_id,
SysUser.del_flag == 0,
),
)
.filter(
and_(
SysUserPost.post_id == post_id,
SysUserPost.tenant_no == tenant_no,
SysUserPost.del_flag == 0,
)
)
.all()
)
return [r[0] for r in rows]
def list_user_ids_bound_to_posts(
db: Session, post_ids: List[str], tenant_no: str
) -> List[str]:
"""多职位展开启用用户 ID(仅启用职位;用户启用过滤留给调用方或本函数后处理)。
返回保序去重的 user_id 列表。仅包含 status=1 的职位下的关联。
"""
from app.models.models import SysPost
ordered = list(dict.fromkeys([pid for pid in (post_ids or []) if pid]))
if not ordered:
return []
rows = (
db.query(SysUserPost.user_id)
.join(
SysPost,
and_(
SysPost.id == SysUserPost.post_id,
SysPost.del_flag == 0,
SysPost.status == "1",
SysPost.tenant_no == tenant_no,
),
)
.filter(
and_(
SysUserPost.post_id.in_(ordered),
SysUserPost.tenant_no == tenant_no,
SysUserPost.del_flag == 0,
)
)
.all()
)
out: List[str] = []
seen = set()
for r in rows:
uid = r[0]
if uid and uid not in seen:
seen.add(uid)
out.append(uid)
return out
def sync_post_user_bindings(
db: Session,
post_id: str,
tenant_no: str,
user_ids: List[str],
updater_id: Optional[str],
) -> None:
"""将本职位绑定的用户集合设为 user_ids(只增删本职位,保留用户其它职位与角色)。"""
ordered = list(dict.fromkeys([uid for uid in (user_ids or []) if uid]))
for uid in ordered:
u = get_user(db, uid)
if not u or u.tenant_no != tenant_no:
raise ValueError("存在无效用户或用户不属于当前租户")
current = set(list_user_ids_bound_to_post(db, post_id, tenant_no))
target = set(ordered)
affected = current | target
for uid in affected:
u = get_user(db, uid)
if not u or u.tenant_no != tenant_no:
continue
existing = list_post_ids_for_user(db, uid)
without = [p for p in existing if p != post_id]
new_posts = without + [post_id] if uid in target else without
set_user_post_ids_only(db, uid, tenant_no, new_posts, updater_id)
db.commit()
def _empty_user_relation_fields(d: Dict[str, Any]) -> None:
d["roleIds"] = []
d["postIds"] = []
d["postInheritedRoleIds"] = []
d["effectiveRoleIds"] = []
d["effectiveRoleNames"] = []
def _attach_user_relations(db: Session, d: Dict[str, Any], user_id: str) -> None:
"""为单个用户 dict 补全职位/角色关联字段(与详情接口字段一致)。"""
if not d.get("id"):
d["id"] = user_id
enrich_user_dicts_with_relations(db, [d])
def enrich_user_dicts_with_relations(
db: Session, items: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""批量就地补全列表项的职位/角色关联字段,供列表列展示。
使用固定次数的 IN 查询,避免按用户 N+1(远程库 RTT 下可达数秒)。
"""
if not items:
return items
from app.core.config import settings
from app.models.models import SysPost, SysPostRole, SysRole
tenant = settings.DEFAULT_TENANT_NO
user_ids: List[str] = []
seen_uids: set = set()
for d in items:
uid = str(d.get("id") or "").strip()
if not uid:
_empty_user_relation_fields(d)
continue
if uid not in seen_uids:
seen_uids.add(uid)
user_ids.append(uid)
if not user_ids:
return items
role_ids_by_user: Dict[str, List[str]] = {uid: [] for uid in user_ids}
for row in (
db.query(SysUserRole.user_id, SysUserRole.role_id)
.filter(SysUserRole.user_id.in_(user_ids), SysUserRole.del_flag == 0)
.all()
):
uid, rid = str(row[0] or ""), str(row[1] or "")
if uid in role_ids_by_user and rid:
role_ids_by_user[uid].append(rid)
post_ids_by_user: Dict[str, List[str]] = {uid: [] for uid in user_ids}
all_post_ids: List[str] = []
seen_posts: set = set()
for row in (
db.query(SysUserPost.user_id, SysUserPost.post_id)
.filter(SysUserPost.user_id.in_(user_ids), SysUserPost.del_flag == 0)
.all()
):
uid, pid = str(row[0] or ""), str(row[1] or "")
if uid in post_ids_by_user and pid:
post_ids_by_user[uid].append(pid)
if pid not in seen_posts:
seen_posts.add(pid)
all_post_ids.append(pid)
enabled_post_ids: set = set()
if all_post_ids:
enabled_post_ids = {
str(r[0])
for r in db.query(SysPost.id)
.filter(
SysPost.id.in_(all_post_ids),
SysPost.del_flag == 0,
SysPost.status == "1",
SysPost.tenant_no == tenant,
)
.all()
if r[0]
}
post_role_ids: Dict[str, List[str]] = {pid: [] for pid in enabled_post_ids}
if enabled_post_ids:
for row in (
db.query(SysPostRole.post_id, SysRole.id)
.join(SysRole, SysRole.id == SysPostRole.role_id)
.filter(
SysPostRole.post_id.in_(list(enabled_post_ids)),
SysPostRole.del_flag == 0,
SysPostRole.tenant_no == tenant,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
)
.order_by(SysRole.create_time.desc())
.all()
):
pid, rid = str(row[0] or ""), str(row[1] or "")
if pid in post_role_ids and rid and rid not in post_role_ids[pid]:
post_role_ids[pid].append(rid)
enabled_direct_by_user: Dict[str, List[str]] = {uid: [] for uid in user_ids}
for row in (
db.query(SysUserRole.user_id, SysRole.id)
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUserRole.user_id.in_(user_ids),
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
)
.order_by(SysRole.create_time.desc())
.all()
):
uid, rid = str(row[0] or ""), str(row[1] or "")
if uid in enabled_direct_by_user and rid and rid not in enabled_direct_by_user[uid]:
enabled_direct_by_user[uid].append(rid)
inherited_by_user: Dict[str, List[str]] = {}
effective_by_user: Dict[str, List[str]] = {}
all_effective: List[str] = []
seen_eff: set = set()
for uid in user_ids:
post_inherited: List[str] = []
seen_inh: set = set()
for pid in post_ids_by_user.get(uid, []):
if pid not in enabled_post_ids:
continue
for rid in post_role_ids.get(pid, []):
if rid not in seen_inh:
seen_inh.add(rid)
post_inherited.append(rid)
inherited_by_user[uid] = post_inherited
effective: List[str] = []
seen_e: set = set()
for rid in enabled_direct_by_user.get(uid, []) + post_inherited:
if rid not in seen_e:
seen_e.add(rid)
effective.append(rid)
effective_by_user[uid] = effective
for rid in effective:
if rid not in seen_eff:
seen_eff.add(rid)
all_effective.append(rid)
name_by_id: Dict[str, str] = {}
if all_effective:
for rid, name in (
db.query(SysRole.id, SysRole.role_name)
.filter(
SysRole.id.in_(all_effective),
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant,
)
.all()
):
if rid and name:
name_by_id[str(rid).strip()] = str(name).strip()
for d in items:
uid = str(d.get("id") or "").strip()
if not uid:
continue
effective = effective_by_user.get(uid, [])
d["roleIds"] = role_ids_by_user.get(uid, [])
d["postIds"] = post_ids_by_user.get(uid, [])
d["postInheritedRoleIds"] = inherited_by_user.get(uid, [])
d["effectiveRoleIds"] = effective
names: List[str] = []
seen_n: set = set()
for rid in effective:
name = name_by_id.get(rid)
if name and name not in seen_n:
seen_n.add(name)
names.append(name)
d["effectiveRoleNames"] = names
return items
def get_user_with_relations(db: Session, user_id: str) -> Optional[Dict[str, Any]]:
u = get_user(db, user_id)
if not u:
return None
dept_name = ""
if u.dept_id:
dept = db.query(SysDept).filter(SysDept.id == u.dept_id).first()
if dept:
dept_name = dept.dept_name
d = _row_to_dict(u, dept_name=dept_name)
_attach_user_relations(db, d, user_id)
return d
def user_no_exists(db: Session, user_no: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
q = db.query(SysUser).filter(
and_(SysUser.user_no == user_no.strip(), SysUser.tenant_no == tenant_no, SysUser.del_flag == 0)
)
if exclude_id:
q = q.filter(SysUser.id != exclude_id)
return q.first() is not None
def _replace_associations(
db: Session,
user_id: str,
tenant_no: str,
creator_id: Optional[str],
role_ids: Optional[List[str]] = None,
post_ids: Optional[List[str]] = None,
) -> None:
"""全量替换关联。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
now = datetime.now()
if role_ids is not None:
db.query(SysUserRole).filter(
and_(SysUserRole.user_id == user_id, SysUserRole.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for rid in role_ids:
if not rid:
continue
db.add(SysUserRole(
id=generate_uuid(),
user_id=user_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
if post_ids is not None:
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for pid in post_ids:
if not pid:
continue
db.add(SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
def _parse_hire_date(value: Optional[Union[str, date]]) -> Optional[date]:
if value is None or value == "":
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
s = str(value).strip()
if not s:
return None
return datetime.strptime(s[:10], "%Y-%m-%d").date()
def create_user(
db: Session,
*,
user_no: str,
nick_name: str,
password: str,
dept_id: Optional[str] = None,
email: str = "",
phone_no: str = "",
hire_date: Optional[Union[str, date]] = None,
probation_months: Optional[int] = None,
last_work_day: Optional[Union[str, date]] = None,
sex: str = "0",
avatar: Optional[str] = None,
status: str = "1",
tenant_no: str = "zkxw",
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
role_ids: Optional[List[str]] = None,
post_ids: Optional[List[str]] = None,
supervisor_user_id: Optional[str] = None,
) -> SysUser:
un = (user_no or "").strip()
if not un:
raise ValueError("用户账号不能为空")
nn = (nick_name or "").strip()
if not nn:
raise ValueError("用户名不能为空")
pw = (password or "").strip()
if not pw:
raise ValueError("密码不能为空")
obj = SysUser(
id=generate_uuid(),
dept_id=dept_id or None,
supervisor_user_id=(supervisor_user_id or "").strip() or None,
user_no=un,
nick_name=nn,
email=(email or "").strip(),
phone_no=(phone_no or "").strip(),
hire_date=_parse_hire_date(hire_date),
probation_months=probation_months,
last_work_day=_parse_hire_date(last_work_day),
sex=sex or "0",
avatar=avatar,
password=_hash_password(pw),
status=status or "1",
tenant_no=tenant_no,
order_num=order_num if order_num is not None else 0,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
_replace_associations(db, obj.id, tenant_no, user_id, role_ids, post_ids)
db.commit()
return obj
def update_user(
db: Session,
user_id: str,
*,
nick_name: Optional[str] = None,
dept_id: Optional[str] = None,
email: Optional[str] = None,
phone_no: Optional[str] = None,
hire_date: Any = ...,
probation_months: Any = ...,
last_work_day: Any = ...,
sex: Optional[str] = None,
avatar: Optional[str] = None,
status: Optional[str] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id_updater: Optional[str] = None,
role_ids: Optional[List[str]] = None,
post_ids: Optional[List[str]] = None,
supervisor_user_id: Optional[str] = None,
) -> SysUser:
obj = get_user(db, user_id)
if not obj:
raise ValueError("用户不存在")
if nick_name is not None:
obj.nick_name = (nick_name or "").strip() or obj.nick_name
if dept_id is not None:
obj.dept_id = dept_id or None
if supervisor_user_id is not None:
obj.supervisor_user_id = (supervisor_user_id or "").strip() or None
if email is not None:
obj.email = (email or "").strip()
if phone_no is not None:
obj.phone_no = (phone_no or "").strip()
if hire_date is not ...:
obj.hire_date = _parse_hire_date(hire_date)
if probation_months is not ...:
if probation_months is None or probation_months == "":
obj.probation_months = None
else:
obj.probation_months = int(probation_months)
if last_work_day is not ...:
obj.last_work_day = _parse_hire_date(last_work_day)
if sex is not None:
obj.sex = sex
if avatar is not None:
obj.avatar = avatar or None
if status is not None:
obj.status = status
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = (remark or "").strip() or None
obj.update_by = user_id_updater
obj.update_time = datetime.now()
db.add(obj)
if role_ids is not None or post_ids is not None:
_replace_associations(
db, user_id, obj.tenant_no, user_id_updater,
role_ids=role_ids, post_ids=post_ids,
)
db.commit()
db.refresh(obj)
return obj
def soft_delete_user(db: Session, user_id: str) -> None:
obj = get_user(db, user_id)
if not obj:
raise ValueError("用户不存在")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def reset_password(db: Session, user_id: str, new_password: str) -> None:
obj = get_user(db, user_id)
if not obj:
raise ValueError("用户不存在")
obj.password = _hash_password(new_password)
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def change_password(db: Session, user_id: str, old_password: str, new_password: str) -> None:
from app.core.security import verify_password
obj = get_user(db, user_id)
if not obj:
raise ValueError("用户不存在")
pwd_hash = getattr(obj, "password", None) or ""
if not verify_password(old_password, pwd_hash):
raise ValueError("原密码不正确")
new_pw = (new_password or "").strip()
if len(new_pw) < 6:
raise ValueError("新密码长度不能少于6位")
obj.password = _hash_password(new_pw)
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def total_pages(total: int, page_size: int) -> int:
if total <= 0:
return 0
return int(math.ceil(total / float(page_size)))