feat: enhance employee CRUD with search, filters, and security module

This commit is contained in:
2026-05-07 13:48:00 +08:00
parent c00db75c13
commit 2d56bc2889
13 changed files with 693 additions and 131 deletions

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
from app.services.employee import EmployeeService
router = APIRouter()
@@ -41,3 +41,21 @@ def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
if employee is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found")
return employee
@router.patch("/{employee_id}", response_model=EmployeeRead)
def update_employee(employee_id: str, payload: EmployeeUpdate, db: DbSession) -> EmployeeRead:
try:
return EmployeeService(db).update_employee(employee_id, payload)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("/{employee_id}/disable", response_model=EmployeeRead)
def disable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
try:
return EmployeeService(db).disable_employee(employee_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import hashlib
import secrets
from base64 import urlsafe_b64decode, urlsafe_b64encode
PBKDF2_ALGORITHM = "sha256"
PBKDF2_ITERATIONS = 120_000
SALT_BYTES = 16
def hash_password(password: str) -> str:
salt = secrets.token_bytes(SALT_BYTES)
digest = hashlib.pbkdf2_hmac(
PBKDF2_ALGORITHM,
password.encode("utf-8"),
salt,
PBKDF2_ITERATIONS,
)
encoded_salt = urlsafe_b64encode(salt).decode("utf-8")
encoded_digest = urlsafe_b64encode(digest).decode("utf-8")
return f"pbkdf2_{PBKDF2_ALGORITHM}${PBKDF2_ITERATIONS}${encoded_salt}${encoded_digest}"
def verify_password(password: str, password_hash: str) -> bool:
try:
scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3)
except ValueError:
return False
if scheme != f"pbkdf2_{PBKDF2_ALGORITHM}":
return False
salt = urlsafe_b64decode(encoded_salt.encode("utf-8"))
expected_digest = urlsafe_b64decode(encoded_digest.encode("utf-8"))
computed_digest = hashlib.pbkdf2_hmac(
PBKDF2_ALGORITHM,
password.encode("utf-8"),
salt,
int(iterations),
)
return secrets.compare_digest(computed_digest, expected_digest)

View File

@@ -32,6 +32,7 @@ class Employee(Base):
grade: Mapped[str] = mapped_column(String(20), default="P3", index=True)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -94,3 +94,9 @@ class EmployeeRepository:
self.db.commit()
self.db.refresh(employee)
return employee
def save(self, employee: Employee) -> Employee:
self.db.add(employee)
self.db.commit()
self.db.refresh(employee)
return employee

View File

@@ -100,3 +100,25 @@ class EmployeeCreate(BaseModel):
def parsed_join_date(self) -> date | None:
return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None
class EmployeeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
gender: str | None = Field(default=None, max_length=20)
birth_date: str | None = None
phone: str | None = Field(default=None, max_length=30)
email: EmailStr | None = None
join_date: str | None = None
location: str | None = Field(default=None, max_length=100)
position: str | None = Field(default=None, min_length=1, max_length=100)
grade: str | None = Field(default=None, min_length=1, max_length=20)
cost_center: str | None = Field(default=None, max_length=50)
finance_owner_name: str | None = Field(default=None, max_length=100)
role_codes: list[str] | None = None
password: str | None = Field(default=None, min_length=5, max_length=128)
def parsed_birth_date(self) -> date | None:
return datetime.strptime(self.birth_date, "%Y-%m-%d").date() if self.birth_date else None
def parsed_join_date(self) -> date | None:
return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None

View File

@@ -4,10 +4,12 @@ from collections import Counter
from datetime import date, datetime
from typing import Any
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import hash_password
from app.db.base import Base
from app.db.session import get_session_factory
from app.models.employee import Employee
@@ -23,6 +25,7 @@ from app.schemas.employee import (
EmployeeRead,
EmployeeRoleOptionRead,
EmployeeStatusSummaryRead,
EmployeeUpdate,
)
from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS,
@@ -64,6 +67,7 @@ class EmployeeService:
def ensure_directory_ready(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._ensure_employee_schema()
self._prune_extra_seed_employees()
self._seed_roles()
self._seed_organization_units()
@@ -170,6 +174,163 @@ class EmployeeService:
hydrated = self.repository.get(created.id)
return self._serialize_employee(hydrated or created)
def update_employee(self, employee_id: str, payload: EmployeeUpdate) -> EmployeeRead:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee is None:
raise LookupError("Employee not found")
changed_fields: list[str] = []
password_changed = False
if "name" in payload.model_fields_set and payload.name is not None:
name = payload.name.strip()
if not name:
raise ValueError("员工姓名不能为空")
if name != employee.name:
employee.name = name
changed_fields.append("姓名")
if "gender" in payload.model_fields_set:
gender = self._normalize_optional_text(payload.gender)
if gender != employee.gender:
employee.gender = gender
changed_fields.append("性别")
if "birth_date" in payload.model_fields_set:
birth_date = payload.parsed_birth_date() if payload.birth_date else None
if birth_date != employee.birth_date:
employee.birth_date = birth_date
changed_fields.append("出生日期")
if "phone" in payload.model_fields_set:
phone = self._normalize_optional_text(payload.phone)
if phone != employee.phone:
employee.phone = phone
changed_fields.append("手机号")
if "email" in payload.model_fields_set and payload.email is not None:
email = str(payload.email)
existing = self.repository.get_by_email(email)
if existing is not None and existing.id != employee.id:
raise ValueError(f"邮箱 {email} 已存在")
if email != employee.email:
employee.email = email
changed_fields.append("邮箱")
if "join_date" in payload.model_fields_set:
join_date = payload.parsed_join_date() if payload.join_date else None
if join_date != employee.join_date:
employee.join_date = join_date
changed_fields.append("入职日期")
if "location" in payload.model_fields_set:
location = self._normalize_optional_text(payload.location)
if location != employee.location:
employee.location = location
changed_fields.append("办公地点")
if "position" in payload.model_fields_set and payload.position is not None:
position = payload.position.strip()
if not position:
raise ValueError("岗位不能为空")
if position != employee.position:
employee.position = position
changed_fields.append("岗位")
if "grade" in payload.model_fields_set and payload.grade is not None:
grade = payload.grade.strip()
if not grade:
raise ValueError("职级不能为空")
if grade != employee.grade:
employee.grade = grade
changed_fields.append("职级")
if "cost_center" in payload.model_fields_set:
cost_center = self._normalize_optional_text(payload.cost_center)
if cost_center != employee.cost_center:
employee.cost_center = cost_center
changed_fields.append("成本中心")
if "finance_owner_name" in payload.model_fields_set:
finance_owner_name = self._normalize_optional_text(payload.finance_owner_name)
if finance_owner_name != employee.finance_owner_name:
employee.finance_owner_name = finance_owner_name
changed_fields.append("财务归口")
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
requested_codes = list(dict.fromkeys(payload.role_codes))
roles: list[Role] = []
invalid_codes: list[str] = []
for code in requested_codes:
role = self.repository.get_role_by_code(code)
if role is None:
invalid_codes.append(code)
continue
roles.append(role)
if invalid_codes:
raise ValueError(f"角色不存在:{''.join(invalid_codes)}")
sorted_roles = self._sorted_roles(roles)
next_role_codes = [role.role_code for role in sorted_roles]
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
if next_role_codes != current_role_codes:
employee.roles = sorted_roles
changed_fields.append("系统角色")
if "password" in payload.model_fields_set and payload.password:
password = payload.password.strip()
if len(password) < 5:
raise ValueError("员工密码至少需要 5 位")
employee.password_hash = hash_password(password)
password_changed = True
if not changed_fields and not password_changed:
return self._serialize_employee(employee)
now = datetime.now()
employee.last_sync_at = now
employee.sync_state = "已同步"
if changed_fields:
self._append_change_log(
employee,
action=f"更新员工信息({''.join(changed_fields)}",
occurred_at=now,
)
if password_changed:
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
return self._serialize_employee(hydrated or saved)
def disable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee is None:
raise LookupError("Employee not found")
if employee.employment_status == "停用":
return self._serialize_employee(employee)
now = datetime.now()
employee.employment_status = "停用"
employee.sync_state = "已同步"
employee.last_sync_at = now
employee.spotlight = False
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
saved = self.repository.save(employee)
hydrated = self.repository.get(saved.id)
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated or saved)
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
@@ -293,6 +454,17 @@ class EmployeeService:
if employee is not None:
self.db.delete(employee)
def _ensure_employee_schema(self) -> None:
bind = self.db.get_bind()
inspector = inspect(bind)
if "employees" not in inspector.get_table_names():
return
column_names = {column["name"] for column in inspector.get_columns("employees")}
if "password_hash" not in column_names:
self.db.execute(text("ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)"))
self.db.flush()
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = {
(item.action, item.owner, self._format_datetime(item.occurred_at))
@@ -332,6 +504,22 @@ class EmployeeService:
)
existing_keys.add(identity)
def _append_change_log(
self,
employee: Employee,
action: str,
owner: str = "系统管理员",
occurred_at: datetime | None = None,
) -> None:
self.db.add(
EmployeeChangeLog(
employee=employee,
action=action,
owner=owner,
occurred_at=occurred_at or datetime.now(),
)
)
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles))
@@ -407,6 +595,13 @@ class EmployeeService:
def _sorted_roles(self, roles: list[Role]) -> list[Role]:
return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name))
@staticmethod
def _normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
text_value = value.strip()
return text_value or None
@staticmethod
def _parse_date(value: str | None) -> date | None:
if not value:

View File

@@ -4,11 +4,13 @@ from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.security import verify_password
from app.db.base import Base
from app.models.employee import Employee
from app.models.employee_change_log import EmployeeChangeLog
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.employee import EmployeeUpdate
from app.services.employee import EmployeeService
@@ -60,3 +62,56 @@ def test_employee_detail_contains_department_and_roles() -> None:
assert detail.manager
assert detail.organization is not None
assert detail.roles
def test_update_employee_persists_changes_and_hashes_password() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
updated = service.update_employee(
employee.id,
EmployeeUpdate(
name="测试员工A",
phone="13900001111",
location="深圳南山",
position="高级财务分析师",
grade="P6",
finance_owner_name="共享财务中心",
cost_center="CC-TEST-01",
role_codes=["finance", "user"],
password="12345",
),
)
persisted = db.get(Employee, employee.id)
assert updated.name == "测试员工A"
assert updated.phone == "13900001111"
assert updated.location == "深圳南山"
assert updated.position == "高级财务分析师"
assert updated.grade == "P6"
assert updated.financeOwner == "共享财务中心"
assert updated.costCenter == "CC-TEST-01"
assert updated.roleCodes == ["finance", "user"]
assert persisted is not None
assert persisted.password_hash is not None
assert verify_password("12345", persisted.password_hash)
assert any("更新员工信息" in item.action for item in updated.history)
assert any("重置员工登录密码" == item.action for item in updated.history)
def test_disable_employee_marks_status_and_logs_change() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = next(item for item in service.list_employees() if item.status != "停用")
updated = service.disable_employee(employee.id)
persisted = db.get(Employee, employee.id)
assert updated.status == "停用"
assert updated.statusTone == "neutral"
assert persisted is not None
assert persisted.employment_status == "停用"
assert any(item.action == "停用员工账号" for item in updated.history)