- 新增 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
394 lines
16 KiB
Python
394 lines
16 KiB
Python
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),
|
||
},
|
||
}
|