diff --git a/.env.example b/.env.example index c4dfce8..2b3c007 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ DJANGO_SECRET_KEY=dev-only-change-me DJANGO_DEBUG=true DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 CORS_ALLOWED_ORIGINS=http://localhost:5173 +CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_PASSWORD=admin12345 +DJANGO_SUPERUSER_EMAIL=admin@example.com POSTGRES_DB=ai_ticket_platform POSTGRES_USER=ai_ticket @@ -14,3 +18,5 @@ CELERY_RESULT_BACKEND=redis://redis:6379/1 MINIO_ROOT_USER=minio MINIO_ROOT_PASSWORD=minio123 + +VITE_API_BASE_URL=http://localhost:8000/api diff --git a/backend/apps/accounts/bootstrap.py b/backend/apps/accounts/bootstrap.py index 4c921c0..22cc2d3 100644 --- a/backend/apps/accounts/bootstrap.py +++ b/backend/apps/accounts/bootstrap.py @@ -7,6 +7,14 @@ from apps.accounts.menus import DEFAULT_MENUS from apps.accounts.models import MenuItem, UserGroup from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS +LEGACY_ROLE_RENAMES = { + "System Admin": "系统管理员", + "Knowledge Admin": "知识库管理员", + "Support Engineer": "技术支持", + "Requester": "普通建单人", + "Auditor": "只读审计员", +} + def seed_permissions() -> dict[str, Permission]: content_type, _ = ContentType.objects.get_or_create( @@ -29,11 +37,26 @@ def seed_permissions() -> dict[str, Permission]: def seed_roles(permissions_by_codename: dict[str, Permission]) -> None: + migrate_legacy_roles() for group_name, codenames in DEFAULT_GROUPS.items(): group, _ = Group.objects.get_or_create(name=group_name) group.permissions.set(permissions_by_codename[codename] for codename in codenames) +def migrate_legacy_roles() -> None: + for legacy_name, target_name in LEGACY_ROLE_RENAMES.items(): + legacy_group = Group.objects.filter(name=legacy_name).first() + if legacy_group is None: + continue + target_group = Group.objects.filter(name=target_name).first() + if target_group is None: + legacy_group.name = target_name + legacy_group.save(update_fields=["name"]) + continue + target_group.user_set.add(*legacy_group.user_set.all()) + legacy_group.delete() + + def seed_user_groups() -> None: UserGroup.objects.update_or_create( code="default", diff --git a/backend/apps/accounts/menus.py b/backend/apps/accounts/menus.py index a609b34..1c09701 100644 --- a/backend/apps/accounts/menus.py +++ b/backend/apps/accounts/menus.py @@ -19,17 +19,74 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = ( MenuSpec("workbench.overview", "运营总览", "#/", "home", "workbench", 10), MenuSpec("business", "业务处理", icon="ticket", sort_order=20), MenuSpec("business.tickets", "工单中心", "#/tickets", "ticket", "business", 10, "ticket.view"), - MenuSpec("business.ticket_create", "创建工单", "#/tickets/new", "briefcase", "business", 20, "ticket.create"), - MenuSpec("business.solutions", "解决方案库", "#/solutions", "library", "business", 30, "solution.view"), + MenuSpec( + "business.ticket_create", + "创建工单", + "#/tickets/new", + "briefcase", + "business", + 20, + "ticket.create", + ), + MenuSpec( + "business.solutions", + "解决方案库", + "#/solutions", + "library", + "business", + 30, + "solution.view", + ), MenuSpec("platform", "平台能力", icon="bot", sort_order=30), - MenuSpec("platform.knowledge", "知识库", "#/knowledge", "database", "platform", 10, "knowledge.view", is_enabled=False), - MenuSpec("platform.agents", "Agent 配置", "#/agents", "bot", "platform", 20, "agent.manage", is_enabled=False), - MenuSpec("platform.models", "模型与工具", "#/models", "settings", "platform", 30, "model.manage", is_enabled=False), + MenuSpec( + "platform.knowledge", + "知识库", + "#/knowledge", + "database", + "platform", + 10, + "knowledge.view", + is_enabled=False, + ), + MenuSpec( + "platform.agents", + "Agent 配置", + "#/agents", + "bot", + "platform", + 20, + "agent.manage", + is_enabled=False, + ), + MenuSpec( + "platform.models", + "模型与工具", + "#/models", + "settings", + "platform", + 30, + "model.manage", + is_enabled=False, + ), MenuSpec("system", "系统治理", icon="shield", sort_order=40), - MenuSpec("system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"), - MenuSpec("system.user_groups", "用户组管理", "#/system/user-groups", "user_groups", "system", 20, "system.user_group.view"), - MenuSpec("system.roles", "角色权限", "#/system/roles", "roles", "system", 30, "system.role.view"), - MenuSpec("system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"), + MenuSpec( + "system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view" + ), + MenuSpec( + "system.user_groups", + "用户组管理", + "#/system/user-groups", + "user_groups", + "system", + 20, + "system.user_group.view", + ), + MenuSpec( + "system.roles", "角色权限", "#/system/roles", "roles", "system", 30, "system.role.view" + ), + MenuSpec( + "system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view" + ), MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"), MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60), ) diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py index d5f6ea3..fb72440 100644 --- a/backend/apps/accounts/migrations/0001_initial.py +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -15,39 +14,63 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='MenuItem', + name="MenuItem", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(max_length=120, unique=True)), - ('title', models.CharField(max_length=120)), - ('path', models.CharField(blank=True, max_length=200)), - ('icon', models.CharField(blank=True, max_length=80)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('permission_code', models.CharField(blank=True, max_length=120)), - ('is_visible', models.BooleanField(default=True)), - ('is_enabled', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='accounts.menuitem')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("code", models.CharField(max_length=120, unique=True)), + ("title", models.CharField(max_length=120)), + ("path", models.CharField(blank=True, max_length=200)), + ("icon", models.CharField(blank=True, max_length=80)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("permission_code", models.CharField(blank=True, max_length=120)), + ("is_visible", models.BooleanField(default=True)), + ("is_enabled", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="accounts.menuitem", + ), + ), ], options={ - 'ordering': ['sort_order', 'id'], + "ordering": ["sort_order", "id"], }, ), migrations.CreateModel( - name='UserGroup', + name="UserGroup", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=120, unique=True)), - ('code', models.CharField(max_length=80, unique=True)), - ('description', models.TextField(blank=True)), - ('is_active', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('users', models.ManyToManyField(blank=True, related_name='business_groups', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=120, unique=True)), + ("code", models.CharField(max_length=80, unique=True)), + ("description", models.TextField(blank=True)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "users", + models.ManyToManyField( + blank=True, related_name="business_groups", to=settings.AUTH_USER_MODEL + ), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 7c31648..2bcdd60 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -6,7 +6,9 @@ class UserGroup(models.Model): name = models.CharField(max_length=120, unique=True) code = models.CharField(max_length=80, unique=True) description = models.TextField(blank=True) - users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name="business_groups") + users = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="business_groups" + ) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/apps/accounts/tests/test_bootstrap_governance.py b/backend/apps/accounts/tests/test_bootstrap_governance.py index 5642b69..3409ec3 100644 --- a/backend/apps/accounts/tests/test_bootstrap_governance.py +++ b/backend/apps/accounts/tests/test_bootstrap_governance.py @@ -21,3 +21,19 @@ def test_bootstrap_governance_seeds_admin_roles_groups_permissions_and_menus(db, assert UserGroup.objects.filter(code="default").exists() assert MenuItem.objects.filter(code="system.users", title="用户管理").exists() assert MenuItem.objects.filter(code="system.roles", permission_code="system.role.view").exists() + + +def test_bootstrap_governance_migrates_legacy_english_roles(db, settings): + legacy_group = Group.objects.create(name="Support Engineer") + user = get_user_model().objects.create_user(username="support", password="password") + user.groups.add(legacy_group) + + settings.DJANGO_SUPERUSER_USERNAME = "admin" + settings.DJANGO_SUPERUSER_PASSWORD = "admin12345" + settings.DJANGO_SUPERUSER_EMAIL = "admin@example.com" + + call_command("bootstrap_governance") + + assert not Group.objects.filter(name="Support Engineer").exists() + support_role = Group.objects.get(name="技术支持") + assert support_role.user_set.filter(username="support").exists() diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 7cc466f..4162d86 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -83,5 +83,9 @@ CSRF_TRUSTED_ORIGINS = os.getenv( "http://localhost:5173,http://127.0.0.1:5173", ).split(",") +DJANGO_SUPERUSER_USERNAME = os.getenv("DJANGO_SUPERUSER_USERNAME", "admin") +DJANGO_SUPERUSER_PASSWORD = os.getenv("DJANGO_SUPERUSER_PASSWORD", "admin12345") +DJANGO_SUPERUSER_EMAIL = os.getenv("DJANGO_SUPERUSER_EMAIL", "admin@example.com") + CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") diff --git a/docs/development/local-setup.md b/docs/development/local-setup.md index d62f9d2..d2a40ba 100644 --- a/docs/development/local-setup.md +++ b/docs/development/local-setup.md @@ -14,6 +14,8 @@ cd infra docker compose --env-file ../.env up --build ``` +首次启动时,backend 容器会自动等待 PostgreSQL、执行数据库迁移,并初始化企业后台治理数据。 + Backend: - API: http://localhost:8000/api/docs @@ -23,6 +25,12 @@ Frontend: - App: http://localhost:5173 +默认管理员: + +- 账号: `admin` +- 密码: `admin12345` +- 可通过 `.env` 中的 `DJANGO_SUPERUSER_USERNAME`、`DJANGO_SUPERUSER_PASSWORD`、`DJANGO_SUPERUSER_EMAIL` 修改。 + ## Backend Tests ```bash diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index 3234533..1d77e80 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -12,7 +12,7 @@ import { useAuth } from './AuthProvider' export function LoginPage() { const { login } = useAuth() const [username, setUsername] = React.useState('admin') - const [password, setPassword] = React.useState('admin123') + const [password, setPassword] = React.useState('admin12345') const [error, setError] = React.useState('') const [submitting, setSubmitting] = React.useState(false) diff --git a/infra/backend-entrypoint.sh b/infra/backend-entrypoint.sh new file mode 100644 index 0000000..16de228 --- /dev/null +++ b/infra/backend-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +python - <<'PY' +import os +import socket +import time + +host = os.environ.get("POSTGRES_HOST", "postgres") +port = int(os.environ.get("POSTGRES_PORT", "5432")) +deadline = time.time() + int(os.environ.get("POSTGRES_WAIT_TIMEOUT", "60")) + +while True: + try: + with socket.create_connection((host, port), timeout=2): + break + except OSError: + if time.time() > deadline: + raise SystemExit(f"Timed out waiting for PostgreSQL at {host}:{port}") + time.sleep(1) +PY + +python manage.py migrate --noinput +python manage.py bootstrap_governance + +exec "$@" diff --git a/infra/backend.Dockerfile b/infra/backend.Dockerfile index 6712f95..01b79cf 100644 --- a/infra/backend.Dockerfile +++ b/infra/backend.Dockerfile @@ -9,5 +9,7 @@ COPY backend/pyproject.toml /app/pyproject.toml RUN pip install --no-cache-dir -e ".[dev]" COPY backend /app +COPY infra/backend-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 11c1a26..80429db 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -31,7 +31,11 @@ services: build: context: .. dockerfile: infra/backend.Dockerfile + entrypoint: ["/entrypoint.sh"] + command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] env_file: + - path: ../.env.example + required: true - path: ../.env required: false ports: @@ -46,10 +50,12 @@ services: build: context: .. dockerfile: infra/backend.Dockerfile + command: celery -A config.celery worker -l info env_file: + - path: ../.env.example + required: true - path: ../.env required: false - command: celery -A config.celery worker -l info depends_on: - backend - redis @@ -60,6 +66,11 @@ services: build: context: .. dockerfile: infra/frontend.Dockerfile + env_file: + - path: ../.env.example + required: true + - path: ../.env + required: false ports: - "5173:5173" volumes: