From 4aba6dfa7712c209300043e99064a5de208efeb8 Mon Sep 17 00:00:00 2001 From: ishenwei Date: Sun, 26 Apr 2026 17:12:09 +0800 Subject: [PATCH] Initialize Fonrey Django multi-tenant project skeleton Set up the required directory layout, app scaffolding, core settings, templates, static assets, and Docker/Tailwind tooling to establish a standardized development baseline. --- .env.example | 29 ++++++ .gitignore | 8 ++ Dockerfile | 8 ++ Makefile | 23 ++++ apps/__init__.py | 0 apps/account/__init__.py | 0 apps/account/admin.py | 1 + apps/account/apps.py | 6 ++ apps/account/models/.gitkeep | 0 apps/account/models/__init__.py | 0 apps/account/services/.gitkeep | 0 apps/account/services/__init__.py | 0 apps/account/tasks.py | 6 ++ apps/account/tests/.gitkeep | 0 apps/account/tests/__init__.py | 0 apps/account/urls.py | 9 ++ apps/account/views.py | 5 + apps/client/__init__.py | 0 apps/client/admin.py | 1 + apps/client/apps.py | 6 ++ apps/client/models/.gitkeep | 0 apps/client/models/__init__.py | 0 apps/client/services/.gitkeep | 0 apps/client/services/__init__.py | 0 apps/client/tasks.py | 6 ++ apps/client/tests/.gitkeep | 0 apps/client/tests/__init__.py | 0 apps/client/urls.py | 9 ++ apps/client/views.py | 5 + apps/complex/__init__.py | 0 apps/complex/admin.py | 1 + apps/complex/apps.py | 6 ++ apps/complex/models/.gitkeep | 0 apps/complex/models/__init__.py | 0 apps/complex/services/.gitkeep | 0 apps/complex/services/__init__.py | 0 apps/complex/tasks.py | 6 ++ apps/complex/tests/.gitkeep | 0 apps/complex/tests/__init__.py | 0 apps/complex/urls.py | 9 ++ apps/complex/views.py | 5 + apps/org/__init__.py | 0 apps/org/admin.py | 1 + apps/org/apps.py | 6 ++ apps/org/models/.gitkeep | 0 apps/org/models/__init__.py | 0 apps/org/services/.gitkeep | 0 apps/org/services/__init__.py | 0 apps/org/tasks.py | 6 ++ apps/org/tests/.gitkeep | 0 apps/org/tests/__init__.py | 0 apps/org/urls.py | 9 ++ apps/org/views.py | 5 + apps/permission/__init__.py | 0 apps/permission/admin.py | 1 + apps/permission/apps.py | 6 ++ apps/permission/models/.gitkeep | 0 apps/permission/models/__init__.py | 0 apps/permission/services/.gitkeep | 0 apps/permission/services/__init__.py | 0 apps/permission/tasks.py | 6 ++ apps/permission/tests/.gitkeep | 0 apps/permission/tests/__init__.py | 0 apps/permission/urls.py | 9 ++ apps/permission/views.py | 5 + apps/property/__init__.py | 0 apps/property/admin.py | 1 + apps/property/apps.py | 6 ++ apps/property/models/.gitkeep | 0 apps/property/models/__init__.py | 0 apps/property/services/.gitkeep | 0 apps/property/services/__init__.py | 0 apps/property/tasks.py | 6 ++ apps/property/tests/.gitkeep | 0 apps/property/tests/__init__.py | 0 apps/property/urls.py | 9 ++ apps/property/views.py | 5 + apps/region/__init__.py | 0 apps/region/admin.py | 1 + apps/region/apps.py | 6 ++ apps/region/models/.gitkeep | 0 apps/region/models/__init__.py | 0 apps/region/services/.gitkeep | 0 apps/region/services/__init__.py | 0 apps/region/tasks.py | 6 ++ apps/region/tests/.gitkeep | 0 apps/region/tests/__init__.py | 0 apps/region/urls.py | 9 ++ apps/region/views.py | 5 + apps/release/__init__.py | 0 apps/release/admin.py | 1 + apps/release/apps.py | 6 ++ apps/release/models/.gitkeep | 0 apps/release/models/__init__.py | 0 apps/release/services/.gitkeep | 0 apps/release/services/__init__.py | 0 apps/release/tasks.py | 6 ++ apps/release/tests/.gitkeep | 0 apps/release/tests/__init__.py | 0 apps/release/urls.py | 9 ++ apps/release/views.py | 5 + apps/setting/__init__.py | 0 apps/setting/admin.py | 1 + apps/setting/apps.py | 6 ++ apps/setting/models/.gitkeep | 0 apps/setting/models/__init__.py | 0 apps/setting/services/.gitkeep | 0 apps/setting/services/__init__.py | 0 apps/setting/tasks.py | 6 ++ apps/setting/tests/.gitkeep | 0 apps/setting/tests/__init__.py | 0 apps/setting/urls.py | 9 ++ apps/setting/views.py | 5 + apps/tenant/__init__.py | 0 apps/tenant/admin.py | 1 + apps/tenant/apps.py | 6 ++ apps/tenant/models.py | 23 ++++ apps/tenant/services/.gitkeep | 0 apps/tenant/services/__init__.py | 0 apps/tenant/tasks.py | 6 ++ apps/tenant/tests/.gitkeep | 0 apps/tenant/tests/__init__.py | 0 apps/tenant/urls.py | 9 ++ apps/tenant/views.py | 5 + config/__init__.py | 0 config/asgi.py | 8 ++ config/settings/__init__.py | 0 config/settings/base.py | 144 ++++++++++++++++++++++++++ config/settings/development.py | 3 + config/settings/production.py | 22 ++++ config/urls.py | 36 +++++++ config/wsgi.py | 7 ++ core/__init__.py | 0 core/cache.py | 13 +++ core/encryption.py | 35 +++++++ core/htmx.py | 15 +++ core/middleware/__init__.py | 0 core/middleware/audit.py | 9 ++ core/models/__init__.py | 0 core/models/base.py | 80 ++++++++++++++ core/templatetags/__init__.py | 0 core/templatetags/heroicons.py | 21 ++++ docker-compose.prod.yml | 55 ++++++++++ docker-compose.yml | 79 ++++++++++++++ manage.py | 20 ++++ package.json | 12 +++ pyproject.toml | 18 ++++ requirements/base.txt | 16 +++ requirements/development.txt | 6 ++ requirements/production.txt | 1 + shared/__init__.py | 0 shared/apps.py | 7 ++ static/css/main.css | 3 + static/js/main.js | 49 +++++++++ static/vendor/alpine.min.js | 0 static/vendor/flatpickr.min.css | 0 static/vendor/htmx.min.js | 0 tailwind.config.js | 61 +++++++++++ templates/base.html | 21 ++++ templates/components/empty-state.html | 4 + templates/components/modal.html | 5 + templates/components/pagination.html | 4 + templates/components/sidebar.html | 7 ++ templates/components/toast.html | 3 + templates/components/topbar.html | 4 + templates/errors/403.html | 3 + templates/errors/404.html | 3 + templates/errors/500.html | 3 + templates/layouts/app.html | 69 ++++++++++++ templates/layouts/auth.html | 13 +++ 170 files changed, 1220 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 apps/__init__.py create mode 100644 apps/account/__init__.py create mode 100644 apps/account/admin.py create mode 100644 apps/account/apps.py create mode 100644 apps/account/models/.gitkeep create mode 100644 apps/account/models/__init__.py create mode 100644 apps/account/services/.gitkeep create mode 100644 apps/account/services/__init__.py create mode 100644 apps/account/tasks.py create mode 100644 apps/account/tests/.gitkeep create mode 100644 apps/account/tests/__init__.py create mode 100644 apps/account/urls.py create mode 100644 apps/account/views.py create mode 100644 apps/client/__init__.py create mode 100644 apps/client/admin.py create mode 100644 apps/client/apps.py create mode 100644 apps/client/models/.gitkeep create mode 100644 apps/client/models/__init__.py create mode 100644 apps/client/services/.gitkeep create mode 100644 apps/client/services/__init__.py create mode 100644 apps/client/tasks.py create mode 100644 apps/client/tests/.gitkeep create mode 100644 apps/client/tests/__init__.py create mode 100644 apps/client/urls.py create mode 100644 apps/client/views.py create mode 100644 apps/complex/__init__.py create mode 100644 apps/complex/admin.py create mode 100644 apps/complex/apps.py create mode 100644 apps/complex/models/.gitkeep create mode 100644 apps/complex/models/__init__.py create mode 100644 apps/complex/services/.gitkeep create mode 100644 apps/complex/services/__init__.py create mode 100644 apps/complex/tasks.py create mode 100644 apps/complex/tests/.gitkeep create mode 100644 apps/complex/tests/__init__.py create mode 100644 apps/complex/urls.py create mode 100644 apps/complex/views.py create mode 100644 apps/org/__init__.py create mode 100644 apps/org/admin.py create mode 100644 apps/org/apps.py create mode 100644 apps/org/models/.gitkeep create mode 100644 apps/org/models/__init__.py create mode 100644 apps/org/services/.gitkeep create mode 100644 apps/org/services/__init__.py create mode 100644 apps/org/tasks.py create mode 100644 apps/org/tests/.gitkeep create mode 100644 apps/org/tests/__init__.py create mode 100644 apps/org/urls.py create mode 100644 apps/org/views.py create mode 100644 apps/permission/__init__.py create mode 100644 apps/permission/admin.py create mode 100644 apps/permission/apps.py create mode 100644 apps/permission/models/.gitkeep create mode 100644 apps/permission/models/__init__.py create mode 100644 apps/permission/services/.gitkeep create mode 100644 apps/permission/services/__init__.py create mode 100644 apps/permission/tasks.py create mode 100644 apps/permission/tests/.gitkeep create mode 100644 apps/permission/tests/__init__.py create mode 100644 apps/permission/urls.py create mode 100644 apps/permission/views.py create mode 100644 apps/property/__init__.py create mode 100644 apps/property/admin.py create mode 100644 apps/property/apps.py create mode 100644 apps/property/models/.gitkeep create mode 100644 apps/property/models/__init__.py create mode 100644 apps/property/services/.gitkeep create mode 100644 apps/property/services/__init__.py create mode 100644 apps/property/tasks.py create mode 100644 apps/property/tests/.gitkeep create mode 100644 apps/property/tests/__init__.py create mode 100644 apps/property/urls.py create mode 100644 apps/property/views.py create mode 100644 apps/region/__init__.py create mode 100644 apps/region/admin.py create mode 100644 apps/region/apps.py create mode 100644 apps/region/models/.gitkeep create mode 100644 apps/region/models/__init__.py create mode 100644 apps/region/services/.gitkeep create mode 100644 apps/region/services/__init__.py create mode 100644 apps/region/tasks.py create mode 100644 apps/region/tests/.gitkeep create mode 100644 apps/region/tests/__init__.py create mode 100644 apps/region/urls.py create mode 100644 apps/region/views.py create mode 100644 apps/release/__init__.py create mode 100644 apps/release/admin.py create mode 100644 apps/release/apps.py create mode 100644 apps/release/models/.gitkeep create mode 100644 apps/release/models/__init__.py create mode 100644 apps/release/services/.gitkeep create mode 100644 apps/release/services/__init__.py create mode 100644 apps/release/tasks.py create mode 100644 apps/release/tests/.gitkeep create mode 100644 apps/release/tests/__init__.py create mode 100644 apps/release/urls.py create mode 100644 apps/release/views.py create mode 100644 apps/setting/__init__.py create mode 100644 apps/setting/admin.py create mode 100644 apps/setting/apps.py create mode 100644 apps/setting/models/.gitkeep create mode 100644 apps/setting/models/__init__.py create mode 100644 apps/setting/services/.gitkeep create mode 100644 apps/setting/services/__init__.py create mode 100644 apps/setting/tasks.py create mode 100644 apps/setting/tests/.gitkeep create mode 100644 apps/setting/tests/__init__.py create mode 100644 apps/setting/urls.py create mode 100644 apps/setting/views.py create mode 100644 apps/tenant/__init__.py create mode 100644 apps/tenant/admin.py create mode 100644 apps/tenant/apps.py create mode 100644 apps/tenant/models.py create mode 100644 apps/tenant/services/.gitkeep create mode 100644 apps/tenant/services/__init__.py create mode 100644 apps/tenant/tasks.py create mode 100644 apps/tenant/tests/.gitkeep create mode 100644 apps/tenant/tests/__init__.py create mode 100644 apps/tenant/urls.py create mode 100644 apps/tenant/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/development.py create mode 100644 config/settings/production.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/cache.py create mode 100644 core/encryption.py create mode 100644 core/htmx.py create mode 100644 core/middleware/__init__.py create mode 100644 core/middleware/audit.py create mode 100644 core/models/__init__.py create mode 100644 core/models/base.py create mode 100644 core/templatetags/__init__.py create mode 100644 core/templatetags/heroicons.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 manage.py create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 requirements/base.txt create mode 100644 requirements/development.txt create mode 100644 requirements/production.txt create mode 100644 shared/__init__.py create mode 100644 shared/apps.py create mode 100644 static/css/main.css create mode 100644 static/js/main.js create mode 100644 static/vendor/alpine.min.js create mode 100644 static/vendor/flatpickr.min.css create mode 100644 static/vendor/htmx.min.js create mode 100644 tailwind.config.js create mode 100644 templates/base.html create mode 100644 templates/components/empty-state.html create mode 100644 templates/components/modal.html create mode 100644 templates/components/pagination.html create mode 100644 templates/components/sidebar.html create mode 100644 templates/components/toast.html create mode 100644 templates/components/topbar.html create mode 100644 templates/errors/403.html create mode 100644 templates/errors/404.html create mode 100644 templates/errors/500.html create mode 100644 templates/layouts/app.html create mode 100644 templates/layouts/auth.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a437a5b --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Django +SECRET_KEY=your-secret-key-here +DEBUG=True +DJANGO_SETTINGS_MODULE=config.settings.development +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database +DB_NAME=fonrey +DB_USER=fonrey +DB_PASSWORD=fonrey +DB_HOST=db +DB_PORT=5432 + +# Redis +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/1 + +# Cloudflare R2 +R2_ENDPOINT_URL=https://.r2.cloudflarestorage.com +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME=media +R2_CUSTOM_DOMAIN= + +# Sentry(production 填写) +SENTRY_DSN= + +# PII 加密密钥(AES-256,生产环境必须替换) +PHONE_ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a78dad --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +*.pyc +__pycache__/ +.DS_Store +node_modules/ +static/css/output.css +media/ +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5e27a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y libpq-dev gcc && rm -rf /var/lib/apt/lists/* +COPY requirements/base.txt requirements/base.txt +RUN pip install --no-cache-dir -r requirements/base.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "config.asgi:application", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d948e82 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: dev migrate shell createsuperuser test lint + +dev: + docker compose up + +migrate: + docker compose exec web python manage.py migrate_schemas --shared + docker compose exec web python manage.py migrate_schemas + +shell: + docker compose exec web python manage.py shell_plus + +test: + docker compose exec web pytest apps/ -v + +lint: + ruff check . && black --check . + +tailwind-build: + npm run build + +createsuperuser: + docker compose exec web python manage.py create_tenant_superuser diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/__init__.py b/apps/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/admin.py b/apps/account/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/account/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/account/apps.py b/apps/account/apps.py new file mode 100644 index 0000000..d198496 --- /dev/null +++ b/apps/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + name = "apps.account" + verbose_name = "登录认证" diff --git a/apps/account/models/.gitkeep b/apps/account/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/models/__init__.py b/apps/account/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/services/.gitkeep b/apps/account/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/services/__init__.py b/apps/account/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/tasks.py b/apps/account/tasks.py new file mode 100644 index 0000000..cc9b107 --- /dev/null +++ b/apps/account/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "account task placeholder" diff --git a/apps/account/tests/.gitkeep b/apps/account/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/tests/__init__.py b/apps/account/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/urls.py b/apps/account/urls.py new file mode 100644 index 0000000..3428704 --- /dev/null +++ b/apps/account/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "account" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/account/views.py b/apps/account/views.py new file mode 100644 index 0000000..b0896ee --- /dev/null +++ b/apps/account/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("account app placeholder") diff --git a/apps/client/__init__.py b/apps/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/admin.py b/apps/client/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/client/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/client/apps.py b/apps/client/apps.py new file mode 100644 index 0000000..b63df23 --- /dev/null +++ b/apps/client/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ClientConfig(AppConfig): + name = "apps.client" + verbose_name = "客源管理" diff --git a/apps/client/models/.gitkeep b/apps/client/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/models/__init__.py b/apps/client/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/services/.gitkeep b/apps/client/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/services/__init__.py b/apps/client/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/tasks.py b/apps/client/tasks.py new file mode 100644 index 0000000..c211dc7 --- /dev/null +++ b/apps/client/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "client task placeholder" diff --git a/apps/client/tests/.gitkeep b/apps/client/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/tests/__init__.py b/apps/client/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/urls.py b/apps/client/urls.py new file mode 100644 index 0000000..bb45b80 --- /dev/null +++ b/apps/client/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "client" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/client/views.py b/apps/client/views.py new file mode 100644 index 0000000..5e0bca4 --- /dev/null +++ b/apps/client/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("client app placeholder") diff --git a/apps/complex/__init__.py b/apps/complex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/admin.py b/apps/complex/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/complex/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/complex/apps.py b/apps/complex/apps.py new file mode 100644 index 0000000..c012532 --- /dev/null +++ b/apps/complex/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ComplexConfig(AppConfig): + name = "apps.complex" + verbose_name = "楼盘管理" diff --git a/apps/complex/models/.gitkeep b/apps/complex/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/models/__init__.py b/apps/complex/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/services/.gitkeep b/apps/complex/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/services/__init__.py b/apps/complex/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/tasks.py b/apps/complex/tasks.py new file mode 100644 index 0000000..5236757 --- /dev/null +++ b/apps/complex/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "complex task placeholder" diff --git a/apps/complex/tests/.gitkeep b/apps/complex/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/tests/__init__.py b/apps/complex/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/urls.py b/apps/complex/urls.py new file mode 100644 index 0000000..c932e88 --- /dev/null +++ b/apps/complex/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "complex" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/complex/views.py b/apps/complex/views.py new file mode 100644 index 0000000..26809a6 --- /dev/null +++ b/apps/complex/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("complex app placeholder") diff --git a/apps/org/__init__.py b/apps/org/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/admin.py b/apps/org/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/org/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/org/apps.py b/apps/org/apps.py new file mode 100644 index 0000000..76a6fce --- /dev/null +++ b/apps/org/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrgConfig(AppConfig): + name = "apps.org" + verbose_name = "组织人事" diff --git a/apps/org/models/.gitkeep b/apps/org/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/models/__init__.py b/apps/org/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/services/.gitkeep b/apps/org/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/services/__init__.py b/apps/org/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/tasks.py b/apps/org/tasks.py new file mode 100644 index 0000000..7e29b91 --- /dev/null +++ b/apps/org/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "org task placeholder" diff --git a/apps/org/tests/.gitkeep b/apps/org/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/tests/__init__.py b/apps/org/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/urls.py b/apps/org/urls.py new file mode 100644 index 0000000..388f639 --- /dev/null +++ b/apps/org/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "org" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/org/views.py b/apps/org/views.py new file mode 100644 index 0000000..ba1f257 --- /dev/null +++ b/apps/org/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("org app placeholder") diff --git a/apps/permission/__init__.py b/apps/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/admin.py b/apps/permission/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/permission/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/permission/apps.py b/apps/permission/apps.py new file mode 100644 index 0000000..0d27980 --- /dev/null +++ b/apps/permission/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PermissionConfig(AppConfig): + name = "apps.permission" + verbose_name = "权限管理" diff --git a/apps/permission/models/.gitkeep b/apps/permission/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/models/__init__.py b/apps/permission/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/services/.gitkeep b/apps/permission/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/services/__init__.py b/apps/permission/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/tasks.py b/apps/permission/tasks.py new file mode 100644 index 0000000..0a23640 --- /dev/null +++ b/apps/permission/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "permission task placeholder" diff --git a/apps/permission/tests/.gitkeep b/apps/permission/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/tests/__init__.py b/apps/permission/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/urls.py b/apps/permission/urls.py new file mode 100644 index 0000000..cc08ddb --- /dev/null +++ b/apps/permission/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "permission" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/permission/views.py b/apps/permission/views.py new file mode 100644 index 0000000..625df14 --- /dev/null +++ b/apps/permission/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("permission app placeholder") diff --git a/apps/property/__init__.py b/apps/property/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/admin.py b/apps/property/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/property/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/property/apps.py b/apps/property/apps.py new file mode 100644 index 0000000..c2e5539 --- /dev/null +++ b/apps/property/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PropertyConfig(AppConfig): + name = "apps.property" + verbose_name = "房源核心" diff --git a/apps/property/models/.gitkeep b/apps/property/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/models/__init__.py b/apps/property/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/services/.gitkeep b/apps/property/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/services/__init__.py b/apps/property/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/tasks.py b/apps/property/tasks.py new file mode 100644 index 0000000..f297bad --- /dev/null +++ b/apps/property/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "property task placeholder" diff --git a/apps/property/tests/.gitkeep b/apps/property/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/tests/__init__.py b/apps/property/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/urls.py b/apps/property/urls.py new file mode 100644 index 0000000..63f9162 --- /dev/null +++ b/apps/property/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "property" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/property/views.py b/apps/property/views.py new file mode 100644 index 0000000..ca9efba --- /dev/null +++ b/apps/property/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("property app placeholder") diff --git a/apps/region/__init__.py b/apps/region/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/admin.py b/apps/region/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/region/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/region/apps.py b/apps/region/apps.py new file mode 100644 index 0000000..6e69d6a --- /dev/null +++ b/apps/region/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RegionConfig(AppConfig): + name = "apps.region" + verbose_name = "区域管理" diff --git a/apps/region/models/.gitkeep b/apps/region/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/models/__init__.py b/apps/region/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/services/.gitkeep b/apps/region/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/services/__init__.py b/apps/region/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/tasks.py b/apps/region/tasks.py new file mode 100644 index 0000000..5c89472 --- /dev/null +++ b/apps/region/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "region task placeholder" diff --git a/apps/region/tests/.gitkeep b/apps/region/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/tests/__init__.py b/apps/region/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/urls.py b/apps/region/urls.py new file mode 100644 index 0000000..7179d93 --- /dev/null +++ b/apps/region/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "region" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/region/views.py b/apps/region/views.py new file mode 100644 index 0000000..c768af5 --- /dev/null +++ b/apps/region/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("region app placeholder") diff --git a/apps/release/__init__.py b/apps/release/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/admin.py b/apps/release/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/release/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/release/apps.py b/apps/release/apps.py new file mode 100644 index 0000000..e3e2297 --- /dev/null +++ b/apps/release/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReleaseConfig(AppConfig): + name = "apps.release" + verbose_name = "客户端发布管理" diff --git a/apps/release/models/.gitkeep b/apps/release/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/models/__init__.py b/apps/release/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/services/.gitkeep b/apps/release/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/services/__init__.py b/apps/release/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/tasks.py b/apps/release/tasks.py new file mode 100644 index 0000000..f08a889 --- /dev/null +++ b/apps/release/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "release task placeholder" diff --git a/apps/release/tests/.gitkeep b/apps/release/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/tests/__init__.py b/apps/release/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/urls.py b/apps/release/urls.py new file mode 100644 index 0000000..6e9bb9b --- /dev/null +++ b/apps/release/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "release" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/release/views.py b/apps/release/views.py new file mode 100644 index 0000000..dddb5a8 --- /dev/null +++ b/apps/release/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("release app placeholder") diff --git a/apps/setting/__init__.py b/apps/setting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/admin.py b/apps/setting/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/setting/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/setting/apps.py b/apps/setting/apps.py new file mode 100644 index 0000000..fba93c1 --- /dev/null +++ b/apps/setting/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SettingConfig(AppConfig): + name = "apps.setting" + verbose_name = "系统设置" diff --git a/apps/setting/models/.gitkeep b/apps/setting/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/models/__init__.py b/apps/setting/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/services/.gitkeep b/apps/setting/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/services/__init__.py b/apps/setting/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/tasks.py b/apps/setting/tasks.py new file mode 100644 index 0000000..e6b29a0 --- /dev/null +++ b/apps/setting/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "setting task placeholder" diff --git a/apps/setting/tests/.gitkeep b/apps/setting/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/tests/__init__.py b/apps/setting/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/urls.py b/apps/setting/urls.py new file mode 100644 index 0000000..1a8d1d8 --- /dev/null +++ b/apps/setting/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "setting" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/setting/views.py b/apps/setting/views.py new file mode 100644 index 0000000..d3e7593 --- /dev/null +++ b/apps/setting/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("setting app placeholder") diff --git a/apps/tenant/__init__.py b/apps/tenant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/admin.py b/apps/tenant/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/apps/tenant/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/tenant/apps.py b/apps/tenant/apps.py new file mode 100644 index 0000000..f077b66 --- /dev/null +++ b/apps/tenant/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TenantConfig(AppConfig): + name = "apps.tenant" + verbose_name = "租户管理" diff --git a/apps/tenant/models.py b/apps/tenant/models.py new file mode 100644 index 0000000..2e15f01 --- /dev/null +++ b/apps/tenant/models.py @@ -0,0 +1,23 @@ +from django.db import models +from django_tenants.models import DomainMixin, TenantMixin + + +class Tenant(TenantMixin): + name = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + auto_create_schema = True + + class Meta: + verbose_name = "租户" + verbose_name_plural = "租户" + + def __str__(self) -> str: + return self.name + + +class Domain(DomainMixin): + class Meta: + verbose_name = "租户域名" + verbose_name_plural = "租户域名" diff --git a/apps/tenant/services/.gitkeep b/apps/tenant/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/services/__init__.py b/apps/tenant/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/tasks.py b/apps/tenant/tasks.py new file mode 100644 index 0000000..2df7365 --- /dev/null +++ b/apps/tenant/tasks.py @@ -0,0 +1,6 @@ +from celery import shared_task + + +@shared_task +def sample_task() -> str: + return "tenant task placeholder" diff --git a/apps/tenant/tests/.gitkeep b/apps/tenant/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/tests/__init__.py b/apps/tenant/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/urls.py b/apps/tenant/urls.py new file mode 100644 index 0000000..1e052bb --- /dev/null +++ b/apps/tenant/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "tenant" + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/apps/tenant/views.py b/apps/tenant/views.py new file mode 100644 index 0000000..f5a58f2 --- /dev/null +++ b/apps/tenant/views.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("tenant app placeholder") diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..12b02ea --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,8 @@ +import os + +from django_tenants.asgi import TenantASGIHandler +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +application = TenantASGIHandler(get_asgi_application()) diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..358deed --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,144 @@ +from pathlib import Path + +from decouple import Csv, config as env + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-me") +DEBUG = env("DEBUG", default=False, cast=bool) +ALLOWED_HOSTS = env("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv()) + +SHARED_APPS = [ + "django_tenants", + "apps.tenant", + "apps.release", + "shared", + "django.contrib.contenttypes", + "django.contrib.auth", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_celery_beat", + "django_celery_results", +] + +TENANT_APPS = [ + "apps.account", + "apps.permission", + "apps.org", + "apps.region", + "apps.complex", + "apps.property", + "apps.client", + "apps.setting", + "core", +] + +INSTALLED_APPS = list(SHARED_APPS) + list(TENANT_APPS) + +TENANT_MODEL = "tenant.Tenant" +TENANT_DOMAIN_MODEL = "tenant.Domain" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +DATABASES = { + "default": { + "ENGINE": "django_tenants.postgresql_backend", + "NAME": env("DB_NAME"), + "USER": env("DB_USER"), + "PASSWORD": env("DB_PASSWORD"), + "HOST": env("DB_HOST", default="localhost"), + "PORT": env("DB_PORT", default="5432"), + "CONN_MAX_AGE": 60, + "OPTIONS": {"pool_size": 10}, + } +} + +DATABASE_ROUTERS = ["django_tenants.routers.TenantSyncRouter"] + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL", default="redis://127.0.0.1:6379/0"), + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + "KEY_PREFIX": "fonrey", + } +} + +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" + +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://127.0.0.1:6379/1") +CELERY_RESULT_BACKEND = "django-db" +CELERY_TASK_ALWAYS_EAGER = False +CELERY_TASK_TIME_LIMIT = 300 +CELERY_TASK_SOFT_TIME_LIMIT = 270 + +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +AWS_S3_ENDPOINT_URL = env("R2_ENDPOINT_URL", default="") +AWS_ACCESS_KEY_ID = env("R2_ACCESS_KEY_ID", default="") +AWS_SECRET_ACCESS_KEY = env("R2_SECRET_ACCESS_KEY", default="") +AWS_STORAGE_BUCKET_NAME = env("R2_BUCKET_NAME", default="media") +AWS_S3_CUSTOM_DOMAIN = env("R2_CUSTOM_DOMAIN", default=None) +AWS_DEFAULT_ACL = "private" + +ASGI_APPLICATION = "config.asgi.application" +WSGI_APPLICATION = "config.wsgi.application" +ROOT_URLCONF = "config.urls" + +MIDDLEWARE = [ + "django_tenants.middleware.main.TenantMainMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "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", + "core.middleware.audit.AuditMiddleware", +] + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_HTTPONLY = False +X_FRAME_OPTIONS = "DENY" + +LANGUAGE_CODE = "zh-hans" +TIME_ZONE = "Asia/Shanghai" +USE_I18N = True +USE_TZ = True + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "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", + ] + }, + } +] + +HTMX_GLOBAL_CSRF = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +PUBLIC_SCHEMA_URLCONF = "config.urls" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "root": {"handlers": ["console"], "level": "INFO"}, +} diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..3ac92de --- /dev/null +++ b/config/settings/development.py @@ -0,0 +1,3 @@ +from .base import * + +DEBUG = True diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..b7b2a17 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,22 @@ +from decouple import config as env + +from .base import * + +DEBUG = False +ALLOWED_HOSTS = env("ALLOWED_HOSTS", default="localhost,127.0.0.1").split(",") + +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +SENTRY_DSN = env("SENTRY_DSN", default="") +if SENTRY_DSN: + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[DjangoIntegration()], + traces_sample_rate=0.1, + send_default_pii=False, + ) diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..223094a --- /dev/null +++ b/config/urls.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.db import connection +from django.http import HttpResponse +from django.urls import include, path +from django_tenants.utils import get_public_schema_name + + +def healthz(_request): + return HttpResponse("ok") + + +public_urlpatterns = [ + path("healthz/", healthz, name="healthz"), + path("tenant/", include("apps.tenant.urls")), + path("release/", include("apps.release.urls")), +] + +tenant_urlpatterns = [ + path("", include("apps.account.urls")), + path("permission/", include("apps.permission.urls")), + path("org/", include("apps.org.urls")), + path("region/", include("apps.region.urls")), + path("complex/", include("apps.complex.urls")), + path("property/", include("apps.property.urls")), + path("client/", include("apps.client.urls")), + path("setting/", include("apps.setting.urls")), +] + +if connection.schema_name == get_public_schema_name(): + urlpatterns = public_urlpatterns +else: + urlpatterns = tenant_urlpatterns + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..3eb3a0c --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/cache.py b/core/cache.py new file mode 100644 index 0000000..cab3721 --- /dev/null +++ b/core/cache.py @@ -0,0 +1,13 @@ +from decouple import config as env + + +class RedisCache: + """Redis 工具骨架。""" + + @staticmethod + def get_client_url() -> str: + return env("REDIS_URL", default="redis://127.0.0.1:6379/0") + + @staticmethod + def make_key(*parts: str) -> str: + return ":".join(["fonrey", *parts]) diff --git a/core/encryption.py b/core/encryption.py new file mode 100644 index 0000000..fd99f9f --- /dev/null +++ b/core/encryption.py @@ -0,0 +1,35 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet +from django.conf import settings + + +class PhoneEncryption: + """ + 手机号 AES-256-GCM 加密存储 + SHA-256 哈希索引 + 存储字段:phone_encrypted(加密密文)+ phone_hash(哈希,用于精确查询) + 显示:脱敏格式 138****1234 + """ + + @staticmethod + def encrypt(phone: str) -> str: + """加密手机号,返回 base64 密文""" + ... + + @staticmethod + def decrypt(ciphertext: str) -> str: + """解密返回明文""" + ... + + @staticmethod + def hash(phone: str) -> str: + """返回 SHA-256 哈希(用于 DB 索引查询)""" + ... + + @staticmethod + def mask(phone: str) -> str: + """返回脱敏格式:138****1234""" + if not phone or len(phone) < 7: + return "***" + return phone[:3] + "****" + phone[-4:] diff --git a/core/htmx.py b/core/htmx.py new file mode 100644 index 0000000..6f9a7e1 --- /dev/null +++ b/core/htmx.py @@ -0,0 +1,15 @@ +from django.http import HttpResponse + + +def htmx_response(content="", status=200, toast=None, redirect=None): + """ + toast: {"type": "success|error|warning|info", "message": "..."} + """ + response = HttpResponse(content, status=status) + if toast: + import json + + response["HX-Trigger"] = json.dumps({"fonrey:toast": toast}) + if redirect: + response["HX-Redirect"] = redirect + return response diff --git a/core/middleware/__init__.py b/core/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/middleware/audit.py b/core/middleware/audit.py new file mode 100644 index 0000000..5d7fb6c --- /dev/null +++ b/core/middleware/audit.py @@ -0,0 +1,9 @@ +class AuditMiddleware: + """审计日志中间件骨架。""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response diff --git a/core/models/__init__.py b/core/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/base.py b/core/models/base.py new file mode 100644 index 0000000..cc83d94 --- /dev/null +++ b/core/models/base.py @@ -0,0 +1,80 @@ +import uuid + +from django.db import models +from django.utils import timezone + + +class UUIDPrimaryKeyModel(models.Model): + """所有业务模型的根基类:UUID v4 主键""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + +class TimeStampedModel(UUIDPrimaryKeyModel): + """追加创建/更新时间(TIMESTAMPTZ)""" + + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + ordering = ["-created_at"] + + +class ActiveManager(models.Manager): + """默认只返回未软删除的记录""" + + def get_queryset(self): + return super().get_queryset().filter(deleted_at__isnull=True) + + +class SoftDeleteModel(TimeStampedModel): + """软删除:deleted_at=NULL 表示未删除""" + + deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) + objects = ActiveManager() + all_objects = models.Manager() + + def delete(self, using=None, keep_parents=False): + self.deleted_at = timezone.now() + self.save(update_fields=["deleted_at"]) + + def hard_delete(self): + super().delete() + + def restore(self): + self.deleted_at = None + self.save(update_fields=["deleted_at"]) + + @property + def is_deleted(self): + return self.deleted_at is not None + + class Meta: + abstract = True + + +class AuditedModel(SoftDeleteModel): + """审计字段:操作人(FK to Staff,允许 NULL 表示系统操作)""" + + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="%(app_label)s_%(class)s_created", + db_index=True, + ) + updated_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="%(app_label)s_%(class)s_updated", + ) + + class Meta: + abstract = True diff --git a/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/heroicons.py b/core/templatetags/heroicons.py new file mode 100644 index 0000000..5065a71 --- /dev/null +++ b/core/templatetags/heroicons.py @@ -0,0 +1,21 @@ +import os + +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() +ICONS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "icons") + + +@register.simple_tag +def heroicon(name: str, size: str = "24", style: str = "outline", css_class: str = "") -> str: + """ + 用法: {% heroicon 'plus' %} + {% heroicon 'trash' size='20' style='solid' css_class='text-danger-600' %} + """ + css = ( + f'class="w-{size//4 if isinstance(size, int) else size} ' + f'h-{size//4 if isinstance(size, int) else size} {css_class}"' + ) + _ = css + return mark_safe(f"") diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..5de79e5 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,55 @@ +services: + web: + build: . + env_file: .env + command: gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + ports: + - "8000:8000" + depends_on: + - db + - redis + networks: + - fonrey_net + + db: + image: postgres:16-alpine + env_file: .env + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - fonrey_net + + redis: + image: redis:7-alpine + env_file: .env + volumes: + - redis_data:/data + networks: + - fonrey_net + + celery: + build: . + env_file: .env + command: celery -A config worker -l info + depends_on: + - redis + - db + networks: + - fonrey_net + + celery-beat: + build: . + env_file: .env + command: celery -A config beat -l info + depends_on: + - redis + - db + networks: + - fonrey_net + +networks: + fonrey_net: + +volumes: + postgres_data: + redis_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..acaf414 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +services: + web: + build: . + env_file: .env + command: uvicorn config.asgi:application --host 0.0.0.0 --port 8000 + volumes: + - .:/app + ports: + - "8000:8000" + depends_on: + - db + - redis + networks: + - fonrey_net + + db: + image: postgres:16-alpine + env_file: .env + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - fonrey_net + + redis: + image: redis:7-alpine + env_file: .env + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - fonrey_net + + celery: + build: . + env_file: .env + command: celery -A config worker -l info + volumes: + - .:/app + depends_on: + - redis + - db + networks: + - fonrey_net + + celery-beat: + build: . + env_file: .env + command: celery -A config beat -l info + volumes: + - .:/app + depends_on: + - redis + - db + networks: + - fonrey_net + + tailwind: + image: node:20-alpine + working_dir: /app + env_file: .env + command: sh -c "npm install && npm run watch" + volumes: + - .:/app + networks: + - fonrey_net + +networks: + fonrey_net: + +volumes: + postgres_data: + redis_data: diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..b4b9546 --- /dev/null +++ b/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + 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/package.json b/package.json new file mode 100644 index 0000000..a7acc51 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "fonrey-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tailwindcss -i ./static/css/main.css -o ./static/css/output.css --minify", + "watch": "tailwindcss -i ./static/css/main.css -o ./static/css/output.css --watch" + }, + "devDependencies": { + "tailwindcss": "^3.4.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..527e869 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.ruff] +line-length = 100 +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] +target-version = "py312" + +[tool.black] +line-length = 100 +target-version = ["py312"] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.development" +python_files = ["test_*.py", "*_test.py"] +addopts = "--reuse-db" diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..55f2825 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,16 @@ +Django==4.2.16 +django-tenants==3.7.0 +psycopg2-binary==2.9.9 +django-redis==5.4.0 +celery==5.4.0 +django-celery-beat==2.7.0 +django-celery-results==2.5.1 +django-storages[s3]==1.14.4 +boto3==1.35.0 +cryptography==43.0.0 +whitenoise==6.8.2 +gunicorn==23.0.0 +uvicorn[standard]==0.32.0 +sentry-sdk[django]==2.18.0 +python-decouple==3.8 +Pillow==11.0.0 diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 0000000..27f1645 --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,6 @@ +-r base.txt +ruff==0.7.0 +black==24.10.0 +pytest-django==4.9.0 +factory-boy==3.3.1 +django-debug-toolbar==4.4.6 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..a3e81b8 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1 @@ +-r base.txt diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/apps.py b/shared/apps.py new file mode 100644 index 0000000..76ed86a --- /dev/null +++ b/shared/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SharedConfig(AppConfig): + default_auto_field = "django.db.models.UUIDField" + name = "shared" + verbose_name = "公共 Schema" diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..3b7ea4f --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,49 @@ +(function () { + if (window.Alpine && window.Alpine.plugin && window.Alpine.$persist) { + window.Alpine.plugin(window.Alpine.$persist); + } + + function createToast(payload) { + var container = document.getElementById("toast-container"); + if (!container || !payload || !payload.message) { + return; + } + + var type = payload.type || "info"; + var color = { + success: "border-success-600", + error: "border-danger-600", + warning: "border-warning-600", + info: "border-info-600", + }[type] || "border-info-600"; + + var item = document.createElement("div"); + item.className = "bg-white border-l-4 " + color + " shadow-xs rounded px-4 py-3 text-sm min-w-[240px]"; + item.textContent = payload.message; + container.appendChild(item); + + window.setTimeout(function () { + item.remove(); + }, 4000); + } + + document.addEventListener("DOMContentLoaded", function () { + document.body.addEventListener("htmx:afterRequest", function (event) { + var xhr = event.detail && event.detail.xhr; + if (!xhr) { + return; + } + var trigger = xhr.getResponseHeader("HX-Trigger"); + if (!trigger) { + return; + } + try { + var parsed = JSON.parse(trigger); + if (parsed["fonrey:toast"]) { + createToast(parsed["fonrey:toast"]); + } + } catch (_err) { + } + }); + }); +})(); diff --git a/static/vendor/alpine.min.js b/static/vendor/alpine.min.js new file mode 100644 index 0000000..e69de29 diff --git a/static/vendor/flatpickr.min.css b/static/vendor/flatpickr.min.css new file mode 100644 index 0000000..e69de29 diff --git a/static/vendor/htmx.min.js b/static/vendor/htmx.min.js new file mode 100644 index 0000000..e69de29 diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..2b245c3 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,61 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./templates/**/*.html", + "./apps/**/templates/**/*.html", + "./static/js/**/*.js", + ], + theme: { + extend: { + colors: { + primary: { + 50: "#F0FDFA", + 100: "#CCFBF1", + 200: "#99F6E4", + 300: "#5EEAD4", + 400: "#2DD4BF", + 500: "#14B8A6", + 600: "#0F766E", + 700: "#0F766E", + 800: "#134E4A", + }, + neutral: { + 50: "#F8FAFC", + 100: "#F1F5F9", + 200: "#E2E8F0", + 300: "#CBD5E1", + 400: "#94A3B8", + 500: "#64748B", + 600: "#475569", + 700: "#334155", + 800: "#1E293B", + 900: "#0F172A", + }, + success: { 600: "#16A34A" }, + warning: { 600: "#D97706" }, + danger: { 600: "#DC2626" }, + info: { 600: "#2563EB" }, + }, + fontFamily: { + sans: ["Inter", "PingFang SC", "Microsoft YaHei", "sans-serif"], + }, + zIndex: { + 60: "60", + 70: "70", + }, + boxShadow: { + xs: "0 1px 2px 0 rgb(15 23 42 / 0.06)", + }, + keyframes: { + "slide-in-right": { + "0%": { transform: "translateX(100%)", opacity: "0" }, + "100%": { transform: "translateX(0)", opacity: "1" }, + }, + }, + animation: { + "slide-in-right": "slide-in-right 0.25s ease-out", + }, + }, + }, + plugins: [], +}; diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..747ec15 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ +{% load static %} + + + + + + {% block title %}Fonrey{% endblock %} + + {% if use_flatpickr %} + + {% endif %} + {% block extra_head %}{% endblock %} + + + {% block content %}{% endblock %} + + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/components/empty-state.html b/templates/components/empty-state.html new file mode 100644 index 0000000..692f29d --- /dev/null +++ b/templates/components/empty-state.html @@ -0,0 +1,4 @@ +
+

暂无数据

+
+
diff --git a/templates/components/modal.html b/templates/components/modal.html new file mode 100644 index 0000000..bfaa253 --- /dev/null +++ b/templates/components/modal.html @@ -0,0 +1,5 @@ + diff --git a/templates/components/pagination.html b/templates/components/pagination.html new file mode 100644 index 0000000..e8623b0 --- /dev/null +++ b/templates/components/pagination.html @@ -0,0 +1,4 @@ +
+

第 1 页,共 1 页

+ +
diff --git a/templates/components/sidebar.html b/templates/components/sidebar.html new file mode 100644 index 0000000..c0fba38 --- /dev/null +++ b/templates/components/sidebar.html @@ -0,0 +1,7 @@ + diff --git a/templates/components/toast.html b/templates/components/toast.html new file mode 100644 index 0000000..2c91424 --- /dev/null +++ b/templates/components/toast.html @@ -0,0 +1,3 @@ +
+

{{ message|default:"操作完成" }}

+
diff --git a/templates/components/topbar.html b/templates/components/topbar.html new file mode 100644 index 0000000..1098fb5 --- /dev/null +++ b/templates/components/topbar.html @@ -0,0 +1,4 @@ +
+

工作台

+
+
diff --git a/templates/errors/403.html b/templates/errors/403.html new file mode 100644 index 0000000..d269718 --- /dev/null +++ b/templates/errors/403.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}403{% endblock %} +{% block content %}
403 Forbidden
{% endblock %} diff --git a/templates/errors/404.html b/templates/errors/404.html new file mode 100644 index 0000000..dfbc7d0 --- /dev/null +++ b/templates/errors/404.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}404{% endblock %} +{% block content %}
404 Not Found
{% endblock %} diff --git a/templates/errors/500.html b/templates/errors/500.html new file mode 100644 index 0000000..0d7d02f --- /dev/null +++ b/templates/errors/500.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}500{% endblock %} +{% block content %}
500 Server Error
{% endblock %} diff --git a/templates/layouts/app.html b/templates/layouts/app.html new file mode 100644 index 0000000..16f29ee --- /dev/null +++ b/templates/layouts/app.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block body_class %}bg-neutral-50 text-neutral-900{% endblock %} + +{% block content %} +
+
+
Fonrey
+ +
+ + + +
+
+ + + +
+ {% include "components/topbar.html" %} +
+
+ {% block app_content %}{% endblock %} +
+
+
+ +
+
+ + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/layouts/auth.html b/templates/layouts/auth.html new file mode 100644 index 0000000..bd073e2 --- /dev/null +++ b/templates/layouts/auth.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block body_class %}min-h-screen bg-neutral-50{% endblock %} + +{% block content %} +
+
+
+ {% block auth_content %}{% endblock %} +
+
+
+{% endblock %}