diff --git a/Main.py b/Main.py index 6761d9da..7c3bf3a4 100644 --- a/Main.py +++ b/Main.py @@ -214,9 +214,9 @@ class FilteredLoggingMiddleware: return self.app(environ, wrapped_start_response) -import re import threading import time +import re class SecurityMiddleware: def __init__(self, app): diff --git a/TempLate/GERP/_base b/TempLate/GERP/_base index 7ceff088..e10ca79a 100644 --- a/TempLate/GERP/_base +++ b/TempLate/GERP/_base @@ -24,7 +24,7 @@ - + diff --git a/TempLate/GERP/assets/css/Component.css b/TempLate/GERP/assets/css/Component.css index 176fd820..993b0b4c 100644 --- a/TempLate/GERP/assets/css/Component.css +++ b/TempLate/GERP/assets/css/Component.css @@ -233,6 +233,20 @@ margin-top: -2px; } +/* 半选状态(横线) */ +.GERPCheckbox input:indeterminate + .GERPCheckboxBox { + background: var(--PrimaryColor); + border-color: var(--PrimaryColor); +} + +.GERPCheckbox input:indeterminate + .GERPCheckboxBox::after { + content: ''; + width: 10px; + height: 0; + border-bottom: 2px solid white; + margin-top: 0; +} + .GERPCheckbox input:disabled + .GERPCheckboxBox { opacity: 0.5; cursor: not-allowed; @@ -458,6 +472,15 @@ align-items: center; justify-content: space-between; padding: 16px 20px 14px; + cursor: move; + user-select: none; +} + +/* 手机端对话框标题不显示拖动光标 */ +@media (max-width: 768px) { + .GERPDialogHeader { + cursor: default; + } } .GERPDialogTitle { diff --git a/TempLate/GERP/assets/css/GERP.css b/TempLate/GERP/assets/css/GERP.css index d1ff40f8..aac3c224 100644 --- a/TempLate/GERP/assets/css/GERP.css +++ b/TempLate/GERP/assets/css/GERP.css @@ -3396,6 +3396,15 @@ body.GERPSidebarOpen .GERPSidebar { padding: 20px 24px; border-bottom: 1px solid var(--BorderColor); flex-shrink: 0; + cursor: move; + user-select: none; +} + +/* 手机端抽屉标题不显示拖动光标 */ +@media (max-width: 768px) { + .GERPDrawerHeader { + cursor: default; + } } .GERPDrawerTitle { @@ -4369,6 +4378,14 @@ body.GERPSidebarOpen .GERPSidebar { justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--BorderColor); + cursor: move; + user-select: none; +} + +@media (max-width: 768px) { + .GERPDrawerHeader { + cursor: default; + } } .GERPDrawerTitle { diff --git a/TempLate/GERP/assets/gerp/CompanyCommon.js b/TempLate/GERP/assets/gerp/CompanyCommon.js new file mode 100644 index 00000000..5e801b04 --- /dev/null +++ b/TempLate/GERP/assets/gerp/CompanyCommon.js @@ -0,0 +1,468 @@ +/** + * CompanyCommon.js — Company/* 页面共享组件 + * 提供工具函数、TablePage 基类和共享样式,消除重复代码。 + * 所有 Company/* 页面通过 引入。 + */ + +// ==================== 共享样式(仅注入一次) ==================== +(function () { + if (document.getElementById('CompanyCommonStyles')) return; + const style = document.createElement('style'); + style.id = 'CompanyCommonStyles'; + style.textContent = ` + .GERPPage { padding: 24px; max-width: 1400px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; } + .cp-page { padding: 20px; flex: 1; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; } + .cp-header { display: flex; align-items: center; margin-bottom: 16px; gap: 12px; flex-shrink: 0; } + .cp-header h1 { margin: 0; flex: 1; } + .cp-container { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } + + /* 详情面板 */ + .cp-detail-section { margin-bottom: 20px; } + .cp-detail-section h3 { font-size: 14px; font-weight: 600; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 1px solid var(--BorderColor); display: flex; align-items: center; gap: 8px; } + .cp-detail-section h3 i { color: var(--PrimaryColor); } + .cp-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; } + .cp-info-item { font-size: 13px; } + .cp-info-item .label { color: var(--TextSecondary); font-size: 12px; } + .cp-info-item .value { color: var(--TextPrimary); font-weight: 500; word-break: break-all; } + .cp-sticky-info { position: sticky; top: 0; z-index: 10; background: var(--BgColor, #fff); padding: 12px 0; border-bottom: 1px solid var(--BorderColor); margin-bottom: 16px; } + .cp-detail-list { list-style: none; padding: 0; margin: 0; } + .cp-detail-list li { padding: 8px 12px; border: 1px solid var(--BorderColor); border-radius: 6px; margin-bottom: 6px; font-size: 13px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .cp-detail-list li .item-name { font-weight: 500; flex: 1; } + .cp-detail-list li .item-desc { color: var(--TextSecondary); font-size: 12px; } + .cp-empty { text-align: center; padding: 20px; color: var(--TextSecondary); font-size: 13px; } + .cp-error { text-align: center; padding: 40px; color: var(--DangerColor); font-size: 14px; } + .cp-loading { text-align: center; padding: 40px; color: var(--TextSecondary); font-size: 13px; } + + /* 返回按钮 */ + .cp-back-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid var(--BorderColor); border-radius: 6px; background: var(--BgColor, #fff); color: var(--TextPrimary); font-size: 13px; cursor: pointer; transition: all .2s; text-decoration: none; } + .cp-back-btn:hover { border-color: var(--PrimaryColor); color: var(--PrimaryColor); } + `; + document.head.appendChild(style); +})(); + +// ==================== 工具函数 ==================== +const CompanyUtils = { + // 实体选择器共享配置 + EntityPickerConfigs: { + User: { + api: '/API/FormAPI/UserSearch', + labelField: 'UserName', + valueField: 'UserUUID', + secondaryField: 'UserEmail', + placeholder: '输入用户名搜索...', + }, + Role: { + api: '/API/FormAPI/RoleSearch', + labelField: 'RoleName', + valueField: 'RoleUUID', + placeholder: '输入角色名搜索...', + }, + Company: { + api: '/API/FormAPI/CompanySearch', + labelField: 'CompanyName', + valueField: 'CompanyUUID', + placeholder: '输入公司名搜索...', + }, + Department: { + api: '/API/FormAPI/DepartmentSearch', + labelField: 'DeptName', + valueField: 'DeptUUID', + placeholder: '输入部门名搜索...', + }, + Workgroup: { + api: '/API/FormAPI/WorkgroupSearch', + labelField: 'WorkgroupName', + valueField: 'WorkgroupUUID', + placeholder: '输入工作组名搜索...', + }, + Permission: { + api: '/API/FormAPI/PermissionSearch', + labelField: 'PermName', + valueField: 'PermUUID', + secondaryField: 'PermCode', + placeholder: '输入权限名搜索...', + }, + }, + + /** 生成 entityPicker 列配置 */ + epColumn(field, title, entityName, extra = {}) { + const ep = this.EntityPickerConfigs[entityName]; + if (!ep) return { field, title, ...extra }; + return { + field, title, type: 'entityPicker', entityPicker: ep, ...extra, + }; + }, + + /** HTML 转义 */ + esc(s) { + if (s == null) return ''; + const d = document.createElement('div'); + d.textContent = String(s); + return d.innerHTML; + }, + + /** 状态标签 */ + statusTag(status) { + if (status === 1 || status === '1') return '启用'; + return '禁用'; + }, + + /** 时间格式化 */ + formatTime(t) { + if (!t) return '-'; + try { const d = new Date(t + (t.endsWith('Z') ? '' : 'Z')); return d.toLocaleString(); } catch { return t; } + }, + + /** 角色类型标签(0=组员/员工, 1=组长/负责人) */ + roleTypeTag(roleType, context) { + const blue = { bg: '#dbeafe', color: '#1e40af' }; + const purple = { bg: '#ede9fe', color: '#6d28d9' }; + let label = '', style = blue; + if (context === 'dept') { + if (roleType === 0) { label = '员工'; style = blue; } + else if (roleType === 1) { label = '负责人'; style = purple; } + else return ''; + } else if (context === 'workgroup') { + if (roleType === 0) { label = '组员'; style = blue; } + else if (roleType === 1) { label = '组长'; style = purple; } + else return ''; + } else { + if (roleType === 0) { label = '组员'; style = blue; } + else if (roleType === 1) { label = '组长'; style = purple; } + else return ''; + } + return `${label}`; + }, + + /** 等待 GERPUI 就绪 */ + waitForUI(callback) { + if (window.GERPUI && typeof window.GERPUI.OpenDrawer === 'function') { + callback(window.GERPUI); + return; + } + document.addEventListener('GERPUIReady', () => callback(window.GERPUI), { once: true }); + }, + + /** 从 URL 获取 server 参数 */ + getServer() { + return new URLSearchParams(window.location.search).get('server') || 'gvsds-master'; + }, + + /** 设置页面标题 */ + setPageTitle(title) { + const server = this.getServer(); + const fullTitle = server === 'gvsds-master' ? title : `${server} ${title}`; + if (typeof GERP !== 'undefined' && GERP.SetTitle) GERP.SetTitle(fullTitle); + const el = document.getElementById('page-title'); + if (el) el.textContent = server === 'gvsds-master' ? title : `${server} - ${title}`; + }, + + /** 跳转到详情页 */ + navigateTo(pagePath, pkName, pkValue, title) { + const server = this.getServer(); + const params = new URLSearchParams({ [pkName]: pkValue }); + if (server !== 'gvsds-master') params.set('server', server); + const url = `${pagePath}?${params}`; + console.log('[CompanyUtils.navigateTo]', { pagePath, pkName, pkValue, title, url }); + if (window.GERP && typeof window.GERP.Navigate === 'function') { + window.GERP.Navigate(url, title || ''); + } else { + console.warn('[CompanyUtils.navigateTo] GERP.Navigate 不可用,使用 location.href 跳转'); + window.location.href = url; + } + }, + + // ---- UUID 解析缓存 ---- + _uuidCache: {}, + + /** + * 批量解析 UUID 到名称 + * @param {Object} uuidMap - 格式: { key: {table:'Company', uuids:['xxx','yyy']}, ... } + * @returns {Object} - 格式: { key: {'xxx':'公司A', 'yyy':'公司B'}, ... } + */ + async resolveUUIDs(uuidMap) { + const server = this.getServer(); + const nameFieldMap = { + Company: 'CompanyName', + Department: 'DeptName', + Workgroup: 'WorkgroupName', + UserRole: 'RoleName', + }; + const pkFieldMap = { + Company: 'CompanyUUID', + Department: 'DeptUUID', + Workgroup: 'WorkgroupUUID', + UserRole: 'UserRoleUUID', + }; + + const result = {}; + const fetchPromises = []; + + for (const [key, info] of Object.entries(uuidMap)) { + result[key] = {}; + const { table, uuids } = info; + const nameField = nameFieldMap[table] || 'Name'; + const pkField = pkFieldMap[table] || (table + 'UUID'); + + for (const uuid of uuids) { + if (!uuid) continue; + // 检查缓存 + const cacheKey = `${table}:${uuid}`; + if (this._uuidCache[cacheKey] !== undefined) { + result[key][uuid] = this._uuidCache[cacheKey]; + continue; + } + // 需要请求 + fetchPromises.push( + this._fetchUUIDName(table, pkField, nameField, uuid, server).then(name => { + this._uuidCache[cacheKey] = name; + result[key][uuid] = name; + }).catch(() => { + result[key][uuid] = uuid; + }) + ); + } + } + + await Promise.all(fetchPromises); + return result; + }, + + /** 内部:获取单个 UUID 对应的名称 */ + async _fetchUUIDName(table, pkField, nameField, uuid, server) { + const params = { PageSize: '1' }; + params[pkField] = uuid; + if (server && server !== 'gvsds-master') params.server = server; + const qs = new URLSearchParams(params).toString(); + const url = `/API/FormAPI/Get/${table}?${qs}`; + const resp = await fetch(url, { credentials: 'include' }); + const json = await resp.json(); + if (json.code === 0 && json.data && json.data.length > 0) { + return json.data[0][nameField] || uuid; + } + return uuid; + }, + + /** + * 通用编辑对话框 + * @param {Object} config + * @param {string} config.tableName - 表名 + * @param {Object} config.data - 当前数据(含主键) + * @param {Array} [config.columns] - 列配置(可选,不传则自动获取) + * @param {string} [config.server] - server 参数 + * @param {Function} [config.onSave] - 保存成功后的回调 + */ + async showEditDialog(config) { + const { tableName, data, server, onSave } = config; + const svr = server || this.getServer(); + const extraParams = config.extraParams || {}; + + // 获取列配置 + let columns = config.columns; + if (!columns) { + const params = { ...extraParams }; + if (svr !== 'gvsds-master') params.server = svr; + const qs = new URLSearchParams(params).toString(); + const resp = await fetch(`/API/FormAPI/Columns/${tableName}?${qs}`, { credentials: 'include' }); + const result = await resp.json(); + if (result.code !== 0) { + alert('获取列配置失败: ' + (result.msg || '未知错误')); + return; + } + columns = result.data || []; + } + + // 隐藏不可编辑字段 + const pkFieldMap = { + Company: 'CompanyUUID', + Department: 'DeptUUID', + Workgroup: 'WorkgroupUUID', + UserRole: 'UserRoleUUID', + User: 'UserUUID', + }; + const pkField = pkFieldMap[tableName] || (tableName + 'UUID'); + columns = columns.map(c => { + if (c.field === 'CompanyUUID') { c.editable = false; c.hideInForm = true; } + if (c.field === pkField) { c.editable = false; c.hideInForm = true; } + if (c.field === 'UserPassword') { c.editable = false; c.hideInForm = true; } + return c; + }); + + // 生成表单 + const formHtml = new FormEngine().GenerateForm(columns, data); + + const pkValue = data[pkField]; + + // 保存对话框引用 + let dialogEl = null; + + window.GERPUI.ShowDialog({ + title: '编辑' + (data._displayName || ''), + content: formHtml, + width: '600px', + buttons: [ + { text: '取消', type: 'Secondary' }, + { + text: '保存', type: 'Primary', onClick: () => { + // 使用 FormEngine.GetFormData 收集表单数据 + if (!dialogEl) dialogEl = document.querySelector('.GERPDialog'); + const formEl = dialogEl ? dialogEl.querySelector('.GERPForm') : null; + if (!formEl) return false; + + const formData = new FormEngine().GetFormData(formEl); + // 移除不可编辑字段 + delete formData.CompanyUUID; + + // 调用 ModifyItem 接口 + const params = { ...extraParams }; + if (svr !== 'gvsds-master') params.server = svr; + const qs = new URLSearchParams(params).toString(); + const url = `/API/FormAPI/ModifyItem/${tableName}/${pkValue}?${qs}`; + + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(formData), + }) + .then(resp => resp.json()) + .then(result => { + if (result.code === 0) { + window.GERPUI.CloseDialog(); + if (typeof onSave === 'function') onSave(); + } else { + alert('保存失败: ' + (result.msg || '未知错误')); + } + }) + .catch(err => { + alert('网络错误: ' + err.message); + }); + + return false; // 不自动关闭对话框 + } + } + ] + }); + }, +}; + +// ==================== TablePage 基类 ==================== +/** + * 强公司隔离的 TableEngine 页面基类。 + * 自动处理:server 参数、列配置获取、CompanyUUID 隐藏、标准 TableEngine 选项。 + * 子类只需提供 tableName、defaultColumns 和 onRowClick 回调。 + */ +class CompanyTablePage { + /** + * @param {Object} config + * @param {string} config.tableName - 表名(如 'Company', 'Department', 'Workgroup') + * @param {string} config.title - 页面标题 + * @param {Function} config.defaultColumns - 返回默认列数组的函数 + * @param {Function} [config.onView] - 查看按钮回调 (row) => void,设置后显示眼睛按钮 + * @param {string} [config.pkField] - 主键字段名,用于 fetchDetail 按字段过滤 + * @param {string} [config.container] - 容器选择器,默认 '#TableContainer' + */ + constructor(config) { + this.tableName = config.tableName; + this.title = config.title; + this.defaultColumnsFn = config.defaultColumns; + this.onView = config.onView || null; + this.pkField = config.pkField || (this.tableName + 'UUID'); + this.container = config.container || '#TableContainer'; + this.server = CompanyUtils.getServer(); + this.tableInstance = null; + this.drawerClose = null; + + CompanyUtils.setPageTitle(this.title); + } + + /** 构建带 server 的 FormAPI URL */ + apiUrl(path, params = {}) { + const allParams = {}; + if (this.server !== 'gvsds-master') allParams.server = this.server; + Object.assign(allParams, params); + const qs = new URLSearchParams(allParams).toString(); + return qs ? `${path}?${qs}` : path; + } + + /** 初始化表格 */ + async init() { + const containerEl = document.querySelector(this.container); + if (!containerEl) return; + + try { + let columns; + if (this.server === 'gvsds-master') { + const resp = await fetch(this.apiUrl(`/API/FormAPI/Columns/${this.tableName}`), { credentials: 'include' }); + const result = await resp.json(); + if (result.code !== 0) { + containerEl.innerHTML = `
加载列配置失败: ${CompanyUtils.esc(result.msg || '')}
`; + return; + } + columns = result.data || []; + } else { + columns = this.defaultColumnsFn(); + } + + // 强公司隔离:CompanyUUID 由后端自动注入,前端隐藏输入框 + columns = columns.map(c => { + if (c.field === 'CompanyUUID') { c.editable = false; c.hideInForm = true; } + return c; + }); + + this.tableInstance = new TableEngine({ + container: this.container, + tableName: this.tableName, + columns, + data: [], + pageSize: 20, + showPagination: true, + showCheckbox: true, + editable: true, + sortable: true, + filterable: true, + striped: true, + hoverable: true, + bordered: true, + api: { + get: this.apiUrl(`/API/FormAPI/Get/${this.tableName}`), + add: this.apiUrl(`/API/FormAPI/Add/${this.tableName}`), + edit: this.apiUrl(`/API/FormAPI/Modify/${this.tableName}/`), + modify: this.apiUrl(`/API/FormAPI/ModifyItem/${this.tableName}/`), + delete: this.apiUrl(`/API/FormAPI/Delete/${this.tableName}/`), + }, + onView: this.onView, + // 注意:不设置 onEdit/onDelete,让 TableEngine 使用内置的编辑/删除对话框 + }); + } catch (error) { + console.error('init error:', error); + containerEl.innerHTML = `
初始化失败: ${CompanyUtils.esc(error.message)}
`; + } + } + + /** 通过 PK 字段获取单条记录 */ + async fetchDetail(pkValue, pageSize = 1) { + const params = { PageSize: String(pageSize) }; + params[this.pkField] = pkValue; + const url = this.apiUrl(`/API/FormAPI/Get/${this.tableName}`, params); + const resp = await fetch(url, { credentials: 'include' }); + return resp.json(); + } + + /** 通过 FormAPI 获取关联列表(支持字段过滤或关键字搜索) */ + async fetchRelated(relatedTable, filterValue, pageSize = 50, filterField = null) { + const params = { PageSize: String(pageSize) }; + if (filterField) { + params[filterField] = filterValue; + } else { + params.keyword = filterValue; + } + const url = this.apiUrl(`/API/FormAPI/Get/${relatedTable}`, params); + const resp = await fetch(url, { credentials: 'include' }); + return resp.json(); + } +} + +// 暴露到全局 +window.CompanyUtils = CompanyUtils; +window.CompanyTablePage = CompanyTablePage; diff --git a/TempLate/GERP/assets/gerp/GERPUI/GERPUI.js b/TempLate/GERP/assets/gerp/GERPUI/GERPUI.js index f94435fe..ca582d15 100644 --- a/TempLate/GERP/assets/gerp/GERPUI/GERPUI.js +++ b/TempLate/GERP/assets/gerp/GERPUI/GERPUI.js @@ -3194,6 +3194,13 @@ class GERPUI { }); }); + // 桌面端:标题栏可拖动 + const dlgHeader = dialog.querySelector('.GERPDialogHeader'); + const dlgContent = dialog.querySelector('.GERPDialogContent'); + if (dlgHeader && dlgContent) { + this._makeDraggable(dlgContent, dlgHeader); + } + return this.State.CurrentDialog; } @@ -3264,12 +3271,17 @@ class GERPUI { OpenDrawer(options) { const { title, content, placement = 'right', width = '400px', showClose = true, onClose, footer } = options; + const isMobile = this._isMobile(); const drawer = document.createElement('div'); drawer.className = `GERPDrawer GERPDrawer${this.Capitalize(placement)}`; + + // 手机端:全屏显示 + const actualWidth = isMobile ? '100%' : width; + drawer.innerHTML = `
-
+
${title ? `

${title}

@@ -3298,6 +3310,15 @@ class GERPUI { drawer.querySelector('.GERPDrawerMask')?.addEventListener('click', closeDrawer); drawer.querySelector('.GERPDrawerClose')?.addEventListener('click', closeDrawer); + // 桌面端:标题栏可拖动 + if (!isMobile) { + const header = drawer.querySelector('.GERPDrawerHeader'); + const contentEl = drawer.querySelector('.GERPDrawerContent'); + if (header && contentEl) { + this._makeDraggable(contentEl, header); + } + } + return { element: drawer, close: closeDrawer }; } @@ -3425,6 +3446,82 @@ class GERPUI { return str.charAt(0).toUpperCase() + str.slice(1); } + /** 判断是否为移动端 */ + _isMobile() { + return window.matchMedia('(max-width: 768px)').matches || 'ontouchstart' in window; + } + + /** + * 使元素可通过拖柄拖动(桌面端)。 + * 拖动时自动从 flex 居中切换到 absolute 定位,保持窗口在视口内。 + */ + _makeDraggable(contentEl, handleEl) { + if (this._isMobile()) return; // 手机端不启用拖动 + + let isDragging = false; + let startX, startY, startLeft, startTop; + let switchedToAbsolute = false; + + handleEl.style.cursor = 'move'; + handleEl.style.userSelect = 'none'; + + const onPointerDown = (e) => { + // 排除关闭按钮等子元素 + if (e.target.closest('.GERPDialogClose, .GERPDrawerClose, button')) return; + isDragging = true; + startX = e.clientX; + startY = e.clientY; + + if (!switchedToAbsolute) { + // 首次拖动:从 flex 居中切换到 absolute 定位 + const rect = contentEl.getBoundingClientRect(); + const parentEl = contentEl.parentElement; + // 取消父级 flex 居中 + parentEl.style.display = 'block'; + contentEl.style.position = 'absolute'; + contentEl.style.left = rect.left + 'px'; + contentEl.style.top = rect.top + 'px'; + contentEl.style.margin = '0'; + startLeft = rect.left; + startTop = rect.top; + switchedToAbsolute = true; + } else { + startLeft = parseFloat(contentEl.style.left) || 0; + startTop = parseFloat(contentEl.style.top) || 0; + } + + document.addEventListener('mousemove', onPointerMove); + document.addEventListener('mouseup', onPointerUp); + e.preventDefault(); + }; + + const onPointerMove = (e) => { + if (!isDragging) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let newLeft = startLeft + dx; + let newTop = startTop + dy; + + // 限制在视口内 + const rect = contentEl.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + newLeft = Math.max(0, Math.min(newLeft, vw - rect.width)); + newTop = Math.max(0, Math.min(newTop, vh - rect.height)); + + contentEl.style.left = newLeft + 'px'; + contentEl.style.top = newTop + 'px'; + }; + + const onPointerUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onPointerMove); + document.removeEventListener('mouseup', onPointerUp); + }; + + handleEl.addEventListener('mousedown', onPointerDown); + } + // ==================== 高级模态框 ==================== ShowModal(options) { const { title, content, width = '520px', closable = true, maskClosable = true, footer = null, onOk, onCancel, className = '' } = options; @@ -3490,6 +3587,13 @@ class GERPUI { closeModal(); }); + // 桌面端:标题栏可拖动 + const modalHeader = modal.querySelector('.GERPModalHeader'); + const modalWrapper = modal.querySelector('.GERPModalWrapper'); + if (modalHeader && modalWrapper) { + this._makeDraggable(modalWrapper, modalHeader); + } + return { element: modal, close: closeModal }; } diff --git a/TempLate/GERP/assets/gerp/TableEngine.js b/TempLate/GERP/assets/gerp/TableEngine.js index d507b55e..b4ea9e04 100644 --- a/TempLate/GERP/assets/gerp/TableEngine.js +++ b/TempLate/GERP/assets/gerp/TableEngine.js @@ -343,9 +343,10 @@ class TableEngine { const cell = document.createElement('div'); cell.className = 'GERPTableHeaderCell GERPTableActionsCell GERPTableFixedRight'; cell.textContent = '操作'; - cell.style.flex = '0 0 100px'; - cell.style.width = '100px'; - cell.style.minWidth = '100px'; + const w = this.Options.onView ? '150px' : '100px'; + cell.style.flex = `0 0 ${w}`; + cell.style.width = w; + cell.style.minWidth = w; cell.style.justifyContent = 'center'; return cell; } @@ -465,20 +466,42 @@ class TableEngine { _createActionsCell(row) { const cell = document.createElement('div'); cell.className = 'GERPTableCell GERPTableActionsCell GERPTableFixedRight'; - cell.style.flex = '0 0 100px'; - cell.style.width = '100px'; - cell.style.minWidth = '100px'; + const hasView = !!this.Options.onView; + const w = hasView ? '150px' : '100px'; + cell.style.flex = `0 0 ${w}`; + cell.style.width = w; + cell.style.minWidth = w; cell.style.justifyContent = 'center'; const actions = document.createElement('div'); actions.className = 'GERPTableActions'; + // 图标按钮通用样式(紧凑型) + const iconBtnStyle = 'padding:4px 8px;font-size:13px;'; + + // 查看按钮(眼睛图标) + if (hasView) { + const viewBtn = document.createElement('button'); + viewBtn.className = 'GERPBtn Text'; + viewBtn.innerHTML = ''; + viewBtn.title = '查看'; + viewBtn.style.cssText = iconBtnStyle + 'color:var(--PrimaryColor);'; + viewBtn.addEventListener('click', (e) => { + e.stopPropagation(); + console.log('[TableEngine] onView clicked, row:', row); + this.Options.onView(row); + }); + actions.appendChild(viewBtn); + } + if (this.Options.editable) { const editBtn = document.createElement('button'); editBtn.className = 'GERPBtn Text'; editBtn.innerHTML = ''; editBtn.title = '编辑'; - editBtn.addEventListener('click', () => { + editBtn.style.cssText = iconBtnStyle; + editBtn.addEventListener('click', (e) => { + e.stopPropagation(); this.ShowEditDialog(row); }); actions.appendChild(editBtn); @@ -488,7 +511,11 @@ class TableEngine { deleteBtn.className = 'GERPBtn Text'; deleteBtn.innerHTML = ''; deleteBtn.title = '删除'; - deleteBtn.addEventListener('click', () => this.HandleDelete(row)); + deleteBtn.style.cssText = iconBtnStyle; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.HandleDelete(row); + }); actions.appendChild(deleteBtn); cell.appendChild(actions); @@ -684,14 +711,9 @@ class TableEngine { if (column.type === 'entityPicker' && column.entityPicker) { const ep = column.entityPicker; - // 尝试从行数据中获取关联实体的显示名称 - const labelField = ep.labelField || ''; - const valueField = ep.valueField || column.field; - // 查找行中是否有 "字段名_标签" 或 "字段名__label" 等辅助字段 + // 优先使用后端附带的 _label 字段(关联实体的实际名称) let label = ''; - if (row[labelField]) { - label = String(row[labelField]); - } else if (row[column.field + '_label']) { + if (row[column.field + '_label']) { label = String(row[column.field + '_label']); } else if (row[column.field + '__label']) { label = String(row[column.field + '__label']); @@ -808,20 +830,21 @@ class TableEngine { return; } - const checkbox = e.target.closest('.GERPTableRowCheckbox'); - if (checkbox) { - const rowId = checkbox.dataset.id; - this.ToggleRowSelection(rowId); - } + const checkboxLabel = e.target.closest('.GERPCheckbox'); + if (checkboxLabel) { + const input = checkboxLabel.querySelector('input'); + if (input?.classList.contains('GERPTableRowCheckbox')) { + const rowId = input.dataset.id; + this.ToggleRowSelection(rowId); + } - const selectAll = e.target.closest('.GERPTableSelectAll'); - if (selectAll) { - const isChecked = selectAll.checked; - if (selectAll.indeterminate) { - selectAll.checked = false; - this.ToggleSelectAll(false); - } else { - this.ToggleSelectAll(isChecked); + if (input?.classList.contains('GERPTableSelectAll')) { + if (input.indeterminate) { + input.checked = false; + this.ToggleSelectAll(false); + } else { + this.ToggleSelectAll(input.checked); + } } } @@ -1529,7 +1552,7 @@ class TableEngine { cell.classList.add('GERPTableCellEditing'); - const input = this._createEditInput(value, column); + const input = this._createEditInput(value, column, row); cell.innerHTML = ''; cell.appendChild(input); @@ -1557,7 +1580,7 @@ class TableEngine { if (input.select) input.select(); } - _createEditInput(value, column) { + _createEditInput(value, column, rowData) { let input; switch (column.type) { @@ -1636,9 +1659,14 @@ class TableEngine { input.className = 'GERPEntityPicker'; const ep = column.entityPicker || {}; const epValue = value || ''; + // 从行数据中获取关联实体的显示名称 + const epLabel = (rowData && rowData[column.field + '_label']) + || (rowData && rowData[column.field + '__label']) + || ''; + const epDisplay = epLabel || (epValue ? String(epValue).substring(0, 8) + '...' : ''); input.innerHTML = ` -
${epValue ? String(epValue).substring(0, 8) + '...' : ''}
+
${this._escapeHtml(epDisplay)}
`; input.style.position = 'relative'; diff --git a/TempLate/GERP/assets/gerp/gerp.js b/TempLate/GERP/assets/gerp/gerp.js index cea46151..aef09dd3 100644 --- a/TempLate/GERP/assets/gerp/gerp.js +++ b/TempLate/GERP/assets/gerp/gerp.js @@ -1000,11 +1000,17 @@ class GERP { // 静态方法:供子页面调用 static Navigate(page, title) { - window.parent.postMessage({ type: 'NAVIGATE', data: { page, title } }, '*'); + if (window.parent !== window && window.parent.GERPApp && window.parent.GERPApp.UI) { + // 在 GERP 父框架内:通过 postMessage 通知父框架打开新标签页 + window.parent.postMessage({ type: 'NAVIGATE', data: { page, title } }, '*'); + } else { + // 非 GERP 父情况:在新标签页打开 + window.open(page, '_blank'); + } } static SetTitle(title) { - if (window.parent !== window) { + if (window.parent !== window && window.parent.GERPApp && window.parent.GERPApp.UI) { window.parent.postMessage({ type: 'SET_TITLE', data: { title } }, '*'); } else { document.title = title; @@ -1012,7 +1018,11 @@ class GERP { } static Refresh() { - window.parent.postMessage({ type: 'REFRESH' }, '*'); + if (window.parent !== window && window.parent.GERPApp && window.parent.GERPApp.UI) { + window.parent.postMessage({ type: 'REFRESH' }, '*'); + } else { + window.location.reload(); + } } } diff --git a/TempLate/GERP/pages/Company/company-detail.html b/TempLate/GERP/pages/Company/company-detail.html new file mode 100644 index 00000000..320c90ec --- /dev/null +++ b/TempLate/GERP/pages/Company/company-detail.html @@ -0,0 +1,244 @@ + + +
+
+
+ 返回 +

+ + 公司详情 +

+ +
+
+
加载中...
+
+
+
+ + diff --git a/TempLate/GERP/pages/Company/company.html b/TempLate/GERP/pages/Company/company.html new file mode 100644 index 00000000..dc4e0573 --- /dev/null +++ b/TempLate/GERP/pages/Company/company.html @@ -0,0 +1,38 @@ + + +
+
+
+

+ + 公司管理 +

+
+
+
+
+ + diff --git a/TempLate/GERP/pages/Company/department-detail.html b/TempLate/GERP/pages/Company/department-detail.html new file mode 100644 index 00000000..d3622ba2 --- /dev/null +++ b/TempLate/GERP/pages/Company/department-detail.html @@ -0,0 +1,152 @@ + + +
+
+
+ 返回 +

+ + 部门详情 +

+ +
+
+
加载中...
+
+
+
+ + diff --git a/TempLate/GERP/pages/Company/department.html b/TempLate/GERP/pages/Company/department.html new file mode 100644 index 00000000..b05fcc61 --- /dev/null +++ b/TempLate/GERP/pages/Company/department.html @@ -0,0 +1,39 @@ + + +
+
+
+

+ + 部门管理 +

+
+
+
+
+ + diff --git a/TempLate/GERP/pages/Company/employee-detail.html b/TempLate/GERP/pages/Company/employee-detail.html new file mode 100644 index 00000000..22d3a662 --- /dev/null +++ b/TempLate/GERP/pages/Company/employee-detail.html @@ -0,0 +1,187 @@ + + +
+
+
+ 返回 +

+ + 员工详情 +

+ +
+
+
加载中...
+
+
+
+ + diff --git a/TempLate/GERP/pages/Company/workgroup-detail.html b/TempLate/GERP/pages/Company/workgroup-detail.html new file mode 100644 index 00000000..eef482ac --- /dev/null +++ b/TempLate/GERP/pages/Company/workgroup-detail.html @@ -0,0 +1,145 @@ + + +
+
+
+ 返回 +

+ + 工作组详情 +

+ +
+
+
加载中...
+
+
+
+ + diff --git a/TempLate/GERP/pages/Company/workgroup.html b/TempLate/GERP/pages/Company/workgroup.html new file mode 100644 index 00000000..3669aa27 --- /dev/null +++ b/TempLate/GERP/pages/Company/workgroup.html @@ -0,0 +1,35 @@ + + +
+
+
+

+ + 工作组管理 +

+
+
+
+
+ + diff --git a/TempLate/GERP/pages/system/role-detail.html b/TempLate/GERP/pages/system/role-detail.html new file mode 100644 index 00000000..d6769e38 --- /dev/null +++ b/TempLate/GERP/pages/system/role-detail.html @@ -0,0 +1,261 @@ + + +
+
+
+ 返回 +

+ + 角色详情 +

+ + +
+
+
加载中...
+
+
+
+ + diff --git a/TempLate/GERP/pages/system/roles.html b/TempLate/GERP/pages/system/roles.html new file mode 100644 index 00000000..3f4b5753 --- /dev/null +++ b/TempLate/GERP/pages/system/roles.html @@ -0,0 +1,118 @@ + + +
+
+
+

+ + 角色管理 +

+
+ +
+
+
+ + diff --git a/TempLate/GERP/pages/system/user-detail.html b/TempLate/GERP/pages/system/user-detail.html new file mode 100644 index 00000000..7dfe5824 --- /dev/null +++ b/TempLate/GERP/pages/system/user-detail.html @@ -0,0 +1,227 @@ + + +
+
+
+ 返回 +

+ + 用户详情 +

+ + +
+
+
加载中...
+
+
+
+ + diff --git a/TempLate/GERP/pages/system/users.html b/TempLate/GERP/pages/system/users.html index 125e3f15..c67db1d8 100644 --- a/TempLate/GERP/pages/system/users.html +++ b/TempLate/GERP/pages/system/users.html @@ -4,24 +4,6 @@ .user-header { display: flex; align-items: center; margin-bottom: 16px; gap: 12px; flex-shrink: 0; } .user-header h1 { margin: 0; flex: 1; } .user-container { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } - - /* 详情面板内样式 */ - .user-detail-section { margin-bottom: 20px; } - .user-detail-section h3 { font-size: 14px; font-weight: 600; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 1px solid var(--BorderColor); display: flex; align-items: center; gap: 8px; } - .user-detail-section h3 i { color: var(--PrimaryColor); } - .user-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; } - .user-info-item { font-size: 13px; } - .user-info-item .label { color: var(--TextSecondary); font-size: 12px; } - .user-info-item .value { color: var(--TextPrimary); font-weight: 500; word-break: break-all; } - .user-detail-list { list-style: none; padding: 0; margin: 0; } - .user-detail-list li { padding: 8px 12px; border: 1px solid var(--BorderColor); border-radius: 6px; margin-bottom: 6px; font-size: 13px; display: flex; align-items: center; gap: 8px; } - .user-detail-list li .item-name { font-weight: 500; flex: 1; } - .user-detail-list li .item-desc { color: var(--TextSecondary); font-size: 12px; } - .user-order-item { display: flex; align-items: center; gap: 12px; width: 100%; } - .user-order-item .order-no { font-weight: 600; color: var(--PrimaryColor); } - .user-order-item .order-amount { font-weight: 600; } - .user-order-item .order-status { margin-left: auto; } - .user-empty { text-align: center; padding: 20px; color: var(--TextSecondary); font-size: 13px; }
@@ -40,9 +22,7 @@ diff --git a/TempLate/login.html b/TempLate/login.html index bbfdd9c1..b3551600 100644 --- a/TempLate/login.html +++ b/TempLate/login.html @@ -1,5 +1,8 @@
-
Card image +
+ Card image

登录

-
+ +
+
-
+ +
+
-
+ +
+
-
-
+
+ +
+
+ +
-
我还没有账号,去 注册
-
+
+ 我还没有账号,去 + 注册 +
+
+ +
@@ -51,10 +77,99 @@ if (window.parent && window.parent !== window) {window.parent.location.reload(); @keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} .animate-fade-in{animation:fadeIn .3s ease-out forwards} input:focus~.input-icon{color:#a855f7} -#LoginToast{animation:fadeIn .3s ease-out forwards} +#LoginToast{ + animation:fadeIn .3s ease-out forwards; + position:relative; + z-index:999; + width:100%; + box-sizing:border-box; +} \ No newline at end of file diff --git a/TempLate/register.html b/TempLate/register.html index 9423526f..1875b4a0 100644 --- a/TempLate/register.html +++ b/TempLate/register.html @@ -16,37 +16,60 @@ if (window.parent && window.parent !== window) { window.parent.location.reload()
-
-
Card image +
+
+ Card image

注册

-
+ +
+
-
+ +
+
-
+
+ +
+
-
+ +
+
-
+ +
+
-
-
+
+ +
+
+ +
-
我已有账号,去 登录
-
+
我已有账号,去 + 登录 +
+
+ +
@@ -60,8 +83,98 @@ if (window.parent && window.parent !== window) { window.parent.location.reload() @keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} .animate-fade-in{animation:fadeIn .3s ease-out forwards} input:focus~.input-icon{color:#a855f7} -#RegisterToast{animation:fadeIn .3s ease-out forwards} +#RegisterToast{ + animation:fadeIn .3s ease-out forwards; + position:relative; + z-index:999; + width:100%; + box-sizing:border-box; +} \ No newline at end of file diff --git a/gvsds/__pycache__/model_base.cpython-312.pyc b/gvsds/__pycache__/model_base.cpython-312.pyc index ad5e44ad..2e64fc23 100644 Binary files a/gvsds/__pycache__/model_base.cpython-312.pyc and b/gvsds/__pycache__/model_base.cpython-312.pyc differ diff --git a/gvsds/__pycache__/urls.cpython-312.pyc b/gvsds/__pycache__/urls.cpython-312.pyc index f493b193..3cd543cc 100644 Binary files a/gvsds/__pycache__/urls.cpython-312.pyc and b/gvsds/__pycache__/urls.cpython-312.pyc differ diff --git a/gvsds/apps/commission/__pycache__/manager.cpython-312.pyc b/gvsds/apps/commission/__pycache__/manager.cpython-312.pyc index 2a88c8df..1c22192c 100644 Binary files a/gvsds/apps/commission/__pycache__/manager.cpython-312.pyc and b/gvsds/apps/commission/__pycache__/manager.cpython-312.pyc differ diff --git a/gvsds/apps/core/__pycache__/models.cpython-312.pyc b/gvsds/apps/core/__pycache__/models.cpython-312.pyc index 0d0d9d76..91cd9019 100644 Binary files a/gvsds/apps/core/__pycache__/models.cpython-312.pyc and b/gvsds/apps/core/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/core/__pycache__/views.cpython-312.pyc b/gvsds/apps/core/__pycache__/views.cpython-312.pyc index 873b126f..7c8a07d5 100644 Binary files a/gvsds/apps/core/__pycache__/views.cpython-312.pyc and b/gvsds/apps/core/__pycache__/views.cpython-312.pyc differ diff --git a/gvsds/apps/core/models.py b/gvsds/apps/core/models.py index 1b5112cd..56eec088 100644 --- a/gvsds/apps/core/models.py +++ b/gvsds/apps/core/models.py @@ -221,12 +221,17 @@ class User(QModel): 汇总所有角色的操作权限后判断。 """ from gvsds.apps.gerp.models import Permission, RolePermission, UserRole - user_role_uuids = UserRole.objects.filter( + user_roles = UserRole.objects.filter( UserUUID=self.UserUUID - ).values_list('RoleUUID_id', flat=True) - perm_uuids = RolePermission.objects.filter( + ) + user_role_uuids = user_roles.values_list('RoleUUID_id', flat=True) + company_uuids = list(set(user_roles.values_list('CompanyUUID_id', flat=True))) + rp_qs = RolePermission.objects.filter( RoleUUID_id__in=user_role_uuids - ).values_list('PermUUID_id', flat=True) + ) + if company_uuids: + rp_qs = rp_qs.filter(CompanyUUID_id__in=company_uuids) + perm_uuids = rp_qs.values_list('PermUUID_id', flat=True) return Permission.objects.filter( PermUUID__in=perm_uuids, PermCode=perm_code, diff --git a/gvsds/apps/core/views.py b/gvsds/apps/core/views.py index 3506810e..1ca13abc 100644 --- a/gvsds/apps/core/views.py +++ b/gvsds/apps/core/views.py @@ -136,13 +136,12 @@ class SubdomainView(View): if subdomain == 'ugm': elt_token = request.COOKIES.get('ELToken') if not elt_token: - # 未登录 → 跳转登录页 + # 未登录 → 跳转 www 登录页 if file_path and file_path not in ('/', 'login.html', 'register.html'): - from django.urls import reverse - login_url = f"https://ugm.gvsds.com/login.html?redirect=https://gerp.gvsds.com/" + login_url = f"https://www.gvsds.com/login.html?redirect=https://gerp.gvsds.com/" return HttpResponseRedirect(login_url) if not file_path or file_path == '/': - return HttpResponseRedirect('https://ugm.gvsds.com/login.html') + return HttpResponseRedirect('https://www.gvsds.com/login.html') else: # 已登录 → 跳转 gerp if not file_path or file_path == '/' or file_path == 'index.html': diff --git a/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc index e27f2589..36c12bb3 100644 Binary files a/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc index c9c650fa..b4f1a1d8 100644 Binary files a/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index 0f26e3a9..d8031e7c 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -2,6 +2,7 @@ import uuid import datetime from django.db import connection from django.db.models import Q +from django.db.models.fields import NOT_PROVIDED from gvsds.apps.gerp.models import ( Tenant, CompanyInfo, Company, Department, Role, UserRole, RolePermission, @@ -16,6 +17,61 @@ from gvsdsdk.order.models import Order, OrderItem, OrderLevel, OrderTemplate, Or from gvsds.apps.gerp.i18n import translate_field, translate_table +# 系统默认公司 UUID(GERP 后台) +SYSTEM_COMPANY_UUID = uuid.UUID('00000000-0000-0000-0000-000000000001').bytes + + +def create_default_role_for_user(user, admin_user=None): + """为新用户创建默认角色及用户-角色关联。 + + 角色名格式为"{用户名}的默认角色",状态为启用。 + 默认绑定系统公司(00000000-0000-0000-0000-000000000001), + 角色租户取自该系统公司所属租户,回退到管理员租户或系统首个租户。 + + Args: + user: 新创建的 User 实例 + admin_user: 可选,操作管理员 User 实例(用于租户回退) + """ + try: + company_uuid = SYSTEM_COMPANY_UUID + # 角色租户优先取自系统公司所属租户 + tenant_uuid = None + try: + company = Company.objects.get(CompanyUUID=company_uuid) + tenant_uuid = company.TenantUUID_id + except Company.DoesNotExist: + pass + # 回退:当前操作管理员的首个角色租户 + if not tenant_uuid and admin_user is not None: + admin_ur = UserRole.objects.filter( + UserUUID_id=admin_user.UserUUID + ).select_related('RoleUUID').first() + if admin_ur and admin_ur.RoleUUID_id and admin_ur.RoleUUID: + tenant_uuid = admin_ur.RoleUUID.TenantUUID_id + # 回退:系统首个租户 + if not tenant_uuid: + tenant = Tenant.objects.first() + if tenant: + tenant_uuid = tenant.TenantUUID + if not tenant_uuid: + return + user_name = user.UserName or '用户' + role = Role.objects.create( + RoleUUID=uuid.uuid4().bytes, + TenantUUID_id=tenant_uuid, + RoleName=f"{user_name}的默认角色", + RoleStatus=1, + ) + UserRole.objects.create( + UserRoleUUID=uuid.uuid4().bytes, + UserUUID_id=user.UserUUID, + RoleUUID_id=role.RoleUUID, + CompanyUUID_id=company_uuid, + ) + except Exception: + pass + + def _pascalize_record(record, model=None): result = {} column_map = {} @@ -87,8 +143,65 @@ class FormService: # 只读表:普通用户只能查询,不能增删改 READONLY_TABLES_FOR_NORMAL = {'Company', 'CompanyInfo', 'Tenant', 'Role', 'Password', 'EphemeralToken'} - def __init__(self, user): + def __init__(self, user, current_company_uuid=None, admin_verify=False, current_role_uuid=None): self.user = user + self.current_role_uuid = None + if current_role_uuid: + if hasattr(current_role_uuid, 'bytes'): + self.current_role_uuid = current_role_uuid.bytes + elif isinstance(current_role_uuid, str): + try: + self.current_role_uuid = uuid.UUID(current_role_uuid).bytes + except ValueError: + self.current_role_uuid = current_role_uuid + else: + self.current_role_uuid = current_role_uuid + # 当前角色所在公司的 UUID,统一转为 bytes 用于查询 + # 可能传入的类型:Company 实例、UUIDObj、uuid.UUID、bytes、str + if current_company_uuid is not None: + if hasattr(current_company_uuid, 'pk'): + # Company 模型实例 + self.current_company_uuid = current_company_uuid.pk + elif hasattr(current_company_uuid, 'bytes'): + # UUIDObj 或 uuid.UUID + self.current_company_uuid = current_company_uuid.bytes + elif isinstance(current_company_uuid, str): + try: + self.current_company_uuid = uuid.UUID(current_company_uuid).bytes + except ValueError: + self.current_company_uuid = current_company_uuid + else: + self.current_company_uuid = current_company_uuid + else: + self.current_company_uuid = None + # 强公司隔离:默认启用,仅当 admin_verify=True 且用户是管理员时才关闭 + if admin_verify and self._is_admin(user): + self.company_scope = False + else: + self.company_scope = True + import logging + logging.info('[FormService] __init__: user=%s, company_scope=%s, current_company_uuid=%s (type=%s)', + user, self.company_scope, + self.current_company_uuid.hex() if isinstance(self.current_company_uuid, bytes) else self.current_company_uuid, + type(self.current_company_uuid).__name__ if self.current_company_uuid else 'None') + + def _is_admin(self, user): + """判断用户是否为管理员:超级用户或当前角色拥有 admin:super 权限""" + if not user or not hasattr(user, 'is_authenticated') or not user.is_authenticated: + return False + # 超级用户始终是管理员 + if getattr(user, 'IsSuperuser', False): + return True + # 通过 RolePermission 判断:当前角色是否拥有 admin:super 权限 + if self.current_role_uuid: + try: + return RolePermission.objects.filter( + RoleUUID_id=self.current_role_uuid, + PermUUID__PermCode='admin:super' + ).exists() + except Exception: + pass + return False def _build_field_map(self, model): pascal_map = {} @@ -98,6 +211,80 @@ class FormService: pascal_map[f.name] = f return pascal_map + def _get_entity_picker_fields(self, model): + """获取模型中所有 entityPicker 类型的字段信息""" + result = [] + for f in model._meta.get_fields(): + if not hasattr(f, 'name'): + continue + # ForeignKey 字段 + if f.__class__.__name__ == 'ForeignKey': + try: + related_model = getattr(f, 'related_model', None) + if related_model: + related_name = related_model.__name__ + ep_config = self.ENTITY_PICKER_MAP.get(related_name) + if ep_config: + result.append({ + 'field_name': f.name, + 'label_field': ep_config['labelField'], + 'value_field': ep_config['valueField'], + 'entity_name': related_name, + 'extra_fields': ep_config.get('extraFields', []), + }) + except Exception: + pass + # UUIDField 字段(非 ForeignKey) + elif f.__class__.__name__ == 'UUIDField' and not getattr(f, 'primary_key', False): + entity_name = self.UUID_FIELD_ENTITY_MAP.get(f.name) + if entity_name: + ep_config = self.ENTITY_PICKER_MAP.get(entity_name) + if ep_config: + result.append({ + 'field_name': f.name, + 'label_field': ep_config['labelField'], + 'value_field': ep_config['valueField'], + 'entity_name': entity_name, + 'extra_fields': ep_config.get('extraFields', []), + }) + return result + + def _enrich_entity_labels(self, page_qs, ep_fields, records): + """为 records 中的 entityPicker 字段添加 _label 后缀字段,以及 extraFields""" + try: + obj_list = list(page_qs) + except Exception: + return records + for i, obj in enumerate(obj_list): + if i >= len(records): + break + for ep_info in ep_fields: + field_name = ep_info['field_name'] + label_field = ep_info['label_field'] + extra_fields = ep_info.get('extra_fields', []) + try: + related_obj = getattr(obj, field_name, None) + if related_obj is not None: + # 获取 db_column 作为 key 前缀 + db_col = None + for f in obj._meta.get_fields(): + if hasattr(f, 'name') and f.name == field_name and hasattr(f, 'db_column'): + db_col = f.db_column or f.name + break + key_prefix = db_col or field_name + # label 字段 + label = getattr(related_obj, label_field, None) + if label is not None: + records[i][key_prefix + '_label'] = str(label) + # extraFields 字段 + for ef in extra_fields: + ef_val = getattr(related_obj, ef, None) + if ef_val is not None: + records[i][key_prefix + '_' + ef] = ef_val + except Exception: + pass + return records + def _resolve_field_name(self, key, pascal_map): if key in pascal_map: return pascal_map[key].name @@ -107,6 +294,17 @@ class FormService: if val is None or val == '': if getattr(field, 'null', False): return None + # 非空字段:数值类型返回默认值或0,其他类型返回空字符串 + field_type = field.__class__.__name__ + if field_type in ('SmallIntegerField', 'IntegerField', 'BigIntegerField', + 'PositiveIntegerField', 'PositiveSmallIntegerField', + 'FloatField', 'DecimalField'): + default = getattr(field, 'default', None) + if default is not None and default is not NOT_PROVIDED: + return default + return 0 + if field_type == 'BooleanField': + return False return val field_type = field.__class__.__name__ if field_type == 'UUIDField' and isinstance(val, str): @@ -138,8 +336,16 @@ class FormService: return datetime.datetime.fromisoformat(val) except (ValueError, TypeError): return val - if hasattr(field, 'max_length') and isinstance(val, str) and len(val) > field.max_length: - val = val[:field.max_length] + if field_type in ('BooleanField', 'NullBooleanField') and isinstance(val, str): + low = val.strip().lower() + if low in ('true', '1', 't', 'yes'): + return True + if low in ('false', '0', 'f', 'no'): + return False + return val + max_length = getattr(field, 'max_length', None) + if max_length and isinstance(val, str) and len(val) > max_length: + val = val[:max_length] return val def get_tables(self): @@ -166,6 +372,7 @@ class FormService: 'labelField': 'RoleName', 'valueField': 'RoleUUID', 'placeholder': '输入角色名搜索...', + 'extraFields': ['RoleStatus'], }, 'User': { 'api': '/API/FormAPI/UserSearch', @@ -223,6 +430,12 @@ class FormService: 'valueField': 'MenuUUID', 'placeholder': '输入菜单名搜索...', }, + 'Workgroup': { + 'api': '/API/FormAPI/WorkgroupSearch', + 'labelField': 'WorkgroupName', + 'valueField': 'WorkgroupUUID', + 'placeholder': '输入工作组名搜索...', + }, } # UUID 字段名后缀到关联模型的映射(用于 UUIDField 非 ForeignKey 的场景) @@ -271,6 +484,10 @@ class FormService: 'primaryKey': is_pk, 'hideInForm': is_pk, } + # 强公司隔离:CompanyUUID 由后端自动注入,前端不可编辑 + if self.company_scope and f.name == 'CompanyUUID': + col['editable'] = False + col['hideInForm'] = True if field_type == 'select' and hasattr(f, 'choices') and f.choices: col['options'] = [{'value': k, 'label': str(v)} for k, v in f.choices] if field_type == 'number': @@ -358,6 +575,59 @@ class FormService: qs = model.objects.all() except Exception: return {'records': [], 'total': 0} + + # 强公司隔离:非管理员自动启用,仅返回当前公司数据 + if self.company_scope: + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + if 'CompanyUUID' in field_names: + if not self.current_company_uuid: + # 无公司上下文时返回空结果 + import logging + logging.warning('[FormService] company_scope=True but current_company_uuid is None, user=%s', self.user) + return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20} + # 判断 CompanyUUID 字段类型:ForeignKey 用 _id 后缀,UUIDField 直接用字段名 + company_field = None + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'CompanyUUID': + company_field = f + break + if company_field and company_field.__class__.__name__ == 'ForeignKey': + qs = qs.filter(CompanyUUID_id=self.current_company_uuid) + else: + # UUIDField(包括 Company 表自身的主键) + qs = qs.filter(CompanyUUID=self.current_company_uuid) + + # 精确字段过滤:URL 参数中与模型字段名匹配的参数用于精确过滤 + # 已知的分页/搜索参数不参与过滤 + _skip_params = {'keyword', 'PageIndex', 'PageSize', 'page', 'format'} + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + for key, value in params.items(): + if key in _skip_params: + continue + if key in field_names and value: + # UUID 字段需要转换 + field_obj = None + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == key: + field_obj = f + break + if field_obj and field_obj.__class__.__name__ == 'ForeignKey': + # ForeignKey: 使用 _id 后缀传入原始 PK 值 + try: + uuid_bytes = uuid.UUID(str(value)).bytes + qs = qs.filter(**{key + '_id': uuid_bytes}) + except (ValueError, AttributeError): + qs = qs.filter(**{key + '_id': value}) + elif field_obj and field_obj.__class__.__name__ == 'UUIDField': + # 普通UUIDField: 直接转 bytes + try: + uuid_bytes = uuid.UUID(str(value)).bytes + qs = qs.filter(**{key: uuid_bytes}) + except (ValueError, AttributeError): + qs = qs.filter(**{key: value}) + else: + qs = qs.filter(**{key: value}) + keyword = params.get('keyword', '') if keyword: or_conditions = {} @@ -380,8 +650,32 @@ class FormService: try: total = qs.count() start = (page - 1) * page_size - records = [_pascalize_record(r, model) for r in qs[start:start + page_size].values()] - except Exception: + # 识别 entityPicker 字段,使用 select_related 预加载关联实体 + ep_fields = self._get_entity_picker_fields(model) + if ep_fields: + fk_names = [f['field_name'] for f in ep_fields] + qs = qs.select_related(*fk_names) + page_qs = qs[start:start + page_size] + # 先获取模型实例列表(用于 _enrich_entity_labels) + obj_list = list(page_qs) + # 将模型实例转为字典再 pascalize + records = [] + for obj in obj_list: + row = {} + for f in model._meta.concrete_fields: + f_name = f.name + # ForeignKey 的 attname 是 xxx_id,存储的是原始值 + if f.__class__.__name__ == 'ForeignKey': + f_name = f.attname # e.g. UserUUID_id -> 但实际 attname 是字段名_id + val = getattr(obj, f_name, None) + row[f.name] = val + records.append(_pascalize_record(row, model)) + # 为 entityPicker 字段附带关联实体的 label + if ep_fields: + records = self._enrich_entity_labels(obj_list, ep_fields, records) + except Exception as e: + import logging + logging.error('[FormService] get_table error: table=%s, error=%s', table_name, e, exc_info=True) total = 0 records = [] return {'records': records, 'total': total, 'page': page, 'PageSize': page_size} @@ -409,14 +703,45 @@ class FormService: is_fk = field.__class__.__name__ == 'ForeignKey' attr_name = field.attname if is_fk else resolved create_data[attr_name] = val + # 强公司隔离:当启用 company_scope 且当前角色有公司上下文时, + # 自动注入当前公司 UUID(若模型有 CompanyUUID 字段且未显式提供) + if self.company_scope and self.current_company_uuid: + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + if 'CompanyUUID' in field_names: + pk_field = model._meta.pk + if pk_field and pk_field.name == 'CompanyUUID': + # CompanyUUID 是主键(如 Company 表):直接设置,跳过后续 PK 自动生成 + if 'CompanyUUID' not in create_data: + create_data['CompanyUUID'] = self.current_company_uuid + else: + # CompanyUUID 是外键:使用 _id 后缀 + has_company = any( + k == 'CompanyUUID' or k == 'CompanyUUID_id' for k in create_data + ) + if not has_company: + create_data['CompanyUUID_id'] = self.current_company_uuid pk_field_name = model._meta.pk.name if model._meta.pk else None if pk_field_name and pk_field_name not in create_data: pk_field = model._meta.pk if pk_field.__class__.__name__ == 'UUIDField': create_data[pk_field_name] = uuid.uuid4().bytes - obj = model.objects.create(**create_data) + # 特殊处理:User 表的密码需要 bcrypt 加密,不能明文存储 + if table_name == 'User' and 'UserPassword' in create_data: + raw_password = create_data.pop('UserPassword') + obj = model(**create_data) + obj.SetPassword(raw_password) + obj.save() + else: + obj = model.objects.create(**create_data) + # 新增用户时自动创建默认角色(与 register 流程保持一致) + if table_name == 'User': + self._create_default_role_for_user(obj) return {'PK': str(obj.pk) if obj.pk else None} + def _create_default_role_for_user(self, user): + """为新用户创建默认角色及用户-角色关联,委托给模块级函数。""" + create_default_role_for_user(user, admin_user=self.user) + def modify_record(self, data): table_name = data.get('Table', '') model = self.TABLE_MAP.get(table_name) @@ -433,6 +758,9 @@ class FormService: obj = model.objects.get(**{pk_field_name: pk_value}) except model.DoesNotExist: return None + # 强公司隔离:company_scope 启用时校验目标记录归属当前公司 + if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj): + return None pascal_map = self._build_field_map(model) update_fields = [] for key, val in data.items(): @@ -449,6 +777,11 @@ class FormService: if isinstance(val, list) and len(val) == 1: val = val[0] val = self._convert_value_for_field(field, val) + # 特殊处理:User 表的密码需要 bcrypt 加密 + if table_name == 'User' and resolved == 'UserPassword' and val: + obj.SetPassword(val) + update_fields.append('UserPassword') + continue # ForeignKey 字段必须通过 attname(如 CompanyUUID_id)设置原始列值, # 否则 Django 的 FK 描述符会要求传入关联模型实例 is_fk = field.__class__.__name__ == 'ForeignKey' @@ -475,10 +808,33 @@ class FormService: pk_field = model._meta.pk pk_value = self._convert_value_for_field(pk_field, pk) try: - model.objects.get(**{pk_field_name: pk_value}).delete() - return True + obj = model.objects.get(**{pk_field_name: pk_value}) except model.DoesNotExist: return False + # 强公司隔离:company_scope 启用时校验目标记录归属当前公司 + if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj): + return False + obj.delete() + return True + + def _check_company_owner(self, obj): + """校验对象是否归属当前公司(用于 company_scope 下的修改/删除保护)。 + 若模型无 CompanyUUID 字段则放行(不隔离)。 + CompanyUUID 可能是外键(Department/Workgroup)或主键(Company 本身)。""" + try: + field_names = {f.name for f in obj._meta.get_fields() if hasattr(f, 'name')} + if 'CompanyUUID' not in field_names: + return True + # self.current_company_uuid 已在 __init__ 中统一转为 bytes + # CompanyUUID 是主键时直接比较 pk + pk_field = obj._meta.pk + if pk_field and pk_field.name == 'CompanyUUID': + return obj.pk == self.current_company_uuid + # CompanyUUID 是外键时比较 _id + obj_company = getattr(obj, 'CompanyUUID_id', None) + return obj_company == self.current_company_uuid + except Exception: + return True def code_display(self, data): table_name = data.get('Table', '') diff --git a/gvsds/apps/gerp/migrations/0004_remove_tenant_code.py b/gvsds/apps/gerp/migrations/0004_remove_tenant_code.py index 23879aa8..28bda7dc 100644 --- a/gvsds/apps/gerp/migrations/0004_remove_tenant_code.py +++ b/gvsds/apps/gerp/migrations/0004_remove_tenant_code.py @@ -12,6 +12,6 @@ class Migration(migrations.Migration): operations = [ migrations.RemoveField( model_name='tenant', - name='TenantCode', + name='tenant_code', ), ] diff --git a/gvsds/apps/gerp/migrations/0005_rolepermission_companyuuid.py b/gvsds/apps/gerp/migrations/0005_rolepermission_companyuuid.py new file mode 100644 index 00000000..c0a9eb2a --- /dev/null +++ b/gvsds/apps/gerp/migrations/0005_rolepermission_companyuuid.py @@ -0,0 +1,36 @@ +from django.db import migrations, models +import django.db.models.deletion +import datetime +import gvsds.apps.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0004_remove_tenant_code'), + ] + + operations = [ + # RolePermission 表已通过 --run-syncdb 在数据库中创建(含 CompanyUUID 列), + # 此迁移仅同步迁移状态,不对数据库执行任何操作。 + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='RolePermission', + fields=[ + ('role_perm_uuid', gvsds.apps.core.models.UUIDField(db_column='RolePermUUID', primary_key=True, serialize=False)), + ('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ('role_uuid', models.ForeignKey(db_column='RoleUUID', on_delete=django.db.models.deletion.CASCADE, related_name='rolepermission_set', to='gerp.role', verbose_name='角色UUID')), + ('perm_uuid', models.ForeignKey(db_column='PermUUID', on_delete=django.db.models.deletion.CASCADE, related_name='rolepermission_set', to='gerp.permission', verbose_name='权限UUID')), + ('company_uuid', models.ForeignKey(db_column='CompanyUUID', on_delete=django.db.models.deletion.CASCADE, related_name='rolepermission_set', to='gerp.company', verbose_name='公司UUID')), + ], + options={ + 'db_table': 'RolePermission', + 'verbose_name': '角色权限关联', + 'verbose_name_plural': '角色权限关联', + }, + ), + ], + database_operations=[], + ), + ] diff --git a/gvsds/apps/gerp/migrations/0006_userrole_dept_workgroup.py b/gvsds/apps/gerp/migrations/0006_userrole_dept_workgroup.py new file mode 100644 index 00000000..20fd0cfc --- /dev/null +++ b/gvsds/apps/gerp/migrations/0006_userrole_dept_workgroup.py @@ -0,0 +1,22 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0005_rolepermission_companyuuid'), + ] + + operations = [ + migrations.AddField( + model_name='userrole', + name='DeptUUID', + field=models.ForeignKey(blank=True, db_column='DeptUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gerp.department', verbose_name='部门UUID'), + ), + migrations.AddField( + model_name='userrole', + name='WorkgroupUUID', + field=models.ForeignKey(blank=True, db_column='WorkgroupUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gerp.workgroup', verbose_name='工作组UUID'), + ), + ] diff --git a/gvsds/apps/gerp/migrations/0007_userrole_roletype.py b/gvsds/apps/gerp/migrations/0007_userrole_roletype.py new file mode 100644 index 00000000..2280da13 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0007_userrole_roletype.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0006_userrole_dept_workgroup'), + ] + + operations = [ + migrations.AddField( + model_name='userrole', + name='RoleType', + field=models.SmallIntegerField(db_column='RoleType', default=0, verbose_name='角色类型'), + ), + ] diff --git a/gvsds/apps/gerp/migrations/0008_workgroup_companyuuid.py b/gvsds/apps/gerp/migrations/0008_workgroup_companyuuid.py new file mode 100644 index 00000000..e37fc457 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0008_workgroup_companyuuid.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0007_userrole_roletype'), + ] + + operations = [ + migrations.AddField( + model_name='workgroup', + name='CompanyUUID', + field=models.ForeignKey(db_column='CompanyUUID', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='gerp.company', verbose_name='公司UUID'), + ), + ] diff --git a/gvsds/apps/gerp/migrations/__pycache__/0004_remove_tenant_code.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0004_remove_tenant_code.cpython-312.pyc new file mode 100644 index 00000000..6870a363 Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0004_remove_tenant_code.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/migrations/__pycache__/0005_rolepermission_companyuuid.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0005_rolepermission_companyuuid.cpython-312.pyc new file mode 100644 index 00000000..5ea4462e Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0005_rolepermission_companyuuid.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/migrations/__pycache__/0006_userrole_dept_workgroup.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0006_userrole_dept_workgroup.cpython-312.pyc new file mode 100644 index 00000000..cc35892a Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0006_userrole_dept_workgroup.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/migrations/__pycache__/0007_userrole_roletype.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0007_userrole_roletype.cpython-312.pyc new file mode 100644 index 00000000..c2b0b6c6 Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0007_userrole_roletype.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/migrations/__pycache__/0008_workgroup_companyuuid.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0008_workgroup_companyuuid.cpython-312.pyc new file mode 100644 index 00000000..c3f6c43a Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0008_workgroup_companyuuid.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index f8dfcadd..5e7a3709 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -46,7 +46,7 @@ class CompanyInfo(QModel): """企业信息表——工商注册与企业详情""" CompanyInfoUUID = UUIDField(primary_key=True, verbose_name='企业信息UUID') - TenantUUID = models.ForeignKey('Tenant', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', verbose_name='租户UUID') + TenantUUID = models.ForeignKey('Tenant', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='TenantUUID', verbose_name='租户UUID') EnterpriseName = models.CharField(max_length=256, verbose_name='企业全称') EnterpriseShortName = models.CharField(max_length=100, verbose_name='企业简称') CreditCode = models.CharField(max_length=64, unique=True, verbose_name='统一社会信用代码') @@ -135,8 +135,8 @@ class Company(QModel): """公司表——系统内组织机构""" CompanyUUID = UUIDField(primary_key=True, verbose_name='公司UUID') - TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', verbose_name='租户UUID') - ParentCompanyUUID = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', verbose_name='上级公司UUID') + TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID') + ParentCompanyUUID = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='ParentCompanyUUID', verbose_name='上级公司UUID') CompanyName = models.CharField(max_length=256, verbose_name='公司全称') CompanyShort = models.CharField(max_length=100, verbose_name='公司简称') CompanyStatus = models.SmallIntegerField(default=1, verbose_name='公司状态') @@ -157,8 +157,8 @@ class Department(QModel): """部门表——组织架构层级""" DeptUUID = UUIDField(primary_key=True, verbose_name='部门UUID') - CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='department_set', verbose_name='公司UUID') - ParentDeptUUID = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name='上级部门UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='department_set', db_column='CompanyUUID', verbose_name='公司UUID') + ParentDeptUUID = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', db_column='ParentDeptUUID', verbose_name='上级部门UUID') DeptName = models.CharField(max_length=256, verbose_name='部门名称') DeptSort = models.IntegerField(default=0, verbose_name='排序') DeptStatus = models.SmallIntegerField(default=1, verbose_name='部门状态') @@ -186,7 +186,7 @@ class Role(QModel): """ RoleUUID = UUIDField(primary_key=True, verbose_name='角色UUID') - TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', verbose_name='租户UUID') + TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID') RoleName = models.CharField(max_length=128, verbose_name='角色名称') RoleType = models.SmallIntegerField(default=2, verbose_name='角色类型') RoleStatus = models.SmallIntegerField(default=1, verbose_name='角色状态') @@ -218,18 +218,44 @@ class Role(QModel): return User.objects.filter(userrole__RoleUUID=self.RoleUUID) +class RoleAdmin(QModel): + """管理员角色表——标记哪些角色具有管理员权限 + + 只要在本表中存在对应 RoleUUID 记录,该角色即为管理员角色。 + 不存在则非管理员。通过单独表实现,避免在 Role 表上增加冗余字段。 + """ + + RoleAdminUUID = UUIDField(primary_key=True, verbose_name='主键UUID') + RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='roleadmin_set', db_column='RoleUUID', verbose_name='角色UUID') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'RoleAdmin' + app_label = 'gerp' + verbose_name = '管理员角色' + verbose_name_plural = '管理员角色' + + def __str__(self): + return f'RoleAdmin({self.RoleUUID})' + + class UserRole(QModel): """用户角色关联表——用户与角色的多对多映射 通过 CompanyUUID 标识该用户-角色关联所属的公司, 用于菜单按公司差异化显示、数据权限按公司过滤等场景。 同一用户在不同公司下可绑定不同角色。 + DeptUUID、WorkgroupUUID 为可选字段,用于将关联进一步限定到部门或工作组。 + RoleType 标识该用户在部门/工作组中的角色类型:0=普通成员/部门员工,1=组长/部门负责人。 """ UserRoleUUID = UUIDField(primary_key=True, verbose_name='关联UUID') - UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='userrole_set', db_index=True, verbose_name='用户UUID') - RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='userrole_set', verbose_name='角色UUID') - CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='userrole_set', verbose_name='公司UUID') + UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='userrole_set', db_column='UserUUID', db_index=True, verbose_name='用户UUID') + RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='userrole_set', db_column='RoleUUID', verbose_name='角色UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='userrole_set', db_column='CompanyUUID', verbose_name='公司UUID') + DeptUUID = models.ForeignKey('Department', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='DeptUUID', verbose_name='部门UUID') + WorkgroupUUID = models.ForeignKey('Workgroup', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='WorkgroupUUID', verbose_name='工作组UUID') + RoleType = models.SmallIntegerField(default=0, db_column='RoleType', verbose_name='角色类型') CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') class Meta: @@ -244,11 +270,13 @@ class RolePermission(QModel): 一个角色可以拥有多个权限,一个权限也可以被多个角色引用。 角色和具体操作权限为多对多绑定关系,一套权限集合可以打包在一个角色内统一维护。 + 同一角色在不同公司下可拥有不同权限集合,通过 CompanyUUID 区分。 """ RolePermUUID = UUIDField(primary_key=True, verbose_name='关联UUID') - RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='rolepermission_set', verbose_name='角色UUID') - PermUUID = models.ForeignKey('Permission', on_delete=models.CASCADE, related_name='rolepermission_set', verbose_name='权限UUID') + RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='rolepermission_set', db_column='RoleUUID', verbose_name='角色UUID') + PermUUID = models.ForeignKey('Permission', on_delete=models.CASCADE, related_name='rolepermission_set', db_column='PermUUID', verbose_name='权限UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='rolepermission_set', db_column='CompanyUUID', verbose_name='公司UUID') CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') class Meta: @@ -267,7 +295,7 @@ class Permission(QModel): """ PermUUID = UUIDField(primary_key=True, verbose_name='权限UUID') - TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', verbose_name='租户UUID') + TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID') PermName = models.CharField(max_length=128, verbose_name='权限名称') PermCode = models.CharField(max_length=128, db_index=True, verbose_name='权限键') PermCategory = models.CharField(max_length=64, default='', verbose_name='权限分类') @@ -293,7 +321,7 @@ class FileAsset(QModel): """文件资产表——文件存储与引用管理""" AssetUUID = UUIDField(primary_key=True, verbose_name='资产UUID') - CompanyUUID = models.ForeignKey('Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='fileasset_set', verbose_name='公司UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='fileasset_set', db_column='CompanyUUID', verbose_name='公司UUID') FileName = models.CharField(max_length=256, verbose_name='文件名') FileSHA1 = models.CharField(max_length=40, unique=True, verbose_name='文件SHA1') FileSize = models.IntegerField(verbose_name='文件大小') @@ -320,8 +348,8 @@ class WorkOrder(QModel): Category = models.CharField(max_length=64, default='other', verbose_name='分类') Priority = models.SmallIntegerField(default=0, verbose_name='优先级') Status = models.SmallIntegerField(default=0, verbose_name='状态') - CreatorUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', db_index=True, verbose_name='创建人UUID') - AssigneeUUID = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='+', verbose_name='指派人UUID') + CreatorUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', db_index=True, db_column='CreatorUUID', verbose_name='创建人UUID') + AssigneeUUID = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='AssigneeUUID', verbose_name='指派人UUID') Resolution = models.CharField(max_length=4096, default='', verbose_name='解决方案') CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') UpdateTime = models.DateTimeField(null=True, blank=True, verbose_name='更新时间') @@ -339,8 +367,8 @@ class Message(QModel): """消息表——系统通知与站内信""" MessageUUID = UUIDField(primary_key=True, verbose_name='消息UUID') - SenderUUID = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='+', verbose_name='发送者UUID') - ReceiverUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', db_index=True, verbose_name='接收者UUID') + SenderUUID = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='SenderUUID', verbose_name='发送者UUID') + ReceiverUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', db_index=True, db_column='ReceiverUUID', verbose_name='接收者UUID') Title = models.CharField(max_length=256, verbose_name='标题') Content = models.TextField(default='', verbose_name='内容') MsgType = models.SmallIntegerField(default=0, verbose_name='消息类型') @@ -361,8 +389,8 @@ class Menu(QModel): """菜单表——系统导航与功能入口""" MenuUUID = UUIDField(primary_key=True, verbose_name='菜单UUID') - CompanyUUID = models.ForeignKey('Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='menu_set', verbose_name='公司UUID') - ParentMenuUUID = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name='上级菜单UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='menu_set', db_column='CompanyUUID', verbose_name='公司UUID') + ParentMenuUUID = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', db_column='ParentMenuUUID', verbose_name='上级菜单UUID') MenuName = models.CharField(max_length=128, verbose_name='菜单名称') MenuTitle = models.CharField(max_length=128, verbose_name='菜单标题') MenuIcon = models.CharField(max_length=128, default='', verbose_name='菜单图标') @@ -425,9 +453,10 @@ class DepartmentSMTM(QModel): class Workgroup(QModel): - """工作组表——跨部门协作单元""" + """工作组表——跨部门协作单元,归属特定公司""" WorkgroupUUID = UUIDField(primary_key=True, verbose_name='工作组UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='+', db_column='CompanyUUID', verbose_name='公司UUID') WorkgroupName = models.CharField(max_length=256, verbose_name='工作组名称') WorkgroupEnglishName = models.CharField(max_length=256, verbose_name='工作组英文名') WorkgroupIntroduction = models.CharField(max_length=1024, verbose_name='工作组介绍') @@ -475,8 +504,8 @@ class Backpack(QModel): """背包表——用户物品栏""" BackpackItemUUID = UUIDField(primary_key=True, verbose_name='背包物品UUID') - UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', verbose_name='用户UUID') - ItemTemplateUUID = models.ForeignKey('ItemReference', on_delete=models.CASCADE, related_name='+', verbose_name='物品模板UUID') + UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', db_column='UserUUID', verbose_name='用户UUID') + ItemTemplateUUID = models.ForeignKey('ItemReference', on_delete=models.CASCADE, related_name='+', db_column='ItemTemplateUUID', verbose_name='物品模板UUID') _ItemName = models.CharField(max_length=128, null=True, blank=True, verbose_name='物品名称缓存') _ItemIcon = models.CharField(max_length=512, null=True, blank=True, verbose_name='物品图标缓存') _ItemIconColor = models.CharField(max_length=6, null=True, blank=True, verbose_name='图标颜色缓存') @@ -675,7 +704,7 @@ class PersonalPage(QModel): class UserSettings(QModel): """用户设置表——存储每位用户的个性化系统设置""" - UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, unique=True, verbose_name='用户UUID') + UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, unique=True, db_column='UserUUID', verbose_name='用户UUID') SettingsData = models.TextField(default='', verbose_name='设置数据') class Meta: @@ -696,7 +725,7 @@ class PositionTemplate(QModel): """ PositionTemplateUUID = UUIDField(primary_key=True, verbose_name='岗位模板UUID') - TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', verbose_name='租户UUID') + TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID') TemplateName = models.CharField(max_length=128, verbose_name='模板名称') TemplateCode = models.CharField(max_length=64, default='', verbose_name='模板编码') TemplateStatus = models.SmallIntegerField(default=1, verbose_name='模板状态') @@ -736,6 +765,7 @@ class PositionTemplate(QModel): RolePermUUID=uuid.uuid4().bytes, RoleUUID=role, PermUUID=tp.PermUUID, + CompanyUUID_id=tenant_uuid, # 岗位模板复制时默认使用租户UUID作为公司 ) return role @@ -748,8 +778,8 @@ class PositionTemplatePermission(QModel): """ PTPermUUID = UUIDField(primary_key=True, verbose_name='关联UUID') - PositionTemplateUUID = models.ForeignKey('PositionTemplate', on_delete=models.CASCADE, related_name='positiontemplatepermission_set', verbose_name='岗位模板UUID') - PermUUID = models.ForeignKey('Permission', on_delete=models.CASCADE, related_name='positiontemplatepermission_set', verbose_name='权限UUID') + PositionTemplateUUID = models.ForeignKey('PositionTemplate', on_delete=models.CASCADE, related_name='positiontemplatepermission_set', db_column='PositionTemplateUUID', verbose_name='岗位模板UUID') + PermUUID = models.ForeignKey('Permission', on_delete=models.CASCADE, related_name='positiontemplatepermission_set', db_column='PermUUID', verbose_name='权限UUID') CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') class Meta: @@ -768,7 +798,7 @@ class Position(QModel): """ PositionUUID = UUIDField(primary_key=True, verbose_name='职务UUID') - TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', verbose_name='租户UUID') + TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID') PositionName = models.CharField(max_length=128, verbose_name='职务名称') PositionCode = models.CharField(max_length=64, default='', verbose_name='职务编码') PositionLevel = models.SmallIntegerField(default=0, verbose_name='职务级别') @@ -797,9 +827,9 @@ class UserDept(QModel): """ UserDeptUUID = UUIDField(primary_key=True, verbose_name='关联UUID') - UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='userdept_set', db_index=True, verbose_name='用户UUID') - DeptUUID = models.ForeignKey('Department', on_delete=models.CASCADE, related_name='userdept_set', verbose_name='部门UUID') - PositionUUID = models.ForeignKey('Position', on_delete=models.SET_NULL, null=True, blank=True, related_name='userdept_set', verbose_name='职务标签UUID') + UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='userdept_set', db_index=True, db_column='UserUUID', verbose_name='用户UUID') + DeptUUID = models.ForeignKey('Department', on_delete=models.CASCADE, related_name='userdept_set', db_column='DeptUUID', verbose_name='部门UUID') + PositionUUID = models.ForeignKey('Position', on_delete=models.SET_NULL, null=True, blank=True, related_name='userdept_set', db_column='PositionUUID', verbose_name='职务标签UUID') IsMainDept = models.SmallIntegerField(default=1, verbose_name='是否主部门') CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index e32c14ca..ad7a15f3 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -38,6 +38,7 @@ from .viewsets import ( PositionTemplatePermissionViewSet, PositionViewSet, ShopViewSet, + WorkgroupViewSet, ) from .twofa import ( TwoFASetupView, @@ -81,7 +82,7 @@ from .asset import ( ) from .message import MessageCompatView from .menu import MenuListCompatView -from .form_api import FormAPICompatView, FormAPIView +from .form_api import FormAPICompatView, FormAPIView, CheckAdminView from .doc import DocView, SerieView from .position import ( PositionListView, diff --git a/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc index 1405248c..350ce6d0 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc index 4e1f8f1b..e59ce4b2 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/user_detail.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/user_detail.cpython-312.pyc index a55541f3..bd990c5c 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/user_detail.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/user_detail.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc index f5d7dd2a..cbd4efbe 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/form_api.py b/gvsds/apps/gerp/views/form_api.py index 6f2f081a..e00fd689 100644 --- a/gvsds/apps/gerp/views/form_api.py +++ b/gvsds/apps/gerp/views/form_api.py @@ -3,16 +3,45 @@ from . import ELTAuthenticatedAPIView from gvsds.apps.gerp.form_service import FormService -def _is_admin(user): - """判断用户是否为管理员""" +def _is_admin(request): + """判断用户是否为管理员:超级用户或当前角色拥有 admin:super 权限""" + user = request.user if hasattr(request, 'user') else None if not user or not user.is_authenticated: return False - return getattr(user, 'IsStaff', False) or getattr(user, 'IsSuperuser', False) + if getattr(user, 'IsSuperuser', False): + return True + current_role = getattr(request, 'current_role', None) + if current_role: + from gvsds.apps.gerp.models import RolePermission + role_uuid = getattr(current_role, 'RoleUUID', None) + if role_uuid: + return RolePermission.objects.filter( + RoleUUID_id=role_uuid, + PermUUID__PermCode='admin:super' + ).exists() + return False -def _check_table_access(table_name, action, user): +def _make_service(request, user): + """构造 FormService,自动传入当前公司 UUID 和角色 UUID""" + admin_verify = request.query_params.get('admin_verify') == '1' if hasattr(request, 'query_params') else False + if not admin_verify and hasattr(request, 'data'): + admin_verify = request.data.get('admin_verify') == '1' + current_role = getattr(request, 'current_role', None) + current_role_uuid = None + if current_role: + current_role_uuid = getattr(current_role, 'RoleUUID', None) + return FormService( + user, + current_company_uuid=getattr(request, 'current_company_uuid', None), + admin_verify=admin_verify, + current_role_uuid=current_role_uuid, + ) + + +def _check_table_access(table_name, action, request): """检查用户对指定表的访问权限,返回错误响应或 None""" - if _is_admin(user): + if _is_admin(request): return None if table_name in FormService.ADMIN_ONLY_TABLES: return Response({'code': 403, 'msg': f'无权访问表 {table_name},仅管理员可操作'}, status=403) @@ -34,7 +63,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): def get(self, request, **kwargs): user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None - service = FormService(user) + service = _make_service(request, user) path = request.path.rstrip('/') table_name = kwargs.get('TableName', '') @@ -45,7 +74,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): result = service.get_columns(table_name) return Response({'code': 0, 'data': result}) elif '/Get/' in path and table_name: - access_error = _check_table_access(table_name, 'get', user) + access_error = _check_table_access(table_name, 'get', request) if access_error: return access_error result = service.get_table(table_name, request.query_params) @@ -57,7 +86,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): table_name = request.query_params.get('Table', '') if not table_name: return Response({'code': 1, 'msg': '缺少table参数'}, status=400) - access_error = _check_table_access(table_name, 'get', user) + access_error = _check_table_access(table_name, 'get', request) if access_error: return access_error result = service.get_table(table_name, request.query_params) @@ -65,13 +94,13 @@ class FormAPICompatView(ELTAuthenticatedAPIView): def post(self, request, **kwargs): user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None - service = FormService(user) + service = _make_service(request, user) path = request.path.rstrip('/') table_name = kwargs.get('TableName', '') item_id = kwargs.get('id', '') if '/Add/' in path and table_name: - access_error = _check_table_access(table_name, 'add', user) + access_error = _check_table_access(table_name, 'add', request) if access_error: return access_error data = dict(request.data) @@ -79,7 +108,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): result = service.add_record(data) return Response({'code': 0, 'msg': '添加成功', 'data': result}, status=201) elif '/ModifyItem/' in path and table_name: - access_error = _check_table_access(table_name, 'modify', user) + access_error = _check_table_access(table_name, 'modify', request) if access_error: return access_error data = dict(request.data) @@ -91,7 +120,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): return Response({'code': 1, 'msg': '修改失败,记录不存在'}, status=404) return Response({'code': 0, 'msg': '修改成功', 'data': result}) elif '/Modify/' in path and table_name: - access_error = _check_table_access(table_name, 'modify', user) + access_error = _check_table_access(table_name, 'modify', request) if access_error: return access_error data = dict(request.data) @@ -103,7 +132,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): return Response({'code': 1, 'msg': '修改失败,记录不存在'}, status=404) return Response({'code': 0, 'msg': '修改成功', 'data': result}) elif '/Delete/' in path and table_name: - access_error = _check_table_access(table_name, 'delete', user) + access_error = _check_table_access(table_name, 'delete', request) if access_error: return access_error data = dict(request.data) @@ -118,7 +147,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): action = request.data.get('Action', 'add') table_name = request.data.get('Table', '') if table_name: - access_error = _check_table_access(table_name, action, user) + access_error = _check_table_access(table_name, action, request) if access_error: return access_error if action == 'add': @@ -167,10 +196,10 @@ class FormAPIView(ELTAuthenticatedAPIView): table_name = request.query_params.get('table', '') if not table_name: return Response({'code': 1, 'msg': '缺少table参数'}, status=400) - access_error = _check_table_access(table_name, 'get', request.user) + access_error = _check_table_access(table_name, 'get', request) if access_error: return access_error - service = FormService(request.user) + service = _make_service(request, request.user) result = service.get_table(table_name, request.query_params) return Response({'code': 0, 'data': result['records'], 'total': result['total']}) @@ -178,10 +207,10 @@ class FormAPIView(ELTAuthenticatedAPIView): action = request.data.get('Action', 'add') table_name = request.data.get('Table', '') if table_name: - access_error = _check_table_access(table_name, action, request.user) + access_error = _check_table_access(table_name, action, request) if access_error: return access_error - service = FormService(request.user) + service = _make_service(request, request.user) if action == 'add': result = service.add_record(request.data) return Response({'code': 0, 'msg': '添加成功', 'data': result}, status=201) @@ -205,3 +234,26 @@ class FormAPIView(ELTAuthenticatedAPIView): result = service.code_display(request.data) return Response({'code': 0, 'data': result}) return Response({'code': 1, 'msg': '未知操作'}, status=400) + + +class CheckAdminView(ELTAuthenticatedAPIView): + """检查当前用户是否具有管理员特权(超级用户或当前角色拥有 admin:super 权限)""" + + def get(self, request): + user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + is_superuser = bool(user and getattr(user, 'IsSuperuser', False)) + is_admin = _is_admin(request) + current_role = getattr(request, 'current_role', None) + current_company_uuid = getattr(request, 'current_company_uuid', None) + role_name = '' + if current_role: + role_name = getattr(current_role, 'RoleName', '') or '' + return Response({ + 'code': 0, + 'data': { + 'is_admin': is_admin, + 'is_superuser': is_superuser, + 'current_role_name': role_name, + 'has_current_company': current_company_uuid is not None, + } + }) diff --git a/gvsds/apps/gerp/views/user_detail.py b/gvsds/apps/gerp/views/user_detail.py index 2e24eabc..ae951e01 100644 --- a/gvsds/apps/gerp/views/user_detail.py +++ b/gvsds/apps/gerp/views/user_detail.py @@ -91,13 +91,17 @@ class UserDetailView(ELTAuthenticatedAPIView): 'CompanyShort': company_short, }) - # 权限列表(通过 UserRole → RolePermission → Permission 链路查询) + # 权限列表(通过 UserRole → RolePermission → Permission 链路查询,按公司过滤) permissions = [] role_uuids = [ur.RoleUUID_id for ur in user_roles if ur.RoleUUID_id] + company_uuids = [ur.CompanyUUID_id for ur in user_roles if ur.CompanyUUID_id] if role_uuids: - perm_uuids = RolePermission.objects.filter( + rp_qs = RolePermission.objects.filter( RoleUUID_id__in=role_uuids - ).values_list('PermUUID_id', flat=True) + ) + if company_uuids: + rp_qs = rp_qs.filter(CompanyUUID_id__in=company_uuids) + perm_uuids = rp_qs.values_list('PermUUID_id', flat=True) perms = Permission.objects.filter(PermUUID__in=perm_uuids, PermStatus=1) seen = set() for p in perms: diff --git a/gvsds/apps/gerp/views/viewsets.py b/gvsds/apps/gerp/views/viewsets.py index 176024c2..560a9eb4 100644 --- a/gvsds/apps/gerp/views/viewsets.py +++ b/gvsds/apps/gerp/views/viewsets.py @@ -18,6 +18,7 @@ from ..models import ( UserABAC, DepartmentSMTM, ItemReference, Backpack, SimulatedTrigger, CashReceiptsPaymentsLedger, Asset, Liability, PositionTemplate, PositionTemplatePermission, Position, + Workgroup, ) from ..serializers import ( TenantSerializer, CompanyInfoSerializer, CompanySerializer, @@ -81,9 +82,19 @@ class CompanyViewSet(viewsets.ModelViewSet): keyword = request.query_params.get('keyword', '') qs = Company.objects.all() if keyword: - qs = qs.filter(CompanyName__icontains=keyword) - serializer = self.get_serializer(qs[:20], many=True) - return Response({'code': 0, 'data': serializer.data}) + from django.db.models import Q + qs = qs.filter( + Q(CompanyName__icontains=keyword) | + Q(CompanyShort__icontains=keyword) + ) + data = [] + for c in qs[:20]: + data.append({ + 'CompanyUUID': UUIDObj(c.CompanyUUID).str if c.CompanyUUID else '', + 'CompanyName': c.CompanyName, + 'CompanyShort': c.CompanyShort, + }) + return Response({'code': 0, 'data': data}) class DepartmentViewSet(CompanyScopedMixin, viewsets.ModelViewSet): @@ -142,6 +153,26 @@ class RoleViewSet(viewsets.ModelViewSet): return Response({'code': 0, 'data': serializer.data}) +class WorkgroupViewSet(viewsets.ModelViewSet): + queryset = Workgroup.objects.all() + authentication_classes = [ELTAuthentication] + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def search(self, request): + keyword = request.query_params.get('keyword', '') + qs = Workgroup.objects.all() + if keyword: + qs = qs.filter(WorkgroupName__icontains=keyword) + data = [] + for w in qs[:20]: + data.append({ + 'WorkgroupUUID': UUIDObj(w.WorkgroupUUID).str if w.WorkgroupUUID else '', + 'WorkgroupName': w.WorkgroupName, + }) + return Response({'code': 0, 'data': data}) + + class UserRoleViewSet(viewsets.ModelViewSet): queryset = UserRole.objects.select_related('UserUUID', 'RoleUUID') serializer_class = UserRoleSerializer @@ -191,7 +222,7 @@ class UserDeptViewSet(viewsets.ModelViewSet): class RolePermissionViewSet(viewsets.ModelViewSet): - queryset = RolePermission.objects.select_related('RoleUUID', 'PermUUID') + queryset = RolePermission.objects.select_related('RoleUUID', 'PermUUID', 'CompanyUUID') serializer_class = RolePermissionSerializer authentication_classes = [ELTAuthentication] permission_classes = [IsAuthenticated, IsAdminUser] @@ -201,9 +232,11 @@ class RolePermissionViewSet(viewsets.ModelViewSet): ser = RolePermissionAssignSerializer(data=request.data) ser.is_valid(raise_exception=True) data = ser.validated_data + company_uuid = data.get('CompanyUUID') existing = RolePermission.objects.filter( RoleUUID_id=data['RoleUUID'], PermUUID_id=data['PermUUID'], + CompanyUUID_id=company_uuid, ).first() if existing: return Response({'code': 409, 'msg': '关联已存在', 'data': RolePermissionSerializer(existing).data}, status=409) @@ -211,6 +244,7 @@ class RolePermissionViewSet(viewsets.ModelViewSet): RolePermUUID=uuid.uuid4().bytes, RoleUUID_id=data['RoleUUID'], PermUUID_id=data['PermUUID'], + CompanyUUID_id=company_uuid, ) rp.save() return Response({'code': 0, 'msg': '关联成功', 'data': RolePermissionSerializer(rp).data}, status=201) @@ -220,10 +254,14 @@ class RolePermissionViewSet(viewsets.ModelViewSet): ser = RolePermissionRemoveSerializer(data=request.data) ser.is_valid(raise_exception=True) data = ser.validated_data - deleted, _ = RolePermission.objects.filter( + company_uuid = data.get('CompanyUUID') + qs = RolePermission.objects.filter( RoleUUID_id=data['RoleUUID'], PermUUID_id=data['PermUUID'], - ).delete() + ) + if company_uuid: + qs = qs.filter(CompanyUUID_id=company_uuid) + deleted, _ = qs.delete() if deleted > 0: return Response({'code': 0, 'msg': '移除成功'}) return Response({'code': 404, 'msg': '关联不存在'}, status=404) diff --git a/gvsds/apps/ugm/__pycache__/serializers.cpython-312.pyc b/gvsds/apps/ugm/__pycache__/serializers.cpython-312.pyc index 94597729..6223c871 100644 Binary files a/gvsds/apps/ugm/__pycache__/serializers.cpython-312.pyc and b/gvsds/apps/ugm/__pycache__/serializers.cpython-312.pyc differ diff --git a/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc b/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc index 93c56a0d..7a4c6076 100644 Binary files a/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc and b/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc differ diff --git a/gvsds/apps/ugm/serializers.py b/gvsds/apps/ugm/serializers.py index 73b4685d..41c9bc18 100644 --- a/gvsds/apps/ugm/serializers.py +++ b/gvsds/apps/ugm/serializers.py @@ -14,7 +14,7 @@ class LoginSerializer(PascalCaseSerializer): class RegisterSerializer(PascalCaseSerializer): UserName = serializers.CharField(max_length=256) UserPassword = serializers.CharField(max_length=256) - Email = serializers.CharField(max_length=256) + UserEmail = serializers.CharField(max_length=256) class Verify2FASerializer(PascalCaseSerializer): diff --git a/gvsds/apps/ugm/views.py b/gvsds/apps/ugm/views.py index b1864e16..1655b38a 100644 --- a/gvsds/apps/ugm/views.py +++ b/gvsds/apps/ugm/views.py @@ -3,7 +3,7 @@ import uuid import datetime import io import bcrypt -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.core.cache import cache from rest_framework import status from rest_framework.views import APIView @@ -140,7 +140,7 @@ class RegisterView(APIView): username = serializer.ValidatedData['UserName'] password = serializer.ValidatedData['UserPassword'] - email = serializer.ValidatedData['Email'] + email = serializer.ValidatedData['UserEmail'] if User.objects.filter(UserName=username).exists(): return Response({'code': 1, 'msg': '用户名已存在'}, status=409) @@ -154,7 +154,47 @@ class RegisterView(APIView): user.SetPassword(password) user.save() - return Response({'code': 0, 'msg': '注册成功', 'redirect': '/'}, status=201) + # 为新用户创建默认角色并绑定到系统公司(GERP 后台) + from gvsds.apps.gerp.form_service import create_default_role_for_user + create_default_role_for_user(user) + + # 注册后自动登录:创建 ELT token 并设置 cookie + elt = EphemeralToken.objects.create( + ELTUUID=uuid.uuid4().bytes, + ELTUserUUID=user.UserUUID, + ELTEndTime=datetime.datetime.utcnow() + datetime.timedelta(days=_ELT_TOKEN_LIFETIME_DAYS), + ELTType=0, + ELTStatus=1, + ) + user.UserLastLoginDate = datetime.datetime.utcnow() + user.save(update_fields=['UserLastLoginDate']) + + response = Response({ + 'code': 0, + 'msg': '注册成功', + 'redirect': 'https://gerp.gvsds.com/', + 'data': { + 'EltToken': UUIDObj(elt.ELTUUID).str, + 'User': { + 'UUID': UUIDObj(user.UserUUID).str, + 'Name': user.UserName, + 'Email': user.UserEmail, + }, + }, + }, status=201) + SetELTCookie(response, UUIDObj(elt.ELTUUID).str) + + # 设置 CurrentRoleUUID cookie(如果用户有角色) + from gvsds.apps.gerp.models import UserRole as _UserRole + first_role = _UserRole.objects.filter(UserUUID=user.UserUUID).select_related('RoleUUID').first() + if first_role and first_role.RoleUUID: + response.set_cookie( + 'CurrentRoleUUID', UUIDObj(first_role.RoleUUID.RoleUUID).str, + domain=ELT_COOKIE_DOMAIN, max_age=ELT_COOKIE_MAX_AGE, + secure=True, httponly=False, samesite='None', + ) + + return response class Verify2FAView(APIView): @@ -435,3 +475,18 @@ class LogoutView(APIView): response = Response({'code': 0, 'msg': '已退出登录'}) DeleteELTCookie(response) return response + + def get(self, request): + """GET 请求登出后重定向到登录页""" + elt_token = request.COOKIES.get(ELT_COOKIE_NAME) + if elt_token: + try: + elt = EphemeralToken.objects.get(ELTUUID=UUIDObj(elt_token).bytes) + elt.ELTStatus = 0 + elt.save(update_fields=['ELTStatus']) + except EphemeralToken.DoesNotExist: + pass + + response = HttpResponseRedirect('https://www.gvsds.com/login.html') + DeleteELTCookie(response) + return response diff --git a/gvsds/model_base.py b/gvsds/model_base.py index fa30b8d1..0c9f34f5 100644 --- a/gvsds/model_base.py +++ b/gvsds/model_base.py @@ -1,8 +1,7 @@ # Re-export from gvsdsdk — ORM 模型基类已迁移至 SDK from gvsdsdk.model_base import * # noqa: F401,F403 from gvsdsdk.model_base import ( - ABBREVIATIONS, FieldExpression, FieldExpressionDescriptor, - ForeignKeyIdDescriptor, QModel, QModelBase, + ABBREVIATIONS, FieldExpression, QModel, QModelBase, _CastJsonExpression, _InvertedFieldExpression, _JsonKeyExpression, classproperty, pascal_to_snake, snake_to_pascal, ) diff --git a/gvsds/urls.py b/gvsds/urls.py index 969afeda..2de07bb6 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -128,6 +128,8 @@ urlpatterns = [ path('API/FormAPI/PositionTemplateSearch', gerp_views.PositionTemplateViewSet.as_view({'get': 'search'}), name='form_position_template_search'), path('API/FormAPI/PositionSearch', gerp_views.PositionViewSet.as_view({'get': 'search'}), name='form_position_search'), path('API/FormAPI/ShopSearch', gerp_views.ShopViewSet.as_view({'get': 'search'}), name='form_shop_search'), + path('API/FormAPI/WorkgroupSearch', gerp_views.WorkgroupViewSet.as_view({'get': 'search'}), name='form_workgroup_search'), + path('API/FormAPI/CheckAdmin', gerp_views.CheckAdminView.as_view(), name='form_check_admin'), path('API/doc/doc/GetDocs', gerp_views.DocView.as_view(), name='doc_list'), path('API/doc/doc/AddDoc', gerp_views.DocView.as_view(), name='doc_add'), diff --git a/gvsdsdk/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/__pycache__/__init__.cpython-312.pyc index c2ec8546..6a96774e 100644 Binary files a/gvsdsdk/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/__main__.cpython-312.pyc b/gvsdsdk/__pycache__/__main__.cpython-312.pyc new file mode 100644 index 00000000..f9a4fbb0 Binary files /dev/null and b/gvsdsdk/__pycache__/__main__.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/apps.cpython-312.pyc b/gvsdsdk/__pycache__/apps.cpython-312.pyc index 0e8cba19..f9ff436b 100644 Binary files a/gvsdsdk/__pycache__/apps.cpython-312.pyc and b/gvsdsdk/__pycache__/apps.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/business_models.cpython-312.pyc b/gvsdsdk/__pycache__/business_models.cpython-312.pyc new file mode 100644 index 00000000..dc38fda4 Binary files /dev/null and b/gvsdsdk/__pycache__/business_models.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/db_router.cpython-312.pyc b/gvsdsdk/__pycache__/db_router.cpython-312.pyc new file mode 100644 index 00000000..ce36e1d2 Binary files /dev/null and b/gvsdsdk/__pycache__/db_router.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/fluent.cpython-312.pyc b/gvsdsdk/__pycache__/fluent.cpython-312.pyc index 1cdb4582..27d04613 100644 Binary files a/gvsdsdk/__pycache__/fluent.cpython-312.pyc and b/gvsdsdk/__pycache__/fluent.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/form_service.cpython-312.pyc b/gvsdsdk/__pycache__/form_service.cpython-312.pyc new file mode 100644 index 00000000..ecb9fb83 Binary files /dev/null and b/gvsdsdk/__pycache__/form_service.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/model_base.cpython-312.pyc b/gvsdsdk/__pycache__/model_base.cpython-312.pyc index ae057aec..bbc381ea 100644 Binary files a/gvsdsdk/__pycache__/model_base.cpython-312.pyc and b/gvsdsdk/__pycache__/model_base.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/model_utils.cpython-312.pyc b/gvsdsdk/__pycache__/model_utils.cpython-312.pyc index 7db3acf8..c055d39c 100644 Binary files a/gvsdsdk/__pycache__/model_utils.cpython-312.pyc and b/gvsdsdk/__pycache__/model_utils.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/models.cpython-312.pyc b/gvsdsdk/__pycache__/models.cpython-312.pyc index d4995604..529edcbd 100644 Binary files a/gvsdsdk/__pycache__/models.cpython-312.pyc and b/gvsdsdk/__pycache__/models.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc index 2c4778cc..d417f0dd 100644 Binary files a/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/cookie.cpython-312.pyc b/gvsdsdk/auth/__pycache__/cookie.cpython-312.pyc index bcb094f5..9a2d64f9 100644 Binary files a/gvsdsdk/auth/__pycache__/cookie.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/cookie.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/crypto.cpython-312.pyc b/gvsdsdk/auth/__pycache__/crypto.cpython-312.pyc index e7db059c..d0765ed9 100644 Binary files a/gvsdsdk/auth/__pycache__/crypto.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/crypto.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/elt_auth.cpython-312.pyc b/gvsdsdk/auth/__pycache__/elt_auth.cpython-312.pyc index 35c9eb83..d3fd43fb 100644 Binary files a/gvsdsdk/auth/__pycache__/elt_auth.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/elt_auth.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/jwt_auth.cpython-312.pyc b/gvsdsdk/auth/__pycache__/jwt_auth.cpython-312.pyc new file mode 100644 index 00000000..fe56851e Binary files /dev/null and b/gvsdsdk/auth/__pycache__/jwt_auth.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc b/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc new file mode 100644 index 00000000..c1f65c42 Binary files /dev/null and b/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc b/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc index 113bab9e..ce88d7f0 100644 Binary files a/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc b/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc index f09e5a47..6435672a 100644 Binary files a/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc differ diff --git a/gvsdsdk/auth/elt_auth.py b/gvsdsdk/auth/elt_auth.py index 0492bda1..6b83ae64 100644 --- a/gvsdsdk/auth/elt_auth.py +++ b/gvsdsdk/auth/elt_auth.py @@ -13,7 +13,8 @@ class ELTAuthentication: except (ValueError, AttributeError): return None - from gvsdsdk.models import User, EphemeralToken + from gvsdsdk.models import EphemeralToken + from gvsds.apps.core.models import User import datetime try: diff --git a/gvsdsdk/auth/jwt_auth.py b/gvsdsdk/auth/jwt_auth.py new file mode 100644 index 00000000..5d9f108b --- /dev/null +++ b/gvsdsdk/auth/jwt_auth.py @@ -0,0 +1,54 @@ +"""自定义 JWT 认证 — 向后兼容旧 token 的 user_id claim""" + +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed +from rest_framework_simplejwt.settings import api_settings +from django.utils.translation import gettext_lazy as _ + + +class CompatibleJWTAuthentication(JWTAuthentication): + """ + 兼容旧 token 的 JWT 认证类。 + + 旧 token(USER_ID_CLAIM='user_id' 时生成)payload 中使用 'user_id' claim, + 值为 user.id(UUID 字符串)。 + 新 token(USER_ID_CLAIM='UserUID' 时生成)payload 中使用 'UserUID' claim, + 值为 user.UserUID(7位业务ID)。 + + 此类先尝试新 claim,找不到时回退到旧 claim,并用对应字段查库。 + """ + + def get_user(self, validated_token): + # 1. 先尝试当前配置的 USER_ID_CLAIM + user_id = validated_token.get(api_settings.USER_ID_CLAIM) + lookup_field = api_settings.USER_ID_FIELD + + if user_id is None: + # 2. 回退:尝试旧 token 中的 'user_id' claim + user_id = validated_token.get('user_id') + if user_id is None: + raise InvalidToken( + {'detail': _('令牌未包含用户标识符'), 'code': 'token_not_valid'} + ) + # 旧 token 的 user_id 来自 user.id(即 UserUUID), + # 需要用 UserUUID 字段查库 + lookup_field = 'UserUUID' + + try: + user = self.user_model.objects.get(**{lookup_field: user_id}) + except self.user_model.DoesNotExist: + raise AuthenticationFailed(_('用户不存在'), code='user_not_found') + + if not user.is_active: + raise AuthenticationFailed(_('用户已被禁用'), code='user_inactive') + + if api_settings.CHECK_REVOKE_TOKEN: + from rest_framework_simplejwt.utils import get_md5_hash_password + if validated_token.get( + api_settings.REVOKE_TOKEN_CLAIM + ) != get_md5_hash_password(user.password): + raise AuthenticationFailed( + _('用户密码已更改'), code='password_changed' + ) + + return user diff --git a/gvsdsdk/auth/managers.py b/gvsdsdk/auth/managers.py index e0780f71..1b49a576 100644 --- a/gvsdsdk/auth/managers.py +++ b/gvsdsdk/auth/managers.py @@ -87,32 +87,39 @@ class RoleManager: @staticmethod def GetUsersForRole(RoleUUID): """获取拥有指定角色的所有用户""" - from gvsdsdk.models import User + from gvsds.apps.core.models import User user_uuids = UserRole.objects.filter(RoleUUID=RoleUUID).values_list('UserUUID', flat=True) return User.objects.filter(UserUUID__in=user_uuids) @staticmethod - def AssignPermission(RoleUUID, PermUUID): - """为角色分配权限""" + def AssignPermission(RoleUUID, PermUUID, CompanyUUID): + """为角色分配权限(指定公司)""" rp = RolePermission( RolePermUUID=uuid.uuid4().bytes, RoleUUID=RoleUUID, PermUUID=PermUUID, + CompanyUUID=CompanyUUID, CreateTime=datetime.datetime.utcnow(), ) rp.save() return rp @staticmethod - def RemovePermission(RoleUUID, PermUUID): + def RemovePermission(RoleUUID, PermUUID, CompanyUUID=None): """移除角色的指定权限""" - deleted, _ = RolePermission.objects.filter(RoleUUID=RoleUUID, PermUUID=PermUUID).delete() + qs = RolePermission.objects.filter(RoleUUID=RoleUUID, PermUUID=PermUUID) + if CompanyUUID is not None: + qs = qs.filter(CompanyUUID=CompanyUUID) + deleted, _ = qs.delete() return deleted > 0 @staticmethod - def GetPermissionsForRole(RoleUUID): - """获取角色的所有权限""" - perm_uuids = RolePermission.objects.filter(RoleUUID=RoleUUID).values_list('PermUUID', flat=True) + def GetPermissionsForRole(RoleUUID, CompanyUUID=None): + """获取角色的所有权限(可按公司过滤)""" + qs = RolePermission.objects.filter(RoleUUID=RoleUUID) + if CompanyUUID is not None: + qs = qs.filter(CompanyUUID=CompanyUUID) + perm_uuids = qs.values_list('PermUUID', flat=True) return Permission.objects.filter(PermUUID__in=perm_uuids, PermStatus=1) diff --git a/gvsdsdk/auth/middleware.py b/gvsdsdk/auth/middleware.py index bf92140c..609769d8 100644 --- a/gvsdsdk/auth/middleware.py +++ b/gvsdsdk/auth/middleware.py @@ -1,6 +1,9 @@ from gvsdsdk.auth.cookie import ELT_COOKIE_NAME from gvsdsdk.models import UserRole, Role from gvsdsdk.model_utils import UUIDObj +import logging + +logger = logging.getLogger(__name__) class ELTAuthMiddleware: @@ -20,35 +23,77 @@ class CurrentRoleMiddleware: request.current_role : gvsdsdk.models.Role 实例(或 None) request.current_company_uuid : 通过 UserRole.CompanyUUID 获取当前角色所属公司UUID + + 注意:此中间件在 DRF 认证之前运行,因此需要自行从 ELT token 解析用户。 """ def __init__(self, get_response): self.get_response = get_response + def _get_user_from_elt(self, request): + """从 ELT token cookie 中解析用户(不依赖 DRF 认证)""" + import uuid as _uuid + import datetime as _datetime + elt_token = request.COOKIES.get(ELT_COOKIE_NAME) + if not elt_token: + return None + try: + token_uuid = _uuid.UUID(elt_token) + except (ValueError, AttributeError): + return None + try: + from gvsdsdk.models import EphemeralToken + elt = EphemeralToken.objects.get(ELTUUID=token_uuid, ELTStatus=1) + except EphemeralToken.DoesNotExist: + return None + now_utc = _datetime.datetime.now(_datetime.timezone.utc) + if elt.ELTEndTime.replace(tzinfo=None) < now_utc.replace(tzinfo=None): + return None + try: + from gvsds.apps.core.models import User + return User.objects.get(UserUUID=elt.ELTUserUUID) + except User.DoesNotExist: + return None + def __call__(self, request): request.current_role = None request.current_company_uuid = None - # 仅对已认证用户处理角色 + # 优先使用已认证的 request.user,否则自行从 ELT token 解析 + user = None if hasattr(request, 'user') and request.user and request.user.is_authenticated: - role_uuid_str = request.COOKIES.get('CurrentRoleUUID', '') - if role_uuid_str: - try: - role_uuid_bytes = UUIDObj(role_uuid_str).bytes - # gvsdsdk.UserRole 的 RoleUUID 是 UUIDField,不能用 select_related - user_role = UserRole.objects.filter( - UserUUID=request.user.UserUUID, - RoleUUID=role_uuid_bytes - ).first() - if user_role: - # 单独查询 Role 实例 - role = Role.objects.filter(RoleUUID=role_uuid_bytes).first() - if role: - request.current_role = role - # 通过 UserRole.CompanyUUID 获取当前公司 - if user_role.CompanyUUID: - request.current_company_uuid = user_role.CompanyUUID - except Exception: - pass + user = request.user + else: + user = self._get_user_from_elt(request) + + if not user: + return self.get_response(request) + + role_uuid_str = request.COOKIES.get('CurrentRoleUUID', '') + logger.info('[CurrentRoleMiddleware] user=%s, CurrentRoleUUID cookie=%s', user, role_uuid_str or '(empty)') + if role_uuid_str: + try: + role_uuid_bytes = UUIDObj(role_uuid_str).bytes + user_uuid = user.UserUUID + logger.info('[CurrentRoleMiddleware] querying UserRole: UserUUID=%s, RoleUUID=%s', + user_uuid.hex() if hasattr(user_uuid, 'hex') else user_uuid, + role_uuid_bytes.hex()) + user_role = UserRole.objects.filter( + UserUUID=user.UserUUID, + RoleUUID=role_uuid_bytes + ).first() + logger.info('[CurrentRoleMiddleware] user_role found=%s', user_role is not None) + if user_role: + role = Role.objects.filter(RoleUUID=role_uuid_bytes).first() + if role: + request.current_role = role + if user_role.CompanyUUID: + request.current_company_uuid = user_role.CompanyUUID + logger.info('[CurrentRoleMiddleware] set current_company_uuid=%s', + user_role.CompanyUUID.hex() if hasattr(user_role.CompanyUUID, 'hex') else user_role.CompanyUUID) + else: + logger.warning('[CurrentRoleMiddleware] user_role.CompanyUUID is None') + except Exception as e: + logger.error('[CurrentRoleMiddleware] error: %s', e, exc_info=True) return self.get_response(request) diff --git a/gvsdsdk/auth/permissions.py b/gvsdsdk/auth/permissions.py index 44543d42..3d4c2584 100644 --- a/gvsdsdk/auth/permissions.py +++ b/gvsdsdk/auth/permissions.py @@ -1,4 +1,5 @@ -from gvsdsdk.models import User, UserRole, Role, RolePermission, Permission, UserABAC +from gvsds.apps.core.models import User +from gvsdsdk.models import UserRole, Role, RolePermission, Permission, UserABAC from rest_framework.permissions import BasePermission @@ -48,7 +49,7 @@ def PermissionVerifier(permission, attributes, user=None, request=None): """权限验证器——混合 ABAC + RBAC 检查 RBAC 仅检查当前角色(CurrentRoleUUID Cookie 指定的角色)的权限, - 通过 RolePermission → Permission 链路查询。 + 通过 RolePermission → Permission 链路查询,按当前公司过滤。 超级管理员/员工直接放行。 """ if not permission: @@ -63,6 +64,7 @@ def PermissionVerifier(permission, attributes, user=None, request=None): return True current_role = getattr(request, 'current_role', None) if request else None + current_company = getattr(request, 'current_company_uuid', None) if request else None for attr in attributes: # ABAC 检查 @@ -73,13 +75,16 @@ def PermissionVerifier(permission, attributes, user=None, request=None): if abac_match: continue - # RBAC 检查:仅检查当前角色 + # RBAC 检查:仅检查当前角色,按公司过滤权限 if current_role and isinstance(current_role, Role): if current_role.RoleStatus != 1: return False - perm_uuids = RolePermission.objects.filter( + rp_qs = RolePermission.objects.filter( RoleUUID=current_role.RoleUUID - ).values_list('PermUUID', flat=True) + ) + if current_company: + rp_qs = rp_qs.filter(CompanyUUID=current_company) + perm_uuids = rp_qs.values_list('PermUUID', flat=True) rbac_result = Permission.objects.filter( PermUUID__in=perm_uuids, PermCode=attr, @@ -94,9 +99,12 @@ def PermissionVerifier(permission, attributes, user=None, request=None): RoleUUID__in=user_role_uuids, RoleStatus=1, ).values_list('RoleUUID', flat=True) - perm_uuids = RolePermission.objects.filter( + rp_qs = RolePermission.objects.filter( RoleUUID__in=active_role_uuids - ).values_list('PermUUID', flat=True) + ) + if current_company: + rp_qs = rp_qs.filter(CompanyUUID=current_company) + perm_uuids = rp_qs.values_list('PermUUID', flat=True) rbac_result = Permission.objects.filter( PermUUID__in=perm_uuids, PermCode=attr, diff --git a/gvsdsdk/commission/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/commission/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..9dcff16c Binary files /dev/null and b/gvsdsdk/commission/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/commission/__pycache__/manager.cpython-312.pyc b/gvsdsdk/commission/__pycache__/manager.cpython-312.pyc new file mode 100644 index 00000000..acfc9766 Binary files /dev/null and b/gvsdsdk/commission/__pycache__/manager.cpython-312.pyc differ diff --git a/gvsdsdk/commission/__pycache__/models.cpython-312.pyc b/gvsdsdk/commission/__pycache__/models.cpython-312.pyc new file mode 100644 index 00000000..b1f707e3 Binary files /dev/null and b/gvsdsdk/commission/__pycache__/models.cpython-312.pyc differ diff --git a/gvsdsdk/commission/strategies/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/commission/strategies/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..d8421eea Binary files /dev/null and b/gvsdsdk/commission/strategies/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/commission/strategies/__pycache__/engine.cpython-312.pyc b/gvsdsdk/commission/strategies/__pycache__/engine.cpython-312.pyc new file mode 100644 index 00000000..592ba732 Binary files /dev/null and b/gvsdsdk/commission/strategies/__pycache__/engine.cpython-312.pyc differ diff --git a/gvsdsdk/fluent.py b/gvsdsdk/fluent.py index 4b0df1f8..100b3d35 100644 --- a/gvsdsdk/fluent.py +++ b/gvsdsdk/fluent.py @@ -1,146 +1,246 @@ +"""FluentQuery 兼容层查询构建器 + +提供 SQLAlchemy 风格的链式 OOP 查询接口,包装 Django QuerySet。 + +核心类: +- ``FluentQuery`` — 链式查询构建器,由 ``Model.query`` 返回 +- ``Session`` — 简易会话管理(add/commit/rollback) +- ``_CompatFunc`` — 兼容层聚合函数(func.sum / func.count 等) +- ``db`` — 全局数据库操作入口(db.session / db.or_ / db.and_) + +典型用法:: + + from gvsdsdk.fluent import db, func, FQ + + # 链式查询 + orders = Dingdan.query.filter(zhuangtai=8).order_by('-create_time').to_list() + + # 字段表达式 + Dingdan.query.filter(Dingdan.jine > 100, Dingdan.zhuangtai.in_([1,2,3])) + + # 聚合 + Dingdan.query.aggregate(total=Sum('jine')) + + # values / annotate + Dingdan.query.values('zhuangtai').annotate(cnt=Count('id')) + + # 分页 + Dingdan.query.paginate(page=1, per_page=20) + + # 兼容层聚合函数 + func.sum(Dingdan.jine) + func.count(Dingdan.id) + func.year(Dingdan.create_time) +""" +from __future__ import annotations + +import logging import threading -import functools +from typing import Any, Dict, Generic, Iterator, List, Optional, Sequence, Tuple, Type, TypeVar, Union + from django.db import connection as default_db_connection, models, transaction from django.db.models import Q, F, Count, Sum, Avg, Min, Max, Subquery, OuterRef, Case, When, Value -from gvsdsdk.model_base import FieldExpression +logger = logging.getLogger(__name__) _local = threading.local() +#: 模型类型变量,用于 FluentQuery[_M] 泛型参数 +_M = TypeVar('_M', bound=models.Model) + + +def _get_field_expression_cls(): + """延迟导入 FieldExpression,避免 fluent ↔ model_base 循环依赖。""" + from gvsdsdk.model_base import FieldExpression + return FieldExpression + + +# --------------------------------------------------------------------------- +# 兼容层聚合函数 +# --------------------------------------------------------------------------- class _AggregateExpr: - def __init__(self, model, aggregate): + """聚合表达式包装,关联模型类和 Django Aggregate 对象。""" + + def __init__(self, model: Optional[Type[models.Model]], aggregate: models.Aggregate) -> None: self.model = model self.aggregate = aggregate class _ExtractExpr: - def __init__(self, field_name, lookup_type): + """日期提取表达式(year/month/day),用于 ``func.year(field) == 2024`` 等。""" + + def __init__(self, field_name: str, lookup_type: str) -> None: self.field_name = field_name self.lookup_type = lookup_type - def __eq__(self, other): + def __eq__(self, other) -> Q: # type: ignore[override] return Q(**{f'{self.field_name}__{self.lookup_type}': other}) - def __and__(self, other): + def __and__(self, other) -> Q: if isinstance(other, Q): return Q(**{f'{self.field_name}__{self.lookup_type}': None}) & other return self.__eq__(other) class _CompatFunc: - def sum(self, field_expr): - if hasattr(field_expr, '_django_name'): - agg = Sum(field_expr._django_name) - elif hasattr(field_expr, 'field_name'): - agg = Sum(field_expr.field_name) - else: - agg = Sum(str(field_expr)) - model = getattr(field_expr, 'model_class', None) if hasattr(field_expr, 'model_class') else None + """兼容层聚合函数集合,提供 SQLAlchemy 风格的 ``func.sum()`` / ``func.count()`` 等。 + + 用法:: + + from gvsdsdk.fluent import func + + func.sum(Model.jine) → Sum('jine') + func.count(Model.id) → Count('id') + func.max(Model.jine) → Max('jine') + func.min(Model.jine) → Min('jine') + func.avg(Model.jine) → Avg('jine') + func.year(Model.riqi) → _ExtractExpr('riqi', 'year') + func.month(Model.riqi) → _ExtractExpr('riqi', 'month') + func.day(Model.riqi) → _ExtractExpr('riqi', 'day') + """ + + def sum(self, field_expr) -> Union[_AggregateExpr, Sum]: + """求和聚合。""" + name = self._resolve_name(field_expr) + agg = Sum(name) + model = getattr(field_expr, 'model_class', None) return _AggregateExpr(model, agg) if model else agg - def count(self, field_expr=None): + def count(self, field_expr=None) -> Union[_AggregateExpr, Count]: + """计数聚合,无参数时等价于 COUNT(*)。""" if field_expr is None: return Count('*') - if hasattr(field_expr, '_django_name'): - agg = Count(field_expr._django_name) - else: - agg = Count(str(field_expr)) - model = getattr(field_expr, 'model_class', None) if hasattr(field_expr, 'model_class') else None + name = self._resolve_name(field_expr) + agg = Count(name) + model = getattr(field_expr, 'model_class', None) return _AggregateExpr(model, agg) if model else agg - def max(self, field_expr): - if hasattr(field_expr, '_django_name'): - agg = Max(field_expr._django_name) - else: - agg = Max(str(field_expr)) - model = getattr(field_expr, 'model_class', None) if hasattr(field_expr, 'model_class') else None + def max(self, field_expr) -> Union[_AggregateExpr, Max]: + """最大值聚合。""" + name = self._resolve_name(field_expr) + agg = Max(name) + model = getattr(field_expr, 'model_class', None) return _AggregateExpr(model, agg) if model else agg - def min(self, field_expr): - if hasattr(field_expr, '_django_name'): - agg = Min(field_expr._django_name) - else: - agg = Min(str(field_expr)) - model = getattr(field_expr, 'model_class', None) if hasattr(field_expr, 'model_class') else None + def min(self, field_expr) -> Union[_AggregateExpr, Min]: + """最小值聚合。""" + name = self._resolve_name(field_expr) + agg = Min(name) + model = getattr(field_expr, 'model_class', None) return _AggregateExpr(model, agg) if model else agg - def avg(self, field_expr): - if hasattr(field_expr, '_django_name'): - agg = Avg(field_expr._django_name) - else: - agg = Avg(str(field_expr)) - model = getattr(field_expr, 'model_class', None) if hasattr(field_expr, 'model_class') else None + def avg(self, field_expr) -> Union[_AggregateExpr, Avg]: + """平均值聚合。""" + name = self._resolve_name(field_expr) + agg = Avg(name) + model = getattr(field_expr, 'model_class', None) return _AggregateExpr(model, agg) if model else agg - def year(self, field_expr): - if hasattr(field_expr, '_django_name'): - return _ExtractExpr(field_expr._django_name, 'year') - return _ExtractExpr(str(field_expr), 'year') + def year(self, field_expr) -> _ExtractExpr: + """提取年份,用于 ``func.year(Model.date) == 2024``。""" + return _ExtractExpr(self._resolve_name(field_expr), 'year') - def month(self, field_expr): - if hasattr(field_expr, '_django_name'): - return _ExtractExpr(field_expr._django_name, 'month') - return _ExtractExpr(str(field_expr), 'month') + def month(self, field_expr) -> _ExtractExpr: + """提取月份。""" + return _ExtractExpr(self._resolve_name(field_expr), 'month') - def day(self, field_expr): + def day(self, field_expr) -> _ExtractExpr: + """提取日期。""" + return _ExtractExpr(self._resolve_name(field_expr), 'day') + + @staticmethod + def _resolve_name(field_expr) -> str: + """从 FieldExpression 或字符串解析 Django 字段名。""" if hasattr(field_expr, '_django_name'): - return _ExtractExpr(field_expr._django_name, 'day') - return _ExtractExpr(str(field_expr), 'day') + return field_expr._django_name + if hasattr(field_expr, 'field_name'): + return field_expr.field_name + return str(field_expr) func = _CompatFunc() +# --------------------------------------------------------------------------- +# DELETE 语句构建器 +# --------------------------------------------------------------------------- + class _DeleteStmt: - def __init__(self, model_class, conditions=None): + """DELETE 语句构建器,支持 ``Model.query.filter(...).delete()`` 风格。""" + + def __init__(self, model_class: Type[models.Model], conditions: Optional[Q] = None) -> None: self.model_class = model_class self.conditions = conditions or Q() - def where(self, *args, **kwargs): + def where(self, *args: Q, **kwargs: Any) -> _DeleteStmt: + """添加 WHERE 条件。""" new_conditions = Q() for a in args: if isinstance(a, Q): new_conditions &= a + if kwargs: + new_conditions &= Q(**kwargs) self.conditions = new_conditions return self - def execute(self): + def execute(self) -> Tuple[int, Dict[str, int]]: + """执行删除,返回 (删除数, {表名: 删除数})。""" if self.model_class and self.conditions: - self.model_class.objects.filter(self.conditions).delete() + return self.model_class.objects.filter(self.conditions).delete() elif self.model_class: - self.model_class.objects.all().delete() + return self.model_class.objects.all().delete() + return (0, {}) -def _get_session(): +# --------------------------------------------------------------------------- +# 会话与缓存 +# --------------------------------------------------------------------------- + +def _get_session() -> Session: + """获取当前线程的 Session 实例。""" if not hasattr(_local, 'db_session'): _local.db_session = Session() return _local.db_session -def _get_cache(): +def _get_cache() -> Dict: + """获取当前线程的查询缓存。""" if not hasattr(_local, 'query_cache'): _local.query_cache = {} return _local.query_cache -def _invalidate_cache(): +def _invalidate_cache() -> None: + """清除当前线程的查询缓存。""" if hasattr(_local, 'query_cache'): _local.query_cache.clear() +# --------------------------------------------------------------------------- +# 关联字段访问器 +# --------------------------------------------------------------------------- + class RelationAccessor: - def __init__(self, model_class, prefix=''): + """关联字段访问器,支持 ``Model.relation.field`` 跨表字段表达式。 + + 用法:: + + # 自动解析关联字段 + Dingdan.query.filter(Dingdan.shangjia.nicheng == '测试') + """ + + def __init__(self, model_class: Type[models.Model], prefix: str = '') -> None: self._model = model_class self._prefix = prefix - def __getattr__(self, name): + def __getattr__(self, name: str): if name.startswith('_'): return object.__getattribute__(self, name) try: self._model._meta.get_field(name) full_name = f'{self._prefix}__{name}' if self._prefix else name - return FieldExpression(self._model, full_name) + return _get_field_expression_cls()(self._model, full_name) except Exception: for rel in self._model._meta.get_fields(include_hidden=True): if hasattr(rel, 'related_model') and rel.related_model: @@ -152,48 +252,64 @@ class RelationAccessor: if rel_name and rel_name == name: full_prefix = f'{self._prefix}__{rel_name}' if self._prefix else rel_name return RelationAccessor(rel.related_model, full_prefix) - return FieldExpression(self._model, name) + return _get_field_expression_cls()(self._model, name) + def __repr__(self) -> str: + return f'RelationAccessor({self._model.__name__}, prefix={self._prefix!r})' + + +# --------------------------------------------------------------------------- +# Values 结果集 +# --------------------------------------------------------------------------- class _ValuesResult: - """ - 包装 Django ValuesQuerySet,支持链式调用。 - 底层使用 Django .values() 返回字典,保持与 Django 行为一致。 - 额外提供 .all() 返回字典列表,.first() 返回字典或 None, - .scalar() 返回单字段场景的标量值。 - """ - def __init__(self, qs, fields, original_fields): - self._qs = qs # Django ValuesQuerySet(返回字典) - self._fields = fields # 转换后的字段名列表(传给 Django .values() 的) - self._original_fields = original_fields # 原始传入的字段(用于判断单字段场景) + """包装 Django ValuesQuerySet,支持链式调用。 - def filter(self, *args, **kwargs): + 底层使用 Django ``.values()`` 返回字典,保持与 Django 行为一致。 + 额外提供 ``.all()`` 返回字典列表,``.first()`` 返回字典或 None, + ``.scalar()`` 返回单字段场景的标量值。 + """ + + def __init__(self, qs: models.QuerySet, fields: List[str], original_fields: Tuple) -> None: + self._qs: models.QuerySet = qs + self._fields: List[str] = fields + self._original_fields: Tuple = original_fields + + @property + def query(self) -> Any: + """暴露底层 Django QuerySet 的 query 属性,支持 Subquery() 等场景。""" + return self._qs.query + + def filter(self, *args: Any, **kwargs: Any) -> _ValuesResult: self._qs = self._qs.filter(*args, **kwargs) return self - def exclude(self, *args, **kwargs): + def exclude(self, *args: Any, **kwargs: Any) -> _ValuesResult: self._qs = self._qs.exclude(*args, **kwargs) return self - def order_by(self, *fields): + def order_by(self, *fields: str) -> _ValuesResult: self._qs = self._qs.order_by(*fields) return self - def distinct(self): + def distinct(self) -> _ValuesResult: self._qs = self._qs.distinct() return self - def annotate(self, **kwargs): + def annotate(self, **kwargs: Any) -> _ValuesResult: self._qs = self._qs.annotate(**kwargs) return self - def first(self): + def first(self) -> Optional[Dict[str, Any]]: + """返回第一条记录(字典)或 None。""" return self._qs.first() - def all(self): + def all(self) -> List[Dict[str, Any]]: + """返回所有记录的字典列表。""" return list(self._qs) - def scalar(self): + def scalar(self) -> Any: + """返回单字段场景的标量值。""" row = self._qs.first() if row is None: return None @@ -201,70 +317,97 @@ class _ValuesResult: return row.get(self._fields[0]) return row - def count(self): + def count(self) -> int: return self._qs.count() - def exists(self): + def exists(self) -> bool: return self._qs.exists() - def __getitem__(self, key): + def __getitem__(self, key: Union[int, slice]) -> Any: if isinstance(key, slice): self._qs = self._qs[key] return self else: return self._qs[key] - def __iter__(self): + def __iter__(self) -> Iterator[Dict[str, Any]]: return iter(self._qs) - def __len__(self): + def __len__(self) -> int: return self.count() - def __bool__(self): + def __bool__(self) -> bool: return self._qs.exists() + def __repr__(self) -> str: + return f'_ValuesResult(fields={self._fields}, count={self.count()})' + + +# --------------------------------------------------------------------------- +# FluentQuery 核心查询构建器 +# --------------------------------------------------------------------------- + +class FluentQuery(Generic[_M]): + """QModel 兼容层查询构建器,提供 SQLAlchemy 风格的链式 OOP 查询接口。 + + 由 ``Model.query`` 返回,支持链式调用:: + + # 基础查询 + Dingdan.query.filter(zhuangtai=8).all() + + # 链式过滤 + Dingdan.query.filter(jine__gt=100).order_by('-create_time').limit(10) + + # 字段表达式 + Dingdan.query.filter(Dingdan.jine > 100, Dingdan.zhuangtai.in_([1,2,3])) + + # 聚合 + Dingdan.query.aggregate(total=Sum('jine')) + + # values / annotate + Dingdan.query.values('zhuangtai').annotate(cnt=Count('id')) + + # 分页 + Dingdan.query.paginate(page=1, per_page=20) + + 泛型参数 ``_M`` 携带模型类型,使 IDE 能推断链式调用返回的模型实例类型。 + """ + + def __init__(self, model_class: Type[_M]) -> None: + self._model: Type[_M] = model_class + self._qs: models.QuerySet = model_class.objects.all() + self._joined_models: List[Type[models.Model]] = [] + self._cache_enabled: bool = True + @property - def query(self): - """代理内部 QuerySet 的 query 属性,使 Subquery() 可用""" + def query(self) -> Any: + """暴露底层 Django QuerySet 的 query 属性,支持 Subquery() 等场景。""" return self._qs.query - def clone(self): - """代理内部 QuerySet 的 clone 方法,使 Subquery() 可用""" - return self._qs.clone() - @property - def model(self): - """代理内部 QuerySet 的 model 属性""" - return self._qs.model + def model(self) -> Type[_M]: + """返回关联的模型类。""" + return self._model - def as_queryset(self): - """返回内部 Django QuerySet,用于 Subquery 等需要原生 QuerySet 的场景""" - return self._qs - - -class FluentQuery: - def __init__(self, model_class): - self._model = model_class - self._qs = model_class.objects.all() - self._joined_models = [] - self._cache_enabled = True - - def _clone(self): + def _clone(self) -> FluentQuery[_M]: + """克隆当前查询状态。""" q = FluentQuery(self._model) q._qs = self._qs q._joined_models = list(self._joined_models) q._cache_enabled = self._cache_enabled return q - def _cache_key(self, method): + def _cache_key(self, method: str) -> Tuple[Any, str, Any]: + """生成缓存键。""" try: query_str = str(self._qs.query) except Exception: query_str = id(self._qs) return (self._model, method, query_str) - def _remap_q(self, q): - new_children = [] + def _remap_q(self, q: Q) -> Q: + """重映射 Q 对象中的字段名(用于 join 跨表查询)。""" + new_children: List[Any] = [] for child in q.children: if isinstance(child, Q): new_children.append(self._remap_q(child)) @@ -281,7 +424,8 @@ class FluentQuery: return new_q return q - def _remap_key(self, key): + def _remap_key(self, key: str) -> str: + """重映射字段名(用于 join 跨表查询自动添加关联前缀)。""" parts = key.split('__') field_name = parts[0] try: @@ -306,8 +450,11 @@ class FluentQuery: pass return key - def filter(self, *args, **kwargs): - converted_args = [] + # ---- 过滤 ---- + + def filter(self, *args: Any, **kwargs: Any) -> FluentQuery[_M]: + """过滤查询,支持 Q 对象和关键字参数,返回 self 以支持链式调用。""" + converted_args: List[Any] = [] for arg in args: if isinstance(arg, Q) and self._joined_models: converted_args.append(self._remap_q(arg)) @@ -316,8 +463,9 @@ class FluentQuery: self._qs = self._qs.filter(*converted_args, **kwargs) return self - def exclude(self, *args, **kwargs): - converted_args = [] + def exclude(self, *args: Any, **kwargs: Any) -> FluentQuery[_M]: + """排除查询,返回 self 以支持链式调用。""" + converted_args: List[Any] = [] for arg in args: if isinstance(arg, Q) and self._joined_models: converted_args.append(self._remap_q(arg)) @@ -326,19 +474,25 @@ class FluentQuery: self._qs = self._qs.exclude(*converted_args, **kwargs) return self - def order_by(self, *args): - converted = [] + # ---- 排序 ---- + + def order_by(self, *args: Any) -> FluentQuery[_M]: + """排序,支持字符串和 FieldExpression,返回 self 以支持链式调用。""" + converted: List[str] = [] for a in args: if isinstance(a, str): converted.append(a) - elif isinstance(a, FieldExpression): + elif isinstance(a, _get_field_expression_cls()): converted.append(str(a)) else: converted.append(str(a)) self._qs = self._qs.order_by(*converted) return self - def first(self): + # ---- 获取记录 ---- + + def first(self) -> Optional[_M]: + """返回第一条记录或 None。""" if self._cache_enabled: cache = _get_cache() key = self._cache_key('first') @@ -351,13 +505,26 @@ class FluentQuery: _get_cache()[self._cache_key('first')] = obj return obj - def all(self): + def last(self) -> Optional[_M]: + """返回最后一条记录或 None。""" + return self._qs.last() + + def get(self, *args: Any, **kwargs: Any) -> _M: + """获取唯一匹配记录,不存在或多个时抛异常。""" + if args: + obj = self._qs.filter(*args).get(**kwargs) + else: + obj = self._qs.get(**kwargs) + _get_session().track(obj) + return obj + + def all(self) -> FluentQuery[_M]: """返回 self 以支持链式调用(与 Django QuerySet.all() 一致)。 - 如需获取列表,使用 .to_list() 或 list()。""" + 如需获取列表,使用 ``.to_list()`` 或 ``list()``。""" self._qs = self._qs.all() return self - def to_list(self): + def to_list(self) -> List[_M]: """执行查询并返回模型实例列表。""" if self._cache_enabled: cache = _get_cache() @@ -372,7 +539,8 @@ class FluentQuery: _get_cache()[self._cache_key('to_list')] = results return results - def count(self): + def count(self) -> int: + """返回匹配记录数。""" if self._cache_enabled: cache = _get_cache() key = self._cache_key('count') @@ -383,97 +551,158 @@ class FluentQuery: _get_cache()[self._cache_key('count')] = result return result - def get(self, *args, **kwargs): - if args: - obj = self._qs.filter(*args).get(**kwargs) - else: - obj = self._qs.get(**kwargs) - _get_session().track(obj) - return obj + def exists(self) -> bool: + """判断是否存在匹配记录。""" + return self._qs.exists() - def create(self, **kwargs): + # ---- 创建/更新/删除 ---- + + def create(self, **kwargs: Any) -> _M: + """创建并保存新记录。""" obj = self._model.objects.create(**kwargs) _invalidate_cache() return obj - def get_or_create(self, defaults=None, **kwargs): + def get_or_create(self, defaults: Optional[Dict[str, Any]] = None, + **kwargs: Any) -> Tuple[_M, bool]: + """查询或创建记录,返回 ``(obj, created)`` 元组。""" obj, created = self._model.objects.get_or_create(defaults=defaults, **kwargs) if created: _invalidate_cache() _get_session().track(obj) return obj, created - def update_or_create(self, defaults=None, **kwargs): + def update_or_create(self, defaults: Optional[Dict[str, Any]] = None, + **kwargs: Any) -> Tuple[_M, bool]: + """更新或创建记录,返回 ``(obj, created)`` 元组。""" obj, created = self._model.objects.update_or_create(defaults=defaults, **kwargs) _invalidate_cache() _get_session().track(obj) return obj, created - def none(self): - self._qs = self._qs.none() - return self - - def last(self): - return self._qs.last() - - def limit(self, n): - self._qs = self._qs[:n] - return self - - def offset(self, n): - self._qs = self._qs[n:] - return self - - def join(self, *args): - for arg in args: - if isinstance(arg, type) and issubclass(arg, models.Model): - if arg not in self._joined_models: - self._joined_models.append(arg) - return self - - def select_related(self, *fields): - self._qs = self._qs.select_related(*fields) - return self - - def prefetch_related(self, *fields): - self._qs = self._qs.prefetch_related(*fields) - return self - - def only(self, *fields): - self._qs = self._qs.only(*fields) - return self - - def defer(self, *fields): - self._qs = self._qs.defer(*fields) - return self - - def select_for_update(self, nowait=False, skip_locked=False, of=(), no_key=False): - self._qs = self._qs.select_for_update( - nowait=nowait, skip_locked=skip_locked, of=of, no_key=no_key - ) - return self - - def bulk_create(self, objs, batch_size=None, ignore_conflicts=False): + def bulk_create(self, objs: List[_M], batch_size: Optional[int] = None, + ignore_conflicts: bool = False) -> List[_M]: + """批量创建记录。""" result = self._model.objects.bulk_create( objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts ) _invalidate_cache() return result - def annotate(self, **kwargs): + def bulk_update(self, objs: List[_M], fields: List[str], + batch_size: Optional[int] = None) -> int: + """批量更新记录,返回受影响行数。""" + result = self._model.objects.bulk_update(objs, fields, batch_size=batch_size) + _invalidate_cache() + return result + + def update(self, **kwargs: Any) -> int: + """批量更新匹配记录,返回受影响行数。""" + result = self._qs.update(**kwargs) + _invalidate_cache() + return result + + def delete(self) -> Tuple[int, Dict[str, int]]: + """批量删除匹配记录,返回 ``(删除数, {表名: 删除数})``。""" + result = self._qs.delete() + _invalidate_cache() + return result + + # ---- 查询修饰 ---- + + def none(self) -> FluentQuery[_M]: + """返回空结果集,返回 self 以支持链式调用。""" + self._qs = self._qs.none() + return self + + def limit(self, n: int) -> FluentQuery[_M]: + """限制返回记录数,返回 self 以支持链式调用。""" + self._qs = self._qs[:n] + return self + + def offset(self, n: int) -> FluentQuery[_M]: + """跳过前 n 条记录,返回 self 以支持链式调用。""" + self._qs = self._qs[n:] + return self + + def join(self, *args: Type[models.Model]) -> FluentQuery[_M]: + """关联模型,用于跨表查询自动映射字段,返回 self 以支持链式调用。""" + for arg in args: + if isinstance(arg, type) and issubclass(arg, models.Model): + if arg not in self._joined_models: + self._joined_models.append(arg) + return self + + def select_related(self, *fields: str) -> FluentQuery[_M]: + """外键关联查询优化,返回 self 以支持链式调用。""" + self._qs = self._qs.select_related(*fields) + return self + + def prefetch_related(self, *fields: str) -> FluentQuery[_M]: + """多对多/反向关联查询优化,返回 self 以支持链式调用。""" + self._qs = self._qs.prefetch_related(*fields) + return self + + def only(self, *fields: str) -> FluentQuery[_M]: + """只加载指定字段,返回 self 以支持链式调用。""" + self._qs = self._qs.only(*fields) + return self + + def defer(self, *fields: str) -> FluentQuery[_M]: + """延迟加载指定字段,返回 self 以支持链式调用。""" + self._qs = self._qs.defer(*fields) + return self + + def select_for_update(self, nowait: bool = False, skip_locked: bool = False, + of: Tuple = (), no_key: bool = False) -> FluentQuery[_M]: + """行级锁,返回 self 以支持链式调用。""" + self._qs = self._qs.select_for_update( + nowait=nowait, skip_locked=skip_locked, of=of, no_key=no_key + ) + return self + + def distinct(self) -> FluentQuery[_M]: + """去重查询,返回 self 以支持链式调用。""" + self._qs = self._qs.distinct() + return self + + def using(self, alias: str) -> FluentQuery[_M]: + """指定数据库别名,返回 self 以支持链式调用。""" + self._qs = self._qs.using(alias) + return self + + def no_cache(self) -> FluentQuery[_M]: + """禁用查询缓存,返回 self 以支持链式调用。""" + self._cache_enabled = False + return self + + def params(self, **kwargs: Any) -> FluentQuery[_M]: + """参数占位(SQLAlchemy 兼容),返回 self 以支持链式调用。""" + return self + + # ---- 聚合与注解 ---- + + def annotate(self, **kwargs: Any) -> FluentQuery[_M]: + """注解查询,添加聚合/计算字段,返回 self 以支持链式调用。""" self._qs = self._qs.annotate(**kwargs) return self - def aggregate(self, **kwargs): - return self._qs.aggregate(**kwargs) + def aggregate(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: + """聚合查询,返回聚合结果字典。 - def values(self, *fields): + 兼容 Django QuerySet.aggregate() 的两种调用方式: + aggregate(Max('field')) # 位置参数 + aggregate(field__max=Max('field')) # 关键字参数 + """ + return self._qs.aggregate(*args, **kwargs) + + def values(self, *fields: Union[str, _AggregateExpr, models.Expression]) -> _ValuesResult: + """返回指定字段的字典结果,支持链式调用。""" if not fields: - # 无参数:返回 Django .values()(所有字段字典) - return _ValuesResult(self._qs.values(), [], []) + return _ValuesResult(self._qs.values(), [], fields) - annotations = {} - converted = [] + annotations: Dict[str, models.Aggregate] = {} + converted: List[str] = [] for i, f in enumerate(fields): if isinstance(f, _AggregateExpr): annotations[f'val{i}'] = f.aggregate @@ -489,38 +718,23 @@ class FluentQuery: qs = self._qs if annotations: qs = qs.annotate(**annotations) - # 使用 Django 原生 .values() 返回字典 qs = qs.values(*converted) return _ValuesResult(qs, converted, fields) - def with_entities(self, *fields): + def with_entities(self, *fields: Union[str, _AggregateExpr, models.Expression]) -> _ValuesResult: + """values() 的别名,SQLAlchemy 风格。""" return self.values(*fields) - def params(self, **kwargs): - return self - - def values_list(self, *fields, flat=False): + def values_list(self, *fields: str, flat: bool = False) -> Any: + """返回指定字段的元组列表。""" return self._qs.values_list(*fields, flat=flat) - def update(self, **kwargs): - result = self._qs.update(**kwargs) - _invalidate_cache() - return result + # ---- 分页 ---- - def delete(self): - result = self._qs.delete() - _invalidate_cache() - return result - - def exists(self): - return self._qs.exists() - - def distinct(self): - self._qs = self._qs.distinct() - return self - - def paginate(self, page=1, per_page=20, error_out=False): - from django.core.paginator import Paginator + def paginate(self, page: int = 1, per_page: int = 20, + error_out: bool = False) -> Any: + """分页查询,返回 Page 对象(含 items, total, pages 等属性)。""" + from django.core.paginator import Paginator, EmptyPage if not self._qs.ordered: meta = getattr(self._model, '_meta', None) if meta and meta.pk: @@ -528,7 +742,7 @@ class FluentQuery: paginator = Paginator(self._qs, per_page) try: page_obj = paginator.page(page) - except Exception: + except EmptyPage: page_obj = paginator.page(1) page_obj.total = paginator.count page_obj.items = page_obj.object_list @@ -541,55 +755,66 @@ class FluentQuery: page_obj.prev_num = page_obj.previous_page_number() if page_obj.has_prev else None return page_obj - def no_cache(self): - self._cache_enabled = False - return self + # ---- 魔术方法 ---- - def using(self, alias): - self._qs = self._qs.using(alias) - return self - - def __getitem__(self, key): - """支持切片 [start:end] 和索引 [n]""" + def __getitem__(self, key: Union[int, slice]) -> Any: + """支持切片 ``[start:end]`` 和索引 ``[n]``。切片返回 self,索引返回模型实例。""" if isinstance(key, slice): self._qs = self._qs[key] return self else: - # 单个索引,返回模型实例 return self._qs[key] - def __iter__(self): + def __iter__(self) -> Iterator[_M]: return iter(self._qs) - def __len__(self): + def __len__(self) -> int: return self.count() - def __bool__(self): + def __bool__(self) -> bool: return self.exists() - def __repr__(self): + def __repr__(self) -> str: return f'FluentQuery({self._model.__name__})' -class Session: - def __init__(self): - self._pending_adds = [] - self._pending_deletes = [] - self._tracked = set() +# --------------------------------------------------------------------------- +# Session 会话管理 +# --------------------------------------------------------------------------- - def add(self, obj): +class Session: + """简易会话管理器,支持 add/delete/commit/rollback 操作。 + + 用法:: + + session = db.session + session.add(obj) + session.commit() + """ + + def __init__(self) -> None: + self._pending_adds: List[models.Model] = [] + self._pending_deletes: List[models.Model] = [] + self._tracked: set = set() + + def add(self, obj: models.Model) -> None: + """添加对象到待保存列表。""" self._pending_adds.append(obj) - def delete(self, obj): + def delete(self, obj: models.Model) -> None: + """添加对象到待删除列表。""" self._pending_deletes.append(obj) self._tracked.discard(obj) - def track(self, obj): + def track(self, obj: models.Model) -> None: + """跟踪对象变更(commit 时自动 save)。""" if isinstance(obj, models.Model): self._tracked.add(obj) - def commit(self): - delete_pks = {(obj.__class__, obj.pk) for obj in self._pending_deletes if obj.pk is not None} + def commit(self) -> None: + """提交所有待处理操作(删除 → 新增 → 更新)。""" + delete_pks = {(obj.__class__, obj.pk) for obj in self._pending_deletes + if obj.pk is not None} with transaction.atomic(): for obj in self._pending_deletes: obj.delete() @@ -601,21 +826,23 @@ class Session: try: obj.save() except Exception: - pass + logger.debug('Session.commit: skip tracked obj %s', obj, exc_info=True) self._pending_adds.clear() self._pending_deletes.clear() self._tracked.clear() _invalidate_cache() - def rollback(self): + def rollback(self) -> None: + """回滚所有待处理操作。""" self._pending_adds.clear() self._pending_deletes.clear() self._tracked.clear() _invalidate_cache() - def query(self, *args): - model = None - aggregates = [] + def query(self, *args) -> Optional[FluentQuery[Any]]: + """创建查询构建器,支持传入模型类或聚合表达式。""" + model: Optional[Type[models.Model]] = None + aggregates: List = [] for a in args: if isinstance(a, type) and issubclass(a, models.Model): model = a @@ -632,10 +859,12 @@ class Session: return fq.values(*args) return None - def flush(self): + def flush(self) -> None: + """flush() 的别名,等同于 commit()。""" self.commit() - def execute(self, stmt): + def execute(self, stmt) -> None: + """执行语句(支持 _DeleteStmt 和原始 SQL 字符串)。""" if isinstance(stmt, _DeleteStmt): stmt.execute() elif isinstance(stmt, str): @@ -644,14 +873,31 @@ class Session: cursor.execute(stmt) +# --------------------------------------------------------------------------- +# 全局入口 +# --------------------------------------------------------------------------- + class DB: + """全局数据库操作入口。 + + 用法:: + + from gvsdsdk.fluent import db + + session = db.session + q = db.or_(Model.field == 1, Model.field == 2) + q = db.and_(Model.field > 0, Model.field < 100) + """ + @property - def session(self): + def session(self) -> Session: + """获取当前线程的 Session 实例。""" return _get_session() @staticmethod - def or_(*args): - result = None + def or_(*args: Q) -> Q: + """逻辑 OR,合并多个 Q 对象。""" + result: Optional[Q] = None for q in args: if isinstance(q, Q): if result is None: @@ -661,7 +907,8 @@ class DB: return result if result is not None else Q() @staticmethod - def and_(*args): + def and_(*args: Q) -> Q: + """逻辑 AND,合并多个 Q 对象。""" result = Q() for q in args: if isinstance(q, Q): @@ -672,5 +919,6 @@ class DB: db = DB() -def FQ(model_class): +def FQ(model_class: Type[_M]) -> FluentQuery[_M]: + """FluentQuery 工厂函数,等同于 ``Model.query``。""" return FluentQuery(model_class) diff --git a/gvsdsdk/management/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/management/__pycache__/__init__.cpython-312.pyc index a66ce6e4..176bffe0 100644 Binary files a/gvsdsdk/management/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/management/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/management/commands/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/management/commands/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..216c4bc1 Binary files /dev/null and b/gvsdsdk/management/commands/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/management/commands/__pycache__/gvsdsdk_init.cpython-312.pyc b/gvsdsdk/management/commands/__pycache__/gvsdsdk_init.cpython-312.pyc new file mode 100644 index 00000000..7365e2b5 Binary files /dev/null and b/gvsdsdk/management/commands/__pycache__/gvsdsdk_init.cpython-312.pyc differ diff --git a/gvsdsdk/management/commands/__pycache__/hash_passwords.cpython-312.pyc b/gvsdsdk/management/commands/__pycache__/hash_passwords.cpython-312.pyc new file mode 100644 index 00000000..f814d523 Binary files /dev/null and b/gvsdsdk/management/commands/__pycache__/hash_passwords.cpython-312.pyc differ diff --git a/gvsdsdk/management/commands/hash_passwords.py b/gvsdsdk/management/commands/hash_passwords.py new file mode 100644 index 00000000..b6c1ba3e --- /dev/null +++ b/gvsdsdk/management/commands/hash_passwords.py @@ -0,0 +1,30 @@ +"""将 User 表中明文密码迁移为 bcrypt 哈希""" +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class Command(BaseCommand): + help = '将 User 表中所有明文密码迁移为 bcrypt 哈希' + + def handle(self, *args, **options): + # 筛选非空且非 bcrypt 哈希的密码 + users = User.objects.exclude(UserPassword='') + updated = 0 + skipped = 0 + + for user in users.iterator(): + stored = user.UserPassword or '' + # 已是 bcrypt 哈希则跳过 + if stored.startswith(('$2b$', '$2a$')) and len(stored) == 60: + skipped += 1 + continue + # 明文密码 → bcrypt 哈希 + user.SetPassword(stored) + user.save(update_fields=['UserPassword']) + updated += 1 + + self.stdout.write(self.style.SUCCESS( + f'完成:{updated} 条明文密码已转为 bcrypt,{skipped} 条已是哈希跳过' + )) diff --git a/gvsdsdk/migrations/0001_initial.py b/gvsdsdk/migrations/0001_initial.py index 3a8544f5..f24fe514 100644 --- a/gvsdsdk/migrations/0001_initial.py +++ b/gvsdsdk/migrations/0001_initial.py @@ -1,8 +1,8 @@ -# Generated by Django 6.0.5 on 2026-06-13 11:43 +# Generated by Django 4.2.27 on 2026-06-19 18:42 import datetime -import gvsdsdk.models from django.db import migrations, models +import gvsdsdk.models class Migration(migrations.Migration): @@ -16,9 +16,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='AccountActivation', fields=[ - ('AccountActivationCode', gvsdsdk.models.FixedBinaryField(binary_length=6, db_column='AccountActivationCode', max_length=6, primary_key=True, serialize=False)), - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID')), - ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ('AccountActivationCode', gvsdsdk.models.FixedBinaryField(binary_length=6, db_column='AccountActivationCode', max_length=6, primary_key=True, serialize=False, verbose_name='激活码')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', verbose_name='用户UUID')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ 'verbose_name': '账户激活', @@ -30,13 +30,13 @@ class Migration(migrations.Migration): migrations.CreateModel( name='EphemeralToken', fields=[ - ('ELTUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='ELTUUID', primary_key=True, serialize=False)), - ('ELTUserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='ELTUserUUID', db_index=True)), - ('ELTCreateTime', models.DateTimeField(db_column='ELTCreateTime', default=datetime.datetime.utcnow)), - ('ELTEndTime', models.DateTimeField(db_column='ELTEndTime', default=gvsdsdk.models._elt_end_time_default)), - ('ELTType', models.SmallIntegerField(db_column='ELTType', default=0)), - ('ELTAllowIP', models.JSONField(db_column='ELTAllowIP', default=list)), - ('ELTStatus', models.SmallIntegerField(db_column='ELTStatus', db_index=True, default=1)), + ('ELTUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='ELTUUID', primary_key=True, serialize=False, verbose_name='令牌UUID')), + ('ELTUserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='ELTUserUUID', db_index=True, verbose_name='用户UUID')), + ('ELTCreateTime', models.DateTimeField(db_column='ELTCreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ('ELTEndTime', models.DateTimeField(db_column='ELTEndTime', default=gvsdsdk.models._EltEndTimeDefault, verbose_name='过期时间')), + ('ELTType', models.SmallIntegerField(db_column='ELTType', default=0, verbose_name='令牌类型')), + ('ELTAllowIP', models.JSONField(db_column='ELTAllowIP', default=list, verbose_name='允许IP')), + ('ELTStatus', models.SmallIntegerField(db_column='ELTStatus', db_index=True, default=1, verbose_name='状态')), ], options={ 'verbose_name': '临时令牌', @@ -48,8 +48,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Password', fields=[ - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', primary_key=True, serialize=False)), - ('Password', models.CharField(db_column='Password', max_length=1024)), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', primary_key=True, serialize=False, verbose_name='用户UUID')), + ('Password', models.CharField(db_column='Password', max_length=1024, verbose_name='密码哈希')), ], options={ 'verbose_name': '密码', @@ -61,12 +61,14 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Permission', fields=[ - ('PermUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PermUUID', primary_key=True, serialize=False)), - ('PermName', models.CharField(db_column='PermName', max_length=128)), - ('PermCode', models.CharField(db_column='PermCode', max_length=128)), - ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID')), - ('PermStatus', models.SmallIntegerField(db_column='PermStatus', default=1)), - ('PermDesc', models.CharField(db_column='PermDesc', default='', max_length=512)), + ('PermUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PermUUID', primary_key=True, serialize=False, verbose_name='权限UUID')), + ('TenantUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='TenantUUID', verbose_name='租户UUID')), + ('PermName', models.CharField(db_column='PermName', max_length=128, verbose_name='权限名称')), + ('PermCode', models.CharField(db_column='PermCode', max_length=128, verbose_name='权限键')), + ('PermCategory', models.CharField(db_column='PermCategory', default='', max_length=64, verbose_name='权限分类')), + ('PermStatus', models.SmallIntegerField(db_column='PermStatus', default=1, verbose_name='是否启用')), + ('PermDesc', models.CharField(db_column='PermDesc', default='', max_length=512, verbose_name='描述')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ 'verbose_name': '权限', @@ -75,16 +77,72 @@ class Migration(migrations.Migration): 'managed': True, }, ), + migrations.CreateModel( + name='Position', + fields=[ + ('PositionUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PositionUUID', primary_key=True, serialize=False, verbose_name='职务UUID')), + ('TenantUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='TenantUUID', verbose_name='租户UUID')), + ('PositionName', models.CharField(db_column='PositionName', max_length=128, verbose_name='职务名称')), + ('PositionCode', models.CharField(db_column='PositionCode', default='', max_length=64, verbose_name='职务编码')), + ('PositionLevel', models.SmallIntegerField(db_column='PositionLevel', default=0, verbose_name='职务级别')), + ('PositionStatus', models.SmallIntegerField(db_column='PositionStatus', default=1, verbose_name='状态')), + ('PositionDesc', models.CharField(db_column='PositionDesc', default='', max_length=512, verbose_name='描述')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(db_column='UpdateTime', default=datetime.datetime.utcnow, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '人事职务标签', + 'verbose_name_plural': '人事职务标签', + 'db_table': 'Position', + 'managed': True, + }, + ), + migrations.CreateModel( + name='PositionTemplate', + fields=[ + ('PositionTemplateUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PositionTemplateUUID', primary_key=True, serialize=False, verbose_name='岗位模板UUID')), + ('TenantUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='TenantUUID', verbose_name='租户UUID')), + ('TemplateName', models.CharField(db_column='TemplateName', max_length=128, verbose_name='模板名称')), + ('TemplateCode', models.CharField(db_column='TemplateCode', default='', max_length=64, verbose_name='模板编码')), + ('TemplateStatus', models.SmallIntegerField(db_column='TemplateStatus', default=1, verbose_name='模板状态')), + ('TemplateDesc', models.CharField(db_column='TemplateDesc', default='', max_length=512, verbose_name='模板描述')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(db_column='UpdateTime', default=datetime.datetime.utcnow, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '岗位模板', + 'verbose_name_plural': '岗位模板', + 'db_table': 'PositionTemplate', + 'managed': True, + }, + ), + migrations.CreateModel( + name='PositionTemplatePermission', + fields=[ + ('PTPermUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PTPermUUID', primary_key=True, serialize=False, verbose_name='关联UUID')), + ('PositionTemplateUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PositionTemplateUUID', verbose_name='岗位模板UUID')), + ('PermUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PermUUID', verbose_name='权限UUID')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '岗位模板权限关联', + 'verbose_name_plural': '岗位模板权限关联', + 'db_table': 'PositionTemplatePermission', + 'managed': True, + }, + ), migrations.CreateModel( name='Role', fields=[ - ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID', primary_key=True, serialize=False)), - ('CompanyUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='CompanyUUID')), - ('DeptUUID', gvsdsdk.models.UUIDField(binary_length=16, blank=True, db_column='DeptUUID', null=True)), - ('RoleName', models.CharField(db_column='RoleName', max_length=128)), - ('RoleType', models.SmallIntegerField(db_column='RoleType', default=2)), - ('RoleStatus', models.SmallIntegerField(db_column='RoleStatus', default=1)), - ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID', primary_key=True, serialize=False, verbose_name='角色UUID')), + ('TenantUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='TenantUUID', verbose_name='租户UUID')), + ('RoleName', models.CharField(db_column='RoleName', max_length=128, verbose_name='角色名称')), + ('RoleType', models.SmallIntegerField(db_column='RoleType', default=2, verbose_name='角色类型')), + ('RoleStatus', models.SmallIntegerField(db_column='RoleStatus', default=1, verbose_name='角色状态')), + ('AssignScope', models.SmallIntegerField(db_column='AssignScope', default=0, verbose_name='分配可见范围')), + ('AssignScopeUUID', gvsdsdk.models.UUIDField(binary_length=16, blank=True, db_column='AssignScopeUUID', null=True, verbose_name='分配范围UUID')), + ('RoleDesc', models.CharField(db_column='RoleDesc', default='', max_length=512, verbose_name='角色描述')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ 'verbose_name': '角色', @@ -94,55 +152,69 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='RoleDept', + name='RolePermission', fields=[ - ('RoleDeptUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleDeptUUID', primary_key=True, serialize=False)), - ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID')), - ('DeptUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='DeptUUID')), - ('IsMainDept', models.SmallIntegerField(db_column='IsMainDept', default=1)), + ('RolePermUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RolePermUUID', primary_key=True, serialize=False, verbose_name='关联UUID')), + ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID', verbose_name='角色UUID')), + ('PermUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PermUUID', verbose_name='权限UUID')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ - 'verbose_name': '角色部门关联', - 'verbose_name_plural': '角色部门关联', - 'db_table': 'RoleDept', + 'verbose_name': '角色权限关联', + 'verbose_name_plural': '角色权限关联', + 'db_table': 'RolePermission', 'managed': True, }, ), migrations.CreateModel( - name='User', + name='RoleShop', fields=[ - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', primary_key=True, serialize=False)), - ('UserName', models.CharField(db_column='UserName', max_length=256, unique=True)), - ('UserPassword', models.CharField(db_column='UserPassword', default='', max_length=60)), - ('UserPositionStatus', models.SmallIntegerField(db_column='UserPositionStatus', default=0)), - ('UserPersonalSignature', models.CharField(db_column='UserPersonalSignature', default='', max_length=1024)), - ('UserEmail', models.CharField(db_column='UserEmail', default='', max_length=256)), - ('UserEmailVerify', models.SmallIntegerField(db_column='UserEmailVerify', default=0)), - ('UserAccountLicense', models.SmallIntegerField(db_column='UserAccountLicense', default=1)), - ('UserGender', models.SmallIntegerField(db_column='UserGender', default=-1)), - ('UserTele', models.CharField(blank=True, db_column='UserTele', max_length=16, null=True)), - ('UserCreateTime', models.DateTimeField(db_column='UserCreateTime', default=datetime.datetime.utcnow)), - ('UserLastLoginDate', models.DateTimeField(db_column='UserLastLoginDate', default=datetime.datetime.utcnow)), - ('IsStaff', models.BooleanField(db_column='IsStaff', default=False)), - ('IsSuperuser', models.BooleanField(db_column='IsSuperuser', default=False)), + ('RoleShopUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleShopUUID', primary_key=True, serialize=False, verbose_name='关联UUID')), + ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID', verbose_name='角色UUID')), + ('ShopUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='ShopUUID', verbose_name='商铺UUID')), + ('IsMainShop', models.SmallIntegerField(db_column='IsMainShop', default=0, verbose_name='主商铺')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ - 'verbose_name': '用户', - 'verbose_name_plural': '用户', - 'db_table': 'User', + 'verbose_name': '角色商铺关联', + 'verbose_name_plural': '角色商铺关联', + 'db_table': 'RoleShop', + 'managed': True, + }, + ), + migrations.CreateModel( + name='Shop', + fields=[ + ('ShopUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='ShopUUID', primary_key=True, serialize=False, verbose_name='商铺UUID')), + ('CompanyUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='CompanyUUID', verbose_name='公司UUID')), + ('ShopName', models.CharField(db_column='ShopName', max_length=128, verbose_name='商铺名称')), + ('ShopCode', models.CharField(db_column='ShopCode', default='', max_length=64, verbose_name='商铺编码')), + ('ShopType', models.SmallIntegerField(db_column='ShopType', default=0, verbose_name='商铺类型')), + ('ShopStatus', models.SmallIntegerField(db_column='ShopStatus', default=1, verbose_name='商铺状态')), + ('ShopAddress', models.CharField(db_column='ShopAddress', default='', max_length=512, verbose_name='商铺地址')), + ('ShopContact', models.CharField(db_column='ShopContact', default='', max_length=128, verbose_name='联系人')), + ('ShopPhone', models.CharField(db_column='ShopPhone', default='', max_length=32, verbose_name='联系电话')), + ('ShopDesc', models.CharField(db_column='ShopDesc', default='', max_length=512, verbose_name='描述')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(db_column='UpdateTime', default=datetime.datetime.utcnow, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '商铺', + 'verbose_name_plural': '商铺', + 'db_table': 'Shop', 'managed': True, }, ), migrations.CreateModel( name='User2FA', fields=[ - ('User2FAUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='User2FAUUID', primary_key=True, serialize=False)), - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', db_index=True)), - ('SecretKey', models.CharField(db_column='SecretKey', max_length=64)), - ('IsEnabled', models.SmallIntegerField(db_column='IsEnabled', default=0)), - ('Verified', models.SmallIntegerField(db_column='Verified', default=0)), - ('LastUsedAt', models.DateTimeField(blank=True, db_column='LastUsedAt', null=True)), - ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ('User2FAUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='User2FAUUID', primary_key=True, serialize=False, verbose_name='2FA UUID')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', db_index=True, verbose_name='用户UUID')), + ('SecretKey', models.CharField(db_column='SecretKey', max_length=64, verbose_name='密钥')), + ('IsEnabled', models.SmallIntegerField(db_column='IsEnabled', default=0, verbose_name='是否启用')), + ('Verified', models.SmallIntegerField(db_column='Verified', default=0, verbose_name='已验证')), + ('LastUsedAt', models.DateTimeField(blank=True, db_column='LastUsedAt', null=True, verbose_name='最后使用')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ 'verbose_name': '双因素认证', @@ -154,9 +226,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserABAC', fields=[ - ('UserABACUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserABACUUID', primary_key=True, serialize=False)), - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID')), - ('HierarchicalPermissionIdentifiers', models.CharField(db_column='HierarchicalPermissionIdentifiers', max_length=256)), + ('UserABACUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserABACUUID', primary_key=True, serialize=False, verbose_name='ABAC UUID')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', verbose_name='用户UUID')), + ('HierarchicalPermissionIdentifiers', models.CharField(db_column='HierarchicalPermissionIdentifiers', max_length=256, verbose_name='层级权限标识')), ], options={ 'verbose_name': '用户ABAC', @@ -168,10 +240,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserAvatar', fields=[ - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', primary_key=True, serialize=False)), - ('UserAvatar', models.BinaryField(db_column='UserAvatar', editable=True)), - ('AvatarUploadTime', models.DateTimeField(db_column='AvatarUploadTime', default=datetime.datetime.utcnow)), - ('AvatarFileType', models.CharField(db_column='AvatarFileType', default='png', max_length=16)), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', primary_key=True, serialize=False, verbose_name='用户UUID')), + ('UserAvatar', models.BinaryField(db_column='UserAvatar', editable=True, verbose_name='头像数据')), + ('AvatarUploadTime', models.DateTimeField(db_column='AvatarUploadTime', default=datetime.datetime.utcnow, verbose_name='上传时间')), + ('AvatarFileType', models.CharField(db_column='AvatarFileType', default='png', max_length=16, verbose_name='文件类型')), ], options={ 'verbose_name': '用户头像', @@ -180,16 +252,33 @@ class Migration(migrations.Migration): 'managed': True, }, ), + migrations.CreateModel( + name='UserDept', + fields=[ + ('UserDeptUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserDeptUUID', primary_key=True, serialize=False, verbose_name='关联UUID')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', verbose_name='用户UUID')), + ('DeptUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='DeptUUID', verbose_name='部门UUID')), + ('PositionUUID', gvsdsdk.models.UUIDField(binary_length=16, blank=True, db_column='PositionUUID', null=True, verbose_name='职务标签UUID')), + ('IsMainDept', models.SmallIntegerField(db_column='IsMainDept', default=1, verbose_name='是否主部门')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '员工部门关联', + 'verbose_name_plural': '员工部门关联', + 'db_table': 'UserDept', + 'managed': True, + }, + ), migrations.CreateModel( name='UserLoginLog', fields=[ - ('LogUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='LogUUID', primary_key=True, serialize=False)), - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', db_index=True)), - ('LoginIP', models.CharField(db_column='LoginIP', max_length=39)), - ('LoginPlatform', models.CharField(db_column='LoginPlatform', max_length=32)), - ('LoginStatus', models.SmallIntegerField(db_column='LoginStatus')), - ('LoginTime', models.DateTimeField(db_column='LoginTime', default=datetime.datetime.utcnow)), - ('UserAgent', models.CharField(db_column='UserAgent', default='', max_length=512)), + ('LogUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='LogUUID', primary_key=True, serialize=False, verbose_name='日志UUID')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', db_index=True, verbose_name='用户UUID')), + ('LoginIP', models.CharField(db_column='LoginIP', max_length=39, verbose_name='登录IP')), + ('LoginPlatform', models.CharField(db_column='LoginPlatform', max_length=32, verbose_name='登录平台')), + ('LoginStatus', models.SmallIntegerField(db_column='LoginStatus', verbose_name='登录状态')), + ('LoginTime', models.DateTimeField(db_column='LoginTime', default=datetime.datetime.utcnow, verbose_name='登录时间')), + ('UserAgent', models.CharField(db_column='UserAgent', default='', max_length=512, verbose_name='用户代理')), ], options={ 'verbose_name': '登录日志', @@ -201,10 +290,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserPending2FA', fields=[ - ('PendingUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PendingUUID', primary_key=True, serialize=False)), - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', unique=True)), - ('SecretKey', models.CharField(db_column='SecretKey', max_length=64)), - ('ExpireAt', models.DateTimeField(db_column='ExpireAt')), + ('PendingUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='PendingUUID', primary_key=True, serialize=False, verbose_name='待确认UUID')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', unique=True, verbose_name='用户UUID')), + ('SecretKey', models.CharField(db_column='SecretKey', max_length=64, verbose_name='密钥')), + ('ExpireAt', models.DateTimeField(db_column='ExpireAt', verbose_name='过期时间')), ], options={ 'verbose_name': '2FA待确认', @@ -216,10 +305,11 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserRole', fields=[ - ('UserRoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserRoleUUID', primary_key=True, serialize=False)), - ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID')), - ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID')), - ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ('UserRoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserRoleUUID', primary_key=True, serialize=False, verbose_name='关联UUID')), + ('UserUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', verbose_name='用户UUID')), + ('RoleUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='RoleUUID', verbose_name='角色UUID')), + ('CompanyUUID', gvsdsdk.models.UUIDField(binary_length=16, db_column='CompanyUUID', verbose_name='公司UUID')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), ], options={ 'verbose_name': '用户角色关联', diff --git a/gvsdsdk/migrations/0002_alter_user_useruuid.py b/gvsdsdk/migrations/0002_alter_user_useruuid.py new file mode 100644 index 00000000..58f20034 --- /dev/null +++ b/gvsdsdk/migrations/0002_alter_user_useruuid.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.27 on 2026-06-19 01:54 + +from django.db import migrations +import gvsdsdk.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('gvsdsdk', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='UserUUID', + field=gvsdsdk.models.UUIDField(binary_length=16, db_column='UserUUID', default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='用户UUID'), + ), + ] diff --git a/gvsdsdk/migrations/__pycache__/0001_initial.cpython-312.pyc b/gvsdsdk/migrations/__pycache__/0001_initial.cpython-312.pyc index 7fbb292b..995e49f7 100644 Binary files a/gvsdsdk/migrations/__pycache__/0001_initial.cpython-312.pyc and b/gvsdsdk/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/gvsdsdk/migrations/__pycache__/0002_alter_user_useruuid.cpython-312.pyc b/gvsdsdk/migrations/__pycache__/0002_alter_user_useruuid.cpython-312.pyc new file mode 100644 index 00000000..5b65e9fb Binary files /dev/null and b/gvsdsdk/migrations/__pycache__/0002_alter_user_useruuid.cpython-312.pyc differ diff --git a/gvsdsdk/model_base.py b/gvsdsdk/model_base.py index 078f05d7..360cf16e 100644 --- a/gvsdsdk/model_base.py +++ b/gvsdsdk/model_base.py @@ -1,52 +1,86 @@ +"""QModel 兼容层基类模块 + +提供 SQLAlchemy 风格的链式 OOP 查询接口,使 Django ORM 支持: + +- ``Model.query.filter(...)`` — 链式 FluentQuery 查询 +- ``Model.field_name`` — 类级别返回 FieldExpression(支持 ``==``, ``>``, ``in_()`` 等运算符) +- ``Model.field_name`` — 实例级别返回字段值(与原生 Django Model 一致) +- ``Model[condition]`` — 快捷 filter 语法 + +典型用法:: + + # 链式查询 + orders = Dingdan.query.filter(zhuangtai=8).order_by('-create_time').to_list() + + # 字段表达式 + Dingdan.query.filter(Dingdan.jine > 100, Dingdan.zhuangtai.in_([1, 2, 3])) + + # 聚合 + Dingdan.query.aggregate(total=Sum('jine')) + + # 分页 + Dingdan.query.paginate(page=1, per_page=20) +""" +from __future__ import annotations + +import re +from typing import Any, Callable, Dict, Generic, Optional, Tuple, Type, TypeVar, Union + +try: + from typing import Self +except ImportError: + from typing_extensions import Self + from django.db import models from django.db.models import Q +from .fluent import FluentQuery -class classproperty: - def __init__(self, func): + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- + +#: classproperty 返回值类型变量 +_RT = TypeVar('_RT') + +#: QModel 子类类型变量(仅用于 __class_getitem__ 兼容) +_QM = TypeVar('_QM', bound='QModel') + + +class classproperty(Generic[_RT]): + """类属性描述符,支持 ``Model.query`` 等类级别属性访问。 + + 泛型参数 ``_RT`` 传播函数返回类型,使 Pylance 能穿透描述符推断 + ``Model.query`` 的返回类型为 ``FluentQuery[Self]``。 + """ + + def __init__(self, func: Callable[[type], _RT]) -> None: self.fget = func - def __get__(self, obj, objtype=None): + def __get__(self, obj: Any, objtype: Optional[type] = None) -> _RT: return self.fget(objtype if objtype is not None else type(obj)) -ABBREVIATIONS = { - 'uuid': 'UUID', - 'id': 'ID', - 'ip': 'IP', - 'url': 'URL', - 'uri': 'URI', - 'api': 'API', - 'html': 'HTML', - 'css': 'CSS', - 'js': 'JS', - 'json': 'JSON', - 'xml': 'XML', - 'sql': 'SQL', - 'http': 'HTTP', - 'https': 'HTTPS', - 'smtp': 'SMTP', - 'ssl': 'SSL', - 'tls': 'TLS', - 'ssh': 'SSH', - 'ftp': 'FTP', - '2fa': '2FA', - 'elt': 'ELT', - 'sve': 'SVE', - 't4': 'T4', - 'mc': 'MC', - 'crb': 'CRB', - 'ccgo': 'CCGO', - 'ugm': 'UGM', - 'gerp': 'GERP', - 'abac': 'ABAC', - 'rbac': 'RBAC', - 'smtm': 'SMTM', - 'pwd': 'PWD', +ABBREVIATIONS: Dict[str, str] = { + 'uuid': 'UUID', 'id': 'ID', 'ip': 'IP', 'url': 'URL', 'uri': 'URI', + 'api': 'API', 'html': 'HTML', 'css': 'CSS', 'js': 'JS', 'json': 'JSON', + 'xml': 'XML', 'sql': 'SQL', 'http': 'HTTP', 'https': 'HTTPS', + 'smtp': 'SMTP', 'ssl': 'SSL', 'tls': 'TLS', 'ssh': 'SSH', 'ftp': 'FTP', + '2fa': '2FA', 'elt': 'ELT', 'sve': 'SVE', 't4': 'T4', 'mc': 'MC', + 'crb': 'CRB', 'ccgo': 'CCGO', 'ugm': 'UGM', 'gerp': 'GERP', + 'abac': 'ABAC', 'rbac': 'RBAC', 'smtm': 'SMTM', 'pwd': 'PWD', } -def snake_to_pascal(name): +def snake_to_pascal(name: str) -> str: + """将 snake_case 转换为 PascalCase,自动识别常见缩写。 + + >>> snake_to_pascal('user_uuid') + 'UserUUID' + >>> snake_to_pascal('paifa_dingdan_shu') + 'PaifaDingdanShu' + """ if '_' not in name: return name[0].upper() + name[1:] if name else name parts = name.split('_') @@ -60,46 +94,74 @@ def snake_to_pascal(name): return ''.join(result) -def pascal_to_snake(name): - import re +def pascal_to_snake(name: str) -> str: + """将 PascalCase 转换为 snake_case。 + + >>> pascal_to_snake('UserUUID') + 'user_uuid' + """ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) - parts = s2.lower().split('_') - return '_'.join(parts) + return s2.lower() +# --------------------------------------------------------------------------- +# 字段表达式(类级别访问 Model.field 时返回) +# --------------------------------------------------------------------------- + class _InvertedFieldExpression: - def __init__(self, field_expr): + """取反字段表达式,由 ``~Model.field`` 产生,支持 ``~field.startswith(...)`` 等否定查询。""" + + def __init__(self, field_expr: FieldExpression) -> None: self._field_expr = field_expr - def startswith(self, value): + def startswith(self, value: str) -> Q: return ~Q(**{f'{self._field_expr._django_name}__startswith': value}) - def contains(self, value): + def contains(self, value: str) -> Q: return ~Q(**{f'{self._field_expr._django_name}__contains': value}) - def like(self, pattern): + def like(self, pattern: str) -> Q: clean = pattern.strip('%') return ~Q(**{f'{self._field_expr._django_name}__icontains': clean}) - def in_(self, values): + def in_(self, values) -> Q: return ~Q(**{f'{self._field_expr._django_name}__in': values}) - def is_(self, other): + def is_(self, other) -> Q: if other is None: return ~Q(**{f'{self._field_expr._django_name}__isnull': True}) return ~Q(**{self._field_expr._django_name: other}) + def between(self, low, high) -> Q: + return ~(Q(**{f'{self._field_expr._django_name}__gte': low}) + & Q(**{f'{self._field_expr._django_name}__lte': high})) + + def __repr__(self) -> str: + return f'~{self._field_expr!r}' + class FieldExpression: - _resolve_cache = {} + """字段表达式,由类级别 ``Model.field_name`` 访问产生。 - def __init__(self, model_class, field_name): + 支持通过 Python 运算符构建 Django Q 对象:: + + Model.field == value → Q(field=value) + Model.field > value → Q(field__gt=value) + Model.field.in_([1,2,3]) → Q(field__in=[1,2,3]) + Model.field.between(a, b) → Q(field__gte=a) & Q(field__lte=b) + ~Model.field → _InvertedFieldExpression(否定查询) + """ + + _resolve_cache: Dict[Tuple[str, str], str] = {} + + def __init__(self, model_class: Type, field_name: str) -> None: self.model_class = model_class self.field_name = field_name - self._django_name = self._resolve_django_name() + self._django_name: str = self._resolve_django_name() - def _resolve_django_name(self): + def _resolve_django_name(self) -> str: + """将 Python 字段名解析为 Django ORM 查询使用的字段名。""" cache_key = (self.model_class.__name__, self.field_name) if cache_key in FieldExpression._resolve_cache: return FieldExpression._resolve_cache[cache_key] @@ -111,221 +173,232 @@ class FieldExpression: FieldExpression._resolve_cache[cache_key] = self.field_name return self.field_name - def __eq__(self, other): + # ---- 比较运算符 ---- + + def __eq__(self, other) -> Q: # type: ignore[override] if isinstance(other, FieldExpression): return NotImplemented if other is None: return Q(**{f'{self._django_name}__isnull': True}) return Q(**{self._django_name: other}) - def __ne__(self, other): + def __ne__(self, other) -> Q: # type: ignore[override] if isinstance(other, FieldExpression): return NotImplemented return ~Q(**{self._django_name: other}) - def __gt__(self, other): + def __gt__(self, other) -> Q: return Q(**{f'{self._django_name}__gt': other}) - def __ge__(self, other): + def __ge__(self, other) -> Q: return Q(**{f'{self._django_name}__gte': other}) - def __lt__(self, other): + def __lt__(self, other) -> Q: return Q(**{f'{self._django_name}__lt': other}) - def __le__(self, other): + def __le__(self, other) -> Q: return Q(**{f'{self._django_name}__lte': other}) - def __getitem__(self, key): - return _JsonKeyExpression(self._django_name, key) - - def __hash__(self): + def __hash__(self) -> int: return hash((self.model_class.__name__, self.field_name)) - def __and__(self, other): + # ---- 逻辑运算符 ---- + + def __and__(self, other) -> Q: if isinstance(other, Q): return Q(self) & other + if isinstance(other, FieldExpression): + return Q(self) & Q(other) return NotImplemented - def __or__(self, other): + def __or__(self, other) -> Q: if isinstance(other, Q): return Q(self) | other + if isinstance(other, FieldExpression): + return Q(self) | Q(other) return NotImplemented - def __invert__(self): + def __invert__(self) -> _InvertedFieldExpression: return _InvertedFieldExpression(self) - def is_(self, other): + # ---- 查询方法 ---- + + def is_(self, other) -> Q: + """``field IS NULL`` 或 ``field IS value``。""" if other is None: return Q(**{f'{self._django_name}__isnull': True}) return Q(**{self._django_name: other}) - def is_not(self, other): + def is_not(self, other) -> Q: + """``field IS NOT NULL`` 或 ``field IS NOT value``。""" if other is None: return Q(**{f'{self._django_name}__isnull': False}) return ~Q(**{self._django_name: other}) - def like(self, pattern): + def like(self, pattern: str) -> Q: + """模糊匹配(不区分大小写),自动去除 ``%`` 通配符。""" clean = pattern.strip('%') return Q(**{f'{self._django_name}__icontains': clean}) - def contains(self, value): + def contains(self, value: str) -> Q: + """包含子串(区分大小写)。""" return Q(**{f'{self._django_name}__contains': value}) - def ilike(self, pattern): + def ilike(self, pattern: str) -> Q: + """like() 的别名。""" return self.like(pattern) - def startswith(self, value): + def startswith(self, value: str) -> Q: + """前缀匹配(区分大小写)。""" return Q(**{f'{self._django_name}__startswith': value}) - def istartswith(self, value): + def istartswith(self, value: str) -> Q: + """前缀匹配(不区分大小写)。""" return Q(**{f'{self._django_name}__istartswith': value}) - def endswith(self, value): + def endswith(self, value: str) -> Q: + """后缀匹配(区分大小写)。""" return Q(**{f'{self._django_name}__endswith': value}) - def iendswith(self, value): + def iendswith(self, value: str) -> Q: + """后缀匹配(不区分大小写)。""" return Q(**{f'{self._django_name}__iendswith': value}) - def in_(self, values): + def in_(self, values) -> Q: + """``field IN (values)``,支持列表和 FluentQuery 子查询。""" if hasattr(values, '_qs'): return Q(**{f'{self._django_name}__in': values._qs.values(self._django_name)}) return Q(**{f'{self._django_name}__in': values}) - def between(self, low, high): + def between(self, low, high) -> Q: + """``field BETWEEN low AND high``。""" return Q(**{f'{self._django_name}__gte': low}) & Q(**{f'{self._django_name}__lte': high}) - def regex(self, pattern): + def regex(self, pattern: str) -> Q: + """正则匹配(区分大小写)。""" return Q(**{f'{self._django_name}__regex': pattern}) - def iregex(self, pattern): + def iregex(self, pattern: str) -> Q: + """正则匹配(不区分大小写)。""" return Q(**{f'{self._django_name}__iregex': pattern}) - def desc(self): + # ---- 排序辅助 ---- + + def desc(self) -> str: + """返回降序排序字符串 ``'-field_name'``。""" return f'-{self._django_name}' - def asc(self): + def asc(self) -> str: + """返回升序排序字符串 ``'field_name'``。""" return self._django_name - def __repr__(self): + # ---- JSON 字段访问 ---- + + def __getitem__(self, key: str) -> _JsonKeyExpression: + """访问 JSON 字段内的键,如 ``Model.meta['key'] == value``。""" + return _JsonKeyExpression(self._django_name, key) + + # ---- 显示 ---- + + def __repr__(self) -> str: return f'FieldExpr({self.model_class.__name__}.{self.field_name})' - def __str__(self): + def __str__(self) -> str: return self._django_name class _JsonKeyExpression: - def __init__(self, field_name, key): + """JSON 字段键表达式,由 ``Model.json_field['key']`` 产生。""" + + def __init__(self, field_name: str, key: str) -> None: self.field_name = field_name self.key = key - def _lookup(self, suffix=''): + def _lookup(self, suffix: str = '') -> str: if suffix: return f'{self.field_name}__{self.key}__{suffix}' return f'{self.field_name}__{self.key}' - def cast(self, type_hint): + def cast(self, type_hint) -> _CastJsonExpression: + """将 JSON 值转换为指定类型后再比较。""" return _CastJsonExpression(self.field_name, self.key, type_hint) - def __eq__(self, other): + def __eq__(self, other) -> Q: # type: ignore[override] return Q(**{self._lookup(): other}) - def __ne__(self, other): + def __ne__(self, other) -> Q: # type: ignore[override] return ~Q(**{self._lookup(): other}) - def __gt__(self, other): + def __gt__(self, other) -> Q: return Q(**{self._lookup('gt'): other}) - def __ge__(self, other): + def __ge__(self, other) -> Q: return Q(**{self._lookup('gte'): other}) - def __lt__(self, other): + def __lt__(self, other) -> Q: return Q(**{self._lookup('lt'): other}) - def __le__(self, other): + def __le__(self, other) -> Q: return Q(**{self._lookup('lte'): other}) - def between(self, low, high): + def between(self, low, high) -> Q: return Q(**{self._lookup('gte'): low}) & Q(**{self._lookup('lte'): high}) - def __repr__(self): + def __repr__(self) -> str: return f'JsonKeyExpr({self.field_name}[{self.key!r}])' class _CastJsonExpression: - def __init__(self, field_name, key, type_hint): + """类型转换后的 JSON 键表达式。""" + + def __init__(self, field_name: str, key: str, type_hint) -> None: self.field_name = field_name self.key = key self.type_hint = type_hint - def _lookup(self, suffix=''): + def _lookup(self, suffix: str = '') -> str: if suffix: return f'{self.field_name}__{self.key}__{suffix}' return f'{self.field_name}__{self.key}' - def between(self, low, high): - return Q(**{self._lookup('gte'): low}) & Q(**{self._lookup('lte'): high}) - - def __eq__(self, other): + def __eq__(self, other) -> Q: # type: ignore[override] return Q(**{self._lookup(): other}) - def __ne__(self, other): + def __ne__(self, other) -> Q: # type: ignore[override] return ~Q(**{self._lookup(): other}) - def __gt__(self, other): + def __gt__(self, other) -> Q: return Q(**{self._lookup('gt'): other}) - def __ge__(self, other): + def __ge__(self, other) -> Q: return Q(**{self._lookup('gte'): other}) - def __lt__(self, other): + def __lt__(self, other) -> Q: return Q(**{self._lookup('lt'): other}) - def __le__(self, other): + def __le__(self, other) -> Q: return Q(**{self._lookup('lte'): other}) - def __repr__(self): + def between(self, low, high) -> Q: + return Q(**{self._lookup('gte'): low}) & Q(**{self._lookup('lte'): high}) + + def __repr__(self) -> str: type_name = getattr(self.type_hint, '__name__', str(self.type_hint)) return f'CastJsonExpr({self.field_name}[{self.key!r}]->{type_name})' -class ForeignKeyIdDescriptor: - def __init__(self, fk_name): - self.fk_name = fk_name - self.id_attr = fk_name + '_id' - - def __get__(self, obj, objtype=None): - if obj is None: - return FieldExpression(objtype, self.id_attr) - return getattr(obj, self.id_attr) - - def __set__(self, obj, value): - setattr(obj, self.id_attr, value) - - -class FieldExpressionDescriptor: - def __init__(self, field_name): - self.field_name = field_name - - def __get__(self, obj, objtype=None): - if obj is None: - return FieldExpression(objtype, self.field_name) - return getattr(obj, self.field_name) - - def __set__(self, obj, value): - setattr(obj, self.field_name, value) - +# --------------------------------------------------------------------------- +# QModel 元类与基类 +# --------------------------------------------------------------------------- class QModelBase(models.base.ModelBase): - def __new__(mcs, name, bases, namespace, **kwargs): - from django.db import models as dj_models - for attr_name, attr_val in list(namespace.items()): - if isinstance(attr_val, dj_models.Field) and not attr_val.db_column: - # ForeignKey/OneToOneField 的数据库列名是 attr_name + '_id' - if isinstance(attr_val, (dj_models.ForeignKey, dj_models.OneToOneField)): - attr_val.db_column = attr_name + '_id' - else: - attr_val.db_column = attr_name + """QModel 元类,负责: + 1. 根据 ``GVSDSDK_MANAGE_TABLES`` 设置动态控制 ``managed`` + 2. 包装字段描述符,使类级别访问返回 FieldExpression + """ + + def __new__(mcs, name, bases, namespace, **kwargs): # 动态设置 managed:子服务器可设 GVSDSDK_MANAGE_TABLES=True 让 gvsdsdk 管理表 meta = namespace.get('Meta') if meta and getattr(meta, 'app_label', None) == 'gvsdsdk': @@ -336,16 +409,132 @@ class QModelBase(models.base.ModelBase): except Exception: pass - return super().__new__(mcs, name, bases, namespace, **kwargs) + cls = super().__new__(mcs, name, bases, namespace, **kwargs) + + # 模型创建后,包装字段描述符以支持类级别 FieldExpression + if not cls._meta.abstract: + _wrap_field_descriptors(cls) + + return cls + + +def _wrap_field_descriptors(cls: Type) -> None: + """包装 Django 字段描述符,使类级别访问返回 FieldExpression, + 实例级别完全委托给 Django 原生描述符。 + + Django 的字段描述符(DeferredAttribute / ForwardManyToOneDescriptor): + - ``__get__(None, cls)`` → 返回字段对象本身(类级别) + - ``__get__(instance, cls)`` → 返回字段值(实例级别) + + 我们包装后: + - ``__get__(None, cls)`` → 返回 FieldExpression(类级别,用于链式 OOP) + - ``__get__(instance, cls)`` → 委托给原生描述符(实例级别,正常行为) + """ + from django.db.models.query_utils import DeferredAttribute + from django.db.models.fields.related_descriptors import ( + ForwardManyToOneDescriptor, + ForwardOneToOneDescriptor, + ) + + for field in cls._meta.local_fields: + fname = field.name + if fname.startswith('_'): + continue + + original_desc = cls.__dict__.get(fname) + if original_desc is None: + continue + + # 只包装已知的 Django 描述符类型 + if isinstance(original_desc, (DeferredAttribute, ForwardManyToOneDescriptor, + ForwardOneToOneDescriptor)): + wrapper = _FieldExpressionWrapper(original_desc, cls, fname) + setattr(cls, fname, wrapper) + + +class _FieldExpressionWrapper: + """包装 Django 字段描述符,类级别返回 FieldExpression,实例级别委托给原生描述符。 + + 这是 QModel 字段表达式机制的核心:当你在类上访问 ``Model.field_name`` 时, + 返回 ``FieldExpression`` 对象,支持 ``Model.field == value`` 等运算符构建 Q 对象; + 当你在实例上访问 ``obj.field_name`` 时,行为与原生 Django 完全一致。 + """ + + def __init__(self, original_descriptor, model_class: Type, field_name: str) -> None: + self._original = original_descriptor + self._model = model_class + self._field_name = field_name + self._has_set: bool = hasattr(original_descriptor, '__set__') + + def __get__(self, obj, objtype=None): + if obj is None: + # 类级别访问:返回 FieldExpression + return FieldExpression(self._model, self._field_name) + # 实例级别访问:委托给原生 Django 描述符 + return self._original.__get__(obj, objtype) + + def __set__(self, obj, value) -> None: + if self._has_set: + # FK/O2O 描述符有 __set__ + self._original.__set__(obj, value) + else: + # 普通字段(DeferredAttribute)没有 __set__,直接写 __dict__ + obj.__dict__[self._field_name] = value + + def __repr__(self) -> str: + return f'_FieldExpressionWrapper({self._model.__name__}.{self._field_name})' class QModel(models.Model, metaclass=QModelBase): + """QModel 兼容层基类,提供 SQLAlchemy 风格的链式 OOP 查询接口。 + + 所有继承 QModel 的模型自动获得: + + - ``Model.query`` : 返回 FluentQuery,支持链式查询 + - ``Model.field_name`` : 类级别访问返回 FieldExpression,支持表达式查询 + - ``Model.field_name`` : 实例级别访问返回字段值(与普通 Django Model 一致) + - ``Model[condition]`` : 快捷 filter 语法 + + 用法示例:: + + # 链式查询 + orders = Dingdan.query.filter(zhuangtai=8).order_by('-create_time').to_list() + + # 字段表达式 + Dingdan.query.filter(Dingdan.jine > 100, Dingdan.zhuangtai.in_([1,2,3])) + + # 聚合 + Dingdan.query.aggregate(total=Sum('jine')) + + # values / annotate + Dingdan.query.values('zhuangtai').annotate(cnt=Count('id')) + + # 分页 + Dingdan.query.paginate(page=1, per_page=20) + + # 快捷 filter + Dingdan[Dingdan.zhuangtai == 8].order_by('-create_time').to_list() + """ + class Meta: abstract = True @classproperty - def query(cls): - from .fluent import FluentQuery + def query(cls) -> FluentQuery[Self]: + """返回 FluentQuery 兼容层查询构建器,支持链式 OOP 查询。""" return FluentQuery(cls) + @classmethod + def __class_getitem__(cls, item) -> FluentQuery[Self]: + """支持 ``Model[condition]`` 语法,返回 filter 后的 FluentQuery。 + 用法:: + + Dingdan[Dingdan.zhuangtai == 8].order_by('-create_time').to_list() + Dingdan[{'zhuangtai': 8}].to_list() + """ + if isinstance(item, Q): + return cls.query.filter(item) + if isinstance(item, dict): + return cls.query.filter(**item) + return cls.query.filter(item) diff --git a/gvsdsdk/models.py b/gvsdsdk/models.py index 310a117f..20262f94 100644 --- a/gvsdsdk/models.py +++ b/gvsdsdk/models.py @@ -1,813 +1,826 @@ -"""GVS-DSDK 数据模型模块 - -将 SAAS 主服务器的用户表(User)、权限表(Permission)、角色表(Role)、 -用户角色关联表(UserRole)、角色权限关联表(RolePermission)、 -岗位模板表(PositionTemplate)、岗位模板权限关联表(PositionTemplatePermission)、 -人事职务标签表(Position)、员工部门关联表(UserDept)封装为独立的 Django ORM 模型, -供子服务器直接进行跨服务器 ORM 查询。 - -权限体系四大分层: - 1. 组织人事层:Tenant → Company → Department,员工通过 UserDept 归属部门 - 2. 岗位模板层:PositionTemplate + PositionTemplatePermission,权限初始化快照 - 3. 角色权限载体层:Role + RolePermission,运行时鉴权实体 - 4. 鉴权规则层:操作权限校验(角色汇总)+ 数据权限校验(组织属性) - -使用方式(子服务器): - from gvsdsdk.models import User, Permission, Role, UserRole, RolePermission - - # 查询所有启用的用户 - users = User.objects.using('saas').filter(UserAccountLicense=1) - - # 或配置 DatabaseRouter 后直接使用 - users = User.objects.filter(UserAccountLicense=1) - -设计原则: - 1. 独立:不依赖 gvsds 内部模块,可在任意 Django 项目中独立使用 - 2. 非托管:managed=False,不创建/迁移表,仅映射已有表结构 - 3. PascalCase:字段名、方法名遵循帕斯卡命名法 - 4. 兼容:db_table、db_column 与 SAAS 主库完全一致 - 5. 租户隔离:所有权限相关数据依附于独立租户空间,不同租户互相不可见 -""" - -import uuid as _uuid -import datetime as _datetime -from django.db import models -from django.contrib.auth.models import BaseUserManager -from gvsdsdk.model_base import QModel -from gvsdsdk.model_utils import UUIDObj - - -# === UUID 字段 === - -class FixedBinaryField(models.BinaryField): - """定长二进制字段:映射 MySQL BINARY(N),可用于主键""" - - description = "Fixed-length binary field" - - def __init__(self, *args, **kwargs): - self.binary_length = kwargs.pop('binary_length', 16) - kwargs.setdefault('max_length', self.binary_length) - kwargs.setdefault('editable', False) - super().__init__(*args, **kwargs) - - def db_type(self, connection): - if connection.vendor == 'mysql': - return f'BINARY({self.binary_length})' - elif connection.vendor == 'sqlite': - return 'BLOB' - elif connection.vendor == 'postgresql': - return 'BYTEA' - return f'BINARY({self.binary_length})' - - def rel_db_type(self, connection): - return self.db_type(connection) - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - kwargs['binary_length'] = self.binary_length - return name, path, args, kwargs - - -class UUIDField(FixedBinaryField): - """UUID 字段:数据库存储为 BINARY(16),Python 侧自动转换 str/bytes/uuid.UUID""" - - description = "UUID stored as BINARY(16)" - - def __init__(self, *args, **kwargs): - kwargs.setdefault('max_length', 16) - kwargs.setdefault('editable', False) - super().__init__(*args, **kwargs) - - def db_type(self, connection): - vendor = connection.vendor - if vendor == 'mysql': - return 'BINARY(16)' - elif vendor == 'sqlite': - return 'BLOB' - elif vendor == 'postgresql': - return 'BYTEA' - return 'BINARY(16)' - - def from_db_value(self, value, expression, connection): - if value is None: - return None - if isinstance(value, UUIDObj): - return value - return UUIDObj(value) - - def get_prep_value(self, value): - if value is None: - return None - if isinstance(value, UUIDObj): - return value.bytes - if isinstance(value, str): - try: - return _uuid.UUID(value).bytes - except ValueError: - return None - if isinstance(value, _uuid.UUID): - return value.bytes - if isinstance(value, bytes): - return value - return None - - def get_prep_lookup(self, lookup_type, value): - if lookup_type in ('exact', 'iexact', 'in'): - if value is None: - return None - if lookup_type == 'in' and isinstance(value, (list, tuple)): - return [self.get_prep_value(v) for v in value] - return self.get_prep_value(value) - return super().get_prep_lookup(lookup_type, value) - - def value_to_string(self, obj): - value = self.value_from_object(obj) - if isinstance(value, UUIDObj): - return value.str or '' - if isinstance(value, str): - return value - if isinstance(value, bytes) and len(value) == 16: - return str(_uuid.UUID(bytes=value)) - if isinstance(value, _uuid.UUID): - return str(value) - return '' - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - del kwargs['max_length'] - return name, path, args, kwargs - - -# === QModel 基础模型 === - -# === 用户管理器 === - -class UserManager(BaseUserManager): - """用户管理器——提供用户创建与超级用户创建。 - 方法名保留蛇形命名以兼容 Django 内部约定。""" - - def create_user(self, username, password=None, email='', **extra_fields): - """创建普通用户""" - if not username: - raise ValueError('The Username field must be set') - user = self.model( - UserUUID=_uuid.uuid4().bytes, - UserName=username, - UserEmail=email or '', - **extra_fields - ) - if password: - user.SetPassword(password) - user.save(using=self._db) - return user - - def create_superuser(self, username, password=None, **extra_fields): - """创建超级用户""" - extra_fields.setdefault('UserAccountLicense', 1) - extra_fields.setdefault('UserPositionStatus', 0) - extra_fields.setdefault('IsStaff', True) - extra_fields.setdefault('IsSuperuser', True) - return self.create_user(username, password, **extra_fields) - - -# === 用户表 === -class User(QModel): - """用户表——系统核心身份模型(映射 SAAS 主库 User 表)""" - - UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') - UserName = models.CharField(max_length=256, unique=True, db_column='UserName', verbose_name='用户名') - UserPassword = models.CharField(max_length=60, default='', db_column='UserPassword', verbose_name='密码') - UserPositionStatus = models.SmallIntegerField(default=0, db_column='UserPositionStatus', verbose_name='职位状态') - UserPersonalSignature = models.CharField(max_length=1024, default='', db_column='UserPersonalSignature', verbose_name='个人签名') - UserEmail = models.CharField(max_length=256, default='', db_column='UserEmail', verbose_name='邮箱') - UserEmailVerify = models.SmallIntegerField(default=0, db_column='UserEmailVerify', verbose_name='邮箱验证') - UserAccountLicense = models.SmallIntegerField(default=1, db_column='UserAccountLicense', verbose_name='账户许可') - UserGender = models.SmallIntegerField(default=-1, db_column='UserGender', verbose_name='性别') - UserTele = models.CharField(max_length=16, null=True, blank=True, db_column='UserTele', verbose_name='电话') - UserCreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UserCreateTime', verbose_name='创建时间') - UserLastLoginDate = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UserLastLoginDate', verbose_name='最后登录') - IsStaff = models.BooleanField(default=False, db_column='IsStaff', verbose_name='是否员工') - IsSuperuser = models.BooleanField(default=False, db_column='IsSuperuser', verbose_name='是否超级用户') - - objects = UserManager() - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'User' - verbose_name = '用户' - verbose_name_plural = '用户' - - def __str__(self): - return self.UserName - - @property - def UUIDStr(self): - """返回 UUID 字符串形式""" - return UUIDObj(self.UserUUID).str - - @property - def IsActive(self): - """账户是否启用(UserAccountLicense == 1)""" - return self.UserAccountLicense == 1 - - @property - def IsOnJob(self): - """是否在职(UserPositionStatus == 0)""" - return self.UserPositionStatus == 0 - - @property - def IsAnonymous(self): - return False - - @property - def IsAuthenticated(self): - return True - - @property - def PK(self): - return UUIDObj(self.UserUUID) - - @property - def ID(self): - return UUIDObj(self.UserUUID) - - def SetPassword(self, RawPassword): - """设置密码——bcrypt 哈希""" - import bcrypt - salt = bcrypt.gensalt(rounds=12) - hashed = bcrypt.hashpw(RawPassword.encode('utf-8'), salt) - self.UserPassword = hashed.decode('utf-8') - - def CheckPassword(self, RawPassword): - """验证密码""" - import bcrypt - try: - return bcrypt.checkpw( - RawPassword.encode('utf-8'), - self.UserPassword.encode('utf-8') - ) - except Exception: - return False - - def GetTele(self): - """获取电话,空则返回默认文本""" - return self.UserTele or '未填写' - - def GetCompanies(self): - """获取用户所属公司列表""" - # Company 模型不在 gvsdsdk 中,此处返回空 QuerySet - # 如需使用,请在子服务器中自行实现 - return [] - - def GetRoles(self): - """获取用户角色列表(需在同一 database 连接下使用)""" - user_role_uuids = UserRole.objects.filter( - UserUUID=self.UserUUID - ).values_list('RoleUUID', flat=True) - return Role.objects.filter(RoleUUID__in=user_role_uuids) - - def GetDepartments(self): - """获取用户部门列表""" - # Department 模型不在 gvsdsdk 中,此处返回空列表 - # 如需使用,请在子服务器中自行实现 - return [] - - def HasPermission(self, PermCode): - """检查用户是否拥有指定权限(需在同一 database 连接下使用) - - 通过 UserRole → RolePermission → Permission 链路查询, - 汇总所有角色的操作权限后判断。 - """ - user_role_uuids = UserRole.objects.filter( - UserUUID=self.UserUUID - ).values_list('RoleUUID', flat=True) - perm_uuids = RolePermission.objects.filter( - RoleUUID__in=user_role_uuids - ).values_list('PermUUID', flat=True) - return Permission.objects.filter( - PermUUID__in=perm_uuids, - PermCode=PermCode, - PermStatus=1, - ).exists() - - # Django auth 框架兼容(内部使用蛇形命名) - @property - def is_active(self): - return self.IsActive - - @property - def is_anonymous(self): - return self.IsAnonymous - - @property - def is_authenticated(self): - return self.IsAuthenticated - - @property - def pk(self): - return self.PK - - @property - def id(self): - return self.ID - - def set_password(self, raw_password): - self.SetPassword(raw_password) - - def check_password(self, raw_password): - return self.CheckPassword(raw_password) - - -# === 用户头像表 === - -class UserAvatar(QModel): - """用户头像表(映射 SAAS 主库 UserAvatar 表)""" - - UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') - UserAvatar = models.BinaryField(editable=True, db_column='UserAvatar', verbose_name='头像数据') - AvatarUploadTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='AvatarUploadTime', verbose_name='上传时间') - AvatarFileType = models.CharField(max_length=16, default='png', db_column='AvatarFileType', verbose_name='文件类型') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserAvatar' - verbose_name = '用户头像' - verbose_name_plural = '用户头像' - - -# === 用户双因素认证表 === - -class User2FA(QModel): - """用户双因素认证表(映射 SAAS 主库 User2FA 表)""" - - User2FAUUID = UUIDField(primary_key=True, db_column='User2FAUUID', verbose_name='2FA UUID') - UserUUID = UUIDField(db_index=True, db_column='UserUUID', verbose_name='用户UUID') - SecretKey = models.CharField(max_length=64, db_column='SecretKey', verbose_name='密钥') - IsEnabled = models.SmallIntegerField(default=0, db_column='IsEnabled', verbose_name='是否启用') - Verified = models.SmallIntegerField(default=0, db_column='Verified', verbose_name='已验证') - LastUsedAt = models.DateTimeField(null=True, blank=True, db_column='LastUsedAt', verbose_name='最后使用') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'User2FA' - verbose_name = '双因素认证' - verbose_name_plural = '双因素认证' - - -# === 双因素认证待验证表 === - -class UserPending2FA(QModel): - """双因素认证待验证表(映射 SAAS 主库 UserPending2FA 表)""" - - PendingUUID = UUIDField(primary_key=True, db_column='PendingUUID', verbose_name='待确认UUID') - UserUUID = UUIDField(unique=True, db_column='UserUUID', verbose_name='用户UUID') - SecretKey = models.CharField(max_length=64, db_column='SecretKey', verbose_name='密钥') - ExpireAt = models.DateTimeField(db_column='ExpireAt', verbose_name='过期时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserPending2FA' - verbose_name = '2FA待确认' - verbose_name_plural = '2FA待确认' - - -# === 临时令牌表 === - -def _EltEndTimeDefault(): - from django.conf import settings - days = getattr(settings, 'ELT_TOKEN_LIFETIME_DAYS', 30) - return _datetime.datetime.utcnow() + _datetime.timedelta(days=days) - - -class EphemeralToken(QModel): - """临时令牌表——用于 API 认证与会话管理(映射 SAAS 主库 EphemeralToken 表)""" - - ELTUUID = UUIDField(primary_key=True, db_column='ELTUUID', verbose_name='令牌UUID') - ELTUserUUID = UUIDField(db_index=True, db_column='ELTUserUUID', verbose_name='用户UUID') - ELTCreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='ELTCreateTime', verbose_name='创建时间') - ELTEndTime = models.DateTimeField(default=_EltEndTimeDefault, db_column='ELTEndTime', verbose_name='过期时间') - ELTType = models.SmallIntegerField(default=0, db_column='ELTType', verbose_name='令牌类型') - ELTAllowIP = models.JSONField(default=list, db_column='ELTAllowIP', verbose_name='允许IP') - ELTStatus = models.SmallIntegerField(default=1, db_index=True, db_column='ELTStatus', verbose_name='状态') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'EphemeralToken' - verbose_name = '临时令牌' - verbose_name_plural = '临时令牌' - - def IsExpired(self): - """检查令牌是否过期""" - return _datetime.datetime.utcnow() > self.ELTEndTime.replace(tzinfo=None) - - def IsValid(self): - """检查令牌是否有效""" - return self.ELTStatus == 1 and not self.IsExpired() - - -# === 用户登录日志表 === - -class UserLoginLog(QModel): - """用户登录日志表(映射 SAAS 主库 UserLoginLog 表)""" - - LogUUID = UUIDField(primary_key=True, db_column='LogUUID', verbose_name='日志UUID') - UserUUID = UUIDField(db_index=True, db_column='UserUUID', verbose_name='用户UUID') - LoginIP = models.CharField(max_length=39, db_column='LoginIP', verbose_name='登录IP') - LoginPlatform = models.CharField(max_length=32, db_column='LoginPlatform', verbose_name='登录平台') - LoginStatus = models.SmallIntegerField(db_column='LoginStatus', verbose_name='登录状态') - LoginTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='LoginTime', verbose_name='登录时间') - UserAgent = models.CharField(max_length=512, default='', db_column='UserAgent', verbose_name='用户代理') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserLoginLog' - verbose_name = '登录日志' - verbose_name_plural = '登录日志' - - -# === 账户激活码表 === - -class AccountActivation(QModel): - """账户激活码表(映射 SAAS 主库 AccountActivation 表)""" - - AccountActivationCode = FixedBinaryField(primary_key=True, binary_length=6, db_column='AccountActivationCode', verbose_name='激活码') - UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'AccountActivation' - verbose_name = '账户激活' - verbose_name_plural = '账户激活' - - -# === 角色表 === - -class Role(QModel): - """角色表——权限分配单元(映射 SAAS 主库 Role 表) - - 角色是真正承载操作权限的实体,在单个租户内部独立存在, - 不依附任何公司或部门。通过 AssignScope 控制角色的分配可见范围: - - 0:全租户管理员可分发 - - 1:仅指定公司管理员可分发(AssignScopeUUID = CompanyUUID) - - 2:仅指定部门管理员可分发(AssignScopeUUID = DeptUUID) - """ - - RoleUUID = UUIDField(primary_key=True, db_column='RoleUUID', verbose_name='角色UUID') - TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') - RoleName = models.CharField(max_length=128, db_column='RoleName', verbose_name='角色名称') - RoleType = models.SmallIntegerField(default=2, db_column='RoleType', verbose_name='角色类型') - RoleStatus = models.SmallIntegerField(default=1, db_column='RoleStatus', verbose_name='角色状态') - AssignScope = models.SmallIntegerField(default=0, db_column='AssignScope', verbose_name='分配可见范围') - AssignScopeUUID = UUIDField(null=True, blank=True, db_column='AssignScopeUUID', verbose_name='分配范围UUID') - RoleDesc = models.CharField(max_length=512, default='', db_column='RoleDesc', verbose_name='角色描述') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'Role' - verbose_name = '角色' - verbose_name_plural = '角色' - - def __str__(self): - return self.RoleName - - def GetPermissions(self): - """获取角色拥有的权限列表(通过 RolePermission 中间表)""" - perm_uuids = RolePermission.objects.filter( - RoleUUID=self.RoleUUID - ).values_list('PermUUID', flat=True) - return Permission.objects.filter(PermUUID__in=perm_uuids, PermStatus=1) - - def GetUsers(self): - """获取拥有该角色的用户列表""" - user_uuids = UserRole.objects.filter( - RoleUUID=self.RoleUUID - ).values_list('UserUUID', flat=True) - return User.objects.filter(UserUUID__in=user_uuids) - - -# === 权限表 === - -class Permission(QModel): - """权限表——操作权限定义(映射 SAAS 主库 Permission 表) - - 独立的权限定义表,租户内全局可用,不再挂在角色下。 - 通过 RolePermission 中间表与角色建立多对多关系, - 通过 PositionTemplatePermission 中间表与岗位模板建立多对多关系。 - """ - - PermUUID = UUIDField(primary_key=True, db_column='PermUUID', verbose_name='权限UUID') - TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') - PermName = models.CharField(max_length=128, db_column='PermName', verbose_name='权限名称') - PermCode = models.CharField(max_length=128, db_column='PermCode', verbose_name='权限键') - PermCategory = models.CharField(max_length=64, default='', db_column='PermCategory', verbose_name='权限分类') - PermStatus = models.SmallIntegerField(default=1, db_column='PermStatus', verbose_name='是否启用') - PermDesc = models.CharField(max_length=512, default='', db_column='PermDesc', verbose_name='描述') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'Permission' - verbose_name = '权限' - verbose_name_plural = '权限' - - def __str__(self): - return f"{self.PermName} ({self.PermCode})" - - def GetRoles(self): - """获取拥有该权限的角色列表""" - role_uuids = RolePermission.objects.filter( - PermUUID=self.PermUUID - ).values_list('RoleUUID', flat=True) - return Role.objects.filter(RoleUUID__in=role_uuids) - - -# === 用户角色关联表 === - -class UserRole(QModel): - """用户角色关联表——用户与角色的多对多映射(映射 SAAS 主库 UserRole 表) - - 通过 CompanyUUID 标识该用户-角色关联所属的公司, - 用于菜单按公司差异化显示、数据权限按公司过滤等场景。 - 同一用户在不同公司下可绑定不同角色。 - - 注意:UserUUID 和 RoleUUID 在 gvsds 中是 ForeignKey, - 但此处改为 UUIDField 以保持独立性。 - """ - - UserRoleUUID = UUIDField(primary_key=True, db_column='UserRoleUUID', verbose_name='关联UUID') - # 原 gvsds 中为 ForeignKey(User),此处改为 UUIDField - UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') - # 原 gvsds 中为 ForeignKey('Role'),此处改为 UUIDField - RoleUUID = UUIDField(db_column='RoleUUID', verbose_name='角色UUID') - CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserRole' - verbose_name = '用户角色关联' - verbose_name_plural = '用户角色关联' - - -# === 角色权限关联表 === - -class RolePermission(QModel): - """角色权限关联表——角色与操作权限的多对多映射(映射 SAAS 主库 RolePermission 表) - - 一个角色可以拥有多个权限,一个权限也可以被多个角色引用。 - 角色和具体操作权限为多对多绑定关系,一套权限集合可以打包在一个角色内统一维护。 - """ - - RolePermUUID = UUIDField(primary_key=True, db_column='RolePermUUID', verbose_name='关联UUID') - RoleUUID = UUIDField(db_column='RoleUUID', verbose_name='角色UUID') - PermUUID = UUIDField(db_column='PermUUID', verbose_name='权限UUID') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'RolePermission' - verbose_name = '角色权限关联' - verbose_name_plural = '角色权限关联' - - -# === 用户 ABAC 表 === - -class UserABAC(QModel): - """用户 ABAC 表——基于属性的访问控制(映射 SAAS 主库 UserABAC 表)""" - - UserABACUUID = UUIDField(primary_key=True, db_column='UserABACUUID', verbose_name='ABAC UUID') - UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') - HierarchicalPermissionIdentifiers = models.CharField(max_length=256, db_column='HierarchicalPermissionIdentifiers', verbose_name='层级权限标识') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserABAC' - verbose_name = '用户ABAC' - verbose_name_plural = '用户ABAC' - - -# === 密码表 === - -class Password(QModel): - """密码表——独立密码存储(映射 SAAS 主库 Password 表)""" - - UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') - Password = models.CharField(max_length=1024, db_column='Password', verbose_name='密码哈希') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'Password' - verbose_name = '密码' - verbose_name_plural = '密码' - - -# === 商铺表 === - -class Shop(QModel): - """商铺表——SAAS 多商铺体系中的商铺实体 - - 每个商铺属于一个公司(CompanyUUID),可关联多个角色(通过 RoleShop)。 - """ - - ShopUUID = UUIDField(primary_key=True, db_column='ShopUUID', verbose_name='商铺UUID') - CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID') - ShopName = models.CharField(max_length=128, db_column='ShopName', verbose_name='商铺名称') - ShopCode = models.CharField(max_length=64, default='', db_column='ShopCode', verbose_name='商铺编码') - ShopType = models.SmallIntegerField(default=0, db_column='ShopType', verbose_name='商铺类型') - ShopStatus = models.SmallIntegerField(default=1, db_column='ShopStatus', verbose_name='商铺状态') - ShopAddress = models.CharField(max_length=512, default='', db_column='ShopAddress', verbose_name='商铺地址') - ShopContact = models.CharField(max_length=128, default='', db_column='ShopContact', verbose_name='联系人') - ShopPhone = models.CharField(max_length=32, default='', db_column='ShopPhone', verbose_name='联系电话') - ShopDesc = models.CharField(max_length=512, default='', db_column='ShopDesc', verbose_name='描述') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - UpdateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UpdateTime', verbose_name='更新时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'Shop' - verbose_name = '商铺' - verbose_name_plural = '商铺' - - def __str__(self): - return self.ShopName - - def GetRoles(self): - """获取商铺关联的所有角色""" - role_uuids = RoleShop.objects.filter( - ShopUUID=self.ShopUUID - ).values_list('RoleUUID', flat=True) - return Role.objects.filter(RoleUUID__in=role_uuids) - - -# === 角色商铺关联表 === - -class RoleShop(QModel): - """角色商铺关联表——角色与商铺的多对多映射 - - 一个角色可以管理多个商铺,一个商铺也可以被多个角色管理。 - """ - - RoleShopUUID = UUIDField(primary_key=True, db_column='RoleShopUUID', verbose_name='关联UUID') - RoleUUID = UUIDField(db_column='RoleUUID', verbose_name='角色UUID') - ShopUUID = UUIDField(db_column='ShopUUID', verbose_name='商铺UUID') - IsMainShop = models.SmallIntegerField(default=0, db_column='IsMainShop', verbose_name='主商铺') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'RoleShop' - verbose_name = '角色商铺关联' - verbose_name_plural = '角色商铺关联' - - -# === 岗位模板表 === - -class PositionTemplate(QModel): - """岗位模板表——权限初始化快照模板(映射 SAAS 主库 PositionTemplate 表) - - 租户内全局可重复使用,定位是权限初始化快照模板, - 不直接分配给员工、不参与运行时鉴权。 - 新建角色时可一键复制模板内的全部权限内容生成新角色。 - 模板后续调整只会作用于之后新创建的角色, - 已有角色不会同步变更,实现模板复用和存量权限独立管控互不干扰。 - """ - - PositionTemplateUUID = UUIDField(primary_key=True, db_column='PositionTemplateUUID', verbose_name='岗位模板UUID') - TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') - TemplateName = models.CharField(max_length=128, db_column='TemplateName', verbose_name='模板名称') - TemplateCode = models.CharField(max_length=64, default='', db_column='TemplateCode', verbose_name='模板编码') - TemplateStatus = models.SmallIntegerField(default=1, db_column='TemplateStatus', verbose_name='模板状态') - TemplateDesc = models.CharField(max_length=512, default='', db_column='TemplateDesc', verbose_name='模板描述') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - UpdateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UpdateTime', verbose_name='更新时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'PositionTemplate' - verbose_name = '岗位模板' - verbose_name_plural = '岗位模板' - - def __str__(self): - return self.TemplateName - - def GetPermissions(self): - """获取模板关联的权限列表""" - perm_uuids = PositionTemplatePermission.objects.filter( - PositionTemplateUUID=self.PositionTemplateUUID - ).values_list('PermUUID', flat=True) - return Permission.objects.filter(PermUUID__in=perm_uuids, PermStatus=1) - - def CopyToRole(self, RoleName, TenantUUID): - """从模板复制权限到新角色(一次性复制,后续模板变更不影响已有角色)""" - role = Role.objects.create( - RoleUUID=_uuid.uuid4().bytes, - TenantUUID=TenantUUID, - RoleName=RoleName, - ) - template_perms = PositionTemplatePermission.objects.filter( - PositionTemplateUUID=self.PositionTemplateUUID - ) - for tp in template_perms: - RolePermission.objects.create( - RolePermUUID=_uuid.uuid4().bytes, - RoleUUID=role.RoleUUID, - PermUUID=tp.PermUUID, - ) - return role - - -# === 岗位模板权限关联表 === - -class PositionTemplatePermission(QModel): - """岗位模板权限关联表——岗位模板与操作权限的多对多映射 - - 仅作为复制源头,不参与运行时鉴权。 - 新建角色时一键复制模板权限到角色,复制后模板与角色无运行时绑定。 - """ - - PTPermUUID = UUIDField(primary_key=True, db_column='PTPermUUID', verbose_name='关联UUID') - PositionTemplateUUID = UUIDField(db_column='PositionTemplateUUID', verbose_name='岗位模板UUID') - PermUUID = UUIDField(db_column='PermUUID', verbose_name='权限UUID') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'PositionTemplatePermission' - verbose_name = '岗位模板权限关联' - verbose_name_plural = '岗位模板权限关联' - - -# === 人事职务标签表 === - -class Position(QModel): - """人事职务标签表——仅用于人事管理标注(映射 SAAS 主库 Position 表) - - 员工会标注对应的职务标签,该标签仅为人事标注信息, - 不直接绑定权限,和角色、权限体系完全解耦。 - 人员调岗、升职只修改组织归属和人事职务标签,权限只需增删绑定角色即可,互不干扰。 - """ - - PositionUUID = UUIDField(primary_key=True, db_column='PositionUUID', verbose_name='职务UUID') - TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') - PositionName = models.CharField(max_length=128, db_column='PositionName', verbose_name='职务名称') - PositionCode = models.CharField(max_length=64, default='', db_column='PositionCode', verbose_name='职务编码') - PositionLevel = models.SmallIntegerField(default=0, db_column='PositionLevel', verbose_name='职务级别') - PositionStatus = models.SmallIntegerField(default=1, db_column='PositionStatus', verbose_name='状态') - PositionDesc = models.CharField(max_length=512, default='', db_column='PositionDesc', verbose_name='描述') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - UpdateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UpdateTime', verbose_name='更新时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'Position' - verbose_name = '人事职务标签' - verbose_name_plural = '人事职务标签' - - def __str__(self): - return self.PositionName - - -# === 员工部门关联表 === - -class UserDept(QModel): - """员工部门关联表——员工与部门的多对多映射(映射 SAAS 主库 UserDept 表) - - 支持一人多部门兼职,同时标注该员工在对应部门的职务标签。 - 该层级仅用于组织架构展示、审批流转、人员统计, - 同时为数据权限判断提供人员所属组织属性依据,不参与操作权限校验。 - """ - - UserDeptUUID = UUIDField(primary_key=True, db_column='UserDeptUUID', verbose_name='关联UUID') - UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') - DeptUUID = UUIDField(db_column='DeptUUID', verbose_name='部门UUID') - PositionUUID = UUIDField(null=True, blank=True, db_column='PositionUUID', verbose_name='职务标签UUID') - IsMainDept = models.SmallIntegerField(default=1, db_column='IsMainDept', verbose_name='是否主部门') - CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserDept' - verbose_name = '员工部门关联' - verbose_name_plural = '员工部门关联' +"""GVS-DSDK 数据模型模块 + +将 SAAS 主服务器的用户表(User)、权限表(Permission)、角色表(Role)、 +用户角色关联表(UserRole)、角色权限关联表(RolePermission)、 +岗位模板表(PositionTemplate)、岗位模板权限关联表(PositionTemplatePermission)、 +人事职务标签表(Position)、员工部门关联表(UserDept)封装为独立的 Django ORM 模型, +供子服务器直接进行跨服务器 ORM 查询。 + +权限体系四大分层: + 1. 组织人事层:Tenant → Company → Department,员工通过 UserDept 归属部门 + 2. 岗位模板层:PositionTemplate + PositionTemplatePermission,权限初始化快照 + 3. 角色权限载体层:Role + RolePermission,运行时鉴权实体 + 4. 鉴权规则层:操作权限校验(角色汇总)+ 数据权限校验(组织属性) + +使用方式(子服务器): + from gvsdsdk.models import Permission, Role, UserRole, RolePermission + + # 查询所有启用的用户(需在业务子类中定义具体 User 模型) + users = User.objects.filter(UserAccountLicense=1) + + # 或配置 DatabaseRouter 后直接使用 + users = User.objects.filter(UserAccountLicense=1) + +设计原则: + 1. 独立:不依赖 gvsds 内部模块,可在任意 Django 项目中独立使用 + 2. 非托管:managed=False,不创建/迁移表,仅映射已有表结构 + 3. PascalCase:字段名、方法名遵循帕斯卡命名法 + 4. 兼容:db_table、db_column 与 SAAS 主库完全一致 + 5. 租户隔离:所有权限相关数据依附于独立租户空间,不同租户互相不可见 + +注意:User 为抽象基类,仅包含 SAAS 主库标准字段。 + 业务子类应继承 User 并在单独文件中添加业务字段和方法。 + 参见 gvsdsdk/business_models.py。 +""" + +import uuid as _uuid +import datetime as _datetime +from django.db import models +from django.contrib.auth.models import BaseUserManager +from gvsdsdk.model_base import QModel +from gvsdsdk.model_utils import UUIDObj + + +# === UUID 字段 === + +class FixedBinaryField(models.BinaryField): + """定长二进制字段:映射 MySQL BINARY(N),可用于主键""" + + description = "Fixed-length binary field" + + def __init__(self, *args, **kwargs): + self.binary_length = kwargs.pop('binary_length', 16) + kwargs.setdefault('max_length', self.binary_length) + kwargs.setdefault('editable', False) + super().__init__(*args, **kwargs) + + def db_type(self, connection): + if connection.vendor == 'mysql': + return f'BINARY({self.binary_length})' + elif connection.vendor == 'sqlite': + return 'BLOB' + elif connection.vendor == 'postgresql': + return 'BYTEA' + return f'BINARY({self.binary_length})' + + def rel_db_type(self, connection): + return self.db_type(connection) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs['binary_length'] = self.binary_length + return name, path, args, kwargs + + +class UUIDField(FixedBinaryField): + """UUID 字段:数据库存储为 BINARY(16),Python 侧自动转换 str/bytes/uuid.UUID""" + + description = "UUID stored as BINARY(16)" + + def __init__(self, *args, **kwargs): + kwargs.setdefault('max_length', 16) + kwargs.setdefault('editable', False) + super().__init__(*args, **kwargs) + + def db_type(self, connection): + vendor = connection.vendor + if vendor == 'mysql': + return 'BINARY(16)' + elif vendor == 'sqlite': + return 'BLOB' + elif vendor == 'postgresql': + return 'BYTEA' + return 'BINARY(16)' + + def from_db_value(self, value, expression, connection): + if value is None: + return None + if isinstance(value, UUIDObj): + return value + return UUIDObj(value) + + def get_prep_value(self, value): + if value is None: + return None + if isinstance(value, UUIDObj): + return value.bytes + if isinstance(value, str): + try: + return _uuid.UUID(value).bytes + except ValueError: + return None + if isinstance(value, _uuid.UUID): + return value.bytes + if isinstance(value, bytes): + return value + return None + + def get_prep_lookup(self, lookup_type, value): + if lookup_type in ('exact', 'iexact', 'in'): + if value is None: + return None + if lookup_type == 'in' and isinstance(value, (list, tuple)): + return [self.get_prep_value(v) for v in value] + return self.get_prep_value(value) + return super().get_prep_lookup(lookup_type, value) + + def value_to_string(self, obj): + value = self.value_from_object(obj) + if isinstance(value, UUIDObj): + return value.str or '' + if isinstance(value, str): + return value + if isinstance(value, bytes) and len(value) == 16: + return str(_uuid.UUID(bytes=value)) + if isinstance(value, _uuid.UUID): + return str(value) + return '' + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + del kwargs['max_length'] + return name, path, args, kwargs + + +# === QModel 基础模型 === + +# === 用户管理器 === + +class UserManager(BaseUserManager): + """用户管理器——提供用户创建与超级用户创建。 + 方法名保留蛇形命名以兼容 Django 内部约定。""" + + def create_user(self, username, password=None, email='', **extra_fields): + """创建普通用户""" + if not username: + raise ValueError('The Username field must be set') + user = self.model( + UserUUID=_uuid.uuid4().bytes, + UserName=username, + UserEmail=email or '', + **extra_fields + ) + if password: + user.SetPassword(password) + user.save(using=self._db) + return user + + def create_superuser(self, username, password=None, **extra_fields): + """创建超级用户""" + extra_fields.setdefault('UserAccountLicense', 1) + extra_fields.setdefault('UserPositionStatus', 0) + extra_fields.setdefault('IsStaff', True) + extra_fields.setdefault('IsSuperuser', True) + return self.create_user(username, password, **extra_fields) + + +# === 用户表 === +class User(QModel): + """用户表——系统核心身份模型(抽象基类,映射 SAAS 主库 User 表标准字段) + + 本类为抽象基类,仅包含 SAAS 主库 User 表的标准字段和方法。 + 业务子类应继承本类,在单独文件中添加业务字段和方法。 + 参见 gvsdsdk/business_models.py。 + """ + + UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID', default=_uuid.uuid4) + UserName = models.CharField(max_length=256, unique=True, db_column='UserName', verbose_name='用户名') + UserPassword = models.CharField(max_length=60, default='', db_column='UserPassword', verbose_name='密码') + UserPositionStatus = models.SmallIntegerField(default=0, db_column='UserPositionStatus', verbose_name='职位状态') + UserPersonalSignature = models.CharField(max_length=1024, default='', db_column='UserPersonalSignature', verbose_name='个人签名') + UserEmail = models.CharField(max_length=256, default='', db_column='UserEmail', verbose_name='邮箱') + UserEmailVerify = models.SmallIntegerField(default=0, db_column='UserEmailVerify', verbose_name='邮箱验证') + UserAccountLicense = models.SmallIntegerField(default=1, db_column='UserAccountLicense', verbose_name='账户许可') + UserGender = models.SmallIntegerField(default=-1, db_column='UserGender', verbose_name='性别') + UserTele = models.CharField(max_length=16, null=True, blank=True, db_column='UserTele', verbose_name='电话') + UserCreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UserCreateTime', verbose_name='创建时间') + UserLastLoginDate = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UserLastLoginDate', verbose_name='最后登录') + + # Django auth 框架必需(由具体子类覆盖) + USERNAME_FIELD = 'UserName' + REQUIRED_FIELDS = [] + + class Meta: + abstract = True + + def __str__(self): + return self.UserName + + @property + def UUIDStr(self): + """返回 UUID 字符串形式""" + return UUIDObj(self.UserUUID).str + + @property + def IsActive(self): + """账户是否启用(UserAccountLicense == 1)""" + return self.UserAccountLicense == 1 + + @property + def IsOnJob(self): + """是否在职(UserPositionStatus == 0)""" + return self.UserPositionStatus == 0 + + @property + def IsAnonymous(self): + return False + + @property + def IsAuthenticated(self): + return True + + @property + def PK(self): + return UUIDObj(self.UserUUID) + + @property + def ID(self): + return UUIDObj(self.UserUUID) + + def SetPassword(self, RawPassword): + """设置密码——bcrypt 哈希""" + import bcrypt + salt = bcrypt.gensalt(rounds=12) + hashed = bcrypt.hashpw(RawPassword.encode('utf-8'), salt) + self.UserPassword = hashed.decode('utf-8') + + def CheckPassword(self, RawPassword): + """验证密码——兼容 bcrypt 哈希和明文存储""" + import bcrypt + stored = self.UserPassword or '' + # bcrypt 哈希以 $2b$ 或 $2a$ 开头,长度固定 60 + if stored.startswith(('$2b$', '$2a$')) and len(stored) == 60: + try: + return bcrypt.checkpw( + RawPassword.encode('utf-8'), + stored.encode('utf-8') + ) + except Exception: + return False + else: + # 明文密码(旧数据兼容) + return stored == RawPassword + + def GetTele(self): + """获取电话,空则返回默认文本""" + return self.UserTele or '未填写' + + def GetCompanies(self): + """获取用户所属公司列表""" + # Company 模型不在 gvsdsdk 中,此处返回空 QuerySet + # 如需使用,请在子服务器中自行实现 + return [] + + def GetRoles(self): + """获取用户角色列表(需在同一 database 连接下使用)""" + user_role_uuids = UserRole.objects.filter( + UserUUID=self.UserUUID + ).values_list('RoleUUID', flat=True) + return Role.objects.filter(RoleUUID__in=user_role_uuids) + + def GetDepartments(self): + """获取用户部门列表""" + # Department 模型不在 gvsdsdk 中,此处返回空列表 + # 如需使用,请在子服务器中自行实现 + return [] + + def HasPermission(self, PermCode): + """检查用户是否拥有指定权限(需在同一 database 连接下使用) + + 通过 UserRole → RolePermission → Permission 链路查询, + 汇总所有角色的操作权限后判断。 + """ + user_role_uuids = UserRole.objects.filter( + UserUUID=self.UserUUID + ).values_list('RoleUUID', flat=True) + perm_uuids = RolePermission.objects.filter( + RoleUUID__in=user_role_uuids + ).values_list('PermUUID', flat=True) + return Permission.objects.filter( + PermUUID__in=perm_uuids, + PermCode=PermCode, + PermStatus=1, + ).exists() + + # ── Django auth 框架必需属性 ── + @property + def is_active(self): + return self.IsActive + + @property + def is_anonymous(self): + return self.IsAnonymous + + @property + def is_authenticated(self): + return self.IsAuthenticated + + @property + def pk(self): + return self.PK + + @property + def id(self): + return self.ID + + def set_password(self, raw_password): + self.SetPassword(raw_password) + + def check_password(self, raw_password): + return self.CheckPassword(raw_password) + + +# === 用户头像表 === + +class UserAvatar(QModel): + """用户头像表(映射 SAAS 主库 UserAvatar 表)""" + + UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') + UserAvatar = models.BinaryField(editable=True, db_column='UserAvatar', verbose_name='头像数据') + AvatarUploadTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='AvatarUploadTime', verbose_name='上传时间') + AvatarFileType = models.CharField(max_length=16, default='png', db_column='AvatarFileType', verbose_name='文件类型') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'UserAvatar' + verbose_name = '用户头像' + verbose_name_plural = '用户头像' + + +# === 用户双因素认证表 === + +class User2FA(QModel): + """用户双因素认证表(映射 SAAS 主库 User2FA 表)""" + + User2FAUUID = UUIDField(primary_key=True, db_column='User2FAUUID', verbose_name='2FA UUID') + UserUUID = UUIDField(db_index=True, db_column='UserUUID', verbose_name='用户UUID') + SecretKey = models.CharField(max_length=64, db_column='SecretKey', verbose_name='密钥') + IsEnabled = models.SmallIntegerField(default=0, db_column='IsEnabled', verbose_name='是否启用') + Verified = models.SmallIntegerField(default=0, db_column='Verified', verbose_name='已验证') + LastUsedAt = models.DateTimeField(null=True, blank=True, db_column='LastUsedAt', verbose_name='最后使用') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'User2FA' + verbose_name = '双因素认证' + verbose_name_plural = '双因素认证' + + +# === 双因素认证待验证表 === + +class UserPending2FA(QModel): + """双因素认证待验证表(映射 SAAS 主库 UserPending2FA 表)""" + + PendingUUID = UUIDField(primary_key=True, db_column='PendingUUID', verbose_name='待确认UUID') + UserUUID = UUIDField(unique=True, db_column='UserUUID', verbose_name='用户UUID') + SecretKey = models.CharField(max_length=64, db_column='SecretKey', verbose_name='密钥') + ExpireAt = models.DateTimeField(db_column='ExpireAt', verbose_name='过期时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'UserPending2FA' + verbose_name = '2FA待确认' + verbose_name_plural = '2FA待确认' + + +# === 临时令牌表 === + +def _EltEndTimeDefault(): + from django.conf import settings + days = getattr(settings, 'ELT_TOKEN_LIFETIME_DAYS', 30) + return _datetime.datetime.utcnow() + _datetime.timedelta(days=days) + + +class EphemeralToken(QModel): + """临时令牌表——用于 API 认证与会话管理(映射 SAAS 主库 EphemeralToken 表)""" + + ELTUUID = UUIDField(primary_key=True, db_column='ELTUUID', verbose_name='令牌UUID') + ELTUserUUID = UUIDField(db_index=True, db_column='ELTUserUUID', verbose_name='用户UUID') + ELTCreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='ELTCreateTime', verbose_name='创建时间') + ELTEndTime = models.DateTimeField(default=_EltEndTimeDefault, db_column='ELTEndTime', verbose_name='过期时间') + ELTType = models.SmallIntegerField(default=0, db_column='ELTType', verbose_name='令牌类型') + ELTAllowIP = models.JSONField(default=list, db_column='ELTAllowIP', verbose_name='允许IP') + ELTStatus = models.SmallIntegerField(default=1, db_index=True, db_column='ELTStatus', verbose_name='状态') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'EphemeralToken' + verbose_name = '临时令牌' + verbose_name_plural = '临时令牌' + + def IsExpired(self): + """检查令牌是否过期""" + return _datetime.datetime.utcnow() > self.ELTEndTime.replace(tzinfo=None) + + def IsValid(self): + """检查令牌是否有效""" + return self.ELTStatus == 1 and not self.IsExpired() + + +# === 用户登录日志表 === + +class UserLoginLog(QModel): + """用户登录日志表(映射 SAAS 主库 UserLoginLog 表)""" + + LogUUID = UUIDField(primary_key=True, db_column='LogUUID', verbose_name='日志UUID') + UserUUID = UUIDField(db_index=True, db_column='UserUUID', verbose_name='用户UUID') + LoginIP = models.CharField(max_length=39, db_column='LoginIP', verbose_name='登录IP') + LoginPlatform = models.CharField(max_length=32, db_column='LoginPlatform', verbose_name='登录平台') + LoginStatus = models.SmallIntegerField(db_column='LoginStatus', verbose_name='登录状态') + LoginTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='LoginTime', verbose_name='登录时间') + UserAgent = models.CharField(max_length=512, default='', db_column='UserAgent', verbose_name='用户代理') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'UserLoginLog' + verbose_name = '登录日志' + verbose_name_plural = '登录日志' + + +# === 账户激活码表 === + +class AccountActivation(QModel): + """账户激活码表(映射 SAAS 主库 AccountActivation 表)""" + + AccountActivationCode = FixedBinaryField(primary_key=True, binary_length=6, db_column='AccountActivationCode', verbose_name='激活码') + UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'AccountActivation' + verbose_name = '账户激活' + verbose_name_plural = '账户激活' + + +# === 角色表 === + +class Role(QModel): + """角色表——权限分配单元(映射 SAAS 主库 Role 表) + + 角色是真正承载操作权限的实体,在单个租户内部独立存在, + 不依附任何公司或部门。通过 AssignScope 控制角色的分配可见范围: + - 0:全租户管理员可分发 + - 1:仅指定公司管理员可分发(AssignScopeUUID = CompanyUUID) + - 2:仅指定部门管理员可分发(AssignScopeUUID = DeptUUID) + """ + + RoleUUID = UUIDField(primary_key=True, db_column='RoleUUID', verbose_name='角色UUID') + TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') + RoleName = models.CharField(max_length=128, db_column='RoleName', verbose_name='角色名称') + RoleType = models.SmallIntegerField(default=2, db_column='RoleType', verbose_name='角色类型') + RoleStatus = models.SmallIntegerField(default=1, db_column='RoleStatus', verbose_name='角色状态') + AssignScope = models.SmallIntegerField(default=0, db_column='AssignScope', verbose_name='分配可见范围') + AssignScopeUUID = UUIDField(null=True, blank=True, db_column='AssignScopeUUID', verbose_name='分配范围UUID') + RoleDesc = models.CharField(max_length=512, default='', db_column='RoleDesc', verbose_name='角色描述') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'Role' + verbose_name = '角色' + verbose_name_plural = '角色' + + def __str__(self): + return self.RoleName + + def GetPermissions(self): + """获取角色拥有的权限列表(通过 RolePermission 中间表)""" + perm_uuids = RolePermission.objects.filter( + RoleUUID=self.RoleUUID + ).values_list('PermUUID', flat=True) + return Permission.objects.filter(PermUUID__in=perm_uuids, PermStatus=1) + + def GetUsers(self): + """获取拥有该角色的用户列表""" + from django.apps import apps + UserModel = apps.get_model('gvsdsdk', 'User') + user_uuids = UserRole.objects.filter( + RoleUUID=self.RoleUUID + ).values_list('UserUUID', flat=True) + return UserModel.objects.filter(UserUUID__in=user_uuids) + + +# === 权限表 === + +class Permission(QModel): + """权限表——操作权限定义(映射 SAAS 主库 Permission 表) + + 独立的权限定义表,租户内全局可用,不再挂在角色下。 + 通过 RolePermission 中间表与角色建立多对多关系, + 通过 PositionTemplatePermission 中间表与岗位模板建立多对多关系。 + """ + + PermUUID = UUIDField(primary_key=True, db_column='PermUUID', verbose_name='权限UUID') + TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') + PermName = models.CharField(max_length=128, db_column='PermName', verbose_name='权限名称') + PermCode = models.CharField(max_length=128, db_column='PermCode', verbose_name='权限键') + PermCategory = models.CharField(max_length=64, default='', db_column='PermCategory', verbose_name='权限分类') + PermStatus = models.SmallIntegerField(default=1, db_column='PermStatus', verbose_name='是否启用') + PermDesc = models.CharField(max_length=512, default='', db_column='PermDesc', verbose_name='描述') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'Permission' + verbose_name = '权限' + verbose_name_plural = '权限' + + def __str__(self): + return f"{self.PermName} ({self.PermCode})" + + def GetRoles(self): + """获取拥有该权限的角色列表""" + role_uuids = RolePermission.objects.filter( + PermUUID=self.PermUUID + ).values_list('RoleUUID', flat=True) + return Role.objects.filter(RoleUUID__in=role_uuids) + + +# === 用户角色关联表 === + +class UserRole(QModel): + """用户角色关联表——用户与角色的多对多映射(映射 SAAS 主库 UserRole 表) + + 通过 CompanyUUID 标识该用户-角色关联所属的公司, + 用于菜单按公司差异化显示、数据权限按公司过滤等场景。 + 同一用户在不同公司下可绑定不同角色。 + + 注意:UserUUID 和 RoleUUID 在 gvsds 中是 ForeignKey, + 但此处改为 UUIDField 以保持独立性。 + """ + + UserRoleUUID = UUIDField(primary_key=True, db_column='UserRoleUUID', verbose_name='关联UUID') + # 原 gvsds 中为 ForeignKey(User),此处改为 UUIDField + UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') + # 原 gvsds 中为 ForeignKey('Role'),此处改为 UUIDField + RoleUUID = UUIDField(db_column='RoleUUID', verbose_name='角色UUID') + CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'UserRole' + verbose_name = '用户角色关联' + verbose_name_plural = '用户角色关联' + + +# === 角色权限关联表 === + +class RolePermission(QModel): + """角色权限关联表——角色与操作权限的多对多映射(映射 SAAS 主库 RolePermission 表) + + 一个角色可以拥有多个权限,一个权限也可以被多个角色引用。 + 角色和具体操作权限为多对多绑定关系,一套权限集合可以打包在一个角色内统一维护。 + """ + + RolePermUUID = UUIDField(primary_key=True, db_column='RolePermUUID', verbose_name='关联UUID') + RoleUUID = UUIDField(db_column='RoleUUID', verbose_name='角色UUID') + PermUUID = UUIDField(db_column='PermUUID', verbose_name='权限UUID') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'RolePermission' + verbose_name = '角色权限关联' + verbose_name_plural = '角色权限关联' + + +# === 用户 ABAC 表 === + +class UserABAC(QModel): + """用户 ABAC 表——基于属性的访问控制(映射 SAAS 主库 UserABAC 表)""" + + UserABACUUID = UUIDField(primary_key=True, db_column='UserABACUUID', verbose_name='ABAC UUID') + UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') + HierarchicalPermissionIdentifiers = models.CharField(max_length=256, db_column='HierarchicalPermissionIdentifiers', verbose_name='层级权限标识') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'UserABAC' + verbose_name = '用户ABAC' + verbose_name_plural = '用户ABAC' + + +# === 密码表 === + +class Password(QModel): + """密码表——独立密码存储(映射 SAAS 主库 Password 表)""" + + UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') + Password = models.CharField(max_length=1024, db_column='Password', verbose_name='密码哈希') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'Password' + verbose_name = '密码' + verbose_name_plural = '密码' + + +# === 商铺表 === + +class Shop(QModel): + """商铺表——SAAS 多商铺体系中的商铺实体 + + 每个商铺属于一个公司(CompanyUUID),可关联多个角色(通过 RoleShop)。 + """ + + ShopUUID = UUIDField(primary_key=True, db_column='ShopUUID', verbose_name='商铺UUID') + CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID') + ShopName = models.CharField(max_length=128, db_column='ShopName', verbose_name='商铺名称') + ShopCode = models.CharField(max_length=64, default='', db_column='ShopCode', verbose_name='商铺编码') + ShopType = models.SmallIntegerField(default=0, db_column='ShopType', verbose_name='商铺类型') + ShopStatus = models.SmallIntegerField(default=1, db_column='ShopStatus', verbose_name='商铺状态') + ShopAddress = models.CharField(max_length=512, default='', db_column='ShopAddress', verbose_name='商铺地址') + ShopContact = models.CharField(max_length=128, default='', db_column='ShopContact', verbose_name='联系人') + ShopPhone = models.CharField(max_length=32, default='', db_column='ShopPhone', verbose_name='联系电话') + ShopDesc = models.CharField(max_length=512, default='', db_column='ShopDesc', verbose_name='描述') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + UpdateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UpdateTime', verbose_name='更新时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'Shop' + verbose_name = '商铺' + verbose_name_plural = '商铺' + + def __str__(self): + return self.ShopName + + def GetRoles(self): + """获取商铺关联的所有角色""" + role_uuids = RoleShop.objects.filter( + ShopUUID=self.ShopUUID + ).values_list('RoleUUID', flat=True) + return Role.objects.filter(RoleUUID__in=role_uuids) + + +# === 角色商铺关联表 === + +class RoleShop(QModel): + """角色商铺关联表——角色与商铺的多对多映射 + + 一个角色可以管理多个商铺,一个商铺也可以被多个角色管理。 + """ + + RoleShopUUID = UUIDField(primary_key=True, db_column='RoleShopUUID', verbose_name='关联UUID') + RoleUUID = UUIDField(db_column='RoleUUID', verbose_name='角色UUID') + ShopUUID = UUIDField(db_column='ShopUUID', verbose_name='商铺UUID') + IsMainShop = models.SmallIntegerField(default=0, db_column='IsMainShop', verbose_name='主商铺') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'RoleShop' + verbose_name = '角色商铺关联' + verbose_name_plural = '角色商铺关联' + + +# === 岗位模板表 === + +class PositionTemplate(QModel): + """岗位模板表——权限初始化快照模板(映射 SAAS 主库 PositionTemplate 表) + + 租户内全局可重复使用,定位是权限初始化快照模板, + 不直接分配给员工、不参与运行时鉴权。 + 新建角色时可一键复制模板内的全部权限内容生成新角色。 + 模板后续调整只会作用于之后新创建的角色, + 已有角色不会同步变更,实现模板复用和存量权限独立管控互不干扰。 + """ + + PositionTemplateUUID = UUIDField(primary_key=True, db_column='PositionTemplateUUID', verbose_name='岗位模板UUID') + TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') + TemplateName = models.CharField(max_length=128, db_column='TemplateName', verbose_name='模板名称') + TemplateCode = models.CharField(max_length=64, default='', db_column='TemplateCode', verbose_name='模板编码') + TemplateStatus = models.SmallIntegerField(default=1, db_column='TemplateStatus', verbose_name='模板状态') + TemplateDesc = models.CharField(max_length=512, default='', db_column='TemplateDesc', verbose_name='模板描述') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + UpdateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UpdateTime', verbose_name='更新时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'PositionTemplate' + verbose_name = '岗位模板' + verbose_name_plural = '岗位模板' + + def __str__(self): + return self.TemplateName + + def GetPermissions(self): + """获取模板关联的权限列表""" + perm_uuids = PositionTemplatePermission.objects.filter( + PositionTemplateUUID=self.PositionTemplateUUID + ).values_list('PermUUID', flat=True) + return Permission.objects.filter(PermUUID__in=perm_uuids, PermStatus=1) + + def CopyToRole(self, RoleName, TenantUUID): + """从模板复制权限到新角色(一次性复制,后续模板变更不影响已有角色)""" + role = Role.objects.create( + RoleUUID=_uuid.uuid4().bytes, + TenantUUID=TenantUUID, + RoleName=RoleName, + ) + template_perms = PositionTemplatePermission.objects.filter( + PositionTemplateUUID=self.PositionTemplateUUID + ) + for tp in template_perms: + RolePermission.objects.create( + RolePermUUID=_uuid.uuid4().bytes, + RoleUUID=role.RoleUUID, + PermUUID=tp.PermUUID, + ) + return role + + +# === 岗位模板权限关联表 === + +class PositionTemplatePermission(QModel): + """岗位模板权限关联表——岗位模板与操作权限的多对多映射 + + 仅作为复制源头,不参与运行时鉴权。 + 新建角色时一键复制模板权限到角色,复制后模板与角色无运行时绑定。 + """ + + PTPermUUID = UUIDField(primary_key=True, db_column='PTPermUUID', verbose_name='关联UUID') + PositionTemplateUUID = UUIDField(db_column='PositionTemplateUUID', verbose_name='岗位模板UUID') + PermUUID = UUIDField(db_column='PermUUID', verbose_name='权限UUID') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'PositionTemplatePermission' + verbose_name = '岗位模板权限关联' + verbose_name_plural = '岗位模板权限关联' + + +# === 人事职务标签表 === + +class Position(QModel): + """人事职务标签表——仅用于人事管理标注(映射 SAAS 主库 Position 表) + + 员工会标注对应的职务标签,该标签仅为人事标注信息, + 不直接绑定权限,和角色、权限体系完全解耦。 + 人员调岗、升职只修改组织归属和人事职务标签,权限只需增删绑定角色即可,互不干扰。 + """ + + PositionUUID = UUIDField(primary_key=True, db_column='PositionUUID', verbose_name='职务UUID') + TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') + PositionName = models.CharField(max_length=128, db_column='PositionName', verbose_name='职务名称') + PositionCode = models.CharField(max_length=64, default='', db_column='PositionCode', verbose_name='职务编码') + PositionLevel = models.SmallIntegerField(default=0, db_column='PositionLevel', verbose_name='职务级别') + PositionStatus = models.SmallIntegerField(default=1, db_column='PositionStatus', verbose_name='状态') + PositionDesc = models.CharField(max_length=512, default='', db_column='PositionDesc', verbose_name='描述') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + UpdateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='UpdateTime', verbose_name='更新时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'Position' + verbose_name = '人事职务标签' + verbose_name_plural = '人事职务标签' + + def __str__(self): + return self.PositionName + + +# === 员工部门关联表 === + +class UserDept(QModel): + """员工部门关联表——员工与部门的多对多映射(映射 SAAS 主库 UserDept 表) + + 支持一人多部门兼职,同时标注该员工在对应部门的职务标签。 + 该层级仅用于组织架构展示、审批流转、人员统计, + 同时为数据权限判断提供人员所属组织属性依据,不参与操作权限校验。 + """ + + UserDeptUUID = UUIDField(primary_key=True, db_column='UserDeptUUID', verbose_name='关联UUID') + UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') + DeptUUID = UUIDField(db_column='DeptUUID', verbose_name='部门UUID') + PositionUUID = UUIDField(null=True, blank=True, db_column='PositionUUID', verbose_name='职务标签UUID') + IsMainDept = models.SmallIntegerField(default=1, db_column='IsMainDept', verbose_name='是否主部门') + CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = True + app_label = 'gvsdsdk' + db_table = 'UserDept' + verbose_name = '员工部门关联' + verbose_name_plural = '员工部门关联' diff --git a/gvsdsdk/msync/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/msync/__pycache__/__init__.cpython-312.pyc index 6f7f7570..a8155272 100644 Binary files a/gvsdsdk/msync/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/msync/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/msync/__pycache__/auth.cpython-312.pyc b/gvsdsdk/msync/__pycache__/auth.cpython-312.pyc index a6027afa..5a3ee337 100644 Binary files a/gvsdsdk/msync/__pycache__/auth.cpython-312.pyc and b/gvsdsdk/msync/__pycache__/auth.cpython-312.pyc differ diff --git a/gvsdsdk/msync/__pycache__/client.cpython-312.pyc b/gvsdsdk/msync/__pycache__/client.cpython-312.pyc index a9805d50..d3d42032 100644 Binary files a/gvsdsdk/msync/__pycache__/client.cpython-312.pyc and b/gvsdsdk/msync/__pycache__/client.cpython-312.pyc differ diff --git a/gvsdsdk/msync/__pycache__/local_tx.cpython-312.pyc b/gvsdsdk/msync/__pycache__/local_tx.cpython-312.pyc index 10731ca3..7af02091 100644 Binary files a/gvsdsdk/msync/__pycache__/local_tx.cpython-312.pyc and b/gvsdsdk/msync/__pycache__/local_tx.cpython-312.pyc differ diff --git a/gvsdsdk/msync/__pycache__/ogm.cpython-312.pyc b/gvsdsdk/msync/__pycache__/ogm.cpython-312.pyc new file mode 100644 index 00000000..9624dd36 Binary files /dev/null and b/gvsdsdk/msync/__pycache__/ogm.cpython-312.pyc differ diff --git a/gvsdsdk/msync/__pycache__/orm_handler.cpython-312.pyc b/gvsdsdk/msync/__pycache__/orm_handler.cpython-312.pyc index 45f5cfdf..1d651080 100644 Binary files a/gvsdsdk/msync/__pycache__/orm_handler.cpython-312.pyc and b/gvsdsdk/msync/__pycache__/orm_handler.cpython-312.pyc differ diff --git a/gvsdsdk/msync/__pycache__/registry.cpython-312.pyc b/gvsdsdk/msync/__pycache__/registry.cpython-312.pyc new file mode 100644 index 00000000..4c9cc284 Binary files /dev/null and b/gvsdsdk/msync/__pycache__/registry.cpython-312.pyc differ diff --git a/gvsdsdk/order/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/order/__pycache__/__init__.cpython-312.pyc index 7a8ee003..8eb612cb 100644 Binary files a/gvsdsdk/order/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/order/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/order/__pycache__/models.cpython-312.pyc b/gvsdsdk/order/__pycache__/models.cpython-312.pyc index 2d0ca1e3..2f51f5aa 100644 Binary files a/gvsdsdk/order/__pycache__/models.cpython-312.pyc and b/gvsdsdk/order/__pycache__/models.cpython-312.pyc differ diff --git a/gvsdsdk/payment/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/payment/__pycache__/__init__.cpython-312.pyc index 6f302a8f..89b25145 100644 Binary files a/gvsdsdk/payment/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/payment/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/payment/__pycache__/manager.cpython-312.pyc b/gvsdsdk/payment/__pycache__/manager.cpython-312.pyc new file mode 100644 index 00000000..119b276a Binary files /dev/null and b/gvsdsdk/payment/__pycache__/manager.cpython-312.pyc differ diff --git a/gvsdsdk/payment/__pycache__/models.cpython-312.pyc b/gvsdsdk/payment/__pycache__/models.cpython-312.pyc new file mode 100644 index 00000000..623da4aa Binary files /dev/null and b/gvsdsdk/payment/__pycache__/models.cpython-312.pyc differ diff --git a/gvsdsdk/payment/gateways/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/payment/gateways/__pycache__/__init__.cpython-312.pyc index 1759c2a8..449da236 100644 Binary files a/gvsdsdk/payment/gateways/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/payment/gateways/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/payment/gateways/__pycache__/alipay.cpython-312.pyc b/gvsdsdk/payment/gateways/__pycache__/alipay.cpython-312.pyc index 31fbdd76..e3cfdda2 100644 Binary files a/gvsdsdk/payment/gateways/__pycache__/alipay.cpython-312.pyc and b/gvsdsdk/payment/gateways/__pycache__/alipay.cpython-312.pyc differ diff --git a/gvsdsdk/payment/gateways/__pycache__/bank.cpython-312.pyc b/gvsdsdk/payment/gateways/__pycache__/bank.cpython-312.pyc index 8dcfb37f..e8a14863 100644 Binary files a/gvsdsdk/payment/gateways/__pycache__/bank.cpython-312.pyc and b/gvsdsdk/payment/gateways/__pycache__/bank.cpython-312.pyc differ diff --git a/gvsdsdk/payment/gateways/__pycache__/base.cpython-312.pyc b/gvsdsdk/payment/gateways/__pycache__/base.cpython-312.pyc index e2f8dbb1..b17cbbff 100644 Binary files a/gvsdsdk/payment/gateways/__pycache__/base.cpython-312.pyc and b/gvsdsdk/payment/gateways/__pycache__/base.cpython-312.pyc differ diff --git a/gvsdsdk/payment/gateways/__pycache__/registry.cpython-312.pyc b/gvsdsdk/payment/gateways/__pycache__/registry.cpython-312.pyc index 2bf2fd2e..fbb8cda0 100644 Binary files a/gvsdsdk/payment/gateways/__pycache__/registry.cpython-312.pyc and b/gvsdsdk/payment/gateways/__pycache__/registry.cpython-312.pyc differ diff --git a/gvsdsdk/payment/gateways/__pycache__/wechat.cpython-312.pyc b/gvsdsdk/payment/gateways/__pycache__/wechat.cpython-312.pyc index 0fd99636..34800225 100644 Binary files a/gvsdsdk/payment/gateways/__pycache__/wechat.cpython-312.pyc and b/gvsdsdk/payment/gateways/__pycache__/wechat.cpython-312.pyc differ