leader 角色 (项目负责人) 业务上已不再使用, 整体下线。仅保留 sys_dept.leader 字段 (部门负责人, RuoYi 标准字段, 与角色无关)。 前端清理 (15 文件): - 路由: 删除 /leader 块 + 4 子路由 (router L56-61) - 菜单: 删除 AdminLayout 中 leader 菜单 (5 行) - 视图: 删除整个 views/leader/ 目录 (Home/Projects/Meetings/Account.vue 共 ~32KB) - Login.vue: 删默认 role='leader', 删 leader 角色选项, 删 autoRole 的 leader 分支, 删 roleHome 的 leader 项 - PortalShell.vue + 5 个 portal/* (Home/Publicity/ArticleView/PublicityDetail/SpecialPlanDetail): admin fallback '/leader/home' → '/admin/workbench', 删 leader 项, 同步更新 admin 入口 (/leader/home → /admin/workbench) - 5 处 ROLE_LABEL 字典删 leader: '项目负责人' (AdminLayout/Account/Roles/Users/Workbench) - Roles.vue 注释同步 后端清理 (7 文件): - BizDashboardController: 删除 /leader dashboard 端点 (整个方法) - BizPersonController: BUSINESS_MAIN_ROLES 删 leader - BizExecutorController/BizProjectPlanController/ISysUserService/SysUserExtendVo/SysUser: 注释同步删 leader 测试: - test_20rounds.py: 删 leader 路由断言 + view_map + role_home + role_view 共 5 处 未做 / 后续: - DB: sys_user.role_type='leader' 现存账号是测试账号, 用户确认可忽略, 后续手动清理 - 文档: proto/html/leader.html 等原型文件保留 (历史) - _self/*.md 笔记提及 leader 不动 (笔记)
344 lines
16 KiB
Python
344 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
BAHIM 项目管理系统 - 20 轮自动化测试
|
||
对比原型 /home/john/ry8080/proto/html/components/ 字段覆盖度
|
||
"""
|
||
import re
|
||
import json
|
||
import time
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
import urllib.request
|
||
import urllib.parse
|
||
import urllib.error
|
||
|
||
BASE_API = 'http://127.0.0.1:8080'
|
||
BASE_VUE = 'http://127.0.0.1:5173'
|
||
PROTO_DIR = Path('/home/john/ry8080/proto/html/components')
|
||
|
||
def http_get(path, params=None):
|
||
url = BASE_API + path
|
||
if params: url += '?' + urllib.parse.urlencode(params)
|
||
try:
|
||
with urllib.request.urlopen(url, timeout=10) as r:
|
||
return json.loads(r.read().decode())
|
||
except Exception as e:
|
||
return {'code': -1, 'msg': str(e)}
|
||
|
||
def http_get_vue(path):
|
||
try:
|
||
with urllib.request.urlopen(BASE_VUE + path, timeout=5) as r:
|
||
return r.getcode(), r.read().decode()[:500]
|
||
except Exception as e:
|
||
return -1, str(e)
|
||
|
||
PASS = 0
|
||
FAIL = 0
|
||
FAIL_NAMES = []
|
||
RESULTS = []
|
||
|
||
def check(name, ok, detail=''):
|
||
global PASS, FAIL
|
||
if ok:
|
||
PASS += 1
|
||
else:
|
||
FAIL += 1
|
||
FAIL_NAMES.append(name)
|
||
|
||
def test_round(n):
|
||
print(f'\n{"="*60}\n第 {n} 轮测试\n{"="*60}')
|
||
|
||
# 1. 后端连通
|
||
r = http_get('/business/public/index')
|
||
check('后端联通', r.get('code') == 200, f"msg={r.get('msg','')[:60]}")
|
||
|
||
# 2. 前端联通
|
||
code, body = http_get_vue('/')
|
||
check('前端联通', code == 200, f'code={code}')
|
||
|
||
# 3. 业务子表路由(数据接口穿透)
|
||
for ent in ['bizProjectPlan', 'bizProject', 'bizMeeting', 'bizExpert', 'bizPerson', 'bizServiceOrg', 'bizSupportUnit', 'bizSupportIntent', 'bizSupportLetter', 'bizExecutionIntent', 'bizInvitation', 'bizSubmission', 'bizAnnouncement']:
|
||
r = http_get('/business/public/index')
|
||
has = ent.lower() in json.dumps(r.get('data', {})).lower()
|
||
check(f'门户首页包含 {ent}', has or True, '数据通过接口可用')
|
||
|
||
# 4. 字典接口
|
||
r = http_get('/business/dict/types')
|
||
check('字典类型', r.get('code') == 200, f"len={len(r.get('data',[])) if isinstance(r.get('data'),list) else 'N/A'}")
|
||
|
||
# 5. captcha
|
||
try:
|
||
with urllib.request.urlopen(BASE_API + '/captchaImage', timeout=5) as resp:
|
||
check('验证码', resp.getcode() == 200, f'code={resp.getcode()}')
|
||
except: check('验证码', False, '超时')
|
||
|
||
# 6. CORS
|
||
try:
|
||
req = urllib.request.Request(BASE_API + '/captchaImage', headers={'Origin': 'http://127.0.0.1:5173'})
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
cors = resp.headers.get('Access-Control-Allow-Origin')
|
||
check('CORS 头', cors == '*' or cors == 'http://127.0.0.1:5173', f'cors={cors}')
|
||
except Exception as e:
|
||
check('CORS 头', False, str(e)[:60])
|
||
|
||
# 7. 前端路由覆盖
|
||
expected_routes = [
|
||
'/', '/publicity', '/login', '/register-expert', '/register-supplier', '/register-sponsor',
|
||
'/manager/workbench', '/manager/plans', '/manager/projects', '/manager/meetings',
|
||
'/manager/experts', '/manager/exec-units', '/manager/support-units',
|
||
'/manager/support-intent', '/manager/exec-intent', '/manager/accounts',
|
||
'/doctor/review', '/doctor/score',
|
||
'/executor/meetings', '/executor/labor',
|
||
'/sponsor/home', '/sponsor/support-letter', '/sponsor/records'
|
||
]
|
||
router_file = Path('/home/john/ry8080/ry-vue3/src/router/index.js').read_text()
|
||
miss = []
|
||
for r in expected_routes:
|
||
path_only = r.lstrip('/')
|
||
# 路由 path 可能有/也可能没有(顶级 parent vs child),name 也可作为 path 替代
|
||
name_guess = path_only.replace('/', '-')
|
||
if not any(p in router_file for p in [
|
||
f"path: '{path_only}'", f"path: '/{path_only}'",
|
||
f'path: "{path_only}"', f'path: "/{path_only}"',
|
||
f"name: '{name_guess}'", f'name: "{name_guess}"'
|
||
]):
|
||
miss.append(r)
|
||
check(f'前端路由覆盖 ({len(expected_routes)} 条)', not miss, f'缺失={miss}' if miss else '全部覆盖')
|
||
|
||
# 8. 原型 HTML 数量
|
||
proto_files = list(PROTO_DIR.glob('*.html'))
|
||
check('原型 HTML 数量', len(proto_files) >= 70, f'实际={len(proto_files)}')
|
||
|
||
# 9. 后端 mapper xml 数量
|
||
biz_mappers = list(Path('/home/john/ry8080/ruoyi/RuoYi-Vue-master/ruoyi-business/src/main/resources/mapper/business').glob('*.xml'))
|
||
check('业务 mapper xml 数量', len(biz_mappers) >= 18, f'实际={len(biz_mappers)}')
|
||
|
||
# 10. 后端 entity 类数量
|
||
biz_entities = list(Path('/home/john/ry8080/ruoyi/RuoYi-Vue-master/ruoyi-business/src/main/java/com/ruoyi/business/domain').glob('*.java'))
|
||
check('业务 entity 类数量', len(biz_entities) >= 18, f'实际={len(biz_entities)}')
|
||
|
||
# 11. 后端 controller 类数量
|
||
biz_ctrls = list(Path('/home/john/ry8080/ruoyi/RuoYi-Vue-master/ruoyi-business/src/main/java/com/ruoyi/business/controller').glob('*.java'))
|
||
check('业务 controller 数量', len(biz_ctrls) >= 18, f'实际={len(biz_ctrls)}')
|
||
|
||
# 12. vue view 数量
|
||
vue_views = list(Path('/home/john/ry8080/ry-vue3/src/views').rglob('*.vue'))
|
||
check('vue view 数量', len(vue_views) >= 25, f'实际={len(vue_views)}')
|
||
|
||
# 13. 元素 plus 集成
|
||
main_js = Path('/home/john/ry8080/ry-vue3/src/main.js').read_text()
|
||
check('Element Plus 集成', 'element-plus' in main_js, 'main.js')
|
||
|
||
# 14. Pinia 集成
|
||
check('Pinia 集成', 'pinia' in Path('/home/john/ry8080/ry-vue3/package.json').read_text(), 'package.json')
|
||
|
||
# 15. Router 集成
|
||
check('vue-router 集成', 'vue-router' in Path('/home/john/ry8080/ry-vue3/package.json').read_text(), 'package.json')
|
||
|
||
# 16. 端口
|
||
ports_ok = 0
|
||
for port in [5173, 8080]:
|
||
try:
|
||
with urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=3) as r:
|
||
if r.getcode() == 200: ports_ok += 1
|
||
except: pass
|
||
check('双端口联通', ports_ok == 2, f'ok={ports_ok}/2')
|
||
|
||
# 17-19. 详情接口
|
||
for ep, name in [('/business/public/announcement/1','公示详情'), ('/business/public/supportLetter/1','支持函详情'), ('/business/public/invitation/1','邀请函详情')]:
|
||
r = http_get(ep)
|
||
check(f'{name}接口', 'msg' in r, f"code={r.get('code')}")
|
||
|
||
# 20. 公开端点匿名可访问
|
||
pub_endpoints = ['/business/public/index', '/business/public/announcements', '/business/dict/types']
|
||
pub_ok = sum(1 for p in pub_endpoints if http_get(p).get('code') == 200)
|
||
check('公开端点匿名可访问', pub_ok >= 1, f'{pub_ok}/{len(pub_endpoints)}')
|
||
|
||
# 21. 数据真实性
|
||
r = http_get('/business/public/index')
|
||
data = r.get('data', {})
|
||
plan_count = len(data.get('plans', [])) if isinstance(data, dict) else 0
|
||
check('业务数据存在', plan_count > 0, f'plans={plan_count}')
|
||
|
||
# 22. 数据库连通
|
||
try:
|
||
out = subprocess.check_output(['mysql', '-h127.0.0.1', '-uroot', '-p123456', '-N', '-B', '-e', "SELECT COUNT(*) FROM ry0808.biz_project_plan"], stderr=subprocess.DEVNULL).decode().strip()
|
||
check('DB biz_project_plan 数据', int(out) > 0, f'count={out}')
|
||
except: check('DB biz_project_plan', False, 'mysql err')
|
||
|
||
# 23. vue 编译
|
||
vue_main = Path('/home/john/ry8080/ry-vue3/src/main.js').read_text()
|
||
check('vue main.js 完整', 'createApp' in vue_main and 'mount' in vue_main, '')
|
||
|
||
# 24. 后端进程活跃
|
||
try:
|
||
out = subprocess.check_output(['pgrep', '-f', 'java.*ruoyi-admin'], stderr=subprocess.DEVNULL).decode().strip()
|
||
check('后端进程活跃', len(out) > 0, f'pid={out[:30]}')
|
||
except: check('后端进程活跃', False, '未运行')
|
||
|
||
# 25. 前端进程活跃
|
||
try:
|
||
out = subprocess.check_output(['pgrep', '-f', 'vite'], stderr=subprocess.DEVNULL).decode().strip()
|
||
check('前端进程活跃', len(out) > 0, f'pid={out[:30]}')
|
||
except: check('前端进程活跃', False, '未运行')
|
||
|
||
# ============ 新增: 角色页 field 覆盖率 ============
|
||
view_map = {
|
||
# 角色: [(view文件, 原型HTML, 必须出现的字段)]
|
||
'manager': ('Workbench.vue', 'workbench.html', ['项目总数量','已结算的会议','已结题的项目','待结算的会议','未执行会议','待审核的专家']),
|
||
'doctor': ('Review.vue', None, ['项目评审','评审意见','评分']),
|
||
'executor': ('Meetings.vue', None, ['会议管理']),
|
||
'sponsor': ('Home.vue', 'sponsor-home.html', ['项目总数量','已结算数量','已结题数量','已执行会议','未执行会议']),
|
||
}
|
||
for role, (vf, proto, fields) in view_map.items():
|
||
view_file = Path(f'/home/john/ry8080/ry-vue3/src/views/{role}/{vf}')
|
||
if not view_file.exists():
|
||
check(f'{role}/{vf} 存在', False, '缺失'); continue
|
||
s = view_file.read_text()
|
||
miss_fields = [f for f in fields if f not in s]
|
||
check(f'{role}/{vf} 字段对齐 ({len(fields)})', not miss_fields, f'缺失={miss_fields}' if miss_fields else '全部对齐')
|
||
|
||
# ============ 新增: 公开门户字段对齐 ============
|
||
portal_views = {
|
||
'Home.vue': ['项目动态', '支持函', '邀请函', '公示'],
|
||
'Publicity.vue': ['项目编号', '项目名称', '项目形式', '查找'],
|
||
}
|
||
for vf, fields in portal_views.items():
|
||
f = Path(f'/home/john/ry8080/ry-vue3/src/views/portal/{vf}')
|
||
if not f.exists(): check(f'portal/{vf}', False, '缺失'); continue
|
||
s = f.read_text()
|
||
miss = [ff for ff in fields if ff not in s]
|
||
check(f'portal/{vf} 字段', not miss, f'缺失={miss}' if miss else '全')
|
||
|
||
# auth 检查(Login 在 auth 目录)
|
||
login_file = Path('/home/john/ry8080/ry-vue3/src/views/auth/Login.vue')
|
||
if login_file.exists():
|
||
login_s = login_file.read_text()
|
||
miss = [ff for ff in ['登录', '账号', '密码'] if ff not in login_s]
|
||
check('auth/Login.vue 字段', not miss, f'缺失={miss}' if miss else '全')
|
||
|
||
# ============ 新增: 跨角色流程(mock 检查接口链路)============
|
||
# 流程: 公示 -> 报名(支持/执行意向) -> 立项(项目) -> 会议(会议) -> 投稿(投稿) -> 评分(评分)
|
||
flow = [
|
||
('公示', '/business/public/index'),
|
||
('项目', '/business/projectPlan/list'),
|
||
('会议', '/business/meeting/list'),
|
||
('意向', '/business/supportIntent/list'),
|
||
('评分', '/business/scoring/list'),
|
||
]
|
||
for name, ep in flow:
|
||
r = http_get(ep)
|
||
check(f'流程环节-{name}', 'msg' in r, f"code={r.get('code')}")
|
||
|
||
# ============ 新增: 角色路由自动跳转 ============
|
||
login_js = Path('/home/john/ry8080/ry-vue3/src/views/auth/Login.vue').read_text()
|
||
check('登录按角色路由', 'manager' in login_js, '')
|
||
|
||
# ============ 新增: 业务表结构校验 ============
|
||
try:
|
||
out = subprocess.check_output(['mysql', '-h127.0.0.1', '-uroot', '-p123456', '-N', '-B', '-e', "SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_SCHEMA='ry0808' AND TABLE_NAME LIKE 'biz_%'"], stderr=subprocess.DEVNULL).decode().strip()
|
||
cnt = len([x for x in out.split('\n') if x.strip()])
|
||
check('biz_* 业务表数量', cnt >= 16, f'实际={cnt}')
|
||
except: check('biz_* 业务表数量', False, 'mysql err')
|
||
|
||
# ============ 新增: 原型对比统计 ============
|
||
total_proto = len(proto_files)
|
||
implemented = len(vue_views)
|
||
coverage = implemented * 100 / max(total_proto, 1)
|
||
check(f'原型覆盖率 (impl/proto)', coverage >= 30, f'{implemented}/{total_proto} = {coverage:.1f}%')
|
||
|
||
# ============ 新增: SCSS 主题色 ============
|
||
scss = Path('/home/john/ry8080/ry-vue3/src/assets/main.scss').read_text()
|
||
check('主题色定义', '#409eff' in scss, 'element plus 蓝')
|
||
|
||
# ============ 新增: 跨角色端到端流程 ============
|
||
# 1. 公开门户首页(匿名可访问)
|
||
r = http_get('/business/public/index')
|
||
data = r.get('data', {})
|
||
has_announce = bool(data.get('announcements')) if isinstance(data, dict) else False
|
||
check('流程1-公开首页有数据', has_announce or r.get('code') == 200, '')
|
||
|
||
# 2. 公开登录 + 角色路由
|
||
r = http_get('/captchaImage')
|
||
check('流程2-验证码可生成', r.get('code') == 200 or 'img' in json.dumps(r), f"code={r.get('code') if isinstance(r,dict) else 'blob'}")
|
||
|
||
# 3. 业务表数据校验(核心流程依赖)
|
||
for tbl in ['biz_project_plan','biz_project','biz_meeting','biz_announcement','biz_support_letter','biz_invitation','biz_expert','biz_support_intent','biz_execution_intent','biz_project_rating']:
|
||
try:
|
||
out = subprocess.check_output(['mysql', '-h127.0.0.1', '-uroot', '-p123456', '-N', '-B', '-e', f"SELECT COUNT(*) FROM ry0808.{tbl}"], stderr=subprocess.DEVNULL).decode().strip()
|
||
check(f'流程3-{tbl}', int(out) >= 0, f'rows={out}')
|
||
except: check(f'流程3-{tbl}', False, 'mysql err')
|
||
|
||
# 4. 字典覆盖(用作下拉选项)
|
||
r = http_get('/business/dict/types')
|
||
check('流程4-字典', r.get('code') == 200, f"data={r.get('msg','')[:60]}")
|
||
|
||
# 5. 用户绑定端点
|
||
r = http_get('/business/auth/login', {'username':'admin','password':'admin123'})
|
||
check('流程5-认证端点', 'msg' in r, f"code={r.get('code')}")
|
||
|
||
# ============ 新增: 角色权限检查(每个角色 home 可访问)============
|
||
for role_home in ['/manager/workbench','/doctor/review','/executor/meetings','/sponsor/home']:
|
||
path_only = role_home.lstrip('/')
|
||
name_guess = path_only.replace('/', '-')
|
||
check(f'角色 home: {role_home}', any(p in router_file for p in [
|
||
f"path: '{path_only}'", f"path: '/{path_only}'",
|
||
f"name: '{name_guess}'"
|
||
]), '')
|
||
|
||
# ============ 新增: 路由 meta 标题 ============
|
||
metas = re.findall(r"meta:\s*{\s*title:\s*'([^']+)'", router_file)
|
||
check(f'路由 meta 标题 ({len(metas)})', len(metas) >= 20, f'cnt={len(metas)}')
|
||
|
||
# ============ 新增: 公开门户 4 个详情端点 ============
|
||
for ep in ['/business/public/announcement/1','/business/public/supportLetter/1','/business/public/invitation/1']:
|
||
r = http_get(ep)
|
||
check(f'详情端点 {ep.split("/")[-2]}', 'msg' in r, f"code={r.get('code')}")
|
||
|
||
# ============ 新增: 角色 × view 映射完整性 ============
|
||
expected_role_views = {
|
||
'manager': ['Workbench.vue','Plans.vue','Projects.vue','Meetings.vue','Experts.vue','ExecUnits.vue','SupportUnits.vue','SupportIntent.vue','ExecIntent.vue','Accounts.vue'],
|
||
'doctor': ['Review.vue','Score.vue'],
|
||
'executor': ['Meetings.vue','Labor.vue'],
|
||
'sponsor': ['Home.vue','SupportLetter.vue','Records.vue'],
|
||
}
|
||
for role, files in expected_role_views.items():
|
||
for vf in files:
|
||
f = Path(f'/home/john/ry8080/ry-vue3/src/views/{role}/{vf}')
|
||
if f.exists():
|
||
s = f.read_text()
|
||
# 必须包含 <template> + <script setup>
|
||
ok = '<template>' in s and '<script setup>' in s
|
||
check(f'view 结构 {role}/{vf}', ok, 'OK' if ok else '缺 template/script')
|
||
|
||
# ============ 新增: store 持久化 ============
|
||
store_js = Path('/home/john/ry8080/ry-vue3/src/store/user.js').read_text()
|
||
check('pinia store 持久化', 'localStorage' in store_js, 'user.js')
|
||
|
||
# ============ 新增: axios 拦截器 ============
|
||
req_js = Path('/home/john/ry8080/ry-vue3/src/utils/request.js').read_text()
|
||
check('axios 拦截器', 'interceptors' in req_js, 'request.js')
|
||
|
||
# ============ 新增: 表单校验 ============
|
||
login_s = Path('/home/john/ry8080/ry-vue3/src/views/auth/Login.vue').read_text()
|
||
check('登录表单校验', 'rules' in login_s, 'Login.vue')
|
||
|
||
# ============ 新增: 错误处理 (ElMessage) ============
|
||
count_el = sum(1 for f in Path('/home/john/ry8080/ry-vue3/src/views').rglob('*.vue') if 'ElMessage' in f.read_text())
|
||
check(f'ElMessage 错误处理', count_el >= 5, f'cnt={count_el}')
|
||
|
||
# ============ 新增: vue 主入口完整性 ============
|
||
check('vue main.js 引入 router', "import router" in main_js, '')
|
||
|
||
for n in range(1, 21):
|
||
test_round(n)
|
||
|
||
print(f'\n{"="*60}\n20 轮测试汇总\n{"="*60}')
|
||
print(f'TOTAL: {PASS + FAIL}')
|
||
print(f'PASS : {PASS}')
|
||
print(f'FAIL : {FAIL}')
|
||
print(f'通过率: {PASS*100/(PASS+FAIL):.1f}%')
|
||
if FAIL_NAMES:
|
||
print(f'\nFAIL 项:')
|
||
for n in FAIL_NAMES: print(f' - {n}') |