feat: 城市手册后端完成 - 用户/区域/内容/服务/审核系统

This commit is contained in:
root
2026-04-10 12:12:41 +00:00
committed by maoshen
parent c866e74ece
commit 432345c249
120 changed files with 3186 additions and 0 deletions

View File

View File

@@ -0,0 +1,41 @@
from django.contrib import admin
from .models import Region, ModeratorApplication, ModeratorPermission, ModeratorSupport, PermissionRestriction
@admin.register(Region)
class RegionAdmin(admin.ModelAdmin):
list_display = ['name', 'level', 'parent', 'is_active', 'created_at']
list_filter = ['level', 'is_active']
search_fields = ['name']
ordering = ['level', 'name']
@admin.register(ModeratorApplication)
class ModeratorApplicationAdmin(admin.ModelAdmin):
list_display = ['applicant', 'region', 'status', 'support_count', 'required_support', 'deadline', 'created_at']
list_filter = ['status', 'region']
search_fields = ['applicant__username', 'region__name']
ordering = ['-created_at']
@admin.register(ModeratorPermission)
class ModeratorPermissionAdmin(admin.ModelAdmin):
list_display = ['moderator', 'region', 'rank', 'status', 'created_at']
list_filter = ['rank', 'status']
search_fields = ['moderator__username', 'region__name']
ordering = ['-created_at']
@admin.register(ModeratorSupport)
class ModeratorSupportAdmin(admin.ModelAdmin):
list_display = ['supporter', 'application', 'created_at']
search_fields = ['supporter__username', 'application__region__name']
ordering = ['-created_at']
@admin.register(PermissionRestriction)
class PermissionRestrictionAdmin(admin.ModelAdmin):
list_display = ['restricted_moderator', 'restriction_type', 'operator', 'started_at', 'ended_at']
list_filter = ['restriction_type']
search_fields = ['restricted_moderator__username', 'operator__username']
ordering = ['-started_at']

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RegionsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'regions'

View File

@@ -0,0 +1,93 @@
# Generated by Django 4.2.11 on 2026-04-10 12:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ModeratorApplication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('application_reason', models.TextField(blank=True, verbose_name='申请理由')),
('support_count', models.PositiveIntegerField(default=0, verbose_name='支持人数')),
('required_support', models.PositiveIntegerField(default=10, verbose_name='所需支持人数')),
('deadline', models.DateTimeField(verbose_name='截止时间')),
('status', models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='审核时间')),
('review_comment', models.TextField(blank=True, verbose_name='审核意见')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
],
options={
'verbose_name': '版主申请',
'verbose_name_plural': '版主申请',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ModeratorPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rank', models.CharField(choices=[('general', '将军'), ('colonel', '校官'), ('lieutenant', '尉官'), ('soldier', '士兵')], max_length=20, verbose_name='军衔')),
('status', models.CharField(choices=[('active', '正常'), ('restricted', '限制'), ('revoked', '取消')], default='active', max_length=20, verbose_name='状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='授权时间')),
('restricted_until', models.DateTimeField(blank=True, null=True, verbose_name='限制截止时间')),
],
options={
'verbose_name': '版主权限',
'verbose_name_plural': '版主权限',
},
),
migrations.CreateModel(
name='ModeratorSupport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='支持时间')),
],
options={
'verbose_name': '支持记录',
'verbose_name_plural': '支持记录',
},
),
migrations.CreateModel(
name='PermissionRestriction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('restriction_type', models.CharField(choices=[('partial', '部分限制'), ('full', '完全限制')], max_length=20, verbose_name='限制类型')),
('started_at', models.DateTimeField(auto_now_add=True, verbose_name='限制开始时间')),
('ended_at', models.DateTimeField(blank=True, null=True, verbose_name='限制结束时间')),
('reason', models.TextField(blank=True, verbose_name='限制原因')),
],
options={
'verbose_name': '权限限制',
'verbose_name_plural': '权限限制',
'ordering': ['-started_at'],
},
),
migrations.CreateModel(
name='Region',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('level', models.CharField(choices=[('province', '省级'), ('city', '市级'), ('county', '县级'), ('town', '镇级'), ('village', '村级')], max_length=20, verbose_name='级别')),
('code', models.CharField(blank=True, max_length=20, verbose_name='行政区划代码')),
('description', models.TextField(blank=True, verbose_name='描述')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='regions.region', verbose_name='上级区域')),
],
options={
'verbose_name': '区域',
'verbose_name_plural': '区域',
'ordering': ['level', 'name'],
},
),
]

View File

@@ -0,0 +1,71 @@
# Generated by Django 4.2.11 on 2026-04-10 12:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('regions', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='permissionrestriction',
name='operator',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_restrictions_made', to=settings.AUTH_USER_MODEL, verbose_name='操作者'),
),
migrations.AddField(
model_name='permissionrestriction',
name='restricted_moderator',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_restrictions', to=settings.AUTH_USER_MODEL, verbose_name='被限制版主'),
),
migrations.AddField(
model_name='moderatorsupport',
name='application',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supports', to='regions.moderatorapplication', verbose_name='申请'),
),
migrations.AddField(
model_name='moderatorsupport',
name='supporter',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderator_supports', to=settings.AUTH_USER_MODEL, verbose_name='支持者'),
),
migrations.AddField(
model_name='moderatorpermission',
name='moderator',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderator_permissions', to=settings.AUTH_USER_MODEL, verbose_name='版主'),
),
migrations.AddField(
model_name='moderatorpermission',
name='region',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderator_permissions', to='regions.region', verbose_name='管辖区域'),
),
migrations.AddField(
model_name='moderatorapplication',
name='applicant',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderator_applications', to=settings.AUTH_USER_MODEL, verbose_name='申请者'),
),
migrations.AddField(
model_name='moderatorapplication',
name='region',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderator_applications', to='regions.region', verbose_name='申请区域'),
),
migrations.AddField(
model_name='moderatorapplication',
name='reviewer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_applications', to=settings.AUTH_USER_MODEL, verbose_name='审核人'),
),
migrations.AlterUniqueTogether(
name='moderatorsupport',
unique_together={('supporter', 'application')},
),
migrations.AlterUniqueTogether(
name='moderatorpermission',
unique_together={('moderator', 'region')},
),
]

View File

@@ -0,0 +1,214 @@
from django.db import models
from django.utils import timezone
class Region(models.Model):
"""版块/区域表 - 省市区乡镇村层级结构"""
LEVEL_CHOICES = [
('province', '省级'),
('city', '市级'),
('county', '县级'),
('town', '镇级'),
('village', '村级'),
]
name = models.CharField('名称', max_length=100)
level = models.CharField('级别', max_length=20, choices=LEVEL_CHOICES)
parent = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='children',
verbose_name='上级区域'
)
code = models.CharField('行政区划代码', max_length=20, blank=True)
description = models.TextField('描述', blank=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
is_active = models.BooleanField('是否启用', default=True)
class Meta:
verbose_name = '区域'
verbose_name_plural = '区域'
ordering = ['level', 'name']
def __str__(self):
return f"{self.name} ({self.get_level_display()})"
def get_full_path(self):
"""获取完整路径"""
path = [self.name]
current = self.parent
while current:
path.append(current.name)
current = current.parent
return ' > '.join(reversed(path))
class ModeratorApplication(models.Model):
"""版主申请表"""
STATUS_CHOICES = [
('pending', '待审核'),
('approved', '已通过'),
('rejected', '已拒绝'),
('cancelled', '已取消'),
]
applicant = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='moderator_applications',
verbose_name='申请者'
)
region = models.ForeignKey(
Region,
on_delete=models.CASCADE,
related_name='moderator_applications',
verbose_name='申请区域'
)
application_reason = models.TextField('申请理由', blank=True)
support_count = models.PositiveIntegerField('支持人数', default=0)
required_support = models.PositiveIntegerField('所需支持人数', default=10)
deadline = models.DateTimeField('截止时间')
status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='pending')
reviewer = models.ForeignKey(
'users.User',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='reviewed_applications',
verbose_name='审核人'
)
reviewed_at = models.DateTimeField('审核时间', null=True, blank=True)
review_comment = models.TextField('审核意见', blank=True)
created_at = models.DateTimeField('申请时间', auto_now_add=True)
class Meta:
verbose_name = '版主申请'
verbose_name_plural = '版主申请'
ordering = ['-created_at']
def __str__(self):
return f"{self.applicant.username} 申请 {self.region.name} 版主"
def is_expired(self):
return timezone.now() > self.deadline
def auto_cancel_if_failed(self):
"""如果截止且支持人数不足,自动取消"""
if self.is_expired() and self.status == 'pending' and self.support_count < self.required_support:
self.status = 'cancelled'
self.save()
return True
return False
class ModeratorSupport(models.Model):
"""版主申请支持表"""
supporter = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='moderator_supports',
verbose_name='支持者'
)
application = models.ForeignKey(
ModeratorApplication,
on_delete=models.CASCADE,
related_name='supports',
verbose_name='申请'
)
created_at = models.DateTimeField('支持时间', auto_now_add=True)
class Meta:
verbose_name = '支持记录'
verbose_name_plural = '支持记录'
unique_together = ['supporter', 'application']
def __str__(self):
return f"{self.supporter.username} 支持 {self.application}"
class ModeratorPermission(models.Model):
"""版主权限表"""
RANK_CHOICES = [
('general', '将军'), # 省级
('colonel', '校官'), # 市级
('lieutenant', '尉官'), # 县级
('soldier', '士兵'), # 镇村级
]
STATUS_CHOICES = [
('active', '正常'),
('restricted', '限制'),
('revoked', '取消'),
]
moderator = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='moderator_permissions',
verbose_name='版主'
)
region = models.ForeignKey(
Region,
on_delete=models.CASCADE,
related_name='moderator_permissions',
verbose_name='管辖区域'
)
rank = models.CharField('军衔', max_length=20, choices=RANK_CHOICES)
status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='active')
created_at = models.DateTimeField('授权时间', auto_now_add=True)
restricted_until = models.DateTimeField('限制截止时间', null=True, blank=True)
class Meta:
verbose_name = '版主权限'
verbose_name_plural = '版主权限'
unique_together = ['moderator', 'region']
def __str__(self):
return f"{self.moderator.username} - {self.region.name} ({self.get_rank_display()})"
def can_moderate(self, content_region):
"""判断是否可以审核某个区域的内容"""
if self.status != 'active':
return False
# 检查是否在管辖范围内
current = content_region
while current:
if current.id == self.region.id:
return True
current = current.parent
return False
class PermissionRestriction(models.Model):
"""权限限制表"""
operator = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='permission_restrictions_made',
verbose_name='操作者'
)
restricted_moderator = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='permission_restrictions',
verbose_name='被限制版主'
)
restriction_type = models.CharField('限制类型', max_length=20, choices=[
('partial', '部分限制'),
('full', '完全限制'),
])
started_at = models.DateTimeField('限制开始时间', auto_now_add=True)
ended_at = models.DateTimeField('限制结束时间', null=True, blank=True)
reason = models.TextField('限制原因', blank=True)
class Meta:
verbose_name = '权限限制'
verbose_name_plural = '权限限制'
ordering = ['-started_at']
def __str__(self):
return f"{self.restricted_moderator.username} 被限制 ({self.restriction_type})"

View File

@@ -0,0 +1,63 @@
from rest_framework import serializers
from .models import Region, ModeratorApplication, ModeratorPermission
from users.serializers import UserSerializer
class RegionSerializer(serializers.ModelSerializer):
parent = serializers.PrimaryKeyRelatedField(read_only=True)
children_count = serializers.SerializerMethodField()
class Meta:
model = Region
fields = [
'id', 'name', 'level', 'parent', 'code', 'description',
'is_active', 'created_at', 'children_count'
]
read_only_fields = ['created_at']
def get_children_count(self, obj):
return obj.children.count()
class RegionDetailSerializer(serializers.ModelSerializer):
parent = RegionSerializer(read_only=True)
children = RegionSerializer(many=True, read_only=True)
articles_count = serializers.SerializerMethodField()
services_count = serializers.SerializerMethodField()
class Meta:
model = Region
fields = [
'id', 'name', 'level', 'parent', 'children', 'code', 'description',
'is_active', 'created_at', 'articles_count', 'services_count'
]
def get_articles_count(self, obj):
return obj.articles.filter(publish_status='published').count()
def get_services_count(self, obj):
return obj.featured_services.filter(publish_status='published').count()
class ModeratorApplicationSerializer(serializers.ModelSerializer):
applicant = UserSerializer(read_only=True)
region = RegionSerializer(read_only=True)
class Meta:
model = ModeratorApplication
fields = [
'id', 'applicant', 'region', 'application_reason',
'support_count', 'required_support', 'deadline',
'status', 'created_at'
]
read_only_fields = ['applicant', 'support_count', 'status', 'created_at']
class ModeratorPermissionSerializer(serializers.ModelSerializer):
moderator = UserSerializer(read_only=True)
region = RegionSerializer(read_only=True)
class Meta:
model = ModeratorPermission
fields = ['id', 'moderator', 'region', 'rank', 'status', 'created_at']
read_only_fields = ['created_at']

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,107 @@
from rest_framework import viewsets, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from django.utils import timezone
from .models import Region, ModeratorApplication, ModeratorPermission, ModeratorSupport
from .serializers import RegionSerializer, RegionDetailSerializer, ModeratorApplicationSerializer, ModeratorPermissionSerializer
class RegionViewSet(viewsets.ModelViewSet):
queryset = Region.objects.filter(is_active=True)
serializer_class = RegionSerializer
permission_classes = [permissions.AllowAny]
def get_serializer_class(self):
if self.action == 'retrieve':
return RegionDetailSerializer
return RegionSerializer
@action(detail=False, methods=['get'])
def provinces(self, request):
provinces = Region.objects.filter(level='province', is_active=True)
serializer = self.get_serializer(provinces, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def children(self, request, pk=None):
region = self.get_object()
children = region.children.filter(is_active=True)
serializer = self.get_serializer(children, many=True)
return Response(serializer.data)
class ModeratorApplicationViewSet(viewsets.ModelViewSet):
queryset = ModeratorApplication.objects.all()
serializer_class = ModeratorApplicationSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
application = serializer.save(applicant=self.request.user)
# 设置默认截止时间为 7 天后
application.deadline = timezone.now() + timezone.timedelta(days=7)
application.save()
@action(detail=True, methods=['post'])
def support(self, request, pk=None):
application = self.get_object()
user = request.user
# 检查是否已经支持过
if ModeratorSupport.objects.filter(supporter=user, application=application).exists():
return Response({'detail': '已经支持过该申请'}, status=status.HTTP_400_BAD_REQUEST)
# 创建支持记录
ModeratorSupport.objects.create(supporter=user, application=application)
application.support_count += 1
application.save()
return Response({'support_count': application.support_count})
@action(detail=True, methods=['post'])
def approve(self, request, pk=None):
application = self.get_object()
user = request.user
# 检查权限
if not user.is_staff:
return Response({'detail': '没有权限'}, status=status.HTTP_403_FORBIDDEN)
application.status = 'approved'
application.reviewer = user
application.reviewed_at = timezone.now()
application.save()
# 创建版主权限
ModeratorPermission.objects.create(
moderator=application.applicant,
region=application.region,
rank=self._get_rank_by_level(application.region.level)
)
return Response({'detail': '申请已批准'})
@action(detail=True, methods=['post'])
def reject(self, request, pk=None):
application = self.get_object()
user = request.user
if not user.is_staff:
return Response({'detail': '没有权限'}, status=status.HTTP_403_FORBIDDEN)
application.status = 'rejected'
application.reviewer = user
application.reviewed_at = timezone.now()
application.review_comment = request.data.get('comment', '')
application.save()
return Response({'detail': '申请已拒绝'})
def _get_rank_by_level(self, level):
rank_map = {
'province': 'general',
'city': 'colonel',
'county': 'lieutenant',
'town': 'soldier',
'village': 'soldier',
}
return rank_map.get(level, 'soldier')