2026-10-29 18:24:00 +08:00

2028 lines
97 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""认证/登录/权限管理路由"""
from functools import wraps
from decimal import Decimal, InvalidOperation
from flask import (Blueprint, render_template, request, jsonify,
session, redirect, url_for)
from services.db import query, add_operation_log
from services.passwords import hash_password, validate_password, verify_password
from services.permission_model import ensure_position_permissions, user_position_code
auth_bp = Blueprint('auth', __name__)
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
# 判断是 AJAX/JSON 请求还是普通页面请求
# fetch() 默认 Accept: */*, 不设 Content-Type
# 通过请求路径和 Accept 头综合判断
is_api = request.path.startswith('/api/')
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
if 'user_id' not in session:
if is_api or is_ajax or request.is_json:
return jsonify({"code": 401, "msg": "未登录"})
return redirect(url_for('auth.login'))
# 校验账号状态:被禁用(status=0)或已删除的用户强制登出(防止已登录会话继续使用)
try:
user_rows = query("SELECT status FROM dashboard_users WHERE id=%s", (session.get('user_id'),))
if not user_rows or user_rows[0].get('status') != 1:
session.clear()
if is_api or is_ajax or request.is_json:
return jsonify({"code": 401, "msg": "账号已禁用,请联系管理员"})
return redirect(url_for('auth.login'))
except Exception:
pass
if session.get('must_change_password') and request.endpoint != 'auth.change_password':
return jsonify({"code": 428, "msg": "首次登录必须修改密码"})
return f(*args, **kwargs)
return decorated
# 权限配置统一标识。左侧为标准ID,列表内保留现有页面ID和历史父级ID兼容。
PERMISSION_PAGE_ALIASES = {
'purchase_pending': ['purchase_pending', 'purchase'],
'purchase_backup': ['purchase_backup', 'purchase_ready', 'purchase'],
'order_domestic': ['order_domestic', 'orders_domestic', 'orders'],
'order_overseas': ['order_overseas', 'orders_overseas', 'orders'],
'order_packaging': ['order_packaging', 'orders_packaging', 'orders'],
'order_finished': ['order_finished', 'orders_finished', 'orders'],
'data_summary': ['data_summary', 'data'],
'data_detail': ['data_detail', 'data'],
'inventory_finished': ['inventory_finished', 'inventory'],
'inventory_packaging': ['inventory_packaging', 'inventory'],
'product_packaging': ['product_packaging', 'product_new'],
'login_records': ['login_records', 'login-records'],
'spot_dashboard': ['spot_dashboard', 'spot'],
'spot_application': ['spot_application', 'spot'],
'spot_confirmation': ['spot_confirmation', 'spot'],
'spot_sales': ['spot_sales', 'spot'],
'spot_authorization': ['spot_authorization', 'spot'],
}
# 只对无歧义的现有页面ID做标准化;历史父级 orders 保留为兼容回退项。
PERMISSION_PAGE_CANONICAL = {
'purchase': 'purchase_pending',
'purchase_ready': 'purchase_backup',
'orders_domestic': 'order_domestic',
'orders_overseas': 'order_overseas',
'orders_packaging': 'order_packaging',
'orders_finished': 'order_finished',
'login-records': 'login_records',
}
for _canonical in PERMISSION_PAGE_ALIASES:
PERMISSION_PAGE_CANONICAL[_canonical] = _canonical
def normalize_permission_page(page):
"""把现有/历史页面ID转换为权限配置标准ID。"""
return PERMISSION_PAGE_CANONICAL.get(page, page)
def _permission_page_aliases(page):
"""返回标准页面ID及兼容别名,标准ID优先。"""
canonical = normalize_permission_page(page)
return PERMISSION_PAGE_ALIASES.get(canonical, [canonical])
# ─── 权限配置页面特征(操作)单一来源 ───
# 未列出的页面默认只有「查看」权限;页面ID为标准权限ID。
PAGE_FEATURES = {
'data_summary': [{'id':'view','name':'查看'},{'id':'query','name':'查询'},{'id':'export','name':'导出'}],
'data_detail': [{'id':'view','name':'查看'},{'id':'query','name':'查询'},{'id':'export','name':'导出'},{'id':'import','name':'导入'}],
'inventory_finished': [{'id':'view','name':'查看'},{'id':'edit_product','name':'编辑产品'},{'id':'export','name':'导出'},{'id':'import','name':'导入'}],
'inventory_packaging': [{'id':'view','name':'查看'},{'id':'edit_product','name':'编辑产品'},{'id':'export','name':'导出'},{'id':'import','name':'导入'}],
'planning': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'edit','name':'编辑按钮'},{'id':'delete','name':'删除按钮'},{'id':'field_setting','name':'字段设置'},{'id':'frozen','name':'冻结列'}],
'purchase_pending': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'add','name':'加入待下单'},{'id':'edit','name':'编辑下单信息'},{'id':'batch_confirm','name':'确认下单按钮'},{'id':'delete','name':'回退按钮'},{'id':'field_setting','name':'字段设置'},{'id':'frozen','name':'冻结列'}],
'purchase_backup': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'edit','name':'编辑订单'},{'id':'rollback','name':'回退按钮'},{'id':'delete','name':'删除按钮'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'}],
'order_domestic': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'create','name':'新增订单'},{'id':'edit','name':'编辑订单'},{'id':'contract','name':'合同按钮'},{'id':'delete','name':'删除按钮'},{'id':'batch_contract','name':'批量生成合同'}],
'order_overseas': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'create','name':'新增订单'},{'id':'edit','name':'编辑订单'},{'id':'contract','name':'合同按钮'},{'id':'delete','name':'删除按钮'},{'id':'batch_contract','name':'批量生成合同'}],
'order_packaging': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'create','name':'新增订单'},{'id':'edit','name':'编辑订单'},{'id':'contract','name':'合同按钮'},{'id':'delete','name':'删除按钮'},{'id':'batch_contract','name':'批量生成合同'}],
'order_finished': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'delete','name':'删除按钮'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'}],
'spot_application': [{'id':'view','name':'查看'},{'id':'create','name':'新增申请'},{'id':'edit','name':'审核通过/驳回'},{'id':'delete','name':'删除申请'}],
'spot_confirmation': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑详情'},{'id':'import','name':'导入'},{'id':'delete','name':'删除记录'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'},{'id':'frozen','name':'冻结字段'}],
'spot_authorization': [{'id':'view','name':'查看'},{'id':'create','name':'新增资料'},
{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'}],
'contract_templates': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑上传'},{'id':'delete','name':'删除'}],
'product_new': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'sync','name':'同步产品信息'},{'id':'field_setting','name':'字段设置'}],
'product_category': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'}],
'products': [{'id':'view','name':'查看'},{'id':'add','name':'新增产品'},{'id':'edit','name':'编辑产品'},{'id':'field_setting','name':'字段设置'}],
'brand_management': [{'id':'view','name':'查看'},{'id':'edit','name':'分类编辑'},{'id':'delete','name':'删除品牌'}],
'bom': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}],
'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}],
'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}],
'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}],
'organization': [{'id':'view','name':'查看'},{'id':'sync','name':'同步钉钉组织架构'}],
'department_matching': [{'id':'view','name':'查看与匹配测试'},{'id':'create','name':'新增规则'},{'id':'edit','name':'编辑规则'},{'id':'delete','name':'删除规则'}],
'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}],
'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}],
'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}],
'config': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'reset','name':'恢复默认'}],
'collaboration_sales_management': [{'id':'view','name':'查看'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'},{'id':'delete','name':'删除'}],
'stagnant_inventory_plans': [{'id':'view','name':'查看'},{'id':'create','name':'新建方案'},{'id':'edit','name':'编辑方案'},{'id':'delete','name':'归档方案'},{'id':'export','name':'导出'}],
}
DEFAULT_FEATURES = [{'id': 'view', 'name': '查看'}]
def _sidebar_menu_pages():
"""从侧边栏模板解析页面列表(页面ID/名称/分组),作为权限配置的单一来源。
新增子菜单只需在 base.html 侧边栏添加,权限配置会自动同步。"""
import os
import re
from flask import current_app
path = os.path.join(current_app.root_path, 'templates', 'base.html')
with open(path, encoding='utf-8') as f:
html = f.read()
entries = []
seen = set()
def add(group, pid, name):
pid = normalize_permission_page(pid.strip())
name = name.strip()
if not pid or pid in seen:
return
seen.add(pid)
entries.append({'id': pid, 'name': name, 'group': group or name})
# 顶层项(nav-item 带 data-page,非分组头)
top_items = [(m.start(), m.group(1), m.group(2)) for m in re.finditer(
r'<a[^>]*class="[^"]*nav-item[^"]*"[^>]*data-page="([^"]+)"[^>]*>.*?<span class="nav-text">([^<]+)</span>',
html, re.S)]
# 分组头(位置 + 分组名)
group_headers = [(m.start(), m.group(1)) for m in re.finditer(
r'<a[^>]*class="[^"]*nav-group-header[^"]*"[^>]*>.*?<span class="nav-text">([^<]+)</span>',
html, re.S)]
submenu_re = re.compile(r'<div class="nav-submenu"[^>]*>(.*?)</div>', re.S)
submenu_items = {}
for pos, gname in group_headers:
m = submenu_re.search(html, pos)
if m:
submenu_items[pos] = (gname, [(mm.group(1), mm.group(2)) for mm in re.finditer(
r'data-page="([^"]+)"[^>]*>\s*<span class="nav-sub-icon">[^<]*</span>\s*<span>([^<]+)</span>',
m.group(1))])
# 按文档顺序合并:顶层项与分组
order = [(pos, 'top', pid, name) for pos, pid, name in top_items] + \
[(pos, 'group', gname, None) for pos, gname in group_headers]
order.sort()
for pos, kind, a, b in order:
if kind == 'top':
add(None, a, b)
else:
gname, items = submenu_items.get(pos, (a, []))
for pid, name in items:
add(gname, pid, name)
return entries
def _query_effective_operation_value(user_id, role, page, operation):
"""返回有效操作权限;未配置时返回 None,供新增按钮权限兼容旧配置。"""
if role in ('admin', 'super_admin'):
return True
pages = _permission_page_aliases(page)
placeholders = ','.join(['%s'] * len(pages))
query_role = 'viewer' if role == '查看者' else role
user_rows = query(
f"SELECT permission FROM user_permissions WHERE user_id=%s AND page IN ({placeholders}) AND operation=%s ORDER BY FIELD(page, {placeholders}) LIMIT 1",
tuple([user_id] + pages + [operation] + pages)
)
if user_rows:
return int(user_rows[0].get('permission', 0)) == 1
ensure_position_permissions()
position = user_position_code(user_id)
if position:
position_rows = query(
f"SELECT permission FROM position_operation_permissions WHERE position_code=%s AND page IN ({placeholders}) AND operation=%s ORDER BY FIELD(page, {placeholders}) LIMIT 1",
tuple([position] + pages + [operation] + pages)
)
if position_rows:
return int(position_rows[0].get('permission', 0)) == 1
role_rows = query(
f"SELECT permission FROM role_operation_permissions WHERE role=%s AND page IN ({placeholders}) AND operation=%s ORDER BY FIELD(page, {placeholders}) LIMIT 1",
tuple([query_role] + pages + [operation] + pages)
)
if role_rows:
return int(role_rows[0].get('permission', 0)) == 1
return None
def _query_effective_operation(user_id, role, page, operation):
return bool(_query_effective_operation_value(user_id, role, page, operation))
def _has_effective_page_permission(user_id, role, page):
"""页面访问权限统一走 view;管理员和超级管理员固定拥有访问权限。"""
if role in ('admin', 'super_admin'):
return True
pages = _permission_page_aliases(page)
placeholders = ','.join(['%s'] * len(pages))
user_rows = query(
f"SELECT permission FROM user_permissions WHERE user_id=%s AND page IN ({placeholders}) AND operation='view' ORDER BY FIELD(page, {placeholders}) LIMIT 1",
tuple([user_id] + pages + pages)
)
if user_rows:
return int(user_rows[0].get('permission', 0)) == 1
ensure_position_permissions()
position = user_position_code(user_id)
if position:
position_rows = query(
f"SELECT permission FROM position_operation_permissions WHERE position_code=%s AND page IN ({placeholders}) AND operation='view' ORDER BY FIELD(page, {placeholders}) LIMIT 1",
tuple([position] + pages + pages)
)
if position_rows:
return int(position_rows[0].get('permission', 0)) == 1
role_rows = query(
f"SELECT permission FROM role_operation_permissions WHERE role=%s AND page IN ({placeholders}) AND operation='view' ORDER BY FIELD(page, {placeholders}) LIMIT 1",
tuple([role] + pages + pages)
)
if role_rows:
return int(role_rows[0].get('permission', 0)) == 1
rows = query(
f"SELECT permission FROM role_permissions WHERE role=%s AND page IN ({placeholders}) LIMIT 1",
tuple([role] + pages)
)
return bool(rows and int(rows[0].get('permission', 0)) == 1)
def role_required(page):
"""从统一的有效权限模型检查当前用户是否有权限访问页面。"""
user_id = session.get('user_id')
return bool(user_id and _has_effective_page_permission(user_id, session.get('role', ''), page))
def operation_required(page, operation):
"""检查最终生效的页面操作权限;用户级明确值覆盖岗位和角色配置。"""
user_id = session.get('user_id')
return bool(user_id and _query_effective_operation(user_id, session.get('role', ''), page, operation))
def operation_required_compat(page, operation, fallback_operation):
"""新增按钮权限未配置时继承旧权限;一旦明确配置则严格使用新权限。"""
user_id = session.get('user_id')
if not user_id:
return False
role = session.get('role', '')
value = _query_effective_operation_value(user_id, role, page, operation)
if value is not None:
return bool(value)
return _query_effective_operation(user_id, role, page, fallback_operation)
# ─── 页面路由 ───
@auth_bp.route('/login', methods=['GET'])
def login():
return render_template('login.html')
@auth_bp.route('/api/login', methods=['POST'])
def api_login():
data = request.get_json(silent=True) or {}
username = data.get('username', '')
password = data.get('password', '')
users = query(
"SELECT * FROM dashboard_users WHERE username=%s",
(username,)
)
if not users:
return jsonify({"code": 401, "msg": "用户名或密码错误"})
if users[0].get('status') != 1:
return jsonify({"code": 401, "msg": "账号已禁用,请联系管理员"})
if not verify_password(users[0]['password'], password):
return jsonify({"code": 401, "msg": "用户名或密码错误"})
user = users[0]
session.clear()
session['user_id'] = user['id']
session['username'] = user['username']
session['real_name'] = user['real_name']
session['role'] = user['role']
session['user_level'] = user.get('user_level', 'employee')
session['must_change_password'] = bool(user.get('must_change_password', 0))
if session['must_change_password']:
return jsonify({"code": 0, "msg": "首次登录需要修改密码", "data": {
"must_change_password": True,
"username": user['username']
}})
# 更新最后登录时间
query(
"UPDATE dashboard_users SET last_login=NOW() WHERE id=%s",
(user['id'],)
)
# 记录登录日志(含IP)
client_ip = request.remote_addr or request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or ''
add_operation_log('登录', '用户登录系统', user_id=user['id'], ip_address=client_ip,
page='登录', event_type='login', object_type='user', object_id=user['id'])
return jsonify({"code": 0, "msg": "登录成功", "data": {
"must_change_password": False,
"username": user['username'],
"real_name": user['real_name'],
"role": user['role'],
"user_level": user.get('user_level', 'employee')
}})
@auth_bp.route('/api/change-password', methods=['POST'])
@login_required
def change_password():
"""Change the current user's password before granting full access."""
data = request.get_json(silent=True) or {}
new_password = data.get('new_password', '')
confirm_password = data.get('confirm_password', '')
if new_password != confirm_password:
return jsonify({"code": 400, "msg": "两次输入的密码不一致"})
valid, message = validate_password(new_password)
if not valid:
return jsonify({"code": 400, "msg": message})
user_id = session['user_id']
rows = query("SELECT password FROM dashboard_users WHERE id=%s AND status=1", (user_id,))
if not rows:
session.clear()
return jsonify({"code": 401, "msg": "账号不存在或已停用"})
if verify_password(rows[0]['password'], new_password):
return jsonify({"code": 400, "msg": "新密码不能与原密码相同"})
query(
"UPDATE dashboard_users SET password=%s, must_change_password=0, last_login=NOW() WHERE id=%s",
(hash_password(new_password), user_id)
)
session['must_change_password'] = False
add_operation_log('修改密码', '用户首次登录完成密码修改')
return jsonify({"code": 0, "msg": "密码修改成功"})
@auth_bp.route('/api/logout', methods=['POST'])
def api_logout():
session.clear()
return jsonify({"code": 0, "msg": "已退出"})
@auth_bp.route('/')
def index():
return render_template('login.html')
@auth_bp.route('/dashboard')
@login_required
def dashboard_page():
return render_template('base.html', page='dashboard')
@auth_bp.route('/api/profile')
@login_required
def profile():
user_id = session['user_id']
role = session['role']
# 角色默认权限 + 用户级覆盖,返回与模板注入一致的最终权限。
role_perms = query(
"SELECT page, operation, permission FROM role_operation_permissions WHERE role=%s",
('viewer' if role == '查看者' else role,)
)
ensure_position_permissions()
position = user_position_code(user_id)
position_perms = query(
"SELECT page, operation, permission FROM position_operation_permissions WHERE position_code=%s",
(position,)
) if position else []
user_perms = query(
"SELECT page, operation, permission FROM user_permissions WHERE user_id=%s",
(user_id,)
)
runtime_page_aliases = {
'purchase_pending': 'purchase', 'purchase_backup': 'purchase_ready',
'order_domestic': 'orders_domestic', 'order_overseas': 'orders_overseas',
'order_packaging': 'orders_packaging', 'order_finished': 'orders_finished'
}
perm_map = {}
for p in role_perms:
runtime_page = runtime_page_aliases.get(p['page'], p['page'])
perm_map.setdefault(runtime_page, {})[p['operation']] = int(p['permission'])
for p in position_perms:
runtime_page = runtime_page_aliases.get(p['page'], p['page'])
perm_map.setdefault(runtime_page, {})[p['operation']] = int(p['permission'])
for p in user_perms:
runtime_page = runtime_page_aliases.get(p['page'], p['page'])
perm_map.setdefault(runtime_page, {})[p['operation']] = int(p['permission'])
# 管理员和超级管理员由后端统一视为全权限,前端保留空映射作为特殊标识。
if role in ('admin', 'super_admin'):
perm_map = {}
else:
# 注入字段级权限显式值(敏感字段默认隐藏 + 角色/用户字段权限)
try:
from services.field_perms import effective_field_overrides
for runtime_page, field_map in effective_field_overrides(user_id, role).items():
base = perm_map.setdefault(runtime_page, {})
for field_key, ops in field_map.items():
base['field:' + field_key + ':view'] = ops['view']
base['field:' + field_key + ':edit'] = ops['edit']
except Exception:
pass
return jsonify({
"code": 0,
"data": {
"id": user_id,
"username": session['username'],
"real_name": session['real_name'],
"role": role,
"user_level": session.get('user_level', 'employee'),
"permissions": perm_map
}
})
# ─── P1 权限数据范围与变更审计 ───
_PERMISSION_P1_TABLES_READY = False
def _ensure_permission_p1_tables():
"""创建 P1 权限控制台所需的配置/审计表;幂等且不触碰业务数据。"""
global _PERMISSION_P1_TABLES_READY
if _PERMISSION_P1_TABLES_READY:
return
query("""CREATE TABLE IF NOT EXISTS permission_data_scopes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
subject_type VARCHAR(20) NOT NULL,
subject_id VARCHAR(100) NOT NULL,
page VARCHAR(100) NOT NULL,
scope_type VARCHAR(40) NOT NULL DEFAULT 'all',
scope_value VARCHAR(500) DEFAULT '',
can_view TINYINT NOT NULL DEFAULT 1,
can_edit TINYINT NOT NULL DEFAULT 0,
can_export TINYINT NOT NULL DEFAULT 0,
created_by INT NULL,
updated_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_permission_scope (subject_type, subject_id, page),
KEY idx_permission_scope_subject (subject_type, subject_id),
KEY idx_permission_scope_page (page)
)""")
query("""CREATE TABLE IF NOT EXISTS permission_change_audits (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
actor_id INT NULL,
target_type VARCHAR(20) NOT NULL,
target_id VARCHAR(100) NOT NULL,
action VARCHAR(80) NOT NULL,
before_json LONGTEXT NULL,
after_json LONGTEXT NULL,
detail TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_permission_audit_target (target_type, target_id),
KEY idx_permission_audit_created (created_at)
)""")
ensure_position_permissions()
query("""CREATE TABLE IF NOT EXISTS permission_versions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(20) NOT NULL,
target_id VARCHAR(100) NOT NULL,
version_no INT NOT NULL,
snapshot_json LONGTEXT NOT NULL,
snapshot_hash CHAR(64) NOT NULL,
summary VARCHAR(500) DEFAULT '',
source_version_id BIGINT NULL,
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_permission_version (target_type, target_id, version_no),
KEY idx_permission_version_target (target_type, target_id, id),
KEY idx_permission_version_created (created_at)
)""")
query("""INSERT IGNORE INTO permission_data_scopes
(subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,created_by,updated_by)
SELECT 'position',p.position_code,s.page,s.scope_type,s.scope_value,s.can_view,s.can_edit,s.can_export,s.created_by,s.updated_by
FROM permission_data_scopes s
INNER JOIN user_positions p ON BINARY p.position_code=BINARY s.subject_id
WHERE s.subject_type='role' AND s.subject_id NOT IN ('super_admin','admin','viewer','查看者')""")
_PERMISSION_P1_TABLES_READY = True
def _record_permission_change(target_type, target_id, action, before=None, after=None, detail=''):
"""记录权限控制台变更,失败不阻断已完成的权限写入。"""
try:
import json
_ensure_permission_p1_tables()
query(
"INSERT INTO permission_change_audits "
"(actor_id,target_type,target_id,action,before_json,after_json,detail) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(
session.get('user_id'), target_type, str(target_id), action,
json.dumps(before, ensure_ascii=False, default=str) if before is not None else None,
json.dumps(after, ensure_ascii=False, default=str) if after is not None else None,
detail or '',
),
)
except Exception:
pass
_PERMISSION_CONFLICT_RULES = [
{
'id': 'confirm_and_rollback', 'level': '高',
'name': '确认下单与订单回退同时授权',
'left': ['purchase_pending.batch_confirm'],
'right': ['purchase_backup.rollback'],
'advice': '建议由不同岗位分别负责确认下单与订单回退。',
},
{
'id': 'user_and_permission_admin', 'level': '高',
'name': '用户管理与权限配置同时授权',
'left': ['users.edit', 'users.create', 'users.delete'],
'right': ['permissions.edit'],
'advice': '可修改账号又可修改权限,建议仅系统管理员持有。',
},
{
'id': 'product_and_confirm', 'level': '中',
'name': '产品资料维护与确认下单同时授权',
'left': ['products.edit', 'bom.edit', 'suppliers.edit'],
'right': ['purchase_pending.batch_confirm'],
'advice': '存在修改基础资料后直接确认下单的风险。',
},
{
'id': 'price_and_backup_edit', 'level': '高',
'name': '成本/单价编辑与订单备份编辑同时授权',
'left': [
'planning.field:成本价:edit', 'products.field:成本价:edit', 'bom.field:成本价:edit',
'order_domestic.field:单价:edit', 'order_overseas.field:单价:edit',
'order_packaging.field:单价:edit',
],
'right': ['purchase_backup.edit'],
'advice': '建议价格维护与订单审核/编辑职责分离。',
},
{
'id': 'domestic_create_delete', 'level': '中',
'name': '国内订单新增与删除同时授权',
'left': ['order_domestic.create'], 'right': ['order_domestic.delete'],
'advice': '建议限制删除权限或启用复核。',
},
{
'id': 'overseas_create_delete', 'level': '中',
'name': '海外订单新增与删除同时授权',
'left': ['order_overseas.create'], 'right': ['order_overseas.delete'],
'advice': '建议限制删除权限或启用复核。',
},
{
'id': 'packaging_create_delete', 'level': '中',
'name': '包材订单新增与删除同时授权',
'left': ['order_packaging.create'], 'right': ['order_packaging.delete'],
'advice': '建议限制删除权限或启用复核。',
},
]
def _permission_snapshot(target_type, target_id):
"""读取角色/用户完整权限状态,作为版本与回滚的唯一快照。"""
_ensure_permission_p1_tables()
target_type = str(target_type)
target_id = str(target_id)
if target_type == 'role':
legacy = query(
"SELECT page,permission FROM role_permissions WHERE role=%s ORDER BY page",
(target_id,),
)
operations = query(
"SELECT page,operation,permission FROM role_operation_permissions "
"WHERE role=%s ORDER BY page,operation", (target_id,)
)
elif target_type == 'position':
ensure_position_permissions()
legacy = []
operations = query(
"SELECT page,operation,permission FROM position_operation_permissions "
"WHERE position_code=%s ORDER BY page,operation", (target_id,)
)
elif target_type == 'user':
legacy = []
operations = query(
"SELECT page,operation,permission FROM user_permissions "
"WHERE user_id=%s ORDER BY page,operation", (int(target_id),)
)
else:
raise ValueError('不支持的权限对象类型')
scopes = query(
"SELECT page,scope_type,scope_value,can_view,can_edit,can_export "
"FROM permission_data_scopes WHERE subject_type=%s AND subject_id=%s ORDER BY page",
(target_type, target_id),
)
return {
'target_type': target_type,
'target_id': target_id,
'legacy_pages': legacy,
'operations': operations,
'data_scopes': scopes,
}
def _create_permission_version(target_type, target_id, summary, snapshot=None, source_version_id=None, force=False):
"""保存权限快照;内容未变化时默认不重复生成版本。"""
import hashlib
import json
_ensure_permission_p1_tables()
snapshot = snapshot or _permission_snapshot(target_type, target_id)
payload = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(',', ':'), default=str)
digest = hashlib.sha256(payload.encode('utf-8')).hexdigest()
latest = query(
"SELECT id,version_no,snapshot_hash FROM permission_versions "
"WHERE target_type=%s AND target_id=%s ORDER BY version_no DESC LIMIT 1",
(target_type, str(target_id)),
)
if latest and latest[0].get('snapshot_hash') == digest and not force:
return latest[0].get('id')
version_no = int(latest[0].get('version_no') or 0) + 1 if latest else 1
return query(
"INSERT INTO permission_versions "
"(target_type,target_id,version_no,snapshot_json,snapshot_hash,summary,source_version_id,created_by) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
(
target_type, str(target_id), version_no, payload, digest,
str(summary or '')[:500], source_version_id, session.get('user_id'),
),
)
def _ensure_permission_baseline(target_type, target_id):
rows = query(
"SELECT id FROM permission_versions WHERE target_type=%s AND target_id=%s LIMIT 1",
(target_type, str(target_id)),
)
if not rows:
return _create_permission_version(target_type, target_id, 'P2启用时的初始权限基线', force=True)
return rows[0].get('id')
def _allowed_pairs_from_items(items):
result = set()
for item in items or []:
try:
allowed = int(item.get('permission', 0)) == 1
except (TypeError, ValueError):
allowed = False
if not allowed:
continue
page = normalize_permission_page(str(item.get('page') or '').strip())
operation = str(item.get('operation') or '').strip()
if page and operation:
result.add(f'{page}.{operation}')
return result
def _effective_user_pairs(user_id, operation_items=None, field_items=None):
users = query("SELECT role,position FROM dashboard_users WHERE id=%s LIMIT 1", (user_id,))
if not users:
return set()
role = users[0].get('role') or ''
if role in ('admin', 'super_admin'):
return {'permissions.edit', 'users.edit'}
query_role = 'viewer' if role == '查看者' else role
role_rows = query(
"SELECT page,operation,permission FROM role_operation_permissions WHERE role=%s",
(query_role,),
)
user_rows = query(
"SELECT page,operation,permission FROM user_permissions WHERE user_id=%s",
(user_id,),
)
merged = {}
for row in role_rows:
merged[(normalize_permission_page(row.get('page')), row.get('operation'))] = int(row.get('permission') or 0)
ensure_position_permissions()
position = str(users[0].get('position') or '').strip()
if position:
for row in query("SELECT page,operation,permission FROM position_operation_permissions WHERE position_code=%s", (position,)):
merged[(normalize_permission_page(row.get('page')), row.get('operation'))] = int(row.get('permission') or 0)
for row in user_rows:
operation = str(row.get('operation') or '')
if operation.startswith('field:'):
if field_items is None:
merged[(normalize_permission_page(row.get('page')), operation)] = int(row.get('permission') or 0)
elif operation_items is None:
merged[(normalize_permission_page(row.get('page')), operation)] = int(row.get('permission') or 0)
for item in operation_items or []:
page = normalize_permission_page(str(item.get('page') or '').strip())
operation = str(item.get('operation') or '').strip()
if page and operation and not operation.startswith('field:'):
merged[(page, operation)] = int(item.get('permission') or 0)
for item in field_items or []:
page = normalize_permission_page(str(item.get('page') or '').strip())
field = str(item.get('field') or '').strip()
operation = str(item.get('operation') or '').strip()
value = item.get('permission')
if page and field and operation in ('view', 'edit'):
key = (page, f'field:{field}:{operation}')
if value is None or value == '':
# 继承时移除用户覆盖,保留角色值。
role_value = next((int(r.get('permission') or 0) for r in role_rows
if normalize_permission_page(r.get('page')) == page
and r.get('operation') == key[1]), 0)
merged[key] = role_value
else:
merged[key] = int(value)
return {f'{page}.{operation}' for (page, operation), value in merged.items() if int(value) == 1}
def _detect_permission_conflicts(allowed_pairs):
conflicts = []
for rule in _PERMISSION_CONFLICT_RULES:
left = next((item for item in rule['left'] if item in allowed_pairs), None)
right = next((item for item in rule['right'] if item in allowed_pairs), None)
if left and right:
conflicts.append({
'id': rule['id'], 'level': rule['level'], 'name': rule['name'],
'left': left, 'right': right, 'advice': rule['advice'],
})
return conflicts
def _restore_permission_snapshot(snapshot):
"""在事务中恢复单个角色或用户快照。"""
from services.db import get_db
target_type = snapshot.get('target_type')
target_id = str(snapshot.get('target_id'))
conn = get_db()
try:
with conn.cursor() as cur:
if target_type == 'role':
cur.execute("DELETE FROM role_permissions WHERE role=%s", (target_id,))
legacy = snapshot.get('legacy_pages') or []
if legacy:
cur.executemany(
"INSERT INTO role_permissions (role,page,permission) VALUES (%s,%s,%s)",
[(target_id, x.get('page'), int(x.get('permission') or 0)) for x in legacy],
)
cur.execute("DELETE FROM role_operation_permissions WHERE role=%s", (target_id,))
operations = snapshot.get('operations') or []
if operations:
cur.executemany(
"INSERT INTO role_operation_permissions (role,page,operation,permission) VALUES (%s,%s,%s,%s)",
[(target_id, x.get('page'), x.get('operation'), int(x.get('permission') or 0)) for x in operations],
)
elif target_type == 'position':
ensure_position_permissions()
cur.execute("DELETE FROM position_operation_permissions WHERE position_code=%s", (target_id,))
operations = snapshot.get('operations') or []
if operations:
cur.executemany(
"INSERT INTO position_operation_permissions (position_code,page,operation,permission) VALUES (%s,%s,%s,%s)",
[(target_id, x.get('page'), x.get('operation'), int(x.get('permission') or 0)) for x in operations],
)
elif target_type == 'user':
user_id = int(target_id)
cur.execute("DELETE FROM user_permissions WHERE user_id=%s", (user_id,))
operations = snapshot.get('operations') or []
if operations:
cur.executemany(
"INSERT INTO user_permissions (user_id,page,operation,permission) VALUES (%s,%s,%s,%s)",
[(user_id, x.get('page'), x.get('operation'), int(x.get('permission') or 0)) for x in operations],
)
else:
raise ValueError('不支持的权限对象类型')
cur.execute(
"DELETE FROM permission_data_scopes WHERE subject_type=%s AND subject_id=%s",
(target_type, target_id),
)
scopes = snapshot.get('data_scopes') or []
if scopes:
cur.executemany(
"INSERT INTO permission_data_scopes "
"(subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,created_by,updated_by) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
[(
target_type, target_id, normalize_permission_page(x.get('page')),
x.get('scope_type') or 'all', x.get('scope_value') or '',
int(x.get('can_view') or 0), int(x.get('can_edit') or 0), int(x.get('can_export') or 0),
session.get('user_id'), session.get('user_id'),
) for x in scopes],
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _flatten_permission_snapshot(snapshot):
flat = {}
for item in snapshot.get('legacy_pages') or []:
flat[f"页面:{normalize_permission_page(item.get('page'))}"] = int(item.get('permission') or 0)
for item in snapshot.get('operations') or []:
flat[f"操作:{normalize_permission_page(item.get('page'))}:{item.get('operation')}"] = int(item.get('permission') or 0)
for item in snapshot.get('data_scopes') or []:
flat[f"范围:{normalize_permission_page(item.get('page'))}"] = {
'scope_type': item.get('scope_type') or 'all',
'scope_value': item.get('scope_value') or '',
'can_view': int(item.get('can_view') or 0),
'can_edit': int(item.get('can_edit') or 0),
'can_export': int(item.get('can_export') or 0),
}
return flat
@auth_bp.route('/api/permissions/conflicts/check', methods=['POST'])
@login_required
def permission_conflicts_check():
"""检查角色或用户拟议权限是否存在职责冲突。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.get_json(silent=True) or {}
subject_type = str(data.get('subject_type') or 'role')
subject_id = str(data.get('subject_id') or '')
items = data.get('items') or []
field_items = data.get('field_items') or []
if subject_type in ('role', 'position'):
pairs = _allowed_pairs_from_items(items)
elif subject_type == 'user' and subject_id:
pairs = _effective_user_pairs(int(subject_id), operation_items=items, field_items=field_items)
else:
return jsonify({"code": 400, "msg": "参数错误"})
conflicts = _detect_permission_conflicts(pairs)
return jsonify({"code": 0, "data": {"conflicts": conflicts, "count": len(conflicts)}})
@auth_bp.route('/api/permissions/versions')
@login_required
def permission_versions():
"""列出角色或用户权限版本;首次读取自动建立当前基线。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
_ensure_permission_p1_tables()
target_type = str(request.args.get('target_type') or 'role')
target_id = str(request.args.get('target_id') or '')
if target_type not in ('role', 'position', 'user') or not target_id:
return jsonify({"code": 400, "msg": "参数错误"})
_ensure_permission_baseline(target_type, target_id)
rows = query(
"SELECT v.id,v.target_type,v.target_id,v.version_no,v.summary,v.source_version_id,v.created_by,v.created_at,u.real_name AS creator_name "
"FROM permission_versions v LEFT JOIN dashboard_users u ON u.id=v.created_by "
"WHERE v.target_type=%s AND v.target_id=%s ORDER BY v.version_no DESC",
(target_type, target_id),
)
for row in rows:
if row.get('created_at') and hasattr(row['created_at'], 'strftime'):
row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
return jsonify({"code": 0, "data": rows})
@auth_bp.route('/api/permissions/versions/detail')
@login_required
def permission_version_detail():
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
import json
version_id = request.args.get('id', type=int)
rows = query(
"SELECT v.*,u.real_name AS creator_name FROM permission_versions v "
"LEFT JOIN dashboard_users u ON u.id=v.created_by WHERE v.id=%s LIMIT 1",
(version_id,)
) if version_id else []
if not rows:
return jsonify({"code": 404, "msg": "权限版本不存在"})
row = rows[0]
if row.get('created_at') and hasattr(row['created_at'], 'strftime'):
row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
snapshot = json.loads(row.get('snapshot_json') or '{}')
return jsonify({"code": 0, "data": {"meta": {k: v for k, v in row.items() if k != 'snapshot_json'}, "snapshot": snapshot}})
@auth_bp.route('/api/permissions/versions/compare')
@login_required
def permission_versions_compare():
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
import json
left_id = request.args.get('left', type=int)
right_id = request.args.get('right', type=int)
rows = query("SELECT id,snapshot_json FROM permission_versions WHERE id IN (%s,%s)", (left_id, right_id)) if left_id and right_id else []
mapping = {row['id']: json.loads(row.get('snapshot_json') or '{}') for row in rows}
if left_id not in mapping or right_id not in mapping:
return jsonify({"code": 404, "msg": "对比版本不存在"})
left = _flatten_permission_snapshot(mapping[left_id])
right = _flatten_permission_snapshot(mapping[right_id])
keys = sorted(set(left) | set(right))
changes = []
for key in keys:
if left.get(key) != right.get(key):
changes.append({'key': key, 'before': left.get(key), 'after': right.get(key)})
return jsonify({"code": 0, "data": {"changes": changes, "count": len(changes)}})
@auth_bp.route('/api/permissions/versions/rollback', methods=['POST'])
@login_required
def permission_version_rollback():
"""将单个角色或用户权限回滚到指定版本,并保留回滚前/后的版本。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
import json
data = request.get_json(silent=True) or {}
version_id = int(data.get('version_id') or 0)
rows = query("SELECT * FROM permission_versions WHERE id=%s LIMIT 1", (version_id,)) if version_id else []
if not rows:
return jsonify({"code": 404, "msg": "权限版本不存在"})
row = rows[0]
target_type, target_id = row['target_type'], str(row['target_id'])
if target_type == 'role' and target_id == 'super_admin':
return jsonify({"code": 403, "msg": "超级管理员角色不可回滚"})
if session.get('role') == 'admin' and ((target_type == 'role' and target_id == 'admin')):
return jsonify({"code": 403, "msg": "管理员不能回滚管理员角色"})
if target_type == 'user':
users = query("SELECT role FROM dashboard_users WHERE id=%s", (int(target_id),))
if not users or users[0].get('role') == 'super_admin':
return jsonify({"code": 403, "msg": "目标用户不可回滚"})
if session.get('role') == 'admin' and users[0].get('role') == 'admin':
return jsonify({"code": 403, "msg": "管理员不能回滚其他管理员"})
current = _permission_snapshot(target_type, target_id)
_create_permission_version(target_type, target_id, '回滚前自动备份', snapshot=current, force=True)
snapshot = json.loads(row.get('snapshot_json') or '{}')
_restore_permission_snapshot(snapshot)
new_id = _create_permission_version(
target_type, target_id, f"回滚到 V{row['version_no']}",
snapshot=_permission_snapshot(target_type, target_id), source_version_id=version_id, force=True,
)
_record_permission_change(target_type, target_id, 'version_rollback', before=current, after=snapshot, detail=f"回滚到权限版本 V{row['version_no']}")
add_operation_log('权限变更', f"{target_type}={target_id} 回滚到权限版本 V{row['version_no']}")
return jsonify({"code": 0, "msg": "权限回滚成功", "data": {"version_id": new_id}})
# ─── API: 角色权限管理 ───
@auth_bp.route('/api/permissions/pages')
@login_required
def permission_pages():
"""获取所有可配置的页面列表"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
pages = _sidebar_menu_pages()
return jsonify({"code": 0, "data": pages})
@auth_bp.route('/api/permissions/menu-tree')
@login_required
def permission_menu_tree():
"""权限配置菜单树:页面 + 分组 + 操作特征,由侧边栏自动生成。"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
tree = []
index = {}
for pg in _sidebar_menu_pages():
g = pg['group']
if g not in index:
index[g] = {'name': g, 'items': []}
tree.append(index[g])
index[g]['items'].append({
'page': pg['id'],
'name': pg['name'],
'features': PAGE_FEATURES.get(pg['id'], DEFAULT_FEATURES),
})
return jsonify({"code": 0, "data": tree})
@auth_bp.route('/api/permissions/fields')
@login_required
def permission_fields():
"""获取所有页面的字段清单(单一来源),含敏感字段标记,供权限配置矩阵渲染。"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
from services.field_perms import FIELD_REGISTRY, _FIELD_REGISTRY_ALIASES
data = {}
for page, fields in FIELD_REGISTRY.items():
data[page] = [
{'key': f['key'], 'label': f['key'], 'sensitive': bool(f.get('sensitive'))}
for f in fields
]
# 运行页面 ID 别名共享同一字段清单(如 product_packaging → product_new、purchase → purchase_pending)
for runtime_id, canonical_id in _FIELD_REGISTRY_ALIASES.items():
if canonical_id in data:
data.setdefault(runtime_id, data[canonical_id])
return jsonify({"code": 0, "data": data})
@auth_bp.route('/api/permissions/list')
@login_required
def permission_list():
"""获取所有角色的权限配置"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
perms = query(
"SELECT role, page, permission FROM role_permissions ORDER BY role, page"
)
return jsonify({"code": 0, "data": perms})
@auth_bp.route('/api/permissions/roles')
@login_required
def permission_roles():
"""获取所有可配置的角色列表"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
roles = [
{'id': 'super_admin', 'name': '超级管理员', 'system': True},
{'id': 'admin', 'name': '管理员', 'system': True},
{'id': 'viewer', 'name': '查看者', 'system': True},
]
return jsonify({"code": 0, "data": roles})
@auth_bp.route('/api/permissions/save', methods=['POST'])
@login_required
def permission_save():
"""保存角色权限配置"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.get_json(silent=True) or {}
items = data.get('items', [])
affected_roles = sorted({str(item.get('role') or '') for item in items if item.get('role')})
for role_id in affected_roles:
_ensure_permission_baseline('role', role_id)
try:
for item in items:
role = item.get('role')
page = item.get('page')
permission = int(item.get('permission', 0))
if permission:
query(
"INSERT INTO role_permissions (role, page, permission) VALUES (%s, %s, 1) "
"ON DUPLICATE KEY UPDATE permission=1",
(role, page)
)
else:
query(
"DELETE FROM role_permissions WHERE role=%s AND page=%s",
(role, page)
)
for role_id in affected_roles:
_create_permission_version('role', role_id, '保存角色页面权限')
add_operation_log('权限变更', '保存角色权限配置')
_record_permission_change('role', ','.join(affected_roles) or 'bulk', 'page_permission_save', after=items, detail='保存角色页面权限配置')
return jsonify({"code": 0, "msg": "保存成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
# ─── API: 用户权限管理 ───
OPERATIONS = [
{'id': 'view', 'name': '查看'},
{'id': 'create', 'name': '新增'},
{'id': 'edit', 'name': '编辑'},
{'id': 'delete', 'name': '删除'},
{'id': 'export', 'name': '导出'},
{'id': 'import', 'name': '导入'},
]
@auth_bp.route('/api/permissions/operations')
@login_required
def permission_operations():
"""获取所有可配置的操作列表"""
return jsonify({"code": 0, "data": OPERATIONS})
# ─── API: 角色操作权限管理 ───
@auth_bp.route('/api/permissions/role-operations')
@login_required
def permission_role_operations():
"""获取所有角色的操作权限配置"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
perms = query(
"SELECT role, page, operation, permission FROM role_operation_permissions ORDER BY role, page, operation"
)
return jsonify({"code": 0, "data": perms})
@auth_bp.route('/api/permissions/role-operations/save', methods=['POST'])
@login_required
def permission_role_operations_save():
"""保存角色操作权限配置,并执行职责冲突检查与版本留档。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.get_json(silent=True) or {}
items = data.get('items', [])
if session.get('role') == 'admin' and any(item.get('role') in ('admin', 'super_admin') for item in items):
return jsonify({"code": 403, "msg": "管理员不能修改管理员或超级管理员的角色权限"})
affected_roles = sorted({str(item.get('role') or '') for item in items if item.get('role')})
all_conflicts = []
for role_id in affected_roles:
role_items = [item for item in items if str(item.get('role') or '') == role_id]
for conflict in _detect_permission_conflicts(_allowed_pairs_from_items(role_items)):
current = dict(conflict)
current['subject_id'] = role_id
all_conflicts.append(current)
if all_conflicts and not data.get('confirm_conflicts'):
return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": all_conflicts}}), 409
for role_id in affected_roles:
_ensure_permission_baseline('role', role_id)
try:
from services.db import get_db
conn = get_db()
with conn.cursor() as cur:
for item in items:
role = item.get('role')
page = normalize_permission_page(item.get('page'))
operation = item.get('operation')
permission = int(item.get('permission', 0))
cur.execute(
"INSERT INTO role_operation_permissions (role, page, operation, permission) "
"VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE permission=%s",
(role, page, operation, permission, permission)
)
conn.commit()
conn.close()
for role_id in affected_roles:
_create_permission_version('role', role_id, '保存角色操作/字段权限')
add_operation_log('权限变更', '保存角色操作权限配置')
_record_permission_change('role', ','.join(affected_roles) or 'bulk', 'role_operation_save', after=items, detail='保存角色操作权限配置')
return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": all_conflicts}})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
@auth_bp.route('/api/permissions/positions')
@login_required
def permission_positions():
"""获取岗位权限矩阵使用的启用岗位。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
ensure_position_permissions()
rows = query("""SELECT p.position_code AS id,p.position_name AS name,p.sort_order,
COUNT(u.id) AS user_count
FROM user_positions p
LEFT JOIN dashboard_users u ON BINARY u.position=BINARY p.position_code
WHERE p.status=1
GROUP BY p.id,p.position_code,p.position_name,p.sort_order
ORDER BY p.sort_order,p.id""")
return jsonify({"code": 0, "data": rows})
@auth_bp.route('/api/permissions/position-operations')
@login_required
def permission_position_operations():
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
ensure_position_permissions()
rows = query("SELECT position_code AS role,page,operation,permission FROM position_operation_permissions ORDER BY position_code,page,operation")
return jsonify({"code": 0, "data": rows})
@auth_bp.route('/api/permissions/position-operations/save', methods=['POST'])
@login_required
def permission_position_operations_save():
"""保存岗位操作/字段权限。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
ensure_position_permissions()
data = request.get_json(silent=True) or {}
items = data.get('items') or []
positions = sorted({str(item.get('position_code') or item.get('role') or '') for item in items if item.get('position_code') or item.get('role')})
conflicts = []
for code in positions:
proposed = [item for item in items if str(item.get('position_code') or item.get('role') or '') == code]
for conflict in _detect_permission_conflicts(_allowed_pairs_from_items(proposed)):
current = dict(conflict); current['subject_id'] = code; conflicts.append(current)
if conflicts and not data.get('confirm_conflicts'):
return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": conflicts}}), 409
for code in positions:
_ensure_permission_baseline('position', code)
try:
from services.db import get_db
conn = get_db()
with conn.cursor() as cur:
for item in items:
code = str(item.get('position_code') or item.get('role') or '')
cur.execute("""INSERT INTO position_operation_permissions(position_code,page,operation,permission)
VALUES (%s,%s,%s,%s) ON DUPLICATE KEY UPDATE permission=%s""",
(code, normalize_permission_page(item.get('page')), item.get('operation'), int(item.get('permission') or 0), int(item.get('permission') or 0)))
conn.commit()
conn.close()
for code in positions:
_create_permission_version('position', code, '保存岗位操作/字段权限')
_record_permission_change('position', ','.join(positions), 'position_operation_save', after=items, detail='保存岗位操作权限')
add_operation_log('权限变更', '保存岗位操作权限配置')
return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": conflicts}})
except Exception as exc:
return jsonify({"code": 500, "msg": str(exc)})
@auth_bp.route('/api/permissions/users')
@login_required
def permission_users():
"""获取用户的权限配置(按用户分组)"""
if session.get("role") not in ("admin", "super_admin"):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
users = query(
"SELECT u.id, u.username, u.real_name, u.role, u.position, u.user_level, "
"COALESCE(NULLIF(p.position_name, ''), NULLIF(u.position, ''), '未设置岗位') AS position_name "
"FROM dashboard_users u "
"LEFT JOIN user_positions p ON BINARY p.position_code=BINARY u.position "
"ORDER BY FIELD(u.role, 'super_admin','admin'), u.real_name"
)
# 获取所有用户权限设置
user_perms = query(
"SELECT user_id, page, operation, permission FROM user_permissions ORDER BY user_id, page"
)
# 构建用户权限字典
perm_map = {}
for p in user_perms:
uid = p['user_id']
if uid not in perm_map:
perm_map[uid] = {}
if p['page'] not in perm_map[uid]:
perm_map[uid][p['page']] = {}
perm_map[uid][p['page']][p['operation']] = p['permission']
for u in users:
u['permissions'] = perm_map.get(u['id'], {})
return jsonify({"code": 0, "data": users})
@auth_bp.route('/api/permissions/user/save', methods=['POST'])
@login_required
def permission_user_save():
"""保存单个用户的操作权限,执行职责冲突检查与版本留档。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.get_json(silent=True) or {}
user_id = data.get('user_id')
items = data.get('items', [])
if not user_id:
return jsonify({"code": 400, "msg": "参数错误"})
target = query("SELECT role FROM dashboard_users WHERE id=%s", (user_id,))
if not target:
return jsonify({"code": 400, "msg": "用户不存在"})
if target[0]['role'] == 'super_admin':
return jsonify({"code": 403, "msg": "超级管理员权限不可修改"})
if session.get('role') == 'admin' and target[0]['role'] == 'admin':
return jsonify({"code": 403, "msg": "管理员不能修改其他管理员的个人权限"})
conflicts = _detect_permission_conflicts(_effective_user_pairs(int(user_id), operation_items=items))
if conflicts and not data.get('confirm_conflicts'):
return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": conflicts}}), 409
_ensure_permission_baseline('user', user_id)
try:
from services.db import get_db
conn = get_db()
with conn.cursor() as cur:
upsert_map = {}
for item in items:
page = normalize_permission_page(str(item.get('page', '')).strip())
operation = str(item.get('operation', 'view')).strip()
if not page or not operation or operation.startswith('field:'):
continue
upsert_map[(page, operation)] = 1 if int(item.get('permission', 0)) == 1 else 0
cur.execute("DELETE FROM user_permissions WHERE user_id=%s AND operation NOT LIKE 'field:%%'", (user_id,))
rows = [(user_id, page, operation, value) for (page, operation), value in upsert_map.items()]
if rows:
cur.executemany(
"INSERT INTO user_permissions (user_id,page,operation,permission) VALUES (%s,%s,%s,%s)", rows
)
conn.commit()
conn.close()
_create_permission_version('user', user_id, '保存用户操作权限')
add_operation_log('权限变更', f'保存用户 #{user_id} 权限配置')
_record_permission_change('user', user_id, 'user_operation_save', after=items, detail=f'保存用户 #{user_id} 操作权限')
return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": conflicts}})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
@auth_bp.route('/api/permissions/user/field-perms')
@login_required
def permission_user_field_perms():
"""获取用户字段级权限 + 页面级有效权限(角色+用户合并)"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
user_id = request.args.get('user_id', type=int)
if not user_id:
return jsonify({"code": 400, "msg": "参数错误"})
# 获取该用户信息
user = query("SELECT role, position FROM dashboard_users WHERE id=%s", (user_id,))
if not user:
return jsonify({"code": 400, "msg": "用户不存在"})
user_role = user[0]['role']
# 获取角色级权限
role_perms = query(
"SELECT page, operation, permission FROM role_operation_permissions WHERE role=%s",
('viewer' if user_role == '查看者' else user_role,)
)
ensure_position_permissions()
position_code = str(user[0].get('position') or '').strip()
position_perms = query(
"SELECT page,operation,permission FROM position_operation_permissions WHERE position_code=%s",
(position_code,)
) if position_code else []
# 获取用户级字段权限
user_perms = query(
"SELECT page, operation, permission FROM user_permissions WHERE user_id=%s",
(user_id,)
)
# 构建用户级权限字典
user_perm_map = {}
user_field_map = {} # 字段级权限
user_op_map = {} # 操作级权限
for p in user_perms:
page = normalize_permission_page(p['page'])
op = p['operation']
if op.startswith('field:'):
if page not in user_field_map:
user_field_map[page] = {}
parts = op.split(':', 2)
if len(parts) == 3:
fname, otype = parts[1], parts[2]
else:
fname, otype = parts[1], 'view'
if fname not in user_field_map[page]:
user_field_map[page][fname] = {}
user_field_map[page][fname][otype] = p['permission']
else:
if page not in user_op_map:
user_op_map[page] = {}
user_op_map[page][op] = p['permission']
# 角色权限作为默认值,用户级配置覆盖角色级配置;0 代表明确拒绝。
effective_op_perms = {}
for p in role_perms:
page = normalize_permission_page(p['page'])
effective_op_perms.setdefault(page, {})[p['operation']] = int(p['permission'])
for p in position_perms:
page = normalize_permission_page(p['page'])
effective_op_perms.setdefault(page, {})[p['operation']] = int(p['permission'])
for p in user_perms:
page = normalize_permission_page(p['page'])
op = p['operation']
if op.startswith('field:'):
continue
effective_op_perms.setdefault(page, {})[op] = int(p['permission'])
return jsonify({"code": 0, "data": {
"field_perms": user_field_map,
"role_perms": effective_op_perms,
"role_field_perms": _inherited_field_perms_map(user_id, user_role),
"role_defaults": {
page: {
p['operation']: int(p['permission'])
for p in (role_perms + position_perms)
if normalize_permission_page(p['page']) == page and not p['operation'].startswith('field:')
}
for page in {normalize_permission_page(p['page']) for p in (role_perms + position_perms)}
},
"position_code": position_code,
"user_overrides": user_op_map
}})
@auth_bp.route('/api/permissions/data-scopes')
@login_required
def permission_data_scopes():
"""读取角色/用户的数据范围配置。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
_ensure_permission_p1_tables()
subject_type = (request.args.get('subject_type') or 'role').strip()
subject_id = (request.args.get('subject_id') or '').strip()
if subject_type not in ('role', 'position', 'user') or not subject_id:
return jsonify({"code": 400, "msg": "参数错误"})
rows = query(
"SELECT id,subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,updated_by,updated_at "
"FROM permission_data_scopes WHERE subject_type=%s AND subject_id=%s ORDER BY page",
(subject_type, subject_id),
)
return jsonify({"code": 0, "data": rows})
@auth_bp.route('/api/permissions/data-scopes/save', methods=['POST'])
@login_required
def permission_data_scopes_save():
"""保存角色/用户数据范围,并生成权限版本。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
_ensure_permission_p1_tables()
data = request.get_json(silent=True) or {}
subject_type = str(data.get('subject_type') or 'role').strip()
subject_id = str(data.get('subject_id') or '').strip()
items = data.get('items') or []
if subject_type not in ('role', 'position', 'user') or not subject_id:
return jsonify({"code": 400, "msg": "参数错误"})
_ensure_permission_baseline(subject_type, subject_id)
before = query(
"SELECT page,scope_type,scope_value,can_view,can_edit,can_export FROM permission_data_scopes "
"WHERE subject_type=%s AND subject_id=%s ORDER BY page", (subject_type, subject_id)
)
conn = None
try:
from services.db import get_db
conn = get_db()
with conn.cursor() as cur:
for item in items:
page = normalize_permission_page(str(item.get('page') or '').strip())
if not page:
continue
scope_type = str(item.get('scope_type') or 'all').strip()
if scope_type not in ('all', 'self', 'follower', 'planner', 'product_type'):
raise ValueError('不支持的数据范围类型')
values = (
subject_type, subject_id, page, scope_type, str(item.get('scope_value') or ''),
int(bool(item.get('can_view', True))), int(bool(item.get('can_edit', False))),
int(bool(item.get('can_export', False))), session.get('user_id'), session.get('user_id'),
scope_type, str(item.get('scope_value') or ''), int(bool(item.get('can_view', True))),
int(bool(item.get('can_edit', False))), int(bool(item.get('can_export', False))), session.get('user_id'),
)
cur.execute(
"INSERT INTO permission_data_scopes "
"(subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,created_by,updated_by) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
"ON DUPLICATE KEY UPDATE scope_type=%s,scope_value=%s,can_view=%s,can_edit=%s,can_export=%s,updated_by=%s",
values,
)
conn.commit()
conn.close()
after = query(
"SELECT page,scope_type,scope_value,can_view,can_edit,can_export FROM permission_data_scopes "
"WHERE subject_type=%s AND subject_id=%s ORDER BY page", (subject_type, subject_id)
)
_create_permission_version(subject_type, subject_id, '保存数据范围权限')
_record_permission_change(subject_type, subject_id, 'data_scope_save', before=before, after=after, detail='保存数据范围权限')
add_operation_log('权限变更', f'保存{subject_type}={subject_id}的数据范围权限')
return jsonify({"code": 0, "msg": "数据范围保存成功", "data": after})
except Exception as exc:
try:
if conn:
conn.rollback()
conn.close()
except Exception:
pass
return jsonify({"code": 500, "msg": str(exc)})
@auth_bp.route('/api/permissions/audits')
@login_required
def permission_audits():
"""读取权限变更审计记录。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
_ensure_permission_p1_tables()
try:
page = max(request.args.get('page', 1, type=int), 1)
size = min(max(request.args.get('size', 30, type=int), 1), 100)
keyword = (request.args.get('keyword') or '').strip()
where, params = [], []
if keyword:
where.append('(target_id LIKE %s OR action LIKE %s OR detail LIKE %s)')
params.extend([f'%{keyword}%', f'%{keyword}%', f'%{keyword}%'])
clause = (' WHERE ' + ' AND '.join(where)) if where else ''
total = query('SELECT COUNT(*) AS total FROM permission_change_audits' + clause, params)[0]['total']
rows = query(
'SELECT a.id,a.actor_id,a.target_type,a.target_id,a.action,a.before_json,a.after_json,a.detail,a.created_at,u.real_name AS actor_name '
'FROM permission_change_audits a LEFT JOIN dashboard_users u ON u.id=a.actor_id' + clause +
' ORDER BY a.id DESC LIMIT %s OFFSET %s', params + [size, (page - 1) * size]
)
for row in rows:
if row.get('created_at') and hasattr(row['created_at'], 'strftime'):
row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}})
except Exception as exc:
return jsonify({"code": 500, "msg": str(exc)})
@auth_bp.route('/api/permissions/user/effective')
@login_required
def permission_user_effective():
"""返回用户最终生效权限及来源,供权限预览/排查使用。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
user_id = request.args.get('user_id', type=int)
if not user_id:
return jsonify({"code": 400, "msg": "参数错误"})
users = query(
"SELECT id, username, real_name, role, position, user_level, status "
"FROM dashboard_users WHERE id=%s LIMIT 1", (user_id,)
)
if not users:
return jsonify({"code": 404, "msg": "用户不存在"})
user = users[0]
role = user.get('role') or ''
query_role = 'viewer' if role == '查看者' else role
role_rows = query(
"SELECT page, operation, permission FROM role_operation_permissions WHERE role=%s",
(query_role,)
)
ensure_position_permissions()
position_code = str(user.get('position') or '').strip()
position_rows = query(
"SELECT page,operation,permission FROM position_operation_permissions WHERE position_code=%s",
(position_code,)
) if position_code else []
user_rows = query(
"SELECT page, operation, permission FROM user_permissions WHERE user_id=%s",
(user_id,)
)
role_page_rows = query(
"SELECT page, permission FROM role_permissions WHERE role=%s",
(role,)
)
def _index(rows, include_field=False):
result = {}
for row in rows:
page = normalize_permission_page(row.get('page'))
operation = row.get('operation')
if not include_field and str(operation or '').startswith('field:'):
continue
result.setdefault(page, {})[operation] = int(row.get('permission') or 0)
return result
role_map = _index(role_rows)
position_map = _index(position_rows)
user_map = _index(user_rows)
role_page_map = {
normalize_permission_page(row.get('page')): int(row.get('permission') or 0)
for row in role_page_rows
}
def _lookup(mapping, page, operation):
for alias in _permission_page_aliases(page):
canonical = normalize_permission_page(alias)
if canonical in mapping and operation in mapping[canonical]:
return mapping[canonical][operation]
return None
pages = []
for menu_page in _sidebar_menu_pages():
page = normalize_permission_page(menu_page['id'])
features = PAGE_FEATURES.get(page, DEFAULT_FEATURES)
operations = []
for feature in features:
op = feature['id']
if role in ('admin', 'super_admin'):
allowed, source = True, '管理员全权限'
else:
user_value = _lookup(user_map, page, op)
position_value = _lookup(position_map, page, op)
role_value = _lookup(role_map, page, op)
legacy_value = role_page_map.get(page) if op == 'view' else None
if user_value is not None:
allowed, source = bool(user_value), '用户级覆盖'
elif position_value is not None:
allowed, source = bool(position_value), '岗位权限'
elif role_value is not None:
allowed, source = bool(role_value), '角色默认'
elif legacy_value is not None:
allowed, source = bool(legacy_value), '历史页面权限'
else:
allowed, source = False, '未配置'
operations.append({
'id': op, 'name': feature.get('name') or op,
'allowed': bool(allowed), 'source': source,
})
pages.append({
'page': page, 'name': menu_page.get('name') or page,
'group': menu_page.get('group') or '其他',
'operations': operations,
})
# 字段权限使用与运行时相同的优先级:用户覆盖 > 角色默认 > 页面权限继承。
from services.field_perms import FIELD_REGISTRY, _FIELD_REGISTRY_ALIASES
fields = {}
field_role_map = _index(role_rows, include_field=True)
field_position_map = _index(position_rows, include_field=True)
field_user_map = _index(user_rows, include_field=True)
field_pages = dict(FIELD_REGISTRY)
for runtime_id, canonical_id in _FIELD_REGISTRY_ALIASES.items():
if canonical_id in field_pages:
field_pages.setdefault(runtime_id, field_pages[canonical_id])
for raw_page, field_list in field_pages.items():
page = normalize_permission_page(raw_page)
page_view = next((x['allowed'] for x in next((p['operations'] for p in pages if p['page'] == page), []) if x['id'] == 'view'), False)
page_edit = next((x['allowed'] for x in next((p['operations'] for p in pages if p['page'] == page), []) if x['id'] == 'edit'), False)
page_fields = []
for field in field_list:
key = field['key']
item = {'key': key, 'label': key, 'sensitive': bool(field.get('sensitive')), 'view': {}, 'edit': {}}
for operation, default_value in (('view', page_view), ('edit', page_edit)):
field_operation = f'field:{key}:{operation}'
user_value = _lookup(field_user_map, page, field_operation)
position_value = _lookup(field_position_map, page, field_operation)
role_value = _lookup(field_role_map, page, field_operation)
if role in ('admin', 'super_admin'):
allowed, source = True, '管理员全权限'
elif user_value is not None:
allowed, source = bool(user_value), '用户级覆盖'
elif position_value is not None:
allowed, source = bool(position_value), '岗位权限'
elif role_value is not None:
allowed, source = bool(role_value), '角色默认'
else:
allowed, source = bool(default_value), '继承页面权限'
item[operation] = {'allowed': allowed, 'source': source}
page_fields.append(item)
if page_fields:
fields[page] = page_fields
return jsonify({
'code': 0,
'data': {'user': user, 'pages': pages, 'fields': fields},
})
def _inherited_field_perms_map(user_id, role):
"""返回角色+岗位字段继承值,供用户权限面板显示。"""
from services.field_perms import FIELD_REGISTRY, inherited_field_perms_for_page
result = {}
for page in FIELD_REGISTRY:
perms = inherited_field_perms_for_page(user_id, role, page)
result[page] = {
key: {'view': 1 if value['view'] else 0, 'edit': 1 if value['edit'] else 0}
for key, value in perms.items()
}
return result
@auth_bp.route('/api/permissions/user/field-perms/save', methods=['POST'])
@login_required
def permission_user_field_save():
"""保存用户字段三态权限,执行职责冲突检查与版本留档。"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.get_json(silent=True) or {}
user_id = data.get('user_id')
items = data.get('items', [])
if not user_id:
return jsonify({"code": 400, "msg": "参数错误"})
target = query("SELECT role FROM dashboard_users WHERE id=%s", (user_id,))
if not target:
return jsonify({"code": 400, "msg": "用户不存在"})
if target[0]['role'] == 'super_admin':
return jsonify({"code": 403, "msg": "超级管理员权限不可修改"})
if session.get('role') == 'admin' and target[0]['role'] == 'admin':
return jsonify({"code": 403, "msg": "管理员不能修改其他管理员的个人权限"})
conflicts = _detect_permission_conflicts(_effective_user_pairs(int(user_id), field_items=items))
if conflicts and not data.get('confirm_conflicts'):
return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": conflicts}}), 409
_ensure_permission_baseline('user', user_id)
try:
from services.db import get_db
conn = get_db()
with conn.cursor() as cur:
for item in items:
page = normalize_permission_page(str(item.get('page', '')).strip())
field = str(item.get('field', '')).strip()
op = str(item.get('operation', 'view')).strip()
if not page or not field or op not in ('view', 'edit'):
continue
operation = f'field:{field}:{op}'
perm = item.get('permission')
if perm is None or perm == '':
cur.execute("DELETE FROM user_permissions WHERE user_id=%s AND page=%s AND operation=%s", (user_id, page, operation))
else:
value = 1 if int(perm) == 1 else 0
cur.execute(
"INSERT INTO user_permissions (user_id,page,operation,permission) VALUES (%s,%s,%s,%s) "
"ON DUPLICATE KEY UPDATE permission=%s", (user_id, page, operation, value, value)
)
conn.commit()
conn.close()
_create_permission_version('user', user_id, '保存用户字段权限')
add_operation_log('权限变更', f'保存用户 #{user_id} 字段级权限')
_record_permission_change('user', user_id, 'user_field_save', after=items, detail=f'保存用户 #{user_id} 字段级权限')
return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": conflicts}})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)})
# ─── 人员信息管理 ───
_DEFAULT_PERSONNEL_GROUPS = [
{'name': '跟单一组', 'leader': '方园霞', 'members': ['方园霞', '邝碧君', '余瑞霞', '卢浩然'], 'color': '#e6f4ff', 'tagColor': '#1677ff', 'icon': '👥'},
{'name': '跟单二组', 'leader': '王加飞', 'members': ['许晓珊', '刘伟', '王加飞', '邓文婷'], 'color': '#f0f5ff', 'tagColor': '#722ed1', 'icon': '👥'},
{'name': '跟单三组', 'leader': '蔡少铃', 'members': ['闫伟华', '蔡少铃', '颜于凯', '张文菁'], 'color': '#e6fffb', 'tagColor': '#13c2c2', 'icon': '👥'},
{'name': '现货小组', 'leader': '', 'members': ['莫碧霞', '范亚欢'], 'color': '#fff7e6', 'tagColor': '#fa8c16', 'icon': '👥'},
{'name': '计划小组', 'leader': '汤定毅', 'members': ['汤定毅', '李媛', '黄静', '胡孟怡', '张莹纯', '范世澄', '陈瑜'], 'color': '#f6ffed', 'tagColor': '#52c41a', 'icon': '📋'}
]
def _ensure_personnel_table():
query("""CREATE TABLE IF NOT EXISTS personnel_groups (
group_name VARCHAR(100) NOT NULL PRIMARY KEY,
group_order INT NOT NULL DEFAULT 0,
group_category VARCHAR(30) NOT NULL DEFAULT '采购跟单',
leader_name VARCHAR(100) NOT NULL DEFAULT '',
members_json TEXT NOT NULL,
color VARCHAR(20) NOT NULL DEFAULT '#fafafa',
tag_color VARCHAR(20) NOT NULL DEFAULT '#1677ff',
icon VARCHAR(20) NOT NULL DEFAULT '👥',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
category_col = query("""SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='personnel_groups'
AND COLUMN_NAME='group_category'""")
if not category_col:
query("ALTER TABLE personnel_groups ADD COLUMN group_category VARCHAR(30) NOT NULL DEFAULT '采购跟单' AFTER group_order")
query("UPDATE personnel_groups SET group_category='采购计划', icon='📋' WHERE group_name='计划小组'")
rows = query("SELECT group_name FROM personnel_groups")
if rows:
return
import json
for index, group in enumerate(_DEFAULT_PERSONNEL_GROUPS):
category = '采购计划' if group['name'] == '计划小组' else '采购跟单'
query("""INSERT INTO personnel_groups
(group_name, group_order, group_category, leader_name, members_json, color, tag_color, icon)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", (
group['name'], index, category, group['leader'], json.dumps(group['members'], ensure_ascii=False),
group['color'], group['tagColor'], group['icon']))
@auth_bp.route('/api/personnel/groups')
@login_required
def personnel_groups():
import json
_ensure_personnel_table()
rows = query("SELECT group_name, group_category, leader_name, members_json, color, tag_color, icon FROM personnel_groups ORDER BY group_order, group_name")
groups = []
for row in rows:
try:
members = json.loads(row['members_json'] or '[]')
except (TypeError, ValueError):
members = []
groups.append({
'name': row['group_name'], 'category': row['group_category'] or '采购跟单',
'leader': row['leader_name'] or '', 'members': members,
'color': row['color'], 'tagColor': row['tag_color'], 'icon': row['icon']
})
return jsonify({'code': 0, 'data': groups})
@auth_bp.route('/api/personnel/groups/create', methods=['POST'])
@login_required
def personnel_group_create():
import json
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({'code': 403, 'msg': '仅管理员可新增人员分组'})
data = request.get_json(silent=True) or {}
name = str(data.get('name') or '').strip()
category = str(data.get('category') or '').strip()
if not name:
return jsonify({'code': 400, 'msg': '组别名称不能为空'})
if category not in ('采购跟单', '采购计划'):
return jsonify({'code': 400, 'msg': '请选择正确的组别类型'})
_ensure_personnel_table()
if query("SELECT group_name FROM personnel_groups WHERE group_name=%s", (name,)):
return jsonify({'code': 400, 'msg': '该组别名称已存在'})
order_row = query("SELECT COALESCE(MAX(group_order), -1) + 1 AS next_order FROM personnel_groups")
next_order = int(order_row[0]['next_order']) if order_row else 0
icon = '📋' if category == '采购计划' else '👥'
query("""INSERT INTO personnel_groups
(group_name, group_order, group_category, leader_name, members_json, color, tag_color, icon)
VALUES (%s,%s,%s,'',%s,%s,%s,%s)""",
(name, next_order, category, json.dumps([], ensure_ascii=False), '#fafafa', '#1677ff', icon))
add_operation_log('人员信息管理', f'新增人员分组:{name}({category})')
return jsonify({'code': 0, 'msg': '新增成功'})
@auth_bp.route('/api/personnel/groups/save', methods=['POST'])
@login_required
def personnel_group_save():
import json
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({'code': 403, 'msg': '仅管理员可编辑人员分组'})
data = request.get_json(silent=True) or {}
name = str(data.get('name') or '').strip()
members = data.get('members') if isinstance(data.get('members'), list) else []
members = list(dict.fromkeys(str(member).strip() for member in members if str(member).strip()))
leader = str(data.get('leader') or '').strip()
if not name:
return jsonify({'code': 400, 'msg': '组别名称不能为空'})
if leader and leader not in members:
return jsonify({'code': 400, 'msg': '组长必须属于该组人员'})
_ensure_personnel_table()
existing = query("SELECT group_name FROM personnel_groups WHERE group_name=%s", (name,))
if not existing:
return jsonify({'code': 404, 'msg': '未找到该人员分组'})
other_rows = query("SELECT group_name, members_json FROM personnel_groups WHERE group_name<>%s", (name,))
for other in other_rows:
try:
other_members = json.loads(other['members_json'] or '[]')
except (TypeError, ValueError):
other_members = []
duplicated = next((member for member in members if member in other_members), '')
if duplicated:
return jsonify({'code': 400, 'msg': f'{duplicated} 已属于 {other["group_name"]},不能重复加入'})
query("""UPDATE personnel_groups SET leader_name=%s, members_json=%s
WHERE group_name=%s""", (leader, json.dumps(members, ensure_ascii=False), name))
add_operation_log('人员信息管理', f'保存人员分组:{name},共{len(members)}人')
return jsonify({'code': 0, 'msg': '保存成功'})
def _ensure_param_list_table():
"""确保云仓外仓权重列表存在并带有初始参数(幂等,部署后不会出现空表)。"""
query("""
CREATE TABLE IF NOT EXISTS param_list (
id INT AUTO_INCREMENT PRIMARY KEY,
param_name VARCHAR(50) NOT NULL COMMENT '参数名称',
param_logic VARCHAR(255) NOT NULL COMMENT '参数计算逻辑',
sort_order INT DEFAULT 0 COMMENT '排序',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='云仓外仓权重列表'
""")
rows = query("SELECT COUNT(*) AS cnt FROM param_list")
if not rows or not rows[0]['cnt']:
query("INSERT INTO param_list (param_name, param_logic, sort_order) VALUES (%s, %s, %s)",
('X', '云仓销量/昨日销量', 1))
query("INSERT INTO param_list (param_name, param_logic, sort_order) VALUES (%s, %s, %s)",
('Y', '外仓销量/昨日销量', 2))
@auth_bp.route('/api/config/list')
@login_required
def config_list():
_ensure_param_list_table()
trend_rows = query("SELECT * FROM trend_config ORDER BY id")
sales_rows = query("SELECT * FROM sales_predict_config ORDER BY id")
global_rows = query("SELECT * FROM global_config ORDER BY config_key")
param_rows = query("SELECT id, param_name, param_logic FROM param_list ORDER BY sort_order, id")
return jsonify({
"code": 0,
"data": {
"trend_config": trend_rows,
"sales_predict_config": sales_rows,
"global_config": global_rows,
"param_list": param_rows
}
})
@auth_bp.route('/api/config/save_global', methods=['POST'])
@login_required
def config_save_global():
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.json
for item in data:
if 'config_key' in item and 'config_value' in item:
query("UPDATE global_config SET config_value=%s WHERE config_key=%s",
(item['config_value'], item['config_key']))
add_operation_log('系统配置', f'保存全局配置:{len(data)}项')
return jsonify({"code": 0, "msg": "保存成功"})
@auth_bp.route('/api/config/save_trend', methods=['POST'])
@login_required
def config_save_trend():
"""保存趋势判断配置"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.json
for item in data:
if 'id' in item:
query("UPDATE trend_config SET trend_type=%s, condition_expr=%s, notes=%s WHERE id=%s",
(item.get('trend_type', ''), item.get('condition_expr', ''),
item.get('notes', ''), item['id']))
add_operation_log('系统配置', f'保存趋势判断配置:{len(data)}项')
return jsonify({"code": 0, "msg": "保存成功"})
@auth_bp.route('/api/config/save_sales', methods=['POST'])
@login_required
def config_save_sales():
"""保存销量预测配置"""
if session.get('role') not in ('admin', 'super_admin'):
return jsonify({"code": 403, "msg": "仅管理员可操作"})
data = request.get_json(silent=True)
if not isinstance(data, list) or not data:
return jsonify({"code": 400, "msg": "参数格式错误"})
# 先完整校验本次提交,避免部分行已写入、后续行校验失败。
limits = {
'avg_3d': Decimal('999.99'),
'avg_7d': Decimal('999.99'),
'avg_14d': Decimal('999.99'),
'adjust_factor': Decimal('99.99'),
}
parsed = []
try:
for item in data:
if not isinstance(item, dict) or 'id' not in item:
raise ValueError('缺少配置行ID')
try:
row_id = int(item['id'])
except (TypeError, ValueError):
raise ValueError('配置行ID无效')
values = {}
for field, maximum in limits.items():
try:
number = Decimal(str(item.get(field, '')).strip())
except (AttributeError, InvalidOperation, ValueError):
raise ValueError(f'{field} 必须是数字')
if not number.is_finite() or number < 0 or number > maximum:
raise ValueError(f'{field} 必须在 0 至 {maximum} 之间')
values[field] = number
trend_type = str(item.get('trend_type') or '').strip()
if not trend_type:
raise ValueError('趋势类型不能为空')
parsed.append((values['avg_3d'], values['avg_7d'], values['avg_14d'],
values['adjust_factor'], trend_type, row_id))
except ValueError as exc:
return jsonify({"code": 400, "msg": str(exc)})
for avg_3d, avg_7d, avg_14d, adjust_factor, trend_type, row_id in parsed:
query("UPDATE sales_predict_config SET avg_3d=%s, avg_7d=%s, avg_14d=%s, adjust_factor=%s, trend_type=%s WHERE id=%s",
(avg_3d, avg_7d, avg_14d, adjust_factor, trend_type, row_id))
from services.forecast import refresh_sales_predict_config
refresh_sales_predict_config()
add_operation_log('系统配置', f'保存销量预测配置:{len(parsed)}项')
return jsonify({"code": 0, "msg": "保存成功"})
@auth_bp.route('/api/config/sandbox_save', methods=['POST'])
@login_required
def config_sandbox_save():
"""保存校准配置(沙箱)"""
data = request.json
items = data.get('items', [])
for item in items:
ct = item.get('config_table', '')
rid = item.get('row_id', 0)
fn = item.get('field_name', '')
sv = item.get('sandbox_value', '')
ov = item.get('original_value', '')
query("""INSERT INTO config_sandbox (config_table, row_id, field_name, original_value, sandbox_value)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE sandbox_value=%s, original_value=%s""",
(ct, rid, fn, ov, sv, sv, ov))
add_operation_log('系统配置', f'保存校准配置(沙箱):{len(items)}项')
return jsonify({"code": 0, "msg": "保存成功"})
@auth_bp.route('/api/config/sandbox_load')
@login_required
def config_sandbox_load():
"""加载校准配置"""
rows = query("SELECT * FROM config_sandbox ORDER BY config_table, row_id, field_name")
return jsonify({"code": 0, "data": rows})
@auth_bp.route('/api/config/sandbox_preview')
@login_required
def config_sandbox_preview():
"""预览校准效果:返回少量产品在校准参数下的预测结果vs原始结果"""
from services.forecast import compute_daily_forecast, get_global_config, sales_predict_config_cached
from services.inventory_calc import compute_product_inventory_cached, compute_product_inventory_15, compute_product_in_transit_cached, _determine_trend
from services.sales_calc import compute_product_sales_cached
from services.product_type import get_product_type_cached
from routes.planning import _enrich_row_with_planning_data
from services.forecast import is_new_product, get_product_grade
# 获取原始产品数据(取前10个有库存的产品)
sample = query("""
SELECT p.product_code, p.product_name, p.brand, p.product_type,
p.cost_price,
p.lead_time AS 生产货期, p.logistics_time AS 物流时间,
p.created_at AS 创建时间
FROM products p
WHERE p.product_type != '包材'
ORDER BY RAND() LIMIT 10
""")
sales = compute_product_sales_cached()
inv_map = compute_product_inventory_cached()
inv15_map = compute_product_inventory_15()
transit_map = compute_product_in_transit_cached()
# 原始配置
gc = get_global_config()
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
# 沙箱配置(覆盖)
sandbox = query("SELECT * FROM config_sandbox")
sb_by_table = {}
for s in sandbox:
key = (s['config_table'], s['row_id'])
if key not in sb_by_table: sb_by_table[key] = {}
sb_by_table[key][s['field_name']] = s['sandbox_value']
original_rows = []
sandbox_rows = []
for row in sample:
if row.get('创建时间'):
row['创建时间'] = row['创建时间'].strftime('%Y-%m-%d') if hasattr(row['创建时间'], 'strftime') else str(row['创建时间'])[:10]
row['产品种类'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type'))
# 原始
r1 = dict(row)
r1 = _enrich_row_with_planning_data(r1, sales, inv_map, inv15_map, transit_map, sp_map, gc)
original_rows.append(r1)
# 校准(待实现:用沙箱参数覆盖后再计算)
r2 = dict(row)
# 为简化,暂时用相同值
r2 = _enrich_row_with_planning_data(r2, sales, inv_map, inv15_map, transit_map, sp_map, gc)
sandbox_rows.append(r2)
return jsonify({"code": 0, "data": {
"original": original_rows,
"sandbox": sandbox_rows
}})