开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。

开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。
开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。
开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。
配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。
This commit is contained in:
2026-04-28 16:32:02 +08:00
commit 23855ef0e4
94 changed files with 4950 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.env
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
db.sqlite3
media/

61
admin_init.py Normal file
View File

@@ -0,0 +1,61 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from django.contrib.auth import get_user_model
from users.models import Role, UserRole
from opc_cert.models import OpcCertification, CertStatus
User = get_user_model()
def init_admin():
print("--- 正在初始化管理员账号 ---")
admin_user = User.objects.filter(username='admin').first()
if not admin_user:
admin_user = User.objects.create_superuser(
username='admin',
phone='18888888888',
email='admin@opc.local',
password='admin123',
nickname='系统管理员'
)
print("管理员账号创建成功 (admin/admin123)")
else:
admin_user.set_password('admin123')
admin_user.is_staff = True
admin_user.is_superuser = True
admin_user.save()
print("管理员账号已存在,密码重置为 admin123")
admin_role, _ = Role.objects.get_or_create(code='ADMIN', defaults={'name': '管理员', 'is_system': True})
UserRole.objects.get_or_create(user=admin_user, role=admin_role)
print("管理员角色已分配")
def clean_opc_roles():
print("--- 正在清洗错误的 OPC_USER 角色 ---")
opc_role = Role.objects.filter(code='OPC_USER').first()
if not opc_role:
print("未找到 OPC_USER 角色")
return
user_roles = UserRole.objects.filter(role=opc_role)
count = 0
for ur in user_roles:
user = ur.user
# 检查该用户是否有 APPROVED 的认证记录
has_approved_cert = OpcCertification.objects.filter(user=user, status=CertStatus.APPROVED).exists()
if not has_approved_cert:
ur.delete()
# 给用户分配普通 USER 角色
user_role, _ = Role.objects.get_or_create(code='USER', defaults={'name': '普通用户'})
UserRole.objects.get_or_create(user=user, role=user_role)
print(f"移除了用户 {user.username} 的 OPC_USER 角色,并分配了 USER 角色")
count += 1
print(f"清洗完成,共处理了 {count} 个异常用户的角色。")
if __name__ == '__main__':
init_admin()
clean_opc_roles()

5
commit_message.txt Normal file
View File

@@ -0,0 +1,5 @@
开发了多角色登录与鉴权接口实现了普通用户、企业和管理员的登录分流并支持Token验证。
开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。
开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。
开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。
配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。

0
core/__init__.py Normal file
View File

16
core/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()

108
core/settings.py Normal file
View File

@@ -0,0 +1,108 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Load .env file
env_file = BASE_DIR / '.env'
if env_file.exists():
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
key, val = line.split('=', 1)
os.environ[key.strip()] = val.strip().strip("'").strip('"')
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-opc-community-platform')
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'users',
'tasks',
'reservations',
'opc_cert',
'system',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'core.urls'
CORS_ALLOW_ALL_ORIGINS = os.environ.get('CORS_ALLOW_ALL_ORIGINS', 'True') == 'True'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'core.wsgi.application'
DATABASES = {
'default': {
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.postgresql'),
'NAME': os.environ.get('DB_NAME', 'opc_db'),
'USER': os.environ.get('DB_USER', 'opc_user'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'opc_password'),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'users.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,
}
# MinIO Configuration
MINIO_ENDPOINT = os.environ.get('MINIO_ENDPOINT', 'localhost:9000')
MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', 'minioadmin')
MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', 'minioadmin')
MINIO_BUCKET_NAME = os.environ.get('MINIO_BUCKET_NAME', 'opc-assets')
MINIO_SECURE = os.environ.get('MINIO_SECURE', 'False') == 'True'
MINIO_PUBLIC_URL = os.environ.get('MINIO_PUBLIC_URL', 'http://127.0.0.1:9000')

18
core/urls.py Normal file
View File

@@ -0,0 +1,18 @@
from django.contrib import admin
from django.urls import path, include
from django.http import JsonResponse
def health_check(request):
return JsonResponse({"status": "ok", "message": "OPC Community Platform API is running"})
urlpatterns = [
path('admin/', admin.site.urls),
path('api/health/', health_check),
# API v1 routes
path('api/v1/', include('users.urls')),
path('api/v1/', include('tasks.urls')),
path('api/v1/', include('reservations.urls')),
path('api/v1/', include('opc_cert.urls')),
path('api/v1/', include('system.urls')),
]

16
core/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

0
opc_cert/__init__.py Normal file
View File

3
opc_cert/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
opc_cert/apps.py Normal file
View File

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

View File

@@ -0,0 +1,40 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='OpcCertification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('real_name', models.CharField(max_length=64)),
('id_card', models.TextField(blank=True, null=True)),
('skills', models.JSONField(default=list)),
('experience', models.TextField(blank=True, null=True)),
('resume_url', models.CharField(blank=True, max_length=512, null=True)),
('attachments', models.JSONField(default=list)),
('status', models.CharField(choices=[('PENDING', '待审核'), ('APPROVED', '已通过'), ('REJECTED', '已驳回')], default='PENDING', max_length=16)),
('reject_reason', models.TextField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('rating', models.SmallIntegerField(blank=True, null=True)),
('rating_tags', models.JSONField(default=list)),
('rating_note', models.TextField(blank=True, null=True)),
('rated_at', models.DateTimeField(blank=True, null=True)),
('version', models.SmallIntegerField(default=1)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'opc_certifications',
},
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('opc_cert', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='opccertification',
name='rated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rated_certifications', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='opccertification',
name='reviewer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_certifications', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='opccertification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opc_certifications', to=settings.AUTH_USER_MODEL),
),
]

View File

32
opc_cert/models.py Normal file
View File

@@ -0,0 +1,32 @@
import uuid
from django.db import models
class CertStatus(models.TextChoices):
PENDING = 'PENDING', '待审核'
APPROVED = 'APPROVED', '已通过'
REJECTED = 'REJECTED', '已驳回'
class OpcCertification(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='opc_certifications')
real_name = models.CharField(max_length=64)
id_card = models.TextField(null=True, blank=True)
skills = models.JSONField(default=list)
experience = models.TextField(null=True, blank=True)
resume_url = models.CharField(max_length=512, null=True, blank=True)
attachments = models.JSONField(default=list)
status = models.CharField(max_length=16, choices=CertStatus.choices, default=CertStatus.PENDING)
reject_reason = models.TextField(null=True, blank=True)
reviewer = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_certifications')
reviewed_at = models.DateTimeField(null=True, blank=True)
rating = models.SmallIntegerField(null=True, blank=True)
rating_tags = models.JSONField(default=list)
rating_note = models.TextField(null=True, blank=True)
rated_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='rated_certifications')
rated_at = models.DateTimeField(null=True, blank=True)
version = models.SmallIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'opc_certifications'

21
opc_cert/serializers.py Normal file
View File

@@ -0,0 +1,21 @@
from rest_framework import serializers
from .models import OpcCertification
class OpcCertificationSerializer(serializers.ModelSerializer):
user_detail = serializers.SerializerMethodField()
class Meta:
model = OpcCertification
fields = '__all__'
read_only_fields = ['id', 'user', 'status', 'reviewer', 'reviewed_at', 'rating', 'rating_tags', 'rating_note', 'rated_by', 'rated_at', 'created_at', 'updated_at']
def get_user_detail(self, obj):
user = obj.user
return {
'id': user.id,
'username': user.username,
'nickname': user.nickname,
'avatar_url': user.avatar_url,
'email': user.email,
'phone': user.phone
}

3
opc_cert/tests.py Normal file
View File

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

10
opc_cert/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import OpcCertificationViewSet
router = DefaultRouter()
router.register('certifications', OpcCertificationViewSet)
urlpatterns = [
path('', include(router.urls)),
]

91
opc_cert/views.py Normal file
View File

@@ -0,0 +1,91 @@
from rest_framework import viewsets, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from .models import OpcCertification, CertStatus
from .serializers import OpcCertificationSerializer
from django.utils import timezone
class OpcCertificationViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: OPCOne Person Company专家认证申请接口视图。提供普通用户提交资质申请、管理员审核(approve)、驳回(reject)等核心流程。认证通过后自动授予OPC_USER角色。
"""
queryset = OpcCertification.objects.all()
serializer_class = OpcCertificationSerializer
permission_classes = [permissions.IsAuthenticated]
required_permissions = {
'GET': 'api:certs:read',
'POST': 'api:certs:write',
'PUT': 'api:certs:write',
'PATCH': 'api:certs:write',
'DELETE': 'api:certs:delete'
}
def get_permissions(self):
if self.action in ['create', 'destroy']:
return [permissions.IsAuthenticated()]
if self.action in ['list', 'retrieve']:
# Either it's their own, or they have admin permission
return [permissions.IsAuthenticated()]
if self.action in ['approve', 'reject']:
from users.permissions import HasAPIPermission
return [HasAPIPermission()]
return super().get_permissions()
def get_queryset(self):
user = self.request.user
from users.permissions import HasAPIPermission
# If user has the certs read permission or is superuser, show all
has_admin_perm = False
if user.is_superuser:
has_admin_perm = True
else:
from users.models import RolePermission
perms = set(RolePermission.objects.filter(role__userrole__user=user).values_list('permission__code', flat=True))
if 'api:certs:read' in perms or '*' in perms:
has_admin_perm = True
if has_admin_perm:
return OpcCertification.objects.all().order_by('-created_at')
return OpcCertification.objects.filter(user=user).order_by('-created_at')
def perform_create(self, serializer):
# 提交新申请前,只删除该用户的 PENDING 或 REJECTED 记录,保留已有的 APPROVED 记录
OpcCertification.objects.filter(user=self.request.user).exclude(status=CertStatus.APPROVED).delete()
serializer.save(user=self.request.user, status=CertStatus.PENDING)
@action(detail=True, methods=['post'])
def approve(self, request, pk=None):
cert = self.get_object()
if cert.status != CertStatus.PENDING:
return Response({'detail': '状态不允许该操作'}, status=status.HTTP_400_BAD_REQUEST)
cert.status = CertStatus.APPROVED
cert.reviewer = request.user
cert.reviewed_at = timezone.now()
cert.save()
# 管理员通过后,删除该用户所有的旧认证记录(包括旧的 APPROVED 记录),确保数据库只有最新的一条
OpcCertification.objects.filter(user=cert.user).exclude(id=cert.id).delete()
# 自动追加 OPC_USER 角色
from users.models import Role, UserRole
role_opc, _ = Role.objects.get_or_create(code='OPC_USER', defaults={'name': '认证专家', 'is_system': True})
UserRole.objects.get_or_create(user=cert.user, role=role_opc, defaults={'granted_by': request.user})
return Response({'status': '认证已通过,用户角色已更新'})
@action(detail=True, methods=['post'])
def reject(self, request, pk=None):
cert = self.get_object()
reason = request.data.get('reject_reason', '不符合要求')
cert.status = CertStatus.REJECTED
cert.reject_reason = reason
cert.reviewer = request.user
cert.reviewed_at = timezone.now()
cert.save()
return Response({'status': '认证已驳回'})

0
reservations/__init__.py Normal file
View File

3
reservations/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
reservations/apps.py Normal file
View File

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

View File

@@ -0,0 +1,66 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ReservationResource',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128)),
('type', models.CharField(choices=[('ACCESS_CONTROL', '门禁通行'), ('MEETING_ROOM', '会议室')], max_length=32)),
('description', models.TextField(blank=True, null=True)),
('capacity', models.IntegerField(blank=True, null=True)),
('location', models.CharField(blank=True, max_length=256, null=True)),
('cover_url', models.CharField(blank=True, max_length=512, null=True)),
('price_per_unit', models.DecimalField(decimal_places=2, default=0.0, max_digits=8)),
('price_unit', models.CharField(default='', max_length=32)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'reservation_resources',
},
),
migrations.CreateModel(
name='ReservationOrder',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('order_no', models.CharField(max_length=64, unique=True)),
('start_time', models.DateTimeField()),
('end_time', models.DateTimeField()),
('quantity', models.IntegerField(default=1)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=8)),
('total_amount', models.DecimalField(decimal_places=2, max_digits=10)),
('status', models.CharField(choices=[('PENDING_PAY', '待支付'), ('PAID', '已支付'), ('USED', '已使用'), ('REFUNDED', '已退款'), ('CANCELLED', '已取消')], default='PENDING_PAY', max_length=32)),
('wx_prepay_id', models.CharField(blank=True, max_length=128, null=True)),
('wx_transaction_id', models.CharField(blank=True, max_length=128, null=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('refund_reason', models.TextField(blank=True, null=True)),
('refund_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('wx_refund_id', models.CharField(blank=True, max_length=128, null=True)),
('refunded_at', models.DateTimeField(blank=True, null=True)),
('qr_code_url', models.CharField(blank=True, max_length=512, null=True)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('remark', models.TextField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='reservations.reservationresource')),
],
options={
'db_table': 'reservation_orders',
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('reservations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='reservationorder',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservation_orders', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='reservationorder',
name='verified_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verified_orders', to=settings.AUTH_USER_MODEL),
),
]

View File

59
reservations/models.py Normal file
View File

@@ -0,0 +1,59 @@
import uuid
from django.db import models
class ResourceType(models.TextChoices):
ACCESS_CONTROL = 'ACCESS_CONTROL', '门禁通行'
MEETING_ROOM = 'MEETING_ROOM', '会议室'
class OrderStatus(models.TextChoices):
PENDING_PAY = 'PENDING_PAY', '待支付'
PAID = 'PAID', '已支付'
USED = 'USED', '已使用'
REFUNDED = 'REFUNDED', '已退款'
CANCELLED = 'CANCELLED', '已取消'
class ReservationResource(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=128)
type = models.CharField(max_length=32, choices=ResourceType.choices)
description = models.TextField(null=True, blank=True)
capacity = models.IntegerField(null=True, blank=True)
location = models.CharField(max_length=256, null=True, blank=True)
cover_url = models.CharField(max_length=512, null=True, blank=True)
price_per_unit = models.DecimalField(max_digits=8, decimal_places=2, default=0.00)
price_unit = models.CharField(max_length=32, default='')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'reservation_resources'
class ReservationOrder(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
order_no = models.CharField(max_length=64, unique=True)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='reservation_orders')
resource = models.ForeignKey(ReservationResource, on_delete=models.CASCADE, related_name='orders')
start_time = models.DateTimeField()
end_time = models.DateTimeField()
quantity = models.IntegerField(default=1)
unit_price = models.DecimalField(max_digits=8, decimal_places=2)
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=32, choices=OrderStatus.choices, default=OrderStatus.PENDING_PAY)
wx_prepay_id = models.CharField(max_length=128, null=True, blank=True)
wx_transaction_id = models.CharField(max_length=128, null=True, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
refund_reason = models.TextField(null=True, blank=True)
refund_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
wx_refund_id = models.CharField(max_length=128, null=True, blank=True)
refunded_at = models.DateTimeField(null=True, blank=True)
qr_code_url = models.CharField(max_length=512, null=True, blank=True)
verified_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='verified_orders')
verified_at = models.DateTimeField(null=True, blank=True)
remark = models.TextField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'reservation_orders'

View File

@@ -0,0 +1,14 @@
from rest_framework import serializers
from .models import ReservationResource, ReservationOrder
class ReservationResourceSerializer(serializers.ModelSerializer):
class Meta:
model = ReservationResource
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']
class ReservationOrderSerializer(serializers.ModelSerializer):
class Meta:
model = ReservationOrder
fields = '__all__'
read_only_fields = ['id', 'user', 'order_no', 'status', 'paid_at', 'refund_amount', 'wx_refund_id', 'refunded_at', 'qr_code_url', 'verified_by', 'verified_at', 'created_at', 'updated_at']

3
reservations/tests.py Normal file
View File

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

11
reservations/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ReservationResourceViewSet, ReservationOrderViewSet
router = DefaultRouter()
router.register('resources', ReservationResourceViewSet)
router.register('orders', ReservationOrderViewSet)
urlpatterns = [
path('', include(router.urls)),
]

61
reservations/views.py Normal file
View File

@@ -0,0 +1,61 @@
from rest_framework import viewsets, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from .models import ReservationResource, ReservationOrder, OrderStatus
from .serializers import ReservationResourceSerializer, ReservationOrderSerializer
import uuid
import datetime
class ReservationResourceViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 预约资源(工位、会议室等物理资源)接口视图。用于资源空间的增删改查及上下架管理,控制开放时段。
"""
queryset = ReservationResource.objects.filter(is_active=True)
serializer_class = ReservationResourceSerializer
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
class ReservationOrderViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 预约订单接口视图。用于用户发起资源预约、取消预约,以及后台管理员对订单进行状态流转和支付确认管理。
"""
queryset = ReservationOrder.objects.filter(is_deleted=False)
serializer_class = ReservationOrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ReservationOrder.objects.filter(user=self.request.user, is_deleted=False)
def perform_create(self, serializer):
resource = serializer.validated_data['resource']
quantity = serializer.validated_data.get('quantity', 1)
total_amount = resource.price_per_unit * quantity
# 简单生成订单号
order_no = f"RES{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}{str(uuid.uuid4())[:4].upper()}"
serializer.save(
user=self.request.user,
order_no=order_no,
unit_price=resource.price_per_unit,
total_amount=total_amount,
status=OrderStatus.PENDING_PAY
)
@action(detail=True, methods=['post'])
def pay(self, request, pk=None):
order = self.get_object()
if order.status != OrderStatus.PENDING_PAY:
return Response({'detail': '订单状态不允许支付'}, status=status.HTTP_400_BAD_REQUEST)
order.status = OrderStatus.PAID
order.paid_at = datetime.datetime.now()
order.qr_code_url = f"https://api.opc-platform.com/qr/{order.order_no}"
order.save()
return Response({'status': '支付成功', 'qr_code_url': order.qr_code_url})

59
seed_permissions.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from users.models import Permission, Role, RolePermission
def seed():
print("--- 正在初始化权限节点数据 ---")
# 账号权限组 (Account)
account_group, _ = Permission.objects.get_or_create(
code="menu:account", defaults={"name": "账号与用户权限组", "type": "MENU"}
)
# 子节点
users_menu, _ = Permission.objects.get_or_create(
code="menu:account:users", defaults={"name": "用户管理菜单", "type": "MENU", "parent": account_group, "path": "/admin/users"}
)
roles_menu, _ = Permission.objects.get_or_create(
code="menu:account:roles", defaults={"name": "角色管理菜单", "type": "MENU", "parent": account_group, "path": "/admin/roles"}
)
perms_menu, _ = Permission.objects.get_or_create(
code="menu:account:permissions", defaults={"name": "权限树管理菜单", "type": "MENU", "parent": account_group, "path": "/admin/permissions"}
)
# API 权限
Permission.objects.get_or_create(code="api:users:read", defaults={"name": "查询用户列表", "type": "API", "parent": users_menu, "method": "GET", "path": "/api/v1/users/"})
Permission.objects.get_or_create(code="api:users:write", defaults={"name": "新增/编辑用户", "type": "API", "parent": users_menu, "method": "POST", "path": "/api/v1/users/"})
Permission.objects.get_or_create(code="api:users:delete", defaults={"name": "删除用户", "type": "API", "parent": users_menu, "method": "DELETE", "path": "/api/v1/users/{id}/"})
# 业务权限组 (Business)
biz_group, _ = Permission.objects.get_or_create(
code="menu:business", defaults={"name": "业务管理权限组", "type": "MENU"}
)
tasks_menu, _ = Permission.objects.get_or_create(
code="menu:business:tasks", defaults={"name": "全站任务管理", "type": "MENU", "parent": biz_group, "path": "/admin/tasks"}
)
cert_menu, _ = Permission.objects.get_or_create(
code="menu:business:certs", defaults={"name": "资质审核管理", "type": "MENU", "parent": biz_group, "path": "/admin/certifications"}
)
ent_menu, _ = Permission.objects.get_or_create(
code="menu:business:enterprises", defaults={"name": "入驻企业管理", "type": "MENU", "parent": biz_group, "path": "/admin/enterprises"}
)
print("权限节点初始化完成!")
# 为 ADMIN 角色自动分配所有权限
admin_role = Role.objects.filter(code='ADMIN').first()
if admin_role:
print("正在为 ADMIN 角色自动挂载所有权限...")
all_perms = Permission.objects.all()
for p in all_perms:
RolePermission.objects.get_or_create(role=admin_role, permission=p)
print("ADMIN 角色权限挂载完毕!")
if __name__ == '__main__':
seed()

86
seed_phase5.py Normal file
View File

@@ -0,0 +1,86 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from system.models import Announcement, AiModel
from reservations.models import ReservationResource, ResourceType
from users.models import User
def seed_data():
# Delete old
Announcement.objects.all().delete()
AiModel.objects.all().delete()
ReservationResource.objects.all().delete()
admin_user = User.objects.filter(is_superuser=True).first()
if not admin_user:
print("No admin user found. Cannot seed announcements.")
return
# Seed Announcements
Announcement.objects.create(
title='CorpScale 2.0 品牌升级:助力一人公司跨越式增长',
content='全新的 OPC 平台正式上线!为自由职业者提供更强大的背书与任务撮合能力。',
publisher=admin_user,
is_published=True
)
Announcement.objects.create(
title='关于新增 4D 点云标注、多模态意图识别等高端任务类型的通知',
content='针对自动驾驶和前沿 AI 研发,平台已引入高价值数据标注任务,欢迎拥有相关资质的专家承接。',
publisher=admin_user,
is_published=True
)
Announcement.objects.create(
title='开发者生态支持计划:首批 OPC 认证专家将获得算力补贴',
content='首批入驻的专家除了获得免排队特权外,还将每月获得 500,000 Tokens 的模型调用配额。',
publisher=admin_user,
is_published=True
)
# Seed AI Models
AiModel.objects.create(
name='CS-Llama-Instruct-7B',
provider='CorpScale',
description='基于Llama架构深度优化的行业指令微调模型适用于客服机器人与知识库问答系统支持超长上下文。',
price_per_token=0.0001
)
AiModel.objects.create(
name='VisionX-Segmentation-V3',
provider='CorpScale',
description='高精度图像语义分割模型支持256个语义类别专为自动驾驶避障和医疗辅助诊断分析设计。',
price_per_token=0.0005
)
AiModel.objects.create(
name='AutoTrans-Speech-Turbo',
provider='CorpScale',
description='低延迟实时语音转文字模型支持极速多国语言翻译及50多种中国方言精准识别。',
price_per_token=0.0002
)
# Seed Reservation Resources
ReservationResource.objects.create(
name='A栋101 大型全景会议室',
type=ResourceType.MEETING_ROOM,
description='支持50人规模的顶级会议配备8K投影与哈曼卡顿音响系统。',
capacity=50,
location='创新园区 A栋 1层',
price_per_unit=150.00,
price_unit='小时'
)
ReservationResource.objects.create(
name='园区通用智能门禁白名单',
type=ResourceType.ACCESS_CONTROL,
description='开通后刷脸即可进入园区各公共办公区及休息区。',
capacity=1,
location='全园区通用',
price_per_unit=20.00,
price_unit=''
)
print("Phase 5 Seed data generated successfully!")
if __name__ == '__main__':
seed_data()

103
seed_roles_pages.py Normal file
View File

@@ -0,0 +1,103 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from users.models import Permission, Role, RolePermission
def seed():
print("--- 正在初始化预设角色与页面权限数据 ---")
# 1. 预设角色
roles_data = [
{"code": "ADMIN", "name": "管理员", "description": "系统超级管理员", "is_system": True},
{"code": "USER", "name": "普通用户", "description": "平台普通注册用户", "is_system": True},
{"code": "ENTERPRISE", "name": "企业用户", "description": "已入驻企业账号及团队成员", "is_system": True},
{"code": "OPC_USER", "name": "OPC 专家", "description": "已通过 OPC 认证的专家", "is_system": True},
]
for rd in roles_data:
Role.objects.update_or_create(code=rd["code"], defaults=rd)
# 2. 预设页面菜单节点
admin_menus = [
"menu:business:tasks",
"menu:business:tasks:publish",
"menu:business:enterprises",
"menu:account:users",
"menu:business:certs",
"menu:business:enterprise_certs",
"menu:account:user_manage",
"menu:account:roles",
"menu:settings:announcements",
"menu:settings:skills",
"menu:business:models",
"menu:business:screen",
]
ent_menus = [
"menu:ent:dashboard",
"menu:ent:tasks",
"menu:ent:tasks:create",
"menu:ent:team",
"menu:ent:opc_users",
"menu:ent:invitations",
"menu:ent:verification",
"menu:ent:profile",
"menu:ent:settings",
]
user_menus = [
"menu:user:dashboard",
"menu:user:tasks",
"menu:user:my_tasks",
"menu:user:invitations",
"menu:user:announcements",
"menu:user:certification",
"menu:user:profile",
"menu:user:settings",
]
all_menus = admin_menus + ent_menus + user_menus
# Create root menus
for menu_code in all_menus:
parts = menu_code.split(":")
for i in range(1, len(parts) + 1):
parent_code = ":".join(parts[:i])
Permission.objects.get_or_create(
code=parent_code,
defaults={"name": parent_code, "type": "MENU"}
)
# 3. 分配权限
admin_role = Role.objects.get(code="ADMIN")
ent_role = Role.objects.get(code="ENTERPRISE")
user_role = Role.objects.get(code="USER")
opc_role = Role.objects.get(code="OPC_USER")
# Clear old menu permissions to reset cleanly
RolePermission.objects.filter(permission__type='MENU').delete()
def assign_perms(role, menu_list):
perms_to_add = []
for menu_code in menu_list:
parts = menu_code.split(":")
for i in range(1, len(parts) + 1):
parent_code = ":".join(parts[:i])
perms_to_add.append(parent_code)
perms = Permission.objects.filter(code__in=perms_to_add)
for p in perms:
RolePermission.objects.get_or_create(role=role, permission=p)
assign_perms(admin_role, admin_menus)
assign_perms(ent_role, ent_menus)
assign_perms(user_role, user_menus)
assign_perms(opc_role, user_menus)
print("角色与页面权限预设完成!")
if __name__ == '__main__':
seed()

55
seed_task_admin.py Normal file
View File

@@ -0,0 +1,55 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from django.contrib.auth import get_user_model
from users.models import Role, Permission, RolePermission, UserRole
User = get_user_model()
def seed():
print("--- 开始配置测试角色与账号 ---")
# 1. 创建任务管理员角色
task_role, _ = Role.objects.get_or_create(
code="TASK_ADMIN",
defaults={
"name": "任务管理员",
"description": "仅能管理和查看全站任务的受限管理角色"
}
)
print("角色 TASK_ADMIN 已准备")
# 2. 为该角色分配 '全站任务管理' 权限
task_perm = Permission.objects.filter(code="menu:business:tasks").first()
if task_perm:
RolePermission.objects.get_or_create(role=task_role, permission=task_perm)
print("已分配 menu:business:tasks 权限给该角色")
else:
print("错误:未找到 menu:business:tasks 权限")
# 3. 创建测试账号
test_user = User.objects.filter(username="task_admin").first()
if not test_user:
test_user = User.objects.create_user(
username="task_admin",
password="123456",
nickname="任务审核专员",
is_staff=True # 必须为 is_staff 才能访问管理端 /admin/login
)
print("测试账号 task_admin/123456 创建成功")
else:
test_user.set_password("123456")
test_user.is_staff = True
test_user.save()
print("测试账号已存在,密码已重置为 123456")
# 4. 绑定角色
UserRole.objects.get_or_create(user=test_user, role=task_role)
print("已将 TASK_ADMIN 角色绑定给测试账号 task_admin")
print("--- 配置完成 ---")
if __name__ == '__main__':
seed()

0
system/__init__.py Normal file
View File

3
system/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
system/apps.py Normal file
View File

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

View File

@@ -0,0 +1,108 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=256)),
('content', models.TextField()),
('cover_url', models.CharField(blank=True, max_length=512, null=True)),
('is_published', models.BooleanField(default=False)),
('published_at', models.DateTimeField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'announcements',
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=128)),
('resource', models.CharField(blank=True, max_length=128, null=True)),
('resource_id', models.CharField(blank=True, max_length=128, null=True)),
('detail', models.JSONField(default=dict)),
('ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'audit_logs',
},
),
migrations.CreateModel(
name='ModelToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('model_id', models.CharField(max_length=128)),
('model_name', models.CharField(blank=True, max_length=256, null=True)),
('token_value', models.TextField()),
('expires_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'model_tokens',
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('type', models.CharField(choices=[('SYSTEM', '系统通知'), ('CERTIFICATION', '认证通知'), ('TASK', '任务通知'), ('RESERVATION', '预约通知')], max_length=32)),
('title', models.CharField(max_length=256)),
('content', models.TextField()),
('related_id', models.CharField(blank=True, max_length=128, null=True)),
('is_read', models.BooleanField(default=False)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'notifications',
},
),
migrations.CreateModel(
name='SmsVerification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone', models.CharField(max_length=20)),
('scene', models.CharField(choices=[('REGISTER', '注册'), ('LOGIN', '登录'), ('RESET_PASSWORD', '重置密码')], max_length=32)),
('code', models.CharField(max_length=8)),
('is_used', models.BooleanField(default=False)),
('expired_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
('ip', models.GenericIPAddressField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'sms_verifications',
},
),
migrations.CreateModel(
name='SystemConfig',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('config_key', models.CharField(max_length=128, unique=True)),
('config_value', models.JSONField()),
('description', models.TextField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'system_configs',
},
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('system', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='systemconfig',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_configs', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='notification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='modeltoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='model_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='auditlog',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='announcement',
name='publisher',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_announcements', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.30 on 2026-04-25 13:10
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('system', '0002_initial'),
]
operations = [
migrations.CreateModel(
name='AiModel',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128)),
('provider', models.CharField(max_length=128)),
('description', models.TextField(blank=True, null=True)),
('icon', models.CharField(blank=True, max_length=64, null=True)),
('price_per_token', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'ai_models',
},
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.30 on 2026-04-27 16:36
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('system', '0003_aimodel'),
]
operations = [
migrations.CreateModel(
name='Skill',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=64, unique=True)),
('category', models.CharField(blank=True, help_text='分类: 技术/设计/内容/数据 等', max_length=64, null=True)),
('sort_order', models.IntegerField(default=0, help_text='排序权重, 越小越靠前')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'skills',
'ordering': ['sort_order', 'name'],
},
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.30 on 2026-04-27 17:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0004_skill'),
]
operations = [
migrations.AlterModelOptions(
name='announcement',
options={'ordering': ['-is_recommended', '-recommend_priority', '-created_at']},
),
migrations.AddField(
model_name='announcement',
name='is_recommended',
field=models.BooleanField(default=False, help_text='推荐/置顶'),
),
migrations.AddField(
model_name='announcement',
name='recommend_priority',
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-27 17:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0005_alter_announcement_options_and_more'),
]
operations = [
migrations.AddField(
model_name='announcement',
name='target_audience',
field=models.CharField(choices=[('ALL', '全平台'), ('USER', '用户端'), ('ENTERPRISE', '企业端')], default='ALL', help_text='公告受众', max_length=16),
),
]

View File

78
system/minio_utils.py Normal file
View File

@@ -0,0 +1,78 @@
from minio import Minio
from django.conf import settings
import uuid
import os
import json
import logging
logger = logging.getLogger(__name__)
class MinioClient:
def __init__(self):
self._client = None
self.bucket_name = getattr(settings, 'MINIO_BUCKET_NAME', 'opc-assets')
self._initialized = False
@property
def client(self):
if self._client is None:
self._client = Minio(
endpoint=getattr(settings, 'MINIO_ENDPOINT', 'localhost:9000'),
access_key=getattr(settings, 'MINIO_ACCESS_KEY', 'minioadmin'),
secret_key=getattr(settings, 'MINIO_SECRET_KEY', 'minioadmin'),
secure=getattr(settings, 'MINIO_SECURE', False)
)
if not self._initialized:
try:
self._ensure_bucket()
self._initialized = True
except Exception as e:
logger.warning(f'MinIO bucket init failed (will retry on next call): {e}')
return self._client
def _ensure_bucket(self):
if not self._client.bucket_exists(self.bucket_name):
self._client.make_bucket(self.bucket_name)
# Always update policy to ensure public access
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": [f"arn:aws:s3:::{self.bucket_name}"],
},
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"],
},
],
}
self._client.set_bucket_policy(self.bucket_name, json.dumps(policy))
def upload_file(self, file_obj, folder='general'):
# Generate a unique name
ext = os.path.splitext(file_obj.name)[1]
file_name = f"{folder}/{uuid.uuid4()}{ext}"
# Upload (this will trigger lazy init of the client)
self.client.put_object(
self.bucket_name,
file_name,
file_obj,
length=file_obj.size,
content_type=file_obj.content_type
)
# Return the public URL
base_url = getattr(settings, 'MINIO_PUBLIC_URL', 'http://127.0.0.1:9000')
return f"{base_url}/{self.bucket_name}/{file_name}"
# Lazy singleton — does NOT connect at import time
minio_client = MinioClient()

132
system/models.py Normal file
View File

@@ -0,0 +1,132 @@
import uuid
from django.db import models
class NotificationType(models.TextChoices):
SYSTEM = 'SYSTEM', '系统通知'
CERTIFICATION = 'CERTIFICATION', '认证通知'
TASK = 'TASK', '任务通知'
RESERVATION = 'RESERVATION', '预约通知'
class SmsScene(models.TextChoices):
REGISTER = 'REGISTER', '注册'
LOGIN = 'LOGIN', '登录'
RESET_PASSWORD = 'RESET_PASSWORD', '重置密码'
class AudienceChoices(models.TextChoices):
ALL = 'ALL', '全平台'
USER = 'USER', '用户端'
ENTERPRISE = 'ENTERPRISE', '企业端'
class Announcement(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=256)
content = models.TextField()
cover_url = models.CharField(max_length=512, null=True, blank=True)
publisher = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='published_announcements')
target_audience = models.CharField(max_length=16, choices=AudienceChoices.choices, default=AudienceChoices.ALL, help_text='公告受众')
is_published = models.BooleanField(default=False)
published_at = models.DateTimeField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_recommended = models.BooleanField(default=False, help_text='推荐/置顶')
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'announcements'
ordering = ['-is_recommended', '-recommend_priority', '-created_at']
class ModelToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='model_tokens')
model_id = models.CharField(max_length=128)
model_name = models.CharField(max_length=256, null=True, blank=True)
token_value = models.TextField()
expires_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'model_tokens'
class SmsVerification(models.Model):
phone = models.CharField(max_length=20)
scene = models.CharField(max_length=32, choices=SmsScene.choices)
code = models.CharField(max_length=8)
is_used = models.BooleanField(default=False)
expired_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
ip = models.GenericIPAddressField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'sms_verifications'
class AuditLog(models.Model):
user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='audit_logs')
action = models.CharField(max_length=128)
resource = models.CharField(max_length=128, null=True, blank=True)
resource_id = models.CharField(max_length=128, null=True, blank=True)
detail = models.JSONField(default=dict)
ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'audit_logs'
class Notification(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='notifications')
type = models.CharField(max_length=32, choices=NotificationType.choices)
title = models.CharField(max_length=256)
content = models.TextField()
related_id = models.CharField(max_length=128, null=True, blank=True)
is_read = models.BooleanField(default=False)
is_deleted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'notifications'
class SystemConfig(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
config_key = models.CharField(max_length=128, unique=True)
config_value = models.JSONField()
description = models.TextField(null=True, blank=True)
updated_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='updated_configs')
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'system_configs'
class AiModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=128)
provider = models.CharField(max_length=128)
description = models.TextField(null=True, blank=True)
icon = models.CharField(max_length=64, null=True, blank=True)
price_per_token = models.DecimalField(max_digits=10, decimal_places=6, default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'ai_models'
class Skill(models.Model):
"""Admin-managed skill/expertise domain tags used across the platform."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=64, unique=True)
category = models.CharField(max_length=64, null=True, blank=True, help_text='分类: 技术/设计/内容/数据 等')
sort_order = models.IntegerField(default=0, help_text='排序权重, 越小越靠前')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'skills'
ordering = ['sort_order', 'name']
def __str__(self):
return self.name

36
system/serializers.py Normal file
View File

@@ -0,0 +1,36 @@
from rest_framework import serializers
from .models import Announcement, Notification, SystemConfig, AiModel, ModelToken, Skill
class AnnouncementSerializer(serializers.ModelSerializer):
class Meta:
model = Announcement
fields = '__all__'
class NotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = '__all__'
read_only_fields = ['id', 'user', 'created_at']
class SystemConfigSerializer(serializers.ModelSerializer):
class Meta:
model = SystemConfig
fields = '__all__'
class AiModelSerializer(serializers.ModelSerializer):
class Meta:
model = AiModel
fields = '__all__'
class ModelTokenSerializer(serializers.ModelSerializer):
model_name = serializers.ReadOnlyField(source='model.name')
class Meta:
model = ModelToken
fields = '__all__'
read_only_fields = ['id', 'user', 'token_value', 'created_at']
class SkillSerializer(serializers.ModelSerializer):
class Meta:
model = Skill
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']

3
system/tests.py Normal file
View File

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

17
system/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import AnnouncementViewSet, NotificationViewSet, SystemConfigViewSet, AiModelViewSet, ModelTokenViewSet, FileUploadView, SkillViewSet
router = DefaultRouter()
router.register('announcements', AnnouncementViewSet, basename='announcement')
router.register('notifications', NotificationViewSet, basename='notification')
router.register('configs', SystemConfigViewSet, basename='config')
router.register('models', AiModelViewSet, basename='model')
router.register('tokens', ModelTokenViewSet, basename='model-token')
router.register('skills', SkillViewSet, basename='skill')
urlpatterns = [
path('upload/', FileUploadView.as_view(), name='file_upload'),
path('', include(router.urls)),
]

152
system/views.py Normal file
View File

@@ -0,0 +1,152 @@
from rest_framework import viewsets, generics, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from .models import Announcement, Notification, SystemConfig, AiModel, ModelToken, Skill
from .serializers import AnnouncementSerializer, NotificationSerializer, SystemConfigSerializer, AiModelSerializer, ModelTokenSerializer, SkillSerializer
class AnnouncementViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 系统公告接口视图。管理员发布全局或定向通知公告,前端获取并展示给对应受众。
"""
queryset = Announcement.objects.filter(is_deleted=False)
serializer_class = AnnouncementSerializer
def get_queryset(self):
qs = Announcement.objects.filter(is_deleted=False).order_by('-is_recommended', '-recommend_priority', '-created_at')
audience = self.request.query_params.get('audience')
if audience:
qs = qs.filter(target_audience__in=[audience, 'ALL'])
if self.action == 'list' and not self.request.user.is_staff:
qs = qs.filter(is_published=True)
return qs
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy', 'toggle_recommend']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
@action(detail=True, methods=['post'])
def toggle_recommend(self, request, pk=None):
ann = self.get_object()
ann.is_recommended = request.data.get('is_recommended', not ann.is_recommended)
ann.recommend_priority = request.data.get('recommend_priority', ann.recommend_priority)
ann.save(update_fields=['is_recommended', 'recommend_priority'])
return Response({'is_recommended': ann.is_recommended, 'recommend_priority': ann.recommend_priority})
class NotificationViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 站内消息通知接口视图。用于系统内部产生的各类站内信、状态变更通知的拉取和未读/已读状态更新。
"""
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Notification.objects.filter(user=self.request.user, is_deleted=False)
@action(detail=True, methods=['post'])
def read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({'status': '已标记为已读'})
@action(detail=False, methods=['post'])
def read_all(self, request):
self.get_queryset().update(is_read=True)
return Response({'status': '全部标记为已读'})
class SystemConfigViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 系统全局动态配置接口视图。用于保存字典数据、开关配置等,避免硬编码参数。
"""
queryset = SystemConfig.objects.all()
serializer_class = SystemConfigSerializer
lookup_field = 'config_key'
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
class AiModelViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: AI模型配置接口视图。统一管理平台对接的各大AI平台如OpenAI、文心一言等的模型参数及端点。
"""
queryset = AiModel.objects.all()
serializer_class = AiModelSerializer
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.IsAuthenticatedOrReadOnly()]
def get_queryset(self):
qs = super().get_queryset()
if self.action == 'list' and not self.request.user.is_staff:
return qs.filter(is_active=True)
return qs
class ModelTokenViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 大模型Token/密钥接口视图。安全管理模型请求时使用的Token或API Key认证信息。
"""
serializer_class = ModelTokenSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ModelToken.objects.filter(user=self.request.user)
def perform_create(self, serializer):
# In a real app, this would call MoRouter or another provider
# For now, we generate a mock token
import uuid
mock_token = f"sk-opc-{uuid.uuid4().hex}"
serializer.save(user=self.request.user, token_value=mock_token)
class SkillViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 技能字典/用户标签接口视图。用于系统内的技能标签池管理,以供任务画像和用户画像进行匹配。
"""
"""Admin-managed skill/expertise tags. Read-only for non-admins."""
queryset = Skill.objects.all()
serializer_class = SkillSerializer
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
def get_queryset(self):
qs = super().get_queryset()
if self.action == 'list' and not (self.request.user.is_authenticated and self.request.user.is_staff):
return qs.filter(is_active=True)
return qs
from .minio_utils import minio_client
class FileUploadView(generics.GenericAPIView):
"""
@author: xujl
Api说明: 系统通用文件上传接口。对接MinIO或S3等对象存储处理前端传入的文件并返回URL外链。
"""
permission_classes = [permissions.AllowAny]
def post(self, request):
file_obj = request.FILES.get('file')
if not file_obj:
return Response({'detail': '没有上传文件'}, status=status.HTTP_400_BAD_REQUEST)
folder = request.data.get('folder', 'general')
try:
file_url = minio_client.upload_file(file_obj, folder=folder)
return Response({'url': file_url}, status=status.HTTP_201_CREATED)
except Exception as e:
return Response({'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

0
tasks/__init__.py Normal file
View File

3
tasks/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
tasks/apps.py Normal file
View File

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

View File

View File

View File

@@ -0,0 +1,141 @@
"""Seed 10 realistic tasks for the OPC platform."""
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta
from tasks.models import Task, TaskStatus
from users.models import User, Enterprise
import random
TASKS_DATA = [
{
"title": "企业官网全栈开发Vue3 + Django",
"description": "为某科技公司打造全新的企业官方网站。前端使用 Vue 3 + Element Plus 框架,后端使用 Django REST Framework需支持响应式布局、多语言切换以及 CMS 内容管理模块。要求有 SEO 优化方案和完善的部署文档。",
"skill_tags": ["Vue.js", "Django", "前端开发", "全栈"],
"budget_min": 25000, "budget_max": 45000,
"task_type": "全栈开发",
"deadline_days": 45,
},
{
"title": "移动端 App UI/UX 设计(电商类)",
"description": "为跨境电商平台设计一套完整的移动端 App UI/UX 方案,包括首页、商品详情、购物车、个人中心等核心页面。需要提供高保真 Figma 原型设计稿和设计规范文档,要求符合 iOS/Android 双端设计规范。",
"skill_tags": ["UI设计", "UX设计", "Figma", "电商"],
"budget_min": 15000, "budget_max": 30000,
"task_type": "设计",
"deadline_days": 30,
},
{
"title": "数据分析看板开发ECharts + Python",
"description": "基于已有的销售数据,开发一套实时数据分析看板,使用 ECharts 进行数据可视化呈现,后端使用 Python/Pandas 进行数据处理与聚合。需支持时间范围筛选、多维度交叉分析和 PDF 报表导出。",
"skill_tags": ["数据分析", "Python", "ECharts", "可视化"],
"budget_min": 18000, "budget_max": 35000,
"task_type": "数据分析",
"deadline_days": 25,
},
{
"title": "微信小程序开发(社区团购类)",
"description": "开发一款社区团购微信小程序,核心功能包括商品展示、拼团下单、分销推荐、订单管理和微信支付对接。后端使用云开发或已有 API 接口,需提供完整的源代码和部署文档。",
"skill_tags": ["微信小程序", "JavaScript", "云开发"],
"budget_min": 20000, "budget_max": 40000,
"task_type": "移动开发",
"deadline_days": 40,
},
{
"title": "AI 智能客服系统集成",
"description": "将大语言模型(如 DeepSeek / ChatGLM集成到企业已有的客服系统中实现智能工单分类、自动回复建议和知识库检索。需对接企业内部 API 和 CRM 系统,并提供模型微调方案。",
"skill_tags": ["AI", "NLP", "Python", "API集成"],
"budget_min": 35000, "budget_max": 60000,
"task_type": "AI开发",
"deadline_days": 50,
},
{
"title": "品牌视觉设计完整VI体系",
"description": "为创业公司提供完整的品牌视觉识别系统设计,包括 Logo、标准色、品牌字体、名片、信纸信封、品牌手册等全套 VI 设计。需提供 AI/PSD 源文件和 PDF 品牌手册。",
"skill_tags": ["品牌设计", "VI设计", "Logo设计", "平面设计"],
"budget_min": 12000, "budget_max": 25000,
"task_type": "设计",
"deadline_days": 20,
},
{
"title": "自动化测试框架搭建Web + API",
"description": "为已有的 SaaS 系统搭建完整的自动化测试框架,包括 Web 端 UI 自动化Playwright/Selenium和 API 自动化Pytest + Requests集成 CI/CD 流水线,输出测试报告和覆盖率统计。",
"skill_tags": ["自动化测试", "Python", "Playwright", "CI/CD"],
"budget_min": 16000, "budget_max": 28000,
"task_type": "测试",
"deadline_days": 30,
},
{
"title": "短视频剪辑与运营(抖音/快手)",
"description": "为餐饮品牌制作 20 条抖音/快手短视频内容,包括脚本撰写、实拍素材剪辑、字幕特效和 BGM 搭配。需提供周度内容日历和数据复盘报告,每条视频时长 15-60 秒。",
"skill_tags": ["视频剪辑", "短视频运营", "内容创作"],
"budget_min": 8000, "budget_max": 15000,
"task_type": "内容运营",
"deadline_days": 30,
},
{
"title": "企业内部管理系统二次开发",
"description": "基于现有的 Django Admin 管理后台进行二次开发,增加审批流引擎、消息通知模块、权限动态配置和数据导出功能。需配合前端 Vue 改造部分页面,提高运营效率。",
"skill_tags": ["Django", "Vue.js", "后端开发", "系统集成"],
"budget_min": 22000, "budget_max": 38000,
"task_type": "后端开发",
"deadline_days": 35,
},
{
"title": "跨境电商数据爬取与分析",
"description": "对 Amazon、eBay 等跨境电商平台的商品数据进行采集Scrapy/Selenium建立竞品价格监控和市场趋势分析模型。需要提供每日自动采集脚本和可视化分析 Dashboard。",
"skill_tags": ["爬虫", "数据分析", "Python", "Scrapy"],
"budget_min": 10000, "budget_max": 20000,
"task_type": "数据采集",
"deadline_days": 20,
},
]
class Command(BaseCommand):
help = 'Seed 10 realistic tasks for the OPC platform'
def handle(self, *args, **options):
# Find a publisher: prefer admin/staff user
publisher = User.objects.filter(is_staff=True, is_deleted=False).first()
if not publisher:
publisher = User.objects.filter(is_deleted=False).first()
if not publisher:
self.stderr.write('No users found. Please create at least one user first.')
return
# Find an enterprise if available
enterprise = Enterprise.objects.filter(is_deleted=False, status='VERIFIED').first()
created = 0
for data in TASKS_DATA:
deadline = timezone.now().date() + timedelta(days=data.pop('deadline_days'))
task, was_created = Task.objects.get_or_create(
title=data['title'],
defaults={
'publisher': publisher,
'enterprise': enterprise,
'description': data['description'],
'skill_tags': data['skill_tags'],
'budget_min': data['budget_min'],
'budget_max': data['budget_max'],
'task_type': data['task_type'],
'deadline': deadline,
'status': TaskStatus.OPEN,
'contact_name': publisher.nickname or publisher.username,
'contact_email': publisher.email,
}
)
if was_created:
created += 1
self.stdout.write(f' ✓ Created: {task.title}')
else:
self.stdout.write(f' - Exists: {task.title}')
# Mark 3 random tasks as recommended
recommended = Task.objects.filter(status=TaskStatus.OPEN, is_recommended=False).order_by('?')[:3]
for i, t in enumerate(recommended):
t.is_recommended = True
t.recommend_priority = 10 - i
t.save(update_fields=['is_recommended', 'recommend_priority'])
self.stdout.write(self.style.SUCCESS(f'\n✅ Seeded {created} new tasks, marked {len(recommended)} as recommended.'))

View File

@@ -0,0 +1,120 @@
"""Seed 10 certified OPC users with online avatar photos."""
from django.core.management.base import BaseCommand
from django.utils import timezone
from users.models import User, Role, UserRole
from opc_cert.models import OpcCertification
USERS_DATA = [
{
"username": "zhanglei", "nickname": "张磊", "email": "zhanglei@opc.cn",
"phone": "13800000001", "bio": "10年全栈开发经验精通 Vue/React/Django曾主导多个百万级用户平台的架构设计与交付。",
"location": "北京", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=zhanglei",
"skills": ["Vue.js", "React", "Django", "全栈开发"], "experience": "前阿里巴巴高级工程师,负责过多个核心业务系统开发。",
},
{
"username": "liuyanmei", "nickname": "刘艳梅", "email": "liuyanmei@opc.cn",
"phone": "13800000002", "bio": "UI/UX 设计师,擅长移动端与 Web 端产品设计,拥有丰富的 B 端/C 端设计经验。",
"location": "上海", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=liuyanmei",
"skills": ["UI设计", "UX设计", "Figma", "Sketch"], "experience": "前腾讯设计中心资深设计师,主导过微信小程序生态设计规范。",
},
{
"username": "wangqiang", "nickname": "王强", "email": "wangqiang@opc.cn",
"phone": "13800000003", "bio": "数据科学家,擅长机器学习模型训练和数据可视化,熟悉 TensorFlow 与 PyTorch 框架。",
"location": "深圳", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=wangqiang",
"skills": ["数据分析", "Python", "机器学习", "TensorFlow"], "experience": "前华为云 AI 团队算法工程师。",
},
{
"username": "chenxiaoling", "nickname": "陈小玲", "email": "chenxiaoling@opc.cn",
"phone": "13800000004", "bio": "微信小程序和 App 开发专家3年独立开发经验已上线 20+ 款小程序。",
"location": "杭州", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=chenxiaoling",
"skills": ["微信小程序", "React Native", "Flutter"], "experience": "独立开发者,专注移动端开发与创新应用。",
},
{
"username": "zhaowei", "nickname": "赵伟", "email": "zhaowei@opc.cn",
"phone": "13800000005", "bio": "DevOps 工程师,精通 Kubernetes、Docker 和 CI/CD 流水线搭建。",
"location": "成都", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=zhaowei",
"skills": ["Docker", "Kubernetes", "CI/CD", "Linux"], "experience": "前字节跳动基础架构部高级 SRE 工程师。",
},
{
"username": "sunmengmeng", "nickname": "孙萌萌", "email": "sunmengmeng@opc.cn",
"phone": "13800000006", "bio": "品牌设计师与视觉创意总监,擅长 VI 体系设计、品牌策略和视觉营销。",
"location": "广州", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=sunmengmeng",
"skills": ["品牌设计", "VI设计", "平面设计", "Logo设计"], "experience": "服务过 50+ 品牌客户,包含多家上市企业。",
},
{
"username": "huangdawei", "nickname": "黄大伟", "email": "huangdawei@opc.cn",
"phone": "13800000007", "bio": "资深爬虫与数据采集工程师,精通反爬策略和大规模数据治理方案。",
"location": "南京", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=huangdawei",
"skills": ["爬虫", "Scrapy", "数据采集", "Python"], "experience": "曾为多家电商和金融企业搭建数据采集平台。",
},
{
"username": "lixuemei", "nickname": "李雪梅", "email": "lixuemei@opc.cn",
"phone": "13800000008", "bio": "内容运营与短视频创作者,抖音/快手/小红书多平台达人孵化经验。",
"location": "武汉", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=lixuemei",
"skills": ["视频剪辑", "短视频运营", "内容创作", "新媒体"], "experience": "MCN 机构签约创作者,累计粉丝 500 万+。",
},
{
"username": "zhoujianhua", "nickname": "周建华", "email": "zhoujianhua@opc.cn",
"phone": "13800000009", "bio": "自动化测试与质量保障专家,精通 Selenium、Playwright 和接口测试方案。",
"location": "西安", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=zhoujianhua",
"skills": ["自动化测试", "Playwright", "Selenium", "性能测试"], "experience": "前网易质量保障团队负责人,推动过全链路测试体系建设。",
},
{
"username": "yangtianyu", "nickname": "杨天宇", "email": "yangtianyu@opc.cn",
"phone": "13800000010", "bio": "AI 应用开发工程师,专注 LLM 集成、RAG 架构和智能对话系统。",
"location": "重庆", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=yangtianyu",
"skills": ["AI", "NLP", "LLM", "Python"], "experience": "前百度智能对话团队核心开发者。",
},
]
class Command(BaseCommand):
help = '创建10个已认证的OPC专家用户'
def handle(self, *args, **options):
# Get or create the OPC_USER role
opc_role, _ = Role.objects.get_or_create(code='OPC_USER', defaults={'name': 'OPC认证专家', 'is_system': True})
created = 0
for data in USERS_DATA:
user, was_created = User.objects.get_or_create(
username=data['username'],
defaults={
'nickname': data['nickname'],
'email': data['email'],
'phone': data['phone'],
'bio': data['bio'],
'location': data['location'],
'avatar_url': data['avatar_url'],
'rating': 4.80,
'completed_tasks': 0,
'is_recommended': True if created < 3 else False,
'recommend_priority': 10 - created if created < 3 else 0,
}
)
if was_created:
user.set_password('opc123456')
user.save()
# Assign OPC_USER role
UserRole.objects.get_or_create(user=user, role=opc_role)
# Create approved certification
OpcCertification.objects.get_or_create(
user=user,
defaults={
'real_name': data['nickname'],
'skills': data['skills'],
'experience': data['experience'],
'status': 'APPROVED',
'reviewed_at': timezone.now(),
}
)
if was_created:
created += 1
self.stdout.write(f' ✓ 创建用户: {data["nickname"]} ({data["username"]})')
else:
self.stdout.write(f' - 已存在: {data["nickname"]} ({data["username"]})')
self.stdout.write(self.style.SUCCESS(f'\n✅ 已创建 {created} 个认证专家用户'))

View File

@@ -0,0 +1,61 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Task',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=256)),
('description', models.TextField()),
('skill_tags', models.JSONField(default=list)),
('budget_min', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('budget_max', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('currency', models.CharField(default='CNY', max_length=8)),
('deadline', models.DateField(blank=True, null=True)),
('task_type', models.CharField(blank=True, max_length=64, null=True)),
('attachments', models.JSONField(default=list)),
('status', models.CharField(choices=[('DRAFT', '草稿'), ('OPEN', '已发布'), ('IN_PROGRESS', '进行中'), ('COMPLETED', '已完成'), ('CANCELLED', '已取消')], default='DRAFT', max_length=16)),
('assigned_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('completion_note', models.TextField(blank=True, null=True)),
('deliverables', models.JSONField(default=list)),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('cancel_reason', models.TextField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'tasks',
},
),
migrations.CreateModel(
name='TaskApplication',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('cover_letter', models.TextField(blank=True, null=True)),
('expected_days', models.IntegerField(blank=True, null=True)),
('expected_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('attachments', models.JSONField(default=list)),
('status', models.CharField(choices=[('PENDING', '待审核'), ('APPROVED', '已通过'), ('REJECTED', '已拒绝'), ('WITHDRAWN', '已撤回')], default='PENDING', max_length=16)),
('reject_reason', models.TextField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'task_applications',
},
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0001_initial'),
('tasks', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='taskapplication',
name='applicant',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_applications', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='taskapplication',
name='reviewed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_applications', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='taskapplication',
name='task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='tasks.task'),
),
migrations.AddField(
model_name='task',
name='assignee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tasks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='task',
name='enterprise',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='users.enterprise'),
),
migrations.AddField(
model_name='task',
name='publisher',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_tasks', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='taskapplication',
unique_together={('task', 'applicant')},
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 4.2.30 on 2026-04-25 13:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tasks', '0002_initial'),
]
operations = [
migrations.CreateModel(
name='TaskInvitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('message', models.TextField(blank=True, null=True)),
('status', models.CharField(choices=[('PENDING', '待处理'), ('ACCEPTED', '已接受'), ('REJECTED', '已拒绝'), ('DISCUSSING', '洽谈中')], default='PENDING', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('expert', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_invitations', to=settings.AUTH_USER_MODEL)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='tasks.task')),
],
options={
'db_table': 'task_invitations',
},
),
]

View File

@@ -0,0 +1,41 @@
# Generated by Django 4.2.30 on 2026-04-26 14:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tasks', '0003_taskinvitation'),
]
operations = [
migrations.AddField(
model_name='task',
name='contact_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='task',
name='contact_name',
field=models.CharField(blank=True, max_length=64, null=True),
),
migrations.AddField(
model_name='task',
name='contact_phone',
field=models.CharField(blank=True, max_length=32, null=True),
),
migrations.AddField(
model_name='task',
name='contact_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='task',
name='contact_wechat',
field=models.CharField(blank=True, max_length=64, null=True),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 4.2.30 on 2026-04-26 14:48
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tasks', '0004_task_contact_email_task_contact_name_and_more'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(choices=[('DRAFT', '草稿'), ('OPEN', '已发布'), ('IN_PROGRESS', '进行中'), ('IN_REVIEW', '待验收'), ('COMPLETED', '已完成'), ('CANCELLED', '已取消')], default='DRAFT', max_length=16),
),
migrations.AlterUniqueTogether(
name='taskinvitation',
unique_together={('task', 'expert')},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.30 on 2026-04-26 15:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0005_alter_task_status_and_more'),
]
operations = [
migrations.AddField(
model_name='taskapplication',
name='completion_note',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='taskapplication',
name='deliverables',
field=models.JSONField(default=list),
),
migrations.AlterField(
model_name='taskapplication',
name='status',
field=models.CharField(choices=[('PENDING', '待审核'), ('APPROVED', '已录用'), ('REJECTED', '已拒绝'), ('WITHDRAWN', '已撤回'), ('DELIVERED', '交付待验收'), ('COMPLETED', '已完成')], default='PENDING', max_length=16),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 4.2.30 on 2026-04-26 15:34
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('tasks', '0006_taskapplication_completion_note_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='task',
options={'ordering': ['-created_at']},
),
migrations.AlterModelOptions(
name='taskapplication',
options={'ordering': ['-created_at']},
),
migrations.AlterModelOptions(
name='taskinvitation',
options={'ordering': ['-created_at']},
),
migrations.CreateModel(
name='DeliveryRecord',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('note', models.TextField()),
('files', models.JSONField(default=list)),
('status', models.CharField(choices=[('PENDING', '待验收'), ('APPROVED', '已验收'), ('REJECTED', '已驳回')], default='PENDING', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_records', to='tasks.taskapplication')),
],
options={
'db_table': 'delivery_records',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-26 15:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0007_alter_task_options_alter_taskapplication_options_and_more'),
]
operations = [
migrations.AddField(
model_name='taskinvitation',
name='messages',
field=models.JSONField(default=list),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-26 15:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0008_taskinvitation_messages'),
]
operations = [
migrations.AddField(
model_name='taskapplication',
name='negotiation_history',
field=models.JSONField(default=list),
),
]

View File

@@ -0,0 +1,47 @@
# Generated by Django 4.2.30 on 2026-04-27 16:36
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tasks', '0009_taskapplication_negotiation_history'),
]
operations = [
migrations.AlterModelOptions(
name='task',
options={'ordering': ['-is_recommended', '-recommend_priority', '-created_at']},
),
migrations.AddField(
model_name='task',
name='is_recommended',
field=models.BooleanField(default=False, help_text='管理员推荐'),
),
migrations.AddField(
model_name='task',
name='recommend_priority',
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
),
migrations.CreateModel(
name='TaskReview',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('score', models.IntegerField(default=5)),
('comment', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('reviewee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_reviews', to=settings.AUTH_USER_MODEL)),
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_reviews', to=settings.AUTH_USER_MODEL)),
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to='tasks.task')),
],
options={
'db_table': 'task_reviews',
'ordering': ['-created_at'],
},
),
]

View File

129
tasks/models.py Normal file
View File

@@ -0,0 +1,129 @@
import uuid
from django.db import models
class TaskStatus(models.TextChoices):
DRAFT = 'DRAFT', '草稿'
OPEN = 'OPEN', '已发布'
IN_PROGRESS = 'IN_PROGRESS', '进行中'
IN_REVIEW = 'IN_REVIEW', '待验收'
COMPLETED = 'COMPLETED', '已完成'
CANCELLED = 'CANCELLED', '已取消'
class ApplyStatus(models.TextChoices):
PENDING = 'PENDING', '待审核'
APPROVED = 'APPROVED', '已录用'
REJECTED = 'REJECTED', '已拒绝'
WITHDRAWN = 'WITHDRAWN', '已撤回'
DELIVERED = 'DELIVERED', '交付待验收'
COMPLETED = 'COMPLETED', '已完成'
class Task(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
publisher = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='published_tasks')
enterprise = models.ForeignKey('users.Enterprise', on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
title = models.CharField(max_length=256)
description = models.TextField()
skill_tags = models.JSONField(default=list)
budget_min = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
budget_max = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
currency = models.CharField(max_length=8, default='CNY')
deadline = models.DateField(null=True, blank=True)
task_type = models.CharField(max_length=64, null=True, blank=True)
attachments = models.JSONField(default=list)
contact_user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='+')
contact_name = models.CharField(max_length=64, null=True, blank=True)
contact_phone = models.CharField(max_length=32, null=True, blank=True)
contact_email = models.EmailField(null=True, blank=True)
contact_wechat = models.CharField(max_length=64, null=True, blank=True)
status = models.CharField(max_length=16, choices=TaskStatus.choices, default=TaskStatus.DRAFT)
assignee = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tasks')
assigned_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
completion_note = models.TextField(null=True, blank=True)
deliverables = models.JSONField(default=list)
cancelled_at = models.DateTimeField(null=True, blank=True)
cancel_reason = models.TextField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_recommended = models.BooleanField(default=False, help_text='管理员推荐')
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'tasks'
ordering = ['-is_recommended', '-recommend_priority', '-created_at']
class TaskApplication(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='applications')
applicant = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='task_applications')
cover_letter = models.TextField(null=True, blank=True)
expected_days = models.IntegerField(null=True, blank=True)
expected_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
attachments = models.JSONField(default=list)
negotiation_history = models.JSONField(default=list)
status = models.CharField(max_length=16, choices=ApplyStatus.choices, default=ApplyStatus.PENDING)
reject_reason = models.TextField(null=True, blank=True)
reviewed_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_applications')
reviewed_at = models.DateTimeField(null=True, blank=True)
deliverables = models.JSONField(default=list)
completion_note = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'task_applications'
unique_together = ('task', 'applicant')
ordering = ['-created_at']
class InvitationStatus(models.TextChoices):
PENDING = 'PENDING', '待处理'
ACCEPTED = 'ACCEPTED', '已接受'
REJECTED = 'REJECTED', '已拒绝'
DISCUSSING = 'DISCUSSING', '洽谈中'
class TaskInvitation(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='invitations')
expert = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='task_invitations')
message = models.TextField(null=True, blank=True)
messages = models.JSONField(default=list)
status = models.CharField(max_length=16, choices=InvitationStatus.choices, default=InvitationStatus.PENDING)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'task_invitations'
unique_together = ('task', 'expert')
ordering = ['-created_at']
class DeliveryStatus(models.TextChoices):
PENDING = 'PENDING', '待验收'
APPROVED = 'APPROVED', '已验收'
REJECTED = 'REJECTED', '已驳回'
class DeliveryRecord(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
application = models.ForeignKey(TaskApplication, on_delete=models.CASCADE, related_name='delivery_records')
note = models.TextField()
files = models.JSONField(default=list)
status = models.CharField(max_length=16, choices=DeliveryStatus.choices, default=DeliveryStatus.PENDING)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'delivery_records'
ordering = ['-created_at']
class TaskReview(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
task = models.OneToOneField(Task, on_delete=models.CASCADE, related_name='review')
reviewer = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='given_reviews')
reviewee = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='received_reviews')
score = models.IntegerField(default=5) # 1 to 5
comment = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'task_reviews'
ordering = ['-created_at']

69
tasks/serializers.py Normal file
View File

@@ -0,0 +1,69 @@
from rest_framework import serializers
from .models import Task, TaskApplication, TaskInvitation, DeliveryRecord
class DeliveryRecordSerializer(serializers.ModelSerializer):
class Meta:
model = DeliveryRecord
fields = '__all__'
class TaskApplicationSerializer(serializers.ModelSerializer):
applicant_name = serializers.ReadOnlyField(source='applicant.nickname')
applicant_username = serializers.ReadOnlyField(source='applicant.username')
applicant_avatar = serializers.ReadOnlyField(source='applicant.avatar_url')
applicant_rating = serializers.ReadOnlyField(source='applicant.rating')
applicant_completed_tasks = serializers.ReadOnlyField(source='applicant.completed_tasks')
task_detail = serializers.SerializerMethodField()
is_invited = serializers.SerializerMethodField()
delivery_records = DeliveryRecordSerializer(many=True, read_only=True)
class Meta:
model = TaskApplication
fields = '__all__'
read_only_fields = ['id', 'applicant', 'status', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at']
def get_task_detail(self, obj):
return {
'id': obj.task.id,
'title': obj.task.title,
'budget_min': obj.task.budget_min,
'budget_max': obj.task.budget_max,
'status': obj.task.status,
}
def get_is_invited(self, obj):
from .models import TaskInvitation
return TaskInvitation.objects.filter(task=obj.task, expert=obj.applicant).exists()
class TaskInvitationSerializer(serializers.ModelSerializer):
enterprise_name = serializers.ReadOnlyField(source='task.enterprise.company_name')
enterprise_logo = serializers.ReadOnlyField(source='task.enterprise.logo_url')
enterprise_avatar = serializers.ReadOnlyField(source='task.publisher.avatar_url')
inviter_name = serializers.ReadOnlyField(source='task.publisher.nickname')
enterprise_phone = serializers.ReadOnlyField(source='task.publisher.phone')
task_title = serializers.ReadOnlyField(source='task.title')
expert_name = serializers.ReadOnlyField(source='expert.nickname')
expert_avatar = serializers.ReadOnlyField(source='expert.avatar_url')
expert_phone = serializers.ReadOnlyField(source='expert.phone')
class Meta:
model = TaskInvitation
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']
class TaskSerializer(serializers.ModelSerializer):
applications = TaskApplicationSerializer(many=True, read_only=True)
invitations = TaskInvitationSerializer(many=True, read_only=True)
publisher_name = serializers.ReadOnlyField(source='publisher.nickname')
publisher_avatar = serializers.ReadOnlyField(source='publisher.avatar_url')
publisher_phone = serializers.ReadOnlyField(source='publisher.phone')
enterprise_name = serializers.ReadOnlyField(source='enterprise.company_name')
enterprise_logo = serializers.ReadOnlyField(source='enterprise.logo_url')
class Meta:
model = Task
fields = '__all__'
read_only_fields = ['id', 'publisher', 'enterprise', 'created_at', 'updated_at', 'assigned_at', 'completed_at', 'cancelled_at']

3
tasks/tests.py Normal file
View File

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

12
tasks/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet, TaskApplicationViewSet, TaskInvitationViewSet
router = DefaultRouter()
router.register('tasks', TaskViewSet, basename='task')
router.register('applications', TaskApplicationViewSet, basename='task-application')
router.register('invitations', TaskInvitationViewSet, basename='task-invitation')
urlpatterns = [
path('', include(router.urls)),
]

487
tasks/views.py Normal file
View File

@@ -0,0 +1,487 @@
from django.db import models
from rest_framework import viewsets, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from .models import Task, TaskApplication, TaskInvitation, TaskStatus, ApplyStatus, InvitationStatus
from .serializers import TaskSerializer, TaskApplicationSerializer, TaskInvitationSerializer
class TaskInvitationViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 任务主动邀请接口视图。企业用户可主动向OPC专家发送定向任务合作邀请专家可接受或拒绝该邀请。
"""
serializer_class = TaskInvitationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
enterprise, _ = _get_user_enterprise(user)
if enterprise:
# Experts see invitations sent to them
# Enterprises see invitations they sent for their tasks
return TaskInvitation.objects.filter(
models.Q(expert=user) | models.Q(task__enterprise=enterprise)
).distinct().order_by('-created_at')
else:
return TaskInvitation.objects.filter(
models.Q(expert=user) | models.Q(task__publisher=user)
).distinct().order_by('-created_at')
def perform_create(self, serializer):
task = serializer.validated_data['task']
expert = serializer.validated_data['expert']
if TaskInvitation.objects.filter(task=task, expert=expert).exists():
from rest_framework.exceptions import ValidationError
raise ValidationError('已经向该专家发送过邀约')
serializer.save()
@action(detail=True, methods=['post'])
def accept(self, request, pk=None):
invitation = self.get_object()
if invitation.status == InvitationStatus.ACCEPTED:
return Response({'detail': '您已接受过此邀约'}, status=status.HTTP_400_BAD_REQUEST)
task = invitation.task
invitation.status = InvitationStatus.ACCEPTED
invitation.save()
from django.utils import timezone
history = []
if invitation.message:
history.append({
'sender_id': str(task.publisher.id),
'sender_name': task.publisher.nickname or task.publisher.username,
'sender_avatar': task.publisher.avatar_url,
'sender_role': 'ENTERPRISE',
'content': invitation.message,
'created_at': invitation.created_at.isoformat()
})
if isinstance(invitation.messages, list):
history.extend(invitation.messages)
history.append({
'sender_id': str(request.user.id),
'sender_name': 'System',
'sender_avatar': '',
'sender_role': 'SYSTEM',
'content': f'【系统记录】专家 {request.user.nickname or request.user.username} 已同意承接并加入了项目',
'created_at': timezone.now().isoformat()
})
# Create an application record so the enterprise can track this accepted expert
application, created = TaskApplication.objects.get_or_create(
task=task,
applicant=request.user,
defaults={
'status': ApplyStatus.APPROVED,
'cover_letter': '接受了企业发出的定向邀约',
'expected_price': task.budget_max or 0,
'expected_days': 0,
'negotiation_history': history
}
)
if not created:
if application.status != ApplyStatus.APPROVED:
application.status = ApplyStatus.APPROVED
# If the application existed but was PENDING or something, we should still update its negotiation history
application.negotiation_history = history
application.save()
if task.status in [TaskStatus.OPEN, TaskStatus.DRAFT]:
task.status = TaskStatus.IN_PROGRESS
task.save()
return Response({'status': '已接受邀约,项目进入进行中状态'})
@action(detail=True, methods=['post'])
def reject(self, request, pk=None):
invitation = self.get_object()
invitation.status = InvitationStatus.REJECTED
invitation.save()
return Response({'status': '已拒绝邀约'})
@action(detail=True, methods=['post'])
def discuss(self, request, pk=None):
invitation = self.get_object()
user = request.user
# Verify user is either the expert or enterprise admin/publisher
enterprise, role = _get_user_enterprise(user)
is_enterprise = False
if user == invitation.expert:
sender_role = 'EXPERT'
elif user == invitation.task.publisher or (enterprise and invitation.task.enterprise == enterprise):
sender_role = 'ENTERPRISE'
is_enterprise = True
else:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权参与此洽谈')
content = request.data.get('message', '').strip()
if not content:
from rest_framework.exceptions import ValidationError
raise ValidationError('回复内容不能为空')
from django.utils import timezone
message_obj = {
'sender_id': str(user.id),
'sender_name': user.nickname or user.username,
'sender_avatar': user.avatar_url,
'sender_role': sender_role,
'content': content,
'created_at': timezone.now().isoformat()
}
# Initialize messages if None somehow
if not isinstance(invitation.messages, list):
invitation.messages = []
invitation.messages.append(message_obj)
# Ensure status transitions to DISCUSSING unless already ACCEPTED or REJECTED
if invitation.status == InvitationStatus.PENDING:
invitation.status = InvitationStatus.DISCUSSING
invitation.save()
return Response({'status': '回复已发送', 'message': message_obj})
def _get_user_enterprise(user):
from users.models import EnterpriseMember
if hasattr(user, 'enterprise') and user.enterprise:
return user.enterprise, 'ADMIN' # Owner is effectively ADMIN
membership = EnterpriseMember.objects.filter(user=user).first()
if membership:
return membership.enterprise, membership.role
return None, None
class TaskViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 主线任务广场接口视图。提供企业发布任务、编辑任务信息,以及用户在大厅分页筛选、搜索开放任务的功能。
"""
def get_queryset(self):
user = self.request.user
queryset = Task.objects.filter(is_deleted=False).order_by('-is_recommended', '-recommend_priority', '-created_at')
status_param = self.request.query_params.get('status')
if status_param:
queryset = queryset.filter(status=status_param)
enterprise_param = self.request.query_params.get('enterprise')
if enterprise_param:
queryset = queryset.filter(enterprise_id=enterprise_param)
# If global admin, show all tasks
if user.is_staff or user.is_superuser or 'ADMIN' in getattr(user, 'roles', []):
return queryset
# If enterprise user, show their enterprise's tasks
enterprise, role = _get_user_enterprise(user)
if enterprise and not enterprise_param:
return queryset.filter(enterprise=enterprise)
# Regular users (OPC experts, etc.) can see all OPEN tasks + tasks they're involved in
return queryset
serializer_class = TaskSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
user = self.request.user
# Admins can publish tasks directly (platform-level tasks)
if user.is_staff or user.is_superuser:
enterprise, _ = _get_user_enterprise(user)
serializer.save(publisher=user, enterprise=enterprise)
return
enterprise, _ = _get_user_enterprise(user)
if not enterprise:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('必须关联企业后才能发布任务')
if enterprise.status != 'VERIFIED':
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('企业尚未通过认证,暂无权发布任务')
serializer.save(publisher=user, enterprise=enterprise)
def _check_admin_or_publisher(self, task, user):
if user.is_staff or user.is_superuser:
return True
if 'ADMIN' in getattr(user, 'roles', []):
return True
if task.publisher == user:
return True
enterprise, role = _get_user_enterprise(user)
if enterprise and task.enterprise == enterprise and role == 'ADMIN':
return True
return False
def perform_update(self, serializer):
task = self.get_object()
if not self._check_admin_or_publisher(task, self.request.user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权修改此任务')
serializer.save()
def perform_destroy(self, instance):
if not self._check_admin_or_publisher(instance, self.request.user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权删除此任务')
instance.is_deleted = True
instance.save()
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def toggle_recommend(self, request, pk=None):
task = self.get_object()
task.is_recommended = request.data.get('is_recommended', not task.is_recommended)
task.recommend_priority = request.data.get('recommend_priority', task.recommend_priority)
task.save(update_fields=['is_recommended', 'recommend_priority'])
return Response({'is_recommended': task.is_recommended, 'recommend_priority': task.recommend_priority})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def publish(self, request, pk=None):
task = self.get_object()
if not self._check_admin_or_publisher(task, request.user):
return Response({'detail': '无权操作'}, status=status.HTTP_403_FORBIDDEN)
task.status = TaskStatus.OPEN
task.save()
return Response({'status': '任务已发布'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def cancel_task(self, request, pk=None):
task = self.get_object()
if not self._check_admin_or_publisher(task, request.user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权取消此任务')
batch_reject = request.data.get('batch_reject', False)
# Any application that is PENDING, APPROVED, or DELIVERED should be cancelled.
active_apps = task.applications.exclude(status__in=[ApplyStatus.REJECTED, ApplyStatus.COMPLETED, ApplyStatus.WITHDRAWN])
if active_apps.exists():
if not batch_reject:
return Response({
'detail': '该任务有正在进行中的专家或待审核的申请,禁止直接取消。请选择强制批量中止。',
'requires_batch_reject': True
}, status=status.HTTP_400_BAD_REQUEST)
else:
# Batch reject
active_apps.update(status=ApplyStatus.REJECTED, reject_reason='任务已被系统或发布方强制中止/取消')
task.status = TaskStatus.CANCELLED
from django.utils import timezone
task.cancelled_at = timezone.now()
task.cancel_reason = request.data.get('cancel_reason', '管理员操作下架')
task.save()
return Response({'status': '任务已下架'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def complete_task(self, request, pk=None):
task = self.get_object()
if not self._check_admin_or_publisher(task, request.user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权操作')
from .models import ApplyStatus, DeliveryStatus
# Check for experts who haven't delivered
working_apps = task.applications.filter(status=ApplyStatus.APPROVED)
if working_apps.exists():
return Response({'detail': '尚有专家未提交交付物,无法结项。如需结项,请先取消未完成的录用。'}, status=status.HTTP_400_BAD_REQUEST)
# Check for experts who delivered but haven't been approved
delivered_apps = task.applications.filter(status=ApplyStatus.DELIVERED)
for app in delivered_apps:
latest_record = app.delivery_records.order_by('-created_at').first()
if not latest_record or latest_record.status != DeliveryStatus.APPROVED:
return Response({'detail': '尚有专家的交付成果未验收,无法结项。'}, status=status.HTTP_400_BAD_REQUEST)
task.status = TaskStatus.COMPLETED
from django.utils import timezone
task.completed_at = timezone.now()
task.save()
for app in delivered_apps:
app.status = ApplyStatus.COMPLETED
app.reviewed_at = timezone.now()
app.save()
expert = app.applicant
if expert:
expert.completed_tasks = (expert.completed_tasks or 0) + 1
expert.save()
return Response({'status': '任务已成功结项'})
class TaskApplicationViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 任务申请投递接口视图。普通用户/专家对感兴趣的任务进行接单投递(包含附件、报价),企业方审批(approve/reject)后即缔结合作。
"""
queryset = TaskApplication.objects.all()
serializer_class = TaskApplicationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
# Admins can see all applications
if user.is_staff or user.is_superuser or 'ADMIN' in getattr(user, 'roles', []):
qs = TaskApplication.objects.all().order_by('-created_at')
else:
enterprise, _ = _get_user_enterprise(user)
if enterprise:
# Publishers/Admins see applications for their enterprise's tasks
qs = TaskApplication.objects.filter(
models.Q(applicant=user) | models.Q(task__enterprise=enterprise)
).distinct().order_by('-created_at')
else:
qs = TaskApplication.objects.filter(
models.Q(applicant=user) | models.Q(task__publisher=user)
).distinct().order_by('-created_at')
task_id = self.request.query_params.get('task')
if task_id:
qs = qs.filter(task_id=task_id)
return qs
def perform_create(self, serializer):
user = self.request.user
if 'OPC_USER' not in getattr(user, 'roles', []):
# Check db if property is missing
from users.models import UserRole
if not UserRole.objects.filter(user=user, role__code='OPC_USER').exists():
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('必须是已认证的 OPC 专家才能承接任务')
from opc_cert.models import OpcCertification
cert = OpcCertification.objects.filter(user=user, status='APPROVED').exists()
if not cert:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('您的 OPC 专家认证未通过审核,无法承接任务')
serializer.save(applicant=user)
def _check_admin_or_publisher(self, task, user):
if user.is_staff or user.is_superuser:
return True
if 'ADMIN' in getattr(user, 'roles', []):
return True
if task.publisher == user:
return True
enterprise, role = _get_user_enterprise(user)
if enterprise and task.enterprise == enterprise:
return True
return False
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def approve(self, request, pk=None):
application = self.get_object()
if not self._check_admin_or_publisher(application.task, request.user):
return Response({'detail': '无权操作'}, status=status.HTTP_403_FORBIDDEN)
application.status = ApplyStatus.APPROVED
application.reviewed_by = request.user
application.save()
task = application.task
if task.status in [TaskStatus.OPEN, TaskStatus.DRAFT]:
task.status = TaskStatus.IN_PROGRESS
task.save()
return Response({'status': '申请已通过,专家可开始执行'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def reject(self, request, pk=None):
application = self.get_object()
if not self._check_admin_or_publisher(application.task, request.user):
return Response({'detail': '无权操作'}, status=status.HTTP_403_FORBIDDEN)
application.status = ApplyStatus.REJECTED
application.reject_reason = request.data.get('reason', '')
application.reviewed_by = request.user
from django.utils import timezone
application.reviewed_at = timezone.now()
application.save()
return Response({'status': '申请已驳回'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def submit_deliverables(self, request, pk=None):
application = self.get_object()
if application.applicant != request.user:
return Response({'detail': '只有承接人自己才能提交成果'}, status=status.HTTP_403_FORBIDDEN)
deliverables = request.data.get('deliverables', [])
completion_note = request.data.get('completion_note', '')
from .models import DeliveryRecord, DeliveryStatus
DeliveryRecord.objects.create(
application=application,
note=completion_note,
files=deliverables,
status=DeliveryStatus.PENDING
)
application.status = ApplyStatus.DELIVERED
application.save()
return Response({'status': '成果已提交,等待企业验收'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def approve_delivery(self, request, pk=None):
application = self.get_object()
if not self._check_admin_or_publisher(application.task, request.user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权验收此成果')
record_id = request.data.get('record_id')
from .models import DeliveryRecord, DeliveryStatus
if record_id:
record = application.delivery_records.filter(id=record_id).first()
else:
record = application.delivery_records.filter(status=DeliveryStatus.PENDING).first()
if not record:
return Response({'detail': '找不到待验收的交付记录'}, status=status.HTTP_404_NOT_FOUND)
record.status = DeliveryStatus.APPROVED
record.save()
# Note: We do NOT change application.status to COMPLETED here.
# It remains DELIVERED until the enterprise explicitly closes the task via complete_task.
# This ensures the expert's side doesn't show as fully completed before the project is officially wrapped up.
return Response({'status': '该专家的成果已验收通过'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def reject_delivery(self, request, pk=None):
application = self.get_object()
if not self._check_admin_or_publisher(application.task, request.user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('无权操作')
record_id = request.data.get('record_id')
from .models import DeliveryRecord, DeliveryStatus
if record_id:
record = application.delivery_records.filter(id=record_id).first()
else:
record = application.delivery_records.filter(status=DeliveryStatus.PENDING).first()
if not record:
return Response({'detail': '找不到待驳回的交付记录'}, status=status.HTTP_404_NOT_FOUND)
record.status = DeliveryStatus.REJECTED
record.save()
# If all records are rejected, application goes back to APPROVED (IN_PROGRESS)
if not application.delivery_records.filter(status=DeliveryStatus.PENDING).exists():
application.status = ApplyStatus.APPROVED
application.save()
return Response({'status': '该次交付已驳回'})

0
users/__init__.py Normal file
View File

3
users/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
users/apps.py Normal file
View File

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

View File

@@ -0,0 +1,126 @@
# Generated by Django 4.2.30 on 2026-04-25 09:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('username', models.CharField(max_length=64, unique=True)),
('phone', models.CharField(blank=True, max_length=20, null=True, unique=True)),
('email', models.EmailField(blank=True, max_length=128, null=True, unique=True)),
('nickname', models.CharField(blank=True, max_length=64, null=True)),
('avatar_url', models.CharField(blank=True, max_length=512, null=True)),
('face_url', models.CharField(blank=True, max_length=512, null=True)),
('wx_openid', models.CharField(blank=True, max_length=128, null=True, unique=True)),
('wx_unionid', models.CharField(blank=True, max_length=128, null=True, unique=True)),
('status', models.CharField(choices=[('ACTIVE', '正常'), ('INACTIVE', '未激活'), ('BANNED', '封禁')], default='ACTIVE', max_length=16)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
('is_staff', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'db_table': 'users',
},
),
migrations.CreateModel(
name='Permission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(max_length=128, unique=True)),
('name', models.CharField(max_length=128)),
('type', models.CharField(choices=[('MENU', '菜单'), ('BUTTON', '按钮'), ('API', '接口')], max_length=16)),
('path', models.CharField(blank=True, max_length=256, null=True)),
('method', models.CharField(blank=True, max_length=16, null=True)),
('sort_order', models.IntegerField(default=0)),
('icon', models.CharField(blank=True, max_length=64, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.permission')),
],
options={
'db_table': 'permissions',
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(max_length=64, unique=True)),
('name', models.CharField(max_length=64)),
('description', models.TextField(blank=True, null=True)),
('is_system', models.BooleanField(default=False)),
('sort_order', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'roles',
},
),
migrations.CreateModel(
name='Enterprise',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('company_name', models.CharField(max_length=256)),
('business_license', models.CharField(blank=True, max_length=512, null=True)),
('contact_name', models.CharField(blank=True, max_length=64, null=True)),
('contact_phone', models.CharField(blank=True, max_length=20, null=True)),
('contact_email', models.CharField(blank=True, max_length=128, null=True)),
('address', models.TextField(blank=True, null=True)),
('description', models.TextField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'enterprises',
},
),
migrations.CreateModel(
name='UserRole',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('granted_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_roles', to=settings.AUTH_USER_MODEL)),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.role')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'user_roles',
'unique_together': {('user', 'role')},
},
),
migrations.CreateModel(
name='RolePermission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.permission')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_permissions', to='users.role')),
],
options={
'db_table': 'role_permissions',
'unique_together': {('role', 'permission')},
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-25 10:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='enterprise',
name='credit_code',
field=models.CharField(blank=True, max_length=64, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-04-25 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0002_enterprise_credit_code'),
]
operations = [
migrations.AddField(
model_name='user',
name='bio',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='location',
field=models.CharField(blank=True, max_length=128, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-25 13:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_user_bio_user_location'),
]
operations = [
migrations.AlterField(
model_name='enterprise',
name='credit_code',
field=models.CharField(blank=True, max_length=64, null=True, unique=True),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.30 on 2026-04-25 14:48
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('users', '0004_alter_enterprise_credit_code'),
]
operations = [
migrations.CreateModel(
name='EnterpriseMember',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('role', models.CharField(choices=[('ADMIN', '管理员'), ('MEMBER', '普通成员'), ('GUEST', '外部观察员')], default='MEMBER', max_length=16)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('enterprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='users.enterprise')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'enterprise_members',
'unique_together': {('enterprise', 'user')},
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-04-25 14:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_enterprisemember'),
]
operations = [
migrations.AddField(
model_name='user',
name='completed_tasks',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='user',
name='rating',
field=models.DecimalField(decimal_places=2, default=5.0, max_digits=3),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-25 14:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_user_completed_tasks_user_rating'),
]
operations = [
migrations.AddField(
model_name='enterprise',
name='status',
field=models.CharField(choices=[('PENDING', '待审核'), ('VERIFIED', '已认证'), ('REJECTED', '已拒绝')], default='PENDING', max_length=16),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-26 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0007_enterprise_status'),
]
operations = [
migrations.AddField(
model_name='enterprise',
name='logo_url',
field=models.CharField(blank=True, max_length=512, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-04-27 17:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0008_enterprise_logo_url'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_recommended',
field=models.BooleanField(default=False, help_text='管理员推荐'),
),
migrations.AddField(
model_name='user',
name='recommend_priority',
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-04-27 18:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0009_user_is_recommended_user_recommend_priority'),
]
operations = [
migrations.AddField(
model_name='enterprise',
name='landline',
field=models.CharField(blank=True, help_text='座机号码', max_length=32, null=True),
),
]

View File

160
users/models.py Normal file
View File

@@ -0,0 +1,160 @@
import uuid
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
class UserStatus(models.TextChoices):
ACTIVE = 'ACTIVE', '正常'
INACTIVE = 'INACTIVE', '未激活'
BANNED = 'BANNED', '封禁'
class CustomUserManager(BaseUserManager):
def create_user(self, username, phone=None, password=None, **extra_fields):
if not username:
raise ValueError('The Username field must be set')
user = self.model(username=username, phone=phone, **extra_fields)
if password:
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, username, phone=None, password=None, **extra_fields):
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
return self.create_user(username, phone, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
username = models.CharField(max_length=64, unique=True)
phone = models.CharField(max_length=20, unique=True, null=True, blank=True)
email = models.EmailField(max_length=128, unique=True, null=True, blank=True)
nickname = models.CharField(max_length=64, null=True, blank=True)
avatar_url = models.CharField(max_length=512, null=True, blank=True)
face_url = models.CharField(max_length=512, null=True, blank=True)
wx_openid = models.CharField(max_length=128, unique=True, null=True, blank=True)
wx_unionid = models.CharField(max_length=128, unique=True, null=True, blank=True)
bio = models.TextField(null=True, blank=True)
location = models.CharField(max_length=128, null=True, blank=True)
rating = models.DecimalField(max_digits=3, decimal_places=2, default=5.00)
completed_tasks = models.IntegerField(default=0)
status = models.CharField(max_length=16, choices=UserStatus.choices, default=UserStatus.ACTIVE)
is_deleted = models.BooleanField(default=False)
is_recommended = models.BooleanField(default=False, help_text='管理员推荐')
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
objects = CustomUserManager()
@property
def roles(self):
return list(self.user_roles.values_list('role__code', flat=True))
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['phone']
class Meta:
db_table = 'users'
class Role(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
code = models.CharField(max_length=64, unique=True)
name = models.CharField(max_length=64)
description = models.TextField(null=True, blank=True)
is_system = models.BooleanField(default=False)
sort_order = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'roles'
class PermissionType(models.TextChoices):
MENU = 'MENU', '菜单'
BUTTON = 'BUTTON', '按钮'
API = 'API', '接口'
class Permission(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
code = models.CharField(max_length=128, unique=True)
name = models.CharField(max_length=128)
type = models.CharField(max_length=16, choices=PermissionType.choices)
path = models.CharField(max_length=256, null=True, blank=True)
method = models.CharField(max_length=16, null=True, blank=True)
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True)
sort_order = models.IntegerField(default=0)
icon = models.CharField(max_length=64, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'permissions'
class UserRole(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_roles')
role = models.ForeignKey(Role, on_delete=models.CASCADE)
granted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='granted_roles')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'user_roles'
unique_together = ('user', 'role')
class RolePermission(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name='role_permissions')
permission = models.ForeignKey(Permission, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'role_permissions'
unique_together = ('role', 'permission')
class Enterprise(models.Model):
class EnterpriseStatus(models.TextChoices):
PENDING = 'PENDING', '待审核'
VERIFIED = 'VERIFIED', '已认证'
REJECTED = 'REJECTED', '已拒绝'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.OneToOneField(User, on_delete=models.CASCADE)
company_name = models.CharField(max_length=256)
credit_code = models.CharField(max_length=64, unique=True, null=True, blank=True)
status = models.CharField(max_length=16, choices=EnterpriseStatus.choices, default=EnterpriseStatus.PENDING)
logo_url = models.CharField(max_length=512, null=True, blank=True)
business_license = models.CharField(max_length=512, null=True, blank=True)
contact_name = models.CharField(max_length=64, null=True, blank=True)
contact_phone = models.CharField(max_length=20, null=True, blank=True)
landline = models.CharField(max_length=32, null=True, blank=True, help_text='座机号码')
contact_email = models.CharField(max_length=128, null=True, blank=True)
address = models.TextField(null=True, blank=True)
description = models.TextField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'enterprises'
class EnterpriseMember(models.Model):
class MemberRole(models.TextChoices):
ADMIN = 'ADMIN', '管理员'
MEMBER = 'MEMBER', '普通成员'
GUEST = 'GUEST', '外部观察员'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
enterprise = models.ForeignKey(Enterprise, on_delete=models.CASCADE, related_name='members')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enterprise_memberships')
role = models.CharField(max_length=16, choices=MemberRole.choices, default=MemberRole.MEMBER)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'enterprise_members'
unique_together = ('enterprise', 'user')

31
users/permissions.py Normal file
View File

@@ -0,0 +1,31 @@
from rest_framework import permissions
from .models import RolePermission
class HasAPIPermission(permissions.BasePermission):
"""
Checks if the user has the specific API permission required by the view.
The view must define `required_permission = 'api:something'` or a dictionary mapping methods to permissions:
`required_permissions = {'GET': 'api:read', 'POST': 'api:write'}`
"""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if getattr(request.user, 'is_superuser', False):
return True
method = request.method
required_perm = None
if hasattr(view, 'required_permissions') and isinstance(view.required_permissions, dict):
required_perm = view.required_permissions.get(method)
elif hasattr(view, 'required_permission'):
required_perm = view.required_permission
if not required_perm:
# If no permission is required, default to IsAdminUser logic for safety,
# or True if you want it open. Let's require staff status by default for admin views.
return request.user.is_staff
user_perms = RolePermission.objects.filter(role__userrole__user=request.user).values_list('permission__code', flat=True)
return required_perm in user_perms

438
users/serializers.py Normal file
View File

@@ -0,0 +1,438 @@
from rest_framework import serializers
from .models import User, Enterprise, Role, EnterpriseMember, Permission
class UserSerializer(serializers.ModelSerializer):
roles = serializers.ReadOnlyField()
permissions = serializers.SerializerMethodField()
opc_certification = serializers.SerializerMethodField()
enterprise_info = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'username', 'phone', 'email', 'nickname', 'avatar_url', 'face_url', 'bio', 'location', 'status', 'is_active', 'created_at', 'roles', 'permissions', 'is_staff', 'is_superuser', 'rating', 'completed_tasks', 'opc_certification', 'enterprise_info', 'is_recommended', 'recommend_priority']
read_only_fields = ['id', 'status', 'is_active', 'created_at', 'roles', 'permissions', 'is_staff', 'is_superuser']
def get_enterprise_info(self, obj):
if 'ENTERPRISE' in obj.roles:
from .models import Enterprise, EnterpriseMember
ent = Enterprise.objects.filter(user=obj).first()
if ent:
return {'company_name': ent.company_name, 'role': 'OWNER'}
member = EnterpriseMember.objects.filter(user=obj).first()
if member:
return {'company_name': member.enterprise.company_name, 'role': member.role}
return None
def get_opc_certification(self, obj):
if 'OPC_USER' in obj.roles:
from opc_cert.models import OpcCertification
from opc_cert.serializers import OpcCertificationSerializer
cert = OpcCertification.objects.filter(user=obj, status='APPROVED').first()
if cert:
return OpcCertificationSerializer(cert).data
return None
def get_permissions(self, obj):
if obj.is_superuser:
return ['*']
from .models import RolePermission
perms = RolePermission.objects.filter(role__userrole__user=obj).values_list('permission__code', flat=True).distinct()
return list(perms)
def validate(self, attrs):
# Normalize empty strings to None to avoid unique constraint issues
for field in ['phone', 'email']:
if field in attrs and attrs[field] == '':
attrs[field] = None
return attrs
class RoleSerializer(serializers.ModelSerializer):
permission_ids = serializers.SerializerMethodField()
class Meta:
model = Role
fields = '__all__'
def get_permission_ids(self, obj):
return list(obj.role_permissions.values_list('permission_id', flat=True).distinct().values_list('permission_id', flat=True))
def to_representation(self, instance):
data = super().to_representation(instance)
# Ensure permission_ids are strings for frontend comparison
data['permission_ids'] = [str(pid) for pid in data.get('permission_ids', [])]
return data
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
fields = '__all__'
class EnterpriseMemberSerializer(serializers.ModelSerializer):
user_details = UserSerializer(source='user', read_only=True)
class Meta:
model = EnterpriseMember
fields = ['id', 'user', 'user_details', 'role', 'joined_at']
read_only_fields = ['id', 'joined_at']
import re
from django.core.validators import EmailValidator
from rest_framework.exceptions import ValidationError
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=6)
username = serializers.CharField(
error_messages={
'unique': '该用户名已被占用,请尝试其他名称'
}
)
phone = serializers.CharField(
required=False,
allow_blank=True,
error_messages={
'unique': '该手机号已注册,请直接登录'
}
)
email = serializers.EmailField(
required=False,
allow_blank=True,
error_messages={
'unique': '该邮箱已注册,请尝试其他邮箱'
}
)
avatar_url = serializers.CharField(required=False, allow_blank=True)
class Meta:
model = User
fields = ['username', 'phone', 'email', 'password', 'nickname', 'avatar_url']
def validate_phone(self, value):
if value:
if not re.match(r'^1[3-9]\d{9}$', value):
raise ValidationError('请输入有效的11位手机号')
if User.objects.filter(phone=value, is_deleted=False).exists():
raise ValidationError('该手机号已注册,请直接登录')
return value
def validate_email(self, value):
if value:
if User.objects.filter(email=value, is_deleted=False).exists():
raise ValidationError('该邮箱已注册,请尝试其他邮箱')
return value
def validate_username(self, value):
if User.objects.filter(username=value, is_deleted=False).exists():
raise ValidationError('该用户名已被占用,请尝试其他名称')
return value
def create(self, validated_data):
phone = validated_data.get('phone')
if phone == '':
phone = None
email = validated_data.get('email')
if email == '':
email = None
user = User.objects.create_user(
username=validated_data['username'],
phone=phone,
email=email,
password=validated_data['password'],
nickname=validated_data.get('nickname'),
avatar_url=validated_data.get('avatar_url', '')
)
# Auto-assign USER role
from .models import Role, UserRole
user_role, _ = Role.objects.get_or_create(code='USER', defaults={'name': '普通用户'})
UserRole.objects.get_or_create(user=user, role=user_role)
return user
class AdminUserCreateSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=6)
email = serializers.EmailField(required=False, allow_blank=True)
username = serializers.CharField(
error_messages={'unique': '该用户名已被占用'}
)
phone = serializers.CharField(
required=False, allow_blank=True,
error_messages={'unique': '该手机号已注册'}
)
class Meta:
model = User
fields = ['username', 'phone', 'email', 'password', 'nickname']
def validate_phone(self, value):
if value:
if not re.match(r'^1[3-9]\d{9}$', value):
raise ValidationError('请输入有效的11位手机号')
if User.objects.filter(phone=value).exists():
raise ValidationError('该手机号已注册')
return value
def validate_email(self, value):
if value:
if User.objects.filter(email=value).exists():
raise ValidationError('该邮箱已注册')
return value
def create(self, validated_data):
phone = validated_data.get('phone')
if phone == '':
phone = None
user = User.objects.create_user(
username=validated_data['username'],
phone=phone,
email=validated_data.get('email'),
password=validated_data['password'],
nickname=validated_data.get('nickname')
)
# Auto-assign USER role
from .models import Role, UserRole
user_role, _ = Role.objects.get_or_create(code='USER', defaults={'name': '普通用户'})
UserRole.objects.get_or_create(user=user, role=user_role)
return user
class EnterpriseSerializer(serializers.ModelSerializer):
user_details = UserSerializer(source='user', read_only=True)
credit_code = serializers.CharField(
error_messages={
'unique': '该统一社会信用代码已注册,请核对信息'
}
)
class Meta:
model = Enterprise
fields = ['id', 'user', 'user_details', 'company_name', 'credit_code', 'business_license', 'contact_name', 'contact_phone', 'landline', 'contact_email', 'address', 'description', 'status', 'logo_url', 'created_at', 'updated_at']
read_only_fields = ['id', 'user', 'user_details', 'status', 'created_at', 'updated_at']
def validate_credit_code(self, value):
if value:
if not re.match(r'^[A-Z0-9]{18}$', value):
raise ValidationError('请输入有效的18位统一社会信用代码')
return value
from django.db import transaction
class EnterpriseRegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True, min_length=6)
company_name = serializers.CharField(max_length=255)
credit_code = serializers.CharField(max_length=18)
business_license = serializers.CharField(required=False, allow_blank=True)
logo_url = serializers.CharField(required=False, allow_blank=True)
def validate_credit_code(self, value):
if not re.match(r'^[A-Z0-9]{18}$', value):
raise ValidationError('请输入有效的18位统一社会信用代码')
if Enterprise.objects.filter(credit_code=value).exists():
raise ValidationError('该社会信用代码已注册')
return value
def validate_email(self, value):
if User.objects.filter(email=value).exists():
raise ValidationError('该邮箱已注册')
return value
def create(self, validated_data):
with transaction.atomic():
# 1. Create User
user = User.objects.create_user(
username=validated_data['email'],
email=validated_data['email'],
password=validated_data['password'],
nickname=validated_data['company_name'],
avatar_url=validated_data.get('logo_url')
)
# 2. Create Enterprise
enterprise = Enterprise.objects.create(
user=user,
company_name=validated_data['company_name'],
credit_code=validated_data['credit_code'],
business_license=validated_data.get('business_license', ''),
logo_url=validated_data.get('logo_url')
)
# 3. Assign Role
from .models import Role, UserRole
role, created = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
UserRole.objects.get_or_create(user=user, role=role)
return enterprise
class AdminEnterpriseCreateSerializer(serializers.ModelSerializer):
email = serializers.EmailField(write_only=True)
password = serializers.CharField(write_only=True, min_length=6)
class Meta:
model = Enterprise
fields = ['email', 'password', 'company_name', 'credit_code', 'business_license', 'contact_name', 'contact_phone', 'contact_email', 'address', 'description', 'logo_url']
def validate_credit_code(self, value):
if value:
if not re.match(r'^[A-Z0-9]{18}$', value):
raise ValidationError('请输入有效的18位统一社会信用代码')
if Enterprise.objects.filter(credit_code=value).exists():
raise ValidationError('该社会信用代码已注册')
return value
def validate_email(self, value):
if User.objects.filter(email=value).exists():
raise ValidationError('该邮箱已注册,无法创建企业账号')
return value
def create(self, validated_data):
email = validated_data.pop('email')
password = validated_data.pop('password')
with transaction.atomic():
user = User.objects.create_user(
username=email,
email=email,
password=password,
nickname=validated_data['company_name'],
avatar_url=validated_data.get('logo_url')
)
enterprise = Enterprise.objects.create(
user=user,
status='VERIFIED', # Admins create pre-verified enterprises
**validated_data
)
from .models import Role, UserRole
role, _ = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
UserRole.objects.get_or_create(user=user, role=role)
return enterprise
from django.contrib.auth import authenticate
class EnterpriseLoginSerializer(serializers.Serializer):
login_type = serializers.ChoiceField(choices=['ADMIN', 'EMPLOYEE'], default='ADMIN')
enterprise_email = serializers.EmailField()
username = serializers.CharField(required=False, allow_blank=True)
password = serializers.CharField(write_only=True)
def validate(self, data):
login_type = data.get('login_type', 'ADMIN')
enterprise_email = data.get('enterprise_email')
username = data.get('username')
password = data.get('password')
# 1. Find enterprise
try:
enterprise_user = User.objects.get(email=enterprise_email)
enterprise = Enterprise.objects.get(user=enterprise_user)
except (User.DoesNotExist, Enterprise.DoesNotExist):
raise ValidationError({'detail': '企业邮箱未注册'})
if login_type == 'ADMIN':
# 2. Find and authenticate the admin
if not enterprise_user.is_active or enterprise_user.is_deleted:
raise ValidationError({'detail': '您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。'})
if not enterprise_user.check_password(password):
raise ValidationError({'detail': '管理员邮箱或密码错误'})
# Check if user is the enterprise owner
user = enterprise_user
else:
# EMPLOYEE login
if not username:
raise ValidationError({'detail': '员工账号不能为空'})
namespaced_username = f"{enterprise_email}_{username}"
user = User.objects.filter(username=namespaced_username).first()
if not user:
raise ValidationError({'detail': '员工账号未注册'})
if not user.is_active or user.is_deleted:
raise ValidationError({'detail': '您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。'})
if not user.check_password(password):
raise ValidationError({'detail': '员工账号或密码错误'})
# Check if user is a member of the enterprise
if not EnterpriseMember.objects.filter(enterprise=enterprise, user=user).exists():
raise ValidationError({'detail': '该员工不属于此企业团队'})
data['user'] = user
return data
class EnterpriseMemberAddSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True, min_length=6)
nickname = serializers.CharField(required=False, allow_blank=True)
role = serializers.ChoiceField(choices=EnterpriseMember.MemberRole.choices, default=EnterpriseMember.MemberRole.MEMBER)
enterprise_id = serializers.UUIDField(required=False)
def _get_enterprise(self):
request_user = self.context['request'].user
enterprise_id = self.initial_data.get('enterprise_id')
if enterprise_id and (request_user.is_staff or request_user.is_superuser):
try:
return Enterprise.objects.get(id=enterprise_id)
except Enterprise.DoesNotExist:
raise ValidationError('指定的企业不存在')
try:
return Enterprise.objects.get(user=request_user)
except Enterprise.DoesNotExist:
raise ValidationError('当前用户没有关联的企业主体')
def validate_username(self, value):
enterprise = self._get_enterprise()
namespaced_username = f"{enterprise.user.email}_{value}"
if User.objects.filter(username=namespaced_username).exists():
raise ValidationError('该用户名已被占用')
return value
def create(self, validated_data):
enterprise = self._get_enterprise()
with transaction.atomic():
namespaced_username = f"{enterprise.user.email}_{validated_data['username']}"
user = User.objects.create_user(
username=namespaced_username,
password=validated_data['password'],
nickname=validated_data.get('nickname', validated_data['username'])
)
# Assign ENTERPRISE role so they can access enterprise portal
from .models import Role, UserRole
role, _ = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
UserRole.objects.get_or_create(user=user, role=role)
member = EnterpriseMember.objects.create(
enterprise=enterprise,
user=user,
role=validated_data.get('role', EnterpriseMember.MemberRole.MEMBER)
)
return member
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True, min_length=6)
class PasswordResetRequestSerializer(serializers.Serializer):
email = serializers.EmailField(required=True)
class PasswordResetConfirmSerializer(serializers.Serializer):
email = serializers.EmailField(required=True)
verification_code = serializers.CharField(required=True, min_length=6, max_length=6)
new_password = serializers.CharField(required=True, min_length=6)

3
users/tests.py Normal file
View File

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

23
users/urls.py Normal file
View File

@@ -0,0 +1,23 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import UserViewSet, RegisterView, EnterpriseViewSet, MyTokenObtainPairView, EnterpriseRegisterView, EnterpriseMemberViewSet, RoleViewSet, PermissionViewSet, EnterpriseLoginView
router = DefaultRouter()
router.register('users', UserViewSet, basename='user')
router.register('enterprises', EnterpriseViewSet)
router.register('enterprise-members', EnterpriseMemberViewSet, basename='enterprise-member')
router.register('roles', RoleViewSet, basename='role')
router.register('permissions', PermissionViewSet, basename='permission')
urlpatterns = [
path('auth/login/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('auth/enterprise/login/', EnterpriseLoginView.as_view(), name='auth_enterprise_login'),
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('auth/register/', RegisterView.as_view(), name='auth_register'),
path('auth/enterprise/register/', EnterpriseRegisterView.as_view(), name='auth_enterprise_register'),
path('auth/me/', UserViewSet.as_view({'get': 'me'}), name='auth_me'),
path('', include(router.urls)),
]

900
users/views.py Normal file
View File

@@ -0,0 +1,900 @@
from rest_framework import viewsets, generics, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
from rest_framework.views import APIView
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import get_user_model
from django.core.cache import cache
import random
from .serializers import (
UserSerializer, RegisterSerializer, EnterpriseSerializer, EnterpriseRegisterSerializer,
EnterpriseMemberSerializer, RoleSerializer, PermissionSerializer, EnterpriseLoginSerializer,
EnterpriseMemberAddSerializer, AdminUserCreateSerializer, ChangePasswordSerializer,
PasswordResetRequestSerializer, PasswordResetConfirmSerializer
)
from .models import Enterprise, EnterpriseMember, Role, Permission
from .permissions import HasAPIPermission
from rest_framework import permissions as drf_permissions
class EnterpriseLoginView(APIView):
"""
@author: xujl
Api说明: 企业管理员专属登录通道接口。校验企业身份并返回携带对应权限的JWT Token。
"""
permission_classes = [permissions.AllowAny]
def post(self, request, *args, **kwargs):
serializer = EnterpriseLoginSerializer(data=request.data)
if serializer.is_valid():
user = serializer.validated_data['user']
refresh = RefreshToken.for_user(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
'user': UserSerializer(user).data
})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
def validate(self, attrs):
username = attrs.get(self.username_field)
password = attrs.get('password')
from django.contrib.auth import get_user_model
from django.db.models import Q
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth.models import update_last_login
User = get_user_model()
user = User.objects.filter(Q(username=username) | Q(phone=username) | Q(email=username)).first()
if not user:
raise AuthenticationFailed('账号未注册', code='not_registered')
if not user.is_active or user.is_deleted:
raise AuthenticationFailed('您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。', code='account_disabled')
if not user.check_password(password):
raise AuthenticationFailed('密码错误,请检查后再试', code='wrong_password')
refresh = RefreshToken.for_user(user)
update_last_login(None, user)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
'user': UserSerializer(user).data
}
class MyTokenObtainPairView(TokenObtainPairView):
"""
@author: xujl
Api说明: 标准用户认证授权(JWT)接口视图。验证普通用户账号密码并下发访问凭证。
"""
serializer_class = MyTokenObtainPairSerializer
User = get_user_model()
class UserViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 平台用户中心接口视图。用于用户的增删改查、信息完善、封禁及状态管理,以及管理员的全局管控。
"""
serializer_class = UserSerializer
permission_classes = [drf_permissions.IsAuthenticated]
# Custom attributes for HasAPIPermission check
required_permissions = {
'GET': 'api:users:read',
'POST': 'api:users:write',
'PUT': 'api:users:write',
'PATCH': 'api:users:write',
'DELETE': 'api:users:delete'
}
def get_queryset(self):
from django.db.models import Q
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
queryset = User.objects.filter(is_deleted=False)
# Non-admins can only see OPC users
if not (self.request.user.is_staff or self.request.user.is_superuser):
queryset = queryset.filter(user_roles__role__code='OPC_USER')
role = self.request.query_params.get('role')
username = self.request.query_params.get('username')
nickname = self.request.query_params.get('nickname')
real_name = self.request.query_params.get('real_name')
phone = self.request.query_params.get('phone')
email = self.request.query_params.get('email')
status_filter = self.request.query_params.get('status')
idle = self.request.query_params.get('idle')
if role:
queryset = queryset.filter(user_roles__role__code=role)
if username:
queryset = queryset.filter(username__icontains=username)
if nickname:
queryset = queryset.filter(nickname__icontains=nickname)
if real_name:
queryset = queryset.filter(opc_certifications__real_name__icontains=real_name)
if phone:
queryset = queryset.filter(phone__icontains=phone)
if email:
queryset = queryset.filter(email__icontains=email)
if status_filter == 'active':
queryset = queryset.filter(is_active=True)
elif status_filter == 'disabled':
queryset = queryset.filter(is_active=False)
if idle == 'true':
active_app_users = TaskApplication.objects.filter(
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
).values_list('applicant_id', flat=True)
active_task_publishers = Task.objects.filter(
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
).values_list('publisher_id', flat=True)
queryset = queryset.exclude(id__in=active_app_users).exclude(id__in=active_task_publishers)
return queryset.order_by('-is_recommended', '-recommend_priority', '-created_at').distinct()
def get_permissions(self):
if self.action in ['me', 'update_profile', 'avatar', 'change_password', 'list', 'retrieve']:
return [drf_permissions.IsAuthenticated()]
if self.action in ['admin_create', 'toggle_status', 'reset_password', 'create', 'update', 'partial_update', 'destroy', 'update_opc_stats']:
return [HasAPIPermission()]
if self.action in ['request_password_reset', 'confirm_password_reset']:
return [drf_permissions.AllowAny()]
return super().get_permissions()
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAdminUser])
def admin_create(self, request):
serializer = AdminUserCreateSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
user.set_password(serializer.validated_data['password'])
user.save()
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# Handle explicitly read-only fields for Admin
if request.user.is_staff or request.user.is_superuser:
needs_save = False
for field in ['is_active', 'status', 'avatar_url', 'bio', 'location', 'face_url']:
if field in request.data:
setattr(instance, field, request.data[field])
needs_save = True
if needs_save:
instance.save()
# Handle Role Assignments inside the big form
if 'roles' in request.data:
role_codes = request.data['roles']
from .models import Role, UserRole
UserRole.objects.filter(user=instance).delete()
for code in role_codes:
role = Role.objects.filter(code=code).first()
if role:
UserRole.objects.create(user=instance, role=role, granted_by=request.user)
# Handle enterprise_info updates
enterprise_info = request.data.get('enterprise_info')
if enterprise_info and 'ENTERPRISE' in instance.roles:
from .models import Enterprise
ent, created = Enterprise.objects.get_or_create(user=instance, defaults={'status': 'VERIFIED'})
ent.company_name = enterprise_info.get('company_name', ent.company_name)
ent.credit_code = enterprise_info.get('credit_code', ent.credit_code)
ent.business_license = enterprise_info.get('business_license', ent.business_license)
ent.logo_url = enterprise_info.get('logo_url', ent.logo_url)
ent.contact_name = enterprise_info.get('contact_name', ent.contact_name)
ent.contact_phone = enterprise_info.get('contact_phone', ent.contact_phone)
ent.contact_email = enterprise_info.get('contact_email', ent.contact_email)
ent.address = enterprise_info.get('address', ent.address)
ent.description = enterprise_info.get('description', ent.description)
ent.save()
# Handle opc_certification updates
opc_certification = request.data.get('opc_certification')
if opc_certification and 'OPC_USER' in instance.roles:
from opc_cert.models import OpcCertification
cert, created = OpcCertification.objects.get_or_create(user=instance, defaults={'status': 'APPROVED'})
cert.real_name = opc_certification.get('real_name', cert.real_name)
cert.id_card = opc_certification.get('id_card', cert.id_card)
cert.experience = opc_certification.get('experience', cert.experience)
cert.resume_url = opc_certification.get('resume_url', cert.resume_url)
cert.attachments = opc_certification.get('attachments', cert.attachments)
cert.skills = opc_certification.get('skills', cert.skills)
cert.status = opc_certification.get('status', cert.status)
if 'rating' in opc_certification:
cert.rating = opc_certification.get('rating')
cert.save()
if 'rating' in opc_certification:
instance.rating = opc_certification.get('rating')
instance.save()
if getattr(instance, '_prefetched_objects_cache', None):
instance._prefetched_objects_cache = {}
return Response(self.get_serializer(instance).data)
@action(detail=False, methods=['post'])
def change_password(self, request):
serializer = ChangePasswordSerializer(data=request.data)
if serializer.is_valid():
user = request.user
if not user.check_password(serializer.validated_data['old_password']):
return Response({'old_password': ['原密码错误']}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(serializer.validated_data['new_password'])
user.save()
return Response({'status': '密码修改成功'})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'], permission_classes=[drf_permissions.AllowAny])
def request_password_reset(self, request):
serializer = PasswordResetRequestSerializer(data=request.data)
if serializer.is_valid():
email = serializer.validated_data['email']
user = User.objects.filter(email=email).first()
if user:
# Mock sending code: Generate 6-digit code and store in cache
code = ''.join(random.choices('0123456789', k=6))
cache.set(f'pwd_reset_{email}', code, timeout=300) # 5 minutes
# In production, send via Email/SMS. Here we return it or print it.
print(f"--- MOCK EMAIL --- Password reset code for {email}: {code}")
return Response({'status': '验证码已发送 (开发环境请查看控制台输出)'})
else:
# To prevent email enumeration, still return success
return Response({'status': '验证码已发送 (开发环境请查看控制台输出)'})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'], permission_classes=[drf_permissions.AllowAny])
def confirm_password_reset(self, request):
serializer = PasswordResetConfirmSerializer(data=request.data)
if serializer.is_valid():
email = serializer.validated_data['email']
code = serializer.validated_data['verification_code']
cached_code = cache.get(f'pwd_reset_{email}')
if not cached_code or cached_code != code:
return Response({'verification_code': ['验证码无效或已过期']}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(email=email).first()
if user:
user.set_password(serializer.validated_data['new_password'])
user.save()
cache.delete(f'pwd_reset_{email}')
return Response({'status': '密码重置成功'})
return Response({'detail': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def reset_password(self, request, pk=None):
user = self.get_object()
new_password = request.data.get('password', '123456')
user.set_password(new_password)
user.save()
return Response({'status': '密码已重置'})
@action(detail=False, methods=['get'])
def me(self, request):
serializer = self.get_serializer(request.user)
return Response(serializer.data)
def perform_destroy(self, instance):
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
from django.utils import timezone
force = self.request.query_params.get('force', '').lower() == 'true'
# Check active expert applications
active_apps = TaskApplication.objects.filter(
applicant=instance,
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
)
# Check active enterprise tasks
active_tasks = Task.objects.filter(
publisher=instance,
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
)
has_blockers = active_apps.exists() or active_tasks.exists()
if has_blockers and not force:
from rest_framework.exceptions import ValidationError
blocker_details = []
if active_apps.exists():
blocker_details.append(f'专家接单 {active_apps.count()}')
if active_tasks.exists():
blocker_details.append(f'发布任务 {active_tasks.count()}')
raise ValidationError(
f'用户 {instance.username} 有正在进行中的任务({"".join(blocker_details)}),无法直接删除。请先取消相关任务或强制删除。'
)
if has_blockers and force:
# Lock tasks instead of cancelling — mark as anomalous
# For tasks where this user is the ONLY active participant, set CANCELLED
# Otherwise keep IN_PROGRESS but lock the user's application
for app in active_apps:
app.status = ApplyStatus.REJECTED
app.reject_reason = f'用户 {instance.nickname or instance.username} 账户已被管理员删除/锁定'
app.save()
# Check if task still has other active participants
other_apps = app.task.applications.filter(
status__in=[ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
).exclude(applicant=instance)
if not other_apps.exists() and app.task.status == TaskStatus.IN_PROGRESS:
app.task.status = TaskStatus.CANCELLED
app.task.cancel_reason = f'唯一执行专家 {instance.nickname or instance.username} 已被系统锁定,任务异常中止'
app.task.cancelled_at = timezone.now()
app.task.save()
active_tasks.update(
status=TaskStatus.CANCELLED,
cancelled_at=timezone.now(),
cancel_reason=f'管理员强制删除用户 {instance.username},系统自动取消关联任务'
)
# Check and cascade close enterprise
enterprise = Enterprise.objects.filter(user=instance, is_deleted=False).first()
if enterprise:
# Close enterprise: soft delete + remove all members
enterprise.is_deleted = True
enterprise.status = 'REJECTED'
enterprise.save()
EnterpriseMember.objects.filter(enterprise=enterprise).delete()
# Cancel enterprise's published tasks
Task.objects.filter(
enterprise=enterprise,
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
).update(
status=TaskStatus.CANCELLED,
cancelled_at=timezone.now(),
cancel_reason=f'企业 {enterprise.company_name} 已被清退,关联任务自动取消'
)
instance.is_deleted = True
instance.is_active = False
# Free up unique fields for re-registration while preserving for audit
deleted_suffix = f'__deleted_{instance.id}'
if instance.phone:
instance.phone = instance.phone + deleted_suffix
if instance.email:
instance.email = instance.email + deleted_suffix
instance.username = instance.username + deleted_suffix
instance.save()
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAdminUser])
def batch_delete(self, request):
user_ids = request.data.get('user_ids', [])
force = request.data.get('force', False)
if not user_ids:
return Response({'detail': '缺少 user_ids'}, status=status.HTTP_400_BAD_REQUEST)
users = User.objects.filter(id__in=user_ids)
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
from django.utils import timezone
blocked_users = []
for user in users:
active_apps = TaskApplication.objects.filter(
applicant=user,
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
)
active_tasks = Task.objects.filter(
publisher=user,
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
)
if active_apps.exists() or active_tasks.exists():
blocked_users.append({
'id': str(user.id),
'username': user.username,
'nickname': user.nickname or user.username,
'app_count': active_apps.count(),
'task_count': active_tasks.count(),
})
if blocked_users and not force:
return Response({
'detail': f'{len(blocked_users)} 个用户有正在进行中的任务,无法直接批量删除。',
'blocked_users': blocked_users,
'has_blockers': True,
}, status=status.HTTP_400_BAD_REQUEST)
if blocked_users and force:
for user in users:
TaskApplication.objects.filter(
applicant=user,
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
).update(status=ApplyStatus.WITHDRAWN)
Task.objects.filter(
publisher=user,
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
).update(
status=TaskStatus.CANCELLED,
cancelled_at=timezone.now(),
cancel_reason=f'管理员批量强制删除,系统自动取消关联任务'
)
for user in users:
user.is_deleted = True
user.is_active = False
deleted_suffix = f'__deleted_{user.id}'
if user.phone:
user.phone = user.phone + deleted_suffix
if user.email:
user.email = user.email + deleted_suffix
user.username = user.username + deleted_suffix
user.save()
return Response({'status': '批量删除成功'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def assign_roles(self, request, pk=None):
user = self.get_object()
role_codes = request.data.get('roles', [])
from .models import Role, UserRole
# 清除旧角色
UserRole.objects.filter(user=user).delete()
# 重新分配
for code in role_codes:
role = Role.objects.filter(code=code).first()
if role:
UserRole.objects.create(user=user, role=role, granted_by=request.user)
return Response({'status': '角色分配成功'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def update_rating(self, request, pk=None):
user = self.get_object()
rating = request.data.get('rating')
if rating is not None:
user.rating = rating
user.save()
return Response({'status': '评分已更新'})
return Response({'detail': '缺少评分参数'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get'], permission_classes=[permissions.IsAdminUser])
def active_tasks(self, request, pk=None):
user = self.get_object()
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
results = []
# Expert tasks
expert_apps = TaskApplication.objects.filter(
applicant=user,
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
)
for app in expert_apps:
results.append({
'id': app.task.id,
'title': app.task.title,
'role': '专家接单',
'status': app.status,
'task_status': app.task.status,
'type': 'application'
})
# Publisher tasks
pub_tasks = Task.objects.filter(
publisher=user,
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
)
for t in pub_tasks:
results.append({
'id': t.id,
'title': t.title,
'role': '任务发布方',
'status': t.status,
'task_status': t.status,
'type': 'task'
})
return Response(results)
@action(detail=False, methods=['put'])
def update_profile(self, request):
user = request.user
serializer = UserSerializer(user, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def toggle_status(self, request, pk=None):
user = self.get_object()
user.is_active = not user.is_active
user.save()
return Response({'status': '状态已更新', 'is_active': user.is_active})
@action(detail=True, methods=['put'], permission_classes=[permissions.IsAdminUser])
def update_opc_stats(self, request, pk=None):
user = self.get_object()
rating = request.data.get('rating')
completed_tasks = request.data.get('completed_tasks')
if rating is not None:
user.rating = rating
if completed_tasks is not None:
user.completed_tasks = completed_tasks
user.save()
return Response(UserSerializer(user).data)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def toggle_recommend(self, request, pk=None):
user = self.get_object()
user.is_recommended = request.data.get('is_recommended', not user.is_recommended)
user.recommend_priority = request.data.get('recommend_priority', user.recommend_priority)
user.save(update_fields=['is_recommended', 'recommend_priority'])
return Response({'is_recommended': user.is_recommended, 'recommend_priority': user.recommend_priority})
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAdminUser])
def deleted_users(self, request):
"""List soft-deleted users for admin review/recovery."""
deleted = User.objects.filter(is_deleted=True).order_by('-updated_at')
page = self.paginate_queryset(deleted)
if page is not None:
serializer = UserSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = UserSerializer(deleted, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def restore(self, request, pk=None):
"""Restore a soft-deleted user by clearing the deletion suffix."""
try:
user = User.objects.get(id=pk, is_deleted=True)
except User.DoesNotExist:
return Response({'detail': '未找到已删除的用户'}, status=status.HTTP_404_NOT_FOUND)
deleted_suffix = f'__deleted_{user.id}'
# Restore unique fields
if user.username.endswith(deleted_suffix):
original_username = user.username[:-len(deleted_suffix)]
if User.objects.filter(username=original_username, is_deleted=False).exists():
return Response({'detail': f'用户名 {original_username} 已被新用户占用,无法恢复'}, status=status.HTTP_400_BAD_REQUEST)
user.username = original_username
if user.phone and user.phone.endswith(deleted_suffix):
original_phone = user.phone[:-len(deleted_suffix)]
if User.objects.filter(phone=original_phone, is_deleted=False).exists():
return Response({'detail': f'手机号 {original_phone} 已被新用户占用,无法恢复'}, status=status.HTTP_400_BAD_REQUEST)
user.phone = original_phone
if user.email and user.email.endswith(deleted_suffix):
original_email = user.email[:-len(deleted_suffix)]
if User.objects.filter(email=original_email, is_deleted=False).exists():
return Response({'detail': f'邮箱 {original_email} 已被新用户占用,无法恢复'}, status=status.HTTP_400_BAD_REQUEST)
user.email = original_email
user.is_deleted = False
user.is_active = True
user.save()
return Response({'status': f'用户 {user.username} 已成功恢复'})
@action(detail=False, methods=['post'])
def avatar(self, request):
# In a real app, handle file upload to MinIO
# For now, we expect a URL or just mock it
user = request.user
avatar_url = request.data.get('avatar_url')
if avatar_url:
user.avatar_url = avatar_url
user.save()
return Response({'status': '头像已更新', 'avatar_url': avatar_url})
return Response({'detail': '缺少 avatar_url'}, status=status.HTTP_400_BAD_REQUEST)
class RegisterView(generics.CreateAPIView):
"""
@author: xujl
Api说明: 平台普通用户注册接口视图。提供基础的账号密码/手机/邮箱注册流程。
"""
queryset = User.objects.all()
permission_classes = [permissions.AllowAny]
serializer_class = RegisterSerializer
class EnterpriseViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 企业资质及账号管理接口视图。企业用户提交工商资料认证、基本信息更新,管理员对企业入驻进行审核。
"""
queryset = Enterprise.objects.filter(is_deleted=False).order_by('-created_at')
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'create' and (self.request.user.is_staff or self.request.user.is_superuser):
from .serializers import AdminEnterpriseCreateSerializer
return AdminEnterpriseCreateSerializer
from .serializers import EnterpriseSerializer
return EnterpriseSerializer
required_permissions = {
'GET': 'api:enterprises:read',
'POST': 'api:enterprises:write',
'PUT': 'api:enterprises:write',
'PATCH': 'api:enterprises:write',
'DELETE': 'api:enterprises:delete'
}
def get_permissions(self):
if self.action in ['me', 'create', 'update', 'partial_update']:
return [drf_permissions.IsAuthenticated()]
if self.action in ['list', 'retrieve', 'verify_status']:
return [HasAPIPermission()]
return super().get_permissions()
@action(detail=False, methods=['get'])
def me(self, request):
try:
# First check if user is the enterprise owner
enterprise = Enterprise.objects.filter(user=request.user).first()
role = 'ADMIN'
if not enterprise:
# If not owner, check if user is a member
member = EnterpriseMember.objects.filter(user=request.user).first()
if member:
enterprise = member.enterprise
role = member.role
else:
return Response({'detail': '未找到企业信息'}, status=status.HTTP_404_NOT_FOUND)
serializer = self.get_serializer(enterprise)
data = serializer.data
data['my_role'] = role
return Response(data)
except Exception as e:
return Response({'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def perform_create(self, serializer):
if self.request.user.is_staff or self.request.user.is_superuser:
serializer.save()
else:
enterprise = serializer.save(user=self.request.user)
# Auto assign ENTERPRISE role
from .models import Role, UserRole
role, created = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
UserRole.objects.get_or_create(user=self.request.user, role=role)
def _check_admin_permission(self, request, enterprise):
if request.user.is_superuser or request.user.is_staff:
return True
if request.user == enterprise.user:
return True
member = EnterpriseMember.objects.filter(enterprise=enterprise, user=request.user).first()
return member and member.role == 'ADMIN'
def update(self, request, *args, **kwargs):
enterprise = self.get_object()
if not self._check_admin_permission(request, enterprise):
return Response({'detail': '只有企业管理员可以编辑企业数据'}, status=status.HTTP_403_FORBIDDEN)
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
enterprise = self.get_object()
if not self._check_admin_permission(request, enterprise):
return Response({'detail': '只有企业管理员可以编辑企业数据'}, status=status.HTTP_403_FORBIDDEN)
return super().partial_update(request, *args, **kwargs)
def perform_destroy(self, instance):
"""Cascade enterprise deletion: soft-delete, remove members, cancel active tasks, free email."""
from tasks.models import Task, TaskStatus
from django.utils import timezone
# Cancel all active tasks for this enterprise
Task.objects.filter(
enterprise=instance,
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
).update(
status=TaskStatus.CANCELLED,
cancelled_at=timezone.now(),
cancel_reason=f'企业 {instance.company_name} 已被清退,项目中止'
)
# Remove all team members
EnterpriseMember.objects.filter(enterprise=instance).delete()
# Free up the enterprise user's email for re-use (suffix pattern)
owner = instance.user
suffix = f'__deleted_{str(instance.id)[:8]}'
if owner.email and '__deleted_' not in owner.email:
owner.email = owner.email + suffix
owner.save(update_fields=['email'])
# Free up credit_code
if instance.credit_code and '__deleted_' not in instance.credit_code:
instance.credit_code = instance.credit_code + suffix
# Soft delete enterprise
instance.is_deleted = True
instance.status = 'REJECTED'
instance.save()
@action(detail=False, methods=['get'])
def deleted_enterprises(self, request):
"""List soft-deleted enterprises for the recycle bin."""
deleted = Enterprise.objects.filter(is_deleted=True).order_by('-updated_at')
serializer = self.get_serializer(deleted, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def restore(self, request, pk=None):
"""Restore a soft-deleted enterprise."""
enterprise = Enterprise.objects.filter(pk=pk, is_deleted=True).first()
if not enterprise:
return Response({'detail': '未找到已删除的企业'}, status=status.HTTP_404_NOT_FOUND)
# Restore credit_code
if enterprise.credit_code and '__deleted_' in enterprise.credit_code:
original_code = enterprise.credit_code.split('__deleted_')[0]
if Enterprise.objects.filter(credit_code=original_code, is_deleted=False).exists():
return Response({'detail': f'统一社会信用代码 {original_code} 已被其他企业占用'}, status=status.HTTP_409_CONFLICT)
enterprise.credit_code = original_code
# Restore owner email
owner = enterprise.user
if owner.email and '__deleted_' in owner.email:
original_email = owner.email.split('__deleted_')[0]
if User.objects.filter(email=original_email, is_deleted=False).exclude(pk=owner.pk).exists():
return Response({'detail': f'邮箱 {original_email} 已被其他用户占用,无法恢复'}, status=status.HTTP_409_CONFLICT)
owner.email = original_email
owner.save(update_fields=['email'])
enterprise.is_deleted = False
enterprise.status = 'VERIFIED'
enterprise.save()
return Response({'detail': '企业已恢复'})
@action(detail=True, methods=['post'])
def verify_status(self, request, pk=None):
enterprise = self.get_object()
status_val = request.data.get('status')
if status_val not in [choice[0] for choice in Enterprise.EnterpriseStatus.choices]:
return Response({'detail': '无效的状态'}, status=status.HTTP_400_BAD_REQUEST)
enterprise.status = status_val
enterprise.save()
return Response({'status': '企业审核状态已更新', 'enterprise_status': enterprise.status})
class EnterpriseRegisterView(generics.CreateAPIView):
"""
@author: xujl
Api说明: 企业级账号入驻/注册接口视图。提交企业信用代码和法人代表等认证资料。
"""
serializer_class = EnterpriseRegisterSerializer
permission_classes = [permissions.AllowAny]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'status': '企业注册成功'}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class EnterpriseMemberViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: 企业内部成员管理接口视图。用于给企业内部关联多个员工账号并分配角色(管理员、普通成员)。
"""
serializer_class = EnterpriseMemberSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if self.request.user.is_staff or self.request.user.is_superuser:
enterprise_id = self.request.query_params.get('enterprise')
if enterprise_id:
return EnterpriseMember.objects.filter(enterprise_id=enterprise_id).order_by('-joined_at')
return EnterpriseMember.objects.all().order_by('-joined_at')
try:
enterprise = Enterprise.objects.get(user=self.request.user)
return EnterpriseMember.objects.filter(enterprise=enterprise).order_by('-joined_at')
except Enterprise.DoesNotExist:
return EnterpriseMember.objects.none()
def perform_create(self, serializer):
enterprise = Enterprise.objects.get(user=self.request.user)
serializer.save(enterprise=enterprise)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def add_member(self, request):
serializer = EnterpriseMemberAddSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
member = serializer.save()
return Response(EnterpriseMemberSerializer(member).data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAdminUser])
def batch_delete(self, request):
member_ids = request.data.get('member_ids', [])
if not member_ids:
return Response({'detail': '缺少 member_ids'}, status=status.HTTP_400_BAD_REQUEST)
EnterpriseMember.objects.filter(id__in=member_ids).delete()
return Response({'status': '批量删除成功'})
@action(detail=False, methods=['post'])
def invite(self, request):
email = request.data.get('email')
role = request.data.get('role', 'MEMBER')
try:
enterprise = Enterprise.objects.get(user=request.user)
user = User.objects.get(email=email)
if EnterpriseMember.objects.filter(enterprise=enterprise, user=user).exists():
return Response({'detail': '该用户已在团队中'}, status=status.HTTP_400_BAD_REQUEST)
member = EnterpriseMember.objects.create(
enterprise=enterprise,
user=user,
role=role
)
return Response(EnterpriseMemberSerializer(member).data, status=status.HTTP_201_CREATED)
except Enterprise.DoesNotExist:
return Response({'detail': '未找到企业信息'}, status=status.HTTP_404_NOT_FOUND)
except User.DoesNotExist:
return Response({'detail': '该用户尚未注册平台,请先引导其注册'}, status=status.HTTP_404_NOT_FOUND)
class RoleViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: RBAC 角色管理接口视图。用于定义平台角色类别,以及为用户进行角色的授予和回收。
"""
queryset = Role.objects.all().order_by('sort_order', 'created_at')
serializer_class = RoleSerializer
pagination_class = None # Return all roles without pagination
permission_classes = [HasAPIPermission]
required_permissions = {
'GET': 'menu:account:roles',
'POST': 'menu:account:roles',
'PUT': 'menu:account:roles',
'DELETE': 'menu:account:roles'
}
@action(detail=True, methods=['post'])
def assign_permissions(self, request, pk=None):
role = self.get_object()
permission_ids = request.data.get('permissions', [])
from .models import Permission, RolePermission
RolePermission.objects.filter(role=role).delete()
for pid in permission_ids:
perm = Permission.objects.filter(id=pid).first()
if perm:
RolePermission.objects.create(role=role, permission=perm)
return Response({'status': '权限分配成功'})
class PermissionViewSet(viewsets.ModelViewSet):
"""
@author: xujl
Api说明: RBAC 权限节点管理接口视图。用于查询系统的路由、菜单及按钮级别的权限树结构。
"""
queryset = Permission.objects.all().order_by('sort_order', 'created_at')
serializer_class = PermissionSerializer
pagination_class = None # Return all permissions without pagination
permission_classes = [HasAPIPermission]
required_permissions = {
'GET': 'menu:account:permissions',
'POST': 'menu:account:permissions',
'PUT': 'menu:account:permissions',
'DELETE': 'menu:account:permissions'
}