diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 77effe740..7c332c9a0 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: pip @@ -56,3 +56,9 @@ jobs: - name: Run backend tests run: pytest -q + + - name: Check timezone behavior under independent host timezones + run: | + for zone in UTC Asia/Shanghai America/New_York; do + TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py + done diff --git a/.github/workflows/timezone.yml b/.github/workflows/timezone.yml new file mode 100644 index 000000000..99d14da09 --- /dev/null +++ b/.github/workflows/timezone.yml @@ -0,0 +1,106 @@ +name: Timezone Integration + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +jobs: + databases: + runs-on: ubuntu-latest + env: + TZ: America/New_York + PYTHONUTF8: '1' + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: timezone_test + TZ: Asia/Shanghai + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h localhost -ptimezone_test" + --health-interval=10s --health-timeout=5s --health-retries=12 + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: timezone_test + TZ: America/New_York + PGTZ: America/New_York + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s --health-timeout=5s --health-retries=12 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - name: Install verification dependencies + working-directory: ruoyi-fastapi-backend + run: python -m pip install -r requirements.txt asyncpg psycopg2-binary pytest pytest-asyncio aiosqlite + - name: Create isolated source configurations + working-directory: ruoyi-fastapi-backend + run: | + mkdir -p .cache/timezone-ci + python - <<'PY' + import json + from pathlib import Path + for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]: + source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username, + db_password='timezone_test', db_database=database) + Path(f'.cache/timezone-ci/{dialect}.json').write_text(json.dumps(source)) + PY + - name: Verify empty databases, drivers, generated CRUD and persistent schedulers + working-directory: ruoyi-fastapi-backend + run: | + python -m scripts.verify_timezone_database --config .cache/timezone-ci/mysql.json --report .cache/timezone-ci/mysql-report.json + python -m scripts.verify_timezone_database --config .cache/timezone-ci/postgresql.json --report .cache/timezone-ci/postgresql-report.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: timezone-database-reports + path: ruoyi-fastapi-backend/.cache/timezone-ci/*-report.json + include-hidden-files: true + + web-app: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + # Lockfiles are ignored by this repository; cache by the committed manifest. + cache-dependency-path: ruoyi-fastapi-frontend/package.json + - uses: pnpm/action-setup@v5 + with: + package_json_file: ruoyi-fastapi-app/package.json + - name: Install Web dependencies + working-directory: ruoyi-fastapi-frontend + run: npm install --no-package-lock + - name: Install App dependencies + working-directory: ruoyi-fastapi-app + run: pnpm install --no-frozen-lockfile + - name: Run time contracts in three host timezones + run: node ruoyi-fastapi-test/time-contract/run-host-timezones.mjs + - name: Build Web targets + working-directory: ruoyi-fastapi-frontend + run: | + npm run build:prod + npm run build:docker + - name: Build App targets + working-directory: ruoyi-fastapi-app + run: | + pnpm build:h5 + pnpm build:mp-weixin + - name: Validate all Compose configurations + run: | + docker compose -f docker-compose.my.yml config --quiet + docker compose -f docker-compose.pg.yml config --quiet + docker compose -f ruoyi-fastapi-test/docker-compose.test.my.yml config --quiet + docker compose -f ruoyi-fastapi-test/docker-compose.test.pg.yml config --quiet diff --git a/.gitignore b/.gitignore index 2e5dfcce8..f13d541cb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ htmlcov/ .coverage .coverage.* .cache +.pnpm-store/ nosetests.xml coverage.xml *.cover diff --git a/docker-compose.my.yml b/docker-compose.my.yml index d54b0f29d..e6a8dd83b 100644 --- a/docker-compose.my.yml +++ b/docker-compose.my.yml @@ -6,6 +6,8 @@ services: dockerfile: Dockerfile image: ruoyi-frontend:latest container_name: ruoyi-frontend + environment: + TZ: UTC ports: - "12580:80" volumes: @@ -22,6 +24,8 @@ services: dockerfile: Dockerfile.my image: ruoyi-backend-my:latest container_name: ruoyi-backend-my + environment: + TZ: UTC ports: - "19099:9099" depends_on: @@ -37,6 +41,7 @@ services: image: mysql:8.0 container_name: ruoyi-mysql environment: + TZ: UTC MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: ruoyi-fastapi ports: @@ -56,6 +61,8 @@ services: ruoyi-redis: image: redis:latest container_name: ruoyi-redis + environment: + TZ: UTC ports: - "16379:6379" networks: @@ -70,4 +77,4 @@ services: networks: ruoyi-network: name: ruoyi-network - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docker-compose.pg.yml b/docker-compose.pg.yml index 1e66c0ced..496008d03 100644 --- a/docker-compose.pg.yml +++ b/docker-compose.pg.yml @@ -6,6 +6,8 @@ services: dockerfile: Dockerfile image: ruoyi-frontend:latest container_name: ruoyi-frontend + environment: + TZ: UTC ports: - "12580:80" volumes: @@ -22,6 +24,8 @@ services: dockerfile: Dockerfile.pg image: ruoyi-backend-pg:latest container_name: ruoyi-backend-pg + environment: + TZ: UTC ports: - "19099:9099" depends_on: @@ -37,6 +41,7 @@ services: image: postgres:14 container_name: ruoyi-pg environment: + TZ: UTC POSTGRES_PASSWORD: root POSTGRES_DB: ruoyi-fastapi POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C @@ -56,6 +61,8 @@ services: ruoyi-redis: image: redis:latest container_name: ruoyi-redis + environment: + TZ: UTC ports: - "16379:6379" networks: @@ -70,4 +77,4 @@ services: networks: ruoyi-network: name: ruoyi-network - driver: bridge \ No newline at end of file + driver: bridge diff --git a/ruoyi-fastapi-app/package.json b/ruoyi-fastapi-app/package.json index 5ce3f0eb7..76a38ec96 100644 --- a/ruoyi-fastapi-app/package.json +++ b/ruoyi-fastapi-app/package.json @@ -1,8 +1,10 @@ { "name": "ruoyi-fastapi-app", "version": "1.10.0", + "type": "module", "packageManager": "pnpm@10.28.1", "scripts": { + "test:time": "node --test tests/time*.test.js", "dev": "uni -p mp-weixin", "build": "npm run build:mp-weixin", "dev:app": "uni -p app", @@ -59,8 +61,14 @@ "@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001", "@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001", "@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001", + "@formatjs/intl-datetimeformat": "7.6.1", + "@formatjs/intl-getcanonicallocales": "3.2.11", + "@formatjs/intl-locale": "5.3.10", + "@formatjs/intl-numberformat": "9.4.0", + "@formatjs/intl-pluralrules": "6.3.13", "@vue/shared": "3.4.21", "@weapp-tailwindcss/merge-v3": "^0.1.5", + "dayjs": "1.11.23", "node-forge": "^1.4.0", "pinia": "2.2.4", "vue": "^3.4.21", diff --git a/ruoyi-fastapi-app/src/App.vue b/ruoyi-fastapi-app/src/App.vue index 1dfc6785e..397646ef6 100644 --- a/ruoyi-fastapi-app/src/App.vue +++ b/ruoyi-fastapi-app/src/App.vue @@ -3,7 +3,9 @@ import config from "./config"; import { getToken } from "@/utils/auth"; import { useConfigStore } from "@/store"; import { getCurrentInstance } from "vue"; -import { onLaunch } from "@dcloudio/uni-app"; +import { onLaunch, onShow } from "@dcloudio/uni-app"; + +import { refreshDeviceTimezone } from "@/utils/time"; const { proxy } = getCurrentInstance(); @@ -11,6 +13,10 @@ onLaunch(() => { initApp(); }); +onShow(() => { + refreshDeviceTimezone(); +}); + // 初始化应用 function initApp() { // 初始化应用配置 diff --git a/ruoyi-fastapi-app/src/api/system/user.js b/ruoyi-fastapi-app/src/api/system/user.js index dccf8227b..ced904008 100644 --- a/ruoyi-fastapi-app/src/api/system/user.js +++ b/ruoyi-fastapi-app/src/api/system/user.js @@ -1,41 +1,55 @@ -import upload from "@/utils/upload"; -import request from "@/utils/request"; - -// 用户密码重置 -export function updateUserPwd(oldPassword, newPassword) { - const data = { - oldPassword, - newPassword, - }; - return request({ - url: "/system/user/profile/updatePwd", - method: "put", - data: data, - }); -} - -// 查询用户个人信息 -export function getUserProfile() { - return request({ - url: "/system/user/profile", - method: "get", - }); -} - -// 修改用户个人信息 -export function updateUserProfile(data) { - return request({ - url: "/system/user/profile", - method: "put", - data: data, - }); -} - -// 用户头像上传 -export function uploadAvatar(data) { - return upload({ - url: "/system/user/profile/avatar", - name: data.name, - filePath: data.filePath, - }); -} +import upload from "@/utils/upload"; +import request from "@/utils/request"; + +// 用户密码重置 +export function updateUserPwd(oldPassword, newPassword) { + const data = { + oldPassword, + newPassword, + }; + return request({ + url: "/system/user/profile/updatePwd", + method: "put", + data: data, + }); +} + +// 查询用户个人信息 +export function getUserProfile() { + return request({ + url: "/system/user/profile", + method: "get", + }); +} + +// 查询服务端支持的显示时区 +export function getTimezoneOptions() { + return request({ url: "/system/user/profile/timezones", method: "get" }); +} + +// 修改当前账号的显示时区 +export function updateUserTimezone(timeZone) { + return request({ + url: "/system/user/profile/timezone", + method: "put", + data: { timeZone }, + }); +} + +// 修改用户个人信息 +export function updateUserProfile(data) { + return request({ + url: "/system/user/profile", + method: "put", + data: data, + }); +} + +// 用户头像上传 +export function uploadAvatar(data) { + return upload({ + url: "/system/user/profile/avatar", + name: data.name, + filePath: data.filePath, + }); +} diff --git a/ruoyi-fastapi-app/src/pages.json b/ruoyi-fastapi-app/src/pages.json index 983cb0181..ecf76cabc 100644 --- a/ruoyi-fastapi-app/src/pages.json +++ b/ruoyi-fastapi-app/src/pages.json @@ -61,6 +61,12 @@ "navigationBarTitleText": "应用设置" } }, + { + "path": "pages/mine/setting/timezone", + "style": { + "navigationBarTitleText": "时区设置" + } + }, { "path": "pages/mine/help/index", "style": { diff --git a/ruoyi-fastapi-app/src/pages/mine/info/index.vue b/ruoyi-fastapi-app/src/pages/mine/info/index.vue index f62fd1e7d..7cb9f9847 100644 --- a/ruoyi-fastapi-app/src/pages/mine/info/index.vue +++ b/ruoyi-fastapi-app/src/pages/mine/info/index.vue @@ -88,7 +88,7 @@ 创建日期 - {{ user.createTime }} + {{ formatBusinessTime(user.createTime) || '-' }} @@ -97,17 +97,26 @@ + + diff --git a/ruoyi-fastapi-app/src/store/modules/user.js b/ruoyi-fastapi-app/src/store/modules/user.js index 96bf59966..4c7fb686b 100644 --- a/ruoyi-fastapi-app/src/store/modules/user.js +++ b/ruoyi-fastapi-app/src/store/modules/user.js @@ -1,125 +1,155 @@ -import { defineStore } from "pinia"; -import { ref } from "vue"; -import config from "@/config"; -import storage from "@/utils/storage"; -import constant from "@/utils/constant"; -import { isHttp, isEmpty } from "@/utils/validate"; -import { getInfo, login, logout } from "@/api/login"; -import { getToken, removeToken, setToken } from "@/utils/auth"; -import defAva from "@/static/images/profile.jpg"; - -const baseUrl = config.baseUrl; - -export const useUserStore = defineStore("user", () => { - const token = ref(getToken()); - const id = ref(storage.get(constant.id)); - const name = ref(storage.get(constant.name)); - const avatar = ref(storage.get(constant.avatar)); - const roles = ref(storage.get(constant.roles)); - const permissions = ref(storage.get(constant.permissions)); - - const SET_TOKEN = (val) => { - token.value = val; - }; - const SET_ID = (val) => { - id.value = val; - storage.set(constant.id, val); - }; - const SET_NAME = (val) => { - name.value = val; - storage.set(constant.name, val); - }; - const SET_AVATAR = (val) => { - avatar.value = val; - storage.set(constant.avatar, val); - }; - const SET_ROLES = (val) => { - roles.value = val; - storage.set(constant.roles, val); - }; - const SET_PERMISSIONS = (val) => { - permissions.value = val; - storage.set(constant.permissions, val); - }; - - // 登录 - const loginAction = (userInfo) => { - const username = userInfo.username.trim(); - const password = userInfo.password; - const code = userInfo.code; - const uuid = userInfo.uuid; - return new Promise((resolve, reject) => { - login(username, password, code, uuid) - .then((res) => { - setToken(res.token); - SET_TOKEN(res.token); - resolve(); - }) - .catch((error) => { - reject(error); - }); - }); - }; - - // 获取用户信息 - const getInfoAction = () => { - return new Promise((resolve, reject) => { - getInfo() - .then((res) => { - const user = res.user; - let avatar = user.avatar || ""; - if (!isHttp(avatar)) { - avatar = isEmpty(avatar) ? defAva : baseUrl + avatar; - } - const userid = - isEmpty(user) || isEmpty(user.userId) ? "" : user.userId; - const username = - isEmpty(user) || isEmpty(user.userName) ? "" : user.userName; - if (res.roles && res.roles.length > 0) { - SET_ROLES(res.roles); - SET_PERMISSIONS(res.permissions); - } else { - SET_ROLES(["ROLE_DEFAULT"]); - } - SET_ID(userid); - SET_NAME(username); - SET_AVATAR(avatar); - resolve(res); - }) - .catch((error) => { - reject(error); - }); - }); - }; - - // 退出系统 - const logOutAction = () => { - return new Promise((resolve, reject) => { - logout(token.value) - .then(() => { - SET_TOKEN(""); - SET_ROLES([]); - SET_PERMISSIONS([]); - removeToken(); - storage.clean(); - resolve(); - }) - .catch((error) => { - reject(error); - }); - }); - }; - - return { - token, - id, - name, - avatar, - roles, - permissions, - SET_AVATAR, - login: loginAction, - getInfo: getInfoAction, - logOut: logOutAction, - }; -}); +import { defineStore } from "pinia"; +import { ref } from "vue"; +import config from "@/config"; +import storage from "@/utils/storage"; +import constant from "@/utils/constant"; +import { isHttp, isEmpty } from "@/utils/validate"; +import { getInfo, login, logout } from "@/api/login"; +import { getToken, removeToken, setToken } from "@/utils/auth"; +import { setBusinessTimezone, setUserTimezone } from "@/utils/time"; +import defAva from "@/static/images/profile.jpg"; + +const baseUrl = config.baseUrl; + +export const useUserStore = defineStore("user", () => { + const token = ref(getToken()); + const id = ref(storage.get(constant.id)); + const name = ref(storage.get(constant.name)); + const avatar = ref(storage.get(constant.avatar)); + const roles = ref(storage.get(constant.roles)); + const permissions = ref(storage.get(constant.permissions)); + const appTimezone = ref("Asia/Shanghai"); + const timeZone = ref("auto"); + + const applyTimezone = (preference = "auto") => { + setUserTimezone(preference); + timeZone.value = preference; + storage.set(constant.timezone, { + appTimezone: appTimezone.value, + timeZone: preference, + }); + }; + // 恢复已登录账号的设置,避免首屏短暂显示为另一个时区。 + const savedTimezone = storage.get(constant.timezone); + if (token.value && savedTimezone) { + try { + appTimezone.value = savedTimezone.appTimezone; + setBusinessTimezone(appTimezone.value); + applyTimezone(savedTimezone.timeZone); + } catch { + storage.remove(constant.timezone); + } + } + + const SET_TOKEN = (val) => { + token.value = val; + }; + const SET_ID = (val) => { + id.value = val; + storage.set(constant.id, val); + }; + const SET_NAME = (val) => { + name.value = val; + storage.set(constant.name, val); + }; + const SET_AVATAR = (val) => { + avatar.value = val; + storage.set(constant.avatar, val); + }; + const SET_ROLES = (val) => { + roles.value = val; + storage.set(constant.roles, val); + }; + const SET_PERMISSIONS = (val) => { + permissions.value = val; + storage.set(constant.permissions, val); + }; + + // 登录 + const loginAction = (userInfo) => { + const username = userInfo.username.trim(); + const password = userInfo.password; + const code = userInfo.code; + const uuid = userInfo.uuid; + return new Promise((resolve, reject) => { + login(username, password, code, uuid) + .then((res) => { + setToken(res.token); + SET_TOKEN(res.token); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + // 获取用户信息 + const getInfoAction = () => { + return new Promise((resolve, reject) => { + getInfo() + .then((res) => { + const user = res.user; + appTimezone.value = res.appTimezone; + setBusinessTimezone(res.appTimezone); + applyTimezone(user.timeZone); + let avatar = user.avatar || ""; + if (!isHttp(avatar)) { + avatar = isEmpty(avatar) ? defAva : baseUrl + avatar; + } + const userid = + isEmpty(user) || isEmpty(user.userId) ? "" : user.userId; + const username = + isEmpty(user) || isEmpty(user.userName) ? "" : user.userName; + if (res.roles && res.roles.length > 0) { + SET_ROLES(res.roles); + SET_PERMISSIONS(res.permissions); + } else { + SET_ROLES(["ROLE_DEFAULT"]); + } + SET_ID(userid); + SET_NAME(username); + SET_AVATAR(avatar); + resolve(res); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + // 退出系统 + const logOutAction = () => { + return new Promise((resolve, reject) => { + logout(token.value) + .then(() => { + SET_TOKEN(""); + applyTimezone("auto"); + SET_ROLES([]); + SET_PERMISSIONS([]); + removeToken(); + storage.clean(); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }; + + return { + token, + id, + name, + avatar, + roles, + permissions, + appTimezone, + timeZone, + applyTimezone, + SET_AVATAR, + login: loginAction, + getInfo: getInfoAction, + logOut: logOutAction, + }; +}); diff --git a/ruoyi-fastapi-app/src/utils/constant.js b/ruoyi-fastapi-app/src/utils/constant.js index ea1bdd425..5f227463a 100644 --- a/ruoyi-fastapi-app/src/utils/constant.js +++ b/ruoyi-fastapi-app/src/utils/constant.js @@ -1,9 +1,10 @@ -const constant = { - avatar: "user_avatar", - id: "user_id", - name: "user_name", - roles: "user_roles", - permissions: "user_permissions", -}; - -export default constant; +const constant = { + timezone: "timezone", + avatar: "user_avatar", + id: "user_id", + name: "user_name", + roles: "user_roles", + permissions: "user_permissions", +}; + +export default constant; diff --git a/ruoyi-fastapi-app/src/utils/request.js b/ruoyi-fastapi-app/src/utils/request.js index 35188a8f0..e6f0e99cb 100644 --- a/ruoyi-fastapi-app/src/utils/request.js +++ b/ruoyi-fastapi-app/src/utils/request.js @@ -1,5 +1,6 @@ import config from "@/config"; import { getToken } from "@/utils/auth"; +import { getDisplayTimezone } from "@/utils/time"; import errorCode from "@/utils/errorCode"; import { useUserStore } from "@/store/modules/user"; import { toast, showConfirm, tansParams } from "@/utils/common"; @@ -20,6 +21,8 @@ const request = async (config) => { const isToken = (config.headers || {}).isToken === false; config.header = config.header || {}; config.headers = config.headers || {}; + config.header["X-Timezone"] = + config.header["X-Timezone"] || getDisplayTimezone(); if (getToken() && !isToken) { config.header["Authorization"] = "Bearer " + getToken(); } @@ -46,7 +49,10 @@ const request = async (config) => { try { const res = await decryptTransportResponse(response, config); - if (shouldRetryTransportWithFreshKey(res) && !config.__transportRetried) { + if ( + shouldRetryTransportWithFreshKey(res) && + !config.__transportRetried + ) { invalidateTransportKeyMeta(); config.__transportRetried = true; config.headers.repeatSubmit = false; @@ -69,7 +75,9 @@ const request = async (config) => { }); } }); - const error = new Error("无效的会话,或者会话已过期,请重新登录。"); + const error = new Error( + "无效的会话,或者会话已过期,请重新登录。", + ); error.response = res; reject(error); } else if (code === 500) { diff --git a/ruoyi-fastapi-app/src/utils/storage.js b/ruoyi-fastapi-app/src/utils/storage.js index d86ca98b7..89e0e0536 100644 --- a/ruoyi-fastapi-app/src/utils/storage.js +++ b/ruoyi-fastapi-app/src/utils/storage.js @@ -1,38 +1,39 @@ -import constant from "./constant"; - -// 存储变量名 -let storageKey = "storage_data"; - -// 存储节点变量名 -let storageNodeKeys = [ - constant.avatar, - constant.id, - constant.name, - constant.roles, - constant.permissions, -]; - -const storage = { - set: function (key, value) { - if (storageNodeKeys.indexOf(key) != -1) { - let tmp = uni.getStorageSync(storageKey); - tmp = tmp ? tmp : {}; - tmp[key] = value; - uni.setStorageSync(storageKey, tmp); - } - }, - get: function (key) { - let storageData = uni.getStorageSync(storageKey) || {}; - return storageData[key] || ""; - }, - remove: function (key) { - let storageData = uni.getStorageSync(storageKey) || {}; - delete storageData[key]; - uni.setStorageSync(storageKey, storageData); - }, - clean: function () { - uni.removeStorageSync(storageKey); - }, -}; - -export default storage; +import constant from "./constant"; + +// 存储变量名 +let storageKey = "storage_data"; + +// 存储节点变量名 +let storageNodeKeys = [ + constant.avatar, + constant.id, + constant.name, + constant.roles, + constant.permissions, + constant.timezone, +]; + +const storage = { + set: function (key, value) { + if (storageNodeKeys.indexOf(key) != -1) { + let tmp = uni.getStorageSync(storageKey); + tmp = tmp ? tmp : {}; + tmp[key] = value; + uni.setStorageSync(storageKey, tmp); + } + }, + get: function (key) { + let storageData = uni.getStorageSync(storageKey) || {}; + return storageData[key] || ""; + }, + remove: function (key) { + let storageData = uni.getStorageSync(storageKey) || {}; + delete storageData[key]; + uni.setStorageSync(storageKey, storageData); + }, + clean: function () { + uni.removeStorageSync(storageKey); + }, +}; + +export default storage; diff --git a/ruoyi-fastapi-app/src/utils/time.js b/ruoyi-fastapi-app/src/utils/time.js new file mode 100644 index 000000000..653bd6678 --- /dev/null +++ b/ruoyi-fastapi-app/src/utils/time.js @@ -0,0 +1,535 @@ +// 补齐小程序运行时可能缺失的 Intl 能力和 IANA 时区数据。 +// 同步加载,保证业务时区初始化和首屏渲染时已具备完整能力。 +import '@formatjs/intl-getcanonicallocales/polyfill.js' +import '@formatjs/intl-locale/polyfill.js' +import '@formatjs/intl-pluralrules/polyfill.js' +import '@formatjs/intl-pluralrules/locale-data/en.js' +import '@formatjs/intl-numberformat/polyfill.js' +import '@formatjs/intl-numberformat/locale-data/en.js' +import '@formatjs/intl-datetimeformat/polyfill.js' +import '@formatjs/intl-datetimeformat/locale-data/en.js' +import '@formatjs/intl-datetimeformat/add-all-tz.js' + +import { ref } from 'vue' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc.js' + +dayjs.extend(utc) + +const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ +const WALL_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/ +const RFC3339_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/ +const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'] +const MILLISECONDS_PER_SECOND = 1000 +const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND +const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE +const ORIGINAL_TIME_FIELDS = Symbol('originalTimeFields') +const timezoneFormatters = new Map() + +const businessTimezone = ref('Asia/Shanghai') +const userTimezone = ref('auto') +const deviceTimezone = ref(null) + +/** + * 日期时间输入校验异常,包含夏令时重复时间的候选值。 + */ +export class TimeInputError extends Error { + /** + * @param {string} message 校验提示 + * @param {string} code 错误类型 + * @param {Array<{epoch: number, offset: string, value: string}>} candidates 可选的真实时刻 + */ + constructor(message, code, candidates = []) { + super(message) + this.code = code + this.candidates = candidates + } +} + +/** + * 获取并缓存指定时区的日期时间格式化器。 + * + * @param {string} timezoneName IANA 时区名称 + * @returns {Intl.DateTimeFormat} 格式化器实例 + */ +function getFormatter(timezoneName) { + if (typeof timezoneName !== 'string' || !timezoneName.trim() || /^[+-]/.test(timezoneName)) { + throw new Error(`无效的 IANA 时区: ${timezoneName}`) + } + + const name = timezoneName.trim() + if (!timezoneFormatters.has(name)) { + try { + const formatter = new Intl.DateTimeFormat('en', { + timeZone: name, + calendar: 'gregory', + numberingSystem: 'latn', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }) + timezoneFormatters.set(name, formatter) + } catch { + throw new Error(`无效的 IANA 时区: ${timezoneName}`) + } + } + return timezoneFormatters.get(name) +} + +/** + * 更新默认业务时区。 + * + * @param {string} timezoneName 服务端返回的 IANA 时区名称 + * @returns {void} + */ +export function setBusinessTimezone(timezoneName) { + getFormatter(timezoneName) + businessTimezone.value = timezoneName.trim() +} + +/** + * 获取当前业务时区。 + * + * @returns {string} IANA 时区名称 + */ +export function getBusinessTimezone() { + return businessTimezone.value +} + +/** + * 设置账号显示偏好。auto 跟随设备;IANA 名称表示手动选择。 + * + * @param {string} preference 账号保存的时区偏好 + * @returns {void} + */ +export function setUserTimezone(preference = 'auto') { + if (preference !== 'auto') { + getFormatter(preference) + } + userTimezone.value = preference.trim() + refreshDeviceTimezone() +} + +/** + * 获取账号时区偏好。 + * + * @returns {string} auto 或 IANA 时区名称 + */ +export function getUserTimezone() { + return userTimezone.value +} + +/** + * 重新识别设备的 IANA 时区。不能从单个 UTC 偏移猜测地区或夏令时规则。 + * + * @returns {string|null} 设备时区;当前运行时不支持识别时返回 null + */ +export function refreshDeviceTimezone() { + try { + // FormatJS 默认时区是 UTC,不能把它误报为设备时区。 + if (Intl.DateTimeFormat.polyfilled) { + deviceTimezone.value = null + return null + } + const name = new Intl.DateTimeFormat().resolvedOptions().timeZone + getFormatter(name) + deviceTimezone.value = name + } catch { + deviceTimezone.value = null + } + return deviceTimezone.value +} + +/** + * 获取最近一次识别的设备时区。 + * + * @returns {string|null} IANA 时区;无法识别时返回 null + */ +export function getDeviceTimezone() { + return deviceTimezone.value +} + +/** + * 获取页面展示、日期输入和普通日期筛选使用的有效时区。 + * + * @returns {string} IANA 时区名称 + */ +export function getDisplayTimezone() { + return userTimezone.value === 'auto' ? deviceTimezone.value || businessTimezone.value : userTimezone.value +} + +/** + * 获取已打开表单的时区,确保控件提示与提交转换一致。 + * + * @param {Object} record 经 prepareTimeFields 准备的表单或子表行 + * @returns {string} 表单绑定的时区 + */ +export function getTimeFieldsTimezone(record) { + return record?.[ORIGINAL_TIME_FIELDS]?.timezoneName || getDisplayTimezone() +} + +/** + * 过滤当前运行时不支持的时区,保留设备时区和已保存的有效选择。 + * + * @param {string[]} names 服务端提供的 IANA 名称 + * @returns {string[]} 按名称排序的可选时区 + */ +export function getSupportedTimezones(names = []) { + return [...new Set([...names, getDisplayTimezone(), getBusinessTimezone(), 'UTC'])] + .filter(name => { + try { + getFormatter(name) + return true + } catch { + return false + } + }) + .sort() +} + +/** + * 校验当地日期时间,并将各时间分量放入 UTC 毫秒坐标以便计算。 + * + * @param {string} value 不含时区的日期或日期时间字符串 + * @param {boolean} dateOnly 是否仅校验纯日期 + * @returns {number|null} 用于日历计算的毫秒值,非法输入返回 null + */ +function parseWallTimeMilliseconds(value, dateOnly = false) { + const match = typeof value === 'string' && value.match(dateOnly ? DATE_ONLY_PATTERN : WALL_TIME_PATTERN) + if (!match) { + return null + } + + const [year, month, day, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number) + const millisecond = Number((match[7] || '').padEnd(3, '0')) + const date = new Date(0) + date.setUTCFullYear(year, month - 1, day) + date.setUTCHours(hour, minute, second, millisecond) + + // Date 会自动进位非法日期,逐项回读以拒绝越界输入。 + if ( + year < 1 || + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second + ) { + return null + } + return date.getTime() +} + +/** + * 解析带偏移的真实时刻,拒绝无时区的日期时间。 + * + * @param {string|Date} value RFC 3339 字符串或 Date 对象 + * @returns {number|null} Unix 毫秒时间戳,非法输入返回 null + */ +function parseInstantMilliseconds(value) { + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value.getTime() : null + } + + const match = typeof value === 'string' && value.match(RFC3339_PATTERN) + if (!match || parseWallTimeMilliseconds(match[1]) === null) { + return null + } + + const result = Date.parse(`${match[1]}${(match[2] || '.000').slice(0, 4)}${match[3]}`) + return Number.isFinite(result) ? result : null +} + +/** + * 计算指定时刻在目标时区的 UTC 偏移。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} timezoneName IANA 时区名称 + * @returns {number} 相对 UTC 的偏移分钟数 + */ +function getTimezoneOffsetMinutes(epoch, timezoneName) { + const parts = Object.fromEntries( + getFormatter(timezoneName) + .formatToParts(epoch) + .map(part => [part.type, part.value]) + ) + const wallTime = `${parts.year.padStart(4, '0')}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` + const instantMilliseconds = Math.floor(epoch / MILLISECONDS_PER_SECOND) * MILLISECONDS_PER_SECOND + return (parseWallTimeMilliseconds(wallTime) - instantMilliseconds) / MILLISECONDS_PER_MINUTE +} + +/** + * 按显式偏移创建业务时间,避免经过宿主机本地时区转换。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} timezoneName IANA 时区名称 + * @returns {import('dayjs').Dayjs} 目标时区下的日期时间 + */ +function createZonedDateTime(epoch, timezoneName) { + return dayjs.utc(epoch).utcOffset(getTimezoneOffsetMinutes(epoch, timezoneName)) +} + +/** + * 格式化毫秒时间戳,兼容项目原有的日期格式占位符。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} pattern Day.js 格式或项目日期格式占位符 + * @param {string} timezoneName IANA 时区名称 + * @returns {string} 业务时间展示文本 + */ +function formatEpoch(epoch, pattern, timezoneName) { + const value = createZonedDateTime(epoch, timezoneName) + const format = pattern + .replaceAll('{y}', 'YYYY') + .replaceAll('{m}', 'MM') + .replaceAll('{d}', 'DD') + .replaceAll('{h}', 'HH') + .replaceAll('{i}', 'mm') + .replaceAll('{s}', 'ss') + .replaceAll('{a}', `[${WEEKDAYS[value.day()]}]`) + return value.format(format) +} + +/** + * 将真实时刻格式化为业务时间。 + * + * @param {string|Date} value RFC 3339 字符串或 Date 对象 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatBusinessTime(value, pattern = 'YYYY-MM-DD HH:mm:ss', timezoneName = getDisplayTimezone()) { + const epoch = parseInstantMilliseconds(value) + return epoch === null ? null : formatEpoch(epoch, pattern, timezoneName) +} + +/** + * 格式化明确以毫秒为单位的 Unix 时间戳。 + * + * @param {number} value Unix 毫秒时间戳 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatEpochMilliseconds(value, pattern = 'YYYY-MM-DD HH:mm:ss', timezoneName = getDisplayTimezone()) { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isFinite(new Date(value).getTime())) { + return null + } + return formatEpoch(value, pattern, timezoneName) +} + +/** + * 格式化明确以秒为单位的 Unix 时间戳。 + * + * @param {number} value Unix 秒时间戳 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatEpochSeconds(value, pattern, timezoneName) { + return typeof value === 'number' + ? formatEpochMilliseconds(value * MILLISECONDS_PER_SECOND, pattern, timezoneName) + : null +} + +/** + * 查找业务当地时间对应的真实时刻候选值。 + * + * @param {string} value 不含偏移的业务日期时间 + * @param {string} timezoneName IANA 时区名称 + * @returns {Array<{epoch: number, offset: string, value: string}>} 按时刻排序的候选值 + */ +export function getWallTimeCandidates(value, timezoneName = getDisplayTimezone()) { + const wallMilliseconds = parseWallTimeMilliseconds(value) + if (wallMilliseconds === null) { + throw new TimeInputError(`无效的日期时间: ${value}`, 'INVALID_TIME') + } + + const offsets = new Set() + // 采样跳转前后的偏移,覆盖半小时夏令时和跨日期变更。 + for (let hours = -48; hours <= 48; hours += 6) { + offsets.add(getTimezoneOffsetMinutes(wallMilliseconds + hours * MILLISECONDS_PER_HOUR, timezoneName)) + } + + return [...offsets] + .map(offset => ({ offset, epoch: wallMilliseconds - offset * MILLISECONDS_PER_MINUTE })) + .filter(candidate => getTimezoneOffsetMinutes(candidate.epoch, timezoneName) === candidate.offset) + .sort((first, second) => first.epoch - second.epoch) + .map(candidate => ({ + epoch: candidate.epoch, + offset: createZonedDateTime(candidate.epoch, timezoneName).format('Z'), + value: createZonedDateTime(candidate.epoch, timezoneName).format('YYYY-MM-DDTHH:mm:ss.SSSZ') + })) +} + +/** + * 将真实时刻或业务当地时间转换为毫秒精度 RFC 3339 字符串。 + * + * @param {string|Date} value 原始时刻或业务日期时间 + * @param {Object} options 时区和重复时间的偏移选择 + * @param {string} options.timezoneName 业务 IANA 时区名称 + * @param {string} options.offset 夏令时重复时间使用的 UTC 偏移 + * @returns {string|undefined} RFC 3339 字符串,空输入返回 undefined + * @throws {TimeInputError} 当地时间不存在或重复时间尚未选择偏移时抛出异常 + */ +export function toRfc3339(value, { timezoneName = getDisplayTimezone(), offset } = {}) { + if (value === undefined || value === null || value === '') { + return undefined + } + + const epoch = parseInstantMilliseconds(value) + if (epoch !== null) { + if (value instanceof Date) { + return value.toISOString() + } + const match = value.match(RFC3339_PATTERN) + return `${match[1]}.${(match[2]?.slice(1) || '').padEnd(3, '0').slice(0, 3)}${match[3]}` + } + + const candidates = getWallTimeCandidates(value, timezoneName) + if (!candidates.length) { + throw new TimeInputError(`${value} 在 ${timezoneName} 不存在,请选择夏令时跳转后的有效时间`, 'DST_GAP') + } + if (candidates.length === 1) { + return candidates[0].value + } + + const selected = candidates.find(candidate => candidate.offset === offset) + if (selected) { + return selected.value + } + throw new TimeInputError(`${value} 在 ${timezoneName} 出现两次,请选择 UTC 偏移`, 'DST_FOLD', candidates) +} + +/** + * 将真实时刻转换为日期时间输入控件使用的业务时间。 + * + * @param {string|Date} value 原始真实时刻 + * @param {string} timezoneName 业务 IANA 时区名称 + * @returns {string|null} 不含偏移的日期时间,非法输入返回 null + */ +export function toBusinessDateTimeInput(value, timezoneName = getDisplayTimezone()) { + return formatBusinessTime(value, 'YYYY-MM-DD HH:mm:ss', timezoneName) +} + +/** + * 按字段语义规范化日期或精确时刻查询边界。 + * + * @param {string|Date} value 日期或真实时刻 + * @returns {string|undefined} 纯日期或 RFC 3339 字符串,空输入返回 undefined + */ +export function normalizeRangeBoundary(value) { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value === 'string' && DATE_ONLY_PATTERN.test(value)) { + if (parseWallTimeMilliseconds(value, true) === null) { + throw new TimeInputError(`无效的日期: ${value}`, 'INVALID_DATE') + } + return value + } + return toRfc3339(value) +} + +/** + * 创建可编辑的业务时间字段,并保存原始时刻供无修改提交时使用。 + * + * @param {Object} record API 返回的原始记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {string} timezoneName 业务 IANA 时区名称 + * @returns {Object} 包含业务时间和原值快照的新记录 + */ +export function prepareTimeFields(record, fields, timezoneName = getDisplayTimezone()) { + const result = { ...record } + const originals = {} + for (const field of fields) { + if (!(field in record)) { + continue + } + + const original = record[field] + const input = original == null || original === '' ? original : toBusinessDateTimeInput(original, timezoneName) + if (input === null && original != null) { + throw new TimeInputError(`字段 ${field} 包含无效的 RFC 3339 时间`, 'INVALID_TIME') + } + result[field] = input + originals[field] = { original, input } + } + result[ORIGINAL_TIME_FIELDS] = { originals, timezoneName } + return result +} + +/** + * 构造提交记录,保留未修改字段的原始毫秒和偏移。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {Object} offsets 各重复时间字段选定的 UTC 偏移 + * @returns {Object} 可提交的新记录,不修改当前表单 + */ +export function serializeTimeFields(record, fields, offsets = {}) { + const result = { ...record } + const state = record[ORIGINAL_TIME_FIELDS] + delete result[ORIGINAL_TIME_FIELDS] + + for (const field of fields) { + if (!(field in record)) { + continue + } + + const value = record[field] + const original = state?.originals[field] + if (original && value === original.input) { + result[field] = original.original + } else if (value == null) { + result[field] = value + } else if (value === '') { + result[field] = null + } else { + try { + result[field] = toRfc3339(value, { + timezoneName: state?.timezoneName || getDisplayTimezone(), + offset: offsets[field] + }) + } catch (error) { + error.field = field + throw error + } + } + } + return result +} + +/** + * 逐项处理重复时间的偏移选择,再生成可提交记录。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {function(TimeInputError): Promise} chooseOffset 请求用户选择偏移的回调 + * @returns {Promise} 已解决重复时间歧义的提交记录 + */ +export async function resolveTimeFields(record, fields, chooseOffset) { + const offsets = {} + while (true) { + try { + return serializeTimeFields(record, fields, offsets) + } catch (error) { + if (error.code !== 'DST_FOLD') { + throw error + } + const offset = await chooseOffset(error) + if (!error.candidates.some(candidate => candidate.offset === offset)) { + throw error + } + offsets[error.field] = offset + } + } +} + +setBusinessTimezone(businessTimezone.value) +refreshDeviceTimezone() diff --git a/ruoyi-fastapi-app/tests/time-polyfill.test.js b/ruoyi-fastapi-app/tests/time-polyfill.test.js new file mode 100644 index 000000000..16ecb045d --- /dev/null +++ b/ruoyi-fastapi-app/tests/time-polyfill.test.js @@ -0,0 +1,5 @@ +import { testTimeContract } from '../../ruoyi-fastapi-test/time-contract/contract.mjs' +// 在独立测试进程中模拟缺少原生 Intl 的运行时,再加载 App 时间工具。 +globalThis.Intl = undefined +const time = await import('../src/utils/time.js') +testTimeContract(time) diff --git a/ruoyi-fastapi-app/tests/time.test.js b/ruoyi-fastapi-app/tests/time.test.js new file mode 100644 index 000000000..3fdff1427 --- /dev/null +++ b/ruoyi-fastapi-app/tests/time.test.js @@ -0,0 +1,3 @@ +import * as time from '../src/utils/time.js' +import { testTimeContract } from '../../ruoyi-fastapi-test/time-contract/contract.mjs' +testTimeContract(time) diff --git a/ruoyi-fastapi-app/vite.config.ts b/ruoyi-fastapi-app/vite.config.ts index 8888b9a36..6f2e8596a 100644 --- a/ruoyi-fastapi-app/vite.config.ts +++ b/ruoyi-fastapi-app/vite.config.ts @@ -1,9 +1,13 @@ -import uni from "@dcloudio/vite-plugin-uni"; +import uniPackage from "@dcloudio/vite-plugin-uni"; import { defineConfig } from "vite"; import { UnifiedViteWeappTailwindcssPlugin } from "weapp-tailwindcss/vite"; import { WeappTailwindcssDisabled } from "./platform"; import postcssPlugins from "./postcss.config"; +// 当前 uni 插件以 CommonJS 导出 default;ESM 配置需解开这一层。 +const uni = + (uniPackage as unknown as { default?: typeof uniPackage }).default || uniPackage; + function patchNodeForgeMiniProgramRuntime() { return { name: "patch-node-forge-mini-program-runtime", diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index d68f083f1..78ec319dc 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -33,6 +33,8 @@ APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' APP_TRUSTED_PROXY_HOPS = 1 # 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 APP_DEFAULT_ENABLED_PLUGINS = 'ai' +# 应用业务时区,使用IANA时区名称(如Asia/Shanghai),用于时间展示、日期范围查询和定时任务默认时区 +APP_TIMEZONE = 'Asia/Shanghai' # -------- Jwt配置 -------- # Jwt秘钥,留空时自动生成,开发环境下不要留空;也可使用 openssl rand -hex 32 手动生成 diff --git a/ruoyi-fastapi-backend/.env.dockermy b/ruoyi-fastapi-backend/.env.dockermy index 895b71706..a14db6a00 100644 --- a/ruoyi-fastapi-backend/.env.dockermy +++ b/ruoyi-fastapi-backend/.env.dockermy @@ -33,6 +33,8 @@ APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' APP_TRUSTED_PROXY_HOPS = 1 # 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 APP_DEFAULT_ENABLED_PLUGINS = 'ai' +# 应用业务时区,使用IANA时区名称(如Asia/Shanghai),用于时间展示、日期范围查询和定时任务默认时区 +APP_TIMEZONE = 'Asia/Shanghai' # -------- Jwt配置 -------- # Jwt秘钥,留空时自动生成;也可使用 openssl rand -hex 32 手动生成 diff --git a/ruoyi-fastapi-backend/.env.dockerpg b/ruoyi-fastapi-backend/.env.dockerpg index cd52d0b42..dcb6d5ec3 100644 --- a/ruoyi-fastapi-backend/.env.dockerpg +++ b/ruoyi-fastapi-backend/.env.dockerpg @@ -33,6 +33,8 @@ APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' APP_TRUSTED_PROXY_HOPS = 1 # 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 APP_DEFAULT_ENABLED_PLUGINS = 'ai' +# 应用业务时区,使用IANA时区名称(如Asia/Shanghai),用于时间展示、日期范围查询和定时任务默认时区 +APP_TIMEZONE = 'Asia/Shanghai' # -------- Jwt配置 -------- # Jwt秘钥,留空时自动生成;也可使用 openssl rand -hex 32 手动生成 diff --git a/ruoyi-fastapi-backend/.env.prod b/ruoyi-fastapi-backend/.env.prod index 542f3a974..f90d51ed0 100644 --- a/ruoyi-fastapi-backend/.env.prod +++ b/ruoyi-fastapi-backend/.env.prod @@ -33,6 +33,8 @@ APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' APP_TRUSTED_PROXY_HOPS = 1 # 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 APP_DEFAULT_ENABLED_PLUGINS = 'ai' +# 应用业务时区,使用IANA时区名称(如Asia/Shanghai),用于时间展示、日期范围查询和定时任务默认时区 +APP_TIMEZONE = 'Asia/Shanghai' # -------- Jwt配置 -------- # Jwt秘钥,留空时自动生成;也可使用 openssl rand -hex 32 手动生成 diff --git a/ruoyi-fastapi-backend/Dockerfile.my b/ruoyi-fastapi-backend/Dockerfile.my index 2400f72cf..240396f8f 100644 --- a/ruoyi-fastapi-backend/Dockerfile.my +++ b/ruoyi-fastapi-backend/Dockerfile.my @@ -1,4 +1,5 @@ FROM python:3.10 +ENV TZ=UTC WORKDIR /app # 复制源代码 diff --git a/ruoyi-fastapi-backend/Dockerfile.pg b/ruoyi-fastapi-backend/Dockerfile.pg index 5a4b0d232..cd22c60fe 100644 --- a/ruoyi-fastapi-backend/Dockerfile.pg +++ b/ruoyi-fastapi-backend/Dockerfile.pg @@ -1,4 +1,5 @@ FROM python:3.10 +ENV TZ=UTC WORKDIR /app # 复制源代码 diff --git a/ruoyi-fastapi-backend/cli/completion/installers.py b/ruoyi-fastapi-backend/cli/completion/installers.py index 92ef1d092..95e488535 100644 --- a/ruoyi-fastapi-backend/cli/completion/installers.py +++ b/ruoyi-fastapi-backend/cli/completion/installers.py @@ -8,7 +8,7 @@ import click import typer import typer.main -from click.shell_completion import BashComplete, FishComplete, ZshComplete +from click.shell_completion import BashComplete, FishComplete, ShellComplete, ZshComplete from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered @@ -19,6 +19,22 @@ CLICK_COMPLETE_ENV_VAR = '_RUOYI_COMPLETE' +class PortableBashComplete(BashComplete): + """ + 无需探测本机Bash版本的补全脚本生成器 + """ + + def source(self) -> str: + """ + 生成可供其他机器使用的Bash补全脚本 + + 旧版Bash差异已由脚本转换器处理;跳过Click版本探测,避免调用Windows WSL启动器。 + + :return: Bash补全脚本文本 + """ + return ShellComplete.source(self) + + class CompletionInstallerShellSupport: """ completion shell 差异支持对象。 @@ -161,7 +177,7 @@ def get(self, shell_name: str) -> CompletionShellRuntimePolicy | None: policies={ 'bash': CompletionShellRuntimePolicy( name='bash', - click_completion_class=BashComplete, + click_completion_class=PortableBashComplete, script_transformer=CompletionInstallerShellSupport.make_bash_completion_script_compatible, source_command_builder=CompletionInstallerShellSupport.build_posix_source_command, ), diff --git a/ruoyi-fastapi-backend/cli/completion/shells.py b/ruoyi-fastapi-backend/cli/completion/shells.py index 1bd8fc5cf..01dcb6123 100644 --- a/ruoyi-fastapi-backend/cli/completion/shells.py +++ b/ruoyi-fastapi-backend/cli/completion/shells.py @@ -109,5 +109,6 @@ def ensure_custom_completion_classes_registered() -> None: :return: None """ - if get_completion_class(PowerShellComplete.name) is None: + # Click 8.5 新增同名实现,但其数字索引协议与本项目脚本的文本参数协议不同。 + if get_completion_class(PowerShellComplete.name) is not PowerShellComplete: add_completion_class(PowerShellComplete) diff --git a/ruoyi-fastapi-backend/cli/groups/job/presenter.py b/ruoyi-fastapi-backend/cli/groups/job/presenter.py index e52f570be..093e78913 100644 --- a/ruoyi-fastapi-backend/cli/groups/job/presenter.py +++ b/ruoyi-fastapi-backend/cli/groups/job/presenter.py @@ -75,14 +75,16 @@ def build_job_detail_text(self, payload: dict[str, object]) -> str: f'job_id: {job.get("jobId", "-")}', f'job_name: {job.get("jobName", "-")}', f'job_group: {job.get("jobGroup", "-")}', + f'job_store: {job.get("jobStore", "-")}', f'job_executor: {job.get("jobExecutor", "-")}', f'status: {job.get("status", "-")}', f'cron_expression: {job.get("cronExpression", "-")}', f'invoke_target: {SHELL_TEXT_FORMATTER.truncate_text(job.get("invokeTarget", ""), 160) or "-"}', f'job_args: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobArgs", ""), 120) or "-"}', f'job_kwargs: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobKwargs", ""), 120) or "-"}', - f'misfire_policy: {job.get("misfirePolicy", "-")}', - f'concurrent: {job.get("concurrent", "-")}', + f'misfire_grace_time: {job.get("misfireGraceTime", "-")}', + f'coalesce: {job.get("coalesce", "-")}', + f'max_instances: {job.get("maxInstances", "-")}', f'remark: {SHELL_TEXT_FORMATTER.truncate_text(job.get("remark", ""), 160) or "-"}', f'create_by: {job.get("createBy", "-")}', f'create_time: {job.get("createTime", "-")}', diff --git a/ruoyi-fastapi-backend/cli/runtime/config/support.py b/ruoyi-fastapi-backend/cli/runtime/config/support.py index 268b1fd58..d9ffae7c1 100644 --- a/ruoyi-fastapi-backend/cli/runtime/config/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/config/support.py @@ -1,8 +1,8 @@ -from datetime import datetime from typing import Any, Literal from cli.exit_codes import RUNTIME_ERROR from cli.runtime.base import RUNTIME_OPERATOR, RuntimeOperatorService +from utils.time_util import TimezoneUtil from .gateway import ConfigInfrastructureGateway @@ -133,7 +133,7 @@ def build_target_config_model( :return: 目标配置模型 """ operator = self.operator_service.resolve_operator() - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() common_constant = self.infrastructure_gateway.get_common_constant() config_vo_module = self.infrastructure_gateway.get_config_vo_module() default_config_type = existing_config.config_type if existing_config else common_constant.NO diff --git a/ruoyi-fastapi-backend/cli/runtime/crypto/service.py b/ruoyi-fastapi-backend/cli/runtime/crypto/service.py index 1e6ed6987..0760920b3 100644 --- a/ruoyi-fastapi-backend/cli/runtime/crypto/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/crypto/service.py @@ -1,6 +1,7 @@ -from datetime import datetime from typing import Any +from utils.time_util import TimezoneUtil + from .gateway import CryptoInfrastructureGateway from .support import CryptoDomainSupport, CryptoResultSupport @@ -119,7 +120,7 @@ def _operation() -> dict[str, Any]: 'currentKid': current_key_pair.kid, 'nextKid': next_kid, 'keySize': key_size, - 'generatedAt': datetime.now().isoformat(), + 'generatedAt': TimezoneUtil.format_rfc3339(TimezoneUtil.utc_now()), 'nextKeyPair': { 'kid': next_kid, 'privateKey': private_key_pem, diff --git a/ruoyi-fastapi-backend/cli/runtime/job/gateway.py b/ruoyi-fastapi-backend/cli/runtime/job/gateway.py index 6c538a1c8..da7e9e9f7 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/gateway.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/gateway.py @@ -38,13 +38,13 @@ def get_redis_util() -> Any: return import_module('config.get_redis').RedisUtil @staticmethod - def get_scheduler_util() -> Any: + def get_scheduler_manager() -> Any: """ - 获取调度器工具类。 + 获取调度器管理类。 - :return: 调度器工具类 + :return: 调度器管理类 """ - return import_module('config.get_scheduler').SchedulerUtil + return import_module('config.scheduler.manager').SchedulerManager @staticmethod def get_job_vo_module() -> Any: diff --git a/ruoyi-fastapi-backend/cli/runtime/job/service.py b/ruoyi-fastapi-backend/cli/runtime/job/service.py index 9cf8e625a..6f6c38e4b 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/service.py @@ -183,13 +183,13 @@ async def run_with_scheduler_context( redis = None async_session_local = self.infrastructure_gateway.get_async_session_local() redis_util = self.infrastructure_gateway.get_redis_util() - scheduler_util = self.infrastructure_gateway.get_scheduler_util() + scheduler_manager = self.infrastructure_gateway.get_scheduler_manager() job_vo_module = self.infrastructure_gateway.get_job_vo_module() job_service = self.infrastructure_gateway.get_job_service() try: async with async_session_local() as session: redis = await redis_util.create_redis_pool(log_enabled=False) - await scheduler_util.init_system_scheduler(redis) + await scheduler_manager.init_system_scheduler(redis) if operation == 'run-once': result = await job_service.execute_job_once_services(session, job_vo_module.JobModel(jobId=job_id)) diff --git a/ruoyi-fastapi-backend/cli/runtime/job/support.py b/ruoyi-fastapi-backend/cli/runtime/job/support.py index 19578b2a4..241a493fb 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/support.py @@ -158,9 +158,9 @@ async def close_scheduler_context(self, redis: Any) -> None: :param redis: Redis 客户端实例 :return: None """ - scheduler_util = self.infrastructure_gateway.get_scheduler_util() + scheduler_manager = self.infrastructure_gateway.get_scheduler_manager() try: - await scheduler_util.close_system_scheduler() + await scheduler_manager.close_system_scheduler() except Exception: pass diff --git a/ruoyi-fastapi-backend/cli/runtime/ops/gateway.py b/ruoyi-fastapi-backend/cli/runtime/ops/gateway.py index 261c20202..40f854911 100644 --- a/ruoyi-fastapi-backend/cli/runtime/ops/gateway.py +++ b/ruoyi-fastapi-backend/cli/runtime/ops/gateway.py @@ -29,13 +29,13 @@ def get_redis_error_class() -> type[Exception]: return import_module('redis.exceptions').RedisError @staticmethod - def get_scheduler_util() -> Any: + def get_scheduler_manager() -> Any: """ - 获取调度器工具类。 + 获取调度器管理类。 - :return: 调度器工具类 + :return: 调度器管理类 """ - return import_module('config.get_scheduler').SchedulerUtil + return import_module('config.scheduler.manager').SchedulerManager @staticmethod def get_server_service() -> Any: diff --git a/ruoyi-fastapi-backend/cli/runtime/ops/service.py b/ruoyi-fastapi-backend/cli/runtime/ops/service.py index cfff10cff..d7b0cab6e 100644 --- a/ruoyi-fastapi-backend/cli/runtime/ops/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/ops/service.py @@ -74,20 +74,20 @@ async def sync_jobs(self) -> dict[str, Any]: :return: 任务同步执行结果 """ redis = None - scheduler_util = None + scheduler_manager = None try: redis_util = self.infrastructure_gateway.get_redis_util() - scheduler_util = self.infrastructure_gateway.get_scheduler_util() + scheduler_manager = self.infrastructure_gateway.get_scheduler_manager() redis = await redis_util.create_redis_pool(log_enabled=False) - await scheduler_util.init_system_scheduler(redis) - await scheduler_util.request_scheduler_sync() + await scheduler_manager.init_system_scheduler(redis) + await scheduler_manager.request_scheduler_sync() return { 'ok': True, 'operation': 'sync', 'operationLabel': '同步调度配置', 'schedulerSyncRequested': True, 'message': '调度配置同步请求已发送', - 'isLeader': scheduler_util._is_leader, + 'isLeader': scheduler_manager._is_leader, } except Exception as exc: return { @@ -100,9 +100,9 @@ async def sync_jobs(self) -> dict[str, Any]: 'exit_code': SCHEDULER_ERROR, } finally: - if scheduler_util is not None: + if scheduler_manager is not None: try: - await scheduler_util.close_system_scheduler() + await scheduler_manager.close_system_scheduler() except Exception: pass if redis is not None: diff --git a/ruoyi-fastapi-backend/cli/runtime/ops/support.py b/ruoyi-fastapi-backend/cli/runtime/ops/support.py index f891c14da..8ba9a17a0 100644 --- a/ruoyi-fastapi-backend/cli/runtime/ops/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/ops/support.py @@ -3,10 +3,12 @@ import socket import time from dataclasses import dataclass +from datetime import datetime, timezone from importlib import metadata from typing import Any from cli.exit_codes import RUNTIME_ERROR +from utils.time_util import TimezoneUtil from .gateway import OperationsInfrastructureGateway @@ -229,7 +231,9 @@ def build_server_info_fallback(self) -> dict[str, Any]: 'py': { 'name': current_process.name(), 'version': platform.python_version(), - 'startTime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time_stamp)), + 'startTime': TimezoneUtil.to_business_time( + datetime.fromtimestamp(start_time_stamp, tz=timezone.utc) + ).strftime('%Y-%m-%d %H:%M:%S'), 'runTime': f'{days}天{hours}小时{minutes}分钟', 'home': current_process.exe(), 'total': bytes2human(memory_info.available), diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py index 8c587e4a8..9984e44f6 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py @@ -119,11 +119,6 @@ def build_controller(plugin_id: str) -> str: @router.get('/ping') async def ping() -> dict[str, str]: - \"\"\" - 插件探活接口。 - - :return: 插件探活结果 - \"\"\" return {service_class_name}Service.ping() """ @@ -145,56 +140,26 @@ def build_crud_controller(plugin_id: str) -> str: @router.get('/ping') async def ping() -> dict[str, str]: - \"\"\" - 插件探活接口。 - - :return: 插件探活结果 - \"\"\" return {service_class_name}Service.ping() @router.get('/items') async def list_items(keyword: str = '') -> dict[str, object]: - \"\"\" - 查询示例数据列表。 - - :param keyword: 名称关键字 - :return: 示例数据分页结果 - \"\"\" return {service_class_name}Service.list_items(keyword) @router.post('/items') async def create_item(payload: dict[str, object]) -> dict[str, object]: - \"\"\" - 创建示例数据。 - - :param payload: 示例数据负载 - :return: 创建后的示例数据 - \"\"\" return {service_class_name}Service.create_item(payload) @router.put('/items/{{item_id}}') async def update_item(item_id: int, payload: dict[str, object]) -> dict[str, object]: - \"\"\" - 更新示例数据。 - - :param item_id: 示例数据ID - :param payload: 示例数据负载 - :return: 更新后的示例数据 - \"\"\" return {service_class_name}Service.update_item(item_id, payload) @router.delete('/items/{{item_id}}') async def delete_item(item_id: int) -> dict[str, object]: - \"\"\" - 删除示例数据。 - - :param item_id: 示例数据ID - :return: 删除结果 - \"\"\" return {service_class_name}Service.delete_item(item_id) """ diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/support.py b/ruoyi-fastapi-backend/cli/runtime/plugin/support.py index fb3da05e3..67c1b8e0e 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/support.py @@ -2,7 +2,6 @@ import hashlib import re from dataclasses import dataclass, field -from datetime import datetime from pathlib import Path from typing import Any @@ -10,6 +9,7 @@ from cli.exit_codes import RUNTIME_ERROR, SUCCESS from plugins.core.utils import validate_plugin_id_value +from utils.time_util import TimezoneUtil DEPENDENCY_OPERATOR_PATTERN = re.compile(r'==|!=|>=|<=|=|>|<|\^|~') PYTHON_PACKAGE_SEPARATOR_PATTERN = re.compile(r'[-_.]+') @@ -283,7 +283,7 @@ def build(cls, manifest: Any, *, offline_dir: Path | str | None = None) -> Plugi :param offline_dir: 离线制品根目录 :return: 锁文件模板 """ - generated_at = datetime.now().astimezone().isoformat() + generated_at = TimezoneUtil.format_rfc3339(TimezoneUtil.utc_now()) dependencies = manifest.dependencies artifact_resolver = PluginDependencyOfflineArtifactResolver(offline_dir) warnings: list[str] = [] diff --git a/ruoyi-fastapi-backend/cli/tui/actions/execution.py b/ruoyi-fastapi-backend/cli/tui/actions/execution.py index 13498c86f..769dded58 100644 --- a/ruoyi-fastapi-backend/cli/tui/actions/execution.py +++ b/ruoyi-fastapi-backend/cli/tui/actions/execution.py @@ -1,6 +1,5 @@ import asyncio from dataclasses import dataclass, field -from datetime import datetime from typing import Any from cli.completion.installers import COMPLETION_INSTALLER, CompletionInstallerService @@ -17,6 +16,7 @@ from cli.tui.actions.models import TuiActionResult, TuiActionSpec from cli.tui.copy import TUI_COPY from cli.utils import SHELL_TEXT_FORMATTER +from utils.time_util import TimezoneUtil TUI_ACTION_TIMEOUT_SECONDS = 30.0 @@ -190,7 +190,7 @@ async def _install_completion(self) -> dict[str, Any]: async def _dispatch_crypto(self, action_id: str, parameters: dict[str, object]) -> dict[str, Any]: """执行传输加密领域动作。""" - timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + timestamp = TimezoneUtil.to_business_time(TimezoneUtil.utc_now()).strftime('%Y%m%d%H%M%S') key_size = int(parameters.get('key_size') or 2048) if action_id == 'crypto-keygen': return await asyncio.to_thread( diff --git a/ruoyi-fastapi-backend/cli/tui/app.py b/ruoyi-fastapi-backend/cli/tui/app.py index db687235a..5544a2f9c 100644 --- a/ruoyi-fastapi-backend/cli/tui/app.py +++ b/ruoyi-fastapi-backend/cli/tui/app.py @@ -2,7 +2,6 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable from dataclasses import dataclass -from datetime import datetime from time import monotonic from textual.app import App @@ -20,6 +19,7 @@ from cli.tui.queries import TUI_PAGE_TIMEOUT_SECONDS from cli.tui.screens import BrowserScreen, DashboardScreen, DetailScreen from cli.tui.widgets import NavigationItem, WorkspaceSidebar +from utils.time_util import TimezoneUtil PageSnapshot = BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot SnapshotCollector = Callable[['RuoyiTuiApp'], Awaitable[PageSnapshot]] @@ -539,7 +539,7 @@ def build_refresh_timestamp() -> str: :return: 格式化后的刷新时间 """ - return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + return TimezoneUtil.to_business_time(TimezoneUtil.utc_now()).strftime('%Y-%m-%d %H:%M:%S') def build( self, diff --git a/ruoyi-fastapi-backend/cli/tui/widgets/workspace.py b/ruoyi-fastapi-backend/cli/tui/widgets/workspace.py index ac2862ee0..44bc96753 100644 --- a/ruoyi-fastapi-backend/cli/tui/widgets/workspace.py +++ b/ruoyi-fastapi-backend/cli/tui/widgets/workspace.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from datetime import datetime from math import sin from time import monotonic @@ -12,6 +11,7 @@ from cli.tui.keymaps import TUI_KEYMAP_REGISTRY from cli.tui.platform import TUI_PLATFORM_POLICY from cli.tui.search import TUI_SEARCH_HIGHLIGHTER +from utils.time_util import TimezoneUtil class WorkspaceRenderingSupport: @@ -521,7 +521,7 @@ def render(self) -> Text: :return: 渲染富文本 """ - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + timestamp = TimezoneUtil.to_business_time(TimezoneUtil.utc_now()).strftime('%Y-%m-%d %H:%M:%S') lines = TUI_COPY.build_workspace_header_lines( env=self.env, view_label=TUI_COPY.render_view_label(self.active_view), diff --git a/ruoyi-fastapi-backend/common/annotation/cache_annotation.py b/ruoyi-fastapi-backend/common/annotation/cache_annotation.py index aabeefa76..541b7ffe5 100644 --- a/ruoyi-fastapi-backend/common/annotation/cache_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/cache_annotation.py @@ -1,7 +1,6 @@ import hashlib import json from collections.abc import Awaitable, Callable, Sequence -from datetime import datetime from functools import wraps from typing import Any, TypeVar @@ -18,6 +17,7 @@ from utils.api_annotation_util import ApiAnnotationUtil from utils.api_response_header_util import ApiResponseHeaderUtil from utils.log_util import logger +from utils.time_util import TimezoneUtil P = ParamSpec('P') R = TypeVar('R') @@ -283,6 +283,7 @@ async def _build_cache_key(self, request: Request) -> str: 'query_params': sorted(request.query_params.multi_items()), 'body_digest': hashlib.sha256(request_body).hexdigest() if request_body else '', 'user_scope': user_scope, + 'timezone': TimezoneUtil.get_request_timezone(), } key_digest = hashlib.sha256( json.dumps( @@ -422,7 +423,7 @@ def _refresh_response_time(self, response_content: Any) -> Any: if {'code', 'msg', 'success', 'time'}.issubset(response_content): refreshed_content = response_content.copy() - refreshed_content['time'] = datetime.now() + refreshed_content['time'] = TimezoneUtil.utc_now() return refreshed_content return response_content diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py index da6190006..6353d0c57 100644 --- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py @@ -31,6 +31,7 @@ from utils.dependency_util import DependencyUtil from utils.log_util import LogSanitizer, logger from utils.response_util import ResponseUtil +from utils.time_util import TimezoneUtil P = ParamSpec('P') R = TypeVar('R') @@ -189,7 +190,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: oper_param = self._limit_log_text(oper_param, self._oper_param_len, '请求参数过长') # 获取操作时间 - oper_time = datetime.now() + oper_time = TimezoneUtil.utc_now() # 此处在登录之前向原始函数传递一些登录信息,用于监测在线用户的相关信息 login_log = self._get_login_log(user_agent, oper_ip, oper_location, oper_time, kwargs) try: @@ -936,7 +937,7 @@ def _get_login_log( 'loginLocation': oper_location, 'browser': browser, 'os': system_os, - 'loginTime': oper_time.strftime('%Y-%m-%d %H:%M:%S'), + 'loginTime': TimezoneUtil.format_rfc3339(oper_time), } self._set_login_data(login_log, origin_kwargs) diff --git a/ruoyi-fastapi-backend/common/context.py b/ruoyi-fastapi-backend/common/context.py index 67e933c6e..6deb5ccb5 100644 --- a/ruoyi-fastapi-backend/common/context.py +++ b/ruoyi-fastapi-backend/common/context.py @@ -12,6 +12,7 @@ ] = ContextVar('current_exclude_patterns', default=None) # 存储当前用户信息 current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None) +current_timezone: ContextVar[str | None] = ContextVar('current_timezone', default=None) class RequestContext: @@ -87,6 +88,38 @@ def reset_current_user(token: Token) -> None: """ current_user.reset(token) + @staticmethod + def set_current_timezone(timezone_name: str | None) -> Token: + """ + 保存中间件已校验的请求时区 + + :param timezone_name: IANA时区名称,未上报时为None + :return: 用于恢复上下文的令牌 + """ + return current_timezone.set(timezone_name) + + @staticmethod + def reset_current_timezone(token: Token) -> None: + """ + 恢复请求进入前的时区,避免并发请求之间串用 + + :param token: 设置请求时区时返回的令牌 + """ + current_timezone.reset(token) + + @staticmethod + def get_current_timezone() -> str | None: + """ + 优先使用页面实际展示的时区,未上报时回退到账号偏好 + + :return: 当前请求的IANA时区名称,未指定时为None + """ + if current_timezone.get(): + return current_timezone.get() + user = current_user.get() + preference = user.user.time_zone if user and user.user else 'auto' + return preference if preference != 'auto' else None + @staticmethod def clear_all() -> None: """ @@ -94,3 +127,4 @@ def clear_all() -> None: """ current_exclude_patterns.set(None) current_user.set(None) + current_timezone.set(None) diff --git a/ruoyi-fastapi-backend/common/mixin.py b/ruoyi-fastapi-backend/common/mixin.py index 1a3120e24..125bc18d8 100644 --- a/ruoyi-fastapi-backend/common/mixin.py +++ b/ruoyi-fastapi-backend/common/mixin.py @@ -1,8 +1,13 @@ -from datetime import datetime +from datetime import date, datetime +from typing import Any, ClassVar -from sqlalchemy import Column, DateTime +from pydantic import field_validator, model_validator +from sqlalchemy import Column from sqlalchemy.orm import Mapped, declared_attr +from common.types import DbUtcDateTime +from utils.time_util import TimezoneUtil + class CreateTimeMixin: """ @@ -15,9 +20,9 @@ class CreateTimeMixin: @declared_attr def create_time(cls) -> Mapped[datetime]: # noqa: N805 return Column( - DateTime, + DbUtcDateTime(), nullable=cls.__create_time_nullable__, - default=datetime.now, + default=TimezoneUtil.utc_now, comment=cls.__create_time_comment__, ) @@ -34,10 +39,10 @@ class UpdateTimeMixin: @declared_attr def update_time(cls) -> Mapped[datetime]: # noqa: N805 return Column( - DateTime, + DbUtcDateTime(), nullable=cls.__update_time_nullable__, - default=datetime.now if cls.__update_time_insert_default__ else None, - onupdate=datetime.now, + default=TimezoneUtil.utc_now if cls.__update_time_insert_default__ else None, + onupdate=TimezoneUtil.utc_now, comment=cls.__update_time_comment__, ) @@ -46,3 +51,90 @@ class AuditTimeMixin(CreateTimeMixin, UpdateTimeMixin): """ 创建时间和更新时间字段Mixin """ + + +class DateRangeQueryMixin: + """ + 业务日期范围查询校验混入类 + """ + + @field_validator('begin_time', 'end_time', mode='before', check_fields=False) + @classmethod + def validate_date_boundary(cls, value: Any) -> str | None: + """ + 校验日期查询边界并保留纯日期字符串协议 + + :param value: 待校验的输入值 + :return: YYYY-MM-DD字符串,空值返回None + """ + if value is None or value == '': + return None + return TimezoneUtil.parse_business_date(value).isoformat() + + @model_validator(mode='after') + def validate_date_range(self) -> 'DateRangeQueryMixin': + """ + 校验业务日期范围顺序和结束日期上限 + + :return: 校验通过的查询模型 + """ + begin, end = self.begin_time, self.end_time + if begin and end and end < begin: + raise ValueError('结束日期不能早于开始日期') + if end == date.max.isoformat(): + raise ValueError('结束日期必须早于9999-12-31') + return self + + +class InstantRangeQueryMixin: + """ + RFC 3339时刻范围查询校验混入类 + """ + + @field_validator('begin_time', 'end_time', mode='before', check_fields=False) + @classmethod + def validate_instant_boundary(cls, value: Any) -> str | None: + """ + 校验精确时刻查询边界并归一化为UTC + + :param value: 待校验的输入值 + :return: 携带UTC偏移的毫秒精度字符串,空值返回None + """ + if value is None or value == '': + return None + return TimezoneUtil.parse_rfc3339(value).isoformat(timespec='milliseconds') + + @model_validator(mode='after') + def validate_instant_range(self) -> 'InstantRangeQueryMixin': + """ + 按真实时刻校验查询范围顺序 + + :return: 校验通过的查询模型 + """ + begin, end = self.begin_time, self.end_time + if begin and end and TimezoneUtil.parse_rfc3339(end) < TimezoneUtil.parse_rfc3339(begin): + raise ValueError('结束时间不能早于开始时间') + return self + + +class GeneratedTimeRangeQueryMixin: + """ + 代码生成器命名时间范围校验混入类 + """ + + time_range_fields: ClassVar[dict[str, str]] = {} + + @model_validator(mode='after') + def validate_generated_time_ranges(self) -> 'GeneratedTimeRangeQueryMixin': + """ + 校验生成模型中的命名时间范围 + + :return: 校验通过的查询模型 + """ + for name, kind in self.time_range_fields.items(): + begin, end = getattr(self, f'begin_{name}'), getattr(self, f'end_{name}') + if begin is not None and end is not None and end < begin: + raise ValueError(f'{name}: 结束值不能早于开始值') + if kind == 'instant' and end == date.max: + raise ValueError(f'{name}: 结束日期必须早于9999-12-31') + return self diff --git a/ruoyi-fastapi-backend/common/types.py b/ruoyi-fastapi-backend/common/types.py new file mode 100644 index 000000000..5d91ea0ca --- /dev/null +++ b/ruoyi-fastapi-backend/common/types.py @@ -0,0 +1,106 @@ +import re +from datetime import date, datetime, time, timezone +from typing import Annotated, Any + +from pydantic import AfterValidator, AwareDatetime, BeforeValidator, PlainSerializer +from sqlalchemy import DateTime +from sqlalchemy.dialects.mysql import DATETIME +from sqlalchemy.dialects.postgresql import TIMESTAMP +from sqlalchemy.engine import Dialect +from sqlalchemy.types import TypeDecorator, TypeEngine + +from utils.time_util import TimezoneUtil + + +def validate_aware_datetime_input(value: Any) -> datetime: + """ + 校验API输入的真实时刻 + + :param value: 待校验的输入值 + :return: 携带UTC时区的毫秒精度时刻 + """ + return TimezoneUtil.parse_rfc3339(value) + + +ApiUtcDateTime = Annotated[ + AwareDatetime, + BeforeValidator(validate_aware_datetime_input), + AfterValidator(TimezoneUtil.to_utc_milliseconds), + PlainSerializer(TimezoneUtil.format_rfc3339, return_type=str, when_used='json'), +] + + +def validate_business_time_input(value: Any) -> time: + """ + 校验不含日期和时区的一天内时间 + + 仅接受time对象或HH:mm:ss格式字符串,可含小数秒,不接受时间戳或持续时长。 + + :param value: 待校验的输入值 + :return: 一天内的时间对象 + """ + if isinstance(value, str) and re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,6})?', value): + value = time.fromisoformat(value) + if not isinstance(value, time) or value.tzinfo is not None: + raise ValueError('一天内时间必须为 HH:mm:ss,可含小数秒,不能带时区') + return value + + +BusinessDate = Annotated[date, BeforeValidator(TimezoneUtil.parse_business_date)] +BusinessTime = Annotated[time, BeforeValidator(validate_business_time_input)] + + +class DbUtcDateTime(TypeDecorator[datetime]): + """ + SQLAlchemy数据库列的UTC时刻类型。 + + PostgreSQL使用带时区时间戳;MySQL和SQLite在驱动边界保存naive UTC, + 应用层始终只接收和返回aware UTC datetime。 + """ + + impl = DateTime + cache_ok = True + + def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]: + """ + 获取当前数据库对应的毫秒精度时间类型 + + :param dialect: SQLAlchemy数据库方言 + :return: 数据库实际使用的时间类型 + """ + if dialect.name == 'postgresql': + return dialect.type_descriptor(TIMESTAMP(timezone=True, precision=3)) + if dialect.name == 'mysql': + return dialect.type_descriptor(DATETIME(fsp=3)) + return dialect.type_descriptor(DateTime()) + + def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None: + """ + 在写入边界统一UTC时区和毫秒精度 + + :param value: 待写入时刻 + :param dialect: SQLAlchemy数据库方言 + :return: 符合驱动要求的UTC时刻,空值返回None + """ + if value is None: + return None + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError('DbUtcDateTime只接受携带时区信息的datetime') + utc_value = TimezoneUtil.to_utc_milliseconds(value) + if dialect.name in {'mysql', 'sqlite'}: + return utc_value.replace(tzinfo=None) + return utc_value + + def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None: + """ + 将数据库读出的时间恢复为带时区UTC时刻 + + :param value: 驱动返回的时间值 + :param dialect: SQLAlchemy数据库方言 + :return: 携带UTC时区的毫秒精度时刻,空值返回None + """ + if value is None: + return None + if value.tzinfo is None or value.utcoffset() is None: + value = value.replace(tzinfo=timezone.utc) + return TimezoneUtil.to_utc_milliseconds(value) diff --git a/ruoyi-fastapi-backend/common/vo.py b/ruoyi-fastapi-backend/common/vo.py index 951facd1b..aff0eb5bf 100644 --- a/ruoyi-fastapi-backend/common/vo.py +++ b/ruoyi-fastapi-backend/common/vo.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Any, Generic, TypeVar from pydantic import BaseModel, ConfigDict, Field, create_model @@ -6,6 +5,8 @@ from typing_extensions import Self from common.constant import HttpStatusConstant +from common.types import ApiUtcDateTime +from utils.time_util import TimezoneUtil T = TypeVar('T') @@ -28,7 +29,7 @@ class ResponseBaseModel(BaseModel): code: int = Field(default=HttpStatusConstant.SUCCESS, description='响应码') msg: str = Field(default='操作成功', description='响应信息') success: bool = Field(default=True, description='响应是否成功') - time: datetime = Field(default_factory=datetime.now, description='响应时间') + time: ApiUtcDateTime = Field(default_factory=TimezoneUtil.utc_now, description='响应时间') class DynamicResponseModel(ResponseBaseModel, Generic[T]): diff --git a/ruoyi-fastapi-backend/config/database.py b/ruoyi-fastapi-backend/config/database.py index a9694e493..9778262d4 100644 --- a/ruoyi-fastapi-backend/config/database.py +++ b/ruoyi-fastapi-backend/config/database.py @@ -1,10 +1,11 @@ import asyncio +import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from functools import cache -from typing import Any +from typing import Any, TypeVar, cast from pydantic import SecretStr from sqlalchemy import URL, Engine, create_engine, text @@ -27,8 +28,54 @@ DataSourceUnavailableException, ) from utils.log_util import logger +from utils.time_util import TimezoneUtil _HEALTH_RETRY_COOLDOWN = timedelta(seconds=5) +_SESSION_TIMEZONE_QUERY = { + 'mysql': 'SELECT @@session.time_zone', + 'postgresql': 'SHOW TIME ZONE', +} +_UTC_SESSION_TIMEZONES = {'+00:00', 'UTC', 'Etc/UTC'} +_QUIET_SQL_LOG_TOKEN = 'ruoyi_internal_sql' +_DatabaseEngine = TypeVar('_DatabaseEngine', Engine, AsyncEngine) + + +class _QuietSqlLogFilter(logging.Filter): + """ + 按连接日志标记过滤内部SQL,保留警告和错误 + """ + + def filter(self, record: logging.LogRecord) -> bool: + """ + 判断是否保留当前SQLAlchemy日志 + + :param record: 待输出的日志记录 + :return: 是否保留日志 + """ + return ( + record.levelno >= logging.WARNING + or not isinstance(record.msg, str) + or not record.msg.startswith(f'[{_QUIET_SQL_LOG_TOKEN}] ') + ) + + +_QUIET_SQL_LOG_FILTER = _QuietSqlLogFilter() + + +def quiet_sql_engine(engine: _DatabaseEngine) -> _DatabaseEngine: + """ + 创建复用原连接池、只过滤自身常规SQL日志的引擎视图 + + :param engine: 注册中心管理的同步或异步引擎 + :return: 带有内部SQL日志标记的引擎视图,由原引擎统一管理连接池 + """ + sync_engine = engine.sync_engine if isinstance(engine, AsyncEngine) else engine + logger_name = 'sqlalchemy.engine.Engine' + if sync_engine.logging_name: + logger_name = f'{logger_name}.{sync_engine.logging_name}' + # Logger过滤同时覆盖echo自动创建的Handler和应用日志转发,不修改共享日志等级。 + logging.getLogger(logger_name).addFilter(_QUIET_SQL_LOG_FILTER) + return cast('_DatabaseEngine', engine.execution_options(logging_token=_QUIET_SQL_LOG_TOKEN)) def _error_details(exc: BaseException) -> tuple[str, int | None]: @@ -86,7 +133,7 @@ def build_url(self, config: DataSourceSettings, *, sync: bool) -> URL: database=config.db_database, ) - def build_connect_args(self, config: DataSourceSettings, *, sync: bool) -> dict[str, int]: + def build_connect_args(self, config: DataSourceSettings, *, sync: bool) -> dict[str, Any]: """ 构建数据库驱动连接参数 @@ -95,7 +142,15 @@ def build_connect_args(self, config: DataSourceSettings, *, sync: bool) -> dict[ :return: 数据库驱动连接参数 """ timeout_key = self.sync_connect_timeout_key if sync else self.async_connect_timeout_key - return {timeout_key: config.db_connect_timeout} + connect_args: dict[str, Any] = {timeout_key: config.db_connect_timeout} + if self.db_type == 'postgresql': + if sync: + connect_args['options'] = '-c timezone=UTC' + else: + connect_args['server_settings'] = {'timezone': 'UTC'} + elif self.db_type == 'mysql': + connect_args['init_command'] = "SET time_zone = '+00:00'" + return connect_args _DATABASE_DRIVER_ADAPTERS = { @@ -274,6 +329,7 @@ class DataSourceRuntime: available: bool = False last_health_check_at: datetime | None = None next_retry_at: datetime | None = None + session_timezone: str | None = None health_lock: asyncio.Lock = field(default_factory=asyncio.Lock) @@ -323,8 +379,9 @@ def _mark_unavailable(runtime: DataSourceRuntime) -> None: :param runtime: 数据源运行时状态 :return: None """ - now = datetime.now(timezone.utc) + now = TimezoneUtil.utc_now() runtime.available = False + runtime.session_timezone = None runtime.last_health_check_at = now runtime.next_retry_at = now + _HEALTH_RETRY_COOLDOWN @@ -388,9 +445,13 @@ async def initialize(self, log_enabled: bool = True) -> None: if error_type is not None: log_context['error_type'] = error_type log_context['error_code'] = error_code + elif healthy: + log_context['session_timezone'] = self._runtime(name).session_timezone source_logger = logger.bind(**log_context) if healthy: - source_logger.info(f'✅ 数据源 {name} 初始化成功') + source_logger.info( + f'✅ 数据源 {name} 初始化成功,sessionTimeZone={self._runtime(name).session_timezone}' + ) elif required: source_logger.error(f'❌ 必需数据源 {name} 连接检查失败{_error_log_suffix(error_type, error_code)}') else: @@ -460,11 +521,16 @@ async def _check_health_locked(self, runtime: DataSourceRuntime) -> None: assert runtime.async_engine is not None async with runtime.async_engine.begin() as connection: await connection.execute(text('SELECT 1')) + timezone_result = await connection.execute(text(_SESSION_TIMEZONE_QUERY[runtime.config.db_type])) + session_timezone = str(timezone_result.scalar_one()) + if session_timezone not in _UTC_SESSION_TIMEZONES: + raise RuntimeError(f'数据库session timezone必须为UTC,实际为{session_timezone}') + runtime.session_timezone = session_timezone except Exception as exc: self._mark_unavailable(runtime) raise self._data_source_error(DataSourceUnavailableException, runtime, exc) from None runtime.available = True - runtime.last_health_check_at = datetime.now(timezone.utc) + runtime.last_health_check_at = TimezoneUtil.utc_now() runtime.next_retry_at = None async def _ensure_available(self, runtime: DataSourceRuntime) -> None: @@ -477,12 +543,14 @@ async def _ensure_available(self, runtime: DataSourceRuntime) -> None: async with runtime.health_lock: if runtime.available: return - now = datetime.now(timezone.utc) + now = TimezoneUtil.utc_now() if runtime.next_retry_at is not None and now < runtime.next_retry_at: raise DataSourceUnavailableException(runtime.name) await self._check_health_locked(runtime) if self._log_enabled: - logger.bind(data_source=runtime.name).info(f'✅ 数据源 {runtime.name} 连接已恢复') + logger.bind(data_source=runtime.name, session_timezone=runtime.session_timezone).info( + f'✅ 数据源 {runtime.name} 连接已恢复,sessionTimeZone={runtime.session_timezone}' + ) @asynccontextmanager async def connection(self, name: str | None = None) -> AsyncGenerator[AsyncConnection, None]: @@ -505,19 +573,24 @@ async def connection(self, name: str | None = None) -> AsyncGenerator[AsyncConne raise self._data_source_error(DataSourceUnavailableException, runtime, exc) from None @asynccontextmanager - async def session(self, name: str | None = None) -> AsyncGenerator[AsyncSession, None]: + async def session(self, name: str | None = None, *, log_sql: bool = True) -> AsyncGenerator[AsyncSession, None]: """ 创建指定数据源的异步数据库会话 :param name: 数据源名称 + :param log_sql: 是否保留数据源原有的SQL日志设置,False时只过滤此会话的常规SQL日志 :return: 异步数据库会话 """ runtime = self._runtime(name) await self._ensure_available(runtime) factory = runtime.async_session_factory assert factory is not None + session_options: dict[str, Any] = {} + if not log_sql: + assert runtime.async_engine is not None + session_options['bind'] = quiet_sql_engine(runtime.async_engine) try: - async with factory() as current_db: + async with factory(**session_options) as current_db: yield current_db except DBAPIError as exc: if not exc.connection_invalidated: diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index 9f8d4fa6d..48a28f4b2 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -12,6 +12,7 @@ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from exceptions.exception import DataSourceNotFoundException +from utils.time_util import TimezoneUtil class AppSettings(BaseSettings): @@ -36,6 +37,21 @@ class AppSettings(BaseSettings): app_trusted_proxy_ips: str = '127.0.0.1,::1' app_trusted_proxy_hops: int = 1 app_default_enabled_plugins: str = 'ai' + app_timezone: str = 'Asia/Shanghai' + + @field_validator('app_timezone') + @classmethod + def validate_app_timezone(cls, value: str) -> str: + """ + 校验应用业务时区是否为有效的IANA时区名称。 + + :param value: IANA时区名称 + :return: 去除首尾空格后的IANA时区名称 + """ + try: + return TimezoneUtil.validate_timezone_name(value) + except ValueError as exc: + raise ValueError(f'APP_TIMEZONE: {exc}') from None class JwtSettings(BaseSettings): diff --git a/ruoyi-fastapi-backend/config/get_scheduler.py b/ruoyi-fastapi-backend/config/get_scheduler.py deleted file mode 100644 index 5190ee56d..000000000 --- a/ruoyi-fastapi-backend/config/get_scheduler.py +++ /dev/null @@ -1,1128 +0,0 @@ -import asyncio -import importlib -import json -import random -from asyncio import iscoroutinefunction -from collections.abc import Callable -from datetime import datetime, timedelta -from typing import Any - -from apscheduler.events import EVENT_ALL, SchedulerEvent -from apscheduler.executors.asyncio import AsyncIOExecutor -from apscheduler.executors.pool import ProcessPoolExecutor -from apscheduler.job import Job -from apscheduler.jobstores.memory import MemoryJobStore -from apscheduler.jobstores.redis import RedisJobStore -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.combining import OrTrigger -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger -from apscheduler.util import obj_to_ref -from redis import asyncio as aioredis -from sqlalchemy.engine import Engine -from sqlalchemy.orm import sessionmaker - -import module_task # noqa: F401 -from common.constant import LockConstant -from config.database import DataSourceRegistry, create_sync_db_engine -from config.env import AppConfig, DataBaseConfig, LogConfig, RedisConfig -from module_admin.dao.job_dao import JobDao -from module_admin.entity.vo.job_vo import JobLogModel, JobModel -from module_admin.service.job_log_service import JobLogService -from utils.log_util import logger -from utils.server_util import StartupUtil, WorkerIdUtil - - -# 重写Cron定时 -class MyCronTrigger(CronTrigger): - CRON_EXPRESSION_LENGTH_MIN = 6 - CRON_EXPRESSION_LENGTH_MAX = 7 - WEEKDAY_COUNT = 5 - - @classmethod - def from_crontab(cls, expr: str, timezone: str | None = None) -> 'MyCronTrigger': - values = expr.split() - if len(values) != cls.CRON_EXPRESSION_LENGTH_MIN and len(values) != cls.CRON_EXPRESSION_LENGTH_MAX: - raise ValueError(f'Wrong number of fields; got {len(values)}, expected 6 or 7') - - second = values[0] - minute = values[1] - hour = values[2] - if '?' in values[3]: - day = None - elif 'L' in values[5]: - day = f'last {values[5].replace("L", "")}' - elif 'W' in values[3]: - day = cls.__find_recent_workday(int(values[3].split('W')[0])) - else: - day = values[3].replace('L', 'last') - month = values[4] - if '?' in values[5] or 'L' in values[5]: - week = None - elif '#' in values[5]: - week = int(values[5].split('#')[1]) - else: - week = values[5] - day_of_week = int(values[5].split('#')[0]) - 1 if '#' in values[5] else None - year = values[6] if len(values) == cls.CRON_EXPRESSION_LENGTH_MAX else None - return cls( - second=second, - minute=minute, - hour=hour, - day=day, - month=month, - week=week, - day_of_week=day_of_week, - year=year, - timezone=timezone, - ) - - @classmethod - def __find_recent_workday(cls, day: int) -> int: - now = datetime.now() - date = datetime(now.year, now.month, day) - if date.weekday() < cls.WEEKDAY_COUNT: - return date.day - diff = 1 - while True: - previous_day = date - timedelta(days=diff) - if previous_day.weekday() < cls.WEEKDAY_COUNT: - return previous_day.day - diff += 1 - - -redis_config = { - 'host': RedisConfig.redis_host, - 'port': RedisConfig.redis_port, - 'username': RedisConfig.redis_username, - 'password': RedisConfig.redis_password, - 'db': RedisConfig.redis_database, -} -job_defaults = {'coalesce': False, 'max_instance': 1} -scheduler = AsyncIOScheduler() - - -class SchedulerUtil: - """ - 定时任务相关方法 - """ - - # 分布式锁相关类变量 - _is_leader: bool = False - _worker_id: str = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id) - _application_lock_owner_token: str = StartupUtil.get_application_lock_owner_token(_worker_id) - _application_lock_renewal_task: asyncio.Task | None = None - _redis: aioredis.Redis | None = None - _job_update_time_cache: dict[str, datetime | None] = {} - _sync_channel: str = 'scheduler:sync:request' - _sync_listener_task: asyncio.Task | None = None - _lock_lost_task: asyncio.Task | None = None - _sync_task: asyncio.Task | None = None - _sync_pending: bool = False - _sync_lock: asyncio.Lock = asyncio.Lock() - _last_sync_at: datetime | None = None - _sync_debounce_seconds: float = 0.5 - _sync_min_interval_seconds: float = 2.0 - _reacquire_task: asyncio.Task | None = None - _reacquire_interval_seconds: float = 5.0 - _reacquire_jitter_seconds: float = 1.0 - _is_closing: bool = False - _disposed_sync_engines: bool = False - - # 懒加载的同步 Engine 和 SessionLocal - _jobstore_engine: Engine | None = None - _listener_engine: Engine | None = None - _session_local: Any | None = None - _scheduler_configured: bool = False - - @staticmethod - def _parse_job_args(job_args: str | None) -> list[Any] | None: - """ - 解析任务位置参数。 - - :param job_args: 数据库中的任务位置参数 - :return: 位置参数列表 - """ - if not job_args: - return None - - try: - parsed_args = json.loads(job_args) - except json.JSONDecodeError: - return job_args.split(',') - - if isinstance(parsed_args, list): - return parsed_args - - return [parsed_args] - - @staticmethod - def _dump_job_args(args: tuple[Any, ...] | list[Any] | None) -> str: - """ - 序列化任务位置参数。 - - :param args: 调度器任务位置参数 - :return: 数据库存储字符串 - """ - return json.dumps(list(args), ensure_ascii=False) if args else '' - - @classmethod - def _get_jobstore_engine(cls) -> Engine: - """ - 懒加载获取 jobstore 使用的同步 Engine - - :return: 同步 Engine - """ - if cls._jobstore_engine is None: - # JobStore 使用独立 Engine,避免 APScheduler 关闭时释放 Registry 共享的 Engine。 - cls._jobstore_engine = create_sync_db_engine(echo=False, config=DataBaseConfig.get_source()) - return cls._jobstore_engine - - @classmethod - def _get_listener_engine(cls) -> Engine: - """ - 懒加载获取 listener 使用的同步 Engine - - :return: 同步 Engine - """ - if cls._listener_engine is None: - cls._listener_engine = DataSourceRegistry.get_sync_engine(DataBaseConfig.db_default_source) - return cls._listener_engine - - @classmethod - def _get_session_local(cls) -> Any: - """ - 懒加载获取同步 SessionLocal - - :return: SessionLocal - """ - if cls._session_local is None: - cls._session_local = sessionmaker( - autocommit=False, - autoflush=False, - bind=cls._get_listener_engine(), - ) - return cls._session_local - - @classmethod - def _configure_scheduler(cls) -> None: - """ - 配置 scheduler(懒加载 jobstore) - - :return: None - """ - if cls._scheduler_configured: - return - job_stores = { - 'default': MemoryJobStore(), - 'sqlalchemy': SQLAlchemyJobStore(engine=cls._get_jobstore_engine()), - 'redis': RedisJobStore(**redis_config), - } - executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)} - scheduler.configure(jobstores=job_stores, executors=executors, job_defaults=job_defaults) - cls._scheduler_configured = True - - @classmethod - def _should_enable_scheduler_sync(cls) -> bool: - """ - 判断是否需要启用多 worker 的任务状态同步机制 - - :return: 是否开启定时同步与监听 - """ - return not AppConfig.app_reload and AppConfig.app_workers > 1 - - @classmethod - async def init_system_scheduler(cls, redis: aioredis.Redis) -> None: - """ - 应用启动时初始化定时任务(使用分布式锁确保只有一个worker启动scheduler) - - :param redis: Redis连接对象 - :return: - """ - cls._redis = redis - cls._is_closing = False - logger.debug(f'🔎 Worker {cls._worker_id} 尝试获取 Application 锁...') - - acquired = await StartupUtil.acquire_application_leader( - redis=redis, - lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - owner_token=cls.get_application_lock_owner_token(), - lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, - ) - - if acquired: - await cls._activate_scheduler_as_leader(redis) - else: - cls._is_leader = False - logger.debug(f'⏸️ Worker {cls._worker_id} 未持有 Application 锁,跳过 Scheduler 启动') - cls._ensure_reacquire_task() - - @classmethod - def get_application_lock_owner_token(cls) -> str: - """ - 获取当前进程的Application leader租约owner token。 - - :return: Application锁owner token - """ - cls._application_lock_owner_token = StartupUtil.get_application_lock_owner_token(cls._worker_id) - return cls._application_lock_owner_token - - @classmethod - def is_application_leader(cls) -> bool: - """ - 判断当前进程是否仍以Application leader身份运行。 - - :return: 是否为Application leader - """ - return cls._is_leader - - @classmethod - def start_application_lock_renewal(cls, redis: aioredis.Redis) -> asyncio.Task: - """ - 启动或复用当前进程的Application leader租约续期任务。 - - :param redis: Redis连接对象 - :return: Application锁续期任务 - """ - # server可能在Scheduler正式初始化前就获得租约;提前保存Redis以便启动失败时释放。 - cls._redis = redis - renewal_task = cls._application_lock_renewal_task - if renewal_task and not renewal_task.done(): - return renewal_task - cls._application_lock_renewal_task = StartupUtil.start_application_leader_renewal( - redis=redis, - lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - owner_token=cls.get_application_lock_owner_token(), - lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, - interval_seconds=LockConstant.LOCK_RENEWAL_INTERVAL, - on_lock_lost=cls.on_lock_lost, - ) - return cls._application_lock_renewal_task - - @classmethod - async def stop_application_lock_renewal(cls) -> None: - """ - 停止当前进程的Application leader租约续期任务。 - - :return: None - """ - renewal_task = cls._application_lock_renewal_task - cls._application_lock_renewal_task = None - if not renewal_task or renewal_task.done(): - return - renewal_task.cancel() - try: - await renewal_task - except asyncio.CancelledError: - pass - - @classmethod - async def _start_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: - """ - 以 Leader 身份启动 Scheduler(内部方法,调用前需确保已持有锁) - - :param redis: Redis连接对象 - :return: None - """ - cls._is_leader = True - cls._disposed_sync_engines = False - logger.info(f'🎯 Worker {cls._worker_id} 持有 Application 锁,开始启动定时任务...') - # 懒加载配置 scheduler - cls._configure_scheduler() - scheduler.start() - - # 加载数据库中的定时任务 - async with cls._get_sync_async_session() as session: - job_list = await JobDao.get_job_list_for_scheduler(session) - for item in job_list: - cls._add_job_to_scheduler(item) - cls._refresh_job_update_cache(str(item.job_id), item.update_time) - - # 添加事件监听器 - scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL) - - if cls._should_enable_scheduler_sync(): - # 添加任务状态同步任务(每30秒从数据库同步一次任务状态) - scheduler.add_job( - func=cls.request_scheduler_sync, - trigger='interval', - seconds=30, - id='_scheduler_job_sync', - name='Scheduler任务同步', - replace_existing=True, - ) - cls._sync_listener_task = asyncio.create_task(cls._listen_sync_channel(redis)) - - logger.info('✅️ 系统初始定时任务加载成功') - - @classmethod - async def _activate_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: - """ - 启动租约续期并以Application leader身份激活Scheduler。 - - Scheduler启动失败时立即停止续期并原子释放租约,避免故障worker继续占用 - Application leader身份。 - - :param redis: Redis连接对象 - :return: None - """ - cls.start_application_lock_renewal(redis) - try: - await cls._start_scheduler_as_leader(redis) - except Exception: - cls._is_leader = False - await cls.stop_application_lock_renewal() - try: - await StartupUtil.release_application_leader( - redis, - LockConstant.APP_STARTUP_LOCK_KEY, - cls.get_application_lock_owner_token(), - ) - except Exception: - logger.exception('❌ Scheduler启动失败后释放Application leader租约失败') - raise - - @classmethod - def on_lock_lost(cls) -> None: - """ - 锁丢失处理入口 - - :return: None - """ - if not cls._is_leader: - return - cls._is_leader = False - logger.warning(f'⚠️ Worker {cls._worker_id} 失去 Application 锁') - if cls._lock_lost_task: - cls._lock_lost_task.cancel() - cls._lock_lost_task = asyncio.create_task(cls._handle_lock_lost()) - - @classmethod - async def _handle_lock_lost(cls) -> None: - """ - 处理锁丢失后的资源释放 - - :return: None - """ - if cls._sync_listener_task: - cls._sync_listener_task.cancel() - try: - await cls._sync_listener_task - except asyncio.CancelledError: - pass - cls._sync_listener_task = None - if cls._sync_task: - cls._sync_task.cancel() - try: - await cls._sync_task - except asyncio.CancelledError: - pass - cls._sync_task = None - cls._sync_pending = False - if getattr(scheduler, 'running', False): - scheduler.shutdown() - cls._scheduler_configured = False - cls._dispose_sync_engines() - cls._ensure_reacquire_task() - - @classmethod - async def _sync_jobs_from_database(cls) -> None: - """ - 从数据库同步任务状态,确保多worker环境下任务状态一致 - """ - if not cls._is_leader: - return - - try: - async with cls._get_sync_async_session() as session: - db_jobs_all = await JobDao.get_all_job_list_for_scheduler(session) - db_jobs_enabled = [job for job in db_jobs_all if job.status == '0'] - db_enabled_ids = {str(job.job_id) for job in db_jobs_enabled} - db_job_map = {str(job.job_id): job for job in db_jobs_enabled} - db_job_update_time_map = { - str(job.job_id): job.update_time for job in db_jobs_enabled if job.update_time is not None - } - scheduler_jobs = scheduler.get_jobs() - scheduler_job_map = {job.id: job for job in scheduler_jobs if not job.id.startswith('_')} - scheduler_job_ids = set(scheduler_job_map.keys()) - - jobs_to_remove = scheduler_job_ids - db_enabled_ids - for job_id in jobs_to_remove: - scheduler.remove_job(job_id=job_id) - logger.info(f'🗑️ 同步移除任务: {job_id}') - cls._invalidate_job_update_cache(job_id) - - jobs_to_add = db_enabled_ids - scheduler_job_ids - for job_id in jobs_to_add: - job_info = db_job_map.get(job_id) - if job_info: - cls._add_job_to_scheduler(job_info) - logger.info(f'➕ 同步添加任务: {job_info.job_name}') - cls._refresh_job_update_cache(job_id, job_info.update_time) - - jobs_to_update = db_enabled_ids & scheduler_job_ids - for job_id in jobs_to_update: - job_info = db_job_map.get(job_id) - scheduler_job = scheduler_job_map.get(job_id) - job_update_time = db_job_update_time_map.get(job_id) - cls._sync_update_job(job_id, job_info, scheduler_job, job_update_time) - - except Exception as e: - logger.error(f'❌ 任务同步异常: {e}') - - @classmethod - def _is_job_config_in_sync(cls, scheduler_job: Job, job_info: JobModel) -> bool: - """ - 判断任务配置是否一致 - - :param scheduler_job: 调度器任务对象 - :param job_info: 数据库任务对象 - :return: 是否一致 - """ - job_state = scheduler_job.__getstate__() - job_kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else {} - job_args = cls._parse_job_args(job_info.job_args) or [] - job_func = cls._import_function(job_info.invoke_target) - job_executor = job_info.job_executor - if iscoroutinefunction(job_func): - job_executor = 'default' - expected = { - 'name': job_info.job_name, - 'executor': job_executor, - 'jobstore': job_info.job_group, - 'misfire_grace_time': 1000000000000 if job_info.misfire_policy == '3' else None, - 'coalesce': job_info.misfire_policy == '2', - 'max_instances': 3 if job_info.concurrent == '0' else 1, - 'trigger': str(MyCronTrigger.from_crontab(job_info.cron_expression)), - 'args': tuple(job_args), - 'kwargs': job_kwargs, - 'func': obj_to_ref(job_func), - } - current = { - 'name': job_state.get('name'), - 'executor': job_state.get('executor'), - 'jobstore': scheduler_job._jobstore_alias, - 'misfire_grace_time': job_state.get('misfire_grace_time'), - 'coalesce': job_state.get('coalesce'), - 'max_instances': job_state.get('max_instances'), - 'trigger': str(job_state.get('trigger')), - 'args': tuple(job_state.get('args') or ()), - 'kwargs': dict(job_state.get('kwargs') or {}), - 'func': job_state.get('func'), - } - return expected == current - - @classmethod - def _sync_update_job( - cls, job_id: str, job_info: JobModel | None, scheduler_job: Job | None, job_update_time: datetime | None - ) -> None: - """ - 同步更新任务配置 - - :param job_id: 任务ID - :param job_info: 数据库任务对象 - :param scheduler_job: 调度器任务对象 - :param job_update_time: 任务更新时间 - :return: None - """ - if not job_info or not scheduler_job: - return - if cls._should_skip_job_update(job_id, job_update_time): - return - if not cls._is_job_config_in_sync(scheduler_job, job_info): - scheduler.remove_job(job_id=job_id) - cls._add_job_to_scheduler(job_info) - logger.info(f'♻️ 同步更新任务: {job_info.job_name}') - cls._refresh_job_update_cache(job_id, job_update_time) - - @classmethod - def _should_skip_job_update(cls, job_id: str, job_update_time: datetime | None) -> bool: - """ - 判断是否跳过同步更新 - - :param job_id: 任务ID - :param job_update_time: 任务更新时间 - :return: 是否跳过 - """ - return job_id in cls._job_update_time_cache and cls._job_update_time_cache[job_id] == job_update_time - - @classmethod - def _refresh_job_update_cache(cls, job_id: str, job_update_time: datetime | None) -> None: - """ - 刷新任务更新时间缓存 - - :param job_id: 任务ID - :param job_update_time: 任务更新时间 - :return: None - """ - cls._job_update_time_cache[job_id] = job_update_time - - @classmethod - def _invalidate_job_update_cache(cls, job_id: str) -> None: - """ - 移除任务更新时间缓存 - - :param job_id: 任务ID - :return: None - """ - cls._job_update_time_cache.pop(job_id, None) - - @classmethod - async def request_scheduler_sync(cls) -> None: - """ - 请求调度器同步任务状态 - - :return: None - """ - if cls._is_leader: - cls._sync_pending = True - cls._ensure_sync_task() - return - if cls._redis: - await cls._redis.publish(cls._sync_channel, cls._worker_id) - - @classmethod - def _ensure_sync_task(cls) -> None: - """ - 启动同步调度任务 - - :return: None - """ - if cls._sync_task and not cls._sync_task.done(): - return - cls._sync_task = asyncio.create_task(cls._run_sync_loop()) - - @classmethod - def _get_sync_async_session(cls) -> Any: - """ - 获取同步任务使用的异步 Session - - :return: 异步 Session - """ - # 每次同步都从注册中心创建新的异步上下文,避免复用已经退出的会话上下文。 - return DataSourceRegistry.session(DataBaseConfig.db_default_source) - - @classmethod - def _dispose_sync_engines(cls) -> None: - """ - 释放 Scheduler 使用的同步 Engine - - :return: None - """ - if cls._disposed_sync_engines: - return - if cls._jobstore_engine: - cls._jobstore_engine.dispose() - cls._jobstore_engine = None - # Listener 使用 Registry 共享的 Engine,此处只清理引用,避免影响其他服务。 - cls._listener_engine = None - cls._session_local = None - cls._disposed_sync_engines = True - - @classmethod - def _ensure_reacquire_task(cls) -> None: - """ - 启动锁重新竞争任务 - - :return: None - """ - if cls._is_closing or not cls._redis: - return - if cls._reacquire_task and not cls._reacquire_task.done(): - return - cls._reacquire_task = asyncio.create_task(cls._run_reacquire_loop()) - - @classmethod - def _get_reacquire_delay(cls) -> float: - """ - 获取带随机抖动的锁重新竞争间隔 - - :return: 重新竞争等待秒数 - """ - return cls._reacquire_interval_seconds + random.uniform(0, cls._reacquire_jitter_seconds) - - @classmethod - async def _run_reacquire_loop(cls) -> None: - """ - 循环尝试重新获取锁并恢复调度器 - - :return: None - """ - try: - while not cls._is_leader and not cls._is_closing: - await asyncio.sleep(cls._get_reacquire_delay()) - if cls._is_closing: - break - if not cls._redis: - continue - try: - acquired = await StartupUtil.acquire_application_leader( - redis=cls._redis, - lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - owner_token=cls.get_application_lock_owner_token(), - lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, - ) - except Exception as exc: - logger.error(f'❌ Application leader租约重新竞争失败:{exc}') - continue - if acquired: - try: - await cls._activate_scheduler_as_leader(cls._redis) - except Exception: - logger.exception('❌ 重新获得Application leader租约后恢复Scheduler失败') - continue - return - except asyncio.CancelledError: - raise - finally: - cls._reacquire_task = None - - @classmethod - async def _run_sync_loop(cls) -> None: - """ - 执行同步调度循环 - - :return: None - """ - try: - while True: - if not cls._sync_pending: - break - cls._sync_pending = False - await asyncio.sleep(cls._sync_debounce_seconds) - await cls._sync_with_throttle() - except asyncio.CancelledError: - raise - finally: - cls._sync_task = None - - @classmethod - async def _sync_with_throttle(cls) -> None: - """ - 按节流规则执行同步 - - :return: None - """ - async with cls._sync_lock: - if not cls._is_leader: - return - if cls._last_sync_at: - elapsed = datetime.now() - cls._last_sync_at - min_interval = timedelta(seconds=cls._sync_min_interval_seconds) - if elapsed < min_interval: - await asyncio.sleep((min_interval - elapsed).total_seconds()) - await cls._sync_jobs_from_database() - cls._last_sync_at = datetime.now() - - @classmethod - async def _listen_sync_channel(cls, redis: aioredis.Redis) -> None: - """ - 监听同步请求通道 - - :param redis: Redis连接对象 - :return: None - """ - while True: - pubsub = redis.pubsub() - try: - await pubsub.subscribe(cls._sync_channel) - async for message in pubsub.listen(): - if not cls._is_leader: - continue - if message.get('type') != 'message': - continue - await cls.request_scheduler_sync() - except asyncio.CancelledError: - await pubsub.unsubscribe(cls._sync_channel) - await pubsub.close() - raise - except Exception as e: - logger.error(f'❌ Scheduler 同步监听异常: {e},5秒后重试...') - await pubsub.close() - await asyncio.sleep(5) - finally: - try: - await pubsub.close() - except Exception: - pass - - @classmethod - async def _execute_async_job_with_log( - cls, job_func: Callable[..., Any], job_info: JobModel, args: list, kwargs: dict - ) -> None: - """ - 执行异步任务并记录日志 - - :param job_func: 任务函数 - :param job_info: 任务对象信息 - :param args: 位置参数 - :param kwargs: 关键字参数 - :return: None - """ - status = '0' - exception_info = '' - start_time = datetime.now() - job_executor = job_info.job_executor - if iscoroutinefunction(job_func): - job_executor = 'default' - try: - await job_func(*args, **kwargs) - except Exception as e: - status = '1' - exception_info = str(e) - logger.error(f'❌ 异步执行任务 {job_info.job_name} 失败: {e}') - finally: - cls._record_job_execution_log( - job_info, - job_executor, - status, - exception_info, - start_time, - datetime.now(), - ) - - @classmethod - def _record_job_execution_log( - cls, - job_info: JobModel, - job_executor: str, - status: str, - exception_info: str, - start_time: datetime, - end_time: datetime, - ) -> None: - """ - 记录任务执行日志(用于非 Leader Worker 直接执行任务时) - - :param job_info: 任务对象信息 - :param job_executor: 任务执行器 - :param status: 执行状态 0-成功 1-失败 - :param exception_info: 异常信息 - :param start_time: 执行开始时间 - :param end_time: 执行结束时间 - :return: None - """ - try: - job_args = job_info.job_args if job_info.job_args else '' - job_kwargs = job_info.job_kwargs if job_info.job_kwargs else '{}' - job_trigger = str(MyCronTrigger.from_crontab(job_info.cron_expression)) if job_info.cron_expression else '' - run_ms = int((end_time - start_time).total_seconds() * 1000) - job_message = ( - f'事件类型: DirectExecution(非Leader), 任务ID: {job_info.job_id}, ' - f'任务名称: {job_info.job_name}, 执行于{end_time.strftime("%Y-%m-%d %H:%M:%S")}, ' - f'总共耗时:{run_ms}毫秒' - ) - job_log = JobLogModel( - jobName=job_info.job_name, - jobGroup=job_info.job_group, - jobExecutor=job_executor, - invokeTarget=job_info.invoke_target, - jobArgs=job_args, - jobKwargs=job_kwargs, - jobTrigger=job_trigger, - jobMessage=job_message, - status=status, - exceptionInfo=exception_info, - startTime=start_time, - endTime=end_time, - createTime=end_time, - ) - session = cls._get_session_local()() - try: - JobLogService.add_job_log_services(session, job_log) - finally: - session.close() - except Exception as e: - logger.error(f'❌ 记录任务执行日志失败: {e}') - - @classmethod - def _prepare_scheduler_job_add(cls, job_info: JobModel) -> dict[str, Any]: - """ - 构建调度器任务参数 - - :param job_info: 任务对象信息 - :return: 调度器任务参数 - """ - job_func = cls._import_function(job_info.invoke_target) - job_executor = job_info.job_executor - if iscoroutinefunction(job_func): - job_executor = 'default' - return { - 'func': job_func, - 'trigger': MyCronTrigger.from_crontab(job_info.cron_expression), - 'args': cls._parse_job_args(job_info.job_args), - 'kwargs': json.loads(job_info.job_kwargs) if job_info.job_kwargs else None, - 'id': str(job_info.job_id), - 'name': job_info.job_name, - 'misfire_grace_time': 1000000000000 if job_info.misfire_policy == '3' else None, - 'coalesce': job_info.misfire_policy == '2', - 'max_instances': 3 if job_info.concurrent == '0' else 1, - 'jobstore': job_info.job_group, - 'executor': job_executor, - } - - @classmethod - def _add_job_to_scheduler(cls, job_info: JobModel) -> None: - """ - 内部方法:将任务添加到调度器(不检查应用锁状态,仅供内部使用) - - :param job_info: 任务对象信息 - """ - try: - # 先移除已存在的同ID任务 - existing_job = scheduler.get_job(job_id=str(job_info.job_id)) - if existing_job: - scheduler.remove_job(job_id=str(job_info.job_id)) - scheduler.add_job(**cls._prepare_scheduler_job_add(job_info)) - except Exception as e: - logger.error(f'❌ 添加任务 {job_info.job_name} 失败: {e}') - - @classmethod - async def close_system_scheduler(cls) -> None: - """ - 应用关闭时关闭定时任务 - - :return: - """ - cls._is_closing = True - await cls.stop_application_lock_renewal() - if cls._sync_listener_task: - cls._sync_listener_task.cancel() - try: - await cls._sync_listener_task - except asyncio.CancelledError: - pass - cls._sync_listener_task = None - if cls._sync_task: - cls._sync_task.cancel() - try: - await cls._sync_task - except asyncio.CancelledError: - pass - cls._sync_task = None - cls._sync_pending = False - if cls._reacquire_task: - cls._reacquire_task.cancel() - try: - await cls._reacquire_task - except asyncio.CancelledError: - pass - cls._reacquire_task = None - cls._dispose_sync_engines() - if cls._lock_lost_task: - cls._lock_lost_task.cancel() - try: - await cls._lock_lost_task - except asyncio.CancelledError: - pass - cls._lock_lost_task = None - if getattr(scheduler, 'running', False): - scheduler.shutdown() - logger.info('✅️ 关闭定时任务成功') - cls._scheduler_configured = False - # 必须在Redis连接池关闭前,原子释放当前进程持有的Application leader租约 - redis = cls._redis - cls._redis = None - try: - if redis: - released = await StartupUtil.release_application_leader( - redis, - LockConstant.APP_STARTUP_LOCK_KEY, - cls.get_application_lock_owner_token(), - ) - if released: - logger.info(f'🔓 Worker {cls._worker_id} 释放 Application 锁') - finally: - cls._is_leader = False - - @classmethod - def _import_function(cls, func_path: str) -> Callable[..., Any]: - """ - 动态导入函数 - - :param func_path: 函数字符串,如module_task.scheduler_test.job - :return: 导入的函数对象 - """ - module_path, func_name = func_path.rsplit('.', 1) - module = importlib.import_module(module_path) - return getattr(module, func_name) - - @classmethod - def get_scheduler_job(cls, job_id: str | int) -> Job: - """ - 根据任务id获取任务对象 - - :param job_id: 任务id - :return: 任务对象 - """ - query_job = scheduler.get_job(job_id=str(job_id)) - - return query_job - - @classmethod - def add_scheduler_job(cls, job_info: JobModel) -> None: - """ - 根据输入的任务对象信息添加任务 - - :param job_info: 任务对象信息 - :return: - """ - # 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会加载) - if not cls._is_leader: - return - scheduler.add_job(**cls._prepare_scheduler_job_add(job_info)) - cls._refresh_job_update_cache(str(job_info.job_id), job_info.update_time) - - @classmethod - def execute_scheduler_job_once(cls, job_info: JobModel) -> None: - """ - 根据输入的任务对象执行一次任务 - - :param job_info: 任务对象信息 - :return: - """ - job_func = cls._import_function(job_info.invoke_target) - job_executor = job_info.job_executor - if iscoroutinefunction(job_func): - job_executor = 'default' - - # 非应用锁 worker:直接执行函数(不通过 scheduler) - if not cls._is_leader: - logger.info(f'📍 当前 Worker 未持有 Application 锁,直接执行任务 {job_info.job_name}') - args = cls._parse_job_args(job_info.job_args) or [] - kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else {} - status = '0' - exception_info = '' - start_time = datetime.now() - try: - if iscoroutinefunction(job_func): - asyncio.create_task(cls._execute_async_job_with_log(job_func, job_info, args, kwargs)) # noqa: RUF006 - else: - job_func(*args, **kwargs) - except Exception as e: - status = '1' - exception_info = str(e) - logger.error(f'❌ 直接执行任务 {job_info.job_name} 失败: {e}') - finally: - # 同步任务记录日志(异步任务在 _execute_async_job_with_log 中记录) - if not iscoroutinefunction(job_func): - cls._record_job_execution_log( - job_info, - job_executor, - status, - exception_info, - start_time, - datetime.now(), - ) - return - - # 应用锁 worker:通过 scheduler 执行 - job_trigger = DateTrigger() - if job_info.status == '0': - job_trigger = OrTrigger(triggers=[DateTrigger(), MyCronTrigger.from_crontab(job_info.cron_expression)]) - scheduler.add_job( - func=job_func, - trigger=job_trigger, - args=cls._parse_job_args(job_info.job_args), - kwargs=json.loads(job_info.job_kwargs) if job_info.job_kwargs else None, - id=str(job_info.job_id), - name=job_info.job_name, - misfire_grace_time=1000000000000 if job_info.misfire_policy == '3' else None, - coalesce=job_info.misfire_policy == '2', - max_instances=3 if job_info.concurrent == '0' else 1, - jobstore=job_info.job_group, - executor=job_executor, - ) - - @classmethod - def remove_scheduler_job(cls, job_id: str | int) -> None: - """ - 根据任务id移除任务 - - :param job_id: 任务id - :return: - """ - # 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会根据状态加载) - if not cls._is_leader: - return - job_id = str(job_id) - query_job = cls.get_scheduler_job(job_id=job_id) - if query_job: - scheduler.remove_job(job_id=job_id) - cls._invalidate_job_update_cache(job_id) - - @classmethod - def scheduler_event_listener(cls, event: SchedulerEvent) -> None: - """ - 调度器事件监听器,记录任务执行日志 - """ - try: - # 获取事件类型和任务ID - event_type = event.__class__.__name__ - # 获取任务执行异常信息 - status = '0' - exception_info = '' - if event_type == 'JobExecutionEvent' and event.exception: - exception_info = str(event.exception) - status = '1' - if hasattr(event, 'job_id'): - job_id = event.job_id - # 跳过内部系统任务(以 _ 开头的任务ID),不记录日志 - if str(job_id).startswith('_'): - return - query_job = cls.get_scheduler_job(job_id=job_id) - if query_job: - query_job_info = query_job.__getstate__() - # 获取任务名称 - job_name = query_job_info.get('name') - # 获取任务组名 - job_group = query_job._jobstore_alias - # 获取任务执行器 - job_executor = query_job_info.get('executor') - # 获取调用目标字符串 - invoke_target = query_job_info.get('func') - # 获取调用函数位置参数(安全处理) - args = query_job_info.get('args') - job_args = cls._dump_job_args(args) - # 获取调用函数关键字参数 - kwargs = query_job_info.get('kwargs') - job_kwargs = json.dumps(kwargs) if kwargs else '{}' - # 获取任务触发器 - job_trigger = str(query_job_info.get('trigger')) - start_time = None - end_time = None - run_ms = None - if event_type == 'JobExecutionEvent': - scheduled_run_time = getattr(event, 'scheduled_run_time', None) - if scheduled_run_time: - start_time = scheduled_run_time.astimezone().replace(tzinfo=None) - end_time = datetime.now() - if start_time: - run_ms = int((end_time - start_time).total_seconds() * 1000) - # 构造日志消息 - job_message = f'事件类型: {event_type}, 任务ID: {job_id}, 任务名称: {job_name}, 执行于{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}' - if run_ms is not None: - job_message += f', 总共耗时:{run_ms}毫秒' - job_log = JobLogModel( - jobName=job_name, - jobGroup=job_group, - jobExecutor=job_executor, - invokeTarget=invoke_target, - jobArgs=job_args, - jobKwargs=job_kwargs, - jobTrigger=job_trigger, - jobMessage=job_message, - status=status, - exceptionInfo=exception_info, - startTime=start_time, - endTime=end_time, - createTime=datetime.now(), - ) - session = cls._get_session_local()() - try: - JobLogService.add_job_log_services(session, job_log) - finally: - session.close() - except Exception as e: - logger.error(f'❌ 调度任务事件监听器异常: {e}') diff --git a/ruoyi-fastapi-backend/config/scheduler/dispatcher.py b/ruoyi-fastapi-backend/config/scheduler/dispatcher.py new file mode 100644 index 000000000..5ab46bf74 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/dispatcher.py @@ -0,0 +1,104 @@ +import asyncio +import time +from collections.abc import Callable + +from sqlalchemy import update + +from config.scheduler.jobs import SchedulerJobs +from config.scheduler.resources import SchedulerResources +from config.scheduler.synchronization import SchedulerSynchronizer +from module_admin.dao.job_runtime_dao import JobRuntimeDao +from module_admin.entity.do.job_runtime_do import SysJobSync +from module_admin.entity.vo.job_vo import JobModel +from utils.log_util import logger +from utils.time_util import TimezoneUtil + + +class SchedulerDispatcher: + """ + 持久化执行请求领取、派发和下次调度时刻观测 + """ + + def __init__(self, jobs: SchedulerJobs, resources: SchedulerResources, synchronizer: SchedulerSynchronizer) -> None: + """ + 初始化请求派发所需的任务、数据库和配置同步对象 + + :param jobs: 本地任务管理器 + :param resources: 调度数据库资源 + :param synchronizer: 已应用配置管理器 + :return: None + """ + self.jobs = jobs + self.resources = resources + self.synchronizer = synchronizer + self.lock = asyncio.Lock() + self._observed_at = 0.0 + + async def refresh_observations(self, *, is_leader: Callable[[], bool]) -> None: + """ + 定期保存Leader观测的下次调度时刻,供其他worker读取 + + :param is_leader: 检查当前进程是否仍持有Leader租约 + :return: None + """ + now = time.monotonic() + interval_seconds = 5 + if not is_leader() or now - self._observed_at < interval_seconds: + return + observed_time = TimezoneUtil.utc_now() + observations = [] + for job_id, cached in self.synchronizer.applied_jobs.items(): + job = self.jobs.scheduler.get_job(str(job_id)) + observations.append((job_id, cached['appliedVersion'], getattr(job, 'next_run_time', None))) + async with self.resources.session() as session: + for job_id, version, next_run_time in observations: + await session.execute( + update(SysJobSync) + .where( + SysJobSync.job_id == job_id, + SysJobSync.config_version == version, + SysJobSync.applied_version == version, + SysJobSync.sync_status == 'applied', + ) + .values(next_run_time=next_run_time, schedule_observed_time=observed_time) + ) + await session.commit() + self._observed_at = now + + async def dispatch_pending(self, *, is_leader: Callable[[], bool]) -> None: + """ + 分批领取待执行请求,派发只在领取事务提交后进行 + + :param is_leader: 检查当前进程是否仍持有Leader租约 + :return: None + """ + if not is_leader() or self.lock.locked(): + return + async with self.lock: + await self.refresh_observations(is_leader=is_leader) + async with self.resources.session() as session: + await JobRuntimeDao.recover_expired(session) + await session.commit() + pending = await JobRuntimeDao.pending_ids(session) + for execution_id, job_id in pending: + if not is_leader(): + return + async with self.resources.session() as session: + execution = await JobRuntimeDao.claim_request(session, execution_id, job_id) + snapshot = execution.job_snapshot if execution is not None else None + token = execution.owner_token if execution is not None else None + await session.commit() + if execution is None: + continue + if not is_leader(): + # 尚未开始的领取租约过期后由新 Leader 重新派发。 + return + try: + self.jobs.execute_once( + JobModel.model_validate(snapshot), execution_id=execution_id, dispatch_token=token + ) + except Exception as exc: + logger.exception(f'❌ 注册手动执行请求失败:{execution_id}') + async with self.resources.session() as session: + await JobRuntimeDao.fail_dispatch(session, execution_id, token, str(exc)) + await session.commit() diff --git a/ruoyi-fastapi-backend/config/scheduler/events.py b/ruoyi-fastapi-backend/config/scheduler/events.py new file mode 100644 index 000000000..2eda2cd06 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/events.py @@ -0,0 +1,105 @@ +import json +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import NAMESPACE_URL, uuid4, uuid5 + +from apscheduler.events import JobExecutionEvent +from apscheduler.job import Job + +from config.scheduler.triggers import trigger_timezone +from utils.time_util import TimezoneUtil + + +@dataclass(frozen=True) +class JobSnapshot: + """ + 任务提交时的不可变配置快照 + """ + + name: str + jobstore: str + executor: str + invoke_target: str + args: str + kwargs: str + trigger: str + time_zone: str | None + job_group: str = 'default' + job_id: int | None = None + execution_id: str | None = None + dispatch_token: str | None = None + managed: bool = False + + @classmethod + def from_job(cls, job: Job, *, managed: bool = False) -> 'JobSnapshot': + """ + 从调度器任务提取执行日志所需的配置 + + :param job: 调度器任务对象 + :param managed: 是否启用持久化执行管理 + :return: 与后续任务变更无关的配置快照 + """ + return cls( + name=job.name, + jobstore=job._jobstore_alias, + executor=job.executor, + invoke_target=job.func_ref or str(job.func), + args=json.dumps(list(job.args), ensure_ascii=False, default=str) if job.args else '', + kwargs=json.dumps(job.kwargs, ensure_ascii=False, default=str), + trigger=str(job.trigger), + time_zone=trigger_timezone(job.trigger), + job_group=getattr(job.trigger, 'task_job_group', 'default'), + job_id=getattr(job.trigger, 'task_job_id', None) or (int(job.id) if job.id.isdecimal() else None), + execution_id=getattr(job.trigger, 'execution_id', None), + dispatch_token=getattr(job.trigger, 'dispatch_token', None), + managed=managed and (job.id.isdecimal() or job.id.startswith('_manual:')), + ) + + def execution_snapshot(self) -> dict[str, Any]: + """ + 生成跨进程执行记录需要的可序列化配置快照 + + :return: 可序列化的任务配置快照 + """ + return { + 'jobId': self.job_id, + 'jobName': self.name, + 'jobGroup': self.job_group, + 'jobStore': self.jobstore, + 'jobExecutor': self.executor, + 'invokeTarget': self.invoke_target, + 'jobArgs': json.loads(self.args) if self.args else [], + 'jobKwargs': json.loads(self.kwargs), + 'timeZone': self.time_zone, + } + + +class TimedJobExecutionEvent(JobExecutionEvent): + """ + 包含任务快照、真实起止时刻和耗时的执行事件 + """ + + def __init__(self, code: int, job: Job, run_time: datetime, snapshot: JobSnapshot, **kwargs) -> None: + """ + 初始化任务执行事件 + + :param code: APScheduler事件类型 + :param job: 调度器任务对象 + :param run_time: 本次计划执行时刻 + :param snapshot: 提交任务时的配置快照 + :param kwargs: 传递给标准执行事件的附加参数 + :return: None + """ + super().__init__(code, job.id, snapshot.jobstore, run_time, **kwargs) + self.snapshot = snapshot + self.execution_id = snapshot.execution_id or ( + uuid5(NAMESPACE_URL, f'ruoyi-job:{snapshot.job_id}:{TimezoneUtil.to_utc(run_time).isoformat()}').hex + if snapshot.managed + else uuid4().hex + ) + self.execution_key = (snapshot.jobstore, job.id, TimezoneUtil.to_utc(run_time)) + self.start_time: datetime | None = None + self.end_time: datetime | None = None + self.run_duration_ms: int | None = None + self.execution_status: str | None = None diff --git a/ruoyi-fastapi-backend/config/scheduler/executors.py b/ruoyi-fastapi-backend/config/scheduler/executors.py new file mode 100644 index 000000000..8a8b4b13c --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/executors.py @@ -0,0 +1,420 @@ +import asyncio +import logging +import sys +import time +import traceback +from collections.abc import Callable, Iterator +from concurrent.futures import Future +from concurrent.futures.process import BrokenProcessPool +from contextlib import contextmanager +from datetime import datetime, timedelta + +from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_MAX_INSTANCES, EVENT_JOB_MISSED +from apscheduler.executors.asyncio import AsyncIOExecutor +from apscheduler.executors.base import MaxInstancesReachedError +from apscheduler.executors.pool import ProcessPoolExecutor +from apscheduler.job import Job +from apscheduler.util import iscoroutinefunction_partial + +from config.scheduler.events import JobSnapshot, TimedJobExecutionEvent +from config.scheduler.job_execution import ExecutionHeartbeat, JobExecutionStore +from utils.time_util import TimezoneUtil + + +def _missed_event(job: Job, run_time: datetime, snapshot: JobSnapshot) -> TimedJobExecutionEvent | None: + """ + 判断本次计划是否已超过允许的延迟执行时限 + + :param job: 调度器任务对象 + :param run_time: 本次计划执行时刻 + :param snapshot: 提交任务时的配置快照 + :return: 过期事件,仍可执行时返回None + """ + if job.misfire_grace_time is not None and TimezoneUtil.utc_now() - run_time > timedelta( + seconds=job.misfire_grace_time + ): + return TimedJobExecutionEvent(EVENT_JOB_MISSED, job, run_time, snapshot) + return None + + +@contextmanager +def _execution( + job: Job, + run_time: datetime, + snapshot: JobSnapshot, + events: list[TimedJobExecutionEvent], + logger_name: str, + *, + event: TimedJobExecutionEvent | None = None, +) -> Iterator[TimedJobExecutionEvent]: + """ + 在实际调用边界记录任务起止时刻和单调耗时 + + 保留APScheduler对BaseException的捕获行为,异常通过执行事件回传。 + + :param job: 调度器任务对象 + :param run_time: 本次计划执行时刻 + :param snapshot: 提交任务时的配置快照 + :param events: 接收执行事件的结果列表 + :param logger_name: 调度器日志名称 + :param event: 已创建的执行事件,未指定时自动创建 + :return: 供调用方写入任务返回值的执行事件 + """ + logger = logging.getLogger(logger_name) + event = event or TimedJobExecutionEvent(EVENT_JOB_EXECUTED, job, run_time, snapshot) + # 内部周期任务正常执行时不输出日志,避免每秒派发和定期同步刷屏。 + log_execution = not job.id.startswith('_scheduler_') + if log_execution: + logger.info('▶️ 开始执行任务“%s”,计划执行时间:%s', job, run_time) + event.start_time = TimezoneUtil.utc_now() + started_at = time.perf_counter() + try: + yield event + except BaseException as exc: + event.code = EVENT_JOB_ERROR + event.exception = exc + finally: + event.run_duration_ms = max(0, int((time.perf_counter() - started_at) * 1000)) + event.end_time = TimezoneUtil.utc_now() + events.append(event) + if event.exception is not None: + exc = event.exception + event.traceback = ''.join(traceback.format_tb(exc.__traceback__)) + logger.error('❌ 任务“%s”执行异常', job, exc_info=(type(exc), exc, exc.__traceback__)) + traceback.clear_frames(exc.__traceback__) + if event.code == EVENT_JOB_EXECUTED and log_execution: + logger.info('✅ 任务“%s”执行成功', job) + + +def _claim_execution(event: TimedJobExecutionEvent) -> str | None: + """ + 在实际调用前检查持久化并发约束,失败时生成未执行事件 + + :param event: 任务执行事件 + :return: 执行占用凭据,未获得占用时返回None + """ + snapshot = event.snapshot + try: + claim = JobExecutionStore.claim( + event.execution_id, + snapshot.job_id, + snapshot.execution_snapshot(), + event.scheduled_run_time, + dispatch_token=snapshot.dispatch_token, + ) + except Exception as exc: + event.code = EVENT_JOB_ERROR + event.execution_status = 'failed' + event.exception = RuntimeError(f'无法确认任务执行占用,本次未调用:{exc}') + return None + if claim.accepted: + return claim.token + event.code = EVENT_JOB_MAX_INSTANCES if claim.status == 'rejected' else EVENT_JOB_ERROR + event.execution_status = claim.status + event.exception = RuntimeError(claim.message) + return None + + +def _finish_execution(event: TimedJobExecutionEvent, token: str) -> None: + """ + 保存已执行任务的终态,存储故障保留未确认占用供恢复流程处理 + + :param event: 任务执行事件 + :param token: 当前执行占用凭据 + :return: None + """ + try: + JobExecutionStore.finish( + event.execution_id, + token, + failed=event.code != EVENT_JOB_EXECUTED, + message=str(event.exception) if event.exception else None, + start_time=event.start_time, + end_time=event.end_time, + run_duration_ms=event.run_duration_ms, + ) + except Exception: + logging.getLogger(__name__).exception('❌ 保存任务执行结果失败:%s', event.execution_id) + + +def _record_unstarted_execution(event: TimedJobExecutionEvent) -> None: + """ + 保存过期和执行器拒绝记录,不覆盖已经开始的执行 + + :param event: 任务执行事件 + :return: None + """ + if not event.snapshot.managed: + return + try: + JobExecutionStore.record_unstarted( + event.execution_id, + event.snapshot.job_id, + event.snapshot.execution_snapshot(), + event.scheduled_run_time, + 'missed' if event.code == EVENT_JOB_MISSED else 'rejected', + '错过执行窗口' if event.code == EVENT_JOB_MISSED else '达到执行器并发上限', + dispatch_token=event.snapshot.dispatch_token, + ) + except Exception: + logging.getLogger(__name__).exception('❌ 保存任务未执行记录失败:%s', event.execution_id) + + +def run_timed_job( + job: Job, run_times: list[datetime], snapshot: JobSnapshot, logger_name: str +) -> list[TimedJobExecutionEvent]: + """ + 执行同步任务并收集带测时信息的事件 + + :param job: 调度器任务对象 + :param run_times: 本次提交需要处理的计划执行时刻列表 + :param snapshot: 提交任务时的配置快照 + :param logger_name: 调度器日志名称 + :return: 本次提交产生的执行事件列表 + """ + events = [] + for run_time in run_times: + if missed := _missed_event(job, run_time, snapshot): + _record_unstarted_execution(missed) + events.append(missed) + continue + event = TimedJobExecutionEvent(EVENT_JOB_EXECUTED, job, run_time, snapshot) + token = _claim_execution(event) if snapshot.managed else None + if snapshot.managed and token is None: + events.append(event) + continue + heartbeat = ExecutionHeartbeat(event.execution_id, token) if token else None + if heartbeat: + heartbeat.start() + try: + with _execution(job, run_time, snapshot, events, logger_name, event=event): + event.retval = job.func(*job.args, **job.kwargs) + if token: + _finish_execution(event, token) + finally: + if heartbeat: + heartbeat.stop() + return events + + +async def run_timed_coroutine_job( + job: Job, run_times: list[datetime], snapshot: JobSnapshot, logger_name: str +) -> list[TimedJobExecutionEvent]: + """ + 执行异步任务并收集带测时信息的事件 + + :param job: 调度器任务对象 + :param run_times: 本次提交需要处理的计划执行时刻列表 + :param snapshot: 提交任务时的配置快照 + :param logger_name: 调度器日志名称 + :return: 本次提交产生的执行事件列表 + """ + events = [] + for run_time in run_times: + if missed := _missed_event(job, run_time, snapshot): + await asyncio.to_thread(_record_unstarted_execution, missed) + events.append(missed) + continue + event = TimedJobExecutionEvent(EVENT_JOB_EXECUTED, job, run_time, snapshot) + token = await asyncio.to_thread(_claim_execution, event) if snapshot.managed else None + if snapshot.managed and token is None: + events.append(event) + continue + heartbeat = ExecutionHeartbeat(event.execution_id, token) if token else None + if heartbeat: + heartbeat.start() + try: + with _execution(job, run_time, snapshot, events, logger_name, event=event): + event.retval = await job.func(*job.args, **job.kwargs) + if token: + await asyncio.to_thread(_finish_execution, event, token) + finally: + if heartbeat: + heartbeat.stop() + return events + + +class JobSubmission: + """ + 任务提交和并发拒绝事件处理 + """ + + def __init__( + self, + on_rejected: Callable[[TimedJobExecutionEvent], None] | None = None, + manage_executions: bool = False, + ) -> None: + """ + 初始化提交配置和拒绝事件回调 + + :param on_rejected: 并发拒绝事件回调 + :param manage_executions: 是否启用持久化执行管理 + :return: None + """ + self._on_rejected = on_rejected + self.manage_executions = manage_executions + + def submit_job( + self, + submit: Callable[[Job, list[datetime]], None], + job: Job, + run_times: list[datetime], + job_logger: logging.Logger, + ) -> None: + """ + 调用执行器提交任务,达到并发上限时记录未执行事件 + + :param submit: 执行器的原始提交方法 + :param job: 调度器任务对象 + :param run_times: 本次提交的计划执行时刻 + :param job_logger: 执行器日志对象 + :return: None + """ + try: + submit(job, run_times) + except MaxInstancesReachedError: + if self._on_rejected is not None or self.manage_executions: + try: + snapshot = JobSnapshot.from_job(job, managed=self.manage_executions) + for run_time in run_times: + event = TimedJobExecutionEvent(EVENT_JOB_MAX_INSTANCES, job, run_time, snapshot) + _record_unstarted_execution(event) + if self._on_rejected is not None: + self._on_rejected(event) + except Exception: + job_logger.exception('❌ 记录任务并发拒绝日志失败') + # 标准JobSubmissionEvent仍由调度器捕获此异常后统一发送。 + raise + + +class TimedAsyncIOExecutor(AsyncIOExecutor): + """ + 记录真实执行耗时的异步及线程执行器 + """ + + def __init__( + self, + *args, + on_rejected: Callable[[TimedJobExecutionEvent], None] | None = None, + manage_executions: bool = False, + **kwargs, + ) -> None: + """ + 初始化执行器和任务提交处理对象 + + :param args: 执行器的位置参数 + :param on_rejected: 并发拒绝事件回调 + :param manage_executions: 是否启用持久化执行管理 + :param kwargs: 执行器的关键字参数 + :return: None + """ + super().__init__(*args, **kwargs) + self._submission = JobSubmission(on_rejected, manage_executions) + + def submit_job(self, job: Job, run_times: list[datetime]) -> None: + """ + 通过提交处理对象执行原始提交并记录并发拒绝事件 + + :param job: 调度器任务对象 + :param run_times: 本次提交的计划执行时刻 + :return: None + """ + self._submission.submit_job(super().submit_job, job, run_times, self._logger) + + def _do_submit_job(self, job: Job, run_times: list[datetime]) -> None: + """ + 提交携带配置快照的任务并注册完成回调 + + :param job: 调度器任务对象 + :param run_times: 本次提交需要处理的计划执行时刻列表 + :return: None + """ + snapshot = JobSnapshot.from_job(job, managed=self._submission.manage_executions) + + def callback(future: Future) -> None: + """ + 将任务执行结果交回APScheduler处理 + + :param future: 任务执行产生的Future对象 + :return: None + """ + self._pending_futures.discard(future) + try: + events = future.result() + except BaseException: + self._run_job_error(job.id, *sys.exc_info()[1:]) + else: + self._run_job_success(job.id, events) + + if iscoroutinefunction_partial(job.func): + future = self._eventloop.create_task(run_timed_coroutine_job(job, run_times, snapshot, self._logger.name)) + else: + future = self._eventloop.run_in_executor(None, run_timed_job, job, run_times, snapshot, self._logger.name) + future.add_done_callback(callback) + self._pending_futures.add(future) + + +class TimedProcessPoolExecutor(ProcessPoolExecutor): + """ + 记录真实执行耗时的进程池执行器 + """ + + def __init__( + self, + *args, + on_rejected: Callable[[TimedJobExecutionEvent], None] | None = None, + manage_executions: bool = False, + **kwargs, + ) -> None: + """ + 初始化执行器和任务提交处理对象 + + :param args: 执行器的位置参数 + :param on_rejected: 并发拒绝事件回调 + :param manage_executions: 是否启用持久化执行管理 + :param kwargs: 执行器的关键字参数 + :return: None + """ + super().__init__(*args, **kwargs) + self._submission = JobSubmission(on_rejected, manage_executions) + + def submit_job(self, job: Job, run_times: list[datetime]) -> None: + """ + 通过提交处理对象执行原始提交并记录并发拒绝事件 + + :param job: 调度器任务对象 + :param run_times: 本次提交的计划执行时刻 + :return: None + """ + self._submission.submit_job(super().submit_job, job, run_times, self._logger) + + def _do_submit_job(self, job: Job, run_times: list[datetime]) -> None: + """ + 提交携带配置快照的任务并注册完成回调 + + :param job: 调度器任务对象 + :param run_times: 本次提交需要处理的计划执行时刻列表 + :return: None + """ + snapshot = JobSnapshot.from_job(job, managed=self._submission.manage_executions) + + def callback(future: Future) -> None: + """ + 将任务执行结果交回APScheduler处理 + + :param future: 任务执行产生的Future对象 + :return: None + """ + exc = future.exception() + if exc: + self._run_job_error(job.id, exc, exc.__traceback__) + else: + self._run_job_success(job.id, future.result()) + + try: + future = self._pool.submit(run_timed_job, job, run_times, snapshot, self._logger.name) + except BrokenProcessPool: + self._logger.warning('⚠️ 进程池异常,正在重建进程池') + self._pool = self._pool.__class__(self._pool._max_workers, **self.pool_kwargs) + future = self._pool.submit(run_timed_job, job, run_times, snapshot, self._logger.name) + future.add_done_callback(callback) diff --git a/ruoyi-fastapi-backend/config/scheduler/job_adapter.py b/ruoyi-fastapi-backend/config/scheduler/job_adapter.py new file mode 100644 index 000000000..525b66ea6 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/job_adapter.py @@ -0,0 +1,199 @@ +import hashlib +import importlib +import json +import pickle +from collections.abc import Callable +from typing import Any + +from apscheduler.job import Job +from apscheduler.util import iscoroutinefunction_partial, obj_to_ref + +from common.constant import CommonConstant, JobConstant +from module_admin.entity.vo.job_vo import JobModel +from utils.cron_util import MyCronTrigger +from utils.string_util import StringUtil + + +class JobAdapter: + """ + 统一任务默认值、参数解析和调度配置校验 + """ + + JOB_STORES = frozenset({'default', 'sqlalchemy', 'redis'}) + EXECUTORS = frozenset({'default', 'processpool'}) + CONFIG_FIELDS = ( + 'job_name', + 'job_group', + 'job_store', + 'job_executor', + 'invoke_target', + 'job_args', + 'job_kwargs', + 'cron_expression', + 'time_zone', + 'misfire_grace_time', + 'coalesce', + 'max_instances', + 'status', + ) + + @staticmethod + def from_record(record: Any) -> JobModel: + """ + 读取已保存配置,不在读取阶段阻断无效配置的诊断和停用 + + :param record: 数据库记录或任务对象 + :return: 任务对象信息 + """ + if isinstance(record, JobModel): + return record + return JobModel.model_construct( + **{name: getattr(record, name) for name in JobModel.model_fields if hasattr(record, name)} + ) + + @staticmethod + def normalize(job: JobModel) -> JobModel: + """ + 补齐保存和注册共用的默认值,保留显式填写的配置 + + :param job: 任务对象信息 + :return: 补齐默认值后的任务对象 + """ + defaults = { + 'job_group': 'default', + 'job_executor': 'default', + 'status': '1', + } + values = {key: value for key, value in defaults.items() if getattr(job, key) is None} + for name in ('job_name', 'job_group'): + if isinstance(value := getattr(job, name), str): + values[name] = value.strip() + return job.model_copy(update=values) + + @staticmethod + def parse_args(value: list[Any]) -> list[Any]: + """ + 校验位置参数为JSON数组,保留数组内的值类型 + + :param value: 待解析的位置参数 + :return: 位置参数列表 + """ + if not isinstance(value, list): + raise ValueError('位置参数必须是 JSON 数组') + return value + + @staticmethod + def import_function(target: str) -> Callable[..., Any]: + """ + 导入任务函数,明确拒绝不存在和不可调用的目标 + + :param target: 调用目标字符串 + :return: 任务函数 + """ + try: + module_name, name = target.rsplit('.', 1) + function = getattr(importlib.import_module(module_name), name) + except (AttributeError, ImportError, TypeError, ValueError) as exc: + raise ValueError(f'调用目标不存在或无法导入:{target}') from exc + if not callable(function): + raise ValueError(f'调用目标不可调用:{target}') + return function + + @staticmethod + def validate_allowed_target(target: str | None) -> None: + """ + 校验管理接口允许保存的调用模块,按完整模块前缀匹配 + + :param target: 调用目标字符串 + :return: None + """ + if not target: + raise ValueError('调用目标不能为空') + forbidden = [ + CommonConstant.LOOKUP_RMI, + CommonConstant.LOOKUP_LDAP, + CommonConstant.LOOKUP_LDAPS, + CommonConstant.HTTP, + CommonConstant.HTTPS, + ] + if StringUtil.contains_any_ignore_case(target, forbidden) or StringUtil.startswith_any_case( + target, JobConstant.JOB_ERROR_LIST + ): + raise ValueError('调用目标包含禁止使用的模块或协议') + if not any( + target.lower().startswith(f'{prefix.rstrip(".").lower()}.') for prefix in JobConstant.JOB_WHITE_LIST + ): + raise ValueError('调用目标字符串不在白名单内') + + @classmethod + def prepare(cls, job: JobModel, *, function: Callable[..., Any] | None = None) -> dict[str, Any]: + """ + 校验完整配置并构造注册参数,不写数据库或改变调度器 + + :param job: 任务对象信息 + :param function: 已导入的任务函数,未指定时根据调用目标导入 + :return: 调度器任务注册参数 + """ + job = JobModel.model_validate(cls.normalize(job).model_dump(by_alias=True)) + if not job.job_name or not job.job_name.strip(): + raise ValueError('任务名称不能为空') + for name, limit in { + 'job_name': 64, + 'job_group': 64, + 'invoke_target': 500, + 'cron_expression': 255, + }.items(): + value = getattr(job, name) + if value is not None and len(value) > limit: + raise ValueError(f'{name} 长度不能超过 {limit} 个字符') + if not job.job_group: + raise ValueError('业务分组不能为空') + if job.job_store not in cls.JOB_STORES: + raise ValueError(f'任务存储未注册:{job.job_store}') + if job.job_executor not in cls.EXECUTORS: + raise ValueError(f'任务执行器未注册:{job.job_executor}') + if not job.invoke_target: + raise ValueError('调用目标不能为空') + function = function if function is not None else cls.import_function(job.invoke_target) + if not callable(function): + raise ValueError('调用目标不可调用') + kwargs = job.job_kwargs + # 禁止非有限数值进入JSON列,避免数据库与执行器对同一参数产生不同解释。 + json.dumps([job.job_args, kwargs], allow_nan=False) + executor = 'default' if iscoroutinefunction_partial(function) else job.job_executor + trigger = MyCronTrigger.from_crontab(job.cron_expression, job.time_zone) + trigger.task_job_group = job.job_group + options = { + 'func': function, + 'trigger': trigger, + 'args': cls.parse_args(job.job_args), + 'kwargs': kwargs, + 'id': str(job.job_id) if job.job_id is not None else 'validation', + 'name': job.job_name, + 'misfire_grace_time': job.misfire_grace_time, + 'coalesce': job.coalesce, + 'max_instances': job.max_instances, + 'jobstore': job.job_store, + 'executor': executor, + } + try: + # Job 构造器执行与真实注册相同的函数签名和参数校验,但不会注册任务。 + Job(None, **{key: value for key, value in options.items() if key != 'jobstore'}) + obj_to_ref(function) + if executor == 'processpool' or job.job_store != 'default': + pickle.dumps((obj_to_ref(function), options['args'], kwargs, options['trigger'])) + except (AttributeError, TypeError, ValueError, pickle.PickleError) as exc: + raise ValueError(f'任务函数或参数不符合调度要求:{exc}') from exc + return options + + @classmethod + def config_hash(cls, job: JobModel | None) -> str: + """ + 计算调度配置摘要,供版本跟踪和插件变更校准使用 + + :param job: 任务对象信息 + :return: 任务配置摘要 + """ + data = None if job is None else cls.normalize(job).model_dump(include=set(cls.CONFIG_FIELDS), mode='json') + value = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(',', ':')) + return hashlib.sha256(value.encode()).hexdigest() diff --git a/ruoyi-fastapi-backend/config/scheduler/job_execution.py b/ruoyi-fastapi-backend/config/scheduler/job_execution.py new file mode 100644 index 000000000..3ecfcad0b --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/job_execution.py @@ -0,0 +1,307 @@ +import logging +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any +from uuid import uuid4 + +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session + +from config.database import DataSourceRegistry, quiet_sql_engine +from config.env import DataBaseConfig +from module_admin.entity.do.job_do import SysJob +from module_admin.entity.do.job_runtime_do import SysJobExecution, SysJobSync +from utils.time_util import TimezoneUtil + + +@dataclass(frozen=True) +class ExecutionClaim: + """ + 实际执行前获得的占用凭据或明确的未执行原因 + """ + + accepted: bool + token: str | None = None + status: str | None = None + message: str | None = None + + +class JobExecutionStore: + """ + 使用独立短事务保存占用、续租和最终执行结果 + """ + + LEASE_SECONDS = 30 + HEARTBEAT_SECONDS = 5 + + @staticmethod + def session() -> Session: + """ + 按当前进程的数据源配置创建独立同步会话 + + :return: 同步数据库会话 + """ + engine = quiet_sql_engine(DataSourceRegistry.get_sync_engine(DataBaseConfig.db_default_source)) + return Session(engine, expire_on_commit=False) + + @classmethod + def claim( + cls, + execution_id: str, + job_id: int, + snapshot: dict[str, Any], + scheduled_time: datetime, + *, + dispatch_token: str | None = None, + ) -> ExecutionClaim: + """ + 串行检查同一任务的并发占用,再原子标记本次执行开始 + + :param execution_id: 执行ID + :param job_id: 任务ID + :param snapshot: 任务配置快照 + :param scheduled_time: 计划执行时刻 + :param dispatch_token: 本次派发的领取凭据 + :return: 执行占用结果 + """ + with cls.session() as db, db.begin(): + # 无值变更的 UPDATE 同时适用于 MySQL、PostgreSQL 和 SQLite 的写锁。 + locked = db.execute( + update(SysJobSync) + .where(SysJobSync.job_id == job_id) + .values( + config_version=SysJobSync.config_version, + update_time=SysJobSync.update_time, + ) + ) + if not locked.rowcount: + return ExecutionClaim(False, status='failed', message='任务尚未完成调度状态初始化') + execution = db.get(SysJobExecution, execution_id) + if dispatch_token is not None: + if execution is None or execution.status != 'submitted' or execution.owner_token != dispatch_token: + return ExecutionClaim(False, status='cancelled', message='执行请求已取消、已领取或派发凭据已失效') + elif execution is not None: + return ExecutionClaim(False, status=execution.status, message='该计划时刻已有执行记录,未重复执行') + else: + execution = SysJobExecution( + execution_id=execution_id, + job_id=job_id, + source='cron', + status='pending', + job_snapshot=snapshot, + scheduled_time=scheduled_time, + ) + db.add(execution) + job = db.execute(select(SysJob).where(SysJob.job_id == job_id)).scalars().first() + if job is None or (dispatch_token is None and job.status != '0'): + message = '任务已删除或已停用,取消尚未开始的定时执行' if dispatch_token is None else '任务已删除' + cls._stop(execution, 'cancelled', message) + return ExecutionClaim(False, status='cancelled', message=message) + active = db.scalar( + select(func.count()) + .select_from(SysJobExecution) + .where( + SysJobExecution.job_id == job_id, + SysJobExecution.status.in_(['running', 'unknown']), + ) + ) + limit = job.max_instances + if active >= limit: + message = f'达到任务并发上限 {limit},本次未执行' + cls._stop(execution, 'rejected', message) + return ExecutionClaim(False, status='rejected', message=message) + token = uuid4().hex + execution.status = 'running' + execution.owner_token = token + execution.scheduled_time = scheduled_time + execution.start_time = TimezoneUtil.utc_now() + execution.lease_until = execution.start_time + timedelta(seconds=cls.LEASE_SECONDS) + execution.message = None + return ExecutionClaim(True, token=token, status='running') + + @staticmethod + def _stop(execution: SysJobExecution, status: str, message: str) -> None: + """ + 保存未执行的终态,不伪造实际执行开始时间和耗时 + + :param execution: 执行记录对象 + :param status: 执行状态 + :param message: 执行结果或未执行原因 + :return: None + """ + execution.status = status + execution.message = message + execution.end_time = None + execution.lease_until = None + execution.owner_token = None + + @classmethod + def renew(cls, execution_id: str, token: str) -> bool: + """ + 仅允许当前占用者续租;迟到的旧执行不能更新其他请求 + + :param execution_id: 执行ID + :param token: 当前执行占用凭据 + :return: 是否续租成功 + """ + with cls.session() as db, db.begin(): + result = db.execute( + update(SysJobExecution) + .where( + SysJobExecution.execution_id == execution_id, + SysJobExecution.owner_token == token, + SysJobExecution.status.in_(['running', 'unknown']), + ) + .values(lease_until=TimezoneUtil.utc_now() + timedelta(seconds=cls.LEASE_SECONDS)) + ) + return bool(result.rowcount) + + @classmethod + def finish( + cls, + execution_id: str, + token: str, + *, + failed: bool, + message: str | None, + start_time: datetime, + end_time: datetime, + run_duration_ms: int, + ) -> None: + """ + 按占用凭据保存真实执行结果;结果未知的原执行仍可补交最终结果 + + :param execution_id: 执行ID + :param token: 当前执行占用凭据 + :param failed: 是否执行失败 + :param message: 执行结果或未执行原因 + :param start_time: 实际执行开始时刻 + :param end_time: 实际执行结束时刻 + :param run_duration_ms: 实际执行耗时(毫秒) + :return: None + """ + with cls.session() as db, db.begin(): + db.execute( + update(SysJobExecution) + .where( + SysJobExecution.execution_id == execution_id, + SysJobExecution.owner_token == token, + SysJobExecution.status.in_(['running', 'unknown']), + ) + .values( + status='failed' if failed else 'success', + message=(message or '执行成功')[:2000], + start_time=start_time, + end_time=end_time, + run_duration_ms=run_duration_ms, + lease_until=None, + ) + ) + + @classmethod + def record_unstarted( + cls, + execution_id: str, + job_id: int, + snapshot: dict[str, Any], + scheduled_time: datetime, + status: str, + message: str, + *, + dispatch_token: str | None = None, + ) -> None: + """ + 记录过期或执行器拒绝,已有执行记录保持原状态以避免覆盖结果 + + :param execution_id: 执行ID + :param job_id: 任务ID + :param snapshot: 任务配置快照 + :param scheduled_time: 计划执行时刻 + :param status: 执行状态 + :param message: 执行结果或未执行原因 + :param dispatch_token: 本次派发的领取凭据 + :return: None + """ + with cls.session() as db, db.begin(): + locked = db.execute( + update(SysJobSync) + .where(SysJobSync.job_id == job_id) + .values( + config_version=SysJobSync.config_version, + update_time=SysJobSync.update_time, + ) + ) + if not locked.rowcount: + return + execution = db.get(SysJobExecution, execution_id) + if execution is None and dispatch_token is None: + execution = SysJobExecution( + execution_id=execution_id, + job_id=job_id, + source='cron', + status=status, + job_snapshot=snapshot, + scheduled_time=scheduled_time, + ) + db.add(execution) + elif execution is None or execution.status != 'submitted' or execution.owner_token != dispatch_token: + return + cls._stop(execution, status, message) + + +class ExecutionHeartbeat: + """ + 在同步、异步和进程池任务运行期间独立续租,退出时停止续租 + """ + + def __init__(self, execution_id: str, token: str) -> None: + """ + 保存执行凭据,线程只在任务真正开始后创建 + + :param execution_id: 执行ID + :param token: 当前执行占用凭据 + :return: None + """ + self.execution_id = execution_id + self.token = token + self._stop = threading.Event() + self._thread = threading.Thread(target=self._renew, name=f'job-lease-{execution_id[:8]}', daemon=True) + + def start(self) -> None: + """ + 启动占用续租 + + :return: None + """ + self._thread.start() + + def stop(self) -> None: + """ + 通知续租线程退出,避免任务完成后继续持有占用 + + :return: None + """ + self._stop.set() + + def _renew(self) -> None: + """ + 周期续租;连接故障交由过期状态处理,不自动重放业务调用 + + :return: None + """ + while not self._stop.wait(JobExecutionStore.HEARTBEAT_SECONDS): + if not self._renew_once(): + return + + def _renew_once(self) -> bool: + """ + 尝试续租一次,暂时断连后允许下一轮恢复连接 + + :return: 是否继续续租 + """ + try: + return JobExecutionStore.renew(self.execution_id, self.token) + except Exception: + logging.getLogger(__name__).exception('❌ 任务执行占用续租失败:%s', self.execution_id) + return True diff --git a/ruoyi-fastapi-backend/config/scheduler/jobs.py b/ruoyi-fastapi-backend/config/scheduler/jobs.py new file mode 100644 index 000000000..0990315c9 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/jobs.py @@ -0,0 +1,267 @@ +import json +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any + +from apscheduler.job import Job +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.combining import OrTrigger +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.util import obj_to_ref + +from config.scheduler.job_adapter import JobAdapter +from config.scheduler.triggers import TaskDateTrigger +from module_admin.entity.vo.job_vo import JobModel +from utils.log_util import logger + + +class SchedulerJobs: + """ + 任务注册、配置比较和本地调度进度管理 + """ + + def __init__(self, scheduler: AsyncIOScheduler) -> None: + """ + 绑定当前进程的调度器并初始化任务更新时间缓存 + + :param scheduler: 当前进程的调度器 + :return: None + """ + self.scheduler = scheduler + self.update_time_cache: dict[str, datetime | None] = {} + + @staticmethod + def parse_args(job_args: list[Any] | None) -> list[Any] | None: + """ + 解析任务位置参数。 + + :param job_args: 数据库中的任务位置参数 + :return: 位置参数列表 + """ + return JobAdapter.parse_args(job_args) if job_args else None + + @staticmethod + def dump_args(args: tuple[Any, ...] | list[Any] | None) -> str: + """ + 序列化任务位置参数。 + + :param args: 调度器任务位置参数 + :return: 数据库存储字符串 + """ + return json.dumps(list(args), ensure_ascii=False) if args else '' + + @classmethod + def import_function(cls, func_path: str) -> Callable[..., Any]: + """ + 动态导入函数 + + :param func_path: 函数字符串,如module_task.scheduler_test.job + :return: 导入的函数对象 + """ + return JobAdapter.import_function(func_path) + + @classmethod + def prepare_job(cls, job_info: JobModel) -> dict[str, Any]: + """ + 构建调度器任务参数 + + :param job_info: 任务对象信息 + :return: 调度器任务参数 + """ + return JobAdapter.prepare(job_info, function=cls.import_function(job_info.invoke_target)) + + @classmethod + def trigger_signature(cls, trigger: Any) -> tuple[Any, ...]: + """ + 提取用于比较触发器配置的完整签名 + + :param trigger: 待比较的触发器对象 + :return: 包含时区、起止边界和子触发器信息的签名 + """ + if isinstance(trigger, CronTrigger): + return ( + type(trigger).__module__, + type(trigger).__qualname__, + tuple((field.name, str(field)) for field in trigger.fields), + str(trigger.timezone), + trigger.start_date, + trigger.end_date, + trigger.jitter, + ) + if isinstance(trigger, DateTrigger): + return ('date', trigger.run_date) + if isinstance(trigger, OrTrigger): + return ('or', tuple(cls.trigger_signature(item) for item in trigger.triggers), trigger.jitter) + return (type(trigger), trigger) + + @classmethod + def is_config_current(cls, scheduler_job: Job, job_info: JobModel) -> bool: + """ + 判断任务配置是否一致 + + :param scheduler_job: 调度器任务对象 + :param job_info: 数据库任务对象 + :return: 是否一致 + """ + job_state = scheduler_job.__getstate__() + options = cls.prepare_job(job_info) + expected = { + 'name': options['name'], + 'executor': options['executor'], + 'jobstore': options['jobstore'], + 'job_group': getattr(options['trigger'], 'task_job_group', 'default'), + 'misfire_grace_time': options['misfire_grace_time'], + 'coalesce': options['coalesce'], + 'max_instances': options['max_instances'], + 'trigger': cls.trigger_signature(options['trigger']), + 'args': tuple(options['args']), + 'kwargs': options['kwargs'], + 'func': obj_to_ref(options['func']), + } + current = { + 'name': job_state.get('name'), + 'executor': job_state.get('executor'), + 'jobstore': scheduler_job._jobstore_alias, + 'job_group': getattr(scheduler_job.trigger, 'task_job_group', 'default'), + 'misfire_grace_time': job_state.get('misfire_grace_time'), + 'coalesce': job_state.get('coalesce'), + 'max_instances': job_state.get('max_instances'), + 'trigger': cls.trigger_signature(job_state.get('trigger')), + 'args': tuple(job_state.get('args') or ()), + 'kwargs': dict(job_state.get('kwargs') or {}), + 'func': job_state.get('func'), + } + return expected == current + + def register_job(self, job_info: JobModel) -> bool: + """ + 注册或更新调度任务,保留未发生变化的调度进度 + + :param job_info: 任务对象信息 + :return: 是否成功注册任务 + """ + existing_job = self.scheduler.get_job(job_id=str(job_info.job_id)) + options = self.prepare_job(job_info) + if ( + existing_job + and hasattr(existing_job, 'next_run_time') + and self.trigger_signature(existing_job.trigger) == self.trigger_signature(options['trigger']) + ): + # 仅修改参数、策略、分组或存储时保留计划进度,让过期规则处理已到期计划。 + options['next_run_time'] = existing_job.next_run_time + self.scheduler.add_job(**options, replace_existing=True) + if existing_job and existing_job._jobstore_alias != job_info.job_store: + self.scheduler.remove_job(job_id=str(job_info.job_id), jobstore=existing_job._jobstore_alias) + return True + + def get_job(self, job_id: str | int) -> Job | None: + """ + 根据任务id获取任务对象 + + :param job_id: 任务id + :return: 任务对象 + """ + query_job = self.scheduler.get_job(job_id=str(job_id)) + + return query_job + + def add_job(self, job_info: JobModel) -> None: + """ + 根据输入的任务对象信息添加任务 + + :param job_info: 任务对象信息 + :return: None + """ + self.scheduler.add_job(**self.prepare_job(job_info)) + self.record_update_time(str(job_info.job_id), job_info.update_time) + + def execute_once(self, job_info: JobModel, *, execution_id: str, dispatch_token: str) -> None: + """ + 将已持久化并领取的手动请求注册为独立单次任务 + + :param job_info: 任务对象信息 + :param execution_id: 已持久化的执行请求 ID + :param dispatch_token: 本次派发持有的领取凭据 + :return: None + """ + options = self.prepare_job(job_info) + options.update( + id=f'_manual:{execution_id}', + trigger=TaskDateTrigger( + job_info.time_zone, + task_job_id=job_info.job_id, + task_job_group=job_info.job_group, + execution_id=execution_id, + dispatch_token=dispatch_token, + timezone=timezone.utc, + ), + misfire_grace_time=None, + coalesce=False, + max_instances=1, + ) + self.scheduler.add_job(**options, replace_existing=True) + + def remove_job(self, job_id: str | int) -> None: + """ + 根据任务id移除任务 + + :param job_id: 任务id + :return: None + """ + job_id = str(job_id) + query_job = self.get_job(job_id=job_id) + if query_job: + self.scheduler.remove_job(job_id=job_id) + self.forget_update_time(job_id) + + def update_job( + self, job_id: str, job_info: JobModel | None, scheduler_job: Job | None, job_update_time: datetime | None + ) -> None: + """ + 同步更新任务配置 + + :param job_id: 任务ID + :param job_info: 数据库任务对象 + :param scheduler_job: 调度器任务对象 + :param job_update_time: 任务更新时间 + :return: None + """ + if not job_info or not scheduler_job: + return + if self.has_update_time(job_id, job_update_time): + return + if not self.is_config_current(scheduler_job, job_info): + if not self.register_job(job_info): + return + logger.info(f'🔄 同步更新任务:{job_info.job_name}') + self.record_update_time(job_id, job_update_time) + + def has_update_time(self, job_id: str, job_update_time: datetime | None) -> bool: + """ + 判断是否跳过同步更新 + + :param job_id: 任务ID + :param job_update_time: 任务更新时间 + :return: 是否跳过 + """ + return job_id in self.update_time_cache and self.update_time_cache[job_id] == job_update_time + + def record_update_time(self, job_id: str, job_update_time: datetime | None) -> None: + """ + 刷新任务更新时间缓存 + + :param job_id: 任务ID + :param job_update_time: 任务更新时间 + :return: None + """ + self.update_time_cache[job_id] = job_update_time + + def forget_update_time(self, job_id: str) -> None: + """ + 移除任务更新时间缓存 + + :param job_id: 任务ID + :return: None + """ + self.update_time_cache.pop(job_id, None) diff --git a/ruoyi-fastapi-backend/config/scheduler/listener.py b/ruoyi-fastapi-backend/config/scheduler/listener.py new file mode 100644 index 000000000..430939c89 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/listener.py @@ -0,0 +1,86 @@ +import json + +from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MAX_INSTANCES, EVENT_JOB_MISSED, SchedulerEvent + +from config.scheduler.events import TimedJobExecutionEvent +from config.scheduler.resources import SchedulerResources +from module_admin.entity.vo.job_vo import JobLogModel +from module_admin.service.job_log_service import JobLogService +from utils.log_util import logger +from utils.time_util import TimezoneUtil + + +class SchedulerJobListener: + """ + 调度事件转换和任务执行日志持久化 + """ + + def __init__(self, resources: SchedulerResources) -> None: + """ + 绑定执行日志使用的独立数据库会话资源 + + :param resources: 调度数据库资源 + :return: None + """ + self.resources = resources + + def handle_event(self, event: SchedulerEvent) -> None: + """ + 调度器事件监听器,记录任务执行日志 + + :param event: APScheduler分发的任务执行事件 + :return: None + """ + if not isinstance(event, TimedJobExecutionEvent) or ( + str(event.job_id).startswith('_') and not str(event.job_id).startswith('_manual:') + ): + return + try: + info = event.snapshot + reason = { + EVENT_JOB_ERROR: '执行失败', + EVENT_JOB_MISSED: '错过执行窗口', + EVENT_JOB_MAX_INSTANCES: '达到并发上限', + }.get(event.code) + message = f'任务ID: {info.job_id or event.job_id}, 执行ID: {event.execution_id}, {reason or "执行成功"}' + if event.run_duration_ms is not None: + message += f', 耗时:{event.run_duration_ms}毫秒' + job_log = JobLogModel( + jobId=info.job_id, + executionId=event.execution_id, + jobName=info.name, + jobGroup=info.job_group, + jobStore=info.jobstore, + jobExecutor=info.executor, + invokeTarget=info.invoke_target, + jobArgs=json.loads(info.args) if info.args else [], + jobKwargs=json.loads(info.kwargs), + jobTrigger=info.trigger, + jobMessage=message, + status='1' if reason else '0', + exceptionInfo=str(event.exception) if event.exception else (reason or ''), + scheduledTime=event.scheduled_run_time, + startTime=event.start_time, + endTime=event.end_time, + runDurationMs=event.run_duration_ms, + timeZone=info.time_zone, + createTime=TimezoneUtil.utc_now(), + ) + self.persist_log(job_log) + except Exception: + logger.exception('❌ 调度任务事件监听器异常') + + def persist_log(self, job_log: JobLogModel) -> None: + """ + 使用独立同步会话保存任务执行日志 + + :param job_log: 待保存的任务执行日志 + :return: None + """ + session = self.resources.log_session_factory()() + try: + result = JobLogService.add_job_log_services(session, job_log) + if not result.is_success: + logger.error(f'❌ 记录任务执行日志失败:{result.message}') + finally: + session.close() diff --git a/ruoyi-fastapi-backend/config/scheduler/manager.py b/ruoyi-fastapi-backend/config/scheduler/manager.py new file mode 100644 index 000000000..60d5e78aa --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/manager.py @@ -0,0 +1,643 @@ +import asyncio +import json +import random +import time +from datetime import timezone +from typing import Any + +from apscheduler.events import EVENT_ALL, SchedulerEvent +from apscheduler.job import Job +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from redis import asyncio as aioredis + +import module_task # noqa: F401 +from common.constant import LockConstant +from config.env import AppConfig, LogConfig +from config.scheduler.dispatcher import SchedulerDispatcher +from config.scheduler.jobs import SchedulerJobs +from config.scheduler.listener import SchedulerJobListener +from config.scheduler.resources import SchedulerResources +from config.scheduler.synchronization import SchedulerSynchronizer +from module_admin.entity.vo.job_vo import JobModel +from utils.log_util import logger +from utils.server_util import StartupUtil, WorkerIdUtil + +scheduler = AsyncIOScheduler(timezone=timezone.utc) + + +class SchedulerManager: + """ + 定时任务统一入口,负责生命周期、Leader控制和跨进程同步通知 + """ + + _scheduler: AsyncIOScheduler = scheduler + _is_leader: bool = False + _worker_id: str = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id) + _application_lock_owner_token: str = StartupUtil.get_application_lock_owner_token(_worker_id) + _application_lock_renewal_task: asyncio.Task | None = None + _redis: aioredis.Redis | None = None + _sync_channel: str = 'scheduler:sync:request' + _sync_listener_task: asyncio.Task | None = None + _lock_lost_task: asyncio.Task | None = None + _sync_task: asyncio.Task | None = None + _sync_pending: bool = False + _sync_full_pending: bool = False + _sync_pending_ids: set[int] = set() + _sync_lock: asyncio.Lock = asyncio.Lock() + _last_sync_at: float | None = None + _sync_debounce_seconds: float = 0.5 + _sync_min_interval_seconds: float = 2.0 + _reacquire_task: asyncio.Task | None = None + _reacquire_interval_seconds: float = 5.0 + _reacquire_jitter_seconds: float = 1.0 + _is_closing: bool = False + + _resources = SchedulerResources() + _jobs = SchedulerJobs(scheduler) + _synchronizer = SchedulerSynchronizer(_jobs, _resources) + _dispatcher = SchedulerDispatcher(_jobs, _resources, _synchronizer) + _listener = SchedulerJobListener(_resources) + + @classmethod + def _should_enable_scheduler_sync(cls) -> bool: + """ + 判断是否需要启用多 worker 的任务状态同步机制 + + :return: 是否开启定时同步与监听 + """ + return not AppConfig.app_reload and AppConfig.app_workers > 1 + + @classmethod + async def init_system_scheduler(cls, redis: aioredis.Redis) -> None: + """ + 应用启动时初始化定时任务(使用分布式锁确保只有一个worker启动scheduler) + + :param redis: Redis连接对象 + :return: None + """ + cls._redis = redis + cls._is_closing = False + logger.debug(f'🔎 Worker {cls._worker_id} 尝试获取 Application 锁...') + + acquired = await StartupUtil.acquire_application_leader( + redis=redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + owner_token=cls.get_application_lock_owner_token(), + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + ) + + if acquired: + await cls._activate_scheduler_as_leader(redis) + else: + cls._is_leader = False + logger.debug(f'⏸️ Worker {cls._worker_id} 未持有 Application 锁,跳过 Scheduler 启动') + cls._ensure_reacquire_task() + + @classmethod + def get_application_lock_owner_token(cls) -> str: + """ + 获取当前进程的Application leader租约owner token。 + + :return: Application锁owner token + """ + cls._application_lock_owner_token = StartupUtil.get_application_lock_owner_token(cls._worker_id) + return cls._application_lock_owner_token + + @classmethod + def is_application_leader(cls) -> bool: + """ + 判断当前进程是否仍以Application leader身份运行。 + + :return: 是否为Application leader + """ + return cls._is_leader + + @classmethod + def start_application_lock_renewal(cls, redis: aioredis.Redis) -> asyncio.Task: + """ + 启动或复用当前进程的Application leader租约续期任务。 + + :param redis: Redis连接对象 + :return: Application锁续期任务 + """ + # server可能在Scheduler正式初始化前就获得租约;提前保存Redis以便启动失败时释放。 + cls._redis = redis + renewal_task = cls._application_lock_renewal_task + if renewal_task and not renewal_task.done(): + return renewal_task + cls._application_lock_renewal_task = StartupUtil.start_application_leader_renewal( + redis=redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + owner_token=cls.get_application_lock_owner_token(), + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + interval_seconds=LockConstant.LOCK_RENEWAL_INTERVAL, + on_lock_lost=cls.on_lock_lost, + ) + return cls._application_lock_renewal_task + + @classmethod + async def stop_application_lock_renewal(cls) -> None: + """ + 停止当前进程的Application leader租约续期任务。 + + :return: None + """ + renewal_task = cls._application_lock_renewal_task + cls._application_lock_renewal_task = None + if not renewal_task or renewal_task.done(): + return + renewal_task.cancel() + try: + await renewal_task + except asyncio.CancelledError: + pass + + @classmethod + async def _start_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: + """ + 以 Leader 身份启动 Scheduler(内部方法,调用前需确保已持有锁) + + :param redis: Redis连接对象 + :return: None + """ + cls._is_leader = True + logger.info(f'🎯 Worker {cls._worker_id} 持有 Application 锁,开始启动定时任务...') + # 懒加载配置 scheduler + cls._configure_scheduler() + cls._scheduler.start(paused=True) + cls._jobs.update_time_cache.clear() + cls._synchronizer.applied_jobs.clear() + cls._sync_pending_ids = set() + cls._sync_full_pending = False + # 先清除持久化 job store 中停用/删除的任务,再允许执行。 + await cls._sync_jobs_from_database(raise_errors=True) + try: + cls._scheduler.remove_listener(cls.scheduler_event_listener) + except ValueError: + pass + cls._scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL) + cls._scheduler.add_job( + func=cls.request_scheduler_sync, + trigger='interval', + seconds=30, + id='_scheduler_job_sync', + name='Scheduler任务同步', + replace_existing=True, + ) + cls._scheduler.add_job( + func=cls._drain_execution_requests, + trigger='interval', + seconds=1, + id='_scheduler_execution_dispatch', + name='Scheduler执行请求派发', + replace_existing=True, + ) + if cls._should_enable_scheduler_sync(): + cls._sync_listener_task = asyncio.create_task(cls._listen_sync_channel(redis)) + cls._scheduler.resume() + + logger.info('✅ 系统初始定时任务加载成功') + + @classmethod + async def _activate_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: + """ + 启动租约续期并以Application leader身份激活Scheduler。 + + Scheduler启动失败时立即停止续期并原子释放租约,避免故障worker继续占用 + Application leader身份。 + + :param redis: Redis连接对象 + :return: None + """ + cls.start_application_lock_renewal(redis) + try: + await cls._start_scheduler_as_leader(redis) + except Exception: + cls._is_leader = False + await cls.stop_application_lock_renewal() + try: + await StartupUtil.release_application_leader( + redis, + LockConstant.APP_STARTUP_LOCK_KEY, + cls.get_application_lock_owner_token(), + ) + except Exception: + logger.exception('❌ Scheduler 启动失败后释放 Application Leader 租约失败') + raise + + @classmethod + def on_lock_lost(cls) -> None: + """ + 锁丢失处理入口 + + :return: None + """ + if not cls._is_leader: + return + cls._is_leader = False + logger.warning(f'⚠️ Worker {cls._worker_id} 失去 Application 锁') + if cls._lock_lost_task: + cls._lock_lost_task.cancel() + cls._lock_lost_task = asyncio.create_task(cls._handle_lock_lost()) + + @classmethod + async def _handle_lock_lost(cls) -> None: + """ + 处理锁丢失后的资源释放 + + :return: None + """ + if cls._sync_listener_task: + cls._sync_listener_task.cancel() + try: + await cls._sync_listener_task + except asyncio.CancelledError: + pass + cls._sync_listener_task = None + if cls._sync_task: + cls._sync_task.cancel() + try: + await cls._sync_task + except asyncio.CancelledError: + pass + cls._sync_task = None + cls._sync_pending = False + if getattr(cls._scheduler, 'running', False): + cls._scheduler.shutdown() + cls._resources.dispose() + cls._ensure_reacquire_task() + + @classmethod + def _ensure_reacquire_task(cls) -> None: + """ + 启动锁重新竞争任务 + + :return: None + """ + if cls._is_closing or not cls._redis: + return + if cls._reacquire_task and not cls._reacquire_task.done(): + return + cls._reacquire_task = asyncio.create_task(cls._run_reacquire_loop()) + + @classmethod + def _get_reacquire_delay(cls) -> float: + """ + 获取带随机抖动的锁重新竞争间隔 + + :return: 重新竞争等待秒数 + """ + return cls._reacquire_interval_seconds + random.uniform(0, cls._reacquire_jitter_seconds) + + @classmethod + async def _run_reacquire_loop(cls) -> None: + """ + 循环尝试重新获取锁并恢复调度器 + + :return: None + """ + try: + while not cls._is_leader and not cls._is_closing: + await asyncio.sleep(cls._get_reacquire_delay()) + if cls._is_closing: + break + if not cls._redis: + continue + try: + acquired = await StartupUtil.acquire_application_leader( + redis=cls._redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + owner_token=cls.get_application_lock_owner_token(), + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + ) + except Exception as exc: + logger.error(f'❌ Application Leader 租约重新竞争失败:{exc}') + continue + if acquired: + try: + await cls._activate_scheduler_as_leader(cls._redis) + except Exception: + logger.exception('❌ 重新获得 Application Leader 租约后恢复 Scheduler 失败') + continue + return + except asyncio.CancelledError: + raise + finally: + cls._reacquire_task = None + + @classmethod + async def close_system_scheduler(cls) -> None: + """ + 应用关闭时关闭定时任务 + + :return: None + """ + cls._is_closing = True + await cls.stop_application_lock_renewal() + if cls._sync_listener_task: + cls._sync_listener_task.cancel() + try: + await cls._sync_listener_task + except asyncio.CancelledError: + pass + cls._sync_listener_task = None + if cls._sync_task: + cls._sync_task.cancel() + try: + await cls._sync_task + except asyncio.CancelledError: + pass + cls._sync_task = None + cls._sync_pending = False + if cls._reacquire_task: + cls._reacquire_task.cancel() + try: + await cls._reacquire_task + except asyncio.CancelledError: + pass + cls._reacquire_task = None + cls._resources.dispose() + if cls._lock_lost_task: + cls._lock_lost_task.cancel() + try: + await cls._lock_lost_task + except asyncio.CancelledError: + pass + cls._lock_lost_task = None + if getattr(cls._scheduler, 'running', False): + cls._scheduler.shutdown() + logger.info('⏹️ 关闭定时任务成功') + # 必须在Redis连接池关闭前,原子释放当前进程持有的Application leader租约 + redis = cls._redis + cls._redis = None + try: + if redis: + released = await StartupUtil.release_application_leader( + redis, + LockConstant.APP_STARTUP_LOCK_KEY, + cls.get_application_lock_owner_token(), + ) + if released: + logger.info(f'🔓 Worker {cls._worker_id} 释放 Application 锁') + finally: + cls._is_leader = False + + @classmethod + async def request_scheduler_sync( + cls, + job_ids: set[int] | None = None, + *, + immediate: bool = False, + ) -> dict[str, Any]: + """ + 请求调度器同步任务状态 + + :param job_ids: 指定受影响任务,None 表示完整校准 + :param immediate: Leader 是否在返回前完成本地同步 + :return: 已应用、待同步或失败的明确结果 + """ + if cls._is_leader and immediate: + async with cls._sync_lock: + # 等待同步锁期间可能失去租约,此时交由新 Leader 处理。 + if cls._is_leader: + result = await cls._sync_jobs_from_database(job_ids) + if cls._is_leader: + cls._last_sync_at = time.monotonic() + return result + if cls._is_leader: + cls._sync_pending = True + if job_ids is None: + cls._sync_full_pending = True + else: + cls._sync_pending_ids.update(job_ids) + cls._ensure_sync_task() + elif cls._redis: + try: + await cls._redis.publish( + cls._sync_channel, json.dumps({'jobIds': sorted(job_ids) if job_ids is not None else None}) + ) + except Exception: + # 数据库已提交,Leader 的周期同步负责重试。 + logger.exception('❌ 调度同步通知失败,将由 Leader 周期同步重试') + return SchedulerSynchronizer.result( + [{'jobId': job_id, 'syncStatus': 'pending'} for job_id in sorted(job_ids or [])], default='pending' + ) + + @classmethod + def _ensure_sync_task(cls) -> None: + """ + 启动同步调度任务 + + :return: None + """ + if cls._sync_task and not cls._sync_task.done(): + return + cls._sync_task = asyncio.create_task(cls._run_sync_loop()) + + @classmethod + async def _run_sync_loop(cls) -> None: + """ + 执行同步调度循环 + + :return: None + """ + try: + while True: + if not cls._sync_pending: + break + await asyncio.sleep(cls._sync_debounce_seconds) + await cls._sync_with_throttle() + except asyncio.CancelledError: + raise + finally: + cls._sync_task = None + + @classmethod + async def _sync_with_throttle(cls) -> None: + """ + 按节流规则执行同步 + + :return: None + """ + async with cls._sync_lock: + if not cls._is_leader: + return + if cls._last_sync_at: + elapsed = time.monotonic() - cls._last_sync_at + if elapsed < cls._sync_min_interval_seconds: + await asyncio.sleep(cls._sync_min_interval_seconds - elapsed) + if not cls._is_leader: + return + job_ids = None if cls._sync_full_pending else set(cls._sync_pending_ids) + cls._sync_pending = False + cls._sync_full_pending = False + cls._sync_pending_ids.clear() + await cls._sync_jobs_from_database(job_ids) + cls._last_sync_at = time.monotonic() + + @classmethod + async def _listen_sync_channel(cls, redis: aioredis.Redis) -> None: + """ + 监听同步请求通道 + + :param redis: Redis连接对象 + :return: None + """ + while True: + pubsub = redis.pubsub() + try: + await pubsub.subscribe(cls._sync_channel) + async for message in pubsub.listen(): + if not cls._is_leader: + continue + if message.get('type') != 'message': + continue + await cls._handle_sync_notification(message.get('data', '{}')) + except asyncio.CancelledError: + await pubsub.unsubscribe(cls._sync_channel) + await pubsub.close() + raise + except Exception as e: + logger.error(f'❌ Scheduler 同步监听异常:{e},5 秒后重试...') + await pubsub.close() + await asyncio.sleep(5) + finally: + try: + await pubsub.close() + except Exception: + pass + + @classmethod + async def _handle_sync_notification(cls, data: str | bytes) -> None: + """ + 解析唤醒通知;实际配置和执行请求均重新从数据库读取 + + :param data: 同步通知数据 + :return: None + """ + try: + payload = json.loads(data) + if not isinstance(payload, dict): + return + if payload.get('executions'): + await cls._drain_execution_requests() + if 'jobIds' in payload: + ids = payload['jobIds'] + if ids is None or (isinstance(ids, list) and all(type(item) is int for item in ids)): + await cls.request_scheduler_sync(set(ids) if ids is not None else None) + except (TypeError, ValueError): + logger.warning('⚠️ 忽略无效的任务同步通知') + + @classmethod + async def request_execution_dispatch(cls) -> None: + """ + 唤醒 Leader 处理持久化执行请求,通知失败由周期派发恢复 + + :return: None + """ + try: + if cls._is_leader: + await cls._drain_execution_requests() + elif cls._redis: + await cls._redis.publish(cls._sync_channel, json.dumps({'executions': True})) + except Exception: + logger.exception('❌ 执行请求已保存,唤醒派发失败,将由周期派发重试') + + @classmethod + def _configure_scheduler(cls) -> None: + """ + 配置当前调度器的任务存储、执行器和事件回调 + + :return: None + """ + cls._resources.configure(cls._scheduler, cls.scheduler_event_listener) + + @classmethod + async def _sync_jobs_from_database( + cls, job_ids: set[int] | None = None, *, raise_errors: bool = False + ) -> dict[str, Any]: + """ + 调用配置同步功能类校准数据库任务 + + :param job_ids: 指定任务ID,None表示完整校准 + :param raise_errors: 是否向调用方抛出完整扫描异常 + :return: 配置应用结果 + """ + return await cls._synchronizer.sync_jobs( + job_ids, is_leader=cls.is_application_leader, raise_errors=raise_errors + ) + + @classmethod + async def _drain_execution_requests(cls) -> None: + """ + 调用执行派发功能类处理已持久化的执行请求 + + :return: None + """ + await cls._dispatcher.dispatch_pending(is_leader=cls.is_application_leader) + + @classmethod + def scheduler_event_listener(cls, event: SchedulerEvent) -> None: + """ + 调用日志功能类记录任务执行事件 + + :param event: 调度器产生的执行事件 + :return: None + """ + cls._listener.handle_event(event) + + @staticmethod + def _prepare_scheduler_job_add(job_info: JobModel) -> dict[str, Any]: + """ + 校验任务配置并构造注册参数 + + :param job_info: 任务配置 + :return: 调度器任务注册参数 + """ + return SchedulerJobs.prepare_job(job_info) + + @classmethod + def get_scheduler_job(cls, job_id: str | int) -> Job | None: + """ + 获取当前进程的调度任务 + + :param job_id: 任务ID + :return: 调度任务,不存在时返回None + """ + return cls._jobs.get_job(job_id) + + @classmethod + def add_scheduler_job(cls, job_info: JobModel) -> None: + """ + 在Leader进程中注册任务 + + :param job_info: 任务配置 + :return: None + """ + if cls._is_leader: + cls._jobs.add_job(job_info) + + @classmethod + def remove_scheduler_job(cls, job_id: str | int) -> None: + """ + 在Leader进程中移除任务 + + :param job_id: 任务ID + :return: None + """ + if cls._is_leader: + cls._jobs.remove_job(job_id) + + @classmethod + def execute_scheduler_job_once(cls, job_info: JobModel, *, execution_id: str, dispatch_token: str) -> None: + """ + 在Leader进程中注册已领取的单次执行请求 + + :param job_info: 任务配置 + :param execution_id: 已持久化的执行请求ID + :param dispatch_token: 本次派发的领取凭据 + :return: None + """ + if not cls._is_leader: + raise RuntimeError('仅 Leader 可以派发已领取的执行请求') + cls._jobs.execute_once(job_info, execution_id=execution_id, dispatch_token=dispatch_token) diff --git a/ruoyi-fastapi-backend/config/scheduler/resources.py b/ruoyi-fastapi-backend/config/scheduler/resources.py new file mode 100644 index 000000000..093daacd4 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/resources.py @@ -0,0 +1,128 @@ +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from datetime import timezone + +from apscheduler.events import SchedulerEvent +from apscheduler.jobstores.memory import MemoryJobStore +from apscheduler.jobstores.redis import RedisJobStore +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from sqlalchemy.engine import Engine +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session, sessionmaker + +from config.database import DataSourceRegistry, create_sync_db_engine +from config.env import DataBaseConfig, RedisConfig +from config.scheduler.executors import TimedAsyncIOExecutor, TimedProcessPoolExecutor + +redis_config = { + 'host': RedisConfig.redis_host, + 'port': RedisConfig.redis_port, + 'username': RedisConfig.redis_username, + 'password': RedisConfig.redis_password, + 'db': RedisConfig.redis_database, +} +job_defaults = {'coalesce': False, 'max_instances': 1} + + +class SchedulerResources: + """ + 调度器存储、执行器和数据库资源管理 + """ + + def __init__(self) -> None: + """ + 初始化当前进程的调度资源引用,数据库引擎按需创建 + + :return: None + """ + self._jobstore_engine: Engine | None = None + self._listener_engine: Engine | None = None + self._session_local: sessionmaker[Session] | None = None + self._configured = False + self._disposed = False + + def jobstore_engine(self) -> Engine: + """ + 懒加载获取 jobstore 使用的同步 Engine + + :return: 同步 Engine + """ + if self._jobstore_engine is None: + # JobStore 使用独立 Engine,避免 APScheduler 关闭时释放 Registry 共享的 Engine。 + self._jobstore_engine = create_sync_db_engine(echo=False, config=DataBaseConfig.get_source()) + return self._jobstore_engine + + def listener_engine(self) -> Engine: + """ + 懒加载获取 listener 使用的同步 Engine + + :return: 同步 Engine + """ + if self._listener_engine is None: + self._listener_engine = DataSourceRegistry.get_sync_engine(DataBaseConfig.db_default_source) + return self._listener_engine + + def log_session_factory(self) -> sessionmaker[Session]: + """ + 懒加载获取同步 SessionLocal + + :return: SessionLocal + """ + if self._session_local is None: + self._session_local = sessionmaker( + autocommit=False, + autoflush=False, + bind=self.listener_engine(), + ) + return self._session_local + + def configure(self, scheduler: AsyncIOScheduler, on_event: Callable[[SchedulerEvent], None]) -> None: + """ + 配置 scheduler(懒加载 jobstore) + + :param scheduler: 当前进程的调度器 + :param on_event: 任务执行事件回调 + :return: None + """ + if self._configured: + return + self._disposed = False + job_stores = { + 'default': MemoryJobStore(), + 'sqlalchemy': SQLAlchemyJobStore(engine=self.jobstore_engine()), + 'redis': RedisJobStore(**redis_config), + } + executors = { + 'default': TimedAsyncIOExecutor(on_rejected=on_event, manage_executions=True), + 'processpool': TimedProcessPoolExecutor(5, on_rejected=on_event, manage_executions=True), + } + scheduler.configure(jobstores=job_stores, executors=executors, job_defaults=job_defaults, timezone=timezone.utc) + self._configured = True + + @staticmethod + def session() -> AbstractAsyncContextManager[AsyncSession]: + """ + 获取内部调度使用的异步 Session,过滤轮询和事务的常规SQL日志 + + :return: 异步 Session + """ + # 日志标记只作用于此会话的连接,不随异步任务上下文传播到业务任务。 + return DataSourceRegistry.session(DataBaseConfig.db_default_source, log_sql=False) + + def dispose(self) -> None: + """ + 释放 Scheduler 使用的同步 Engine + + :return: None + """ + if self._disposed: + return + self._configured = False + if self._jobstore_engine: + self._jobstore_engine.dispose() + self._jobstore_engine = None + # Listener 使用 Registry 共享的 Engine,此处只清理引用,避免影响其他服务。 + self._listener_engine = None + self._session_local = None + self._disposed = True diff --git a/ruoyi-fastapi-backend/config/scheduler/synchronization.py b/ruoyi-fastapi-backend/config/scheduler/synchronization.py new file mode 100644 index 000000000..d58dd07f0 --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/synchronization.py @@ -0,0 +1,159 @@ +from collections.abc import Callable +from typing import Any + +from config.scheduler.job_adapter import JobAdapter +from config.scheduler.jobs import SchedulerJobs +from config.scheduler.resources import SchedulerResources +from module_admin.dao.job_dao import JobDao +from module_admin.dao.job_runtime_dao import JobRuntimeDao +from utils.log_util import logger +from utils.time_util import TimezoneUtil + + +class SchedulerSynchronizer: + """ + 数据库任务配置校准和已应用版本管理 + """ + + def __init__(self, jobs: SchedulerJobs, resources: SchedulerResources) -> None: + """ + 初始化配置同步所需的任务管理器和数据库资源 + + :param jobs: 本地任务管理器 + :param resources: 调度数据库资源 + :return: None + """ + self.jobs = jobs + self.resources = resources + self.applied_jobs: dict[int, dict[str, Any]] = {} + + async def sync_jobs( + self, + job_ids: set[int] | None = None, + *, + is_leader: Callable[[], bool], + raise_errors: bool = False, + ) -> dict[str, Any]: + """ + 按最新数据库配置同步指定任务,未指定 ID 时执行完整校准。 + + :param job_ids: 需要同步的逻辑任务 ID,None 表示完整校准 + :param is_leader: 检查当前进程是否仍持有Leader租约 + :param raise_errors: 完整扫描失败时是否阻止调度器启动 + :return: 每个任务的应用结果及总体同步状态 + """ + if not is_leader(): + return self.result( + [{'jobId': job_id, 'syncStatus': 'pending'} for job_id in sorted(job_ids or [])], default='pending' + ) + full = job_ids is None + scanned_jobs = {} + pending_ids = set() + try: + if full: + async with self.resources.session() as session: + rows = await JobDao.get_all_job_list_for_scheduler(session) + scanned_jobs = {row.job_id: JobAdapter.from_record(row) for row in rows} + pending_ids = await JobRuntimeDao.pending_sync_ids(session) + job_ids = ( + set(scanned_jobs) + | pending_ids + | {int(job.id) for job in self.jobs.scheduler.get_jobs() if job.id.isdecimal()} + ) + except Exception as exc: + if raise_errors: + raise + logger.exception('❌ 读取任务同步配置失败') + return {'syncStatus': 'failed', 'jobs': [], 'syncError': str(exc)[:2000]} + results = [] + for job_id in sorted(job_ids): + if not is_leader(): + results.append({'jobId': job_id, 'syncStatus': 'pending'}) + continue + job_info = scanned_jobs.get(job_id) + cached = self.applied_jobs.get(job_id) + if full and job_info and cached and job_id not in pending_ids: + current = self.jobs.scheduler.get_job(str(job_id)) + try: + matches = ( + self.jobs.is_config_current(current, job_info) + if current and job_info.status == '0' + else (current is None and job_info.status != '0') + ) + if matches and cached.get('configHash') == JobAdapter.config_hash(job_info): + results.append({key: value for key, value in cached.items() if key != 'configHash'}) + continue + except Exception: + # 配置不再可用时进入单任务同步,持久化具体失败原因。 + pass + try: + results.append(await self.sync_job(job_id, is_leader=is_leader)) + except Exception as exc: + logger.exception(f'❌ 同步任务 {job_id} 的数据库操作失败') + results.append({'jobId': job_id, 'syncStatus': 'failed', 'syncError': str(exc)[:2000]}) + return self.result(results) + + async def sync_job(self, job_id: int, *, is_leader: Callable[[], bool]) -> dict[str, Any]: + """ + 锁定并应用一个任务的最新配置,独立保存成功或失败状态 + + :param job_id: 任务ID + :param is_leader: 检查当前进程是否仍持有Leader租约 + :return: 任务同步结果 + """ + async with self.resources.session() as session: + db_job, state = await JobRuntimeDao.lock_job(session, job_id) + if not is_leader(): + await session.commit() + return {'jobId': job_id, 'syncStatus': 'pending'} + job_info = JobAdapter.from_record(db_job) if db_job else None + try: + if job_info is None or job_info.status != '0': + for current in self.jobs.scheduler.get_jobs(): + if current.id == str(job_id): + self.jobs.scheduler.remove_job(current.id, jobstore=current._jobstore_alias) + self.jobs.forget_update_time(str(job_id)) + if job_info is None: + await JobRuntimeDao.cancel_unstarted(session, [job_id]) + else: + current = self.jobs.scheduler.get_job(str(job_id), jobstore=job_info.job_store) + if current is None or not self.jobs.is_config_current(current, job_info): + self.jobs.register_job(job_info) + # 清理旧存储中残留的同 ID 定义;独立手动请求不参与此过程。 + for other in self.jobs.scheduler.get_jobs(): + if other.id == str(job_id) and other._jobstore_alias != job_info.job_store: + self.jobs.scheduler.remove_job(other.id, jobstore=other._jobstore_alias) + self.jobs.record_update_time(str(job_id), job_info.update_time) + state.applied_version = state.config_version + state.sync_status = 'applied' + state.sync_error = None + state.applied_time = TimezoneUtil.utc_now() + current = self.jobs.scheduler.get_job(str(job_id)) + state.next_run_time = getattr(current, 'next_run_time', None) + state.schedule_observed_time = state.applied_time + except Exception as exc: + state.sync_status = 'failed' + state.sync_error = str(exc)[:2000] + state.next_run_time = None + state.schedule_observed_time = None + logger.exception(f'❌ 应用任务 {job_id} 的调度配置失败') + await session.commit() + result = JobRuntimeDao.state_result(state) + if state.sync_status == 'applied': + self.applied_jobs[job_id] = {**result, 'configHash': state.config_hash} + else: + self.applied_jobs.pop(job_id, None) + return result + + @staticmethod + def result(jobs: list[dict[str, Any]], *, default: str = 'applied') -> dict[str, Any]: + """ + 汇总逐任务结果,失败和待同步状态不会被当成全部生效 + + :param jobs: 各任务的同步结果 + :param default: 没有任务结果时使用的同步状态 + :return: 汇总后的同步结果 + """ + states = {job['syncStatus'] for job in jobs} + status = 'failed' if 'failed' in states else ('pending' if 'pending' in states else default) + return {'syncStatus': status, 'jobs': jobs} diff --git a/ruoyi-fastapi-backend/config/scheduler/triggers.py b/ruoyi-fastapi-backend/config/scheduler/triggers.py new file mode 100644 index 000000000..b91abea7e --- /dev/null +++ b/ruoyi-fastapi-backend/config/scheduler/triggers.py @@ -0,0 +1,83 @@ +from typing import Any + +from apscheduler.triggers.date import DateTrigger + + +class TaskDateTrigger(DateTrigger): + """ + 携带任务原始时区的单次触发器 + """ + + def __init__( + self, + task_timezone: str, + *, + task_job_id: int | None = None, + task_job_group: str = 'default', + execution_id: str | None = None, + dispatch_token: str | None = None, + **kwargs, + ) -> None: + """ + 初始化保留任务时区的单次触发器 + + :param task_timezone: 任务原始IANA时区名称 + :param task_job_id: 关联的逻辑任务ID + :param task_job_group: 业务分组快照 + :param execution_id: 执行ID + :param dispatch_token: 本次派发的领取凭据 + :param kwargs: 传递给DateTrigger的执行时刻及其他配置 + :return: None + """ + super().__init__(**kwargs) + self.task_timezone = task_timezone + self.task_job_id = task_job_id + self.task_job_group = task_job_group + self.execution_id = execution_id + self.dispatch_token = dispatch_token + + def __getstate__(self) -> dict[str, Any]: + """ + 序列化单次触发器及任务时区 + + :return: 可供持久化任务仓库存储的触发器状态 + """ + return { + **super().__getstate__(), + 'task_timezone': self.task_timezone, + 'task_job_id': self.task_job_id, + 'task_job_group': self.task_job_group, + 'execution_id': self.execution_id, + 'dispatch_token': self.dispatch_token, + } + + def __setstate__(self, state: dict[str, Any]) -> None: + """ + 从持久化状态恢复触发器和任务时区 + + :param state: 已序列化的触发器状态 + :return: None + """ + super().__setstate__(state) + self.task_timezone = state['task_timezone'] + self.task_job_id = state.get('task_job_id') + self.task_job_group = state.get('task_job_group', 'default') + self.execution_id = state.get('execution_id') + self.dispatch_token = state.get('dispatch_token') + + +def trigger_timezone(trigger: Any) -> str | None: + """ + 读取触发器携带的任务时区 + + :param trigger: Cron、单次或组合触发器 + :return: 任务时区名称,未携带时区时返回None + """ + if hasattr(trigger, 'task_timezone'): + return trigger.task_timezone + if hasattr(trigger, 'timezone'): + return str(trigger.timezone) + for child in getattr(trigger, 'triggers', ()): + if value := trigger_timezone(child): + return value + return None diff --git a/ruoyi-fastapi-backend/middlewares/context_middleware.py b/ruoyi-fastapi-backend/middlewares/context_middleware.py index 89c905454..4742c65d8 100644 --- a/ruoyi-fastapi-backend/middlewares/context_middleware.py +++ b/ruoyi-fastapi-backend/middlewares/context_middleware.py @@ -1,8 +1,9 @@ from fastapi import FastAPI, Request from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.responses import Response +from starlette.responses import JSONResponse, Response from common.context import RequestContext +from utils.time_util import TimezoneUtil class ContextCleanupMiddleware(BaseHTTPMiddleware): @@ -14,10 +15,21 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - """ 在每个请求处理完成后清理上下文信息 """ - response = await call_next(request) - # 请求处理完成后清理所有上下文变量 - RequestContext.clear_all() - return response + timezone_name = request.headers.get('X-Timezone') + try: + if timezone_name is not None: + timezone_name = TimezoneUtil.validate_timezone_name(timezone_name) + except ValueError: + return JSONResponse( + status_code=422, + content={'code': 422, 'msg': 'X-Timezone必须是有效的IANA时区', 'success': False}, + ) + token = RequestContext.set_current_timezone(timezone_name) + try: + return await call_next(request) + finally: + RequestContext.clear_all() + RequestContext.reset_current_timezone(token) def add_context_cleanup_middleware(app: FastAPI) -> None: diff --git a/ruoyi-fastapi-backend/module_admin/controller/job_controller.py b/ruoyi-fastapi-backend/module_admin/controller/job_controller.py index 80d46c3c4..749123d5c 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/job_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/job_controller.py @@ -5,7 +5,7 @@ from pydantic_validation_decorator import ValidateFields from sqlalchemy.ext.asyncio import AsyncSession -from common.annotation.cache_annotation import ApiCache, ApiCacheEvict +from common.annotation.cache_annotation import ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset from common.aspect.db_session import DBSessionDependency @@ -15,7 +15,15 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from module_admin.entity.vo.job_runtime_vo import ( + JobExecutionModel, + JobExecutionQueryModel, + JobMutationResult, + JobSyncModel, + JobSyncQueryModel, +) from module_admin.entity.vo.job_vo import ( + ChangeJobStatusModel, DeleteJobLogModel, DeleteJobModel, EditJobModel, @@ -23,19 +31,45 @@ JobLogPageQueryModel, JobModel, JobPageQueryModel, + JobPreviewRequest, + JobPreviewResult, + JobRunModel, ) from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.service.job_log_service import JobLogService from module_admin.service.job_service import JobService from utils.common_util import bytes2file_response +from utils.cron_util import CronUtil from utils.log_util import logger from utils.response_util import ResponseUtil +from utils.time_util import TimezoneUtil job_controller = APIRouterPro( prefix='/monitor', order_num=13, tags=['系统监控-定时任务'], dependencies=[PreAuthDependency()] ) +@job_controller.post( + '/job/preview', + summary='预览定时任务执行时刻接口', + description=( + '根据 Quartz Cron 表达式和任务 IANA 时区计算未来执行时刻。' + 'timeZone 省略时使用系统业务时区,startTime 省略时使用服务端当前时刻;' + 'count 默认为 5,支持 1~20。返回 UTC 起算时刻及执行时刻列表,无后续匹配时返回空列表。' + ), + response_model=DataResponseModel[JobPreviewResult], + dependencies=[UserInterfaceAuthDependency(['monitor:job:add', 'monitor:job:edit'])], +) +async def preview_system_job(request: Request, preview: JobPreviewRequest) -> Response: + start_time = preview.start_time or TimezoneUtil.utc_now() + result = JobPreviewResult( + timeZone=preview.time_zone, + startTime=start_time, + nextRunTimes=CronUtil.next_run_times(preview.cron_expression, preview.time_zone, start_time, preview.count), + ) + return ResponseUtil.success(data=result) + + @job_controller.get( '/job/list', summary='获取定时任务分页列表接口', @@ -43,7 +77,6 @@ response_model=PageResponseModel[JobModel], dependencies=[UserInterfaceAuthDependency('monitor:job:list')], ) -@ApiCache(namespace=ApiNamespace.MONITOR_JOB_LIST) async def get_system_job_list( request: Request, job_page_query: Annotated[JobPageQueryModel, Query()], @@ -60,7 +93,7 @@ async def get_system_job_list( '/job', summary='新增定时任务接口', description='用于新增定时任务', - response_model=ResponseBaseModel, + response_model=DataResponseModel[JobMutationResult], dependencies=[UserInterfaceAuthDependency('monitor:job:add')], ) @ValidateFields(validate_model='add_job') @@ -77,14 +110,14 @@ async def add_system_job( add_job_result = await JobService.add_job_services(query_db, add_job) logger.info(add_job_result.message) - return ResponseUtil.success(msg=add_job_result.message) + return ResponseUtil.success(msg=add_job_result.message, data=add_job_result.result) @job_controller.put( '/job', summary='编辑定时任务接口', description='用于编辑定时任务', - response_model=ResponseBaseModel, + response_model=DataResponseModel[JobMutationResult], dependencies=[UserInterfaceAuthDependency('monitor:job:edit')], ) @ValidateFields(validate_model='edit_job') @@ -100,21 +133,21 @@ async def edit_system_job( edit_job_result = await JobService.edit_job_services(query_db, edit_job) logger.info(edit_job_result.message) - return ResponseUtil.success(msg=edit_job_result.message) + return ResponseUtil.success(msg=edit_job_result.message, data=edit_job_result.result) @job_controller.put( '/job/changeStatus', summary='修改定时任务状态接口', description='用于修改定时任务状态', - response_model=ResponseBaseModel, + response_model=DataResponseModel[JobMutationResult], dependencies=[UserInterfaceAuthDependency('monitor:job:changeStatus')], ) @ApiCacheEvict(namespaces=ApiGroup.JOB_MUTATION) @Log(title='定时任务', business_type=BusinessType.UPDATE) async def change_system_job_status( request: Request, - change_job: EditJobModel, + change_job: ChangeJobStatusModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: @@ -127,14 +160,14 @@ async def change_system_job_status( edit_job_result = await JobService.edit_job_services(query_db, edit_job) logger.info(edit_job_result.message) - return ResponseUtil.success(msg=edit_job_result.message) + return ResponseUtil.success(msg=edit_job_result.message, data=edit_job_result.result) @job_controller.put( '/job/run', summary='执行定时任务接口', description='用于执行指定的定时任务', - response_model=ResponseBaseModel, + response_model=DataResponseModel[JobExecutionModel], dependencies=[UserInterfaceAuthDependency('monitor:job:changeStatus')], ) @ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_RUN, preset=ApiRateLimitPreset.USER_RESOURCE_EXECUTION) @@ -142,20 +175,104 @@ async def change_system_job_status( @Log(title='定时任务', business_type=BusinessType.UPDATE) async def execute_system_job( request: Request, - execute_job: JobModel, + execute_job: JobRunModel, query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: - execute_job_result = await JobService.execute_job_once_services(query_db, execute_job) + execute_job_result = await JobService.execute_job_once_services( + query_db, + execute_job, + requested_by=current_user.user.user_name, + ) logger.info(execute_job_result.message) - return ResponseUtil.success(msg=execute_job_result.message) + return ResponseUtil.success(msg=execute_job_result.message, data=execute_job_result.result) + + +@job_controller.get( + '/job/execution/list', + summary='获取定时任务执行记录分页列表接口', + description=( + '按任务ID、执行ID、执行状态和执行来源(manual 手动、cron 定时)分页查询执行记录,' + '返回执行结果或未执行原因、计划/开始/结束时刻及耗时。时间字段统一以 UTC 返回,' + '支持查询已删除任务保留的执行记录。' + ), + response_model=PageResponseModel[JobExecutionModel], + dependencies=[UserInterfaceAuthDependency('monitor:job:query')], +) +async def get_system_job_executions( + request: Request, + query: Annotated[JobExecutionQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + result = await JobService.execution_list_services(query_db, query) + return ResponseUtil.success(model_content=result) + + +@job_controller.get( + '/job/execution/{execution_id}', + summary='获取定时任务执行记录详情接口', + description=( + '根据 32 位小写十六进制执行ID查询单次执行的当前状态、执行结果或未执行原因及 UTC 时间信息。' + '任务删除后仍可通过执行ID追踪;执行记录不存在时返回业务错误。' + ), + response_model=DataResponseModel[JobExecutionModel], + dependencies=[UserInterfaceAuthDependency('monitor:job:query')], +) +async def get_system_job_execution( + request: Request, + execution_id: Annotated[str, Path(min_length=32, max_length=32, pattern='^[0-9a-f]{32}$', description='执行ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + result = await JobService.execution_detail_services(query_db, execution_id) + return ResponseUtil.success(data=result) + + +@job_controller.get( + '/job/sync/list', + summary='获取定时任务调度同步状态分页列表接口', + description=( + '按任务ID和同步状态(pending 待同步、applied 已生效、failed 同步失败)分页查询调度同步记录,' + '返回最新配置版本、已应用版本、最近同步错误、删除标记及 UTC 应用/更新时间,' + '包含任务删除后保留的同步记录。' + ), + response_model=PageResponseModel[JobSyncModel], + dependencies=[UserInterfaceAuthDependency('monitor:job:query')], +) +async def get_system_job_sync_states( + request: Request, + query: Annotated[JobSyncQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + result = await JobService.sync_list_services(query_db, query) + return ResponseUtil.success(model_content=result) + + +@job_controller.post( + '/job/sync/{job_id}', + summary='重试定时任务调度同步接口', + description=( + '根据任务ID重新请求应用已保存的最新任务配置或删除操作,返回提交状态、同步状态及任务同步结果。' + '以 syncStatus 判断调度是否生效:pending 待同步、applied 已生效、failed 同步失败;' + '失败原因见 syncError。同步记录不存在时返回业务错误。' + ), + response_model=DataResponseModel[JobMutationResult], + dependencies=[UserInterfaceAuthDependency('monitor:job:edit')], +) +async def retry_system_job_sync( + request: Request, + job_id: Annotated[int, Path(gt=0, description='任务ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + result = await JobService.retry_sync_services(query_db, job_id) + return ResponseUtil.success(msg=result.message, data=result.result) @job_controller.delete( '/job/{job_ids}', summary='删除定时任务接口', description='用于删除定时任务', - response_model=ResponseBaseModel, + response_model=DataResponseModel[JobMutationResult], dependencies=[UserInterfaceAuthDependency('monitor:job:remove')], ) @ApiRateLimit(namespace=ApiNamespace.MONITOR_JOB_DELETE, preset=ApiRateLimitPreset.USER_DESTRUCTIVE_MUTATION) @@ -170,7 +287,7 @@ async def delete_system_job( delete_job_result = await JobService.delete_job_services(query_db, delete_job) logger.info(delete_job_result.message) - return ResponseUtil.success(msg=delete_job_result.message) + return ResponseUtil.success(msg=delete_job_result.message, data=delete_job_result.result) @job_controller.get( @@ -180,7 +297,6 @@ async def delete_system_job( response_model=DataResponseModel[JobModel], dependencies=[UserInterfaceAuthDependency('monitor:job:query')], ) -@ApiCache(namespace=ApiNamespace.MONITOR_JOB_DETAIL) async def query_detail_system_job( request: Request, job_id: Annotated[int, Path(description='任务ID')], diff --git a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py index a3cdfc453..428e3dece 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta +from datetime import timedelta from typing import Annotated from fastapi import Depends, Request, Response @@ -29,6 +29,7 @@ from utils.jwt_util import JwtUtil from utils.log_util import logger from utils.response_util import ResponseUtil +from utils.time_util import TimezoneUtil login_controller = APIRouterPro(order_num=1, tags=['登录模块']) @@ -85,7 +86,7 @@ async def login( ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), ) await UserService.edit_user_services( - query_db, EditUserModel(userId=result[0].user_id, loginDate=datetime.now(), type='status') + query_db, EditUserModel(userId=result[0].user_id, loginDate=TimezoneUtil.utc_now(), type='status') ) logger.info('登录成功') # 判断请求是否来自于api文档,如果是返回指定格式的结果,用于修复api文档认证成功后token显示undefined的bug diff --git a/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py b/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py index 781d21c26..a9b67f117 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py @@ -26,12 +26,6 @@ ) @ApiRateLimit(namespace=ApiNamespace.TRANSPORT_CRYPTO_FRONTEND_CONFIG, preset=ApiRateLimitPreset.ANON_PUBLIC_METADATA) async def get_transport_frontend_config(request: Request) -> Response: - """ - 获取当前前端传输层加解密运行配置 - - :param request: 当前请求对象 - :return: 前端传输层加解密运行配置响应 - """ transport_frontend_config = await TransportCryptoService.get_transport_frontend_config_services() logger.info('获取成功') @@ -46,12 +40,6 @@ async def get_transport_frontend_config(request: Request) -> Response: ) @ApiRateLimit(namespace=ApiNamespace.TRANSPORT_CRYPTO_PUBLIC_KEY, preset=ApiRateLimitPreset.ANON_PUBLIC_METADATA) async def get_transport_public_key(request: Request) -> Response: - """ - 获取当前传输层加密公钥 - - :param request: 当前请求对象 - :return: 公钥下发响应 - """ transport_public_key = await TransportCryptoService.get_transport_public_key_services() logger.info('获取成功') @@ -66,12 +54,6 @@ async def get_transport_public_key(request: Request) -> Response: dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('monitor:transportCrypto:list')], ) async def get_transport_crypto_monitor_info(request: Request) -> Response: - """ - 获取基于Redis聚合的传输层加解密监控信息 - - :param request: 当前请求对象 - :return: 传输层加解密监控信息响应 - """ transport_crypto_monitor_info = await TransportCryptoService.get_transport_crypto_monitor_info_services(request) logger.info('获取成功') diff --git a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py index 7c3de7c36..5c50a66e3 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py @@ -1,6 +1,6 @@ import os -from datetime import datetime from typing import Annotated, Literal +from zoneinfo import available_timezones import aiofiles from fastapi import File, Form, Path, Query, Request, Response, UploadFile @@ -41,6 +41,7 @@ UserRoleQueryModel, UserRoleResponseModel, UserRowModel, + UserTimezoneModel, ) from module_admin.service.dept_service import DeptService from module_admin.service.role_service import RoleService @@ -49,6 +50,7 @@ from utils.log_util import logger from utils.pwd_util import PwdUtil from utils.response_util import ResponseUtil +from utils.time_util import TimezoneUtil from utils.upload_util import UploadUtil user_controller = APIRouterPro( @@ -222,7 +224,7 @@ async def reset_system_user_pwd( edit_user = EditUserModel( userId=reset_user.user_id, password=PwdUtil.get_password_hash(reset_user.password), - pwdUpdateDate=datetime.now(), + pwdUpdateDate=TimezoneUtil.utc_now(), updateBy=current_user.user.user_name, type='pwd', ) @@ -327,15 +329,16 @@ async def change_system_user_profile_avatar( current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: if avatarfile: + business_now = TimezoneUtil.to_business_time(TimezoneUtil.utc_now()) relative_path = ( - f'avatar/{datetime.now().strftime("%Y")}/{datetime.now().strftime("%m")}/{datetime.now().strftime("%d")}' + f'avatar/{business_now.strftime("%Y")}/{business_now.strftime("%m")}/{business_now.strftime("%d")}' ) dir_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path) try: os.makedirs(dir_path) except FileExistsError: pass - avatar_name = f'avatar_{datetime.now().strftime("%Y%m%d%H%M%S")}{UploadConfig.UPLOAD_MACHINE}{UploadUtil.generate_random_number()}.png' + avatar_name = f'avatar_{business_now.strftime("%Y%m%d%H%M%S")}{UploadConfig.UPLOAD_MACHINE}{UploadUtil.generate_random_number()}.png' avatar_path = os.path.join(dir_path, avatar_name) async with aiofiles.open(avatar_path, 'wb') as f: await f.write(avatarfile) @@ -385,6 +388,43 @@ async def change_system_user_profile_info( return ResponseUtil.success(msg=edit_user_result.message) +@user_controller.get( + '/profile/timezones', + summary='获取可选显示时区列表接口', + description=( + '返回服务端支持的 IANA 时区名称列表,按名称排序并排除 localtime,供当前登录用户选择显示时区。' + '如需跟随设备时区,可在修改显示时区接口中提交 timeZone=auto。' + ), + response_model=DataResponseModel[list[str]], +) +async def get_system_user_timezones(request: Request) -> Response: + return ResponseUtil.success(data=sorted(available_timezones() - {'localtime'})) + + +@user_controller.put( + '/profile/timezone', + summary='修改当前用户显示时区接口', + description=( + '保存当前登录账号的显示时区偏好,timeZone 支持 auto(跟随设备)或有效的 IANA 时区名称。' + '更新成功后清理用户信息缓存并记录操作日志,返回保存结果;该偏好用于界面时间显示,' + '系统业务时区和定时任务时区由各自配置决定。' + ), + response_model=ResponseBaseModel, +) +@ApiCacheEvict(namespaces=ApiGroup.USER_INFO_MUTATION) +@Log(title='时区设置', business_type=BusinessType.UPDATE) +async def change_system_user_timezone( + request: Request, + preference: UserTimezoneModel, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], +) -> Response: + result = await UserService.update_user_timezone_services( + query_db, current_user.user.user_id, current_user.user.user_name, preference.time_zone + ) + return ResponseUtil.success(msg=result.message) + + @user_controller.put( '/profile/updatePwd', summary='修改用户密码接口', @@ -403,7 +443,7 @@ async def reset_system_user_password( userId=current_user.user.user_id, oldPassword=reset_password.old_password, password=reset_password.new_password, - pwdUpdateDate=datetime.now(), + pwdUpdateDate=TimezoneUtil.utc_now(), updateBy=current_user.user.user_name, ) await UserService.validate_password_services(request.app.state.redis, reset_user.password) diff --git a/ruoyi-fastapi-backend/module_admin/dao/config_dao.py b/ruoyi-fastapi-backend/module_admin/dao/config_dao.py index f3396cf8f..c12fbaa76 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/config_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/config_dao.py @@ -1,4 +1,3 @@ -from datetime import datetime, time from typing import Any from sqlalchemy import delete, select, update @@ -8,6 +7,7 @@ from module_admin.entity.do.config_do import SysConfig from module_admin.entity.vo.config_vo import ConfigModel, ConfigPageQueryModel from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class ConfigDao: @@ -64,18 +64,17 @@ async def get_config_list( :param is_page: 是否开启分页 :return: 参数配置列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysConfig) .where( SysConfig.config_name.like(f'%{query_object.config_name}%') if query_object.config_name else True, SysConfig.config_key.like(f'%{query_object.config_key}%') if query_object.config_key else True, SysConfig.config_type == query_object.config_type if query_object.config_type else True, - SysConfig.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysConfig.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysConfig.create_time < time_range[1] if time_range and time_range[1] is not None else True, ) .order_by(SysConfig.config_id) .distinct() diff --git a/ruoyi-fastapi-backend/module_admin/dao/dict_dao.py b/ruoyi-fastapi-backend/module_admin/dao/dict_dao.py index adb89c447..947fdd941 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/dict_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/dict_dao.py @@ -1,5 +1,4 @@ from collections.abc import Sequence -from datetime import datetime, time from typing import Any from sqlalchemy import and_, delete, func, select, update @@ -9,7 +8,7 @@ from module_admin.entity.do.dict_do import SysDictData, SysDictType from module_admin.entity.vo.dict_vo import DictDataModel, DictDataPageQueryModel, DictTypeModel, DictTypePageQueryModel from utils.page_util import PageUtil -from utils.time_format_util import list_format_datetime +from utils.time_util import TimezoneUtil class DictTypeDao: @@ -64,7 +63,7 @@ async def get_all_dict_type(cls, db: AsyncSession) -> list[Any]: """ dict_type_info = (await db.execute(select(SysDictType))).scalars().all() - return list_format_datetime(dict_type_info) + return list(dict_type_info) @classmethod async def get_dict_type_list( @@ -78,18 +77,17 @@ async def get_dict_type_list( :param is_page: 是否开启分页 :return: 字典类型列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysDictType) .where( SysDictType.dict_name.like(f'%{query_object.dict_name}%') if query_object.dict_name else True, SysDictType.dict_type.like(f'%{query_object.dict_type}%') if query_object.dict_type else True, SysDictType.status == query_object.status if query_object.status else True, - SysDictType.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysDictType.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysDictType.create_time < time_range[1] if time_range and time_range[1] is not None else True, ) .order_by(SysDictType.dict_id) .distinct() diff --git a/ruoyi-fastapi-backend/module_admin/dao/file_access_dao.py b/ruoyi-fastapi-backend/module_admin/dao/file_access_dao.py index c689ec31a..5431c706b 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/file_access_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/file_access_dao.py @@ -14,6 +14,7 @@ FileAccessLogPageQueryModel, ) from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class FileAclDao: @@ -347,6 +348,7 @@ async def get_file_access_log_list( :param is_page: 是否开启分页 :return: 文件访问审计列表 """ + time_range = TimezoneUtil.rfc3339_range_to_utc(query_object.begin_time, query_object.end_time) query = ( select(SysFileAccessLog) .where( @@ -354,12 +356,8 @@ async def get_file_access_log_list( SysFileAccessLog.action == query_object.action if query_object.action else True, SysFileAccessLog.result == query_object.result if query_object.result else True, SysFileAccessLog.actor_name.like(f'%{query_object.actor_name}%') if query_object.actor_name else True, - SysFileAccessLog.access_time.between( - datetime.strptime(query_object.begin_time, '%Y-%m-%d %H:%M:%S'), - datetime.strptime(query_object.end_time, '%Y-%m-%d %H:%M:%S'), - ) - if query_object.begin_time and query_object.end_time - else True, + SysFileAccessLog.access_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysFileAccessLog.access_time <= time_range[1] if time_range and time_range[1] is not None else True, ) .order_by(SysFileAccessLog.access_time.desc(), SysFileAccessLog.audit_id.desc()) ) diff --git a/ruoyi-fastapi-backend/module_admin/dao/file_business_dao.py b/ruoyi-fastapi-backend/module_admin/dao/file_business_dao.py index d84eae945..c8c744641 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/file_business_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/file_business_dao.py @@ -15,6 +15,7 @@ from module_admin.entity.do.user_do import SysUser from module_admin.entity.vo.file_vo import FileRetentionNoticePageQueryModel from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class FileReferenceDao: @@ -478,7 +479,7 @@ async def get_file_retention_notice_list( :param is_page: 是否开启分页 :return: 文件保留期限提醒列表 """ - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() reference_count = ( select(func.count(SysFileReference.reference_id)) .where(SysFileReference.file_id == SysFileInfo.file_id) diff --git a/ruoyi-fastapi-backend/module_admin/dao/file_info_dao.py b/ruoyi-fastapi-backend/module_admin/dao/file_info_dao.py index 615917447..f505e6537 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/file_info_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/file_info_dao.py @@ -23,6 +23,7 @@ FileStatsModel, ) from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class FileInfoDao: @@ -745,7 +746,7 @@ async def get_file_info_list( :param is_page: 是否开启分页 :return: 文件信息列表 """ - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() acl_summary = ( select( SysFileAcl.file_id.label('acl_file_id'), @@ -793,7 +794,7 @@ async def get_file_management_detail_by_id( :param file_data_scope_sql: 文件数据权限对应的查询sql语句 :return: 文件管理详情 """ - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() acl_summary = ( select( SysFileAcl.file_id.label('acl_file_id'), @@ -843,7 +844,7 @@ async def get_file_stats( :param file_data_scope_sql: 文件数据权限对应的查询sql语句 :return: 文件管理统计信息 """ - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() acl_expiring_time = current_time + timedelta(days=cls.ACL_EXPIRING_DAYS) acl_expiring_files = ( select(SysFileAcl.file_id) @@ -938,6 +939,7 @@ def _get_file_info_query_conditions( :return: 查询条件列表 """ expiring_time = current_time + timedelta(days=cls.FILE_EXPIRING_DAYS) + time_range = TimezoneUtil.rfc3339_range_to_utc(query_object.begin_time, query_object.end_time) expiration_condition: ColumnElement | bool = True if query_object.expiration_status == 'permanent': expiration_condition = SysFileInfo.expire_time.is_(None) @@ -960,12 +962,8 @@ def _get_file_info_query_conditions( else True, SysFileInfo.dept_id == query_object.dept_id if query_object.dept_id else True, expiration_condition, - SysFileInfo.create_time.between( - datetime.strptime(query_object.begin_time, '%Y-%m-%d %H:%M:%S'), - datetime.strptime(query_object.end_time, '%Y-%m-%d %H:%M:%S'), - ) - if query_object.begin_time and query_object.end_time - else True, + SysFileInfo.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysFileInfo.create_time <= time_range[1] if time_range and time_range[1] is not None else True, ] @classmethod diff --git a/ruoyi-fastapi-backend/module_admin/dao/job_dao.py b/ruoyi-fastapi-backend/module_admin/dao/job_dao.py index 71b04ea2e..77261f8bd 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/job_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/job_dao.py @@ -43,11 +43,6 @@ async def get_job_detail_by_info(cls, db: AsyncSession, job: JobModel) -> SysJob select(SysJob).where( SysJob.job_name == job.job_name, SysJob.job_group == job.job_group, - SysJob.job_executor == job.job_executor, - SysJob.invoke_target == job.invoke_target, - SysJob.job_args == job.job_args, - SysJob.job_kwargs == job.job_kwargs, - SysJob.cron_expression == job.cron_expression, ) ) ) @@ -77,7 +72,6 @@ async def get_job_list( SysJob.status == query_object.status if query_object.status else True, ) .order_by(SysJob.job_id) - .distinct() ) job_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( db, query, query_object.page_num, query_object.page_size, is_page @@ -93,7 +87,7 @@ async def get_job_list_for_scheduler(cls, db: AsyncSession) -> Sequence[SysJob]: :param db: orm对象 :return: 定时任务列表信息对象 """ - job_list = (await db.execute(select(SysJob).where(SysJob.status == '0').distinct())).scalars().all() + job_list = (await db.execute(select(SysJob).where(SysJob.status == '0'))).scalars().all() return job_list @@ -105,7 +99,7 @@ async def get_all_job_list_for_scheduler(cls, db: AsyncSession) -> Sequence[SysJ :param db: orm对象 :return: 定时任务列表信息对象 """ - job_list = (await db.execute(select(SysJob).distinct())).scalars().all() + job_list = (await db.execute(select(SysJob))).scalars().all() return job_list @@ -118,7 +112,22 @@ async def add_job_dao(cls, db: AsyncSession, job: JobModel) -> SysJob: :param job: 定时任务对象 :return: """ - db_job = SysJob(**job.model_dump(exclude={'create_time', 'update_time'})) + db_job = SysJob( + **job.model_dump( + exclude={ + 'create_time', + 'update_time', + 'cron_next_time', + 'next_run_time', + 'schedule_observed_time', + 'config_version', + 'applied_version', + 'sync_status', + 'sync_error', + 'applied_time', + } + ) + ) db.add(db_job) await db.flush() @@ -138,8 +147,6 @@ async def edit_job_dao(cls, db: AsyncSession, job: dict, old_job: JobModel) -> N update(SysJob) .where( SysJob.job_id == old_job.job_id, - SysJob.job_name == old_job.job_name, - SysJob.job_group == old_job.job_group, ) .values(**{key: value for key, value in job.items() if key not in {'create_time', 'update_time'}}) ) diff --git a/ruoyi-fastapi-backend/module_admin/dao/job_log_dao.py b/ruoyi-fastapi-backend/module_admin/dao/job_log_dao.py index 4269aafd1..4120258da 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/job_log_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/job_log_dao.py @@ -1,4 +1,3 @@ -from datetime import datetime, time from typing import Any from sqlalchemy import delete, desc, select @@ -9,6 +8,7 @@ from module_admin.entity.do.job_do import SysJobLog from module_admin.entity.vo.job_vo import JobLogModel, JobLogPageQueryModel from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class JobLogDao: @@ -28,21 +28,21 @@ async def get_job_log_list( :param is_page: 是否开启分页 :return: 定时任务日志列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysJobLog) .where( + SysJobLog.job_id == query_object.job_id if query_object.job_id is not None else True, + SysJobLog.execution_id == query_object.execution_id if query_object.execution_id else True, SysJobLog.job_name.like(f'%{query_object.job_name}%') if query_object.job_name else True, SysJobLog.job_group == query_object.job_group if query_object.job_group else True, SysJobLog.status == query_object.status if query_object.status else True, - SysJobLog.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysJobLog.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysJobLog.create_time < time_range[1] if time_range and time_range[1] is not None else True, ) .order_by(desc(SysJobLog.create_time)) - .distinct() ) job_log_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( db, query, query_object.page_num, query_object.page_size, is_page diff --git a/ruoyi-fastapi-backend/module_admin/dao/job_runtime_dao.py b/ruoyi-fastapi-backend/module_admin/dao/job_runtime_dao.py new file mode 100644 index 000000000..a8105e8bf --- /dev/null +++ b/ruoyi-fastapi-backend/module_admin/dao/job_runtime_dao.py @@ -0,0 +1,410 @@ +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as postgres_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from config.scheduler.job_adapter import JobAdapter +from module_admin.entity.do.job_do import SysJob +from module_admin.entity.do.job_runtime_do import SysJobExecution, SysJobSync +from module_admin.entity.vo.job_runtime_vo import JobExecutionQueryModel, JobSyncQueryModel +from module_admin.entity.vo.job_vo import JobModel +from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil + + +class JobRuntimeDao: + """ + 在业务事务内维护版本、删除记录和手动执行请求 + """ + + LEASE_SECONDS = 30 + + @staticmethod + async def ensure_state(db: AsyncSession, job_id: int) -> None: + """ + 原子建立状态行,避免两个事务首次接触同一任务时重复插入 + + :param db: orm对象 + :param job_id: 任务ID + :return: None + """ + values = { + 'job_id': job_id, + 'config_version': 0, + 'applied_version': 0, + 'config_hash': '', + 'deleted': False, + 'sync_status': 'pending', + } + dialect = db.get_bind().dialect.name + if dialect == 'mysql': + statement = mysql_insert(SysJobSync).values(**values).on_duplicate_key_update(job_id=SysJobSync.job_id) + elif dialect == 'postgresql': + statement = postgres_insert(SysJobSync).values(**values).on_conflict_do_nothing(index_elements=['job_id']) + elif dialect == 'sqlite': + statement = sqlite_insert(SysJobSync).values(**values).on_conflict_do_nothing(index_elements=['job_id']) + else: + raise ValueError(f'任务状态不支持数据库方言:{dialect}') + await db.execute(statement) + + @classmethod + async def lock_job(cls, db: AsyncSession, job_id: int) -> tuple[SysJob | None, SysJobSync | None]: + """ + 按同步状态、任务记录的固定顺序加锁,并为已有任务建立初始状态 + + :param db: orm对象 + :param job_id: 任务ID + :return: 任务记录和同步状态 + """ + await cls.ensure_state(db, job_id) + state = ( + await db.execute(select(SysJobSync).where(SysJobSync.job_id == job_id).with_for_update()) + ).scalar_one_or_none() + job = (await db.execute(select(SysJob).where(SysJob.job_id == job_id).with_for_update())).scalars().first() + state = await cls.record_configuration(db, job_id, JobAdapter.from_record(job) if job else None) + return job, state + + @classmethod + async def record_configuration(cls, db: AsyncSession, job_id: int, job: JobModel | None) -> SysJobSync: + """ + 在业务提交前记录最新配置版本,任务删除后仍保留同步状态 + + :param db: orm对象 + :param job_id: 任务ID + :param job: 任务对象信息 + :return: 最新任务同步状态 + """ + await cls.ensure_state(db, job_id) + state = ( + await db.execute(select(SysJobSync).where(SysJobSync.job_id == job_id).with_for_update()) + ).scalar_one_or_none() + digest = JobAdapter.config_hash(job) + if state is None: + state = SysJobSync( + job_id=job_id, + config_version=1, + applied_version=0, + config_hash=digest, + deleted=job is None, + sync_status='pending', + ) + db.add(state) + elif state.config_hash != digest: + state.config_version += 1 + state.config_hash = digest + state.deleted = job is None + state.sync_status = 'pending' + state.sync_error = None + state.next_run_time = None + state.schedule_observed_time = None + await db.flush() + return state + + @staticmethod + def state_result(state: SysJobSync) -> dict[str, Any]: + """ + 构造可供接口返回的同步状态,避免暴露 ORM 对象 + + :param state: 任务同步状态对象 + :return: 任务同步状态字典 + """ + return { + 'jobId': state.job_id, + 'configVersion': state.config_version, + 'appliedVersion': state.applied_version, + 'syncStatus': state.sync_status, + 'syncError': state.sync_error, + 'deleted': state.deleted, + 'appliedTime': state.applied_time, + 'nextRunTime': state.next_run_time, + 'scheduleObservedTime': state.schedule_observed_time, + } + + @classmethod + async def get_states(cls, db: AsyncSession, job_ids: list[int]) -> dict[int, dict[str, Any]]: + """ + 批量读取任务同步状态,供列表和变更响应使用 + + :param db: orm对象 + :param job_ids: 任务ID集合 + :return: 任务ID与同步状态的映射 + """ + if not job_ids: + return {} + states = ( + ( + await db.execute( + select(SysJobSync).where(SysJobSync.job_id.in_(job_ids)).execution_options(populate_existing=True) + ) + ) + .scalars() + .all() + ) + return {state.job_id: cls.state_result(state) for state in states} + + @classmethod + async def all_sync_ids(cls, db: AsyncSession) -> set[int]: + """ + 收集任务与删除记录,支持完整校准和删除同步重试 + + :param db: orm对象 + :return: 需要校准的任务ID集合 + """ + job_ids = (await db.execute(select(SysJob.job_id))).scalars().all() + state_ids = ( + (await db.execute(select(SysJobSync.job_id).where(SysJobSync.sync_status != 'applied'))).scalars().all() + ) + return set(job_ids) | set(state_ids) + + @staticmethod + async def pending_sync_ids(db: AsyncSession) -> set[int]: + """ + 读取需要重试的同步状态,包括已经删除的任务 + + :param db: orm对象 + :return: 待同步任务ID集合 + """ + return set( + (await db.execute(select(SysJobSync.job_id).where(SysJobSync.sync_status != 'applied'))).scalars().all() + ) + + @staticmethod + async def cancel_unstarted(db: AsyncSession, job_ids: list[int]) -> None: + """ + 删除任务时取消未开始的手动请求,保留运行中的执行记录 + + :param db: orm对象 + :param job_ids: 任务ID集合 + :return: None + """ + await db.execute( + update(SysJobExecution) + .where(SysJobExecution.job_id.in_(job_ids), SysJobExecution.status.in_(['pending', 'submitted'])) + .values( + status='cancelled', + message='任务已删除,取消尚未开始的执行', + end_time=None, + owner_token=None, + lease_until=None, + ) + ) + + @classmethod + async def create_request(cls, db: AsyncSession, job: JobModel, requested_by: str | None) -> SysJobExecution: + """ + 在事务内保存手动请求及配置快照,派发必须发生在提交之后 + + :param db: orm对象 + :param job: 任务对象信息 + :param requested_by: 手动执行提交者 + :return: 新建的手动执行请求 + """ + request = SysJobExecution( + execution_id=uuid4().hex, + job_id=job.job_id, + source='manual', + status='pending', + job_snapshot=job.model_dump(mode='json', by_alias=True), + requested_by=requested_by, + ) + db.add(request) + await db.flush() + return request + + @staticmethod + async def get_execution(db: AsyncSession, execution_id: str) -> SysJobExecution | None: + """ + 按执行 ID 查询手动或定时执行结果 + + :param db: orm对象 + :param execution_id: 执行ID + :return: 执行记录,不存在时返回None + """ + return await db.get(SysJobExecution, execution_id) + + @staticmethod + async def execution_page(db: AsyncSession, query: JobExecutionQueryModel) -> PageModel: + """ + 分页查询执行状态,排除配置参数和内部占用凭据 + + :param db: orm对象 + :param query: 查询参数对象 + :return: 执行记录分页信息 + """ + fields = [ + getattr(SysJobExecution, name) + for name in ( + 'execution_id', + 'job_id', + 'source', + 'status', + 'message', + 'requested_by', + 'create_time', + 'scheduled_time', + 'start_time', + 'end_time', + 'run_duration_ms', + ) + ] + statement = ( + select(*fields, SysJobExecution.job_snapshot['jobName'].as_string().label('job_name')) + .where( + SysJobExecution.job_id == query.job_id if query.job_id is not None else True, + SysJobExecution.execution_id == query.execution_id if query.execution_id else True, + SysJobExecution.status == query.status if query.status else True, + SysJobExecution.source == query.source if query.source else True, + ) + .order_by(SysJobExecution.create_time.desc(), SysJobExecution.execution_id.desc()) + ) + return await PageUtil.paginate(db, statement, query.page_num, query.page_size, is_page=True) + + @staticmethod + async def sync_page(db: AsyncSession, query: JobSyncQueryModel) -> PageModel: + """ + 分页查询同步状态,删除任务后仍能查看结果和发起重试 + + :param db: orm对象 + :param query: 查询参数对象 + :return: 同步状态分页信息 + """ + fields = [ + getattr(SysJobSync, name) + for name in ( + 'job_id', + 'config_version', + 'applied_version', + 'sync_status', + 'sync_error', + 'deleted', + 'applied_time', + 'update_time', + ) + ] + statement = ( + select(*fields) + .where( + SysJobSync.job_id == query.job_id if query.job_id is not None else True, + SysJobSync.sync_status == query.sync_status if query.sync_status else True, + ) + .order_by(SysJobSync.update_time.desc(), SysJobSync.job_id.desc()) + ) + return await PageUtil.paginate(db, statement, query.page_num, query.page_size, is_page=True) + + @staticmethod + def execution_result(execution: SysJobExecution) -> dict[str, Any]: + """ + 返回执行状态和必要的时间信息,不返回调用参数快照 + + :param execution: 执行记录对象 + :return: 执行状态及时间信息 + """ + return { + 'executionId': execution.execution_id, + 'jobId': execution.job_id, + 'jobName': execution.job_snapshot.get('jobName'), + 'source': execution.source, + 'status': execution.status, + 'message': execution.message, + 'requestedBy': execution.requested_by, + 'createTime': execution.create_time, + 'scheduledTime': execution.scheduled_time, + 'startTime': execution.start_time, + 'endTime': execution.end_time, + 'runDurationMs': execution.run_duration_ms, + } + + @staticmethod + async def recover_expired(db: AsyncSession) -> None: + """ + 重试尚未开始的过期派发;已开始的过期执行标记未知,禁止自动重放 + + :param db: orm对象 + :return: None + """ + now = TimezoneUtil.utc_now() + await db.execute( + update(SysJobExecution) + .where(SysJobExecution.status == 'submitted', SysJobExecution.lease_until < now) + .values(status='pending', owner_token=None, lease_until=None, message='派发租约过期,等待重新派发') + ) + await db.execute( + update(SysJobExecution) + .where(SysJobExecution.status == 'running', SysJobExecution.lease_until < now) + .values(status='unknown', message='执行租约过期,结果待确认;未自动重试') + ) + + @staticmethod + async def pending_ids(db: AsyncSession, limit: int = 50) -> list[tuple[str, int]]: + """ + 按提交顺序读取一批待派发执行,不持有跨任务的行锁 + + :param db: orm对象 + :param limit: 本次读取的最大请求数 + :return: 执行ID和任务ID列表 + """ + rows = ( + await db.execute( + select(SysJobExecution.execution_id, SysJobExecution.job_id) + .where(SysJobExecution.status == 'pending') + .order_by(SysJobExecution.create_time, SysJobExecution.execution_id) + .limit(limit) + ) + ).all() + return [(row.execution_id, row.job_id) for row in rows] + + @classmethod + async def claim_request(cls, db: AsyncSession, execution_id: str, job_id: int) -> SysJobExecution | None: + """ + 原子领取尚未开始的手动请求,并绑定本次派发凭据 + + :param db: orm对象 + :param execution_id: 执行ID + :param job_id: 任务ID + :return: 已领取的执行请求,未领取时返回None + """ + job, _state = await cls.lock_job(db, job_id) + execution = ( + await db.execute( + select(SysJobExecution).where(SysJobExecution.execution_id == execution_id).with_for_update() + ) + ).scalar_one_or_none() + if execution is None or execution.status != 'pending': + return None + if job is None: + execution.status = 'cancelled' + execution.message = '任务已删除,取消尚未开始的执行' + execution.end_time = TimezoneUtil.utc_now() + return None + execution.status = 'submitted' + execution.owner_token = uuid4().hex + execution.lease_until = TimezoneUtil.utc_now() + timedelta(seconds=cls.LEASE_SECONDS) + execution.message = None + return execution + + @staticmethod + async def fail_dispatch(db: AsyncSession, execution_id: str, token: str, message: str) -> None: + """ + 记录确定未成功注册的派发失败,保留错误供用户查询 + + :param db: orm对象 + :param execution_id: 执行ID + :param token: 当前执行占用凭据 + :param message: 执行结果或未执行原因 + :return: None + """ + await db.execute( + update(SysJobExecution) + .where( + SysJobExecution.execution_id == execution_id, + SysJobExecution.owner_token == token, + SysJobExecution.status == 'submitted', + ) + .values(status='failed', message=message[:2000], end_time=TimezoneUtil.utc_now(), lease_until=None) + ) diff --git a/ruoyi-fastapi-backend/module_admin/dao/log_dao.py b/ruoyi-fastapi-backend/module_admin/dao/log_dao.py index 5ee30785b..853df9b35 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/log_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/log_dao.py @@ -1,4 +1,3 @@ -from datetime import datetime, time from typing import Any from sqlalchemy import asc, delete, desc, select @@ -9,7 +8,7 @@ from module_admin.entity.vo.log_vo import LogininforModel, LoginLogPageQueryModel, OperLogModel, OperLogPageQueryModel from utils.common_util import SnakeCaseUtil from utils.page_util import PageUtil -from utils.time_format_util import TimeFormatUtil +from utils.time_util import TimezoneUtil class OperationLogDao: @@ -37,6 +36,9 @@ async def get_operation_log_list( ) else: order_by_column = desc(SysOperLog.oper_time) + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysOperLog) .where( @@ -44,12 +46,8 @@ async def get_operation_log_list( SysOperLog.oper_name.like(f'%{query_object.oper_name}%') if query_object.oper_name else True, SysOperLog.business_type == query_object.business_type if query_object.business_type else True, SysOperLog.status == query_object.status if query_object.status else True, - SysOperLog.oper_time.between( - datetime.combine(TimeFormatUtil.parse_date(query_object.begin_time), time(00, 00, 00)), - datetime.combine(TimeFormatUtil.parse_date(query_object.end_time), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysOperLog.oper_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysOperLog.oper_time < time_range[1] if time_range and time_range[1] is not None else True, ) .distinct() .order_by(order_by_column) @@ -124,18 +122,17 @@ async def get_login_log_list( ) else: order_by_column = desc(SysLogininfor.login_time) + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysLogininfor) .where( SysLogininfor.ipaddr.like(f'%{query_object.ipaddr}%') if query_object.ipaddr else True, SysLogininfor.user_name.like(f'%{query_object.user_name}%') if query_object.user_name else True, SysLogininfor.status == query_object.status if query_object.status else True, - SysLogininfor.login_time.between( - datetime.combine(TimeFormatUtil.parse_date(query_object.begin_time), time(00, 00, 00)), - datetime.combine(TimeFormatUtil.parse_date(query_object.end_time), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysLogininfor.login_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysLogininfor.login_time < time_range[1] if time_range and time_range[1] is not None else True, ) .distinct() .order_by(order_by_column) diff --git a/ruoyi-fastapi-backend/module_admin/dao/notice_dao.py b/ruoyi-fastapi-backend/module_admin/dao/notice_dao.py index 180cd4ce7..db31bf895 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/notice_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/notice_dao.py @@ -1,4 +1,3 @@ -from datetime import datetime, time from typing import Any from sqlalchemy import and_, case, delete, func, or_, select, update @@ -13,6 +12,7 @@ from module_admin.entity.do.user_do import SysUser from module_admin.entity.vo.notice_vo import NoticeModel, NoticePageQueryModel, NoticeReadUserPageQueryModel from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class NoticeDao: @@ -70,18 +70,17 @@ async def get_notice_list( :param is_page: 是否开启分页 :return: 通知公告列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysNotice) .where( SysNotice.notice_title.like(f'%{query_object.notice_title}%') if query_object.notice_title else True, SysNotice.create_by.like(f'%{query_object.create_by}%') if query_object.create_by else True, SysNotice.notice_type == query_object.notice_type if query_object.notice_type else True, - SysNotice.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysNotice.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysNotice.create_time < time_range[1] if time_range and time_range[1] is not None else True, ) .order_by(SysNotice.notice_id.desc()) .distinct() @@ -197,7 +196,8 @@ async def add_notice_reads(cls, db: AsyncSession, user_id: int, notice_ids: list return values = [ - {'notice_id': notice_id, 'user_id': user_id, 'read_time': datetime.now()} for notice_id in unique_notice_ids + {'notice_id': notice_id, 'user_id': user_id, 'read_time': TimezoneUtil.utc_now()} + for notice_id in unique_notice_ids ] dialect_name = db.get_bind().dialect.name if dialect_name == 'mysql': diff --git a/ruoyi-fastapi-backend/module_admin/dao/role_dao.py b/ruoyi-fastapi-backend/module_admin/dao/role_dao.py index 3df42b181..3acc6ff4b 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/role_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/role_dao.py @@ -1,5 +1,4 @@ from collections.abc import Sequence -from datetime import datetime, time from typing import Any from sqlalchemy import ColumnElement, and_, delete, desc, func, select, update @@ -12,6 +11,7 @@ from module_admin.entity.do.user_do import SysUser, SysUserRole from module_admin.entity.vo.role_vo import RoleDeptModel, RoleMenuModel, RoleModel, RolePageQueryModel from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class RoleDao: @@ -142,6 +142,9 @@ async def get_role_list( :param is_page: 是否开启分页 :return: 角色列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysRole) .join(SysUserRole, SysUserRole.role_id == SysRole.role_id, isouter=True) @@ -153,12 +156,8 @@ async def get_role_list( SysRole.role_name.like(f'%{query_object.role_name}%') if query_object.role_name else True, SysRole.role_key.like(f'%{query_object.role_key}%') if query_object.role_key else True, SysRole.status == query_object.status if query_object.status else True, - SysRole.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysRole.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysRole.create_time < time_range[1] if time_range and time_range[1] is not None else True, data_scope_sql, ) .order_by(SysRole.role_sort) diff --git a/ruoyi-fastapi-backend/module_admin/dao/user_dao.py b/ruoyi-fastapi-backend/module_admin/dao/user_dao.py index 1a1f8f24d..6f65aaaef 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/user_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/user_dao.py @@ -1,5 +1,4 @@ from collections.abc import Sequence -from datetime import datetime, time from typing import Any from sqlalchemy import ColumnElement, and_, delete, desc, func, or_, select, update @@ -20,6 +19,7 @@ UserRoleQueryModel, ) from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil class UserDao: @@ -291,6 +291,9 @@ async def get_user_list( :param is_page: 是否开启分页 :return: 用户列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_time, query_object.end_time, timezone_name=TimezoneUtil.get_request_timezone() + ) query = ( select(SysUser, SysDept) .where( @@ -310,12 +313,8 @@ async def get_user_list( SysUser.phonenumber.like(f'%{query_object.phonenumber}%') if query_object.phonenumber else True, SysUser.status == query_object.status if query_object.status else True, SysUser.sex == query_object.sex if query_object.sex else True, - SysUser.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + SysUser.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + SysUser.create_time < time_range[1] if time_range and time_range[1] is not None else True, data_scope_sql, ) .join( diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/file_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/file_do.py index 899d28b98..6fe41b0e1 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/file_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/file_do.py @@ -1,9 +1,9 @@ -from datetime import datetime - -from sqlalchemy import CHAR, BigInteger, Column, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy import CHAR, BigInteger, Column, Index, Integer, String, Text, UniqueConstraint from common.mixin import AuditTimeMixin, CreateTimeMixin +from common.types import DbUtcDateTime from config.database import Base +from utils.time_util import TimezoneUtil class SysFileInfo(AuditTimeMixin, Base): @@ -53,8 +53,8 @@ class SysFileInfo(AuditTimeMixin, Base): status = Column(String(20), nullable=False, server_default='active', comment='文件状态') create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') - expire_time = Column(DateTime, nullable=True, comment='过期时间') - deleted_time = Column(DateTime, nullable=True, comment='移入回收站时间') + expire_time = Column(DbUtcDateTime(), nullable=True, comment='过期时间') + deleted_time = Column(DbUtcDateTime(), nullable=True, comment='移入回收站时间') del_flag = Column(CHAR(1), nullable=False, server_default='0', comment='删除标志') @@ -82,7 +82,7 @@ class SysFileReference(CreateTimeMixin, Base): business_type = Column(String(50), nullable=False, comment='业务类型') business_id = Column(String(64), nullable=False, comment='业务ID') business_name = Column(String(255), nullable=True, comment='业务名称') - retention_expire_time = Column(DateTime, nullable=True, comment='保留期限到期时间') + retention_expire_time = Column(DbUtcDateTime(), nullable=True, comment='保留期限到期时间') create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') @@ -126,10 +126,10 @@ class SysFileRetentionNotice(CreateTimeMixin, Base): notice_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='提醒ID') file_id = Column(String(36), nullable=False, comment='文件ID') notice_type = Column(String(20), nullable=False, comment='提醒类型') - expire_time = Column(DateTime, nullable=False, comment='文件过期时间') + expire_time = Column(DbUtcDateTime(), nullable=False, comment='文件过期时间') status = Column(CHAR(1), nullable=False, server_default='0', comment='状态(0未读 1已读 2已失效)') read_by = Column(String(64), nullable=True, server_default="''", comment='读取者') - read_time = Column(DateTime, nullable=True, comment='读取时间') + read_time = Column(DbUtcDateTime(), nullable=True, comment='读取时间') class SysFileAcl(CreateTimeMixin, Base): @@ -159,7 +159,7 @@ class SysFileAcl(CreateTimeMixin, Base): permission = Column(String(20), nullable=False, server_default='download', comment='权限类型') effect = Column(String(10), nullable=False, server_default='allow', comment='授权效果') include_children = Column(CHAR(1), nullable=False, server_default='0', comment='部门是否包含下级') - expire_time = Column(DateTime, nullable=True, comment='授权过期时间') + expire_time = Column(DbUtcDateTime(), nullable=True, comment='授权过期时间') create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') del_flag = Column(CHAR(1), nullable=False, server_default='0', comment='删除标志') @@ -189,7 +189,7 @@ class SysFileAccessLog(Base): bytes_sent = Column(BigInteger, nullable=False, server_default='0', comment='发送字节数') error_message = Column(String(500), nullable=True, server_default="''", comment='失败原因') operation_detail = Column(Text, nullable=True, comment='操作详情') - access_time = Column(DateTime, nullable=False, default=datetime.now, comment='访问时间') + access_time = Column(DbUtcDateTime(), nullable=False, default=TimezoneUtil.utc_now, comment='访问时间') class SysFileReconcileRun(Base): @@ -215,8 +215,8 @@ class SysFileReconcileRun(Base): new_issue_count = Column(BigInteger, nullable=False, server_default='0', comment='新增或重新出现异常数') resolved_issue_count = Column(BigInteger, nullable=False, server_default='0', comment='自动恢复异常数') started_by = Column(String(64), nullable=True, server_default="''", comment='发起人') - started_time = Column(DateTime, nullable=False, default=datetime.now, comment='开始时间') - finished_time = Column(DateTime, nullable=True, comment='完成时间') + started_time = Column(DbUtcDateTime(), nullable=False, default=TimezoneUtil.utc_now, comment='开始时间') + finished_time = Column(DbUtcDateTime(), nullable=True, comment='完成时间') error_message = Column(Text, nullable=True, comment='失败原因') @@ -253,10 +253,10 @@ class SysFileReconcileIssue(Base): status = Column(String(20), nullable=False, server_default='open', comment='处理状态') detail = Column(Text, nullable=True, comment='异常说明') occurrence_count = Column(Integer, nullable=False, server_default='1', comment='发现次数') - first_seen_time = Column(DateTime, nullable=False, default=datetime.now, comment='首次发现时间') - last_seen_time = Column(DateTime, nullable=False, default=datetime.now, comment='最近发现时间') + first_seen_time = Column(DbUtcDateTime(), nullable=False, default=TimezoneUtil.utc_now, comment='首次发现时间') + last_seen_time = Column(DbUtcDateTime(), nullable=False, default=TimezoneUtil.utc_now, comment='最近发现时间') handle_action = Column(String(32), nullable=True, comment='处理动作') handle_reason = Column(String(500), nullable=True, comment='处理原因') handled_by = Column(String(64), nullable=True, comment='处理人') - handled_time = Column(DateTime, nullable=True, comment='处理时间') + handled_time = Column(DbUtcDateTime(), nullable=True, comment='处理时间') quarantine_key = Column(String(500), nullable=True, comment='隔离区相对路径') diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/job_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/job_do.py index 66c5bf49c..fc9beb53d 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/job_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/job_do.py @@ -1,12 +1,20 @@ -from sqlalchemy import CHAR, BigInteger, Column, DateTime, String -from sqlalchemy.dialects.mysql import DATETIME -from sqlalchemy.dialects.postgresql import TIMESTAMP +from sqlalchemy import ( + CHAR, + JSON, + BigInteger, + Boolean, + CheckConstraint, + Column, + Index, + Integer, + String, + UniqueConstraint, +) from common.mixin import AuditTimeMixin, CreateTimeMixin +from common.types import DbUtcDateTime from config.database import Base -JOB_LOG_TIME_TYPE = DateTime().with_variant(DATETIME(fsp=3), 'mysql').with_variant(TIMESTAMP(precision=3), 'postgresql') - class SysJob(AuditTimeMixin, Base): """ @@ -14,24 +22,29 @@ class SysJob(AuditTimeMixin, Base): """ __tablename__ = 'sys_job' - __table_args__ = {'comment': '定时任务调度表'} + __table_args__ = ( + UniqueConstraint('job_group', 'job_name', name='uq_job_group_name'), + CheckConstraint('max_instances >= 1', name='ck_job_max_instances'), + CheckConstraint('misfire_grace_time IS NULL OR misfire_grace_time >= 1', name='ck_job_misfire_grace'), + {'comment': '定时任务调度表'}, + ) job_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='任务ID') - job_name = Column(String(64), primary_key=True, nullable=False, server_default="''", comment='任务名称') - job_group = Column(String(64), primary_key=True, nullable=False, server_default='default', comment='任务组名') + job_name = Column(String(64), nullable=False, comment='任务名称(同一业务分组内唯一)') + job_group = Column(String(64), nullable=False, server_default='default', comment='业务分组') + job_store = Column(String(64), nullable=False, server_default='default', comment='调度存储') job_executor = Column(String(64), nullable=True, server_default='default', comment='任务执行器') invoke_target = Column(String(500), nullable=False, comment='调用目标字符串') - job_args = Column(String(255), nullable=True, server_default="''", comment='位置参数') - job_kwargs = Column(String(255), nullable=True, server_default="''", comment='关键字参数') + job_args = Column(JSON, nullable=False, default=list, comment='位置参数(JSON数组)') + job_kwargs = Column(JSON, nullable=False, default=dict, comment='关键字参数(JSON对象)') cron_expression = Column(String(255), nullable=True, server_default="''", comment='cron执行表达式') - misfire_policy = Column( - String(20), - nullable=True, - server_default='3', - comment='计划执行错误策略(1立即执行 2执行一次 3放弃执行)', + time_zone = Column(String(64), nullable=False, comment='cron时区(IANA)') + misfire_grace_time = Column( + Integer().evaluates_none(), nullable=True, default=1, comment='允许延迟秒数,NULL表示不限' ) - concurrent = Column(CHAR(1), nullable=True, server_default='1', comment='是否并发执行(0允许 1禁止)') - status = Column(CHAR(1), nullable=True, server_default='0', comment='状态(0正常 1暂停)') + coalesce = Column(Boolean, nullable=False, default=False, comment='积压时是否只执行最近一次') + max_instances = Column(Integer, nullable=False, server_default='1', comment='任务最大并发数') + status = Column(CHAR(1), nullable=False, server_default='1', comment='状态(0正常 1暂停)') create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') remark = Column(String(500), nullable=True, server_default="''", comment='备注信息') @@ -43,18 +56,28 @@ class SysJobLog(CreateTimeMixin, Base): """ __tablename__ = 'sys_job_log' - __table_args__ = {'comment': '定时任务调度日志表'} + __table_args__ = ( + Index('ix_job_log_job_id', 'job_id', 'create_time'), + Index('ix_job_log_execution_id', 'execution_id'), + {'comment': '定时任务调度日志表'}, + ) job_log_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='任务日志ID') + job_id = Column(BigInteger, nullable=True, comment='逻辑任务ID,历史未关联日志可为空') + execution_id = Column(String(32), nullable=True, comment='执行ID,历史未关联日志可为空') job_name = Column(String(64), nullable=False, comment='任务名称') job_group = Column(String(64), nullable=False, comment='任务组名') + job_store = Column(String(64), nullable=True, comment='调度存储快照') job_executor = Column(String(64), nullable=False, comment='任务执行器') invoke_target = Column(String(500), nullable=False, comment='调用目标字符串') - job_args = Column(String(255), nullable=True, server_default="''", comment='位置参数') - job_kwargs = Column(String(255), nullable=True, server_default="''", comment='关键字参数') + job_args = Column(JSON, nullable=True, comment='位置参数快照(JSON数组)') + job_kwargs = Column(JSON, nullable=True, comment='关键字参数快照(JSON对象)') job_trigger = Column(String(255), nullable=True, server_default="''", comment='任务触发器') + time_zone = Column(String(64), nullable=True, comment='任务时区快照(IANA)') job_message = Column(String(500), nullable=True, comment='日志信息') status = Column(CHAR(1), nullable=True, server_default='0', comment='执行状态(0正常 1失败)') exception_info = Column(String(2000), nullable=True, server_default="''", comment='异常信息') - start_time = Column(JOB_LOG_TIME_TYPE, nullable=True, comment='执行开始时间') - end_time = Column(JOB_LOG_TIME_TYPE, nullable=True, comment='执行结束时间') + scheduled_time = Column(DbUtcDateTime(), nullable=True, comment='计划执行时刻') + start_time = Column(DbUtcDateTime(), nullable=True, comment='执行开始时间') + end_time = Column(DbUtcDateTime(), nullable=True, comment='执行结束时间') + run_duration_ms = Column(BigInteger, nullable=True, comment='实际执行耗时(毫秒)') diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/job_runtime_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/job_runtime_do.py new file mode 100644 index 000000000..501c5530d --- /dev/null +++ b/ruoyi-fastapi-backend/module_admin/entity/do/job_runtime_do.py @@ -0,0 +1,55 @@ +from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text + +from common.mixin import AuditTimeMixin +from common.types import DbUtcDateTime +from config.database import Base + + +class SysJobSync(AuditTimeMixin, Base): + """ + 任务配置版本和应用状态,删除任务后保留删除同步记录 + """ + + __tablename__ = 'sys_job_sync' + __table_args__ = ( + Index('ix_job_sync_status', 'sync_status', 'update_time'), + {'comment': '任务调度同步状态'}, + ) + + job_id = Column(BigInteger, primary_key=True, autoincrement=False, comment='逻辑任务ID') + config_version = Column(BigInteger, nullable=False, default=1, comment='最新配置版本') + applied_version = Column(BigInteger, nullable=False, default=0, comment='已应用版本') + config_hash = Column(String(64), nullable=False, comment='最新配置摘要') + deleted = Column(Boolean, nullable=False, default=False, comment='任务是否已删除') + sync_status = Column(String(16), nullable=False, default='pending', comment='pending/applied/failed') + sync_error = Column(String(2000), nullable=True, comment='最近同步错误') + applied_time = Column(DbUtcDateTime(), nullable=True, comment='最近应用时刻') + next_run_time = Column(DbUtcDateTime(), nullable=True, comment='最近观测的实际下次调度时刻') + schedule_observed_time = Column(DbUtcDateTime(), nullable=True, comment='Leader调度观测时刻') + + +class SysJobExecution(AuditTimeMixin, Base): + """ + 手动执行请求及定时执行记录,运行记录同时承担跨进程并发占用 + """ + + __tablename__ = 'sys_job_execution' + __table_args__ = ( + Index('ix_job_execution_dispatch', 'status', 'create_time'), + Index('ix_job_execution_active', 'job_id', 'status'), + {'comment': '任务执行请求与状态'}, + ) + + execution_id = Column(String(32), primary_key=True, comment='执行ID') + job_id = Column(BigInteger, nullable=False, comment='逻辑任务ID') + source = Column(String(10), nullable=False, comment='manual/cron') + status = Column(String(16), nullable=False, default='pending', comment='执行状态') + job_snapshot = Column(JSON, nullable=False, comment='提交时任务配置快照') + owner_token = Column(String(64), nullable=True, comment='派发或执行占用凭据') + lease_until = Column(DbUtcDateTime(), nullable=True, comment='执行占用租约截止时刻') + scheduled_time = Column(DbUtcDateTime(), nullable=True, comment='计划执行时刻') + start_time = Column(DbUtcDateTime(), nullable=True, comment='实际开始时刻') + end_time = Column(DbUtcDateTime(), nullable=True, comment='实际结束时刻') + run_duration_ms = Column(BigInteger, nullable=True, comment='实际执行耗时(毫秒)') + message = Column(Text, nullable=True, comment='执行结果或未执行原因') + requested_by = Column(String(64), nullable=True, comment='手动执行提交者') diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/log_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/log_do.py index e9bcb53d0..bd2a9afab 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/log_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/log_do.py @@ -1,8 +1,8 @@ -from datetime import datetime - -from sqlalchemy import CHAR, BigInteger, Column, DateTime, Index, Integer, String +from sqlalchemy import CHAR, BigInteger, Column, Index, Integer, String +from common.types import DbUtcDateTime from config.database import Base +from utils.time_util import TimezoneUtil class SysLogininfor(Base): @@ -21,7 +21,7 @@ class SysLogininfor(Base): os = Column(String(50), nullable=True, server_default="''", comment='操作系统') status = Column(CHAR(1), nullable=True, server_default='0', comment='登录状态(0成功 1失败)') msg = Column(String(255), nullable=True, server_default="''", comment='提示消息') - login_time = Column(DateTime, nullable=True, default=datetime.now, comment='访问时间') + login_time = Column(DbUtcDateTime(), nullable=True, default=TimezoneUtil.utc_now, comment='访问时间') idx_sys_logininfor_s = Index('idx_sys_logininfor_s', status) idx_sys_logininfor_lt = Index('idx_sys_logininfor_lt', login_time) @@ -52,7 +52,7 @@ class SysOperLog(Base): json_result = Column(String(2000), nullable=True, server_default="''", comment='返回参数') status = Column(Integer, nullable=True, server_default='0', comment='操作状态(0正常 1异常)') error_msg = Column(String(2000), nullable=True, server_default="''", comment='错误消息') - oper_time = Column(DateTime, nullable=True, default=datetime.now, comment='操作时间') + oper_time = Column(DbUtcDateTime(), nullable=True, default=TimezoneUtil.utc_now, comment='操作时间') cost_time = Column(BigInteger, nullable=True, server_default='0', comment='消耗时间') idx_sys_oper_log_bt = Index('idx_sys_oper_log_bt', business_type) diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py index 9565ad354..bec2589d1 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py @@ -1,12 +1,12 @@ -from datetime import datetime - -from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, LargeBinary, String, UniqueConstraint +from sqlalchemy import CHAR, BigInteger, Column, Integer, LargeBinary, String, UniqueConstraint from sqlalchemy.dialects import mysql from common.mixin import AuditTimeMixin +from common.types import DbUtcDateTime from config.database import Base from config.env import DataBaseConfig from utils.common_util import SqlalchemyUtil +from utils.time_util import TimezoneUtil class SysNotice(AuditTimeMixin, Base): @@ -57,4 +57,4 @@ class SysNoticeRead(Base): ) notice_id = Column(Integer, nullable=False, comment='公告ID') user_id = Column(BigInteger, nullable=False, comment='用户ID') - read_time = Column(DateTime, nullable=False, default=datetime.now, comment='阅读时间') + read_time = Column(DbUtcDateTime(), nullable=False, default=TimezoneUtil.utc_now, comment='阅读时间') diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py index 4a7fb904a..4b95e40b8 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py @@ -1,6 +1,7 @@ -from sqlalchemy import CHAR, BigInteger, Column, DateTime, String +from sqlalchemy import CHAR, BigInteger, Column, String from common.mixin import AuditTimeMixin +from common.types import DbUtcDateTime from config.database import Base from config.env import DataBaseConfig from utils.common_util import SqlalchemyUtil @@ -28,12 +29,13 @@ class SysUser(AuditTimeMixin, Base): phonenumber = Column(String(11), nullable=True, server_default="''", comment='手机号码') sex = Column(CHAR(1), nullable=True, server_default='0', comment='用户性别(0男 1女 2未知)') avatar = Column(String(100), nullable=True, server_default="''", comment='头像地址') + time_zone = Column(String(64), nullable=False, server_default='auto', comment='显示时区(auto跟随设备或IANA名称)') password = Column(String(100), nullable=True, server_default="''", comment='密码') status = Column(CHAR(1), nullable=True, server_default='0', comment='帐号状态(0正常 1停用)') del_flag = Column(CHAR(1), nullable=True, server_default='0', comment='删除标志(0代表存在 2代表删除)') login_ip = Column(String(128), nullable=True, server_default="''", comment='最后登录IP') - login_date = Column(DateTime, nullable=True, comment='最后登录时间') - pwd_update_date = Column(DateTime, nullable=True, comment='密码最后更新时间') + login_date = Column(DbUtcDateTime(), nullable=True, comment='最后登录时间') + pwd_update_date = Column(DbUtcDateTime(), nullable=True, comment='密码最后更新时间') create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') remark = Column( diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/config_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/config_vo.py index e6a62ae67..5b2745d7e 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/config_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/config_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class ConfigModel(BaseModel): """ @@ -19,9 +21,9 @@ class ConfigModel(BaseModel): config_value: str | None = Field(default=None, description='参数键值') config_type: Literal['Y', 'N'] | None = Field(default=None, description='系统内置(Y是 N否)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @NotBlank(field_name='config_key', message='参数名称不能为空') @@ -45,7 +47,7 @@ def validate_fields(self) -> None: self.get_config_value() -class ConfigQueryModel(ConfigModel): +class ConfigQueryModel(DateRangeQueryMixin, ConfigModel): """ 参数配置管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/dept_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/dept_vo.py index 640377fea..7f6326b0b 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/dept_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/dept_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_validation_decorator import Network, NotBlank, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class DeptModel(BaseModel): """ @@ -24,9 +26,9 @@ class DeptModel(BaseModel): status: Literal['0', '1'] | None = Field(default=None, description='部门状态(0正常 1停用)') del_flag: Literal['0', '2'] | None = Field(default=None, description='删除标志(0代表存在 2代表删除)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') @NotBlank(field_name='dept_name', message='部门名称不能为空') @Size(field_name='dept_name', min_length=0, max_length=30, message='部门名称长度不能超过30个字符') @@ -53,7 +55,7 @@ def validate_fields(self) -> None: self.get_email() -class DeptQueryModel(DeptModel): +class DeptQueryModel(DateRangeQueryMixin, DeptModel): """ 部门管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/dict_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/dict_vo.py index 787e00f6a..ec164c69d 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/dict_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/dict_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Pattern, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class DictTypeModel(BaseModel): """ @@ -18,9 +20,9 @@ class DictTypeModel(BaseModel): dict_type: str | None = Field(default=None, description='字典类型') status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1停用)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @NotBlank(field_name='dict_name', message='字典名称不能为空') @@ -60,9 +62,9 @@ class DictDataModel(BaseModel): is_default: Literal['Y', 'N'] | None = Field(default=None, description='是否默认(Y是 N否)') status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1停用)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @NotBlank(field_name='dict_label', message='字典标签不能为空') @@ -91,7 +93,7 @@ def validate_fields(self) -> None: self.get_css_class() -class DictTypeQueryModel(DictTypeModel): +class DictTypeQueryModel(DateRangeQueryMixin, DictTypeModel): """ 字典类型管理不分页查询模型 """ @@ -119,7 +121,7 @@ class DeleteDictTypeModel(BaseModel): dict_ids: str = Field(description='需要删除的字典主键') -class DictDataQueryModel(DictDataModel): +class DictDataQueryModel(DateRangeQueryMixin, DictDataModel): """ 字典数据管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/file_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/file_vo.py index d7dca6433..d1dc6978a 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/file_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/file_vo.py @@ -1,9 +1,11 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from common.mixin import InstantRangeQueryMixin +from common.types import ApiUtcDateTime + FileReconcileAction = Literal[ 'ignore', 'reopen', @@ -44,11 +46,11 @@ class FileInfoModel(BaseModel): file_hash: str = Field(min_length=64, max_length=64, description='文件SHA-256') status: Literal['active', 'deleted', 'purging'] = Field(default='active', description='文件状态') create_by: str | None = Field(default=None, max_length=64, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, max_length=64, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') - expire_time: datetime | None = Field(default=None, description='过期时间') - deleted_time: datetime | None = Field(default=None, description='移入回收站时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') + expire_time: ApiUtcDateTime | None = Field(default=None, description='过期时间') + deleted_time: ApiUtcDateTime | None = Field(default=None, description='移入回收站时间') del_flag: Literal['0', '1'] = Field(default='0', description='删除标志') @@ -59,7 +61,7 @@ class FileInfoDisplayModel(FileInfoModel): owner_name: str | None = Field(default=None, description='所有者用户名称') dept_name: str | None = Field(default=None, description='所属部门名称') - acl_nearest_expire_time: datetime | None = Field(default=None, description='最近ACL过期时间') + acl_nearest_expire_time: ApiUtcDateTime | None = Field(default=None, description='最近ACL过期时间') acl_entry_count: int = Field(default=0, ge=0, description='ACL配置数量') reference_count: int = Field(default=0, ge=0, description='业务引用数量') storage_status: Literal['normal', 'missing', 'quarantined', 'invalid'] = Field( @@ -79,9 +81,9 @@ class FileReferenceModel(BaseModel): business_type: str = Field(description='业务类型') business_id: str = Field(description='业务ID') business_name: str | None = Field(default=None, description='业务名称') - retention_expire_time: datetime | None = Field(default=None, description='保留期限到期时间') + retention_expire_time: ApiUtcDateTime | None = Field(default=None, description='保留期限到期时间') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') legacy: bool = Field(default=False, description='是否为文件主表兼容引用') @@ -97,9 +99,9 @@ class FileRetentionPolicyModel(BaseModel): status: Literal['0', '1'] = Field(default='0', description='状态(0启用 1停用)') remark: str | None = Field(default=None, max_length=500, description='备注') create_by: str | None = Field(default=None, max_length=64, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, max_length=64, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') class FileRetentionNoticeModel(BaseModel): @@ -115,11 +117,11 @@ class FileRetentionNoticeModel(BaseModel): owner_name: str | None = Field(default=None, description='所有者用户名称') dept_name: str | None = Field(default=None, description='所属部门名称') notice_type: Literal['expiring', 'expired'] = Field(description='提醒类型') - expire_time: datetime = Field(description='文件过期时间') + expire_time: ApiUtcDateTime = Field(description='文件过期时间') status: Literal['0', '1'] = Field(description='状态(0未读 1已读)') - create_time: datetime = Field(description='创建时间') + create_time: ApiUtcDateTime = Field(description='创建时间') read_by: str | None = Field(default=None, description='读取者') - read_time: datetime | None = Field(default=None, description='读取时间') + read_time: ApiUtcDateTime | None = Field(default=None, description='读取时间') reference_count: int = Field(default=0, ge=0, description='业务引用数量') can_dispose: bool = Field(default=False, description='是否允许到期处置') @@ -163,7 +165,7 @@ class ExtendFileRetentionModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, str_strip_whitespace=True) - expire_time: datetime = Field(description='新的到期时间') + expire_time: ApiUtcDateTime = Field(description='新的到期时间') reason: str = Field(min_length=1, max_length=500, description='延期原因') @@ -208,7 +210,7 @@ class FileAccessLogModel(BaseModel): bytes_sent: int = Field(default=0, description='发送字节数') error_message: str | None = Field(default=None, description='失败原因') operation_detail: str | None = Field(default=None, description='操作详情') - access_time: datetime | None = Field(default=None, description='访问时间') + access_time: ApiUtcDateTime | None = Field(default=None, description='访问时间') class FileAclModel(BaseModel): @@ -226,9 +228,9 @@ class FileAclModel(BaseModel): permission: Literal['download'] = Field(default='download', description='权限类型') effect: Literal['allow', 'deny'] = Field(description='授权效果') include_children: bool = Field(default=False, description='部门是否包含下级') - expire_time: datetime | None = Field(default=None, description='授权过期时间') + expire_time: ApiUtcDateTime | None = Field(default=None, description='授权过期时间') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') class FileAclItemModel(BaseModel): @@ -242,7 +244,7 @@ class FileAclItemModel(BaseModel): subject_id: int = Field(gt=0, description='主体ID') effect: Literal['allow', 'deny'] = Field(description='授权效果') include_children: bool = Field(default=False, description='部门是否包含下级') - expire_time: datetime | None = Field(default=None, description='授权过期时间') + expire_time: ApiUtcDateTime | None = Field(default=None, description='授权过期时间') class FileAclBuiltinPermissionModel(BaseModel): @@ -309,7 +311,7 @@ class FileAclSubjectOptionModel(BaseModel): dept_id: int | None = Field(default=None, description='所属部门ID') -class FileInfoQueryModel(BaseModel): +class FileInfoQueryModel(InstantRangeQueryMixin, BaseModel): """ 文件信息管理不分页查询模型 """ @@ -356,7 +358,7 @@ class FileStatsModel(BaseModel): acl_expiring_count: int = Field(default=0, ge=0, description='ACL即将过期文件数') -class FileAccessLogQueryModel(BaseModel): +class FileAccessLogQueryModel(InstantRangeQueryMixin, BaseModel): """ 文件访问审计不分页查询模型 """ @@ -433,8 +435,8 @@ class FileReconcileRunModel(BaseModel): new_issue_count: int = Field(default=0, ge=0, description='新增或重新出现异常数') resolved_issue_count: int = Field(default=0, ge=0, description='自动恢复异常数') started_by: str | None = Field(default=None, description='发起人') - started_time: datetime = Field(description='开始时间') - finished_time: datetime | None = Field(default=None, description='完成时间') + started_time: ApiUtcDateTime = Field(description='开始时间') + finished_time: ApiUtcDateTime | None = Field(default=None, description='完成时间') error_message: str | None = Field(default=None, description='失败原因') @@ -489,12 +491,12 @@ class FileReconcileIssueModel(BaseModel): status: Literal['open', 'ignored', 'quarantined', 'resolved'] = Field(description='处理状态') detail: str | None = Field(default=None, description='异常说明') occurrence_count: int = Field(default=1, ge=1, description='发现次数') - first_seen_time: datetime = Field(description='首次发现时间') - last_seen_time: datetime = Field(description='最近发现时间') + first_seen_time: ApiUtcDateTime = Field(description='首次发现时间') + last_seen_time: ApiUtcDateTime = Field(description='最近发现时间') handle_action: str | None = Field(default=None, description='处理动作') handle_reason: str | None = Field(default=None, description='处理原因') handled_by: str | None = Field(default=None, description='处理人') - handled_time: datetime | None = Field(default=None, description='处理时间') + handled_time: ApiUtcDateTime | None = Field(default=None, description='处理时间') quarantine_key: str | None = Field(default=None, description='隔离区相对路径') available_actions: list[FileReconcileAction] = Field(default_factory=list, description='可用处理动作') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/job_runtime_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/job_runtime_vo.py new file mode 100644 index 000000000..5b1727589 --- /dev/null +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/job_runtime_vo.py @@ -0,0 +1,97 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from common.types import ApiUtcDateTime + + +class JobExecutionModel(BaseModel): + """ + 可供管理界面查询的执行状态,不包含执行占用凭据 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + execution_id: str = Field(description='执行ID') + job_id: int = Field(description='任务ID') + job_name: str | None = Field(default=None, description='任务名称') + source: Literal['manual', 'cron'] = Field(description='执行来源(manual手动 cron定时)') + status: Literal[ + 'pending', 'submitted', 'running', 'success', 'failed', 'rejected', 'missed', 'cancelled', 'unknown' + ] = Field(description='执行状态') + message: str | None = Field(default=None, description='执行结果或未执行原因') + requested_by: str | None = Field(default=None, description='手动执行提交者') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') + scheduled_time: ApiUtcDateTime | None = Field(default=None, description='计划执行时刻') + start_time: ApiUtcDateTime | None = Field(default=None, description='实际执行开始时刻') + end_time: ApiUtcDateTime | None = Field(default=None, description='实际执行结束时刻') + run_duration_ms: int | None = Field(default=None, description='实际执行耗时(毫秒)') + + +class JobExecutionQueryModel(BaseModel): + """ + 执行记录分页查询条件 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + job_id: int | None = Field(default=None, gt=0, description='任务ID') + execution_id: str | None = Field( + default=None, min_length=32, max_length=32, pattern='^[0-9a-f]{32}$', description='执行ID' + ) + status: str | None = Field(default=None, description='执行状态') + source: Literal['manual', 'cron'] | None = Field(default=None, description='执行来源(manual手动 cron定时)') + page_num: int = Field(default=1, ge=1, description='当前页码') + page_size: int = Field(default=10, ge=1, le=100, description='每页记录数') + + +class JobSyncModel(BaseModel): + """ + 单任务配置版本和实际应用状态 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + job_id: int = Field(description='任务ID') + config_version: int = Field(description='最新配置版本') + applied_version: int = Field(description='已应用配置版本') + sync_status: Literal['pending', 'applied', 'failed'] = Field( + description='调度同步状态(pending待同步 applied已生效 failed同步失败)' + ) + sync_error: str | None = Field(default=None, description='最近同步错误') + deleted: bool = Field(description='任务是否已删除') + applied_time: ApiUtcDateTime | None = Field(default=None, description='最近应用时刻') + next_run_time: ApiUtcDateTime | None = Field(default=None, description='最近观测的实际下次调度时刻') + schedule_observed_time: ApiUtcDateTime | None = Field(default=None, description='Leader调度观测时刻') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') + + +class JobSyncQueryModel(BaseModel): + """ + 包含删除记录的同步状态分页查询条件 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + job_id: int | None = Field(default=None, gt=0, description='任务ID') + sync_status: Literal['pending', 'applied', 'failed'] | None = Field( + default=None, description='调度同步状态(pending待同步 applied已生效 failed同步失败)' + ) + page_num: int = Field(default=1, ge=1, description='当前页码') + page_size: int = Field(default=10, ge=1, le=100, description='每页记录数') + + +class JobMutationResult(BaseModel): + """ + 业务修改已保存与调度配置已生效分开表达的响应数据 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + saved: bool = Field(description='业务修改是否已提交') + sync_status: Literal['pending', 'applied', 'failed'] = Field( + description='调度同步状态(pending待同步 applied已生效 failed同步失败)' + ) + sync_error: str | None = Field(default=None, description='最近同步错误') + jobs: list[JobSyncModel] = Field(description='各任务的调度同步结果') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/job_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/job_vo.py index 403221d78..db77f30ce 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/job_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/job_vo.py @@ -1,10 +1,15 @@ -from datetime import datetime -from typing import Literal +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime +from config.env import AppConfig +from utils.cron_util import MyCronTrigger +from utils.time_util import TimezoneUtil + class JobModel(BaseModel): """ @@ -13,24 +18,89 @@ class JobModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) - job_id: int | None = Field(default=None, description='任务ID') - job_name: str | None = Field(default=None, description='任务名称') - job_group: str | None = Field(default=None, description='任务组名') - job_executor: str | None = Field(default=None, description='任务执行器') + job_id: int | None = Field(default=None, gt=0, description='任务ID') + job_name: str | None = Field(default=None, max_length=64, description='任务名称(同一业务分组内唯一)') + job_group: str = Field(default='default', min_length=1, max_length=64, description='业务分组') + job_store: Literal['default', 'sqlalchemy', 'redis'] = Field(default='default', description='调度存储') + job_executor: Literal['default', 'processpool'] = Field(default='default', description='任务执行器') invoke_target: str | None = Field(default=None, description='调用目标字符串') - job_args: str | None = Field(default=None, description='位置参数') - job_kwargs: str | None = Field(default=None, description='关键字参数') + job_args: list[JsonValue] = Field(default_factory=list, description='位置参数(JSON数组)') + job_kwargs: dict[str, JsonValue] = Field(default_factory=dict, description='关键字参数(JSON对象)') cron_expression: str | None = Field(default=None, description='cron执行表达式') - misfire_policy: Literal['1', '2', '3'] | None = Field( - default=None, description='计划执行错误策略(1立即执行 2执行一次 3放弃执行)' + time_zone: str = Field(default_factory=lambda: AppConfig.app_timezone, description='cron时区(IANA)') + misfire_grace_time: int | None = Field( + default=1, ge=1, le=2147483647, strict=True, description='允许延迟秒数,null表示不限' ) - concurrent: Literal['0', '1'] | None = Field(default=None, description='是否并发执行(0允许 1禁止)') - status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1暂停)') + coalesce: bool = Field(default=False, strict=True, description='积压时是否只执行最近一次') + max_instances: int = Field(default=1, ge=1, le=2147483647, strict=True, description='任务最大并发数') + status: Literal['0', '1'] = Field(default='1', description='状态(0正常 1暂停),新增默认暂停') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注信息') + cron_next_time: ApiUtcDateTime | None = Field( + default=None, description='Cron理论预览时刻(UTC)', json_schema_extra={'readOnly': True} + ) + next_run_time: ApiUtcDateTime | None = Field( + default=None, description='最近观测的实际下次调度时刻(UTC)', json_schema_extra={'readOnly': True} + ) + schedule_observed_time: ApiUtcDateTime | None = Field( + default=None, description='调度观测时刻(UTC)', json_schema_extra={'readOnly': True} + ) + config_version: int | None = Field(default=None, description='最新配置版本', json_schema_extra={'readOnly': True}) + applied_version: int | None = Field( + default=None, description='已应用配置版本', json_schema_extra={'readOnly': True} + ) + sync_status: Literal['pending', 'applied', 'failed'] | None = Field( + default=None, + description='调度同步状态', + json_schema_extra={'readOnly': True}, + ) + sync_error: str | None = Field(default=None, description='最近同步错误', json_schema_extra={'readOnly': True}) + applied_time: ApiUtcDateTime | None = Field( + default=None, + description='最近应用时刻', + json_schema_extra={'readOnly': True}, + ) + + @model_validator(mode='before') + @classmethod + def reject_legacy_policy(cls, value: Any) -> Any: + """ + 拒绝已替换的策略字段,避免旧调用方的配置被静默忽略 + + :param value: 接口传入的任务数据 + :return: 使用当前字段的任务数据 + """ + if isinstance(value, dict) and {'misfirePolicy', 'misfire_policy', 'concurrent'} & value.keys(): + raise ValueError('请使用 misfireGraceTime、coalesce 和 maxInstances 配置执行策略') + return value + + @field_validator('time_zone') + @classmethod + def validate_time_zone(cls, value: str) -> str: + """ + 校验任务IANA时区名称 + + :param value: 任务时区名称 + :return: 去除首尾空白后的有效时区名称 + """ + return TimezoneUtil.validate_timezone_name(value) + + @field_validator('cron_expression') + @classmethod + def validate_cron_expression(cls, value: str | None) -> str | None: + """ + 校验Quartz表达式语法 + + :param value: 待校验的Cron表达式 + :return: 去除首尾空白后的表达式,未填写时保留None + """ + if value is not None: + MyCronTrigger.from_crontab(value, 'UTC') + return value.strip() + return value @NotBlank(field_name='invoke_target', message='调用目标字符串不能为空') @Size(field_name='invoke_target', min_length=0, max_length=500, message='调用目标字符串长度不能超过500个字符') @@ -47,6 +117,54 @@ def validate_fields(self) -> None: self.get_cron_expression() +class JobPreviewRequest(BaseModel): + """ + Cron执行时刻预览请求模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + cron_expression: str = Field(min_length=1, max_length=255, description='Cron 表达式') + time_zone: str = Field(default_factory=lambda: AppConfig.app_timezone, description='任务 IANA 时区') + start_time: ApiUtcDateTime | None = Field(default=None, description='起算时刻,省略时使用服务端当前时刻') + count: int = Field(default=5, ge=1, le=20, description='返回的执行时刻数量') + + @field_validator('time_zone') + @classmethod + def validate_time_zone(cls, value: str) -> str: + """ + 校验任务IANA时区名称 + + :param value: 任务时区名称 + :return: 去除首尾空白后的有效时区名称 + """ + return TimezoneUtil.validate_timezone_name(value) + + @field_validator('cron_expression') + @classmethod + def validate_cron_expression(cls, value: str) -> str: + """ + 校验Quartz表达式语法 + + :param value: 待校验的Cron表达式 + :return: 去除首尾空白后的表达式 + """ + MyCronTrigger.from_crontab(value, 'UTC') + return value.strip() + + +class JobPreviewResult(BaseModel): + """ + Cron执行时刻预览结果模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + time_zone: str = Field(description='任务IANA时区名称') + start_time: ApiUtcDateTime = Field(description='本次预览的UTC起算时刻') + next_run_times: list[ApiUtcDateTime] = Field(description='未来UTC执行时刻列表') + + class JobLogModel(BaseModel): """ 定时任务调度日志表对应pydantic模型 @@ -55,26 +173,37 @@ class JobLogModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) job_log_id: int | None = Field(default=None, description='任务日志ID') + job_id: int | None = Field(default=None, description='逻辑任务ID') + execution_id: str | None = Field(default=None, description='执行ID') job_name: str | None = Field(default=None, description='任务名称') job_group: str | None = Field(default=None, description='任务组名') + job_store: str | None = Field(default=None, description='调度存储快照') job_executor: str | None = Field(default=None, description='任务执行器') invoke_target: str | None = Field(default=None, description='调用目标字符串') - job_args: str | None = Field(default=None, description='位置参数') - job_kwargs: str | None = Field(default=None, description='关键字参数') + job_args: list[JsonValue] | None = Field(default=None, description='位置参数快照(JSON数组)') + job_kwargs: dict[str, JsonValue] | None = Field(default=None, description='关键字参数快照(JSON对象)') job_trigger: str | None = Field(default=None, description='任务触发器') + time_zone: str | None = Field(default=None, description='任务时区快照(IANA)') job_message: str | None = Field(default=None, description='日志信息') status: Literal['0', '1'] | None = Field(default=None, description='执行状态(0正常 1失败)') exception_info: str | None = Field(default=None, description='异常信息') - start_time: datetime | None = Field(default=None, description='执行开始时间') - end_time: datetime | None = Field(default=None, description='执行结束时间') - create_time: datetime | None = Field(default=None, description='创建时间') + scheduled_time: ApiUtcDateTime | None = Field(default=None, description='计划执行时刻') + start_time: ApiUtcDateTime | None = Field(default=None, description='执行开始时间') + end_time: ApiUtcDateTime | None = Field(default=None, description='执行结束时间') + run_duration_ms: int | None = Field(default=None, ge=0, description='实际执行耗时(毫秒)') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') -class JobQueryModel(JobModel): +class JobQueryModel(DateRangeQueryMixin, BaseModel): """ 定时任务管理不分页查询模型 """ + model_config = ConfigDict(alias_generator=to_camel) + + job_name: str | None = Field(default=None, description='任务名称') + job_group: str | None = Field(default=None, description='业务分组') + status: Literal['0', '1'] | None = Field(default=None, description='任务状态') begin_time: str | None = Field(default=None, description='开始时间') end_time: str | None = Field(default=None, description='结束时间') @@ -93,9 +222,28 @@ class EditJobModel(JobModel): 编辑定时任务模型 """ + job_id: int = Field(gt=0, description='任务ID') type: str | None = Field(default=None, description='操作类型') +class JobRunModel(BaseModel): + """ + 手动执行请求,仅按稳定任务ID读取已保存配置 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + job_id: int = Field(gt=0, description='任务ID') + + +class ChangeJobStatusModel(JobRunModel): + """ + 修改任务状态请求 + """ + + status: Literal['0', '1'] = Field(description='状态(0正常 1暂停)') + + class DeleteJobModel(BaseModel): """ 删除定时任务模型 @@ -106,11 +254,18 @@ class DeleteJobModel(BaseModel): job_ids: str = Field(description='需要删除的定时任务ID') -class JobLogQueryModel(JobLogModel): +class JobLogQueryModel(DateRangeQueryMixin, BaseModel): """ 定时任务日志不分页查询模型 """ + model_config = ConfigDict(alias_generator=to_camel) + + job_id: int | None = Field(default=None, gt=0, description='逻辑任务ID') + execution_id: str | None = Field(default=None, pattern='^[0-9a-f]{32}$', description='执行ID') + job_name: str | None = Field(default=None, description='任务名称快照') + job_group: str | None = Field(default=None, description='业务分组快照') + status: Literal['0', '1'] | None = Field(default=None, description='执行状态') begin_time: str | None = Field(default=None, description='开始时间') end_time: str | None = Field(default=None, description='结束时间') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/log_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/log_vo.py index b09b4de8a..081ae7638 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/log_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/log_vo.py @@ -1,9 +1,11 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class OperLogModel(BaseModel): """ @@ -31,7 +33,7 @@ class OperLogModel(BaseModel): json_result: str | None = Field(default=None, description='返回参数') status: Literal[0, 1, '0', '1'] | None = Field(default=None, description='操作状态(0正常 1异常)') error_msg: str | None = Field(default=None, description='错误消息') - oper_time: datetime | None = Field(default=None, description='操作时间') + oper_time: ApiUtcDateTime | None = Field(default=None, description='操作时间') cost_time: int | None = Field(default=None, description='消耗时间') @@ -50,10 +52,10 @@ class LogininforModel(BaseModel): os: str | None = Field(default=None, description='操作系统') status: Literal['0', '1'] | None = Field(default=None, description='登录状态(0成功 1失败)') msg: str | None = Field(default=None, description='提示消息') - login_time: datetime | None = Field(default=None, description='访问时间') + login_time: ApiUtcDateTime | None = Field(default=None, description='访问时间') -class OperLogQueryModel(OperLogModel): +class OperLogQueryModel(DateRangeQueryMixin, OperLogModel): """ 操作日志管理不分页查询模型 """ @@ -85,7 +87,7 @@ class DeleteOperLogModel(BaseModel): oper_ids: str = Field(description='需要删除的日志主键') -class LoginLogQueryModel(LogininforModel): +class LoginLogQueryModel(DateRangeQueryMixin, LogininforModel): """ 登录日志管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/menu_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/menu_vo.py index 35307afda..ccb63783d 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/menu_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/menu_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class MenuModel(BaseModel): """ @@ -29,9 +31,9 @@ class MenuModel(BaseModel): perms: str | None = Field(default=None, description='权限标识') icon: str | None = Field(default=None, description='菜单图标') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @NotBlank(field_name='menu_name', message='菜单名称不能为空') @@ -68,7 +70,7 @@ def validate_fields(self) -> None: self.get_perms() -class MenuQueryModel(MenuModel): +class MenuQueryModel(DateRangeQueryMixin, MenuModel): """ 菜单管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py index ea6d91207..055ffb54f 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size, Xss +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class NoticeModel(BaseModel): """ @@ -19,9 +21,9 @@ class NoticeModel(BaseModel): notice_content: bytes | None = Field(default=None, description='公告内容') status: Literal['0', '1'] | None = Field(default=None, description='公告状态(0正常 1关闭)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @Xss(field_name='notice_title', message='公告标题不能包含脚本字符') @@ -34,7 +36,7 @@ def validate_fields(self) -> None: self.get_notice_title() -class NoticeQueryModel(NoticeModel): +class NoticeQueryModel(DateRangeQueryMixin, NoticeModel): """ 通知公告管理不分页查询模型 """ @@ -64,7 +66,7 @@ class NoticeReadUserModel(BaseModel): nick_name: str = Field(description='用户昵称') dept_name: str | None = Field(default=None, description='部门名称') phonenumber: str | None = Field(default=None, description='手机号码') - read_time: datetime = Field(description='阅读时间') + read_time: ApiUtcDateTime = Field(description='阅读时间') class NoticeReadUserPageQueryModel(BaseModel): @@ -102,7 +104,7 @@ class NoticeTopModel(BaseModel): notice_type: Literal['1', '2'] = Field(description='公告类型(1通知 2公告)') status: Literal['0', '1'] = Field(description='公告状态(0正常 1关闭)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') is_read: bool = Field(default=False, description='是否已读') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/online_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/online_vo.py index 88f5b9561..47f784613 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/online_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/online_vo.py @@ -1,8 +1,9 @@ -from datetime import datetime - from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class OnlineModel(BaseModel): """ @@ -18,10 +19,10 @@ class OnlineModel(BaseModel): login_location: str | None = Field(default=None, description='登录地点') browser: str | None = Field(default=None, description='浏览器类型') os: str | None = Field(default=None, description='操作系统') - login_time: datetime | None = Field(default=None, description='登录时间') + login_time: ApiUtcDateTime | None = Field(default=None, description='登录时间') -class OnlineQueryModel(OnlineModel): +class OnlineQueryModel(DateRangeQueryMixin, OnlineModel): """ 岗位管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/post_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/post_vo.py index f3826d487..61b900bd9 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/post_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/post_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class PostModel(BaseModel): """ @@ -19,9 +21,9 @@ class PostModel(BaseModel): post_sort: int | None = Field(default=None, description='显示顺序') status: Literal['0', '1'] | None = Field(default=None, description='状态(0正常 1停用)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @NotBlank(field_name='post_code', message='岗位编码不能为空') @@ -44,7 +46,7 @@ def validate_fields(self) -> None: self.get_post_sort() -class PostQueryModel(PostModel): +class PostQueryModel(DateRangeQueryMixin, PostModel): """ 岗位管理不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/role_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/role_vo.py index aff9c385a..c711bc492 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/role_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/role_vo.py @@ -1,10 +1,12 @@ -from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime + class RoleModel(BaseModel): """ @@ -26,9 +28,9 @@ class RoleModel(BaseModel): status: Literal['0', '1'] | None = Field(default=None, description='角色状态(0正常 1停用)') del_flag: Literal['0', '2'] | None = Field(default=None, description='删除标志(0代表存在 2代表删除)') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') admin: bool | None = Field(default=False, description='是否为admin') @@ -95,7 +97,7 @@ class RoleDeptModel(BaseModel): dept_id: int | None = Field(default=None, description='部门ID') -class RoleQueryModel(RoleModel): +class RoleQueryModel(DateRangeQueryMixin, RoleModel): """ 角色管理不分页查询模型 """ @@ -154,4 +156,4 @@ class DeleteRoleModel(BaseModel): role_ids: str = Field(description='需要删除的角色ID') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/server_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/server_vo.py index 6d810bac8..78ecb139c 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/server_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/server_vo.py @@ -1,6 +1,8 @@ from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from common.types import ApiUtcDateTime + class CpuInfo(BaseModel): model_config = ConfigDict(alias_generator=to_camel) @@ -35,7 +37,7 @@ class PyInfo(MemoryInfo): name: str | None = Field(default=None, description='Python名称') version: str | None = Field(default=None, description='Python版本') - start_time: str | None = Field(default=None, description='启动时间') + start_time: ApiUtcDateTime | None = Field(default=None, description='启动时间') run_time: str | None = Field(default=None, description='运行时长') home: str | None = Field(default=None, description='安装路径') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py index d7511fd0c..8f3affd82 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py @@ -1,8 +1,8 @@ -from datetime import datetime - from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from common.types import ApiUtcDateTime + class TransportCryptoFrontendConfigModel(BaseModel): """ @@ -61,7 +61,7 @@ class TransportCryptoFailureRecordModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel) - time: datetime | None = Field(default=None, description='失败时间') + time: ApiUtcDateTime | None = Field(default=None, description='失败时间') method: str | None = Field(default=None, description='请求方法') path: str | None = Field(default=None, description='请求路径') reason: str | None = Field(default=None, description='失败原因分类') @@ -76,7 +76,7 @@ class TransportCryptoMonitorModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel) monitor_scope: str | None = Field(default=None, description='监控统计范围,默认基于Redis聚合') - started_at: datetime | None = Field(default=None, description='当前监控统计起始时间') + started_at: ApiUtcDateTime | None = Field(default=None, description='当前监控统计起始时间') app_env: str | None = Field(default=None, description='当前应用环境') transport_crypto_enabled: bool | None = Field(default=None, description='是否启用传输层加解密') transport_crypto_mode: str | None = Field(default=None, description='当前传输层加解密模式') diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/user_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/user_vo.py index 26ddb8fdc..f3abc7155 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/user_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/user_vo.py @@ -1,15 +1,17 @@ import re -from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic.alias_generators import to_camel from pydantic_validation_decorator import Network, NotBlank, Size, Xss +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime from exceptions.exception import ModelValidatorException from module_admin.entity.vo.dept_vo import DeptModel from module_admin.entity.vo.post_vo import PostModel from module_admin.entity.vo.role_vo import RoleModel +from utils.time_util import TimezoneUtil class TokenData(BaseModel): @@ -36,19 +38,31 @@ class UserModel(BaseModel): phonenumber: str | None = Field(default=None, description='手机号码') sex: Literal['0', '1', '2'] | None = Field(default=None, description='用户性别(0男 1女 2未知)') avatar: str | None = Field(default=None, description='头像地址') + time_zone: str = Field(default='auto', max_length=64, description='显示时区(auto跟随设备或IANA名称)') password: str | None = Field(default=None, description='密码') status: Literal['0', '1'] | None = Field(default=None, description='帐号状态(0正常 1停用)') del_flag: Literal['0', '2'] | None = Field(default=None, description='删除标志(0代表存在 2代表删除)') login_ip: str | None = Field(default=None, description='最后登录IP') - login_date: datetime | None = Field(default=None, description='最后登录时间') - pwd_update_date: datetime | None = Field(default=None, description='密码最后更新时间') + login_date: ApiUtcDateTime | None = Field(default=None, description='最后登录时间') + pwd_update_date: ApiUtcDateTime | None = Field(default=None, description='密码最后更新时间') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') admin: bool | None = Field(default=False, description='是否为admin') + @field_validator('time_zone') + @classmethod + def validate_time_zone(cls, value: str) -> str: + """ + 校验账号显示时区偏好 + + :param value: auto或IANA时区名称 + :return: 校验后的时区偏好 + """ + return TimezoneUtil.validate_timezone_preference(value) + @model_validator(mode='after') def check_password(self) -> 'UserModel': pattern = r"""^[^<>"'|\\]+$""" @@ -141,12 +155,34 @@ class UpdateUserProfileModel(BaseModel): sex: Literal['0', '1', '2'] | None = Field(default=None, description='用户性别(0男 1女 2未知)') +class UserTimezoneModel(BaseModel): + """ + 当前用户的显示时区偏好模型 + """ + + model_config = ConfigDict(alias_generator=to_camel, extra='forbid') + + time_zone: str = Field(max_length=64, description='auto跟随设备,或指定IANA时区') + + @field_validator('time_zone') + @classmethod + def validate_time_zone(cls, value: str) -> str: + """ + 校验并规范化时区偏好 + + :param value: auto或IANA时区名称 + :return: 校验后的时区偏好 + """ + return TimezoneUtil.validate_timezone_preference(value) + + class CurrentUserModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel) permissions: list = Field(description='权限信息') roles: list = Field(description='角色信息') user: UserInfoModel | None = Field(description='用户信息') + app_timezone: str = Field(default='UTC', description='业务时区(IANA)') pwd_chrtype: str = Field(default='0', description='密码字符范围') is_default_modify_pwd: bool = Field(default=False, description='是否初始密码修改提醒') is_password_expired: bool = Field(default=False, description='密码是否过期提醒') @@ -188,7 +224,7 @@ class AvatarModel(BaseModel): img_url: str = Field(description='头像地址') -class UserQueryModel(UserModel): +class UserQueryModel(DateRangeQueryMixin, UserModel): """ 用户管理不分页查询模型 """ @@ -261,7 +297,7 @@ class DeleteUserModel(BaseModel): user_ids: str = Field(description='需要删除的用户ID') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') class UserRoleQueryModel(UserModel): diff --git a/ruoyi-fastapi-backend/module_admin/service/common_service.py b/ruoyi-fastapi-backend/module_admin/service/common_service.py index c944aa02d..ee754daf9 100644 --- a/ruoyi-fastapi-backend/module_admin/service/common_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/common_service.py @@ -22,6 +22,7 @@ from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.service.file_access_service import FileAuditService from utils.file_util import FileByteRange, FileDownloadResult, FileUtil +from utils.time_util import TimezoneUtil from utils.upload_util import FilePathUtil, UploadUtil @@ -56,7 +57,7 @@ async def upload_service( if file.size is not None and file.size > UploadConfig.MAX_FILE_SIZE: raise ServiceException(message=f'文件大小不能超过{UploadConfig.MAX_FILE_SIZE // 1024 // 1024}MB') - now = datetime.now() + now = TimezoneUtil.to_business_time(TimezoneUtil.utc_now()) relative_path = Path('upload', now.strftime('%Y'), now.strftime('%m'), now.strftime('%d')) storage_root = UploadConfig.UPLOAD_PATH if access_type == 'public' else UploadConfig.PRIVATE_UPLOAD_PATH dir_path = Path(storage_root, relative_path) @@ -224,7 +225,7 @@ async def download_managed_file_services( ) raise ServiceException(message='文件不存在或无权访问') - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() is_expired = ( file_info.access_type == 'private' and file_info.expire_time and file_info.expire_time < current_time ) diff --git a/ruoyi-fastapi-backend/module_admin/service/file_access_service.py b/ruoyi-fastapi-backend/module_admin/service/file_access_service.py index 2bec7c586..0242e23a6 100644 --- a/ruoyi-fastapi-backend/module_admin/service/file_access_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/file_access_service.py @@ -1,5 +1,4 @@ import json -from datetime import datetime from typing import Any, Literal from fastapi import Request @@ -28,6 +27,7 @@ from utils.client_ip_util import ClientIPUtil from utils.file_util import FileUtil from utils.log_util import LogSanitizer, logger +from utils.time_util import TimezoneUtil class FileAclService: @@ -243,7 +243,7 @@ async def save_file_acl_services( await query_db.rollback() raise ServiceException(message='文件权限已被其他用户修改,请刷新后重试') - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() unique_subjects = set() subject_ids: dict[str, set[int]] = {'user': set(), 'role': set(), 'dept': set()} normalized_expire_times = [] @@ -254,8 +254,8 @@ async def save_file_acl_services( unique_subjects.add(subject_key) subject_ids[entry.subject_type].add(entry.subject_id) expire_time = entry.expire_time - if expire_time and expire_time.tzinfo: - expire_time = expire_time.astimezone().replace(tzinfo=None) + if expire_time: + expire_time = TimezoneUtil.to_utc(expire_time) normalized_expire_times.append(expire_time) if expire_time and expire_time <= current_time: raise ServiceException(message='授权过期时间必须晚于当前时间') @@ -357,7 +357,7 @@ async def batch_save_file_acl_services( await query_db.rollback() raise ServiceException(message='批量授权仅支持受保护文件') - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() unique_subjects = set() subject_ids: dict[str, set[int]] = {'user': set(), 'role': set(), 'dept': set()} normalized_entries = [] @@ -368,8 +368,8 @@ async def batch_save_file_acl_services( unique_subjects.add(subject_key) subject_ids[entry.subject_type].add(entry.subject_id) expire_time = entry.expire_time - if expire_time and expire_time.tzinfo: - expire_time = expire_time.astimezone().replace(tzinfo=None) + if expire_time: + expire_time = TimezoneUtil.to_utc(expire_time) if expire_time and expire_time <= current_time: raise ServiceException(message='授权过期时间必须晚于当前时间') normalized_entries.append((entry, expire_time)) @@ -516,7 +516,7 @@ async def enqueue_file_audit( bytesSent=bytes_sent, errorMessage=error_message[:500], operationDetail=cls._serialize_operation_detail(operation_detail), - accessTime=datetime.now(), + accessTime=TimezoneUtil.utc_now(), ) await LogQueueService.enqueue_file_access_log( request, @@ -554,7 +554,7 @@ async def add_system_file_audit( result=result, errorMessage=error_message[:500], operationDetail=cls._serialize_operation_detail(operation_detail), - accessTime=datetime.now(), + accessTime=TimezoneUtil.utc_now(), ) await FileAccessLogDao.add_file_access_log_dao(query_db, file_access_log) diff --git a/ruoyi-fastapi-backend/module_admin/service/file_business_service.py b/ruoyi-fastapi-backend/module_admin/service/file_business_service.py index b17aeb662..263d6ce8f 100644 --- a/ruoyi-fastapi-backend/module_admin/service/file_business_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/file_business_service.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any from sqlalchemy import ColumnElement, true @@ -26,6 +26,7 @@ FileRetentionScanModel, ) from utils.common_util import CamelCaseUtil +from utils.time_util import TimezoneUtil class FileReferenceService: @@ -107,7 +108,7 @@ async def replace_business_file_references_services( ) if len(file_infos) != len(normalized_file_ids): raise ServiceException(message='部分引用文件不存在或已失效') - create_time = datetime.now() + create_time = TimezoneUtil.utc_now() retention_policy = await FileRetentionPolicyService.get_enabled_file_retention_policy_services( query_db, normalized_business_type, @@ -402,7 +403,7 @@ async def scan_file_retention_notices_services( :return: 扫描结果 """ cls._validate_scan_parameters(remind_days, batch_size) - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() reminder_deadline = current_time + timedelta(days=remind_days) data_scope_sql = file_data_scope_sql if file_data_scope_sql is not None else true() try: @@ -505,7 +506,7 @@ async def mark_file_retention_notices_read_services( query_db, parsed_notice_ids, read_by, - datetime.now(), + TimezoneUtil.utc_now(), ) await query_db.commit() return CrudResponseModel(is_success=True, message='提醒已标记为已读') diff --git a/ruoyi-fastapi-backend/module_admin/service/file_service.py b/ruoyi-fastapi-backend/module_admin/service/file_service.py index d921cc76a..66ab4e379 100644 --- a/ruoyi-fastapi-backend/module_admin/service/file_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/file_service.py @@ -41,6 +41,7 @@ from module_admin.service.file_business_service import FileReferenceService from utils.file_util import FileReconcileUtil, FileUtil from utils.log_util import logger +from utils.time_util import TimezoneUtil from utils.upload_util import UploadUtil @@ -96,10 +97,9 @@ async def extend_file_retention_services( original_name=file_info.original_name, access_type=file_info.access_type, ) - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() new_expire_time = extend_retention.expire_time - if new_expire_time.tzinfo: - new_expire_time = new_expire_time.astimezone().replace(tzinfo=None) + new_expire_time = TimezoneUtil.to_utc(new_expire_time) previous_expire_time = file_info.expire_time if previous_expire_time is None: await query_db.rollback() @@ -233,7 +233,7 @@ async def dispose_expired_file_services( original_name=file_info.original_name, access_type=file_info.access_type, ) - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() expire_time = file_info.expire_time if expire_time is None or expire_time > current_time: await cls._enqueue_retention_audit( @@ -460,7 +460,7 @@ async def delete_file_services( raise ServiceException(message='文件移入回收区失败') from exc try: - await FileInfoDao.soft_delete_file_infos(query_db, file_ids, user.user_name, datetime.now()) + await FileInfoDao.soft_delete_file_infos(query_db, file_ids, user.user_name, TimezoneUtil.utc_now()) await query_db.commit() except Exception as exc: await query_db.rollback() @@ -564,7 +564,7 @@ async def purge_file_services( query_db, parsed_file_ids, user.user_name, - datetime.now(), + TimezoneUtil.utc_now(), ) await query_db.commit() except Exception: @@ -621,7 +621,7 @@ async def purge_recycle_bin_services( :return: 永久清理文件数量 """ cls._validate_purge_parameters(retention_days, batch_size) - deleted_before = datetime.now() - timedelta(days=retention_days) + deleted_before = TimezoneUtil.utc_now() - timedelta(days=retention_days) file_infos = await FileInfoDao.get_recycle_bin_purge_candidates( query_db, deleted_before, @@ -638,7 +638,7 @@ async def purge_recycle_bin_services( raise ServiceException(message='自动清理候选文件仍存在业务引用') try: staged_files = await asyncio.to_thread(FileUtil.prepare_deleted_files_for_purge, file_infos) - await FileInfoDao.mark_file_infos_purging(query_db, file_ids, 'system', datetime.now()) + await FileInfoDao.mark_file_infos_purging(query_db, file_ids, 'system', TimezoneUtil.utc_now()) await query_db.commit() except Exception: await query_db.rollback() @@ -744,7 +744,7 @@ async def restore_file_services( query_db, parsed_file_ids, user.user_name, - datetime.now(), + TimezoneUtil.utc_now(), ) await query_db.commit() except Exception as exc: @@ -772,7 +772,7 @@ async def restore_file_services( query_db, parsed_file_ids, user.user_name, - datetime.now(), + TimezoneUtil.utc_now(), ) await query_db.commit() except Exception as compensation_exc: @@ -1040,7 +1040,7 @@ async def transfer_file_services( target_dept_id, transfer_file.retain_uploader_access, user.user_name, - datetime.now(), + TimezoneUtil.utc_now(), ) await query_db.commit() except Exception as exc: @@ -1122,7 +1122,7 @@ async def start_reconcile_run_services( if trigger_type == 'manual': user = cls._require_admin(current_user) started_by = user.user_name - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() reconcile_run = SysFileReconcileRun( run_id=str(uuid.uuid4()), trigger_type=trigger_type, @@ -1169,7 +1169,7 @@ async def execute_reconcile_run_services(cls, run_id: str) -> None: file_infos, check_hash, ) - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() new_issue_count = await FileInfoDao.upsert_reconcile_issues( query_db, run_id, @@ -1205,7 +1205,7 @@ async def execute_reconcile_run_services(cls, run_id: str) -> None: query_db, run_id, status='failed', - finished_time=datetime.now(), + finished_time=TimezoneUtil.utc_now(), error_message=f'{exc.__class__.__name__}:对账任务执行失败', ) await query_db.commit() @@ -1347,7 +1347,7 @@ async def handle_reconcile_issue_services( 'expectedKey': issue.expected_key, } performed_move: tuple[str, str, str, str] | None = None - current_time = datetime.now() + current_time = TimezoneUtil.utc_now() try: file_id, performed_move = await cls._apply_reconcile_action( query_db, diff --git a/ruoyi-fastapi-backend/module_admin/service/job_log_service.py b/ruoyi-fastapi-backend/module_admin/service/job_log_service.py index b21cafa95..2770e045c 100644 --- a/ruoyi-fastapi-backend/module_admin/service/job_log_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/job_log_service.py @@ -1,3 +1,4 @@ +import json from typing import Any from fastapi import Request @@ -104,8 +105,11 @@ async def export_job_log_list_services(request: Request, job_log_list: list) -> # 创建一个映射字典,将英文键映射到中文键 mapping_dict = { 'jobLogId': '任务日志编码', + 'jobId': '任务ID', + 'executionId': '执行ID', 'jobName': '任务名称', 'jobGroup': '任务组名', + 'jobStore': '调度存储', 'jobExecutor': '任务执行器', 'invokeTarget': '调用目标字符串', 'jobArgs': '位置参数', @@ -114,16 +118,14 @@ async def export_job_log_list_services(request: Request, job_log_list: list) -> 'jobMessage': '日志信息', 'status': '执行状态', 'exceptionInfo': '异常信息', + 'scheduledTime': '计划执行时刻', + 'runDurationMs': '执行耗时(毫秒)', + 'timeZone': '任务时区', 'startTime': '执行开始时间', 'endTime': '执行结束时间', 'createTime': '创建时间', } - job_group_list = await DictDataService.query_dict_data_list_from_cache_services( - request.app.state.redis, dict_type='sys_job_group' - ) - job_group_option = [{'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in job_group_list] - job_group_option_dict = {item.get('value'): item for item in job_group_option} job_executor_list = await DictDataService.query_dict_data_list_from_cache_services( request.app.state.redis, dict_type='sys_job_executor' ) @@ -133,12 +135,12 @@ async def export_job_log_list_services(request: Request, job_log_list: list) -> job_executor_option_dict = {item.get('value'): item for item in job_executor_option} for item in job_log_list: + item['jobArgs'] = json.dumps(item.get('jobArgs'), ensure_ascii=False) + item['jobKwargs'] = json.dumps(item.get('jobKwargs'), ensure_ascii=False) if item.get('status') == '0': item['status'] = '正常' else: - item['status'] = '暂停' - if str(item.get('jobGroup')) in job_group_option_dict: - item['jobGroup'] = job_group_option_dict.get(str(item.get('jobGroup'))).get('label') + item['status'] = '失败' if str(item.get('jobExecutor')) in job_executor_option_dict: item['jobExecutor'] = job_executor_option_dict.get(str(item.get('jobExecutor'))).get('label') binary_data = ExcelUtil.export_list2excel(job_log_list, mapping_dict) diff --git a/ruoyi-fastapi-backend/module_admin/service/job_service.py b/ruoyi-fastapi-backend/module_admin/service/job_service.py index b0fba2280..1aa231ad3 100644 --- a/ruoyi-fastapi-backend/module_admin/service/job_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/job_service.py @@ -1,19 +1,24 @@ +import json +from datetime import datetime, timedelta from typing import Any from fastapi import Request from sqlalchemy.ext.asyncio import AsyncSession -from common.constant import CommonConstant, JobConstant +from common.constant import CommonConstant from common.vo import CrudResponseModel, PageModel -from config.get_scheduler import SchedulerUtil +from config.scheduler.job_adapter import JobAdapter +from config.scheduler.manager import SchedulerManager from exceptions.exception import ServiceException from module_admin.dao.job_dao import JobDao +from module_admin.dao.job_runtime_dao import JobRuntimeDao +from module_admin.entity.vo.job_runtime_vo import JobExecutionQueryModel, JobSyncQueryModel from module_admin.entity.vo.job_vo import DeleteJobModel, EditJobModel, JobModel, JobPageQueryModel from module_admin.service.dict_service import DictDataService -from utils.common_util import CamelCaseUtil from utils.cron_util import CronUtil from utils.excel_util import ExcelUtil -from utils.string_util import StringUtil +from utils.log_util import logger +from utils.time_util import TimezoneUtil class JobService: @@ -21,6 +26,63 @@ class JobService: 定时任务管理模块服务层 """ + RUNTIME_FIELDS = { + 'cron_next_time', + 'next_run_time', + 'schedule_observed_time', + 'config_version', + 'applied_version', + 'sync_status', + 'sync_error', + 'applied_time', + } + + @staticmethod + def _validate_job(job: JobModel, *, check_target_permission: bool = True) -> JobModel: + """ + 在业务事务提交前完成与调度注册相同的配置校验 + + :param job: 任务对象信息 + :param check_target_permission: 是否校验管理接口的调用目标白名单 + :return: 校验后的任务对象 + """ + job = JobAdapter.normalize(job) + try: + if check_target_permission: + JobAdapter.validate_allowed_target(job.invoke_target) + SchedulerManager._prepare_scheduler_job_add(job) + except Exception as exc: + raise ServiceException(message=f'任务配置无效:{exc}') from exc + return job + + @classmethod + async def _after_commit(cls, states: list[dict[str, Any]], action: str) -> CrudResponseModel: + """ + 反馈已提交修改的同步结果,后续同步失败不伪装成事务回滚 + + :param states: 已提交的任务同步状态 + :param action: 任务操作类型 + :return: 业务提交及调度同步结果 + """ + job_ids = {state['jobId'] for state in states} + try: + sync = await SchedulerManager.request_scheduler_sync(job_ids, immediate=True) + except Exception as exc: + logger.exception('任务修改已提交,同步请求失败') + sync = {'syncStatus': 'failed', 'syncError': str(exc)[:2000], 'jobs': []} + results = {state['jobId']: state for state in states} + for result in sync['jobs']: + results[result['jobId']] = {**results.get(result['jobId'], {}), **result} + submitted = {'新增': '新增已保存', '更新': '修改已保存', '删除': '删除已提交', '同步': '同步请求已提交'}[action] + message = { + 'applied': f'{action}成功,调度已生效', + 'pending': f'{submitted},等待调度生效', + 'failed': f'{submitted},但调度同步失败,请查看同步状态', + }[sync['syncStatus']] + return CrudResponseModel( + is_success=True, message=message, result={**sync, 'saved': True, 'jobs': list(results.values())} + ) + @classmethod async def get_job_list_services( cls, query_db: AsyncSession, query_object: JobPageQueryModel, is_page: bool = False @@ -34,9 +96,47 @@ async def get_job_list_services( :return: 定时任务列表信息对象 """ job_list_result = await JobDao.get_job_list(query_db, query_object, is_page) + rows = job_list_result.rows if isinstance(job_list_result, PageModel) else job_list_result + states = await JobRuntimeDao.get_states(query_db, [row['jobId'] for row in rows]) + for row in rows: + row['cronNextTime'] = cls._get_next_valid_time(row.get('cronExpression'), row.get('timeZone')) + row.update(states.get(row['jobId'], {'syncStatus': 'pending'})) + row['nextRunTime'] = cls._get_observed_next_run_time(row) return job_list_result + @staticmethod + def _get_observed_next_run_time(row: dict[str, Any]) -> datetime | None: + """ + 只展示已生效且近期被Leader观测到的实际调度时刻 + + :param row: 包含任务状态与调度观测数据的字典 + :return: 实际下次调度时刻,停用、同步未完成或观测过期时返回None + """ + observed = row.get('scheduleObservedTime') + if row.get('status') != '0' or row.get('syncStatus') != 'applied' or not observed: + return None + if TimezoneUtil.utc_now() - TimezoneUtil.to_utc(observed) > timedelta(seconds=30): + return None + return row.get('nextRunTime') + + @staticmethod + def _get_next_valid_time(cron_expression: str | None, time_zone: str | None) -> datetime | None: + """ + 根据任务时区计算Cron理论预览,不代表调度器已安排执行 + + :param cron_expression: 任务Cron表达式 + :param time_zone: 任务IANA时区名称 + :return: 下一次UTC执行时刻,配置缺失或无未来结果时返回None + """ + if not cron_expression or not time_zone: + return None + try: + times = CronUtil.next_run_times(cron_expression, time_zone, TimezoneUtil.utc_now(), count=1) + except (TypeError, ValueError): + return None + return times[0] if times else None + @classmethod async def check_job_unique_services(cls, query_db: AsyncSession, page_object: JobModel) -> bool: """ @@ -61,35 +161,19 @@ async def add_job_services(cls, query_db: AsyncSession, page_object: JobModel) - :param page_object: 新增定时任务对象 :return: 新增定时任务校验结果 """ - if not CronUtil.validate_cron_expression(page_object.cron_expression): - raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,Cron表达式不正确') - if StringUtil.contains_ignore_case(page_object.invoke_target, CommonConstant.LOOKUP_RMI): - raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不允许rmi调用') - if StringUtil.contains_any_ignore_case( - page_object.invoke_target, [CommonConstant.LOOKUP_LDAP, CommonConstant.LOOKUP_LDAPS] - ): - raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不允许ldap(s)调用') - if StringUtil.contains_any_ignore_case(page_object.invoke_target, [CommonConstant.HTTP, CommonConstant.HTTPS]): - raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不允许http(s)调用') - if StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_ERROR_LIST): - raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串存在违规') - if not StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_WHITE_LIST): - raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,目标字符串不在白名单内') + page_object = cls._validate_job(page_object.model_copy(update={'job_id': None})) if not await cls.check_job_unique_services(query_db, page_object): raise ServiceException(message=f'新增定时任务{page_object.job_name}失败,定时任务已存在') try: - add_job = await JobDao.add_job_dao(query_db, page_object) - job_info = await cls.job_detail_services(query_db, add_job.job_id) - if job_info.status == '0': - SchedulerUtil.add_scheduler_job(job_info=job_info) + db_job = await JobDao.add_job_dao(query_db, page_object) + page_object = page_object.model_copy(update={'job_id': db_job.job_id}) + state = await JobRuntimeDao.record_configuration(query_db, db_job.job_id, page_object) await query_db.commit() - await SchedulerUtil.request_scheduler_sync() - result = {'is_success': True, 'message': '新增成功'} except Exception as e: await query_db.rollback() raise e - return CrudResponseModel(**result) + return await cls._after_commit([JobRuntimeDao.state_result(state)], '新增') @classmethod def _deal_edit_job(cls, page_object: EditJobModel, edit_job: dict[str, Any]) -> None: @@ -100,7 +184,10 @@ def _deal_edit_job(cls, page_object: EditJobModel, edit_job: dict[str, Any]) -> :param edit_job: 编辑定时任务字典 """ if page_object.type == 'status': - del edit_job['type'] + for key in set(edit_job) - {'job_id', 'status', 'update_by'}: + edit_job.pop(key) + else: + edit_job.pop('type', None) @classmethod async def edit_job_services(cls, query_db: AsyncSession, page_object: EditJobModel) -> CrudResponseModel: @@ -111,63 +198,60 @@ async def edit_job_services(cls, query_db: AsyncSession, page_object: EditJobMod :param page_object: 编辑定时任务对象 :return: 编辑定时任务校验结果 """ - edit_job = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) + edit_job = page_object.model_dump( + exclude_unset=True, + exclude={'create_time', 'update_time'} | cls.RUNTIME_FIELDS, + ) cls._deal_edit_job(page_object, edit_job) - job_info = await cls.job_detail_services(query_db, page_object.job_id) - if job_info: - if page_object.type != 'status': - if not CronUtil.validate_cron_expression(page_object.cron_expression): - raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,Cron表达式不正确') - if StringUtil.contains_ignore_case(page_object.invoke_target, CommonConstant.LOOKUP_RMI): - raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,目标字符串不允许rmi调用') - if StringUtil.contains_any_ignore_case( - page_object.invoke_target, [CommonConstant.LOOKUP_LDAP, CommonConstant.LOOKUP_LDAPS] - ): - raise ServiceException( - message=f'修改定时任务{page_object.job_name}失败,目标字符串不允许ldap(s)调用' - ) - if StringUtil.contains_any_ignore_case( - page_object.invoke_target, [CommonConstant.HTTP, CommonConstant.HTTPS] - ): - raise ServiceException( - message=f'修改定时任务{page_object.job_name}失败,目标字符串不允许http(s)调用' - ) - if StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_ERROR_LIST): - raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,目标字符串存在违规') - if not StringUtil.startswith_any_case(page_object.invoke_target, JobConstant.JOB_WHITE_LIST): - raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,目标字符串不在白名单内') - if not await cls.check_job_unique_services(query_db, page_object): - raise ServiceException(message=f'修改定时任务{page_object.job_name}失败,定时任务已存在') - try: - await JobDao.edit_job_dao(query_db, edit_job, job_info) - SchedulerUtil.remove_scheduler_job(job_id=edit_job.get('job_id')) - if edit_job.get('status') == '0': - job_info = await cls.job_detail_services(query_db, edit_job.get('job_id')) - SchedulerUtil.add_scheduler_job(job_info=job_info) - await query_db.commit() - await SchedulerUtil.request_scheduler_sync() - return CrudResponseModel(is_success=True, message='更新成功') - except Exception as e: - await query_db.rollback() - raise e - else: - raise ServiceException(message='定时任务不存在') + try: + db_job, _state = await JobRuntimeDao.lock_job(query_db, page_object.job_id) + if db_job is None: + raise ServiceException(message='定时任务不存在') + job_info = JobAdapter.from_record(db_job) + changed = job_info.model_copy(update=edit_job) + if page_object.type != 'status' or changed.status == '0': + changed = cls._validate_job(changed, check_target_permission=page_object.type != 'status') + for name in set(edit_job) & set(JobAdapter.CONFIG_FIELDS): + edit_job[name] = getattr(changed, name) + if page_object.type != 'status' and not await cls.check_job_unique_services(query_db, changed): + raise ServiceException(message=f'修改定时任务{changed.job_name}失败,定时任务已存在') + await JobDao.edit_job_dao(query_db, edit_job, job_info) + state = await JobRuntimeDao.record_configuration(query_db, job_info.job_id, changed) + await query_db.commit() + except Exception: + await query_db.rollback() + raise + return await cls._after_commit([JobRuntimeDao.state_result(state)], '更新') @classmethod - async def execute_job_once_services(cls, query_db: AsyncSession, page_object: JobModel) -> CrudResponseModel: + async def execute_job_once_services( + cls, + query_db: AsyncSession, + page_object: JobModel, + *, + requested_by: str | None = None, + ) -> CrudResponseModel: """ 执行一次定时任务service :param query_db: orm对象 :param page_object: 定时任务对象 + :param requested_by: 手动执行提交者 :return: 执行一次定时任务结果 """ - SchedulerUtil.remove_scheduler_job(job_id=page_object.job_id) - job_info = await cls.job_detail_services(query_db, page_object.job_id) - if job_info: - SchedulerUtil.execute_scheduler_job_once(job_info=job_info) - return CrudResponseModel(is_success=True, message='执行成功') - raise ServiceException(message='定时任务不存在') + try: + db_job, _state = await JobRuntimeDao.lock_job(query_db, page_object.job_id) + if db_job is None: + raise ServiceException(message='定时任务不存在') + job_info = cls._validate_job(JobAdapter.from_record(db_job), check_target_permission=False) + execution = await JobRuntimeDao.create_request(query_db, job_info, requested_by) + await query_db.commit() + result = JobRuntimeDao.execution_result(execution) + except Exception: + await query_db.rollback() + raise + await SchedulerManager.request_execution_dispatch() + return CrudResponseModel(is_success=True, message='已提交执行,请查看执行记录', result=result) @classmethod async def delete_job_services(cls, query_db: AsyncSession, page_object: DeleteJobModel) -> CrudResponseModel: @@ -179,19 +263,26 @@ async def delete_job_services(cls, query_db: AsyncSession, page_object: DeleteJo :return: 删除定时任务校验结果 """ if page_object.job_ids: - job_id_list = page_object.job_ids.split(',') + try: + job_id_list = sorted({int(value) for value in page_object.job_ids.split(',')}) + if any(job_id <= 0 for job_id in job_id_list): + raise ValueError + except ValueError as exc: + raise ServiceException(message='任务ID必须为正整数') from exc + states = [] try: for job_id in job_id_list: + await JobRuntimeDao.lock_job(query_db, job_id) await JobDao.delete_job_dao(query_db, JobModel(jobId=job_id)) - SchedulerUtil.remove_scheduler_job(job_id=job_id) + state = await JobRuntimeDao.record_configuration(query_db, job_id, None) + states.append(JobRuntimeDao.state_result(state)) + await JobRuntimeDao.cancel_unstarted(query_db, job_id_list) await query_db.commit() - await SchedulerUtil.request_scheduler_sync() - return CrudResponseModel(is_success=True, message='删除成功') except Exception as e: await query_db.rollback() raise e - else: - raise ServiceException(message='传入定时任务id为空') + return await cls._after_commit(states, '删除') + raise ServiceException(message='传入定时任务id为空') @classmethod async def job_detail_services(cls, query_db: AsyncSession, job_id: int) -> JobModel: @@ -203,10 +294,75 @@ async def job_detail_services(cls, query_db: AsyncSession, job_id: int) -> JobMo :return: 定时任务id对应的信息 """ job = await JobDao.get_job_detail_by_id(query_db, job_id=job_id) - result = JobModel(**CamelCaseUtil.transform_result(job)) if job else JobModel() + if job is None: + raise ServiceException(message='定时任务不存在') + result = JobAdapter.from_record(job) + result.cron_next_time = cls._get_next_valid_time(result.cron_expression, result.time_zone) + state = (await JobRuntimeDao.get_states(query_db, [job_id])).get(job_id, {'syncStatus': 'pending'}) + result = result.model_copy( + update={ + 'config_version': state.get('configVersion'), + 'applied_version': state.get('appliedVersion'), + 'sync_status': state.get('syncStatus'), + 'sync_error': state.get('syncError'), + 'applied_time': state.get('appliedTime'), + 'next_run_time': cls._get_observed_next_run_time({**state, 'status': result.status}), + 'schedule_observed_time': state.get('scheduleObservedTime'), + } + ) return result + @classmethod + async def execution_detail_services(cls, query_db: AsyncSession, execution_id: str) -> dict[str, Any]: + """ + 查询执行结果,任务删除后仍可按执行 ID 追踪 + + :param query_db: orm对象 + :param execution_id: 执行ID + :return: 执行状态及时间信息 + """ + execution = await JobRuntimeDao.get_execution(query_db, execution_id) + if execution is None: + raise ServiceException(message='执行记录不存在') + return JobRuntimeDao.execution_result(execution) + + @staticmethod + async def execution_list_services(query_db: AsyncSession, query: JobExecutionQueryModel) -> PageModel: + """ + 查询任务执行记录及未执行原因 + + :param query_db: orm对象 + :param query: 查询参数对象 + :return: 执行记录分页信息 + """ + return await JobRuntimeDao.execution_page(query_db, query) + + @staticmethod + async def sync_list_services(query_db: AsyncSession, query: JobSyncQueryModel) -> PageModel: + """ + 查询配置和删除操作的调度应用状态 + + :param query_db: orm对象 + :param query: 查询参数对象 + :return: 同步状态分页信息 + """ + return await JobRuntimeDao.sync_page(query_db, query) + + @classmethod + async def retry_sync_services(cls, query_db: AsyncSession, job_id: int) -> CrudResponseModel: + """ + 重试指定任务的最新配置或删除记录,不重复保存业务修改 + + :param query_db: orm对象 + :param job_id: 任务ID + :return: 调度同步结果 + """ + states = await JobRuntimeDao.get_states(query_db, [job_id]) + if job_id not in states: + raise ServiceException(message='调度同步记录不存在') + return await cls._after_commit([states[job_id]], '同步') + @staticmethod async def export_job_list_services(request: Request, job_list: list) -> bytes: """ @@ -221,13 +377,16 @@ async def export_job_list_services(request: Request, job_list: list) -> bytes: 'jobId': '任务编码', 'jobName': '任务名称', 'jobGroup': '任务组名', + 'jobStore': '调度存储', 'jobExecutor': '任务执行器', 'invokeTarget': '调用目标字符串', 'jobArgs': '位置参数', 'jobKwargs': '关键字参数', 'cronExpression': 'cron执行表达式', - 'misfirePolicy': '计划执行错误策略', - 'concurrent': '是否并发执行', + 'timeZone': 'cron时区', + 'misfireGraceTime': '允许延迟秒数', + 'coalesce': '积压处理', + 'maxInstances': '最大并发数', 'status': '状态', 'createBy': '创建者', 'createTime': '创建时间', @@ -236,11 +395,6 @@ async def export_job_list_services(request: Request, job_list: list) -> bytes: 'remark': '备注', } - job_group_list = await DictDataService.query_dict_data_list_from_cache_services( - request.app.state.redis, dict_type='sys_job_group' - ) - job_group_option = [{'label': item.get('dictLabel'), 'value': item.get('dictValue')} for item in job_group_list] - job_group_option_dict = {item.get('value'): item for item in job_group_option} job_executor_list = await DictDataService.query_dict_data_list_from_cache_services( request.app.state.redis, dict_type='sys_job_executor' ) @@ -254,20 +408,14 @@ async def export_job_list_services(request: Request, job_list: list) -> bytes: item['status'] = '正常' else: item['status'] = '暂停' - if str(item.get('jobGroup')) in job_group_option_dict: - item['jobGroup'] = job_group_option_dict.get(str(item.get('jobGroup'))).get('label') if str(item.get('jobExecutor')) in job_executor_option_dict: item['jobExecutor'] = job_executor_option_dict.get(str(item.get('jobExecutor'))).get('label') - if item.get('misfirePolicy') == '1': - item['misfirePolicy'] = '立即执行' - elif item.get('misfirePolicy') == '2': - item['misfirePolicy'] = '执行一次' - else: - item['misfirePolicy'] = '放弃执行' - if item.get('concurrent') == '0': - item['concurrent'] = '允许' - else: - item['concurrent'] = '禁止' + item['misfireGraceTime'] = ( + item.get('misfireGraceTime') if item.get('misfireGraceTime') is not None else '不限' + ) + item['coalesce'] = '只执行最近一次' if item.get('coalesce') else '逐次执行' + item['jobArgs'] = json.dumps(item.get('jobArgs', []), ensure_ascii=False) + item['jobKwargs'] = json.dumps(item.get('jobKwargs', {}), ensure_ascii=False) binary_data = ExcelUtil.export_list2excel(job_list, mapping_dict) return binary_data diff --git a/ruoyi-fastapi-backend/module_admin/service/log_service.py b/ruoyi-fastapi-backend/module_admin/service/log_service.py index f8109185a..77ca0c826 100644 --- a/ruoyi-fastapi-backend/module_admin/service/log_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/log_service.py @@ -334,7 +334,7 @@ async def enqueue_login_log(cls, request: Request, login_log: LogininforModel, s :param source: 日志来源 :return: None """ - payload = LogSanitizer.sanitize_data(login_log.model_dump(by_alias=True, exclude_none=True)) + payload = LogSanitizer.sanitize_data(login_log.model_dump(mode='json', by_alias=True, exclude_none=True)) await cls._xadd_event(request.app.state.redis, 'login', payload, source) @classmethod @@ -347,7 +347,7 @@ async def enqueue_operation_log(cls, request: Request, operation_log: OperLogMod :param source: 日志来源 :return: None """ - payload = LogSanitizer.sanitize_data(operation_log.model_dump(by_alias=True, exclude_none=True)) + payload = LogSanitizer.sanitize_data(operation_log.model_dump(mode='json', by_alias=True, exclude_none=True)) await cls._xadd_event(request.app.state.redis, 'operation', payload, source) @classmethod @@ -360,7 +360,7 @@ async def enqueue_file_access_log(cls, request: Request, file_access_log: FileAc :param source: 日志来源 :return: None """ - payload = LogSanitizer.sanitize_data(file_access_log.model_dump(by_alias=True, exclude_none=True)) + payload = LogSanitizer.sanitize_data(file_access_log.model_dump(mode='json', by_alias=True, exclude_none=True)) await cls._xadd_event(request.app.state.redis, 'file_access', payload, source) diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index 71c1116e1..a13212769 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -1,6 +1,6 @@ import random import uuid -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import Any from fastapi import Depends, Form, Request @@ -29,6 +29,7 @@ from utils.log_util import logger from utils.message_util import message_service from utils.pwd_util import PwdUtil +from utils.time_util import TimezoneUtil oauth2_scheme = OAuth2PasswordBearer(tokenUrl='login') @@ -202,10 +203,9 @@ async def create_access_token(cls, data: dict, expires_delta: timedelta | None = :return: token """ to_encode = data.copy() - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta(minutes=30) + expire = ( + TimezoneUtil.utc_now() + expires_delta if expires_delta else TimezoneUtil.utc_now() + timedelta(minutes=30) + ) to_encode.update({'exp': expire}) return JwtUtil.encode(to_encode) @@ -285,6 +285,7 @@ async def get_current_user( current_user = CurrentUserModel( permissions=permissions, roles=roles, + appTimezone=AppConfig.app_timezone, user=UserInfoModel( **CamelCaseUtil.transform_result(query_user.get('user_basic_info')), postIds=post_ids, @@ -344,7 +345,7 @@ async def __password_is_expired(cls, request: Request, pwd_update_date: datetime if pwd_update_date is None: return True expire_date = pwd_update_date + timedelta(days=int(password_validate_days)) - if datetime.now() > expire_date: + if TimezoneUtil.utc_now() > expire_date: return True return False @@ -493,7 +494,7 @@ async def register_user_services( userName=user_register.username, nickName=user_register.username, password=PwdUtil.get_password_hash(user_register.password), - pwdUpdateDate=datetime.now(), + pwdUpdateDate=TimezoneUtil.utc_now(), ) result = await UserService.add_user_services(query_db, add_user) return result diff --git a/ruoyi-fastapi-backend/module_admin/service/server_service.py b/ruoyi-fastapi-backend/module_admin/service/server_service.py index ca840905f..8c70add4c 100644 --- a/ruoyi-fastapi-backend/module_admin/service/server_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/server_service.py @@ -2,6 +2,7 @@ import platform import socket import time +from datetime import datetime, timezone import anyio import psutil @@ -54,7 +55,7 @@ async def get_server_monitor_info() -> ServerMonitorModel: python_version = platform.python_version() python_home = current_process.exe() start_time_stamp = current_process.create_time() - start_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time_stamp)) + start_time = datetime.fromtimestamp(start_time_stamp, tz=timezone.utc) current_time_stamp = time.time() difference = current_time_stamp - start_time_stamp # 将时间差转换为天、小时和分钟数 diff --git a/ruoyi-fastapi-backend/module_admin/service/user_service.py b/ruoyi-fastapi-backend/module_admin/service/user_service.py index 66565b21e..2205fb6ed 100644 --- a/ruoyi-fastapi-backend/module_admin/service/user_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/user_service.py @@ -41,6 +41,7 @@ from utils.common_util import CamelCaseUtil from utils.excel_util import ExcelUtil from utils.pwd_util import PwdUtil +from utils.time_util import TimezoneUtil class UserService: @@ -344,6 +345,28 @@ async def user_detail_services(cls, query_db: AsyncSession, user_id: int | str) return UserDetailModel(posts=posts, roles=roles) + @classmethod + async def update_user_timezone_services( + cls, query_db: AsyncSession, user_id: int, user_name: str, time_zone: str + ) -> CrudResponseModel: + """ + 更新当前账号的显示时区 + + :param query_db: 数据库会话 + :param user_id: 当前登录用户ID + :param user_name: 当前登录用户账号 + :param time_zone: auto或IANA时区名称 + :return: 时区更新结果 + """ + preference = TimezoneUtil.validate_timezone_preference(time_zone) + try: + await UserDao.edit_user_dao(query_db, {'user_id': user_id, 'time_zone': preference, 'update_by': user_name}) + await query_db.commit() + except Exception: + await query_db.rollback() + raise + return CrudResponseModel(is_success=True, message='时区设置已保存') + @classmethod async def user_profile_services(cls, query_db: AsyncSession, user_id: int) -> UserProfileModel: """ diff --git a/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py b/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py index 44ffed84a..d46449582 100644 --- a/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py +++ b/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py @@ -1,6 +1,5 @@ from collections.abc import Sequence from dataclasses import dataclass -from datetime import datetime, time from typing import Any from sqlalchemy import Row, bindparam, delete, func, select, text, update @@ -8,8 +7,10 @@ from sqlalchemy.orm import selectinload from sqlglot.expressions import Expression +from common.types import DbUtcDateTime from common.vo import PageModel from config.env import DataBaseConfig, DataSourceSettings +from exceptions.exception import ServiceWarning from module_generator.entity.do.gen_do import GenTable, GenTableColumn from module_generator.entity.vo.gen_vo import ( GenTableBaseModel, @@ -19,11 +20,14 @@ GenTablePageQueryModel, ) from utils.page_util import PageUtil +from utils.time_util import TimezoneUtil @dataclass(frozen=True, slots=True) class DatabaseMetadataAdapter: - """代码生成器使用的数据库元数据查询。""" + """元数据时间单独定义:MySQL 的目录时间按连接会话时区返回(连接工厂固定 UTC); + PostgreSQL 不提供表创建时间,list_table 返回 NULL,不能用查询时刻冒充。 + """ table_list_query: str tables_by_name_query: str @@ -37,8 +41,8 @@ class DatabaseMetadataAdapter: table_list_query=r""" table_name as table_name, table_comment as table_comment, - create_time as create_time, - update_time as update_time + date_format(create_time, '%Y-%m-%dT%H:%i:%s.000Z') as create_time, + date_format(update_time, '%Y-%m-%dT%H:%i:%s.000Z') as update_time from information_schema.tables where @@ -50,8 +54,8 @@ class DatabaseMetadataAdapter: select table_name as table_name, table_comment as table_comment, - create_time as create_time, - update_time as update_time + date_format(create_time, '%Y-%m-%dT%H:%i:%s.000Z') as create_time, + date_format(update_time, '%Y-%m-%dT%H:%i:%s.000Z') as update_time from information_schema.tables where @@ -77,8 +81,8 @@ class DatabaseMetadataAdapter: order by ordinal_position """, - created_after_filter=" and date_format(create_time, '%Y%m%d') >= date_format(:begin_time, '%Y%m%d')", - created_before_filter=" and date_format(create_time, '%Y%m%d') <= date_format(:end_time, '%Y%m%d')", + created_after_filter=' and create_time >= :begin_time', + created_before_filter=' and create_time < :end_time', ), 'postgresql': DatabaseMetadataAdapter( table_list_query=""" @@ -229,6 +233,7 @@ async def get_gen_table_list( :param is_page: 是否开启分页 :return: 代码生成业务表列表信息对象 """ + time_range = TimezoneUtil.local_date_strings_to_utc(query_object.begin_time, query_object.end_time) query = ( select(GenTable) .options(selectinload(GenTable.columns)) @@ -239,12 +244,8 @@ async def get_gen_table_list( func.lower(GenTable.table_comment).like(f'%{query_object.table_comment.lower()}%') if query_object.table_comment else True, - GenTable.create_time.between( - datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_time and query_object.end_time - else True, + GenTable.create_time >= time_range[0] if time_range and time_range[0] is not None else True, + GenTable.create_time < time_range[1] if time_range and time_range[1] is not None else True, GenTable.data_source_name == query_object.data_source_name if query_object.data_source_name else True, ) .distinct() @@ -302,14 +303,21 @@ async def get_gen_db_table_list( if query_object.table_comment: query_sql += " and lower(table_comment) like lower(concat('%', :table_comment, '%'))" query_params['table_comment'] = query_object.table_comment + metadata_range = TimezoneUtil.local_date_strings_to_utc(query_object.begin_time, query_object.end_time) + if metadata_range and source_config.db_type == 'postgresql': + raise ServiceWarning(message='PostgreSQL 系统目录不记录建表时间,请清除建表日期筛选') if query_object.begin_time: query_sql += metadata.created_after_filter - query_params['begin_time'] = query_object.begin_time + query_params['begin_time'] = metadata_range[0] if query_object.end_time: query_sql += metadata.created_before_filter - query_params['end_time'] = query_object.end_time + query_params['end_time'] = metadata_range[1] query_sql += ' order by create_time desc' statement = text(query_sql) + if 'begin_time' in query_params: + statement = statement.bindparams(bindparam('begin_time', type_=DbUtcDateTime())) + if 'end_time' in query_params: + statement = statement.bindparams(bindparam('end_time', type_=DbUtcDateTime())) if excluded_table_names: statement = statement.bindparams(bindparam('excluded_table_names', expanding=True)) query = select(statement.bindparams(**query_params)) diff --git a/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py b/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py index 9486dcfdb..e313ef8ea 100644 --- a/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py +++ b/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -6,6 +5,8 @@ from pydantic_validation_decorator import NotBlank from common.constant import GenConstant +from common.mixin import DateRangeQueryMixin +from common.types import ApiUtcDateTime from utils.string_util import StringUtil @@ -35,9 +36,9 @@ class GenTableBaseModel(BaseModel): gen_path: str | None = Field(default=None, description='生成路径(不填默认项目路径)') options: str | None = Field(default=None, description='其它生成选项') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') @NotBlank(field_name='table_name', message='表名称不能为空') @@ -100,8 +101,8 @@ class GenTableDbRowModel(BaseModel): table_name: str | None = Field(default=None, description='表名称') table_comment: str | None = Field(default=None, description='表描述') - create_time: datetime | None = Field(default=None, description='创建时间') - update_time: datetime | None = Field(default=None, description='更新时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') class GenDataSourceModel(BaseModel): @@ -164,7 +165,7 @@ class GenTableParamsModel(BaseModel): gen_view: bool = Field(default=False, description='是否生成详情页') -class GenTableQueryModel(GenTableBaseModel): +class GenTableQueryModel(DateRangeQueryMixin, GenTableBaseModel): """ 代码生成业务表不分页查询模型 """ @@ -233,9 +234,9 @@ class GenTableColumnBaseModel(BaseModel): dict_type: str | None = Field(default=None, description='字典类型') sort: int | None = Field(default=None, description='排序') create_by: str | None = Field(default=None, description='创建者') - create_time: datetime | None = Field(default=None, description='创建时间') + create_time: ApiUtcDateTime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, description='更新者') - update_time: datetime | None = Field(default=None, description='更新时间') + update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') @NotBlank(field_name='python_field', message='Python属性不能为空') def get_python_field(self) -> str | None: @@ -261,6 +262,8 @@ class GenTableColumnModel(GenTableColumnBaseModel): query: bool | None = Field(default=None, description='是否查询字段') super_column: bool | None = Field(default=None, description='是否为基类字段') usable_column: bool | None = Field(default=None, description='是否为基类字段白名单') + time_kind: str | None = Field(default=None, exclude=True) + vo_type: str | None = Field(default=None, exclude=True) @model_validator(mode='after') def check_some_is(self) -> 'GenTableModel': @@ -282,7 +285,7 @@ def check_some_is(self) -> 'GenTableModel': return self -class GenTableColumnQueryModel(GenTableColumnBaseModel): +class GenTableColumnQueryModel(DateRangeQueryMixin, GenTableColumnBaseModel): """ 代码生成业务表字段不分页查询模型 """ diff --git a/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 index ab0ebaf68..7998645d7 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 @@ -17,13 +17,12 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel -from {{ packageName }}.service.{{ businessName }}_service import {{ BusinessName }}Service from {{ packageName }}.entity.vo.{{ businessName }}_vo import Delete{{ BusinessName }}Model, {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel +from {{ packageName }}.service.{{ businessName }}_service import {{ BusinessName }}Service from utils.common_util import bytes2file_response from utils.log_util import logger from utils.response_util import ResponseUtil - {{ businessName }}_controller = APIRouterPro( prefix='/{{ moduleName }}/{{ businessName }}', order_num=50, tags=['{{ functionName }}'], dependencies=[PreAuthDependency()] ) diff --git a/ruoyi-fastapi-backend/module_generator/templates/python/dao.py.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/python/dao.py.jinja2 index 3a5e74bcb..4fec4b567 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/python/dao.py.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/python/dao.py.jinja2 @@ -35,9 +35,10 @@ {% for field in sub_edit_audit_fields.fields %} {% set sub_edit_audit_values.value = sub_edit_audit_values.value ~ "'" ~ field ~ "'" ~ (", " if not loop.last else "") %} {% endfor %} +{% set has_datetime_range = namespace(value=False) %} {% for column in columns %} -{% if column.query and column.query_type == 'BETWEEN' and column.column_name == "create_time" %} -from datetime import datetime, time +{% if column.query and column.time_kind == "instant" and column.query_type == "BETWEEN" %} +{% set has_datetime_range.value = True %} {% endif %} {% endfor %} from typing import Any @@ -50,13 +51,16 @@ from sqlalchemy.orm import selectinload from common.vo import PageModel {% if table.sub %} -from {{ packageName }}.entity.do.{{ businessName }}_do import {{ ClassName }}, {{ subClassName }} -from {{ packageName }}.entity.vo.{{ businessName }}_vo import {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel, {{ subTable.business_name | capitalize }}Model +from {{ packageName }}.entity.do.{{ businessName }}_do import {{ [ClassName, subClassName] | sort | join(', ') }} +from {{ packageName }}.entity.vo.{{ businessName }}_vo import {{ [BusinessName ~ 'Model', BusinessName ~ 'PageQueryModel', (subTable.business_name | capitalize) ~ 'Model'] | sort | join(', ') }} {% else %} from {{ packageName }}.entity.do.{{ businessName }}_do import {{ ClassName }} from {{ packageName }}.entity.vo.{{ businessName }}_vo import {{ BusinessName }}Model, {{ BusinessName }}PageQueryModel {% endif %} from utils.page_util import PageUtil +{% if has_datetime_range.value %} +from utils.time_util import TimezoneUtil +{% endif %} class {{ BusinessName }}Dao: @@ -132,6 +136,14 @@ class {{ BusinessName }}Dao: :param is_page: 是否开启分页 :return: {{ functionName }}列表信息对象 """ + {% for column in columns %} + {% if column.query and column.time_kind == "instant" and column.query_type == "BETWEEN" %} + {{ column.column_name }}_range = TimezoneUtil.local_date_strings_to_utc( + query_object.begin_{{ column.column_name }}, query_object.end_{{ column.column_name }}, + timezone_name=TimezoneUtil.get_request_timezone(), + ) + {% endif %} + {% endfor %} query = ( {% if table.sub %} select({{ ClassName }}) @@ -157,13 +169,12 @@ class {{ BusinessName }}Dao: {{ ClassName }}.{{ field }} <= query_object.{{ field }} if query_object.{{ field }} else True, {% elif column.query_type == "LIKE" %} {{ ClassName }}.{{ field }}.like(f'%{% raw %}{{% endraw %}query_object.{{ field }}{% raw %}}{% endraw %}%') if query_object.{{ field }} else True, - {% elif column.html_type == "datetime" and column.query_type == "BETWEEN" %} - {{ ClassName }}.{{ field }}.between( - datetime.combine(datetime.strptime(query_object.begin_{{ column.column_name }}, '%Y-%m-%d'), time(00, 00, 00)), - datetime.combine(datetime.strptime(query_object.end_{{ column.column_name }}, '%Y-%m-%d'), time(23, 59, 59)), - ) - if query_object.begin_{{ column.column_name }} and query_object.end_{{ column.column_name }} - else True, + {% elif column.time_kind in ['date', 'time'] and column.query_type == "BETWEEN" %} + {{ ClassName }}.{{ field }} >= query_object.begin_{{ column.column_name }} if query_object.begin_{{ column.column_name }} is not None else True, + {{ ClassName }}.{{ field }} <= query_object.end_{{ column.column_name }} if query_object.end_{{ column.column_name }} is not None else True, + {% elif column.time_kind == "instant" and column.query_type == "BETWEEN" %} + {{ ClassName }}.{{ field }} >= {{ column.column_name }}_range[0] if {{ column.column_name }}_range and {{ column.column_name }}_range[0] is not None else True, + {{ ClassName }}.{{ field }} < {{ column.column_name }}_range[1] if {{ column.column_name }}_range and {{ column.column_name }}_range[1] is not None else True, {% endif %} {% endif %} {% endfor %} diff --git a/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 index c81f3f55f..78cd1bb99 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 @@ -1,17 +1,17 @@ {% set main_time = namespace(create=None, update=None) %} {% for column in columns %} -{% if column.column_name == "create_time" and column.python_type == "datetime" %} +{% if column.column_name == "create_time" and column.time_kind == "instant" %} {% set main_time.create = column %} -{% elif column.column_name == "update_time" and column.python_type == "datetime" %} +{% elif column.column_name == "update_time" and column.time_kind == "instant" %} {% set main_time.update = column %} {% endif %} {% endfor %} {% set sub_time = namespace(create=None, update=None) %} {% if table.sub %} {% for column in subTable.columns %} -{% if column.column_name == "create_time" and column.python_type == "datetime" %} +{% if column.column_name == "create_time" and column.time_kind == "instant" %} {% set sub_time.create = column %} -{% elif column.column_name == "update_time" and column.python_type == "datetime" %} +{% elif column.column_name == "update_time" and column.time_kind == "instant" %} {% set sub_time.update = column %} {% endif %} {% endfor %} @@ -26,8 +26,10 @@ {% if (main_time.update and not main_time.create) or (sub_time.update and not sub_time.create) %} {% set time_mixin_imports.names = time_mixin_imports.names + ['UpdateTimeMixin'] %} {% endif %} -{% for do_import in doImportList %} +{% for do_import in doImportList | sort %} +{% if not do_import.startswith('from common.') %} {{ do_import }} +{% endif %} {% endfor %} {% if table.sub %} from sqlalchemy.orm import relationship @@ -36,6 +38,11 @@ from sqlalchemy.orm import relationship {% if time_mixin_imports.names %} from common.mixin import {{ time_mixin_imports.names | join(', ') }} {% endif %} +{% for do_import in doImportList %} +{% if do_import.startswith('from common.') %} +{{ do_import }} +{% endif %} +{% endfor %} {% if dataSourceName == defaultDataSourceName %} from config.database import Base {% else %} @@ -67,8 +74,8 @@ class {{ ClassName }}({% if main_time.create and main_time.update %}AuditTimeMix {% endif %} {% for column in columns %} - {% if not (column.column_name in ["create_time", "update_time"] and column.python_type == "datetime") %} - {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type(dataSourceName) }}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {% if not (column.column_name in ["create_time", "update_time"] and column.time_kind == "instant") %} + {{ column.column_name }} = Column({% if column.time_kind == "instant" %}DbUtcDateTime(){% else %}{{ column.column_type | get_sqlalchemy_type(dataSourceName) }}{% endif %}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') {% endif %} {% endfor %} @@ -98,8 +105,8 @@ class {{ subClassName }}({% if sub_time.create and sub_time.update %}AuditTimeMi {% endif %} {% for column in subTable.columns %} - {% if not (column.column_name in ["create_time", "update_time"] and column.python_type == "datetime") %} - {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type(dataSourceName) }}, {% if column.column_name == subTableFkName %}ForeignKey('{{ tableName }}.{{ subTableFkName }}'), {% endif %}{% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {% if not (column.column_name in ["create_time", "update_time"] and column.time_kind == "instant") %} + {{ column.column_name }} = Column({% if column.time_kind == "instant" %}DbUtcDateTime(){% else %}{{ column.column_type | get_sqlalchemy_type(dataSourceName) }}{% endif %}, {% if column.column_name == subTableFkName %}ForeignKey('{{ tableName }}.{{ pkColumn.column_name }}'), {% endif %}{% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') {% endif %} {% endfor %} diff --git a/ruoyi-fastapi-backend/module_generator/templates/python/vo.py.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/python/vo.py.jinja2 index a21fb84aa..54afa8b53 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/python/vo.py.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/python/vo.py.jinja2 @@ -8,7 +8,7 @@ {% if column.required %} {% set vo_field_required.has_required = True %} {% endif %} -{% if column.html_type == "datetime" and column.query_type == "BETWEEN" %} +{% if column.time_kind and column.query_type == "BETWEEN" %} {% set vo_field_daterange.has_daterange = True %} {% endif %} {% endfor %} @@ -21,8 +21,13 @@ {% endfor %} {% endif %} {% for vo_import in voImportList %} +{% if not vo_import.startswith('from common.') %} {{ vo_import }} +{% endif %} {% endfor %} +{% if vo_field_daterange.has_daterange %} +from typing import ClassVar +{% endif %} from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel @@ -30,6 +35,15 @@ from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank {% endif %} +{% if vo_field_daterange.has_daterange %} +from common.mixin import GeneratedTimeRangeQueryMixin +{% endif %} +{% for vo_import in voImportList %} +{% if vo_import.startswith('from common.') %} +{{ vo_import }} +{% endif %} +{% endfor %} + {% if table.sub %} class {{ BusinessName }}BaseModel(BaseModel): @@ -40,7 +54,7 @@ class {{ BusinessName }}BaseModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) {% for column in columns %} - {{ column.column_name }}: {{ column.python_type }} | None = Field(default=None, description='{{ column.column_comment }}') + {{ column.column_name }}: {{ column.vo_type }} | None = Field(default=None, description='{{ column.column_comment }}') {% endfor %} {% for column in columns %} @@ -48,7 +62,7 @@ class {{ BusinessName }}BaseModel(BaseModel): {% set parentheseIndex = column.column_comment.find("(") %} {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} @NotBlank(field_name='{{ column.column_name }}', message='{{ comment }}不能为空') - def get_{{ column.column_name }}(self) -> {{ column.python_type }} | None: + def get_{{ column.column_name }}(self) -> {{ column.vo_type }} | None: return self.{{ column.column_name }} {% if not loop.last %}{{ "\n" }}{% endif %} {% endif %} @@ -62,9 +76,9 @@ class {{ BusinessName }}BaseModel(BaseModel): {% endif %} {% endfor %} {% endif %} -{% endif %} +{% endif %} class {{ BusinessName }}Model({% if table.sub %}{{ BusinessName }}BaseModel{% else %}BaseModel{% endif %}): """ {{ functionName }}表对应pydantic模型 @@ -73,7 +87,7 @@ class {{ BusinessName }}Model({% if table.sub %}{{ BusinessName }}BaseModel{% el model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) {% for column in columns %} - {{ column.column_name }}: {{ column.python_type }} | None = Field(default=None, description='{{ column.column_comment }}') + {{ column.column_name }}: {{ column.vo_type }} | None = Field(default=None, description='{{ column.column_comment }}') {% endfor %} {% endif %} {% if table.sub %} @@ -86,7 +100,7 @@ class {{ BusinessName }}Model({% if table.sub %}{{ BusinessName }}BaseModel{% el {% set parentheseIndex = column.column_comment.find("(") %} {% set comment = column.column_comment[:parentheseIndex] if parentheseIndex != -1 else column.column_comment %} @NotBlank(field_name='{{ column.column_name }}', message='{{ comment }}不能为空') - def get_{{ column.column_name }}(self) -> {{ column.python_type }} | None: + def get_{{ column.column_name }}(self) -> {{ column.vo_type }} | None: return self.{{ column.column_name }} {% if not loop.last %}{{ "\n" }}{% endif %} {% endif %} @@ -112,7 +126,7 @@ class {{ subTable.business_name | capitalize }}Model(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) {% for sub_column in subTable.columns %} - {{ sub_column.column_name }}: {{ sub_column.python_type }} | None = Field(default=None, description='{{ sub_column.column_comment}}') + {{ sub_column.column_name }}: {{ sub_column.vo_type }} | None = Field(default=None, description='{{ sub_column.column_comment}}') {% endfor %} {% for sub_column in subTable.columns %} @@ -120,7 +134,7 @@ class {{ subTable.business_name | capitalize }}Model(BaseModel): {% set parentheseIndex = sub_column.column_comment.find("(") %} {% set comment = sub_column.column_comment[:parentheseIndex] if parentheseIndex != -1 else sub_column.column_comment %} @NotBlank(field_name='{{ sub_column.column_name }}', message='{{ comment }}不能为空') - def get_{{ sub_column.column_name }}(self) -> {{ sub_column.python_type }} | None: + def get_{{ sub_column.column_name }}(self) -> {{ sub_column.vo_type }} | None: return self.{{ sub_column.column_name }} {% if not loop.last %}{{ "\n" }}{% endif %} {% endif %} @@ -137,15 +151,23 @@ class {{ subTable.business_name | capitalize }}Model(BaseModel): {% endif %} -class {{ BusinessName }}QueryModel({% if table.sub %}{{ BusinessName }}BaseModel{% else %}{{ BusinessName }}Model{% endif %}): +class {{ BusinessName }}QueryModel({% if vo_field_daterange.has_daterange %}GeneratedTimeRangeQueryMixin, {% endif %}{% if table.sub %}{{ BusinessName }}BaseModel{% else %}{{ BusinessName }}Model{% endif %}): """ {{ functionName }}不分页查询模型 """ {% if vo_field_daterange.has_daterange %} + time_range_fields: ClassVar[dict[str, str]] = { + {% for column in columns %} + {% if column.time_kind and column.query_type == 'BETWEEN' %} + '{{ column.column_name }}': '{{ column.time_kind }}', + {% endif %} + {% endfor %} + } + {% for column in columns %} - {% if column.html_type == "datetime" and column.query_type == "BETWEEN" %} - begin_{{ column.column_name }}: str | None = Field(default=None, description='开始{{ column.column_comment }}') - end_{{ column.column_name }}: str | None = Field(default=None, description='结束{{ column.column_comment }}') + {% if column.time_kind and column.query_type == "BETWEEN" %} + begin_{{ column.column_name }}: {% if column.time_kind == 'time' %}BusinessTime{% else %}BusinessDate{% endif %} | None = Field(default=None, description='开始{{ column.column_comment }}') + end_{{ column.column_name }}: {% if column.time_kind == 'time' %}BusinessTime{% else %}BusinessDate{% endif %} | None = Field(default=None, description='结束{{ column.column_comment }}') {% endif %} {% endfor %} {% else %} diff --git a/ruoyi-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 index e6561ffba..cf3ab29d8 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/vue/index-tree.vue.jinja2 @@ -1,3 +1,33 @@ +{% set hasUserDateQuery = columns | selectattr('query') | selectattr('time_kind', 'equalto', 'instant') | selectattr('query_type', 'equalto', 'BETWEEN') | list %} +{% macro picker(column, model, vue3=True, range=False) -%} +{% set date_format = 'YYYY-MM-DD' if vue3 else 'yyyy-MM-dd' %} +{% if column.time_kind == 'instant' and not range %} + +{% elif column.time_kind == 'time' %} + +{% else %} + +{% endif %} +{%- endmacro %} diff --git a/ruoyi-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 index d6d8739d3..55ddb82ce 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/vue/v3/index.vue.jinja2 @@ -1,3 +1,33 @@ +{% set hasUserDateQuery = columns | selectattr('query') | selectattr('time_kind', 'equalto', 'instant') | selectattr('query_type', 'equalto', 'BETWEEN') | list %} +{% macro picker(column, model, vue3=True, range=False) -%} +{% set date_format = 'YYYY-MM-DD' if vue3 else 'yyyy-MM-dd' %} +{% if column.time_kind == 'instant' and not range %} + +{% elif column.time_kind == 'time' %} + +{% else %} + +{% endif %} +{%- endmacro %} - {% elif column.list and column.html_type == 'datetime' %} + {% elif column.list and column.time_kind %} {% elif column.list and (column.html_type == 'select' or column.html_type == 'radio') and column.dict_type %} @@ -373,7 +378,9 @@ diff --git a/ruoyi-fastapi-backend/module_generator/templates/vue/v3/view.vue.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/vue/v3/view.vue.jinja2 index e0c0f05a7..edc8c16c7 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/vue/v3/view.vue.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/vue/v3/view.vue.jinja2 @@ -30,8 +30,8 @@ {% else %} {% endif %} - {% elif column.html_type == "datetime" %} - {% raw %}{{{% endraw %} parseTime(info.{{ pythonField }}, '{y}-{m}-{d}') {% raw %}}}{% endraw %} + {% elif column.time_kind == "instant" %} + {% raw %}{{{% endraw %} parseTime(info.{{ pythonField }}) {% raw %}}}{% endraw %} {% elif column.html_type == "imageUpload" %} {% else %} diff --git a/ruoyi-fastapi-backend/module_generator/templates/vue/view.vue.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/vue/view.vue.jinja2 index a318815cb..d160cc6e2 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/vue/view.vue.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/vue/view.vue.jinja2 @@ -30,8 +30,8 @@ {% else %} {% endif %} - {% elif column.html_type == "datetime" %} - {% raw %}{{{% endraw %} parseTime(info.{{ pythonField }}, '{y}-{m}-{d}') {% raw %}}}{% endraw %} + {% elif column.time_kind == "instant" %} + {% raw %}{{{% endraw %} parseTime(info.{{ pythonField }}) {% raw %}}}{% endraw %} {% elif column.html_type == "imageUpload" %} {% else %} @@ -55,6 +55,7 @@ diff --git a/ruoyi-fastapi-frontend/src/api/monitor/job.js b/ruoyi-fastapi-frontend/src/api/monitor/job.js index 38155693a..96d39e1eb 100644 --- a/ruoyi-fastapi-frontend/src/api/monitor/job.js +++ b/ruoyi-fastapi-frontend/src/api/monitor/job.js @@ -1,11 +1,24 @@ import request from '@/utils/request' +// 预览定时任务执行时刻 +export function previewJob(data, signal) { + return request({ + url: '/monitor/job/preview', + method: 'post', + data, + signal, + skipErrorMessage: true, + headers: { repeatSubmit: false } + }) +} + // 查询定时任务调度列表 -export function listJob(query) { +export function listJob(query, options = {}) { return request({ url: '/monitor/job/list', method: 'get', - params: query + params: query, + ...options }) } @@ -58,14 +71,50 @@ export function changeJobStatus(jobId, status) { // 定时任务立即执行一次 -export function runJob(jobId, jobGroup) { +export function runJob(jobId) { const data = { - jobId, - jobGroup + jobId } return request({ url: '/monitor/job/run', method: 'put', data: data }) -} \ No newline at end of file +} + +// 查询独立执行请求和执行结果 +export function listJobExecutions(query, options = {}) { + return request({ + url: '/monitor/job/execution/list', + method: 'get', + params: query, + ...options + }) +} + +// 查询定时任务执行结果 +export function getJobExecution(executionId, options = {}) { + return request({ + url: '/monitor/job/execution/' + executionId, + method: 'get', + ...options + }) +} + +// 查询同步状态,包括已删除任务的同步记录 +export function listJobSync(query, options = {}) { + return request({ + url: '/monitor/job/sync/list', + method: 'get', + params: query, + ...options + }) +} + +// 重试定时任务调度同步 +export function retryJobSync(jobId) { + return request({ + url: '/monitor/job/sync/' + jobId, + method: 'post' + }) +} diff --git a/ruoyi-fastapi-frontend/src/api/system/user.js b/ruoyi-fastapi-frontend/src/api/system/user.js index b5e3edd81..c9f2ccfd1 100644 --- a/ruoyi-fastapi-frontend/src/api/system/user.js +++ b/ruoyi-fastapi-frontend/src/api/system/user.js @@ -78,6 +78,16 @@ export function getUserProfile() { }) } +// 查询服务端支持的显示时区 +export function getTimezoneOptions() { + return request({ url: '/system/user/profile/timezones', method: 'get' }) +} + +// 修改当前账号的显示时区 +export function updateUserTimezone(timeZone) { + return request({ url: '/system/user/profile/timezone', method: 'put', data: { timeZone } }) +} + // 修改用户个人信息 export function updateUserProfile(data) { return request({ diff --git a/ruoyi-fastapi-frontend/src/components/BusinessDateTimePicker/index.vue b/ruoyi-fastapi-frontend/src/components/BusinessDateTimePicker/index.vue new file mode 100644 index 000000000..62926262e --- /dev/null +++ b/ruoyi-fastapi-frontend/src/components/BusinessDateTimePicker/index.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/components/Crontab/index.vue b/ruoyi-fastapi-frontend/src/components/Crontab/index.vue index cbb41abc2..9fd91e25f 100644 --- a/ruoyi-fastapi-frontend/src/components/Crontab/index.vue +++ b/ruoyi-fastapi-frontend/src/components/Crontab/index.vue @@ -113,7 +113,7 @@ - +
确定 @@ -143,6 +143,10 @@ const props = defineProps({ expression: { type: String, default: "" + }, + timeZone: { + type: String, + required: true } }) const tabTitles = ref(["秒", "分钟", "小时", "日", "月", "周", "年"]) @@ -310,4 +314,4 @@ onMounted(() => { height: 10em; overflow-y: auto; } - \ No newline at end of file + diff --git a/ruoyi-fastapi-frontend/src/components/Crontab/result.vue b/ruoyi-fastapi-frontend/src/components/Crontab/result.vue index 5a812eeff..548e5dbc1 100644 --- a/ruoyi-fastapi-frontend/src/components/Crontab/result.vue +++ b/ruoyi-fastapi-frontend/src/components/Crontab/result.vue @@ -1,540 +1,76 @@ + + diff --git a/ruoyi-fastapi-frontend/src/components/JsonEditor/index.vue b/ruoyi-fastapi-frontend/src/components/JsonEditor/index.vue new file mode 100644 index 000000000..cdd5c6389 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/components/JsonEditor/index.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/DetailView.vue b/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/DetailView.vue index fa08e03da..ac133f1a1 100644 --- a/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/DetailView.vue +++ b/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/DetailView.vue @@ -27,7 +27,7 @@ - {{ detail.createTime || '—' }} + {{ parseTime(detail.createTime) || '—' }} diff --git a/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/index.vue b/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/index.vue index 2dfabd0f3..e69a73030 100644 --- a/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/index.vue +++ b/ruoyi-fastapi-frontend/src/layout/components/HeaderNotice/index.vue @@ -19,7 +19,7 @@ {{ item.noticeType === '1' ? '通知' : '公告' }} {{ item.noticeTitle }} - {{ item.createTime }} + {{ parseTime(item.createTime) || '-' }}
diff --git a/ruoyi-fastapi-frontend/src/store/modules/user.js b/ruoyi-fastapi-frontend/src/store/modules/user.js index 94c8738a8..719ff76d6 100644 --- a/ruoyi-fastapi-frontend/src/store/modules/user.js +++ b/ruoyi-fastapi-frontend/src/store/modules/user.js @@ -3,6 +3,7 @@ import cache from '@/plugins/cache' import { ElMessageBox } from 'element-plus' import { login, logout, getInfo } from '@/api/login' import { getToken, setToken, removeToken } from '@/utils/auth' +import { setBusinessTimezone, setUserTimezone } from '@/utils/time' import { isHttp, isEmpty } from "@/utils/validate" import useLockStore from '@/store/modules/lock' import defAva from '@/assets/images/profile.jpg' @@ -16,10 +17,17 @@ const useUserStore = defineStore( name: '', nickName: '', avatar: '', + appTimezone: 'Asia/Shanghai', + timeZone: 'auto', roles: [], permissions: [] }), actions: { + // 保存成功后同步当前页面使用的偏好。 + applyTimezone(preference = 'auto') { + setUserTimezone(preference) + this.timeZone = preference + }, // 登录 login(userInfo) { const username = userInfo.username.trim() @@ -42,6 +50,9 @@ const useUserStore = defineStore( return new Promise((resolve, reject) => { getInfo().then(res => { const user = res.user + this.appTimezone = res.appTimezone + setBusinessTimezone(res.appTimezone) + this.applyTimezone(user.timeZone) let avatar = user.avatar || "" if (!isHttp(avatar)) { avatar = (isEmpty(avatar)) ? defAva : import.meta.env.VITE_APP_BASE_API + avatar @@ -80,6 +91,7 @@ const useUserStore = defineStore( return new Promise((resolve, reject) => { logout(this.token).then(() => { this.token = '' + this.applyTimezone('auto') this.roles = [] this.permissions = [] removeToken() diff --git a/ruoyi-fastapi-frontend/src/utils/index.js b/ruoyi-fastapi-frontend/src/utils/index.js index af9d0e21e..de8daec56 100644 --- a/ruoyi-fastapi-frontend/src/utils/index.js +++ b/ruoyi-fastapi-frontend/src/utils/index.js @@ -4,15 +4,7 @@ import { parseTime } from './ruoyi' * 表格时间格式化 */ export function formatDate(cellValue) { - if (cellValue == null || cellValue == "") return ""; - const date = new Date(cellValue) - const year = date.getFullYear() - const month = date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1 - const day = date.getDate() < 10 ? '0' + date.getDate() : date.getDate() - const hours = date.getHours() < 10 ? '0' + date.getHours() : date.getHours() - const minutes = date.getMinutes() < 10 ? '0' + date.getMinutes() : date.getMinutes() - const seconds = date.getSeconds() < 10 ? '0' + date.getSeconds() : date.getSeconds() - return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds + return parseTime(cellValue) || '' } /** @@ -21,15 +13,12 @@ export function formatDate(cellValue) { * @returns {string} */ export function formatTime(time, option) { - if (('' + time).length === 10) { - time = parseInt(time) * 1000 - } else { - time = +time - } - const d = new Date(time) + const formattedTime = parseTime(time, option || '{m}月{d}日{h}时{i}分') + if (!formattedTime) return '' + time = new Date(time).getTime() const now = Date.now() - const diff = (now - d) / 1000 + const diff = (now - time) / 1000 if (diff < 30) { return '刚刚' @@ -41,21 +30,7 @@ export function formatTime(time, option) { } else if (diff < 3600 * 24 * 2) { return '1天前' } - if (option) { - return parseTime(time, option) - } else { - return ( - d.getMonth() + - 1 + - '月' + - d.getDate() + - '日' + - d.getHours() + - '时' + - d.getMinutes() + - '分' - ) - } + return formattedTime } /** diff --git a/ruoyi-fastapi-frontend/src/utils/job.js b/ruoyi-fastapi-frontend/src/utils/job.js new file mode 100644 index 000000000..359369e96 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/utils/job.js @@ -0,0 +1,61 @@ +/** 校验并解析任务参数,保留JSON值类型 */ +export function parseJobParameter(value, field) { + const isArgs = field === 'jobArgs' + const message = isArgs ? '位置参数必须是JSON数组,例如 ["test", 1]' : '关键字参数必须是JSON对象,例如 {"enabled": true}' + let result + try { + result = JSON.parse(value) + } catch { + throw new Error(message) + } + if (isArgs ? !Array.isArray(result) : result === null || Array.isArray(result) || typeof result !== 'object') { + throw new Error(message) + } + JSON.stringify(result, (_key, item) => { + if (typeof item === 'number' && !Number.isFinite(item)) { + throw new Error('参数中的数字超出可表示范围') + } + return item + }) + return result +} + +/** 将表单中的JSON文本转为接口数组和对象 */ +export function buildJobPayload(form) { + return { + ...form, + jobArgs: parseJobParameter(form.jobArgs, 'jobArgs'), + jobKwargs: parseJobParameter(form.jobKwargs, 'jobKwargs') + } +} + +// 任务调度同步状态 +export const syncStates = { + pending: { label: '待同步', type: 'warning' }, + applied: { label: '已生效', type: 'success' }, + failed: { label: '同步失败', type: 'danger' } +} + +// 任务执行状态 +export const executionStates = { + pending: { label: '等待派发', type: 'info' }, + submitted: { label: '已派发', type: 'primary' }, + running: { label: '执行中', type: 'primary' }, + success: { label: '执行成功', type: 'success' }, + failed: { label: '执行失败', type: 'danger' }, + rejected: { label: '达到并发上限', type: 'warning' }, + missed: { label: '已过期', type: 'warning' }, + cancelled: { label: '已取消', type: 'info' }, + unknown: { label: '结果待确认', type: 'warning' } +} + +/** 显示任务变更的提交及同步结果 */ +export function notifyJobMutation(modal, response) { + if (response.data?.syncStatus === 'failed') { + modal.msgWarning(response.msg) + } else if (response.data?.syncStatus === 'pending') { + modal.msg(response.msg) + } else { + modal.msgSuccess(response.msg) + } +} diff --git a/ruoyi-fastapi-frontend/src/utils/request.js b/ruoyi-fastapi-frontend/src/utils/request.js index e73e4a70c..7a55184c2 100644 --- a/ruoyi-fastapi-frontend/src/utils/request.js +++ b/ruoyi-fastapi-frontend/src/utils/request.js @@ -1,6 +1,7 @@ import axios from 'axios' import { ElNotification , ElMessageBox, ElMessage, ElLoading } from 'element-plus' import { getToken } from '@/utils/auth' +import { getDisplayTimezone } from '@/utils/time'; import errorCode from '@/utils/errorCode' import { tansParams, blobValidate } from '@/utils/ruoyi' import cache from '@/plugins/cache' @@ -36,6 +37,7 @@ const service = axios.create({ */ // request拦截器 service.interceptors.request.use(async config => { + config.headers['X-Timezone'] = config.headers['X-Timezone'] || getDisplayTimezone() // 是否需要设置 token const isToken = (config.headers || {}).isToken === false // 是否需要防止数据重复提交 @@ -48,6 +50,7 @@ service.interceptors.request.use(async config => { if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) { const requestObj = { url: config.url, + timezone: config.headers['X-Timezone'], data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data, time: new Date().getTime() } @@ -64,7 +67,7 @@ service.interceptors.request.use(async config => { const s_url = sessionObj.url; // 请求地址 const s_data = sessionObj.data; // 请求数据 const s_time = sessionObj.time; // 请求时间 - if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) { + if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url && sessionObj.timezone === requestObj.timezone) { const message = '数据正在处理,请勿重复提交'; console.warn(`[${s_url}]: ` + message) return Promise.reject(new Error(message)) @@ -133,6 +136,7 @@ service.interceptors.response.use(async res => { } }, async error => { + if (axios.isCancel(error)) return Promise.reject(error) // 错误响应也可能是加密信封,先尝试解密再进入统一错误提示流程。 error = await decryptTransportErrorResponse(error) // 若后端提示密钥失效,则清空本地公钥缓存并基于原始请求重试一次。 @@ -149,6 +153,14 @@ service.interceptors.response.use(async res => { const responseStatus = response?.status const responseCode = response?.data?.code const responseMsg = response?.data?.msg + if (responseStatus === 422) { + const details = Array.isArray(response?.data?.detail) ? response.data.detail : [] + error.fieldErrors = Object.fromEntries(details.map(item => [item.loc?.slice(1).join('.') || '参数', item.msg])) + error.message = details.map(item => `${item.loc?.slice(1).join('.') || '参数'}:${item.msg}`).join(';') || responseMsg || '请检查输入参数' + if (!error.config?.skipErrorMessage) ElMessage({ message: error.message, type: 'warning', duration: 5000 }) + return Promise.reject(error) + } + if (error.config?.skipErrorMessage) return Promise.reject(error) if (responseMsg) { const messageType = responseStatus === 429 || responseCode === 429 ? 'warning' : 'error' ElMessage({ message: responseMsg, type: messageType, duration: 5 * 1000 }) diff --git a/ruoyi-fastapi-frontend/src/utils/ruoyi.js b/ruoyi-fastapi-frontend/src/utils/ruoyi.js index 692ca16db..e64b53904 100644 --- a/ruoyi-fastapi-frontend/src/utils/ruoyi.js +++ b/ruoyi-fastapi-frontend/src/utils/ruoyi.js @@ -5,45 +5,11 @@ * Copyright (c) 2019 ruoyi */ +import { formatBusinessTime, normalizeRangeBoundary } from '@/utils/time' + // 日期格式化 export function parseTime(time, pattern) { - if (arguments.length === 0 || !time) { - return null - } - const format = pattern || '{y}-{m}-{d} {h}:{i}:{s}' - let date - if (typeof time === 'object') { - date = time - } else { - if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) { - time = parseInt(time) - } else if (typeof time === 'string') { - time = time.replace(new RegExp(/-/gm), '/').replace('T', ' ').replace(new RegExp(/\.[\d]{3}/gm), ''); - } - if ((typeof time === 'number') && (time.toString().length === 10)) { - time = time * 1000 - } - date = new Date(time) - } - const formatObj = { - y: date.getFullYear(), - m: date.getMonth() + 1, - d: date.getDate(), - h: date.getHours(), - i: date.getMinutes(), - s: date.getSeconds(), - a: date.getDay() - } - const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => { - let value = formatObj[key] - // Note: getDay() returns 0 on Sunday - if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] } - if (result.length > 0 && value < 10) { - value = '0' + value - } - return value || 0 - }) - return time_str + return formatBusinessTime(time, pattern) } // 表单重置 @@ -58,11 +24,11 @@ export function addDateRange(params, dateRange, propName) { let search = params; dateRange = Array.isArray(dateRange) ? dateRange : []; if (typeof (propName) === 'undefined') { - search['beginTime'] = dateRange[0]; - search['endTime'] = dateRange[1]; + search['beginTime'] = normalizeRangeBoundary(dateRange[0]); + search['endTime'] = normalizeRangeBoundary(dateRange[1]); } else { - search['begin' + propName] = dateRange[0]; - search['end' + propName] = dateRange[1]; + search['begin' + propName] = normalizeRangeBoundary(dateRange[0]); + search['end' + propName] = normalizeRangeBoundary(dateRange[1]); } return search; } diff --git a/ruoyi-fastapi-frontend/src/utils/time.js b/ruoyi-fastapi-frontend/src/utils/time.js new file mode 100644 index 000000000..126dfc0fb --- /dev/null +++ b/ruoyi-fastapi-frontend/src/utils/time.js @@ -0,0 +1,563 @@ +import { ElMessage, ElMessageBox } from 'element-plus' +import { ref } from 'vue' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc.js' + +dayjs.extend(utc) + +const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ +const WALL_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/ +const RFC3339_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/ +const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'] +const MILLISECONDS_PER_SECOND = 1000 +const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND +const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE +const ORIGINAL_TIME_FIELDS = Symbol('originalTimeFields') +const timezoneFormatters = new Map() + +const businessTimezone = ref('Asia/Shanghai') +const userTimezone = ref('auto') +const deviceTimezone = ref(null) + +/** + * 日期时间输入校验异常,包含夏令时重复时间的候选值。 + */ +export class TimeInputError extends Error { + /** + * @param {string} message 校验提示 + * @param {string} code 错误类型 + * @param {Array<{epoch: number, offset: string, value: string}>} candidates 可选的真实时刻 + */ + constructor(message, code, candidates = []) { + super(message) + this.code = code + this.candidates = candidates + } +} + +/** + * 获取并缓存指定时区的日期时间格式化器。 + * + * @param {string} timezoneName IANA 时区名称 + * @returns {Intl.DateTimeFormat} 格式化器实例 + */ +function getFormatter(timezoneName) { + if (typeof timezoneName !== 'string' || !timezoneName.trim() || /^[+-]/.test(timezoneName)) { + throw new Error(`无效的 IANA 时区: ${timezoneName}`) + } + + const name = timezoneName.trim() + if (!timezoneFormatters.has(name)) { + try { + const formatter = new Intl.DateTimeFormat('en', { + timeZone: name, + calendar: 'gregory', + numberingSystem: 'latn', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }) + timezoneFormatters.set(name, formatter) + } catch { + throw new Error(`无效的 IANA 时区: ${timezoneName}`) + } + } + return timezoneFormatters.get(name) +} + +/** + * 更新默认业务时区。 + * + * @param {string} timezoneName 服务端返回的 IANA 时区名称 + * @returns {void} + */ +export function setBusinessTimezone(timezoneName) { + getFormatter(timezoneName) + businessTimezone.value = timezoneName.trim() +} + +/** + * 获取当前业务时区。 + * + * @returns {string} IANA 时区名称 + */ +export function getBusinessTimezone() { + return businessTimezone.value +} + +/** + * 设置账号显示偏好。auto 跟随设备;IANA 名称表示手动选择。 + * + * @param {string} preference 账号保存的时区偏好 + * @returns {void} + */ +export function setUserTimezone(preference = 'auto') { + if (preference !== 'auto') { + getFormatter(preference) + } + userTimezone.value = preference.trim() + refreshDeviceTimezone() +} + +/** + * 获取账号时区偏好。 + * + * @returns {string} auto 或 IANA 时区名称 + */ +export function getUserTimezone() { + return userTimezone.value +} + +/** + * 重新识别设备的 IANA 时区。不能从单个 UTC 偏移猜测地区或夏令时规则。 + * + * @returns {string|null} 设备时区;当前运行时不支持识别时返回 null + */ +export function refreshDeviceTimezone() { + try { + // FormatJS 默认时区是 UTC,不能把它误报为设备时区。 + if (Intl.DateTimeFormat.polyfilled) { + deviceTimezone.value = null + return null + } + const name = new Intl.DateTimeFormat().resolvedOptions().timeZone + getFormatter(name) + deviceTimezone.value = name + } catch { + deviceTimezone.value = null + } + return deviceTimezone.value +} + +/** + * 获取最近一次识别的设备时区。 + * + * @returns {string|null} IANA 时区;无法识别时返回 null + */ +export function getDeviceTimezone() { + return deviceTimezone.value +} + +/** + * 获取页面展示、日期输入和普通日期筛选使用的有效时区。 + * + * @returns {string} IANA 时区名称 + */ +export function getDisplayTimezone() { + return userTimezone.value === 'auto' ? deviceTimezone.value || businessTimezone.value : userTimezone.value +} + +/** + * 获取已打开表单的时区,确保控件提示与提交转换一致。 + * + * @param {Object} record 经 prepareTimeFields 准备的表单或子表行 + * @returns {string} 表单绑定的时区 + */ +export function getTimeFieldsTimezone(record) { + return record?.[ORIGINAL_TIME_FIELDS]?.timezoneName || getDisplayTimezone() +} + +/** + * 过滤当前运行时不支持的时区,保留设备时区和已保存的有效选择。 + * + * @param {string[]} names 服务端提供的 IANA 名称 + * @returns {string[]} 按名称排序的可选时区 + */ +export function getSupportedTimezones(names = []) { + return [...new Set([...names, getDisplayTimezone(), getBusinessTimezone(), 'UTC'])] + .filter(name => { + try { + getFormatter(name) + return true + } catch { + return false + } + }) + .sort() +} + +/** + * 校验当地日期时间,并将各时间分量放入 UTC 毫秒坐标以便计算。 + * + * @param {string} value 不含时区的日期或日期时间字符串 + * @param {boolean} dateOnly 是否仅校验纯日期 + * @returns {number|null} 用于日历计算的毫秒值,非法输入返回 null + */ +function parseWallTimeMilliseconds(value, dateOnly = false) { + const match = typeof value === 'string' && value.match(dateOnly ? DATE_ONLY_PATTERN : WALL_TIME_PATTERN) + if (!match) { + return null + } + + const [year, month, day, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number) + const millisecond = Number((match[7] || '').padEnd(3, '0')) + const date = new Date(0) + date.setUTCFullYear(year, month - 1, day) + date.setUTCHours(hour, minute, second, millisecond) + + // Date 会自动进位非法日期,逐项回读以拒绝越界输入。 + if ( + year < 1 || + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second + ) { + return null + } + return date.getTime() +} + +/** + * 解析带偏移的真实时刻,拒绝无时区的日期时间。 + * + * @param {string|Date} value RFC 3339 字符串或 Date 对象 + * @returns {number|null} Unix 毫秒时间戳,非法输入返回 null + */ +function parseInstantMilliseconds(value) { + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value.getTime() : null + } + + const match = typeof value === 'string' && value.match(RFC3339_PATTERN) + if (!match || parseWallTimeMilliseconds(match[1]) === null) { + return null + } + + const result = Date.parse(`${match[1]}${(match[2] || '.000').slice(0, 4)}${match[3]}`) + return Number.isFinite(result) ? result : null +} + +/** + * 计算指定时刻在目标时区的 UTC 偏移。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} timezoneName IANA 时区名称 + * @returns {number} 相对 UTC 的偏移分钟数 + */ +function getTimezoneOffsetMinutes(epoch, timezoneName) { + const parts = Object.fromEntries( + getFormatter(timezoneName) + .formatToParts(epoch) + .map(part => [part.type, part.value]) + ) + const wallTime = `${parts.year.padStart(4, '0')}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` + const instantMilliseconds = Math.floor(epoch / MILLISECONDS_PER_SECOND) * MILLISECONDS_PER_SECOND + return (parseWallTimeMilliseconds(wallTime) - instantMilliseconds) / MILLISECONDS_PER_MINUTE +} + +/** + * 按显式偏移创建业务时间,避免经过宿主机本地时区转换。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} timezoneName IANA 时区名称 + * @returns {import('dayjs').Dayjs} 目标时区下的日期时间 + */ +function createZonedDateTime(epoch, timezoneName) { + return dayjs.utc(epoch).utcOffset(getTimezoneOffsetMinutes(epoch, timezoneName)) +} + +/** + * 格式化毫秒时间戳,兼容项目原有的日期格式占位符。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} pattern Day.js 格式或项目日期格式占位符 + * @param {string} timezoneName IANA 时区名称 + * @returns {string} 业务时间展示文本 + */ +function formatEpoch(epoch, pattern, timezoneName) { + const value = createZonedDateTime(epoch, timezoneName) + const format = pattern + .replaceAll('{y}', 'YYYY') + .replaceAll('{m}', 'MM') + .replaceAll('{d}', 'DD') + .replaceAll('{h}', 'HH') + .replaceAll('{i}', 'mm') + .replaceAll('{s}', 'ss') + .replaceAll('{a}', `[${WEEKDAYS[value.day()]}]`) + return value.format(format) +} + +/** + * 将真实时刻格式化为业务时间。 + * + * @param {string|Date} value RFC 3339 字符串或 Date 对象 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatBusinessTime(value, pattern = 'YYYY-MM-DD HH:mm:ss', timezoneName = getDisplayTimezone()) { + const epoch = parseInstantMilliseconds(value) + return epoch === null ? null : formatEpoch(epoch, pattern, timezoneName) +} + +/** + * 格式化明确以毫秒为单位的 Unix 时间戳。 + * + * @param {number} value Unix 毫秒时间戳 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatEpochMilliseconds(value, pattern = 'YYYY-MM-DD HH:mm:ss', timezoneName = getDisplayTimezone()) { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isFinite(new Date(value).getTime())) { + return null + } + return formatEpoch(value, pattern, timezoneName) +} + +/** + * 格式化明确以秒为单位的 Unix 时间戳。 + * + * @param {number} value Unix 秒时间戳 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatEpochSeconds(value, pattern, timezoneName) { + return typeof value === 'number' + ? formatEpochMilliseconds(value * MILLISECONDS_PER_SECOND, pattern, timezoneName) + : null +} + +/** + * 查找业务当地时间对应的真实时刻候选值。 + * + * @param {string} value 不含偏移的业务日期时间 + * @param {string} timezoneName IANA 时区名称 + * @returns {Array<{epoch: number, offset: string, value: string}>} 按时刻排序的候选值 + */ +export function getWallTimeCandidates(value, timezoneName = getDisplayTimezone()) { + const wallMilliseconds = parseWallTimeMilliseconds(value) + if (wallMilliseconds === null) { + throw new TimeInputError(`无效的日期时间: ${value}`, 'INVALID_TIME') + } + + const offsets = new Set() + // 采样跳转前后的偏移,覆盖半小时夏令时和跨日期变更。 + for (let hours = -48; hours <= 48; hours += 6) { + offsets.add(getTimezoneOffsetMinutes(wallMilliseconds + hours * MILLISECONDS_PER_HOUR, timezoneName)) + } + + return [...offsets] + .map(offset => ({ offset, epoch: wallMilliseconds - offset * MILLISECONDS_PER_MINUTE })) + .filter(candidate => getTimezoneOffsetMinutes(candidate.epoch, timezoneName) === candidate.offset) + .sort((first, second) => first.epoch - second.epoch) + .map(candidate => ({ + epoch: candidate.epoch, + offset: createZonedDateTime(candidate.epoch, timezoneName).format('Z'), + value: createZonedDateTime(candidate.epoch, timezoneName).format('YYYY-MM-DDTHH:mm:ss.SSSZ') + })) +} + +/** + * 将真实时刻或业务当地时间转换为毫秒精度 RFC 3339 字符串。 + * + * @param {string|Date} value 原始时刻或业务日期时间 + * @param {Object} options 时区和重复时间的偏移选择 + * @param {string} options.timezoneName 业务 IANA 时区名称 + * @param {string} options.offset 夏令时重复时间使用的 UTC 偏移 + * @returns {string|undefined} RFC 3339 字符串,空输入返回 undefined + * @throws {TimeInputError} 当地时间不存在或重复时间尚未选择偏移时抛出异常 + */ +export function toRfc3339(value, { timezoneName = getDisplayTimezone(), offset } = {}) { + if (value === undefined || value === null || value === '') { + return undefined + } + + const epoch = parseInstantMilliseconds(value) + if (epoch !== null) { + if (value instanceof Date) { + return value.toISOString() + } + const match = value.match(RFC3339_PATTERN) + return `${match[1]}.${(match[2]?.slice(1) || '').padEnd(3, '0').slice(0, 3)}${match[3]}` + } + + const candidates = getWallTimeCandidates(value, timezoneName) + if (!candidates.length) { + throw new TimeInputError(`${value} 在 ${timezoneName} 不存在,请选择夏令时跳转后的有效时间`, 'DST_GAP') + } + if (candidates.length === 1) { + return candidates[0].value + } + + const selected = candidates.find(candidate => candidate.offset === offset) + if (selected) { + return selected.value + } + throw new TimeInputError(`${value} 在 ${timezoneName} 出现两次,请选择 UTC 偏移`, 'DST_FOLD', candidates) +} + +/** + * 将真实时刻转换为日期时间输入控件使用的业务时间。 + * + * @param {string|Date} value 原始真实时刻 + * @param {string} timezoneName 业务 IANA 时区名称 + * @returns {string|null} 不含偏移的日期时间,非法输入返回 null + */ +export function toBusinessDateTimeInput(value, timezoneName = getDisplayTimezone()) { + return formatBusinessTime(value, 'YYYY-MM-DD HH:mm:ss', timezoneName) +} + +/** + * 按字段语义规范化日期或精确时刻查询边界。 + * + * @param {string|Date} value 日期或真实时刻 + * @returns {string|undefined} 纯日期或 RFC 3339 字符串,空输入返回 undefined + */ +export function normalizeRangeBoundary(value) { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value === 'string' && DATE_ONLY_PATTERN.test(value)) { + if (parseWallTimeMilliseconds(value, true) === null) { + throw new TimeInputError(`无效的日期: ${value}`, 'INVALID_DATE') + } + return value + } + return toRfc3339(value) +} + +/** + * 创建可编辑的业务时间字段,并保存原始时刻供无修改提交时使用。 + * + * @param {Object} record API 返回的原始记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {string} timezoneName 业务 IANA 时区名称 + * @returns {Object} 包含业务时间和原值快照的新记录 + */ +export function prepareTimeFields(record, fields, timezoneName = getDisplayTimezone()) { + const result = { ...record } + const originals = {} + for (const field of fields) { + if (!(field in record)) { + continue + } + + const original = record[field] + const input = original == null || original === '' ? original : toBusinessDateTimeInput(original, timezoneName) + if (input === null && original != null) { + throw new TimeInputError(`字段 ${field} 包含无效的 RFC 3339 时间`, 'INVALID_TIME') + } + result[field] = input + originals[field] = { original, input } + } + result[ORIGINAL_TIME_FIELDS] = { originals, timezoneName } + return result +} + +/** + * 构造提交记录,保留未修改字段的原始毫秒和偏移。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {Object} offsets 各重复时间字段选定的 UTC 偏移 + * @returns {Object} 可提交的新记录,不修改当前表单 + */ +export function serializeTimeFields(record, fields, offsets = {}) { + const result = { ...record } + const state = record[ORIGINAL_TIME_FIELDS] + delete result[ORIGINAL_TIME_FIELDS] + + for (const field of fields) { + if (!(field in record)) { + continue + } + + const value = record[field] + const original = state?.originals[field] + if (original && value === original.input) { + result[field] = original.original + } else if (value == null) { + result[field] = value + } else if (value === '') { + result[field] = null + } else { + try { + result[field] = toRfc3339(value, { + timezoneName: state?.timezoneName || getDisplayTimezone(), + offset: offsets[field] + }) + } catch (error) { + error.field = field + throw error + } + } + } + return result +} + +/** + * 逐项处理重复时间的偏移选择,再生成可提交记录。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {function(TimeInputError): Promise} chooseOffset 请求用户选择偏移的回调 + * @returns {Promise} 已解决重复时间歧义的提交记录 + */ +export async function resolveTimeFields(record, fields, chooseOffset) { + const offsets = {} + while (true) { + try { + return serializeTimeFields(record, fields, offsets) + } catch (error) { + if (error.code !== 'DST_FOLD') { + throw error + } + const offset = await chooseOffset(error) + if (!error.candidates.some(candidate => candidate.offset === offset)) { + throw error + } + offsets[error.field] = offset + } + } +} + +setBusinessTimezone(businessTimezone.value) +refreshDeviceTimezone() + +/** + * 请求用户选择夏令时重复时间对应的 UTC 偏移。 + * + * @param {TimeInputError} error 包含候选偏移的校验异常 + * @returns {Promise} 用户选定的 UTC 偏移 + */ +async function chooseTimeOffset(error) { + const offsets = error.candidates.map(candidate => candidate.offset) + const { value } = await ElMessageBox.prompt( + `${error.message}。第一次:${offsets[0]};第二次:${offsets[1]}。`, + '选择重复时间', + { + inputPlaceholder: `请输入 ${offsets.join(' 或 ')}`, + inputValidator: value => offsets.includes(value) || '请输入上述 UTC 偏移之一', + confirmButtonText: '使用所选时间', + cancelButtonText: '返回修改' + } + ) + return value +} + +/** + * 校验表单时间并构造新的 API 提交数据。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @returns {Promise} 提交数据;校验失败或用户取消时返回 null + */ +export async function serializeTimeFieldsForSubmit(record, fields) { + try { + return await resolveTimeFields(record, fields, chooseTimeOffset) + } catch (error) { + if (error instanceof Error) { + ElMessage.error(error.message) + } + return null + } +} diff --git a/ruoyi-fastapi-frontend/src/views/lock.vue b/ruoyi-fastapi-frontend/src/views/lock.vue index 7c4cfc152..c0f71b006 100644 --- a/ruoyi-fastapi-frontend/src/views/lock.vue +++ b/ruoyi-fastapi-frontend/src/views/lock.vue @@ -38,6 +38,7 @@ import { useRouter } from 'vue-router' import useUserStore from '@/store/modules/user' import useLockStore from '@/store/modules/lock' import { unlockScreen } from '@/api/login' +import { formatBusinessTime } from '@/utils/time' import defAva from '@/assets/images/profile.jpg' const router = useRouter() @@ -64,10 +65,8 @@ const onAvatarError = (e) => { const startClock = () => { const update = () => { const now = new Date() - const pad = n => String(n).padStart(2, '0') - currentTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` - const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] - currentDate.value = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${days[now.getDay()]}` + currentTime.value = formatBusinessTime(now, 'HH:mm:ss') + currentDate.value = formatBusinessTime(now, 'YYYY年M月D日 星期{a}') } update() timer = setInterval(update, 1000) diff --git a/ruoyi-fastapi-frontend/src/views/monitor/job/detail.vue b/ruoyi-fastapi-frontend/src/views/monitor/job/detail.vue index f52095fe0..9ee917dc3 100644 --- a/ruoyi-fastapi-frontend/src/views/monitor/job/detail.vue +++ b/ruoyi-fastapi-frontend/src/views/monitor/job/detail.vue @@ -1,5 +1,5 @@ - + + + + + @@ -121,7 +130,9 @@ - + @@ -139,28 +150,22 @@ /> - + - + - - - - + + - + diff --git a/ruoyi-fastapi-frontend/src/views/monitor/job/runtime.vue b/ruoyi-fastapi-frontend/src/views/monitor/job/runtime.vue new file mode 100644 index 000000000..6c7ddf804 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/monitor/job/runtime.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/views/monitor/logininfor/index.vue b/ruoyi-fastapi-frontend/src/views/monitor/logininfor/index.vue index 7480b5e02..89f97cacc 100644 --- a/ruoyi-fastapi-frontend/src/views/monitor/logininfor/index.vue +++ b/ruoyi-fastapi-frontend/src/views/monitor/logininfor/index.vue @@ -37,12 +37,12 @@ @@ -125,6 +125,7 @@ diff --git a/ruoyi-fastapi-frontend/src/views/monitor/operlog/detail.vue b/ruoyi-fastapi-frontend/src/views/monitor/operlog/detail.vue index 053d76dc3..1d7211795 100644 --- a/ruoyi-fastapi-frontend/src/views/monitor/operlog/detail.vue +++ b/ruoyi-fastapi-frontend/src/views/monitor/operlog/detail.vue @@ -14,7 +14,7 @@ -
操作时间{{ form.operTime }}
+
操作时间{{ parseTime(form.operTime) || '-' }}
diff --git a/ruoyi-fastapi-frontend/src/views/monitor/operlog/index.vue b/ruoyi-fastapi-frontend/src/views/monitor/operlog/index.vue index 1743d1eca..ec4059347 100644 --- a/ruoyi-fastapi-frontend/src/views/monitor/operlog/index.vue +++ b/ruoyi-fastapi-frontend/src/views/monitor/operlog/index.vue @@ -61,12 +61,12 @@ @@ -153,6 +153,7 @@ diff --git a/ruoyi-fastapi-frontend/src/views/monitor/server/index.vue b/ruoyi-fastapi-frontend/src/views/monitor/server/index.vue index ad9d42d29..71e170c08 100644 --- a/ruoyi-fastapi-frontend/src/views/monitor/server/index.vue +++ b/ruoyi-fastapi-frontend/src/views/monitor/server/index.vue @@ -112,7 +112,7 @@
启动时间
-
{{ server.py.startTime }}
+
{{ parseTime(server.py.startTime) }}
运行时长
{{ server.py.runTime }}
diff --git a/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue b/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue index 95e5d9934..5e8f5088b 100644 --- a/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue +++ b/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue @@ -500,29 +500,6 @@ const healthMessage = computed(() => { }) function formatMonitorTime(value, pattern = '{y}-{m}-{d} {h}:{i}:{s}') { - if (!value) { - return null - } - if (typeof value === 'string') { - const normalizedValue = value.trim() - const microsecondIsoMatch = normalizedValue.match( - /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?$/ - ) - if (microsecondIsoMatch) { - const [, year, month, day, hour, minute, second] = microsecondIsoMatch - return parseTime( - new Date( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ), - pattern - ) - } - } return parseTime(value, pattern) } diff --git a/ruoyi-fastapi-frontend/src/views/system/config/index.vue b/ruoyi-fastapi-frontend/src/views/system/config/index.vue index f904e0892..031f55046 100644 --- a/ruoyi-fastapi-frontend/src/views/system/config/index.vue +++ b/ruoyi-fastapi-frontend/src/views/system/config/index.vue @@ -39,6 +39,7 @@ v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" + :aria-label="`日期范围(${getDisplayTimezone()})`" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" @@ -170,6 +171,7 @@ diff --git a/ruoyi-fastapi-frontend/src/views/system/dict/index.vue b/ruoyi-fastapi-frontend/src/views/system/dict/index.vue index 70289a2b4..2d8539eb5 100644 --- a/ruoyi-fastapi-frontend/src/views/system/dict/index.vue +++ b/ruoyi-fastapi-frontend/src/views/system/dict/index.vue @@ -39,6 +39,7 @@ v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" + :aria-label="`日期范围(${getDisplayTimezone()})`" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" @@ -181,6 +182,7 @@ diff --git a/ruoyi-fastapi-frontend/src/views/system/file/components/FileAclDrawer.vue b/ruoyi-fastapi-frontend/src/views/system/file/components/FileAclDrawer.vue index 930ed07c9..ce81aaefd 100644 --- a/ruoyi-fastapi-frontend/src/views/system/file/components/FileAclDrawer.vue +++ b/ruoyi-fastapi-frontend/src/views/system/file/components/FileAclDrawer.vue @@ -137,14 +137,11 @@ - - + @@ -180,6 +177,8 @@ import { saveFileAcl, searchFileAclSubjects } from "@/api/system/file"; +import { prepareTimeFields, serializeTimeFieldsForSubmit } from "@/utils/time"; +import BusinessDateTimePicker from "@/components/BusinessDateTimePicker"; const emit = defineEmits(["refresh"]); const { proxy } = getCurrentInstance(); @@ -238,7 +237,7 @@ function open(row, selectedIds, selectedPrivateIds) { deptOptions.value = deptResponse.data; aclVersion.value = aclResponse.data.aclVersion; builtinPermissions.value = aclResponse.data.builtinPermissions || []; - entries.value = aclResponse.data.entries.map(item => ({ + entries.value = aclResponse.data.entries.map(item => prepareTimeFields({ subjectType: item.subjectType, subjectId: item.subjectId, effect: item.effect, @@ -248,7 +247,7 @@ function open(row, selectedIds, selectedPrivateIds) { subjectOptions: [ { subjectId: item.subjectId, subjectName: item.subjectName } ] - })); + }, ['expireTime'])); }) .finally(() => { loading.value = false; @@ -318,17 +317,23 @@ function searchSubjectOptions(row, keyword) { }); } -function submit() { +async function submit() { if (entries.value.some(item => !item.subjectId)) { proxy.$modal.msgError("请选择完整的授权主体"); return; } - const aclEntries = entries.value.map(item => ({ + const convertedEntries = []; + for (const item of entries.value) { + const converted = await serializeTimeFieldsForSubmit(item, ['expireTime']); + if (!converted) return; + convertedEntries.push(converted); + } + const aclEntries = convertedEntries.map(item => ({ subjectType: item.subjectType, subjectId: item.subjectId, effect: item.effect, includeChildren: item.subjectType === "dept" && item.includeChildren, - expireTime: item.expireTime || undefined + expireTime: item.expireTime })); const saveAcl = () => { saving.value = true; diff --git a/ruoyi-fastapi-frontend/src/views/system/file/components/FileRetentionReminderDrawer.vue b/ruoyi-fastapi-frontend/src/views/system/file/components/FileRetentionReminderDrawer.vue index bda29898b..e6811b2f2 100644 --- a/ruoyi-fastapi-frontend/src/views/system/file/components/FileRetentionReminderDrawer.vue +++ b/ruoyi-fastapi-frontend/src/views/system/file/components/FileRetentionReminderDrawer.vue @@ -234,11 +234,9 @@ {{ parseTime(currentRow.expireTime) }}
- @@ -272,6 +270,8 @@ import { readFileRetentionReminder, scanFileRetentionReminder } from "@/api/system/file"; +import { toBusinessDateTimeInput, serializeTimeFieldsForSubmit } from "@/utils/time"; +import BusinessDateTimePicker from "@/components/BusinessDateTimePicker"; const emit = defineEmits(["refresh"]); const { proxy } = getCurrentInstance(); @@ -377,10 +377,12 @@ function handleExtend(row) { } function submitExtend() { - extendRef.value.validate(valid => { + extendRef.value.validate(async valid => { if (!valid) return; + const submitData = await serializeTimeFieldsForSubmit(extendForm, ['expireTime']); + if (!submitData) return; submitting.value = true; - extendFileRetention(currentRow.value.noticeId, extendForm) + extendFileRetention(currentRow.value.noticeId, submitData) .then(() => { proxy.$modal.msgSuccess("文件保留期限已延长"); extendOpen.value = false; @@ -425,12 +427,7 @@ function handleDispose(row) { function defaultExtendTime(expireTime) { const baseTime = Math.max(Date.now(), new Date(expireTime).getTime()); const target = new Date(baseTime + 30 * 24 * 60 * 60 * 1000); - const pad = value => String(value).padStart(2, "0"); - return `${target.getFullYear()}-${pad(target.getMonth() + 1)}-${pad( - target.getDate() - )} ${pad(target.getHours())}:${pad(target.getMinutes())}:${pad( - target.getSeconds() - )}`; + return toBusinessDateTimeInput(target); } defineExpose({ open }); diff --git a/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue b/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue index 24d752ddf..251fe0e7a 100644 --- a/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue +++ b/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue @@ -657,7 +657,6 @@ const { proxy } = getCurrentInstance(); const { plugin_operation_type } = proxy.useDict("plugin_operation_type"); const parseTime = proxy.parseTime; const INVALID_PLUGIN_TIME_VALUES = new Set(["", "-", "0", "0-0-0 0:0:0", "0000-00-00 00:00:00"]); -const PLUGIN_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; const OPERATION_LOG_RETENTION_CONFIG_KEY = "sys.plugin.operationLogRetentionDays"; const pluginTableRef = ref(null); @@ -719,7 +718,7 @@ function formatPluginTime(value) { return "-"; } - const formatted = formatPluginIsoTime(value) || parseTime(value); + const formatted = parseTime(value); if (!formatted || INVALID_PLUGIN_TIME_VALUES.has(String(formatted).trim())) { return "-"; } @@ -727,17 +726,6 @@ function formatPluginTime(value) { return formatted; } -function formatPluginIsoTime(value) { - if (typeof value !== "string") { - return ""; - } - const matched = value.trim().match(PLUGIN_TIME_PATTERN); - if (!matched) { - return ""; - } - - return `${matched[1]}-${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${matched[6]}`; -} const pluginStatusOptions = [ { label: "已发现", value: "discovered", tagType: "info" }, { label: "已安装", value: "installed", tagType: "success" }, diff --git a/ruoyi-fastapi-frontend/src/views/system/role/index.vue b/ruoyi-fastapi-frontend/src/views/system/role/index.vue index bc1472c4e..3a6f42705 100644 --- a/ruoyi-fastapi-frontend/src/views/system/role/index.vue +++ b/ruoyi-fastapi-frontend/src/views/system/role/index.vue @@ -39,6 +39,7 @@ v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" + :aria-label="`日期范围(${getDisplayTimezone()})`" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" @@ -242,6 +243,7 @@ diff --git a/ruoyi-fastapi-frontend/src/views/system/user/index.vue b/ruoyi-fastapi-frontend/src/views/system/user/index.vue index 033ba002f..d67e9937a 100644 --- a/ruoyi-fastapi-frontend/src/views/system/user/index.vue +++ b/ruoyi-fastapi-frontend/src/views/system/user/index.vue @@ -57,6 +57,7 @@ v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" + :aria-label="`日期范围(${getDisplayTimezone()})`" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" @@ -450,6 +451,7 @@ diff --git a/ruoyi-fastapi-frontend/src/views/system/user/profile/index.vue b/ruoyi-fastapi-frontend/src/views/system/user/profile/index.vue index 26f4bd480..cc5355a95 100644 --- a/ruoyi-fastapi-frontend/src/views/system/user/profile/index.vue +++ b/ruoyi-fastapi-frontend/src/views/system/user/profile/index.vue @@ -35,7 +35,7 @@
  • 创建日期 -
    {{ state.user.createTime }}
    +
    {{ parseTime(state.user.createTime) || '-' }}
  • @@ -55,6 +55,9 @@ + + +
    @@ -66,6 +69,8 @@ import userAvatar from "./userAvatar"; import userInfo from "./userInfo"; import resetPwd from "./resetPwd"; +import timezoneSettings from "./timezoneSettings.vue"; +import useUserStore from '@/store/modules/user'; import { getUserProfile } from "@/api/system/user"; const route = useRoute() @@ -79,6 +84,7 @@ const state = reactive({ function getUser() { getUserProfile().then(response => { state.user = response.data; + useUserStore().applyTimezone(response.data.timeZone); state.roleGroup = response.roleGroup; state.postGroup = response.postGroup; }); diff --git a/ruoyi-fastapi-frontend/src/views/system/user/profile/timezoneSettings.vue b/ruoyi-fastapi-frontend/src/views/system/user/profile/timezoneSettings.vue new file mode 100644 index 000000000..97ff1c32a --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/system/user/profile/timezoneSettings.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/views/tool/gen/editTable.vue b/ruoyi-fastapi-frontend/src/views/tool/gen/editTable.vue index 0da7576a9..8be89dc1e 100644 --- a/ruoyi-fastapi-frontend/src/views/tool/gen/editTable.vue +++ b/ruoyi-fastapi-frontend/src/views/tool/gen/editTable.vue @@ -93,7 +93,9 @@ - + + + diff --git a/ruoyi-fastapi-frontend/src/views/tool/gen/importTable.vue b/ruoyi-fastapi-frontend/src/views/tool/gen/importTable.vue index c9c119fee..96ee2fe0c 100644 --- a/ruoyi-fastapi-frontend/src/views/tool/gen/importTable.vue +++ b/ruoyi-fastapi-frontend/src/views/tool/gen/importTable.vue @@ -46,7 +46,9 @@ - + + + { + const form = { + jobArgs: '["tenant,a", 3, true, {"nested": [null, 2]}]', + jobKwargs: '{"enabled": false, "limit": 30}', + misfireGraceTime: null, + coalesce: true, + maxInstances: 2, + jobGroup: '报表', + jobStore: 'redis' + } + const payload = buildJobPayload(form) + assert.deepEqual(payload.jobArgs, ['tenant,a', 3, true, { nested: [null, 2] }]) + assert.deepEqual(payload.jobKwargs, { enabled: false, limit: 30 }) + assert.equal(payload.misfireGraceTime, null) + assert.equal(payload.jobGroup, '报表') + assert.equal(payload.jobStore, 'redis') + assert.equal(typeof form.jobArgs, 'string') +}) + +test('无参数使用空数组和空对象', () => { + assert.deepEqual(buildJobPayload({ jobArgs: '[]', jobKwargs: '{}' }), { jobArgs: [], jobKwargs: {} }) +}) + +test('错误的JSON格式和参数结构返回对应字段提示', () => { + for (const value of ['', 'a,b', '{broken', '{}', 'true', 'null']) { + assert.throws(() => parseJobParameter(value, 'jobArgs'), /位置参数必须是JSON数组/) + } + for (const value of ['', '[1]', 'true', 'null', '{broken']) { + assert.throws(() => parseJobParameter(value, 'jobKwargs'), /关键字参数必须是JSON对象/) + } + assert.throws(() => parseJobParameter('[1e999]', 'jobArgs'), /数字超出可表示范围/) +}) diff --git a/ruoyi-fastapi-frontend/tests/time/time.test.js b/ruoyi-fastapi-frontend/tests/time/time.test.js new file mode 100644 index 000000000..e7484ff9b --- /dev/null +++ b/ruoyi-fastapi-frontend/tests/time/time.test.js @@ -0,0 +1,3 @@ +import * as time from '../../src/utils/time.js' +import { testTimeContract } from '../../../ruoyi-fastapi-test/time-contract/contract.mjs' +testTimeContract(time) diff --git a/ruoyi-fastapi-test/README.md b/ruoyi-fastapi-test/README.md index 84c4cfa1f..a50f4fb84 100644 --- a/ruoyi-fastapi-test/README.md +++ b/ruoyi-fastapi-test/README.md @@ -103,6 +103,18 @@ python -m pytest -v - 验证未登录用户访问受保护页面时被重定向到登录页 - 验证登录后可以访问受保护页面 +## 通用时间契约 + +`time-contract` 为本项目 Web、App 和缺少原生 Intl 的 App 实现提供相同的时间工具回归测试,覆盖 RFC 3339、日期合法性、epoch 单位、DST 缺失/重复时间和表单往返。固定时间样例用于验证边界行为,不依赖真实业务数据、数据库、浏览器、构建产物或 `.cache` 中预先生成的文件。 + +先按前端和 App 各自的依赖清单安装依赖,再从仓库根目录运行: + +```bash +node ruoyi-fastapi-test/time-contract/run-host-timezones.mjs +``` + +脚本通过自身位置定位仓库,在 UTC、Asia/Shanghai 和 America/New_York 三个独立进程中执行;更换检出目录或从其他目录调用不影响路径解析。各工程中的测试入口负责导入自身时间工具,共享契约不写死本机绝对路径。 + ## 配置说明 使用 `docker-compose.test.my.yml`或`docker-compose.test.pg.yml`启动服务,默认前端端口为 `80`,后端端口为 `9099`。测试环境已禁用验证码功能。 diff --git a/ruoyi-fastapi-test/common/base_page_test.py b/ruoyi-fastapi-test/common/base_page_test.py index 0542a6862..d0537754e 100644 --- a/ruoyi-fastapi-test/common/base_page_test.py +++ b/ruoyi-fastapi-test/common/base_page_test.py @@ -2,6 +2,7 @@ from playwright.async_api._context_manager import PlaywrightContextManager +from common.config import Config from common.login_helper import LoginHelper @@ -19,7 +20,7 @@ async def setup(self, playwright: PlaywrightContextManager) -> None: assert self.token is not None, '登录应该成功' # 启动浏览器 - self.browser = await playwright.chromium.launch(headless=True) + self.browser = await playwright.chromium.launch(headless=True, channel=Config.browser_channel) self.context = await self.browser.new_context() # 设置认证token await self.context.add_cookies( diff --git a/ruoyi-fastapi-test/common/config.py b/ruoyi-fastapi-test/common/config.py index 003ab289f..3a583566d 100644 --- a/ruoyi-fastapi-test/common/config.py +++ b/ruoyi-fastapi-test/common/config.py @@ -1,3 +1,8 @@ +import os + + class Config: - frontend_url = 'http://localhost:80' - backend_url = 'http://localhost:9099' + frontend_url = os.environ.get('TEST_FRONTEND_URL', 'http://localhost:80') + backend_url = os.environ.get('TEST_BACKEND_URL', 'http://localhost:9099') + browser_channel = os.environ.get('TEST_BROWSER_CHANNEL') or None + swagger_disabled = os.environ.get('TEST_SWAGGER_DISABLED', 'true').lower() == 'true' diff --git a/ruoyi-fastapi-test/docker-compose.test.my.yml b/ruoyi-fastapi-test/docker-compose.test.my.yml index 69511cdb9..a420a0bb3 100644 --- a/ruoyi-fastapi-test/docker-compose.test.my.yml +++ b/ruoyi-fastapi-test/docker-compose.test.my.yml @@ -6,6 +6,8 @@ services: dockerfile: Dockerfile image: ruoyi-frontend:latest container_name: ruoyi-frontend-test + environment: + TZ: UTC ports: - "80:80" volumes: @@ -25,6 +27,7 @@ services: ports: - "9099:9099" environment: + TZ: UTC TRANSPORT_CRYPTO_ENABLED: "false" TRANSPORT_CRYPTO_MODE: "off" depends_on: @@ -40,6 +43,7 @@ services: image: mysql:8.0 container_name: ruoyi-mysql-test environment: + TZ: UTC MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: ruoyi-fastapi ports: @@ -60,6 +64,8 @@ services: ruoyi-redis: image: redis:latest container_name: ruoyi-redis-test + environment: + TZ: UTC ports: - "16380:6379" networks: @@ -74,4 +80,4 @@ services: networks: ruoyi-network-test: name: ruoyi-network-test - driver: bridge \ No newline at end of file + driver: bridge diff --git a/ruoyi-fastapi-test/docker-compose.test.pg.yml b/ruoyi-fastapi-test/docker-compose.test.pg.yml index 3e9b1394c..884a0d2c1 100644 --- a/ruoyi-fastapi-test/docker-compose.test.pg.yml +++ b/ruoyi-fastapi-test/docker-compose.test.pg.yml @@ -6,6 +6,8 @@ services: dockerfile: Dockerfile image: ruoyi-frontend:latest container_name: ruoyi-frontend-test + environment: + TZ: UTC ports: - "80:80" volumes: @@ -25,6 +27,7 @@ services: ports: - "9099:9099" environment: + TZ: UTC TRANSPORT_CRYPTO_ENABLED: "false" TRANSPORT_CRYPTO_MODE: "off" depends_on: @@ -40,6 +43,7 @@ services: image: postgres:14 container_name: ruoyi-pg-test environment: + TZ: UTC POSTGRES_PASSWORD: root POSTGRES_DB: ruoyi-fastapi POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C @@ -60,6 +64,8 @@ services: ruoyi-redis: image: redis:latest container_name: ruoyi-redis-test + environment: + TZ: UTC ports: - "16381:6379" networks: @@ -74,4 +80,4 @@ services: networks: ruoyi-network-test: name: ruoyi-network-test - driver: bridge \ No newline at end of file + driver: bridge diff --git a/ruoyi-fastapi-test/monitor/test_job_management.py b/ruoyi-fastapi-test/monitor/test_job_management.py index 3ecb03658..9bc9bbbb3 100644 --- a/ruoyi-fastapi-test/monitor/test_job_management.py +++ b/ruoyi-fastapi-test/monitor/test_job_management.py @@ -1,186 +1,200 @@ -from datetime import datetime +import asyncio +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from time import monotonic +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 import pytest -from playwright.async_api import async_playwright +from playwright.async_api import Locator, async_playwright, expect from common.base_page_test import BasePageTest from common.config import Config class JobManagementTest(BasePageTest): - """定时任务管理测试""" - - def generate_job_data(self) -> dict: - """生成测试数据""" - timestamp = datetime.now().strftime('%H%M%S') - return { - 'job_name': f'测试任务_{timestamp}', - 'job_group': '默认', - 'job_executor': '进程池', - 'invoke_target': 'module_task.scheduler_test.job', # 必须为这个值 - 'cron_expression': '0/2 * * * * ?', # 每2秒执行一次 - 'new_invoke_target': 'module_task.scheduler_test.job', # 修改时也用这个 - 'job_name_edit': f'测试任务_{timestamp}_edit', - } - - async def create_job( - self, job_name: str, job_group: str, job_executor: str, invoke_target: str, cron_expression: str - ) -> None: - """创建定时任务""" - await self.page.get_by_role('button', name='新增').click() - - # 等待对话框 - dialog = self.page.get_by_role('dialog') - await dialog.wait_for() - - # 填写表单 - await dialog.get_by_role('textbox', name='任务名称').fill(job_name) - - # 选择任务分组 - await dialog.locator("label:has-text('任务分组') + div .el-select").click() - await self.page.get_by_role('option', name=job_group).click() - - # 选择任务执行器 - await dialog.locator("label:has-text('任务执行器') + div .el-select").click() - await self.page.get_by_role('option', name=job_executor).click() + """通过真实界面验证任务配置、异步同步和手动执行。""" - # 填写调用方法 - await dialog.get_by_role('textbox', name='调用方法').fill(invoke_target) + job_id: int | None = None - # 填写cron表达式 - await dialog.get_by_role('textbox', name='cron表达式').fill(cron_expression) - - # 策略:如果"失败"策略等有默认值,就不管了 + def job_row(self, job_name: str) -> Locator: + return self.page.locator('.app-container:visible > .el-table tbody tr').filter( + has=self.page.get_by_text(job_name, exact=True) + ) - await dialog.get_by_role('button', name='确 定').click() - await self.wait_for_selector("div:has-text('新增成功')", timeout=10000) + async def submit(self, action: Callable[[], Awaitable], method: str, path: str) -> dict: + """等待本次操作的响应,不依赖短暂提示或固定延时。""" + async with self.page.expect_response( + lambda response: response.request.method == method and urlparse(response.url).path.endswith(path) + ) as response_info: + await action() + response = await response_info.value + assert response.status == HTTPStatus.OK, await response.text() + payload = await response.json() + assert payload['code'] == HTTPStatus.OK, payload + return payload['data'] + + async def poll_api(self, path: str, ready: Callable[[dict], bool], **params) -> dict: + """轮询异步结果,超时时保留最后一次响应以便诊断。""" + deadline = monotonic() + 30 + while True: + response = await self.context.request.get( + Config.backend_url + path, + params=params, + headers={'Authorization': f'Bearer {self.token}'}, + ) + assert response.status == HTTPStatus.OK, await response.text() + payload = await response.json() + assert payload['code'] == HTTPStatus.OK, payload + if ready(payload): + return payload + assert monotonic() < deadline, f'{path} 未达到预期状态: {payload}' + await asyncio.sleep(0.25) + + async def wait_for_sync(self, mutation: dict) -> None: + """已保存后,还要确认本次配置版本已由调度器应用。""" + assert mutation['saved'] is True, mutation + assert len(mutation['jobs']) == 1, mutation + changed = mutation['jobs'][0] + await self.poll_api( + '/monitor/job/sync/list', + lambda payload: any( + row['jobId'] == changed['jobId'] + and row['syncStatus'] == 'applied' + and row['appliedVersion'] == changed['configVersion'] + and row['deleted'] == changed['deleted'] + for row in payload['rows'] + ), + jobId=changed['jobId'], + ) async def search_job(self, job_name: str) -> None: - """搜索任务""" - # 我们可以限制在 form 里搜索 form = self.page.locator('form').first - await form.get_by_role('textbox', name='任务名称').fill(job_name) - await self.page.get_by_role('button', name='搜索').click() - await self.page.wait_for_timeout(1000) # 等待搜索结果 - - async def edit_job(self, job_name: str, new_invoke_target: str) -> None: - """修改任务""" - await self.search_job(job_name) - - # 点击修改 (在操作列) - row = self.page.locator('tbody tr').first - # 操作列按钮顺序: 修改, 删除, 执行一次, 详细, 日志 - await row.locator('button').nth(0).click() - - dialog = self.page.get_by_role('dialog') - await dialog.wait_for() - - # 修改调用方法 - await dialog.get_by_role('textbox', name='调用方法').fill(new_invoke_target) - - await dialog.get_by_role('button', name='确 定').click() - await self.wait_for_selector("div:has-text('修改成功')", timeout=10000) - - async def toggle_job_status(self, job_name: str) -> None: - """切换任务状态""" - await self.search_job(job_name) - - row = self.page.locator('tbody tr').first - # 切换开关 - # 需要点击可见的 .el-switch 或 .el-switch__core - await row.locator('.el-switch').click() - - # 确认切换 - await self.page.get_by_role('button', name='确定').click() - await self.page.wait_for_timeout(1000) - await self.wait_for_selector("div:has-text('成功')", timeout=10000) - - async def run_job_once(self, job_name: str) -> None: - """执行一次任务""" + await form.get_by_role('textbox', name='任务名称', exact=True).fill(job_name) + async with self.page.expect_response( + lambda response: ( + urlparse(response.url).path.endswith('/monitor/job/list') + and parse_qs(urlparse(response.url).query).get('jobName') == [job_name] + ) + ) as response_info: + await form.get_by_role('button', name='搜索', exact=True).click() + response = await response_info.value + assert response.status == HTTPStatus.OK + assert (await response.json())['code'] == HTTPStatus.OK + await expect(self.page.locator('.el-table .el-loading-mask:visible')).to_have_count(0) + + async def create_job(self, job_name: str) -> None: + await self.page.get_by_role('button', name='新增', exact=True).click() + dialog = self.page.get_by_role('dialog', name='添加任务', exact=True) + await dialog.get_by_role('textbox', name='任务名称').fill(job_name) + await dialog.get_by_role('textbox', name='业务分组').fill('playwright') + executor = dialog.locator('.el-select').filter(has=self.page.get_by_role('combobox', name='任务执行器')) + await executor.click() + await self.page.get_by_role('option', name='进程池', exact=True).click() + await dialog.get_by_role('textbox', name='调用方法').fill('module_task.scheduler_test.job') + # 手动执行验证运行结果;年度计划避免启停断言与高频定时执行相互干扰。 + await dialog.get_by_role('textbox', name='cron表达式').fill('0 0 0 1 1 ?') + mutation = await self.submit(dialog.get_by_role('button', name='确 定').click, 'POST', '/monitor/job') + self.job_id = mutation['jobs'][0]['jobId'] + await self.wait_for_sync(mutation) + await expect(dialog).to_be_hidden() await self.search_job(job_name) - - row = self.page.locator('tbody tr').first - # 点击执行一次 (第3个按钮) - await row.locator('button').nth(2).click() - - # 确认执行 - await self.page.get_by_role('button', name='确定').click() - await self.wait_for_selector("div:has-text('执行成功')", timeout=10000) - - async def view_job_log(self, job_name: str) -> None: - """查看调度日志""" - # 可以点击顶部的 "日志" 按钮,也可以点击行的 "调度日志" - # 我们点击行的 "调度日志" (icon="Operation") + await expect(self.job_row(job_name)).to_have_count(1) + + async def edit_job(self, job_name: str, new_job_name: str) -> None: + # 同步状态列也包含按钮,操作按钮仅在最后一列定位。 + await self.job_row(job_name).locator('td').last.get_by_role('button').nth(0).click() + dialog = self.page.get_by_role('dialog', name='修改任务', exact=True) + await dialog.get_by_role('textbox', name='任务名称').fill(new_job_name) + mutation = await self.submit(dialog.get_by_role('button', name='确 定').click, 'PUT', '/monitor/job') + await self.wait_for_sync(mutation) + await expect(dialog).to_be_hidden() + await self.search_job(new_job_name) + await expect(self.job_row(new_job_name)).to_have_count(1) + + async def toggle_job_status(self, job_name: str, enabled: bool) -> None: + switch = self.job_row(job_name).get_by_role('switch') + await self.job_row(job_name).locator('.el-switch').click() + confirm = self.page.get_by_role('dialog').get_by_role('button', name='确定', exact=True) + mutation = await self.submit(confirm.click, 'PUT', '/monitor/job/changeStatus') + await self.wait_for_sync(mutation) await self.search_job(job_name) - row = self.page.locator('tbody tr').first - - # 操作列按钮顺序: 修改, 删除, 执行一次, 调度日志 - log_btn = row.locator('button').nth(3) - await log_btn.click() - - # 等待跳转到日志页面 (实际上是 router push 到 /monitor/job-log) - # 检查 URL - await self.page.wait_for_url('**/monitor/job-log**') - - # 验证在日志页面 - await self.wait_for_selector('text=调度日志') - - # 关闭返回 (点击 "关闭" 按钮) - await self.page.get_by_role('button', name='关闭').click() - - # 等待返回任务列表 + await expect(switch).to_have_attribute('aria-checked', str(enabled).lower()) + + async def run_job_once(self, job_name: str) -> str: + await self.job_row(job_name).get_by_role('button', name=f'立即执行 {job_name}', exact=True).click() + confirm = self.page.get_by_role('dialog').get_by_role('button', name='确定', exact=True) + execution = await self.submit(confirm.click, 'PUT', '/monitor/job/run') + execution_id = execution['executionId'] + result = await self.poll_api( + f'/monitor/job/execution/{execution_id}', + lambda payload: payload['data']['status'] not in ('pending', 'submitted', 'running'), + ) + assert result['data']['status'] == 'success', result + assert result['data']['jobId'] == self.job_id + assert result['data']['source'] == 'manual' + dialog = self.page.get_by_role('dialog', name='任务运行记录', exact=True) + await expect(dialog.locator('.execution-focus code')).to_have_text(execution_id) + row = dialog.locator('tbody tr').filter(has_text=job_name) + await expect(row.locator('.el-tag')).to_have_text('执行成功', timeout=10000) + await dialog.get_by_role('button', name='关闭', exact=True).click() + await expect(dialog).to_be_hidden() + return execution_id + + async def view_job_log(self, job_name: str, execution_id: str) -> None: + # 执行状态和日志分别落库,先等待本次执行的日志出现。 + await self.poll_api('/monitor/jobLog/list', lambda payload: len(payload['rows']) == 1, executionId=execution_id) + await self.job_row(job_name).locator('td').last.get_by_role('button').nth(3).click() + await self.page.wait_for_url(f'**/monitor/job-log/index/{self.job_id}') + await expect(self.page.get_by_role('textbox', name='任务编号', exact=True)).to_have_value(str(self.job_id)) + row = self.page.locator('.app-container:visible > .el-table tbody tr').filter(has_text=job_name) + await expect(row.get_by_text('成功', exact=True)).to_be_visible() + await self.page.get_by_role('button', name='关闭', exact=True).click() await self.page.wait_for_url('**/monitor/job') async def delete_job(self, job_name: str) -> None: - """删除任务""" await self.search_job(job_name) - - row = self.page.locator('tbody tr').first - # 点击删除 (第2个按钮) - await row.locator('button').nth(1).click() - - # 确认删除 - await self.page.get_by_role('button', name='确定').click() - await self.wait_for_selector("div:has-text('删除成功')", timeout=10000) + await self.job_row(job_name).locator('td').last.get_by_role('button').nth(1).click() + confirm = self.page.get_by_role('dialog').get_by_role('button', name='确定', exact=True) + mutation = await self.submit(confirm.click, 'DELETE', f'/monitor/job/{self.job_id}') + await self.wait_for_sync(mutation) + self.job_id = None + await self.search_job(job_name) + await expect(self.job_row(job_name)).to_have_count(0) + + async def cleanup_job(self) -> None: + """失败时也只清理本用例创建的任务。""" + if self.job_id is not None: + response = await self.context.request.delete( + f'{Config.backend_url}/monitor/job/{self.job_id}', + headers={'Authorization': f'Bearer {self.token}'}, + ) + assert response.status == HTTPStatus.OK, await response.text() + assert (await response.json())['code'] == HTTPStatus.OK async def test_job_crud_operations(self) -> None: - """测试定时任务管理全流程""" - data = self.generate_job_data() - - # 1. 进入定时任务页面 + job_name = f'测试任务_{uuid4().hex[:12]}' + edited_name = job_name + '_edit' await self.goto_page(Config.frontend_url + '/monitor/job') - await self.wait_for_selector('text=任务名称') - - # 2. 创建任务 - await self.create_job( - data['job_name'], data['job_group'], data['job_executor'], data['invoke_target'], data['cron_expression'] - ) - - # 3. 修改任务 (修改调用方法) - await self.edit_job(data['job_name'], data['new_invoke_target']) - - # 4. 切换状态 (开启/关闭) - await self.toggle_job_status(data['job_name']) # 开启 - await self.page.wait_for_timeout(1000) - await self.toggle_job_status(data['job_name']) # 关闭 - - # 5. 执行一次 - await self.run_job_once(data['job_name']) - - # 6. 查看日志 - await self.view_job_log(data['job_name']) - - # 7. 删除任务 - await self.delete_job(data['job_name']) + await self.create_job(job_name) + await self.edit_job(job_name, edited_name) + await self.toggle_job_status(edited_name, enabled=True) + await self.toggle_job_status(edited_name, enabled=False) + execution_id = await self.run_job_once(edited_name) + await self.view_job_log(edited_name, execution_id) + await self.delete_job(edited_name) @pytest.mark.asyncio async def test_job_management_page() -> None: - """测试定时任务管理页面功能""" - async with async_playwright() as p: + """测试定时任务管理页面功能。""" + async with async_playwright() as playwright: test_instance = JobManagementTest() - await test_instance.setup(p) + await test_instance.setup(playwright) try: await test_instance.test_job_crud_operations() finally: - await test_instance.teardown() + try: + await test_instance.cleanup_job() + finally: + await test_instance.teardown() diff --git a/ruoyi-fastapi-test/monitor/test_server_monitor.py b/ruoyi-fastapi-test/monitor/test_server_monitor.py index 0d89aa489..e7736c03f 100644 --- a/ruoyi-fastapi-test/monitor/test_server_monitor.py +++ b/ruoyi-fastapi-test/monitor/test_server_monitor.py @@ -1,5 +1,5 @@ import pytest -from playwright.async_api import async_playwright +from playwright.async_api import async_playwright, expect from common.base_page_test import BasePageTest from common.config import Config @@ -10,7 +10,13 @@ class ServerMonitorTest(BasePageTest): async def test_server_monitor(self) -> None: """测试服务监控页面""" - await self.page.goto(Config.frontend_url + '/monitor/server') + async with self.page.expect_response( + lambda response: ( + response.url.endswith('/monitor/server') and response.request.resource_type in {'xhr', 'fetch'} + ) + ) as server_response: + await self.page.goto(Config.frontend_url + '/monitor/server') + payload = await (await server_response.value).json() await self.page.wait_for_load_state('networkidle') # 验证主要板块存在 @@ -20,18 +26,11 @@ async def test_server_monitor(self) -> None: await self.page.wait_for_selector('text=Python解释器信息') await self.page.wait_for_selector('text=磁盘状态') - # 验证项目路径为 /app - # 尝试在表格行中查找 + # 容器和本机部署的路径不同,页面应展示 API 返回的实际路径。 + project_path = payload['data']['sys']['userDir'] + assert project_path, '服务器应返回非空项目路径' project_path_row = self.page.locator('tr', has_text='项目路径') - try: - await project_path_row.wait_for(timeout=5000) - text = await project_path_row.text_content() - assert '/app' in text, f"Expected project path '/app' in row, but got: {text}" - except Exception: - # 如果没找到行,尝试全局搜索 - print("Warning: '项目路径' row not found, checking page content") - content = await self.page.content() - assert '/app' in content, "Project path '/app' not found in page content" + await expect(project_path_row).to_contain_text(project_path) @pytest.mark.asyncio diff --git a/ruoyi-fastapi-test/system/test_notice_management.py b/ruoyi-fastapi-test/system/test_notice_management.py index d73a61853..57705a211 100644 --- a/ruoyi-fastapi-test/system/test_notice_management.py +++ b/ruoyi-fastapi-test/system/test_notice_management.py @@ -2,7 +2,7 @@ import time import pytest -from playwright.async_api import async_playwright +from playwright.async_api import async_playwright, expect from common.base_page_test import BasePageTest from common.config import Config @@ -108,14 +108,17 @@ async def delete_notice(self) -> None: async def test_notice_crud_operations(self) -> None: """测试通知公告的增删查改功能""" # 访问通知公告页面 - await self.goto_page(Config.frontend_url + '/system/notice') + async with self.page.expect_response( + lambda response: '/system/notice/list?' in response.url + ) as initial_response: + await self.goto_page(Config.frontend_url + '/system/notice') + initial_payload = await (await initial_response.value).json() # 等待页面加载完成 await self.wait_for_page_title('通知公告', timeout=10000) - await self.page.wait_for_timeout(1000) # 等待列表刷新 - - # 记录初始公告数量 - initial_notice_count = await self.get_table_total_rows() + # 等待真实列表数据渲染,不能将加载中的 total=0 当作初始数量。 + initial_notice_count = initial_payload['total'] + await expect(self.page.locator('span.el-pagination__total').first).to_contain_text(str(initial_notice_count)) # 生成测试数据 data = self.generate_notice_data() diff --git a/ruoyi-fastapi-test/test_login.py b/ruoyi-fastapi-test/test_login.py index 28b25d9e6..b686e54ba 100644 --- a/ruoyi-fastapi-test/test_login.py +++ b/ruoyi-fastapi-test/test_login.py @@ -9,7 +9,7 @@ async def test_login_page_loads() -> None: """测试登录页面是否能正常加载""" async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) page = await browser.new_page() # 访问登录页面 @@ -33,7 +33,7 @@ async def test_login_page_loads() -> None: async def test_captcha_generation() -> None: """测试验证码是否正常生成(在测试环境中,验证码已禁用,但仍应能访问登录页面)""" async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) page = await browser.new_page() # 访问登录页面 @@ -62,7 +62,7 @@ def test_login_without_captcha() -> None: async def test_login_flow_with_playwright() -> None: """使用Playwright测试完整的登录流程(测试环境中验证码已禁用)""" async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) page = await browser.new_page() # 访问登录页面 @@ -110,7 +110,7 @@ async def test_login_flow_with_playwright() -> None: async def test_protected_routes_require_auth() -> None: """测试受保护的路由需要认证""" async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) page = await browser.new_page() # 直接访问受保护的页面(如用户管理) @@ -157,7 +157,7 @@ async def test_authenticated_access() -> None: # 使用Playwright测试带认证的访问 async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) context = await browser.new_context( storage_state={ # 使用storage state来保持登录状态 'cookies': [ diff --git a/ruoyi-fastapi-test/test_pages.py b/ruoyi-fastapi-test/test_pages.py index 4172ef44a..43d077c15 100644 --- a/ruoyi-fastapi-test/test_pages.py +++ b/ruoyi-fastapi-test/test_pages.py @@ -14,7 +14,7 @@ async def test_dashboard_page() -> None: assert token is not None, '登录应该成功' async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) context = await browser.new_context() # 设置认证token await context.add_cookies( @@ -52,7 +52,7 @@ async def test_druid_page() -> None: assert token is not None, '登录应该成功' async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) context = await browser.new_context() # 设置认证token await context.add_cookies( @@ -90,7 +90,7 @@ async def test_build_page() -> None: assert token is not None, '登录应该成功' async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) + browser = await p.chromium.launch(headless=True, channel=Config.browser_channel) context = await browser.new_context() # 设置认证token await context.add_cookies( diff --git a/ruoyi-fastapi-test/test_timezone.py b/ruoyi-fastapi-test/test_timezone.py new file mode 100644 index 000000000..c0f9b858d --- /dev/null +++ b/ruoyi-fastapi-test/test_timezone.py @@ -0,0 +1,85 @@ +from datetime import datetime +from http import HTTPStatus +from urllib.parse import urlparse +from zoneinfo import ZoneInfo + +import pytest +from playwright.async_api import async_playwright, expect + +from common.config import Config +from common.login_helper import LoginHelper + + +@pytest.mark.asyncio +@pytest.mark.parametrize('host_timezone', ['UTC', 'Asia/Shanghai', 'America/New_York']) +async def test_authenticated_profile_uses_user_timezone_and_cron_keeps_its_zone(host_timezone: str) -> None: + """验证真实登录、用户时区展示、请求头和独立cron时区。""" + token = LoginHelper().login() + assert token + async with async_playwright() as playwright: + api = await playwright.request.new_context( + base_url=Config.backend_url, extra_http_headers={'Authorization': f'Bearer {token}'} + ) + browser = await playwright.chromium.launch(headless=True, channel=Config.browser_channel) + try: + info = await (await api.get('/getInfo')).json() + profile = await (await api.get('/system/user/profile')).json() + created = profile['data']['createTime'] + assert created.endswith('Z') + preference = info['user']['timeZone'] + display_timezone = host_timezone if preference == 'auto' else preference + expected = ( + datetime.fromisoformat(created.replace('Z', '+00:00')) + .astimezone(ZoneInfo(display_timezone)) + .strftime('%Y-%m-%d %H:%M:%S') + ) + context = await browser.new_context(timezone_id=host_timezone) + await context.add_cookies( + [{'name': 'Admin-Token', 'value': token, 'domain': urlparse(Config.frontend_url).hostname, 'path': '/'}] + ) + page = await context.new_page() + errors = [] + request_timezones = [] + page.on('pageerror', lambda error: errors.append(str(error))) + page.on( + 'request', + lambda request: ( + request_timezones.append(request.headers.get('x-timezone')) + if urlparse(request.url).path.endswith('/system/user/profile') + else None + ), + ) + await page.goto(Config.frontend_url + '/user/profile') + created_at = page.get_by_role('listitem').filter(has_text='创建日期').locator('.pull-right') + await expect(created_at).to_have_text(expected, timeout=30000) + await expect(created_at).to_be_visible() + assert not errors + assert display_timezone in request_timezones + + preview = { + 'cronExpression': '0 30 2 * * ?', + 'timeZone': 'America/New_York', + 'startTime': '2026-03-07T00:00:00.000Z', + 'count': 3, + } + response = await api.post('/monitor/job/preview', data=preview, headers={'X-Timezone': 'Asia/Kathmandu'}) + assert response.status == HTTPStatus.OK + payload = (await response.json())['data'] + assert payload['nextRunTimes'] == [ + '2026-03-07T07:30:00.000Z', + '2026-03-09T06:30:00.000Z', + '2026-03-10T06:30:00.000Z', + ] + bad_zone = await api.post('/monitor/job/preview', data={**preview, 'timeZone': 'Invalid/Zone'}) + assert bad_zone.status == HTTPStatus.UNPROCESSABLE_ENTITY + assert (await bad_zone.json())['detail'][0]['loc'][-1] == 'timeZone' + bad_range = await api.get('/system/config/list', params={'beginTime': '2026-02-30'}) + assert bad_range.status == HTTPStatus.UNPROCESSABLE_ENTITY + single_bound = await api.get('/system/config/list', params={'beginTime': '2000-01-01'}) + assert single_bound.status == HTTPStatus.OK + assert (await single_bound.json())['code'] == HTTPStatus.OK + bad_request_zone = await api.get('/system/config/list', headers={'X-Timezone': 'UTC+8'}) + assert bad_request_zone.status == HTTPStatus.UNPROCESSABLE_ENTITY + finally: + await browser.close() + await api.dispose() diff --git a/ruoyi-fastapi-test/time-contract/contract.mjs b/ruoyi-fastapi-test/time-contract/contract.mjs new file mode 100644 index 000000000..6db0cb40e --- /dev/null +++ b/ruoyi-fastapi-test/time-contract/contract.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +const fixtures = JSON.parse(readFileSync(new URL('./fixtures.json', import.meta.url))) + +/** + * 为 Web、App 及 Intl 兼容实现注册相同的时间契约测试。 + * 使用固定时间边界样例,不依赖本机配置、业务数据或预先生成的产物。 + * + * @param {Object} time 待验证的时间工具模块 + * @returns {void} + */ +export function testTimeContract(time) { + test('RFC 3339, invalid values, dates and explicit epoch units share one contract', () => { + for (const { value, zone, display } of fixtures.instants) { + time.setUserTimezone(zone) + assert.equal(time.formatBusinessTime(value), display) + } + for (const value of fixtures.invalid) { + assert.equal(time.formatBusinessTime(value), null, String(value)) + } + for (const value of fixtures.epochs) { + assert.equal(time.formatEpochSeconds(value), time.formatEpochMilliseconds(value * 1000)) + } + assert.notEqual(time.formatEpochSeconds(999999999), time.formatEpochMilliseconds(999999999)) + assert.equal(time.normalizeRangeBoundary('2026-08-28'), '2026-08-28') + assert.throws(() => time.normalizeRangeBoundary('2026-02-30')) + assert.throws(() => time.setUserTimezone('Mars/Olympus')) + time.setUserTimezone(' Asia/Shanghai ') + assert.equal(time.toRfc3339('2026-08-28 10:30:00'), '2026-08-28T10:30:00.000+08:00') + assert.equal(time.toRfc3339('2026-08-28T10:30:00.123456+08:00'), '2026-08-28T10:30:00.123+08:00') + }) + + test('gap is rejected and both fold occurrences require an explicit choice', async () => { + time.setUserTimezone('America/New_York') + assert.throws(() => time.toRfc3339('2026-03-08 02:30:00'), { code: 'DST_GAP' }) + assert.throws(() => time.toRfc3339('2026-11-01 01:30:00'), { code: 'DST_FOLD' }) + assert.deepEqual( + time.getWallTimeCandidates('2026-11-01 01:30:00').map(item => item.offset), + ['-04:00', '-05:00'] + ) + assert.equal(time.toRfc3339('2026-11-01 01:30:00', { offset: '-05:00' }), '2026-11-01T01:30:00.000-05:00') + const input = { scheduledAt: '2026-11-01 01:30:00' } + const result = await time.resolveTimeFields(input, ['scheduledAt'], async () => '-04:00') + assert.equal(result.scheduledAt, '2026-11-01T01:30:00.000-04:00') + assert.equal(input.scheduledAt, '2026-11-01 01:30:00') + assert.equal(time.getWallTimeCandidates('2026-04-05 01:45:00', 'Australia/Lord_Howe').length, 2) + }) + + test('unchanged form and subrows preserve milliseconds, original fold, null and omission', () => { + time.setUserTimezone('America/New_York') + const original = { id: 7, birthday: '2000-02-29', at: '2026-11-01T01:30:00.456-05:00', blank: null } + const form = time.prepareTimeFields(original, ['at', 'blank', 'missing']) + assert.equal(form.at, '2026-11-01 01:30:00') + time.setUserTimezone('Asia/Shanghai') // 已打开的表单保留原时区,重新打开时再使用新时区。 + assert.equal(time.getTimeFieldsTimezone(form), 'America/New_York') + assert.deepEqual(time.serializeTimeFields(form, ['at', 'blank', 'missing']), original) + form.at = '2026-11-02 01:30:00' + assert.equal(time.serializeTimeFields(form, ['at']).at, '2026-11-02T01:30:00.000-05:00') + form.at = '' + assert.equal(time.serializeTimeFields(form, ['at']).at, null) + const rows = [original, { id: 8, at: null }, { id: 9 }].map(row => time.prepareTimeFields(row, ['at'])) + assert.deepEqual( + rows.map(row => time.serializeTimeFields(row, ['at'])), + [original, { id: 8, at: null }, { id: 9 }] + ) + }) + + test('account timezone follows device changes, supports manual override and preserves business timezone', () => { + const DateTimeFormat = Intl.DateTimeFormat + let deviceZone = 'America/New_York' + try { + Intl.DateTimeFormat = function (locale, options) { + return new DateTimeFormat(locale, options || { timeZone: deviceZone }) + } + time.setBusinessTimezone('Asia/Shanghai') + time.setUserTimezone('auto') + assert.equal(time.getUserTimezone(), 'auto') + assert.equal(time.getDisplayTimezone(), 'America/New_York') + const instant = '2026-08-28T02:30:00.000Z' + assert.equal(time.formatBusinessTime(instant), '2026-08-27 22:30:00') + deviceZone = 'Asia/Kathmandu' + time.refreshDeviceTimezone() + assert.equal( + time.getDisplayTimezone(), + new DateTimeFormat('en', { timeZone: 'Asia/Kathmandu' }).resolvedOptions().timeZone + ) + assert.equal(time.formatBusinessTime(instant), '2026-08-28 08:15:00') + assert.equal(time.toRfc3339('2026-08-28 08:15:00'), '2026-08-28T08:15:00.000+05:45') + assert.equal(time.normalizeRangeBoundary('2000-02-29'), '2000-02-29') + time.setUserTimezone('UTC') + deviceZone = 'America/New_York' + time.refreshDeviceTimezone() + assert.equal(time.getDisplayTimezone(), 'UTC') + assert.equal(time.getBusinessTimezone(), 'Asia/Shanghai') + assert.equal(time.formatBusinessTime(instant), '2026-08-28 02:30:00') + assert.equal(time.getSupportedTimezones(['UTC', 'Mars/Olympus']).includes('Mars/Olympus'), false) + assert.throws(() => time.setUserTimezone('Mars/Olympus')) + assert.equal(time.getUserTimezone(), 'UTC') + // 兼容库的默认UTC不是设备时区;识别不可用时明确回退到系统配置。 + Intl.DateTimeFormat.polyfilled = true + time.setUserTimezone('auto') + assert.equal(time.getDeviceTimezone(), null) + assert.equal(time.getDisplayTimezone(), 'Asia/Shanghai') + time.setUserTimezone('America/New_York') + assert.equal(time.getDisplayTimezone(), 'America/New_York') + } finally { + Intl.DateTimeFormat = DateTimeFormat + time.setUserTimezone('auto') + } + }) +} diff --git a/ruoyi-fastapi-test/time-contract/fixtures.json b/ruoyi-fastapi-test/time-contract/fixtures.json new file mode 100644 index 000000000..adc4e4939 --- /dev/null +++ b/ruoyi-fastapi-test/time-contract/fixtures.json @@ -0,0 +1,10 @@ +{ + "instants": [ + {"value":"2026-08-28T02:30:00.123Z","zone":"Asia/Shanghai","display":"2026-08-28 10:30:00"}, + {"value":"2026-08-28T10:30:00.123+08:00","zone":"UTC","display":"2026-08-28 02:30:00"}, + {"value":"2026-08-27T22:30:00.123-04:00","zone":"Asia/Kathmandu","display":"2026-08-28 08:15:00"}, + {"value":"2026-11-01T06:30:00.456Z","zone":"America/New_York","display":"2026-11-01 01:30:00"} + ], + "invalid": [null,"","2026-08-28","2026-08-28T10:30:00","2026-02-30T00:00:00Z","2026-01-01T24:00:00Z","2026-01-01 00:00:00Z","2026-01-01T00:00:00+0800",1777000000,"1777000000"], + "epochs": [-1,0,999,999999999,1777000000,10000000000] +} diff --git a/ruoyi-fastapi-test/time-contract/run-host-timezones.mjs b/ruoyi-fastapi-test/time-contract/run-host-timezones.mjs new file mode 100644 index 000000000..c463afe66 --- /dev/null +++ b/ruoyi-fastapi-test/time-contract/run-host-timezones.mjs @@ -0,0 +1,24 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +// 根据脚本位置定位仓库,支持任意检出目录和调用目录。 +const root = fileURLToPath(new URL('../../', import.meta.url)) +const paths = [ + 'ruoyi-fastapi-frontend/tests/time/time.test.js', + 'ruoyi-fastapi-app/tests/time.test.js', + 'ruoyi-fastapi-app/tests/time-polyfill.test.js' +] +for (const zone of ['UTC', 'Asia/Shanghai', 'America/New_York']) { + const result = spawnSync(process.execPath, ['--test', ...paths], { + cwd: root, + env: { ...process.env, TZ: zone }, + encoding: 'utf8' + }) + console.log(`${zone}: ${result.status === 0 ? 'PASS' : 'FAIL'}`) + if (result.status !== 0) { + if (result.error) console.error(result.error.message) + if (result.signal) console.error(`Test process terminated by ${result.signal}`) + console.error(result.stdout, result.stderr) + process.exit(result.status || 1) + } +} diff --git a/ruoyi-fastapi-test/tool/test_code_gen.py b/ruoyi-fastapi-test/tool/test_code_gen.py index 7678b8aec..8f111a4f0 100644 --- a/ruoyi-fastapi-test/tool/test_code_gen.py +++ b/ruoyi-fastapi-test/tool/test_code_gen.py @@ -1,4 +1,6 @@ import re +from http import HTTPStatus +from urllib.parse import parse_qs, urlparse import pytest from playwright.async_api import async_playwright, expect @@ -56,16 +58,32 @@ async def search_table(self, table_name: str) -> None: async def import_table(self, table_name: str) -> None: """导入表""" - await self.page.get_by_role('button', name='导入').click() - - dialog = self.page.locator("div[role='dialog'][aria-label='导入表']") - await dialog.wait_for() - - # 搜索要导入的表 + # 初始列表响应可能晚于搜索响应,先等待初始数据渲染,避免覆盖搜索结果。 + async with self.page.expect_response( + lambda response: urlparse(response.url).path.endswith('/tool/gen/db/list') + ) as initial_response: + await self.page.get_by_role('button', name='导入').click() + response = await initial_response.value + assert response.status == HTTPStatus.OK + initial_data = await response.json() + assert initial_data['code'] == HTTPStatus.OK, initial_data + dialog = self.page.get_by_role('dialog', name='导入表', exact=True) + await expect(dialog.locator('tbody tr')).to_have_count(len(initial_data['rows'])) + + # 等待本次搜索响应及表格更新后再选择目标表。 await dialog.get_by_placeholder('请输入表名称').fill(table_name) - await dialog.get_by_role('button', name='搜索').click() - - # 等待搜索结果 + async with self.page.expect_response( + lambda response: ( + urlparse(response.url).path.endswith('/tool/gen/db/list') + and parse_qs(urlparse(response.url).query).get('tableName') == [table_name] + ) + ) as search_response: + await dialog.get_by_role('button', name='搜索').click() + response = await search_response.value + assert response.status == HTTPStatus.OK + search_data = await response.json() + assert search_data['code'] == HTTPStatus.OK, search_data + await expect(dialog.locator('tbody tr')).to_have_count(len(search_data['rows'])) await dialog.locator(f"tr:has-text('{table_name}')").wait_for() # 选中行 diff --git a/ruoyi-fastapi-test/tool/test_swagger.py b/ruoyi-fastapi-test/tool/test_swagger.py index 55f67f566..92878343c 100644 --- a/ruoyi-fastapi-test/tool/test_swagger.py +++ b/ruoyi-fastapi-test/tool/test_swagger.py @@ -20,10 +20,12 @@ async def check_swagger_interface(self) -> None: # 获取 iframe 内容框架 frame = self.page.frame_locator('iframe') - # 后端未禁用Swagger时,验证 iframe 内部的标题包含 "RuoYi-FastAPI" - # 当前生产环境已默认禁用Swagger,此处验证标题是否包含默认禁用提示 + # 默认验收容器的禁用状态;本机开发环境可显式验证启用状态。 h1_locator = frame.locator('h1') - await expect(h1_locator).to_contain_text('Swagger UI has been disabled. Please enable it first.', timeout=15000) + expected_title = ( + 'Swagger UI has been disabled. Please enable it first.' if Config.swagger_disabled else 'RuoYi-FastAPI' + ) + await expect(h1_locator).to_contain_text(expected_title, timeout=15000) @pytest.mark.asyncio