From 23855ef0e4e8d2207a4647e0402873b37192181a Mon Sep 17 00:00:00 2001 From: xujl <2585081818@qq.com> Date: Tue, 28 Apr 2026 16:32:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=8F=91=E4=BA=86=E5=A4=9A=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E7=99=BB=E5=BD=95=E4=B8=8E=E9=89=B4=E6=9D=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=9A=E5=AE=9E=E7=8E=B0=E4=BA=86=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E3=80=81=E4=BC=81=E4=B8=9A=E5=92=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E7=9A=84=E7=99=BB=E5=BD=95=E5=88=86=E6=B5=81?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81Token=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E3=80=82=20=E5=BC=80=E5=8F=91=E4=BA=86=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=8E=A5=E5=8F=A3=EF=BC=9A=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BA=86=E9=80=9A=E8=BF=87=E6=95=B0=E6=8D=AE=E5=BA=93=E5=88=86?= =?UTF-8?q?=E9=85=8D=E8=8F=9C=E5=8D=95=E6=9D=83=E9=99=90=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E6=8E=A7=E5=88=B6=E6=8E=A5=E5=8F=A3=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E3=80=82=20=E5=BC=80=E5=8F=91=E4=BA=86?= =?UTF-8?q?=E5=AE=9E=E5=90=8D=E8=AE=A4=E8=AF=81=E4=B8=AD=E5=BF=83=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BA=86=E4=B8=AA=E4=BA=BA=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E8=AF=81=E4=BF=A1=E6=81=AF=E4=B8=8E=E4=BC=81=E4=B8=9A=E8=90=A5?= =?UTF-8?q?=E4=B8=9A=E6=89=A7=E7=85=A7=E7=9A=84=E6=8F=90=E4=BA=A4=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=8E=A5=E5=8F=A3=E3=80=82=20=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E4=BA=86=E4=BB=BB=E5=8A=A1=E4=B8=8E=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E5=A4=A7=E5=8E=85=E6=A0=B8=E5=BF=83=E4=B8=9A=E5=8A=A1=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BA=86=E4=BB=BB=E5=8A=A1=E7=9A=84=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E3=80=81=E6=8E=A5=E5=8D=95=E3=80=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=B5=81=E8=BD=AC=E4=BB=A5=E5=8F=8A=E4=B8=93=E5=AE=B6=E9=82=80?= =?UTF-8?q?=E7=BA=A6=E6=8E=A5=E5=8F=A3=E3=80=82=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=BA=86=E5=85=A8=E5=B1=80=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93=E5=BC=95=E6=93=8E=EF=BC=9A?= =?UTF-8?q?=E9=9B=86=E6=88=90=E4=BA=86=20PostgreSQL=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E3=80=81Redis=20=E7=BC=93=E5=AD=98=E4=B8=8E=20MinIO?= =?UTF-8?q?=20=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 14 + admin_init.py | 61 ++ commit_message.txt | 5 + core/__init__.py | 0 core/asgi.py | 16 + core/settings.py | 108 +++ core/urls.py | 18 + core/wsgi.py | 16 + manage.py | 22 + opc_cert/__init__.py | 0 opc_cert/admin.py | 3 + opc_cert/apps.py | 6 + opc_cert/migrations/0001_initial.py | 40 + opc_cert/migrations/0002_initial.py | 33 + opc_cert/migrations/__init__.py | 0 opc_cert/models.py | 32 + opc_cert/serializers.py | 21 + opc_cert/tests.py | 3 + opc_cert/urls.py | 10 + opc_cert/views.py | 91 ++ reservations/__init__.py | 0 reservations/admin.py | 3 + reservations/apps.py | 6 + reservations/migrations/0001_initial.py | 66 ++ reservations/migrations/0002_initial.py | 28 + reservations/migrations/__init__.py | 0 reservations/models.py | 59 ++ reservations/serializers.py | 14 + reservations/tests.py | 3 + reservations/urls.py | 11 + reservations/views.py | 61 ++ seed_permissions.py | 59 ++ seed_phase5.py | 86 ++ seed_roles_pages.py | 103 ++ seed_task_admin.py | 55 ++ system/__init__.py | 0 system/admin.py | 3 + system/apps.py | 6 + system/migrations/0001_initial.py | 108 +++ system/migrations/0002_initial.py | 43 + system/migrations/0003_aimodel.py | 30 + system/migrations/0004_skill.py | 30 + ...005_alter_announcement_options_and_more.py | 27 + .../0006_announcement_target_audience.py | 18 + system/migrations/__init__.py | 0 system/minio_utils.py | 78 ++ system/models.py | 132 +++ system/serializers.py | 36 + system/tests.py | 3 + system/urls.py | 17 + system/views.py | 152 +++ tasks/__init__.py | 0 tasks/admin.py | 3 + tasks/apps.py | 6 + tasks/management/__init__.py | 0 tasks/management/commands/__init__.py | 0 tasks/management/commands/seed_tasks.py | 141 +++ tasks/management/commands/seed_users.py | 120 +++ tasks/migrations/0001_initial.py | 61 ++ tasks/migrations/0002_initial.py | 53 ++ tasks/migrations/0003_taskinvitation.py | 32 + ...ontact_email_task_contact_name_and_more.py | 41 + .../0005_alter_task_status_and_more.py | 24 + ...askapplication_completion_note_and_more.py | 28 + ..._alter_taskapplication_options_and_more.py | 43 + .../0008_taskinvitation_messages.py | 18 + ...009_taskapplication_negotiation_history.py | 18 + ...sk_options_task_is_recommended_and_more.py | 47 + tasks/migrations/__init__.py | 0 tasks/models.py | 129 +++ tasks/serializers.py | 69 ++ tasks/tests.py | 3 + tasks/urls.py | 12 + tasks/views.py | 487 ++++++++++ users/__init__.py | 0 users/admin.py | 3 + users/apps.py | 6 + users/migrations/0001_initial.py | 126 +++ .../migrations/0002_enterprise_credit_code.py | 18 + .../migrations/0003_user_bio_user_location.py | 23 + .../0004_alter_enterprise_credit_code.py | 18 + users/migrations/0005_enterprisemember.py | 30 + .../0006_user_completed_tasks_user_rating.py | 23 + users/migrations/0007_enterprise_status.py | 18 + users/migrations/0008_enterprise_logo_url.py | 18 + ..._is_recommended_user_recommend_priority.py | 23 + users/migrations/0010_enterprise_landline.py | 18 + users/migrations/__init__.py | 0 users/models.py | 160 ++++ users/permissions.py | 31 + users/serializers.py | 438 +++++++++ users/tests.py | 3 + users/urls.py | 23 + users/views.py | 900 ++++++++++++++++++ 94 files changed, 4950 insertions(+) create mode 100644 .gitignore create mode 100644 admin_init.py create mode 100644 commit_message.txt create mode 100644 core/__init__.py create mode 100644 core/asgi.py create mode 100644 core/settings.py create mode 100644 core/urls.py create mode 100644 core/wsgi.py create mode 100755 manage.py create mode 100644 opc_cert/__init__.py create mode 100644 opc_cert/admin.py create mode 100644 opc_cert/apps.py create mode 100644 opc_cert/migrations/0001_initial.py create mode 100644 opc_cert/migrations/0002_initial.py create mode 100644 opc_cert/migrations/__init__.py create mode 100644 opc_cert/models.py create mode 100644 opc_cert/serializers.py create mode 100644 opc_cert/tests.py create mode 100644 opc_cert/urls.py create mode 100644 opc_cert/views.py create mode 100644 reservations/__init__.py create mode 100644 reservations/admin.py create mode 100644 reservations/apps.py create mode 100644 reservations/migrations/0001_initial.py create mode 100644 reservations/migrations/0002_initial.py create mode 100644 reservations/migrations/__init__.py create mode 100644 reservations/models.py create mode 100644 reservations/serializers.py create mode 100644 reservations/tests.py create mode 100644 reservations/urls.py create mode 100644 reservations/views.py create mode 100644 seed_permissions.py create mode 100644 seed_phase5.py create mode 100644 seed_roles_pages.py create mode 100644 seed_task_admin.py create mode 100644 system/__init__.py create mode 100644 system/admin.py create mode 100644 system/apps.py create mode 100644 system/migrations/0001_initial.py create mode 100644 system/migrations/0002_initial.py create mode 100644 system/migrations/0003_aimodel.py create mode 100644 system/migrations/0004_skill.py create mode 100644 system/migrations/0005_alter_announcement_options_and_more.py create mode 100644 system/migrations/0006_announcement_target_audience.py create mode 100644 system/migrations/__init__.py create mode 100644 system/minio_utils.py create mode 100644 system/models.py create mode 100644 system/serializers.py create mode 100644 system/tests.py create mode 100644 system/urls.py create mode 100644 system/views.py create mode 100644 tasks/__init__.py create mode 100644 tasks/admin.py create mode 100644 tasks/apps.py create mode 100644 tasks/management/__init__.py create mode 100644 tasks/management/commands/__init__.py create mode 100644 tasks/management/commands/seed_tasks.py create mode 100644 tasks/management/commands/seed_users.py create mode 100644 tasks/migrations/0001_initial.py create mode 100644 tasks/migrations/0002_initial.py create mode 100644 tasks/migrations/0003_taskinvitation.py create mode 100644 tasks/migrations/0004_task_contact_email_task_contact_name_and_more.py create mode 100644 tasks/migrations/0005_alter_task_status_and_more.py create mode 100644 tasks/migrations/0006_taskapplication_completion_note_and_more.py create mode 100644 tasks/migrations/0007_alter_task_options_alter_taskapplication_options_and_more.py create mode 100644 tasks/migrations/0008_taskinvitation_messages.py create mode 100644 tasks/migrations/0009_taskapplication_negotiation_history.py create mode 100644 tasks/migrations/0010_alter_task_options_task_is_recommended_and_more.py create mode 100644 tasks/migrations/__init__.py create mode 100644 tasks/models.py create mode 100644 tasks/serializers.py create mode 100644 tasks/tests.py create mode 100644 tasks/urls.py create mode 100644 tasks/views.py create mode 100644 users/__init__.py create mode 100644 users/admin.py create mode 100644 users/apps.py create mode 100644 users/migrations/0001_initial.py create mode 100644 users/migrations/0002_enterprise_credit_code.py create mode 100644 users/migrations/0003_user_bio_user_location.py create mode 100644 users/migrations/0004_alter_enterprise_credit_code.py create mode 100644 users/migrations/0005_enterprisemember.py create mode 100644 users/migrations/0006_user_completed_tasks_user_rating.py create mode 100644 users/migrations/0007_enterprise_status.py create mode 100644 users/migrations/0008_enterprise_logo_url.py create mode 100644 users/migrations/0009_user_is_recommended_user_recommend_priority.py create mode 100644 users/migrations/0010_enterprise_landline.py create mode 100644 users/migrations/__init__.py create mode 100644 users/models.py create mode 100644 users/permissions.py create mode 100644 users/serializers.py create mode 100644 users/tests.py create mode 100644 users/urls.py create mode 100644 users/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40fe459 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +db.sqlite3 +media/ diff --git a/admin_init.py b/admin_init.py new file mode 100644 index 0000000..2e5ba1e --- /dev/null +++ b/admin_init.py @@ -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() diff --git a/commit_message.txt b/commit_message.txt new file mode 100644 index 0000000..f1c0230 --- /dev/null +++ b/commit_message.txt @@ -0,0 +1,5 @@ +开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。 +开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。 +开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。 +开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。 +配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/asgi.py b/core/asgi.py new file mode 100644 index 0000000..3b35c6b --- /dev/null +++ b/core/asgi.py @@ -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() diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..09d15a0 --- /dev/null +++ b/core/settings.py @@ -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') diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..46ff401 --- /dev/null +++ b/core/urls.py @@ -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')), +] diff --git a/core/wsgi.py b/core/wsgi.py new file mode 100644 index 0000000..f44964d --- /dev/null +++ b/core/wsgi.py @@ -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() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..f2a662c --- /dev/null +++ b/manage.py @@ -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() diff --git a/opc_cert/__init__.py b/opc_cert/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opc_cert/admin.py b/opc_cert/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/opc_cert/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/opc_cert/apps.py b/opc_cert/apps.py new file mode 100644 index 0000000..ed34414 --- /dev/null +++ b/opc_cert/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OpcCertConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'opc_cert' diff --git a/opc_cert/migrations/0001_initial.py b/opc_cert/migrations/0001_initial.py new file mode 100644 index 0000000..9d2383e --- /dev/null +++ b/opc_cert/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/opc_cert/migrations/0002_initial.py b/opc_cert/migrations/0002_initial.py new file mode 100644 index 0000000..910c39a --- /dev/null +++ b/opc_cert/migrations/0002_initial.py @@ -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), + ), + ] diff --git a/opc_cert/migrations/__init__.py b/opc_cert/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opc_cert/models.py b/opc_cert/models.py new file mode 100644 index 0000000..9f65ebe --- /dev/null +++ b/opc_cert/models.py @@ -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' diff --git a/opc_cert/serializers.py b/opc_cert/serializers.py new file mode 100644 index 0000000..340e4b8 --- /dev/null +++ b/opc_cert/serializers.py @@ -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 + } diff --git a/opc_cert/tests.py b/opc_cert/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/opc_cert/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/opc_cert/urls.py b/opc_cert/urls.py new file mode 100644 index 0000000..dcc289e --- /dev/null +++ b/opc_cert/urls.py @@ -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)), +] diff --git a/opc_cert/views.py b/opc_cert/views.py new file mode 100644 index 0000000..307e2d0 --- /dev/null +++ b/opc_cert/views.py @@ -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说明: OPC(One 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': '认证已驳回'}) diff --git a/reservations/__init__.py b/reservations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/admin.py b/reservations/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/reservations/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/reservations/apps.py b/reservations/apps.py new file mode 100644 index 0000000..b26d61a --- /dev/null +++ b/reservations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReservationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'reservations' diff --git a/reservations/migrations/0001_initial.py b/reservations/migrations/0001_initial.py new file mode 100644 index 0000000..79686f0 --- /dev/null +++ b/reservations/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/reservations/migrations/0002_initial.py b/reservations/migrations/0002_initial.py new file mode 100644 index 0000000..c06210f --- /dev/null +++ b/reservations/migrations/0002_initial.py @@ -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), + ), + ] diff --git a/reservations/migrations/__init__.py b/reservations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/models.py b/reservations/models.py new file mode 100644 index 0000000..8ae4997 --- /dev/null +++ b/reservations/models.py @@ -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' diff --git a/reservations/serializers.py b/reservations/serializers.py new file mode 100644 index 0000000..c895020 --- /dev/null +++ b/reservations/serializers.py @@ -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'] diff --git a/reservations/tests.py b/reservations/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/reservations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/reservations/urls.py b/reservations/urls.py new file mode 100644 index 0000000..f3c2073 --- /dev/null +++ b/reservations/urls.py @@ -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)), +] diff --git a/reservations/views.py b/reservations/views.py new file mode 100644 index 0000000..dc018c5 --- /dev/null +++ b/reservations/views.py @@ -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}) diff --git a/seed_permissions.py b/seed_permissions.py new file mode 100644 index 0000000..34b630b --- /dev/null +++ b/seed_permissions.py @@ -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() diff --git a/seed_phase5.py b/seed_phase5.py new file mode 100644 index 0000000..f3a7f42 --- /dev/null +++ b/seed_phase5.py @@ -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() diff --git a/seed_roles_pages.py b/seed_roles_pages.py new file mode 100644 index 0000000..3da5e7a --- /dev/null +++ b/seed_roles_pages.py @@ -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() diff --git a/seed_task_admin.py b/seed_task_admin.py new file mode 100644 index 0000000..2f5b0d9 --- /dev/null +++ b/seed_task_admin.py @@ -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() diff --git a/system/__init__.py b/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system/admin.py b/system/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/system/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/system/apps.py b/system/apps.py new file mode 100644 index 0000000..a2d131d --- /dev/null +++ b/system/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SystemConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'system' diff --git a/system/migrations/0001_initial.py b/system/migrations/0001_initial.py new file mode 100644 index 0000000..b1a6b9c --- /dev/null +++ b/system/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/system/migrations/0002_initial.py b/system/migrations/0002_initial.py new file mode 100644 index 0000000..aeae0e6 --- /dev/null +++ b/system/migrations/0002_initial.py @@ -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), + ), + ] diff --git a/system/migrations/0003_aimodel.py b/system/migrations/0003_aimodel.py new file mode 100644 index 0000000..a713be1 --- /dev/null +++ b/system/migrations/0003_aimodel.py @@ -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', + }, + ), + ] diff --git a/system/migrations/0004_skill.py b/system/migrations/0004_skill.py new file mode 100644 index 0000000..c8f0c06 --- /dev/null +++ b/system/migrations/0004_skill.py @@ -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'], + }, + ), + ] diff --git a/system/migrations/0005_alter_announcement_options_and_more.py b/system/migrations/0005_alter_announcement_options_and_more.py new file mode 100644 index 0000000..2d43da2 --- /dev/null +++ b/system/migrations/0005_alter_announcement_options_and_more.py @@ -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='推荐优先级, 越大越靠前'), + ), + ] diff --git a/system/migrations/0006_announcement_target_audience.py b/system/migrations/0006_announcement_target_audience.py new file mode 100644 index 0000000..c2b2470 --- /dev/null +++ b/system/migrations/0006_announcement_target_audience.py @@ -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), + ), + ] diff --git a/system/migrations/__init__.py b/system/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system/minio_utils.py b/system/minio_utils.py new file mode 100644 index 0000000..6c65b51 --- /dev/null +++ b/system/minio_utils.py @@ -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() + diff --git a/system/models.py b/system/models.py new file mode 100644 index 0000000..3257319 --- /dev/null +++ b/system/models.py @@ -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 + diff --git a/system/serializers.py b/system/serializers.py new file mode 100644 index 0000000..2df5d7e --- /dev/null +++ b/system/serializers.py @@ -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'] diff --git a/system/tests.py b/system/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/system/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/system/urls.py b/system/urls.py new file mode 100644 index 0000000..5d29178 --- /dev/null +++ b/system/urls.py @@ -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)), +] + diff --git a/system/views.py b/system/views.py new file mode 100644 index 0000000..9f38694 --- /dev/null +++ b/system/views.py @@ -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) diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/admin.py b/tasks/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/tasks/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tasks/apps.py b/tasks/apps.py new file mode 100644 index 0000000..3ff3ab3 --- /dev/null +++ b/tasks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tasks' diff --git a/tasks/management/__init__.py b/tasks/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/management/commands/__init__.py b/tasks/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/management/commands/seed_tasks.py b/tasks/management/commands/seed_tasks.py new file mode 100644 index 0000000..acdf2d9 --- /dev/null +++ b/tasks/management/commands/seed_tasks.py @@ -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.')) diff --git a/tasks/management/commands/seed_users.py b/tasks/management/commands/seed_users.py new file mode 100644 index 0000000..834ff1a --- /dev/null +++ b/tasks/management/commands/seed_users.py @@ -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} 个认证专家用户')) diff --git a/tasks/migrations/0001_initial.py b/tasks/migrations/0001_initial.py new file mode 100644 index 0000000..69a8618 --- /dev/null +++ b/tasks/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/tasks/migrations/0002_initial.py b/tasks/migrations/0002_initial.py new file mode 100644 index 0000000..840e684 --- /dev/null +++ b/tasks/migrations/0002_initial.py @@ -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')}, + ), + ] diff --git a/tasks/migrations/0003_taskinvitation.py b/tasks/migrations/0003_taskinvitation.py new file mode 100644 index 0000000..8b9f726 --- /dev/null +++ b/tasks/migrations/0003_taskinvitation.py @@ -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', + }, + ), + ] diff --git a/tasks/migrations/0004_task_contact_email_task_contact_name_and_more.py b/tasks/migrations/0004_task_contact_email_task_contact_name_and_more.py new file mode 100644 index 0000000..603496a --- /dev/null +++ b/tasks/migrations/0004_task_contact_email_task_contact_name_and_more.py @@ -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), + ), + ] diff --git a/tasks/migrations/0005_alter_task_status_and_more.py b/tasks/migrations/0005_alter_task_status_and_more.py new file mode 100644 index 0000000..b2d5c68 --- /dev/null +++ b/tasks/migrations/0005_alter_task_status_and_more.py @@ -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')}, + ), + ] diff --git a/tasks/migrations/0006_taskapplication_completion_note_and_more.py b/tasks/migrations/0006_taskapplication_completion_note_and_more.py new file mode 100644 index 0000000..85f6c95 --- /dev/null +++ b/tasks/migrations/0006_taskapplication_completion_note_and_more.py @@ -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), + ), + ] diff --git a/tasks/migrations/0007_alter_task_options_alter_taskapplication_options_and_more.py b/tasks/migrations/0007_alter_task_options_alter_taskapplication_options_and_more.py new file mode 100644 index 0000000..c1a121b --- /dev/null +++ b/tasks/migrations/0007_alter_task_options_alter_taskapplication_options_and_more.py @@ -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'], + }, + ), + ] diff --git a/tasks/migrations/0008_taskinvitation_messages.py b/tasks/migrations/0008_taskinvitation_messages.py new file mode 100644 index 0000000..f26359b --- /dev/null +++ b/tasks/migrations/0008_taskinvitation_messages.py @@ -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), + ), + ] diff --git a/tasks/migrations/0009_taskapplication_negotiation_history.py b/tasks/migrations/0009_taskapplication_negotiation_history.py new file mode 100644 index 0000000..d666160 --- /dev/null +++ b/tasks/migrations/0009_taskapplication_negotiation_history.py @@ -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), + ), + ] diff --git a/tasks/migrations/0010_alter_task_options_task_is_recommended_and_more.py b/tasks/migrations/0010_alter_task_options_task_is_recommended_and_more.py new file mode 100644 index 0000000..bc1b788 --- /dev/null +++ b/tasks/migrations/0010_alter_task_options_task_is_recommended_and_more.py @@ -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'], + }, + ), + ] diff --git a/tasks/migrations/__init__.py b/tasks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/models.py b/tasks/models.py new file mode 100644 index 0000000..8232b03 --- /dev/null +++ b/tasks/models.py @@ -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'] diff --git a/tasks/serializers.py b/tasks/serializers.py new file mode 100644 index 0000000..29c5422 --- /dev/null +++ b/tasks/serializers.py @@ -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'] + diff --git a/tasks/tests.py b/tasks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tasks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tasks/urls.py b/tasks/urls.py new file mode 100644 index 0000000..7d8cf57 --- /dev/null +++ b/tasks/urls.py @@ -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)), +] diff --git a/tasks/views.py b/tasks/views.py new file mode 100644 index 0000000..458fe54 --- /dev/null +++ b/tasks/views.py @@ -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': '该次交付已驳回'}) diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..72b1401 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..f26c69b --- /dev/null +++ b/users/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/users/migrations/0002_enterprise_credit_code.py b/users/migrations/0002_enterprise_credit_code.py new file mode 100644 index 0000000..f0ac054 --- /dev/null +++ b/users/migrations/0002_enterprise_credit_code.py @@ -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), + ), + ] diff --git a/users/migrations/0003_user_bio_user_location.py b/users/migrations/0003_user_bio_user_location.py new file mode 100644 index 0000000..0d4dc77 --- /dev/null +++ b/users/migrations/0003_user_bio_user_location.py @@ -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), + ), + ] diff --git a/users/migrations/0004_alter_enterprise_credit_code.py b/users/migrations/0004_alter_enterprise_credit_code.py new file mode 100644 index 0000000..5d6e6ae --- /dev/null +++ b/users/migrations/0004_alter_enterprise_credit_code.py @@ -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), + ), + ] diff --git a/users/migrations/0005_enterprisemember.py b/users/migrations/0005_enterprisemember.py new file mode 100644 index 0000000..b8855ef --- /dev/null +++ b/users/migrations/0005_enterprisemember.py @@ -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')}, + }, + ), + ] diff --git a/users/migrations/0006_user_completed_tasks_user_rating.py b/users/migrations/0006_user_completed_tasks_user_rating.py new file mode 100644 index 0000000..4cf996a --- /dev/null +++ b/users/migrations/0006_user_completed_tasks_user_rating.py @@ -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), + ), + ] diff --git a/users/migrations/0007_enterprise_status.py b/users/migrations/0007_enterprise_status.py new file mode 100644 index 0000000..7e8f98c --- /dev/null +++ b/users/migrations/0007_enterprise_status.py @@ -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), + ), + ] diff --git a/users/migrations/0008_enterprise_logo_url.py b/users/migrations/0008_enterprise_logo_url.py new file mode 100644 index 0000000..eb6c4e6 --- /dev/null +++ b/users/migrations/0008_enterprise_logo_url.py @@ -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), + ), + ] diff --git a/users/migrations/0009_user_is_recommended_user_recommend_priority.py b/users/migrations/0009_user_is_recommended_user_recommend_priority.py new file mode 100644 index 0000000..1cbca8b --- /dev/null +++ b/users/migrations/0009_user_is_recommended_user_recommend_priority.py @@ -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='推荐优先级, 越大越靠前'), + ), + ] diff --git a/users/migrations/0010_enterprise_landline.py b/users/migrations/0010_enterprise_landline.py new file mode 100644 index 0000000..b19c298 --- /dev/null +++ b/users/migrations/0010_enterprise_landline.py @@ -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), + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..cf1e0cb --- /dev/null +++ b/users/models.py @@ -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') + diff --git a/users/permissions.py b/users/permissions.py new file mode 100644 index 0000000..4e57c9a --- /dev/null +++ b/users/permissions.py @@ -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 diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..3686d20 --- /dev/null +++ b/users/serializers.py @@ -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) diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..9f6e8c4 --- /dev/null +++ b/users/urls.py @@ -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)), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..ef3e8f9 --- /dev/null +++ b/users/views.py @@ -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' + }