Files
TermiNexus fbf1bb7d6e refactor(gerp): 应用按子应用目录规约重组,清单外置并拆分路由
- 新增 gvsds/apps/gerp/apps/<appid>/:talk/mailbox/gitea/oa/ehrm/dingcalc_finance/
  product_order/shop/store_value/wallet/enterprise,各应用自带 models/manager/views/urls
  与 apps.gerp_manifests(JSON 清单,$schema 指向 apps/gerp_manifests.schema.json)
- 删除 app_manifests.py 内联 _LEGACY_MANIFESTS,改为扫描各应用清单文件(未知键/缺必填键即判非法并跳过)
- 路由按应用拆:/API/Mail/、/API/Gitea/、/API/Oa/ 由扁平直挂改为 include 子应用 urls.py
  (reverse() 断言 19 个 name 的路径逐字不变)
- AppConfig 移至 gerp_app.py 并改显式点分路径注册,规避 apps/ 目录与 Django <app>.apps 同名冲突
- gerp/models.py 裁剪已搬迁模型(保留 _NewUuidBytes 与平台模型),修复 views/__init__.py 与 oidc.py 导入
- AssetDownloadView:优先按 FileAsset.StoragePath 定位并回退 ./resource 旧布局,修上传成功却下载 404
- 同批落地 Talk 应用后端(app 已迁入 apps/talk,gerp.0046 状态认领迁移)
- 注意:app.ini 被 .gitignore 忽略,INSTALLED_APPS 需在部署环境手工同步 gvsds.apps.gerp.gerp_app.GerpConfig
2026-09-26 23:46:00 +08:00

394 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from pathlib import Path
from datetime import timedelta
from gvsds.secrets import get as _secret_get, getint as _secret_getint
from gvsds.app_config import get as _app_get, getint as _app_getint, getboolean as _app_getbool, getlist as _app_getlist
BASE_DIR = Path(__file__).resolve().parent.parent
# 将 gvsdsdk 加入 Python 路径,便于子服务器 SDK 共享
import sys as _sys
import logging as _logging
_GVSDSDK = str(BASE_DIR / 'gvsdsdk')
if _GVSDSDK not in _sys.path:
_sys.path.insert(0, _GVSDSDK)
# 启动时校验 gvsdsdk 版本——主从必须一致
_REQUIRED_GVSDSDK = '1.1.0'
try:
import gvsdsdk
_sdk_info = gvsdsdk.get_version_info()
_logging.getLogger('gvsds.startup').info(
f'[SDK] 加载: v{_sdk_info["Version"]} @ {_sdk_info["Location"]}'
)
_ok, _msg = gvsdsdk.check_min_version(_REQUIRED_GVSDSDK)
if _ok:
_logging.getLogger('gvsds.startup').info(f'[SDK] {_msg}')
else:
_logging.getLogger('gvsds.startup').warning(
f'[SDK] 版本不符!{_msg}。双端 gvsdsdk 版本必须一致!'
)
except ImportError as e:
_logging.getLogger('gvsds.startup').error(f'[SDK] gvsdsdk 未安装: {e}')
SECRET_KEY = _secret_get('django', 'SECRET_KEY')
DEBUG = os.environ.get('DJANGO_DEBUG', '').lower() in ('true', '1', 'yes') or _app_getbool('server', 'DEBUG', fallback=False)
ALLOWED_HOSTS = _app_getlist('hosts', 'ALLOWED_HOSTS', fallback=[
"www.gvsds.com", "gvsds.com", "127.0.0.1", "localhost",
"gvsds.dev", "yc8z.org", "ycbz.org", "gvsds.org", "gvsds.studio",
".gvsds.com",
])
INSTALLED_APPS = _app_getlist('apps', 'INSTALLED_APPS', fallback=[
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'gvsds.apps.core',
'gvsds.apps.gerp.gerp_app.GerpConfig',
'gvsds.apps.ugm',
'gvsds.apps.crb',
'gvsds.apps.invite',
'gvsds.apps.ccgo',
'gvsds.apps.sve',
'gvsds.apps.t4',
'gvsds.apps.mc',
'gvsds.apps.doc',
'gvsds.apps.rollcall',
'gvsds.apps.api',
'gvsds.apps.community',
'gvsdsdk.msync',
'gvsds.apps.payment',
'gvsds.apps.commission',
'gvsds.apps.stats',
'gvsdsdk',
])
MIDDLEWARE = _app_getlist('middleware', 'MIDDLEWARE', fallback=[
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'gvsds.ip_filter.IPFilterMiddleware',
'gvsds.middleware.DomainMiddleware',
'gvsds.middleware.CurrentRoleMiddleware',
'gvsdsdk.msync.middleware.ServiceAuthMiddleware',
'gvsds.middleware.AttackDetectMiddleware',
'gvsds.middleware.ErrorLoggingMiddleware',
])
ROOT_URLCONF = _app_get('server', 'ROOT_URLCONF', fallback='gvsds.urls')
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / _app_get('template', 'DIRS', fallback='TempLate')],
'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 = _app_get('server', 'WSGI_APPLICATION', fallback='gvsds.wsgi.application')
# ---------------------------------------------------------------------------
# 用户模型(AUTH_USER_MODEL)——架构决策说明
# 业务用户模型为 gvsds.apps.core.models.User(db_table='User',主键 UserUUID,
# bcrypt 密码 UserPassword)。**不设置 AUTH_USER_MODEL**,原因:
# 1. core.User 继承 QModel 而非 Django AbstractBaseUser,无 USERNAME_FIELD 等
# 框架必需接口,设置后 Django SystemCheckError 启动失败;
# 2. 改 AUTH_USER_MODEL 要求重建 contenttypes/auth_permission 框架,
# 现有 MySQL 业务库无法承受(非新建项目);
# 3. 认证链路已收敛:CustomJWTAuthentication 显式绑定 core.User
# (gvsds/auth.py),admin/session 依赖的 auth_user 表未被业务使用。
# 若未来要接入 Django auth 框架,需先重构 core.User 为 AbstractBaseUser 子类。
# ---------------------------------------------------------------------------
SQL_IP = _secret_get('database', 'HOST', fallback='127.0.0.1')
DB_USER = _secret_get('database', 'USER', fallback='root')
DB_PASS = _secret_get('database', 'PASSWORD', fallback='')
DB_PORT = _secret_get('database', 'PORT', fallback='3306')
DB_CHARSET = _secret_get('database', 'CHARSET', fallback=_app_get('database', 'CHARSET', fallback='utf8mb4'))
DB_ENGINE = _app_get('database', 'ENGINE', fallback='django.db.backends.mysql')
DATABASES = {
'default': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'DEFAULT_NAME', fallback='Users'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
'OPTIONS': {
'init_command': f"SET sql_mode='STRICT_TRANS_TABLES', character_set_connection={DB_CHARSET}",
},
},
'gerpcore': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'GERPCORE_NAME', fallback='GerpCore'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
},
'plans': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'PLANS_NAME', fallback='Plans'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
},
'tasks': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'TASKS_NAME', fallback='Tasks'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
},
't4pc': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'T4PC_NAME', fallback='T4PC'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
},
'document': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'DOCUMENT_NAME', fallback='Document'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
},
'minecraft': {
'ENGINE': DB_ENGINE,
'NAME': _app_get('database', 'MINECRAFT_NAME', fallback='Minecraft'),
'USER': DB_USER,
'PASSWORD': DB_PASS,
'HOST': SQL_IP,
'PORT': DB_PORT,
'CHARSET': DB_CHARSET,
},
}
DATABASE_ROUTERS = ['gvsds.db_router.DatabaseRouter']
# === 缓存(Redis 主,locmem 降级) ===
# Redis 不可用时自动降级到本地内存缓存,保证开发环境零依赖启动。
REDIS_HOST = _secret_get('redis', 'HOST', fallback='127.0.0.1')
REDIS_PORT = _secret_getint('redis', 'PORT', fallback=6379)
REDIS_DB = _secret_getint('redis', 'DB', fallback=0)
REDIS_PASSWORD = _secret_get('redis', 'PASSWORD', fallback='')
if _app_getbool('redis', 'ENABLED', fallback=False):
_REDIS_KWARGS = {
'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}',
}
if REDIS_PASSWORD:
_REDIS_KWARGS['LOCATION'] = f'redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': _REDIS_KWARGS['LOCATION'],
'TIMEOUT': _app_getint('cache', 'DEFAULT_TIMEOUT', fallback=300),
'OPTIONS': {},
},
}
else:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'gvsds-default',
'TIMEOUT': _app_getint('cache', 'DEFAULT_TIMEOUT', fallback=300),
},
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {'min_length': _app_getint('password', 'MIN_LENGTH', fallback=8)}},
]
LANGUAGE_CODE = _app_get('server', 'LANGUAGE_CODE', fallback='zh-hans')
TIME_ZONE = _app_get('server', 'TIME_ZONE', fallback='Asia/Shanghai')
USE_I18N = _app_getbool('server', 'USE_I18N', fallback=True)
# USE_TZ 保持 False:全项目时间戳以 datetime.utcnow() 存 naive UTC,
# 改 True 会把存量 naive 时间解释为本地时区,导致 stats 周期键/跨时区比较错乱。
# 若未来统一时区,需先迁移存量数据为 aware UTC(见架构债治理清单 2.7)。
USE_TZ = _app_getbool('server', 'USE_TZ', fallback=False)
STATIC_URL = _app_get('static', 'STATIC_URL', fallback='/static/')
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = _app_get('static', 'MEDIA_URL', fallback='/media/')
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = _app_get('server', 'DEFAULT_AUTO_FIELD', fallback='django.db.models.BigAutoField')
SESSION_COOKIE_DOMAIN = os.environ.get('SESSION_COOKIE_DOMAIN', _app_get('session', 'COOKIE_DOMAIN', fallback='.gvsds.com'))
SESSION_COOKIE_AGE = _app_getint('session', 'COOKIE_AGE', fallback=30 * 24 * 60 * 60)
CSRF_TRUSTED_ORIGINS = [
f'https://{h}' for h in ALLOWED_HOSTS if '*' not in h
]
CORS_ALLOW_ALL_ORIGINS = _app_getbool('cors', 'ALLOW_ALL_ORIGINS', fallback=False)
CORS_ALLOWED_ORIGINS = [
f'https://{h}' for h in ALLOWED_HOSTS if '*' not in h
]
CORS_ALLOW_CREDENTIALS = _app_getbool('cors', 'ALLOW_CREDENTIALS', fallback=True)
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'gvsds.auth.CustomJWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'anon': _app_get('throttle', 'ANON_RATE', fallback='100/min'),
'user': _app_get('throttle', 'USER_RATE', fallback='1000/min'),
},
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': _app_getint('rest_framework', 'PAGE_SIZE', fallback=50),
'DEFAULT_RENDERER_CLASSES': (
'gvsds.json_encoder.JSONRenderer',
),
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.FormParser',
'rest_framework.parsers.MultiPartParser',
),
'EXCEPTION_HANDLER': 'gvsds.exception_handler.custom_exception_handler',
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=_app_getint('jwt', 'ACCESS_TOKEN_LIFETIME_HOURS', fallback=24)), # 24 小时(收敛自 168h,缩小泄露窗口)
'REFRESH_TOKEN_LIFETIME': timedelta(days=_app_getint('jwt', 'REFRESH_TOKEN_LIFETIME_DAYS', fallback=90)), # 90 天
'ROTATE_REFRESH_TOKENS': _app_getbool('jwt', 'ROTATE_REFRESH_TOKENS', fallback=True),
'BLACKLIST_AFTER_ROTATION': _app_getbool('jwt', 'BLACKLIST_AFTER_ROTATION', fallback=False), # 已禁用 blacklist(业务 User 非 Django auth 模型,OutstandingToken 无法落库)
'AUTH_HEADER_TYPES': tuple(_app_getlist('jwt', 'AUTH_HEADER_TYPES', fallback=['Bearer'])),
'USER_ID_FIELD': 'UserUUID',
'USER_ID_CLAIM': 'UserUUID',
}
APPEND_SLASH = _app_getbool('server', 'APPEND_SLASH', fallback=False)
# SSL重定向:生产环境通常由反向代理(nginx)处理SSL终止和HTTP→HTTPS重定向,
# 因此Django层面的SSL_REDIRECT默认关闭。若Django直接对外服务,应设为True。
SECURE_SSL_REDIRECT = _app_getbool('security', 'SSL_REDIRECT', fallback=False)
SECURE_HSTS_SECONDS = _app_getint('security', 'HSTS_SECONDS', fallback=31536000)
SECURE_HSTS_INCLUDE_SUBDOMAINS = _app_getbool('security', 'HSTS_INCLUDE_SUBDOMAINS', fallback=True)
SECURE_HSTS_PRELOAD = _app_getbool('security', 'HSTS_PRELOAD', fallback=True)
SESSION_COOKIE_SECURE = _app_getbool('security', 'SESSION_COOKIE_SECURE', fallback=True)
CSRF_COOKIE_SECURE = _app_getbool('security', 'CSRF_COOKIE_SECURE', fallback=True)
DATA_UPLOAD_MAX_MEMORY_SIZE = _secret_getint('upload', 'MAX_MEMORY_SIZE', fallback=10 * 1024 * 1024)
FILE_UPLOAD_MAX_MEMORY_SIZE = _secret_getint('upload', 'MAX_MEMORY_SIZE', fallback=10 * 1024 * 1024)
PAYMENT_ENCRYPTION_KEY = _secret_get('payment', 'ENCRYPTION_KEY', fallback=None)
if not PAYMENT_ENCRYPTION_KEY:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured('payment ENCRYPTION_KEY 未配置,请在 secrets.ini 或环境变量 PAYMENT_ENCRYPTION_KEY 中设置')
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': _app_get('logging', 'FORMAT', fallback='[{asctime}] [{levelname}] {message}'),
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': str(BASE_DIR / 'logs' / 'django.log'),
'maxBytes': 10 * 1024 * 1024,
'backupCount': 5,
'formatter': 'verbose',
'encoding': 'utf-8',
},
},
'root': {
'handlers': ['console', 'file'],
'level': _app_get('logging', 'LEVEL', fallback='INFO'),
},
}
# === Celery 异步任务(无 Celery 时降级为同步执行) ===
if _app_getbool('celery', 'ENABLED', fallback=False):
_CELERY_BROKER_URL = f'redis://{REDIS_HOST}:{REDIS_PORT}/{_secret_getint("redis", "CELERY_DB", fallback=1)}'
if REDIS_PASSWORD:
_CELERY_BROKER_URL = f'redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{_secret_getint("redis", "CELERY_DB", fallback=1)}'
CELERY_BROKER_URL = _app_get('celery', 'BROKER_URL', fallback=_CELERY_BROKER_URL)
CELERY_RESULT_BACKEND = _app_get('celery', 'RESULT_BACKEND', fallback=_CELERY_BROKER_URL)
CELERY_TIMEZONE = TIME_ZONE
CELERY_ENABLE_UTC = _app_getbool('celery', 'ENABLE_UTC', fallback=False)
CELERY_TASK_SERIALIZER = _app_get('celery', 'TASK_SERIALIZER', fallback='json')
CELERY_RESULT_SERIALIZER = _app_get('celery', 'RESULT_SERIALIZER', fallback='json')
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_TIME_LIMIT = _app_getint('celery', 'TASK_TIME_LIMIT', fallback=300)
CELERY_TASK_SOFT_TIME_LIMIT = _app_getint('celery', 'TASK_SOFT_TIME_LIMIT', fallback=240)
CELERY_TASK_ACKS_LATE = _app_getbool('celery', 'TASK_ACKS_LATE', fallback=True)
CELERY_WORKER_PREFETCH_MULTIPLIER = _app_getint('celery', 'WORKER_PREFETCH', fallback=1)
CELERY_WORKER_MAX_TASKS_PER_CHILD = _app_getint('celery', 'WORKER_MAX_TASKS', fallback=1000)
from celery.schedules import crontab as _crontab
CELERY_BEAT_SCHEDULE = {
# 每日 00:00 重置当日统计
'reset-daily-stats': {
'task': 'gvsds.apps.stats.tasks.ResetDailyStats',
'schedule': _crontab(hour=0, minute=0),
},
# 每月 1 日 00:00 重置月度统计
'reset-monthly-stats': {
'task': 'gvsds.apps.stats.tasks.ResetMonthlyStats',
'schedule': _crontab(day_of_month=1, hour=0, minute=0),
},
# 每日 03:00 清理过期统计(保留 90 天)
'purge-old-stats': {
'task': 'gvsds.apps.stats.tasks.PurgeOldStats',
'schedule': _crontab(hour=3, minute=0),
},
# 每日 23:50 构建当日排行榜
'build-daily-rankings': {
'task': 'gvsds.apps.stats.tasks.BuildDailyRankings',
'schedule': _crontab(hour=23, minute=50),
},
}