Skip to content

章节6:模块综合实战


一、章节概述

本章将前五章的知识融会贯通,通过两个完整的实战项目巩固所学内容。项目一构建博客系统后台(ORM + Admin + 模板),采用传统的服务端渲染架构;项目二构建企业员工管理系统 API(DRF + JWT + RBAC),采用前后端分离架构。两个项目覆盖了 Django 开发的核心场景,完成后学生将具备独立开发 Django 项目的能力。

二、学习目标

目标分类具体目标
知识目标理解传统服务端渲染与前后端分离两种架构的设计差异;掌握完整项目从需求分析到编码实现的全流程
技能目标能独立完成 Django 项目的架构设计、模型设计、业务逻辑编码与接口调试
素养目标培养项目工程化思维;养成编写可读性高、可维护性强的代码习惯

项目一:博客系统后台(ORM + Admin + 模板)


1.1 项目需求

1.1.1 功能需求

功能模块需求描述
文章管理文章的增删改查;支持 Markdown 编辑;文章分类与标签;草稿/发布状态管理
分类管理分类的增删改查;分类下文章数量统计
标签管理标签的增删改查;标签云展示
评论管理文章评论审核;嵌套评论展示
用户管理用户注册登录;个人中心;头像上传
Admin 后台完善的后台管理界面;数据统计看板;批量操作
前端展示文章列表页(分页);文章详情页(含评论);分类/标签筛选;侧栏(最新文章、热门文章、分类、标签云)

1.1.2 技术栈

Python 3.12+ + Django 5.0+ + SQLite/PostgreSQL
模板引擎:Django Templates + Bootstrap 5
富文本编辑:django-markdownx
数据库:SQLite(开发)/ PostgreSQL(生产)

1.2 项目结构

myblog/
├── manage.py
├── myblog/                 # 项目配置
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   ├── wsgi.py
│   └── asgi.py
├── blog/                   # 博客应用
│   ├── __init__.py
│   ├── admin.py            # Admin 配置
│   ├── apps.py
│   ├── models.py           # 数据模型
│   ├── urls.py             # 路由
│   ├── views.py            # 视图
│   ├── forms.py            # 表单
│   ├── templatetags/       # 自定义模板标签
│   │   ├── __init__.py
│   │   └── blog_tags.py
│   ├── migrations/
│   ├── templates/          # 模板
│   │   ├── base.html
│   │   ├── blog/
│   │   │   ├── index.html
│   │   │   ├── article_list.html
│   │   │   ├── article_detail.html
│   │   │   ├── article_form.html
│   │   │   ├── category_list.html
│   │   │   └── search.html
│   │   └── includes/
│   │       ├── sidebar.html
│   │       └── pagination.html
│   └── static/
│       └── blog/
│           ├── css/
│           │   └── style.css
│           └── js/
│               └── main.js
└── media/                  # 用户上传文件
    └── avatars/

1.3 模型设计

python
# blog/models.py

from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.text import slugify
from django.urls import reverse
from markdownx.models import MarkdownxField
from markdownx.utils import markdownify


class User(AbstractUser):
    """自定义用户模型"""
    bio = models.TextField(max_length=500, blank=True, verbose_name='个人简介')
    avatar = models.ImageField(upload_to='avatars/%Y/%m/', blank=True, verbose_name='头像')
    website = models.URLField(blank=True, verbose_name='个人网站')
    
    class Meta:
        verbose_name = '用户'
        verbose_name_plural = '用户'
    
    def __str__(self):
        return self.username


class Category(models.Model):
    """文章分类"""
    name = models.CharField(max_length=100, unique=True, verbose_name='分类名称')
    slug = models.SlugField(max_length=120, unique=True, blank=True, verbose_name='URL 标识')
    description = models.TextField(blank=True, verbose_name='分类描述')
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
    
    class Meta:
        verbose_name = '分类'
        verbose_name_plural = '分类'
        ordering = ['name']
    
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)
    
    def __str__(self):
        return self.name
    
    def article_count(self):
        """分类下的文章数量"""
        return self.articles.count()
    article_count.short_description = '文章数量'


class Tag(models.Model):
    """文章标签"""
    name = models.CharField(max_length=50, unique=True, verbose_name='标签名称')
    slug = models.SlugField(max_length=80, unique=True, blank=True, verbose_name='URL 标识')
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
    
    class Meta:
        verbose_name = '标签'
        verbose_name_plural = '标签'
        ordering = ['name']
    
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)
    
    def __str__(self):
        return self.name


class Article(models.Model):
    """文章模型"""
    STATUS_CHOICES = [
        ('draft', '草稿'),
        ('published', '已发布'),
        ('archived', '已归档'),
    ]
    
    title = models.CharField(max_length=200, verbose_name='标题')
    slug = models.SlugField(max_length=220, unique=True, blank=True, verbose_name='URL 标识')
    content = MarkdownxField(verbose_name='内容(Markdown)')
    summary = models.TextField(max_length=300, blank=True, verbose_name='摘要')
    
    category = models.ForeignKey(
        Category, on_delete=models.SET_NULL, null=True,
        related_name='articles', verbose_name='分类'
    )
    tags = models.ManyToManyField(Tag, blank=True, related_name='articles', verbose_name='标签')
    author = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name='articles', verbose_name='作者'
    )
    
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='状态')
    views = models.PositiveIntegerField(default=0, verbose_name='阅读量')
    likes = models.PositiveIntegerField(default=0, verbose_name='点赞数')
    
    cover_image = models.ImageField(upload_to='articles/covers/%Y/%m/', blank=True, verbose_name='封面图')
    is_pinned = models.BooleanField(default=False, verbose_name='是否置顶')
    allow_comments = models.BooleanField(default=True, verbose_name='允许评论')
    
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
    updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
    published_at = models.DateTimeField(null=True, blank=True, verbose_name='发布时间')
    
    class Meta:
        verbose_name = '文章'
        verbose_name_plural = '文章'
        ordering = ['-is_pinned', '-published_at', '-created_at']
        indexes = [
            models.Index(fields=['status', 'published_at']),
            models.Index(fields=['slug']),
        ]
    
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        if self.status == 'published' and not self.published_at:
            from django.utils import timezone
            self.published_at = timezone.now()
        super().save(*args, **kwargs)
    
    def get_absolute_url(self):
        return reverse('blog:article_detail', kwargs={'slug': self.slug})
    
    @property
    def content_html(self):
        """将 Markdown 渲染为 HTML"""
        return markdownify(self.content)
    
    @property
    def summary_text(self):
        """自动生成摘要"""
        if self.summary:
            return self.summary
        # 去除 Markdown 标记,取前 200 字符
        import re
        text = re.sub(r'[#*>`\[\]\(\)!\-]', '', self.content)
        return text[:200] + '...' if len(text) > 200 else text
    
    def __str__(self):
        return self.title


class Comment(models.Model):
    """文章评论"""
    article = models.ForeignKey(
        Article, on_delete=models.CASCADE, related_name='comments', verbose_name='文章'
    )
    author = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name='comments', verbose_name='评论者'
    )
    parent = models.ForeignKey(
        'self', on_delete=models.CASCADE, null=True, blank=True,
        related_name='replies', verbose_name='父评论'
    )
    content = models.TextField(verbose_name='评论内容')
    is_approved = models.BooleanField(default=False, verbose_name='是否审核通过')
    
    ip_address = models.GenericIPAddressField(blank=True, null=True, verbose_name='IP 地址')
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='评论时间')
    
    class Meta:
        verbose_name = '评论'
        verbose_name_plural = '评论'
        ordering = ['created_at']
    
    def __str__(self):
        return f'{self.author.username} 评论 {self.article.title}'

1.4 Admin 配置

python
# blog/admin.py

from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from django.db.models import Count, Sum
from .models import Article, Category, Tag, Comment, User


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    list_display = ['username', 'email', 'article_count', 'comment_count', 'date_joined']
    search_fields = ['username', 'email']
    
    def article_count(self, obj):
        return obj.articles.count()
    article_count.short_description = '文章数'
    
    def comment_count(self, obj):
        return obj.comments.count()
    comment_count.short_description = '评论数'


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug', 'article_count', 'created_at']
    search_fields = ['name']
    prepopulated_fields = {'slug': ('name',)}
    
    def article_count(self, obj):
        count = obj.articles.filter(status='published').count()
        url = f'/admin/blog/article/?category__id__exact={obj.id}'
        return format_html('<a href="{}">{}</a>', url, count)
    article_count.short_description = '文章数'


@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug', 'article_count', 'created_at']
    search_fields = ['name']
    prepopulated_fields = {'slug': ('name',)}
    
    def article_count(self, obj):
        return obj.articles.count()
    article_count.short_description = '文章数'


class CommentInline(admin.TabularInline):
    model = Comment
    extra = 0
    fields = ['author', 'content_preview', 'is_approved', 'created_at']
    readonly_fields = ['created_at']
    autocomplete_fields = ['author']
    
    def content_preview(self, obj):
        return obj.content[:60] + '...' if len(obj.content) > 60 else obj.content
    content_preview.short_description = '评论内容'


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = [
        'title', 'category', 'author', 'status_badge',
        'views', 'likes', 'comment_count', 'created_at',
    ]
    list_filter = ['status', 'category', 'is_pinned', 'author', 'created_at']
    search_fields = ['title', 'content']
    list_per_page = 20
    date_hierarchy = 'created_at'
    
    filter_horizontal = ['tags']
    autocomplete_fields = ['category', 'author']
    prepopulated_fields = {'slug': ('title',)}
    readonly_fields = ['views', 'likes', 'created_at', 'updated_at', 'published_at', 'content_preview']
    
    fieldsets = [
        ('基本信息', {
            'fields': ['title', 'slug', 'category', 'tags', 'author'],
        }),
        ('内容', {
            'fields': ['content', 'summary', 'cover_image'],
        }),
        ('发布设置', {
            'fields': ['status', 'is_pinned', 'allow_comments', 'published_at'],
            'classes': ['collapse'],
        }),
        ('统计信息', {
            'fields': ['views', 'likes', 'created_at', 'updated_at'],
            'classes': ['collapse'],
        }),
    ]
    
    inlines = [CommentInline]
    
    def status_badge(self, obj):
        colors = {
            'draft': 'gray',
            'published': 'green',
            'archived': 'orange',
        }
        labels = {
            'draft': '草稿',
            'published': '已发布',
            'archived': '已归档',
        }
        return format_html(
            '<span style="color:{};font-weight:bold;">● {}</span>',
            colors.get(obj.status, 'gray'),
            labels.get(obj.status, obj.status),
        )
    status_badge.short_description = '状态'
    
    def comment_count(self, obj):
        count = obj.comments.filter(is_approved=True).count()
        return count
    comment_count.short_description = '评论数'
    
    def content_preview(self, obj):
        if obj.content:
            return format_html(
                '<div style="max-height:300px;overflow-y:auto;padding:8px;'
                'background:#f8f9fa;border-radius:4px;">{}</div>',
                obj.content_html
            )
        return '-'
    content_preview.short_description = '内容预览'
    
    @admin.action(description='批量发布')
    def make_published(self, request, queryset):
        updated = queryset.update(
            status='published',
            published_at=timezone.now(),
        )
        self.message_user(request, f'已发布 {updated} 篇文章')
    
    @admin.action(description='批量归档')
    def make_archived(self, request, queryset):
        updated = queryset.update(status='archived')
        self.message_user(request, f'已归档 {updated} 篇文章')
    
    actions = ['make_published', 'make_archived']
    
    def save_model(self, request, obj, form, change):
        if not change:
            obj.author = request.user
        super().save_model(request, obj, form, change)
    
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        if not request.user.is_superuser:
            qs = qs.filter(author=request.user)
        return qs


@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
    list_display = ['content_preview', 'article', 'author', 'is_approved_status', 'created_at']
    list_filter = ['is_approved', 'created_at', 'article']
    search_fields = ['content', 'author__username']
    autocomplete_fields = ['article', 'author', 'parent']
    list_editable = ['is_approved']
    
    def content_preview(self, obj):
        return obj.content[:80] + '...' if len(obj.content) > 80 else obj.content
    content_preview.short_description = '评论内容'
    
    def is_approved_status(self, obj):
        if obj.is_approved:
            return format_html('<span style="color:green;">✓ 已审核</span>')
        return format_html('<span style="color:orange;">✗ 待审核</span>')
    is_approved_status.short_description = '审核状态'
    
    @admin.action(description='批量通过审核')
    def approve_comments(self, request, queryset):
        updated = queryset.update(is_approved=True)
        self.message_user(request, f'已审核通过 {updated} 条评论')
    
    actions = ['approve_comments']

1.5 视图与路由

python
# blog/views.py

from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.contrib import messages
from django.db.models import Count, Q
from django.urls import reverse_lazy
from .models import Article, Category, Tag, Comment


class IndexView(ListView):
    """首页:展示已发布文章列表"""
    model = Article
    template_name = 'blog/index.html'
    context_object_name = 'articles'
    paginate_by = 10
    
    def get_queryset(self):
        return Article.objects.filter(status='published').select_related(
            'author', 'category'
        ).prefetch_related('tags')


class ArticleDetailView(DetailView):
    """文章详情"""
    model = Article
    template_name = 'blog/article_detail.html'
    context_object_name = 'article'
    slug_field = 'slug'
    
    def get_queryset(self):
        return Article.objects.select_related('author', 'category').prefetch_related(
            'tags', 'comments__author'
        )
    
    def get(self, request, *args, **kwargs):
        response = super().get(request, *args, **kwargs)
        # 阅读量 +1(使用 Cookie 防止重复计数)
        if not request.COOKIES.get(f'viewed_article_{self.object.pk}'):
            Article.objects.filter(pk=self.object.pk).update(views=models.F('views') + 1)
            response.set_cookie(f'viewed_article_{self.object.pk}', '1', max_age=3600)
        return response
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        article = self.object
        
        # 上一篇/下一篇
        context['prev_article'] = Article.objects.filter(
            status='published', published_at__lt=article.published_at
        ).first()
        context['next_article'] = Article.objects.filter(
            status='published', published_at__gt=article.published_at
        ).last()
        
        # 评论(仅审核通过的)
        context['comments'] = article.comments.filter(is_approved=True, parent=None)
        
        return context


class CategoryView(ListView):
    """按分类展示文章"""
    template_name = 'blog/category_list.html'
    context_object_name = 'articles'
    paginate_by = 10
    
    def get_queryset(self):
        self.category = get_object_or_404(Category, slug=self.kwargs['slug'])
        return Article.objects.filter(
            status='published', category=self.category
        ).select_related('author')
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['category'] = self.category
        return context


class TagView(ListView):
    """按标签展示文章"""
    template_name = 'blog/tag_list.html'
    context_object_name = 'articles'
    paginate_by = 10
    
    def get_queryset(self):
        self.tag = get_object_or_404(Tag, slug=self.kwargs['slug'])
        return self.tag.articles.filter(status='published').select_related('author')
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['tag'] = self.tag
        return context


class SearchView(ListView):
    """搜索文章"""
    template_name = 'blog/search.html'
    context_object_name = 'articles'
    paginate_by = 10
    
    def get_queryset(self):
        self.query = self.request.GET.get('q', '').strip()
        if self.query:
            return Article.objects.filter(
                Q(status='published'),
                Q(title__icontains=self.query) | Q(content__icontains=self.query)
            ).select_related('author')
        return Article.objects.none()
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['query'] = self.query
        return context


@login_required
def add_comment(request, slug):
    """添加评论"""
    article = get_object_or_404(Article, slug=slug, status='published')
    
    if request.method == 'POST':
        content = request.POST.get('content', '').strip()
        parent_id = request.POST.get('parent_id')
        
        if content:
            comment = Comment.objects.create(
                article=article,
                author=request.user,
                content=content,
                parent_id=parent_id if parent_id else None,
            )
            messages.success(request, '评论提交成功,等待审核')
        else:
            messages.error(request, '评论内容不能为空')
    
    return redirect('blog:article_detail', slug=slug)
python
# blog/urls.py

from django.urls import path
from . import views

app_name = 'blog'

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('article/<slug:slug>/', views.ArticleDetailView.as_view(), name='article_detail'),
    path('category/<slug:slug>/', views.CategoryView.as_view(), name='category'),
    path('tag/<slug:slug>/', views.TagView.as_view(), name='tag'),
    path('search/', views.SearchView.as_view(), name='search'),
    path('article/<slug:slug>/comment/', views.add_comment, name='add_comment'),
]

1.6 模板示例

html
{# templates/blog/article_detail.html #}
{% extends 'base.html' %}
{% load blog_tags %}
{% load static %}

{% block title %}{{ article.title }} - 我的博客{% endblock %}

{% block content %}
<article class="article-detail">
    <header class="article-header">
        <h1>{{ article.title }}</h1>
        <div class="meta">
            <span class="author">
                <img src="{{ article.author.avatar.url|default:'/static/images/default-avatar.png' }}" 
                     alt="" class="avatar-sm">
                {{ article.author.username }}
            </span>
            <span class="date">{{ article.published_at|date:'Y年m月d日' }}</span>
            <span class="category">
                <a href="{% url 'blog:category' article.category.slug %}">
                    {{ article.category.name }}
                </a>
            </span>
            <span class="views">👁 {{ article.views }}</span>
            <span class="likes">❤ {{ article.likes }}</span>
        </div>
        
        {% if article.tags.all %}
        <div class="tags">
            {% for tag in article.tags.all %}
            <a href="{% url 'blog:tag' tag.slug %}" class="tag">#{{ tag.name }}</a>
            {% endfor %}
        </div>
        {% endif %}
    </header>
    
    {% if article.cover_image %}
    <div class="cover-image">
        <img src="{{ article.cover_image.url }}" alt="{{ article.title }}">
    </div>
    {% endif %}
    
    <div class="article-content">
        {{ article.content_html|safe }}
    </div>
</article>

<section class="comments">
    <h3>评论 ({{ comments|length }})</h3>
    
    {% if user.is_authenticated %}
    <form method="post" action="{% url 'blog:add_comment' article.slug %}" class="comment-form">
        {% csrf_token %}
        <textarea name="content" rows="3" placeholder="写下你的评论..." required></textarea>
        <button type="submit">提交评论</button>
    </form>
    {% else %}
    <p><a href="{% url 'login' %}">登录</a>后可以发表评论</p>
    {% endif %}
    
    {% for comment in comments %}
    <div class="comment">
        <div class="comment-header">
            <strong>{{ comment.author.username }}</strong>
            <span>{{ comment.created_at|timesince }}前</span>
        </div>
        <p>{{ comment.content }}</p>
        
        {% for reply in comment.replies.all %}
        <div class="reply">
            <strong>{{ reply.author.username }}</strong>
            <p>{{ reply.content }}</p>
        </div>
        {% endfor %}
    </div>
    {% empty %}
    <p>暂无评论</p>
    {% endfor %}
</section>
{% endblock %}

1.7 启动运行

bash
# 1. 创建虚拟环境
python -m venv venv
venv\Scripts\activate  # Windows
source venv/bin/activate  # Mac/Linux

# 2. 安装依赖
pip install django==5.0.2 django-markdownx Pillow

# 3. 迁移数据库
python manage.py makemigrations
python manage.py migrate

# 4. 创建超级管理员
python manage.py createsuperuser

# 5. 启动开发服务器
python manage.py runserver

# 访问:http://127.0.0.1:8000/
# 后台:http://127.0.0.1:8000/admin/

项目二:企业员工管理系统 API(DRF + JWT + RBAC)


2.1 项目需求

2.1.1 功能需求

功能模块需求描述
员工管理员工的增删改查;支持多条件筛选(部门/职级/状态);员工详情页包含个人信息、工作信息、薪资信息
部门管理部门的增删改查;树形部门结构(父子部门);部门人数统计
职级管理职级的增删改查;职级晋升记录
考勤管理每日打卡(签到/签退);考勤统计(出勤率、迟到/早退次数)
薪资管理员工薪资记录;薪资调整历史;薪资统计报表
用户认证JWT 认证;角色权限(管理员/HR/部门经理/员工);用户注册与个人信息管理

2.1.2 技术栈

Python 3.12+ + Django 5.0+ + DRF + PostgreSQL
认证:djangorestframework-simplejwt
权限:RBAC(Django Group + Permission)
文档:drf-spectacular
测试:pytest + pytest-django

2.2 项目结构

employee_management/
├── manage.py
├── employee_management/       # 项目配置
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── accounts/                  # 用户认证应用
│   ├── __init__.py
│   ├── admin.py
│   ├── models.py
│   ├── serializers.py
│   ├── urls.py
│   ├── views.py
│   └── permissions.py
├── employee/                  # 员工管理应用
│   ├── __init__.py
│   ├── admin.py
│   ├── models.py
│   ├── serializers.py
│   ├── urls.py
│   ├── views.py
│   ├── filters.py
│   └── permissions.py
├── attendance/                # 考勤管理应用
│   ├── __init__.py
│   ├── models.py
│   ├── serializers.py
│   ├── urls.py
│   └── views.py
├── salary/                    # 薪资管理应用
│   ├── __init__.py
│   ├── models.py
│   ├── serializers.py
│   ├── urls.py
│   └── views.py
└── requirements.txt

2.3 模型设计

python
# accounts/models.py

from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    """企业用户模型"""
    ROLE_CHOICES = [
        ('admin', '系统管理员'),
        ('hr', '人力资源'),
        ('manager', '部门经理'),
        ('employee', '普通员工'),
    ]
    
    role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='employee', verbose_name='角色')
    phone = models.CharField(max_length=20, unique=True, verbose_name='手机号')
    avatar = models.ImageField(upload_to='avatars/', blank=True, verbose_name='头像')
    is_active_account = models.BooleanField(default=True, verbose_name='账号是否激活')
    
    class Meta:
        verbose_name = '用户'
        verbose_name_plural = '用户'
    
    def __str__(self):
        return f'{self.username} ({self.get_role_display()})'
python
# employee/models.py

from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()


class Department(models.Model):
    """部门"""
    name = models.CharField(max_length=100, unique=True, verbose_name='部门名称')
    parent = models.ForeignKey(
        'self', on_delete=models.SET_NULL, null=True, blank=True,
        related_name='children', verbose_name='上级部门'
    )
    manager = models.OneToOneField(
        User, on_delete=models.SET_NULL, null=True, blank=True,
        related_name='managed_department', verbose_name='部门经理'
    )
    description = models.TextField(blank=True, verbose_name='部门描述')
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
    
    class Meta:
        verbose_name = '部门'
        verbose_name_plural = '部门'
        ordering = ['name']
    
    def __str__(self):
        return self.name
    
    @property
    def employee_count(self):
        return self.employees.filter(status='active').count()


class Rank(models.Model):
    """职级"""
    name = models.CharField(max_length=50, unique=True, verbose_name='职级名称')
    level = models.IntegerField(unique=True, verbose_name='职级等级(数字越小越高)')
    description = models.TextField(blank=True, verbose_name='职级描述')
    min_salary = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='最低薪资')
    max_salary = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='最高薪资')
    
    class Meta:
        verbose_name = '职级'
        verbose_name_plural = '职级'
        ordering = ['level']
    
    def __str__(self):
        return f'{self.name} (Level {self.level})'


class Employee(models.Model):
    """员工"""
    GENDER_CHOICES = [('M', '男'), ('F', '女')]
    STATUS_CHOICES = [
        ('active', '在职'),
        ('probation', '试用期'),
        ('leave', '离职'),
        ('retired', '退休'),
    ]
    
    user = models.OneToOneField(
        User, on_delete=models.CASCADE, related_name='employee_profile', verbose_name='关联用户'
    )
    
    # 基本信息
    employee_id = models.CharField(max_length=20, unique=True, verbose_name='工号')
    name = models.CharField(max_length=100, verbose_name='姓名')
    gender = models.CharField(max_length=2, choices=GENDER_CHOICES, verbose_name='性别')
    birth_date = models.DateField(verbose_name='出生日期')
    id_card = models.CharField(max_length=18, unique=True, verbose_name='身份证号')
    
    # 工作信息
    department = models.ForeignKey(
        Department, on_delete=models.SET_NULL, null=True,
        related_name='employees', verbose_name='所属部门'
    )
    rank = models.ForeignKey(
        Rank, on_delete=models.SET_NULL, null=True,
        related_name='employees', verbose_name='职级'
    )
    position = models.CharField(max_length=100, verbose_name='职位')
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active', verbose_name='状态')
    
    # 联系信息
    email = models.EmailField(verbose_name='邮箱')
    phone = models.CharField(max_length=20, verbose_name='联系电话')
    address = models.TextField(blank=True, verbose_name='家庭地址')
    
    # 时间信息
    hire_date = models.DateField(verbose_name='入职日期')
    probation_end = models.DateField(null=True, blank=True, verbose_name='试用期结束日期')
    resign_date = models.DateField(null=True, blank=True, verbose_name='离职日期')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    class Meta:
        verbose_name = '员工'
        verbose_name_plural = '员工'
        ordering = ['employee_id']
        indexes = [
            models.Index(fields=['status', 'department']),
            models.Index(fields=['employee_id']),
        ]
    
    def __str__(self):
        return f'{self.name} ({self.employee_id})'
    
    @property
    def work_duration(self):
        """工作年限"""
        from datetime import date
        if self.resign_date:
            end = self.resign_date
        else:
            end = date.today()
        return (end - self.hire_date).days // 365


class PromotionRecord(models.Model):
    """晋升记录"""
    employee = models.ForeignKey(
        Employee, on_delete=models.CASCADE, related_name='promotions', verbose_name='员工'
    )
    from_rank = models.ForeignKey(
        Rank, on_delete=models.SET_NULL, null=True, related_name='+', verbose_name='原职级'
    )
    to_rank = models.ForeignKey(
        Rank, on_delete=models.SET_NULL, null=True, related_name='+', verbose_name='新职级'
    )
    approved_by = models.ForeignKey(
        User, on_delete=models.SET_NULL, null=True, verbose_name='审批人'
    )
    reason = models.TextField(verbose_name='晋升原因')
    effective_date = models.DateField(verbose_name='生效日期')
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        verbose_name = '晋升记录'
        verbose_name_plural = '晋升记录'
        ordering = ['-effective_date']
    
    def __str__(self):
        return f'{self.employee.name}: {self.from_rank}{self.to_rank}'
python
# attendance/models.py

from django.db import models
from django.contrib.auth import get_user_model
from employee.models import Employee

class Attendance(models.Model):
    """考勤记录"""
    employee = models.ForeignKey(
        Employee, on_delete=models.CASCADE, related_name='attendances', verbose_name='员工'
    )
    date = models.DateField(verbose_name='日期')
    
    check_in = models.TimeField(null=True, blank=True, verbose_name='签到时间')
    check_out = models.TimeField(null=True, blank=True, verbose_name='签退时间')
    
    STATUS_CHOICES = [
        ('normal', '正常'),
        ('late', '迟到'),
        ('early', '早退'),
        ('absent', '缺勤'),
        ('leave', '请假'),
        ('overtime', '加班'),
    ]
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='normal', verbose_name='状态')
    
    remark = models.TextField(blank=True, verbose_name='备注')
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        verbose_name = '考勤记录'
        verbose_name_plural = '考勤记录'
        unique_together = ['employee', 'date']
        ordering = ['-date']
    
    def __str__(self):
        return f'{self.employee.name} - {self.date}'
python
# salary/models.py

from django.db import models
from employee.models import Employee

class Salary(models.Model):
    """薪资记录"""
    employee = models.ForeignKey(
        Employee, on_delete=models.CASCADE, related_name='salaries', verbose_name='员工'
    )
    month = models.DateField(verbose_name='薪资月份')  # 存储月份的第一天
    
    base_salary = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='基本工资')
    performance_bonus = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='绩效奖金')
    overtime_pay = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='加班费')
    allowance = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='补贴')
    
    insurance = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='社保扣款')
    tax = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='个税扣款')
    other_deductions = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='其他扣款')
    
    @property
    def gross_pay(self):
        """应发工资"""
        return self.base_salary + self.performance_bonus + self.overtime_pay + self.allowance
    
    @property
    def net_pay(self):
        """实发工资"""
        return self.gross_pay - self.insurance - self.tax - self.other_deductions
    
    is_paid = models.BooleanField(default=False, verbose_name='是否已发放')
    paid_at = models.DateTimeField(null=True, blank=True, verbose_name='发放时间')
    remark = models.TextField(blank=True, verbose_name='备注')
    
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        verbose_name = '薪资记录'
        verbose_name_plural = '薪资记录'
        unique_together = ['employee', 'month']
        ordering = ['-month']
    
    def __str__(self):
        return f'{self.employee.name} - {self.month.strftime("%Y年%m月")}'

2.4 序列化器

python
# employee/serializers.py

from rest_framework import serializers
from .models import Department, Rank, Employee, PromotionRecord


class DepartmentSerializer(serializers.ModelSerializer):
    """部门序列化器"""
    employee_count = serializers.IntegerField(read_only=True)
    manager_name = serializers.CharField(source='manager.username', read_only=True)
    children_count = serializers.SerializerMethodField()
    
    class Meta:
        model = Department
        fields = '__all__'
    
    def get_children_count(self, obj):
        return obj.children.count()


class DepartmentTreeSerializer(serializers.ModelSerializer):
    """部门树形序列化器"""
    children = serializers.SerializerMethodField()
    employee_count = serializers.IntegerField(read_only=True)
    
    class Meta:
        model = Department
        fields = ['id', 'name', 'employee_count', 'children']
    
    def get_children(self, obj):
        children = obj.children.all()
        if children:
            return DepartmentTreeSerializer(children, many=True).data
        return []


class RankSerializer(serializers.ModelSerializer):
    """职级序列化器"""
    employee_count = serializers.SerializerMethodField()
    
    class Meta:
        model = Rank
        fields = '__all__'
    
    def get_employee_count(self, obj):
        return obj.employees.count()


class EmployeeListSerializer(serializers.ModelSerializer):
    """员工列表序列化器(精简)"""
    department_name = serializers.CharField(source='department.name', read_only=True)
    rank_name = serializers.CharField(source='rank.name', read_only=True)
    status_display = serializers.CharField(source='get_status_display', read_only=True)
    
    class Meta:
        model = Employee
        fields = [
            'id', 'employee_id', 'name', 'gender', 'department_name',
            'rank_name', 'position', 'status', 'status_display',
            'hire_date', 'phone',
        ]


class EmployeeDetailSerializer(serializers.ModelSerializer):
    """员工详情序列化器(完整)"""
    department = DepartmentSerializer(read_only=True)
    rank = RankSerializer(read_only=True)
    gender_display = serializers.CharField(source='get_gender_display', read_only=True)
    status_display = serializers.CharField(source='get_status_display', read_only=True)
    work_duration = serializers.IntegerField(read_only=True)
    
    class Meta:
        model = Employee
        fields = '__all__'
        read_only_fields = ['employee_id', 'created_at', 'updated_at']


class EmployeeCreateSerializer(serializers.ModelSerializer):
    """员工创建序列化器"""
    username = serializers.CharField(write_only=True)
    password = serializers.CharField(write_only=True)
    
    class Meta:
        model = Employee
        fields = '__all__'
        read_only_fields = ['employee_id', 'created_at', 'updated_at']
    
    def create(self, validated_data):
        username = validated_data.pop('username')
        password = validated_data.pop('password')
        
        # 创建关联用户账号
        from django.contrib.auth import get_user_model
        User = get_user_model()
        user = User.objects.create_user(
            username=username,
            password=password,
            phone=validated_data.get('phone', ''),
        )
        
        # 创建员工
        validated_data['user'] = user
        employee = Employee.objects.create(**validated_data)
        
        return employee


class PromotionRecordSerializer(serializers.ModelSerializer):
    """晋升记录序列化器"""
    employee_name = serializers.CharField(source='employee.name', read_only=True)
    from_rank_name = serializers.CharField(source='from_rank.name', read_only=True)
    to_rank_name = serializers.CharField(source='to_rank.name', read_only=True)
    approved_by_name = serializers.CharField(source='approved_by.username', read_only=True)
    
    class Meta:
        model = PromotionRecord
        fields = '__all__'
        read_only_fields = ['created_at']

2.5 视图集与权限

python
# employee/permissions.py

from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsAdminOrHR(BasePermission):
    """系统管理员或 HR 权限"""
    
    def has_permission(self, request, view):
        return request.user.is_authenticated and request.user.role in ['admin', 'hr']


class IsManagerOrAbove(BasePermission):
    """部门经理及以上权限"""
    
    def has_permission(self, request, view):
        if not request.user.is_authenticated:
            return False
        return request.user.role in ['admin', 'hr', 'manager']


class CanManageEmployee(BasePermission):
    """可以管理员工信息的权限"""
    
    def has_permission(self, request, view):
        user = request.user
        if not user.is_authenticated:
            return False
        
        # 管理员和 HR 可以管理所有员工
        if user.role in ['admin', 'hr']:
            return True
        
        # 部门经理只能管理本部门员工
        if user.role == 'manager':
            return True
        
        return False
    
    def has_object_permission(self, request, view, obj):
        user = request.user
        
        if user.role == 'admin':
            return True
        
        if user.role == 'hr':
            return True
        
        # 部门经理只能操作本部门员工
        if user.role == 'manager':
            managed_dept = getattr(user, 'managed_department', None)
            if managed_dept and obj.department == managed_dept:
                return True
        
        # 员工只能查看自己的信息
        if user.role == 'employee':
            return obj.user == user or request.method in SAFE_METHODS
        
        return False
python
# employee/views.py

from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Count, Q
from .models import Department, Rank, Employee
from .serializers import (
    DepartmentSerializer, DepartmentTreeSerializer,
    RankSerializer, EmployeeListSerializer,
    EmployeeDetailSerializer, EmployeeCreateSerializer,
)
from .permissions import IsAdminOrHR, IsManagerOrAbove, CanManageEmployee
from .filters import EmployeeFilter


class DepartmentViewSet(viewsets.ModelViewSet):
    """部门管理 API"""
    queryset = Department.objects.annotate(
        employee_count=Count('employees', filter=Q(employees__status='active'))
    )
    serializer_class = DepartmentSerializer
    permission_classes = [IsAuthenticated, IsManagerOrAbove]
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['name']
    ordering_fields = ['name', 'employee_count']
    
    def get_permissions(self):
        if self.action in ('create', 'update', 'partial_update', 'destroy'):
            self.permission_classes = [IsAuthenticated, IsAdminOrHR]
        return [p() for p in self.permission_classes]
    
    @action(detail=False, methods=['get'])
    def tree(self, request):
        """部门树形结构"""
        departments = Department.objects.filter(parent=None).annotate(
            employee_count=Count('employees', filter=Q(employees__status='active'))
        )
        serializer = DepartmentTreeSerializer(departments, many=True)
        return Response(serializer.data)


class EmployeeViewSet(viewsets.ModelViewSet):
    """员工管理 API"""
    queryset = Employee.objects.select_related(
        'department', 'rank', 'user'
    ).prefetch_related('promotions')
    
    permission_classes = [IsAuthenticated, CanManageEmployee]
    
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_class = EmployeeFilter
    search_fields = ['name', 'employee_id', 'phone', 'email']
    ordering_fields = ['employee_id', 'hire_date', 'name']
    ordering = ['employee_id']
    
    def get_serializer_class(self):
        if self.action == 'list':
            return EmployeeListSerializer
        elif self.action == 'create':
            return EmployeeCreateSerializer
        return EmployeeDetailSerializer
    
    def get_queryset(self):
        queryset = super().get_queryset()
        user = self.request.user
        
        # 部门经理只能看到本部门员工
        if user.role == 'manager':
            managed_dept = getattr(user, 'managed_department', None)
            if managed_dept:
                queryset = queryset.filter(department=managed_dept)
        
        # 员工只能看到自己
        elif user.role == 'employee':
            queryset = queryset.filter(user=user)
        
        return queryset
    
    @action(detail=False, methods=['get'])
    def stats(self, request):
        """员工统计信息"""
        total = Employee.objects.count()
        active = Employee.objects.filter(status='active').count()
        probation = Employee.objects.filter(status='probation').count()
        
        # 各部门人数统计
        dept_stats = Department.objects.annotate(
            active_count=Count('employees', filter=Q(employees__status='active'))
        ).values('name', 'active_count')
        
        # 各职级人数统计
        rank_stats = Rank.objects.annotate(
            employee_count=Count('employees')
        ).values('name', 'employee_count')
        
        return Response({
            'total': total,
            'active': active,
            'probation': probation,
            'by_department': dept_stats,
            'by_rank': rank_stats,
        })
    
    @action(detail=True, methods=['post'])
    def promote(self, request, pk=None):
        """员工晋升"""
        employee = self.get_object()
        to_rank_id = request.data.get('to_rank_id')
        reason = request.data.get('reason', '')
        
        if not to_rank_id:
            return Response({'error': '请指定目标职级'}, status=400)
        
        try:
            to_rank = Rank.objects.get(id=to_rank_id)
        except Rank.DoesNotExist:
            return Response({'error': '职级不存在'}, status=404)
        
        # 创建晋升记录
        PromotionRecord.objects.create(
            employee=employee,
            from_rank=employee.rank,
            to_rank=to_rank,
            approved_by=request.user,
            reason=reason,
            effective_date=request.data.get('effective_date'),
        )
        
        # 更新员工职级
        employee.rank = to_rank
        employee.save()
        
        serializer = EmployeeDetailSerializer(employee)
        return Response(serializer.data)
python
# employee/filters.py

import django_filters
from .models import Employee

class EmployeeFilter(django_filters.FilterSet):
    """员工过滤器"""
    name = django_filters.CharFilter(lookup_expr='icontains')
    department = django_filters.NumberFilter(field_name='department_id')
    department_name = django_filters.CharFilter(field_name='department__name', lookup_expr='icontains')
    rank = django_filters.NumberFilter(field_name='rank_id')
    rank_level = django_filters.NumberFilter(field_name='rank__level')
    status = django_filters.ChoiceFilter(choices=Employee.STATUS_CHOICES)
    gender = django_filters.ChoiceFilter(choices=Employee.GENDER_CHOICES)
    
    hire_date_after = django_filters.DateFilter(field_name='hire_date', lookup_expr='gte')
    hire_date_before = django_filters.DateFilter(field_name='hire_date', lookup_expr='lte')
    
    min_age = django_filters.NumberFilter(method='filter_min_age')
    max_age = django_filters.NumberFilter(method='filter_max_age')
    
    search = django_filters.CharFilter(method='filter_search')
    
    class Meta:
        model = Employee
        fields = [
            'name', 'department', 'department_name', 'rank', 'rank_level',
            'status', 'gender', 'hire_date_after', 'hire_date_before',
        ]
    
    def filter_min_age(self, queryset, name, value):
        from datetime import date, timedelta
        min_birth = date.today() - timedelta(days=value * 365)
        return queryset.filter(birth_date__lte=min_birth)
    
    def filter_max_age(self, queryset, name, value):
        from datetime import date, timedelta
        max_birth = date.today() - timedelta(days=value * 365)
        return queryset.filter(birth_date__gte=max_birth)
    
    def filter_search(self, queryset, name, value):
        return queryset.filter(
            models.Q(name__icontains=value) |
            models.Q(employee_id__icontains=value) |
            models.Q(phone__icontains=value)
        )

2.6 考勤与薪资 API

python
# attendance/views.py

from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from django.db.models import Count, Q
from datetime import date, timedelta
from .models import Attendance
from .serializers import AttendanceSerializer
from employee.permissions import IsAdminOrHR, IsManagerOrAbove


class AttendanceViewSet(viewsets.ModelViewSet):
    """考勤管理 API"""
    serializer_class = AttendanceSerializer
    permission_classes = [IsAuthenticated, IsManagerOrAbove]
    
    def get_queryset(self):
        queryset = Attendance.objects.select_related('employee').all()
        user = self.request.user
        
        if user.role == 'employee':
            queryset = queryset.filter(employee__user=user)
        elif user.role == 'manager':
            managed_dept = getattr(user, 'managed_department', None)
            if managed_dept:
                queryset = queryset.filter(employee__department=managed_dept)
        
        # 日期过滤
        date_from = self.request.query_params.get('date_from')
        date_to = self.request.query_params.get('date_to')
        if date_from:
            queryset = queryset.filter(date__gte=date_from)
        if date_to:
            queryset = queryset.filter(date__lte=date_to)
        
        return queryset
    
    @action(detail=False, methods=['post'])
    def check_in(self, request):
        """签到"""
        employee = request.user.employee_profile
        today = date.today()
        
        # 检查是否已签到
        attendance, created = Attendance.objects.get_or_create(
            employee=employee,
            date=today,
            defaults={'check_in': timezone.localtime().time()},
        )
        
        if not created:
            return Response({'message': '今日已签到', 'check_in': str(attendance.check_in)})
        
        # 判断是否迟到(假设 9:00 上班)
        from datetime import time
        if attendance.check_in and attendance.check_in > time(9, 0):
            attendance.status = 'late'
            attendance.save()
        
        return Response(AttendanceSerializer(attendance).data, status=status.HTTP_201_CREATED)
    
    @action(detail=False, methods=['post'])
    def check_out(self, request):
        """签退"""
        employee = request.user.employee_profile
        today = date.today()
        
        try:
            attendance = Attendance.objects.get(employee=employee, date=today)
        except Attendance.DoesNotExist:
            return Response({'error': '请先签到'}, status=400)
        
        attendance.check_out = timezone.localtime().time()
        
        # 判断是否早退(假设 18:00 下班)
        from datetime import time
        if attendance.check_out < time(18, 0):
            if attendance.status == 'normal':
                attendance.status = 'early'
        
        attendance.save()
        return Response(AttendanceSerializer(attendance).data)
    
    @action(detail=False, methods=['get'])
    def monthly_stats(self, request):
        """月度考勤统计"""
        year = int(request.query_params.get('year', timezone.now().year))
        month = int(request.query_params.get('month', timezone.now().month))
        
        employee = request.user.employee_profile
        
        attendances = Attendance.objects.filter(
            employee=employee,
            date__year=year,
            date__month=month,
        )
        
        total_days = attendances.count()
        stats = attendances.values('status').annotate(
            count=Count('id')
        )
        
        return Response({
            'year': year,
            'month': month,
            'total_days': total_days,
            'stats': {s['status']: s['count'] for s in stats},
        })
python
# salary/views.py

from rest_framework import viewsets, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Sum, Avg
from .models import Salary
from .serializers import SalarySerializer
from employee.permissions import IsAdminOrHR, IsManagerOrAbove


class SalaryViewSet(viewsets.ModelViewSet):
    """薪资管理 API"""
    serializer_class = SalarySerializer
    permission_classes = [IsAuthenticated, IsAdminOrHR]
    
    filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
    filterset_fields = ['employee', 'month', 'is_paid']
    ordering_fields = ['month', 'base_salary']
    ordering = ['-month']
    
    def get_queryset(self):
        queryset = Salary.objects.select_related('employee').all()
        user = self.request.user
        
        # 员工只能查看自己的薪资
        if user.role == 'employee':
            queryset = queryset.filter(employee__user=user)
        
        return queryset
    
    @action(detail=False, methods=['get'])
    def summary(self, request):
        """薪资汇总统计"""
        year = int(request.query_params.get('year'))
        month = int(request.query_params.get('month'))
        
        salaries = Salary.objects.filter(
            month__year=year,
            month__month=month,
        )
        
        summary = salaries.aggregate(
            total_base=Sum('base_salary'),
            total_bonus=Sum('performance_bonus'),
            total_overtime=Sum('overtime_pay'),
            total_allowance=Sum('allowance'),
            total_insurance=Sum('insurance'),
            total_tax=Sum('tax'),
            total_net=Sum('net_pay'),
            avg_net=Avg('net_pay'),
            count=Sum('is_paid') / Sum('id'),  # 发放比例
        )
        
        return Response({
            'year': year,
            'month': month,
            'summary': summary,
        })

2.7 路由与配置

python
# employee_management/urls.py

from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView

urlpatterns = [
    path('admin/', admin.site.urls),
    
    # API
    path('api/v1/', include('accounts.urls')),
    path('api/v1/', include('employee.urls')),
    path('api/v1/', include('attendance.urls')),
    path('api/v1/', include('salary.urls')),
    
    # API 文档
    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
    path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
    path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
]
python
# accounts/urls.py

from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
from . import views

urlpatterns = [
    path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('auth/verify/', TokenVerifyView.as_view(), name='token_verify'),
    path('auth/register/', views.UserRegistrationView.as_view(), name='user_register'),
    path('auth/profile/', views.UserProfileView.as_view(), name='user_profile'),
    path('auth/change-password/', views.ChangePasswordView.as_view(), name='change_password'),
]
python
# employee/urls.py

from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r'departments', views.DepartmentViewSet, basename='department')
router.register(r'ranks', views.RankViewSet, basename='rank')
router.register(r'employees', views.EmployeeViewSet, basename='employee')

urlpatterns = router.urls
python
# settings.py 关键配置

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # 第三方应用
    'rest_framework',
    'rest_framework_simplejwt',
    'django_filters',
    'drf_spectacular',
    'corsheaders',
    
    # 自定义应用
    'accounts',
    'employee',
    'attendance',
    'salary',
]

AUTH_USER_MODEL = 'accounts.User'

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
    'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.AnonRateThrottle',
        'rest_framework.throttling.UserRateThrottle',
    ],
    'DEFAULT_THROTTLE_RATES': {
        'anon': '30/minute',
        'user': '300/minute',
    },
}

from datetime import timedelta
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=14),
    'ROTATE_REFRESH_TOKENS': True,
    'AUTH_HEADER_TYPES': ('Bearer',),
}

SPECTACULAR_SETTINGS = {
    'TITLE': '企业员工管理系统 API',
    'DESCRIPTION': '企业人事管理系统的 RESTful API 接口文档',
    'VERSION': '1.0.0',
    'SERVE_INCLUDE_SCHEMA': False,
}

2.8 启动与测试

bash
# 1. 安装依赖
pip install django djangorestframework djangorestframework-simplejwt \
            django-filter drf-spectacular django-cors-headers Pillow

# 2. 迁移数据库
python manage.py makemigrations accounts employee attendance salary
python manage.py migrate

# 3. 创建初始数据
python manage.py shell
python
# 初始化脚本:创建角色和初始数据

from django.contrib.auth.models import Group, Permission
from accounts.models import User
from employee.models import Department, Rank, Employee

# 创建角色组
admin_group, _ = Group.objects.get_or_create(name='系统管理员')
hr_group, _ = Group.objects.get_or_create(name='人力资源')
manager_group, _ = Group.objects.get_or_create(name='部门经理')

# 创建超级管理员
admin = User.objects.create_superuser(
    username='admin',
    email='admin@example.com',
    password='admin123',
    role='admin',
    phone='13800000000',
)

# 创建部门
dept_it = Department.objects.create(name='技术部', description='负责技术研发')
dept_hr = Department.objects.create(name='人力资源部', description='负责人事管理')
dept_finance = Department.objects.create(name='财务部', description='负责财务管理')

# 创建职级
Rank.objects.create(name='初级', level=5, min_salary=5000, max_salary=10000)
Rank.objects.create(name='中级', level=4, min_salary=8000, max_salary=15000)
Rank.objects.create(name='高级', level=3, min_salary=12000, max_salary=25000)
Rank.objects.create(name='资深', level=2, min_salary=20000, max_salary=40000)
Rank.objects.create(name='专家', level=1, min_salary=35000, max_salary=80000)

print('初始数据创建完成!')
bash
# 4. 启动服务
python manage.py runserver

# API 访问示例:
# POST /api/v1/auth/login/   → 获取 JWT Token
# GET  /api/v1/employees/    → 员工列表(需 Token)
# GET  /api/v1/departments/  → 部门列表
# GET  /api/docs/            → Swagger API 文档

四、小结

项目核心技术架构模式适用场景
博客系统后台Django ORM + Admin + 模板 + Markdown传统 MVT 服务端渲染内容管理系统、企业官网、个人博客
企业员工管理系统DRF + JWT + RBAC + drf-spectacular前后端分离 RESTful API企业级管理系统、移动端后台 API

两个项目的核心差异

维度博客系统(项目一)员工管理系统(项目二)
渲染方式Django Template 服务端渲染DRF JSON + 前端渲染
认证方式Session + CookieJWT Token
权限模型简单用户/管理员完整 RBAC(4种角色)
数据集轻量级复杂关联(树形部门、职级体系)
文档人工维护drf-spectacular 自动生成
接口形式页面 URLRESTful API 端点

五、练习

项目一扩展练习

  1. 添加 RSS 订阅功能:为博客添加 RSS/Atom 订阅
  2. 文章阅读排行榜:在侧栏展示本周/本月热门文章
  3. 邮件通知:新评论时发送邮件通知文章作者
  4. 文章导出:支持将文章导出为 PDF 或 Markdown 文件

项目二扩展练习

  1. 请假管理:添加请假申请/审批流程(年假、事假、病假等)
  2. 报表导出:支持导出考勤统计报表、薪资报表为 Excel
  3. 审批工作流:实现多级审批流程(员工申请 → 部门经理 → HR → 管理员)
  4. 仪表盘统计:开发部门人员变动趋势、薪资分布统计等可视化 API

综合挑战

  1. 将两个项目整合:在博客系统中使用 JWT 认证,将员工管理系统作为博客的后台内容管理 API
  2. 部署上线:使用 Docker + Nginx + Gunicorn + PostgreSQL 部署两个项目

Python 学习资料