"""认证/登录/权限管理路由"""
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'],
}
# 只对无歧义的现有页面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':'删除申请'}],
'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':'删除'}],
'feedback': [{'id':'view','name':'查看'},{'id':'submit','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':'关系图谱'}],
'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':'删除'}],
}
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']*class="[^"]*nav-item[^"]*"[^>]*data-page="([^"]+)"[^>]*>.*?([^<]+)',
html, re.S)]
# 分组头(位置 + 分组名)
group_headers = [(m.start(), m.group(1)) for m in re.finditer(
r']*class="[^"]*nav-group-header[^"]*"[^>]*>.*?([^<]+)',
html, re.S)]
submenu_re = re.compile(r'', 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*[^<]*\s*([^<]+)',
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
}})