67837df8d0
- 新增全套考试卷、评估卷、参考答案与考试培训包模板 - 修正slidev培训课件:调整模块标题、链路图缩放,替换工单示例为通用业务对象 - 修正课堂红线表述为证据红线,将小组作业调整为个人作业要求 - 新增权限、状态流转、审计日志、看板指标四类可复用业务规则块 - 整理AI工程技能交付包,迁移旧资产至客户专属交付包并统一术语 - 新增考试输出模板与TRAE使用记录文档
265 lines
14 KiB
Python
265 lines
14 KiB
Python
from pathlib import Path
|
||
|
||
from docx import Document
|
||
from docx.enum.section import WD_SECTION
|
||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
from docx.oxml import OxmlElement
|
||
from docx.oxml.ns import qn
|
||
from docx.shared import Inches, Pt, RGBColor
|
||
|
||
|
||
ROOT = Path('/Users/smart/Documents/TRAE培训/TRAE课件/exams')
|
||
TARGET = ROOT / 'TRAE编程培训考试评估卷-实操与项目实战(阅卷参考答案).docx'
|
||
|
||
FONT = 'Microsoft YaHei'
|
||
BODY = 11
|
||
BLUE = '2E74B5'
|
||
DARK_BLUE = '1F4D78'
|
||
HEADER_FILL = 'E8EEF5'
|
||
LIGHT_FILL = 'F4F6F9'
|
||
TABLE_WIDTH = 9360
|
||
|
||
|
||
def set_font(run, size=BODY, bold=False, color=None, italic=False):
|
||
run.font.name = FONT
|
||
run._element.rPr.rFonts.set(qn('w:ascii'), 'Calibri')
|
||
run._element.rPr.rFonts.set(qn('w:hAnsi'), 'Calibri')
|
||
run._element.rPr.rFonts.set(qn('w:eastAsia'), FONT)
|
||
run.font.size = Pt(size)
|
||
run.bold = bold
|
||
run.italic = italic
|
||
if color:
|
||
run.font.color.rgb = RGBColor.from_string(color)
|
||
|
||
|
||
def shade(cell, color):
|
||
tc_pr = cell._tc.get_or_add_tcPr()
|
||
shd = OxmlElement('w:shd')
|
||
shd.set(qn('w:fill'), color)
|
||
tc_pr.append(shd)
|
||
|
||
|
||
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
|
||
tc_pr = cell._tc.get_or_add_tcPr()
|
||
tc_mar = tc_pr.first_child_found_in('w:tcMar')
|
||
if tc_mar is None:
|
||
tc_mar = OxmlElement('w:tcMar')
|
||
tc_pr.append(tc_mar)
|
||
for side, value in [('top', top), ('start', start), ('bottom', bottom), ('end', end)]:
|
||
element = tc_mar.find(qn(f'w:{side}'))
|
||
if element is None:
|
||
element = OxmlElement(f'w:{side}')
|
||
tc_mar.append(element)
|
||
element.set(qn('w:w'), str(value))
|
||
element.set(qn('w:type'), 'dxa')
|
||
|
||
|
||
def set_table_geometry(table, widths):
|
||
table.autofit = False
|
||
tbl_pr = table._tbl.tblPr
|
||
tbl_w = tbl_pr.first_child_found_in('w:tblW')
|
||
tbl_w.set(qn('w:w'), str(sum(widths)))
|
||
tbl_w.set(qn('w:type'), 'dxa')
|
||
tbl_ind = tbl_pr.first_child_found_in('w:tblInd')
|
||
if tbl_ind is None:
|
||
tbl_ind = OxmlElement('w:tblInd')
|
||
tbl_pr.append(tbl_ind)
|
||
tbl_ind.set(qn('w:w'), '120')
|
||
tbl_ind.set(qn('w:type'), 'dxa')
|
||
grid_cols = table._tbl.tblGrid.gridCol_lst
|
||
for grid_col, width in zip(grid_cols, widths):
|
||
grid_col.set(qn('w:w'), str(width))
|
||
for row in table.rows:
|
||
for cell, width in zip(row.cells, widths):
|
||
tc_w = cell._tc.tcPr.tcW
|
||
tc_w.set(qn('w:w'), str(width))
|
||
tc_w.set(qn('w:type'), 'dxa')
|
||
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||
set_cell_margins(cell)
|
||
|
||
|
||
def add_paragraph(doc, text='', style=None, before=0, after=6, line=1.1, color=None, bold=False, size=BODY):
|
||
paragraph = doc.add_paragraph(style=style)
|
||
paragraph.paragraph_format.space_before = Pt(before)
|
||
paragraph.paragraph_format.space_after = Pt(after)
|
||
paragraph.paragraph_format.line_spacing = line
|
||
run = paragraph.add_run(text)
|
||
set_font(run, size=size, bold=bold, color=color)
|
||
return paragraph
|
||
|
||
|
||
def add_heading(doc, text, level):
|
||
sizes = {1: 16, 2: 13, 3: 12}
|
||
colors = {1: BLUE, 2: BLUE, 3: DARK_BLUE}
|
||
before = {1: 16, 2: 12, 3: 8}
|
||
after = {1: 8, 2: 6, 3: 4}
|
||
paragraph = doc.add_paragraph()
|
||
paragraph.paragraph_format.space_before = Pt(before[level])
|
||
paragraph.paragraph_format.space_after = Pt(after[level])
|
||
paragraph.paragraph_format.keep_with_next = True
|
||
run = paragraph.add_run(text)
|
||
set_font(run, size=sizes[level], bold=True, color=colors[level])
|
||
return paragraph
|
||
|
||
|
||
def add_table(doc, headers, rows, widths):
|
||
table = doc.add_table(rows=1, cols=len(headers))
|
||
table.style = 'Table Grid'
|
||
table.rows[0]._tr.get_or_add_trPr().append(OxmlElement('w:tblHeader'))
|
||
for cell, text in zip(table.rows[0].cells, headers):
|
||
shade(cell, HEADER_FILL)
|
||
paragraph = cell.paragraphs[0]
|
||
paragraph.paragraph_format.space_after = Pt(0)
|
||
run = paragraph.add_run(text)
|
||
set_font(run, size=10, bold=True)
|
||
for values in rows:
|
||
cells = table.add_row().cells
|
||
for cell, text in zip(cells, values):
|
||
paragraph = cell.paragraphs[0]
|
||
paragraph.paragraph_format.space_after = Pt(0)
|
||
paragraph.paragraph_format.line_spacing = 1.08
|
||
run = paragraph.add_run(text)
|
||
set_font(run, size=9.5)
|
||
set_table_geometry(table, widths)
|
||
doc.add_paragraph().paragraph_format.space_after = Pt(2)
|
||
return table
|
||
|
||
|
||
def add_callout(doc, label, text):
|
||
table = doc.add_table(rows=1, cols=1)
|
||
table.style = 'Table Grid'
|
||
shade(table.cell(0, 0), LIGHT_FILL)
|
||
paragraph = table.cell(0, 0).paragraphs[0]
|
||
paragraph.paragraph_format.space_after = Pt(0)
|
||
label_run = paragraph.add_run(f'{label}:')
|
||
set_font(label_run, bold=True, color=DARK_BLUE)
|
||
text_run = paragraph.add_run(text)
|
||
set_font(text_run)
|
||
set_table_geometry(table, [TABLE_WIDTH])
|
||
doc.add_paragraph().paragraph_format.space_after = Pt(2)
|
||
|
||
|
||
def build_document():
|
||
doc = Document()
|
||
section = doc.sections[0]
|
||
section.top_margin = Inches(1)
|
||
section.bottom_margin = Inches(1)
|
||
section.left_margin = Inches(1)
|
||
section.right_margin = Inches(1)
|
||
section.header_distance = Inches(0.492)
|
||
section.footer_distance = Inches(0.492)
|
||
|
||
styles = doc.styles
|
||
normal = styles['Normal']
|
||
normal.font.name = 'Calibri'
|
||
normal._element.rPr.rFonts.set(qn('w:eastAsia'), FONT)
|
||
normal.font.size = Pt(BODY)
|
||
normal.paragraph_format.space_after = Pt(6)
|
||
normal.paragraph_format.line_spacing = 1.1
|
||
|
||
header = section.header.paragraphs[0]
|
||
header.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||
header.paragraph_format.space_after = Pt(0)
|
||
set_font(header.add_run('TRAE 编程培训 | 阅卷参考答案'), size=9, color='6B7280')
|
||
footer = section.footer.paragraphs[0]
|
||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
footer.paragraph_format.space_after = Pt(0)
|
||
set_font(footer.add_run('阅卷专用 - 不随学生试卷发放'), size=9, color='6B7280')
|
||
|
||
title = doc.add_paragraph()
|
||
title.paragraph_format.space_before = Pt(16)
|
||
title.paragraph_format.space_after = Pt(4)
|
||
set_font(title.add_run('TRAE 编程培训考试评估卷'), size=23, bold=True, color='0B2545')
|
||
subtitle = doc.add_paragraph()
|
||
subtitle.paragraph_format.space_after = Pt(16)
|
||
set_font(subtitle.add_run('实操与项目实战 - 阅卷参考答案'), size=14, color='475569')
|
||
for label, value in [
|
||
('适用试卷', '系统用户状态管理综合卷(修订版)'),
|
||
('固定源码', 'GitHub jeecgboot/JeecgBoot,v3.9.2'),
|
||
('固定提交', '7df07a823fd558be857d0208ccae96342539fbc1'),
|
||
('阅卷原则', '考查证据与判断,不要求逐字复述;禁止把未执行测试写成已通过。'),
|
||
]:
|
||
paragraph = doc.add_paragraph()
|
||
paragraph.paragraph_format.space_after = Pt(2)
|
||
set_font(paragraph.add_run(f'{label}:'), bold=True, color='1F4D78')
|
||
set_font(paragraph.add_run(value))
|
||
|
||
add_callout(doc, '使用说明', '本答案与 exam-kit/reference-answer.md 内容对应。学生提交可以有不同表述,但必须给出等价的源码证据、边界判断和如实的测试状态。')
|
||
|
||
add_heading(doc, 'A 卷参考答案', 1)
|
||
add_heading(doc, '任务 1:Prompt 改写', 2)
|
||
add_paragraph(doc, '合格 Prompt 必须明确固定提交、分析范围、状态白名单目标、不可改动的保护项、输出结构和验收证据。可按以下六项核查:', after=4)
|
||
add_table(doc, ['项目', '参考要求'], [
|
||
('背景与目标', '在固定版本中审查批量冻结/解冻,保证 status 仅允许 1/2 且在写库前校验。'),
|
||
('范围', 'SysUserController.frozenBatch、ISysUserService.updateStatus、SysUserServiceImpl.updateStatus、状态常量及直接关联的保护。'),
|
||
('约束', '不得改接口路径、system:user:frozenBatch 权限、管理员保护和缓存失效;事实、推断、待确认分开。'),
|
||
('输出', '调用链与证据、最小改造、测试矩阵、待确认项。'),
|
||
('验收', '每项结论写文件路径和符号;未执行测试必须如实标注。'),
|
||
], [1800, 7560])
|
||
|
||
add_heading(doc, '任务 2:AI 输出辨析', 2)
|
||
add_callout(doc, '正确结论', '“页面已限制状态值,因此 Service 不需要校验”是错误结论。页面不是可信边界,调用者可直接请求接口;Service.updateStatus 在原版中直接解析 status 并更新数据库。')
|
||
add_table(doc, ['应说明的点', '参考答案'], [
|
||
('正确校验位置', 'SysUserServiceImpl.updateStatus 内,在 Integer.parseInt(status) 与 userMapper.update 之前。'),
|
||
('允许值', 'CommonConstant.USER_UNFREEZE(1)和 USER_FREEZE(2)。'),
|
||
('拒绝值', 'null、空串、空白串、3 及其他非 1/2 值,抛 JeecgBootException。'),
|
||
('保留项', 'Controller 权限、管理员保护、@CacheEvict 和原有数据更新路径。'),
|
||
], [1800, 7560])
|
||
|
||
add_heading(doc, 'B 卷参考答案', 1)
|
||
add_heading(doc, '任务 1:项目理解与影响分析', 2)
|
||
add_table(doc, ['结论', '文件路径与符号', '证明内容', '类型'], [
|
||
('接口与权限', 'SysUserController.java / frozenBatch', '@RequiresPermissions("system:user:frozenBatch");PUT /sys/user/frozenBatch。', '事实'),
|
||
('调用链', 'SysUserController.java / frozenBatch', '读取 ids,先做 checkUserAdminRejectDel,再读取 status,逐个调用 updateStatus。', '事实'),
|
||
('服务契约', 'ISysUserService.java / updateStatus', '状态更新是 Service 接口。', '事实'),
|
||
('写库与缓存', 'SysUserServiceImpl.java / updateStatus', '@CacheEvict;Integer.parseInt(status) 后调用 userMapper.update。', '事实'),
|
||
('状态枚举', 'CommonConstant.java / USER_UNFREEZE、USER_FREEZE、USER_QUIT', '取值为 1、2、3;本题只允许 1、2。', '事实'),
|
||
('管理员保护', 'SysUserServiceImpl.java / checkUserAdminRejectDel', '检测目标 ID 中的 admin 用户并抛业务异常。', '事实'),
|
||
('登录影响', 'SysUserServiceImpl.java / 用户登录有效性检查', '状态为 USER_FREEZE 时返回“该用户已冻结”。', '事实'),
|
||
], [1500, 3000, 3600, 1260])
|
||
add_callout(doc, '边界判断', 'Service 白名单必须早于解析和 mapper 写入。异常返回时不完成该次状态更新;缓存失效注解、接口权限和管理员保护都不是此变更的替代项。批量原子性与跨节点缓存可列为待确认,不能直接写成事实。')
|
||
|
||
add_heading(doc, '任务 2:最小改造与测试安全网', 2)
|
||
add_paragraph(doc, '参考改造:在 SysUserServiceImpl.updateStatus 方法开头增加状态白名单,保持已有 @CacheEvict 和 mapper 写入。', after=3)
|
||
code_table = doc.add_table(rows=1, cols=1)
|
||
code_table.style = 'Table Grid'
|
||
shade(code_table.cell(0, 0), 'F8FAFC')
|
||
code = 'if (!String.valueOf(CommonConstant.USER_UNFREEZE).equals(status)\n && !String.valueOf(CommonConstant.USER_FREEZE).equals(status)) {\n throw new JeecgBootException("账号状态仅支持冻结或解冻");\n}'
|
||
paragraph = code_table.cell(0, 0).paragraphs[0]
|
||
paragraph.paragraph_format.space_after = Pt(0)
|
||
set_font(paragraph.add_run(code), size=9.5)
|
||
set_table_geometry(code_table, [TABLE_WIDTH])
|
||
doc.add_paragraph().paragraph_format.space_after = Pt(2)
|
||
add_table(doc, ['场景', '预期', '验证口径'], [
|
||
('null、空串、空白串、3', '抛 JeecgBootException;不写库。', '负例单元测试;未跑则写明原因和计划。'),
|
||
('"1" 解冻', '更新状态 1,成功后缓存失效。', 'Service 单元测试或集成测试。'),
|
||
('"2" 冻结', '更新状态 2,成功后缓存失效。', 'Service 单元测试或集成测试。'),
|
||
('无权限调用', '被权限框架拒绝。', '接口/集成测试,记录账号与结果。'),
|
||
('目标含 admin', '管理员保护拒绝更新。', 'Controller/接口测试。'),
|
||
('冻结后登录', '登录有效性检查返回冻结错误。', '可列待环境验证,不得虚报通过。'),
|
||
], [1950, 3660, 3750])
|
||
add_callout(doc, '测试状态', 'review-diff.patch 中的最小测试只覆盖 status 为 "3"。父 POM 默认跳过 surefire 测试;未修改本地测试配置的学员应标注未执行,而不是将测试状态写为已通过。')
|
||
|
||
add_heading(doc, '任务 3:Review、PR 与资产沉淀', 2)
|
||
add_table(doc, ['结论', '证据', '阅卷判断'], [
|
||
('可合入,建议补测', '白名单校验位于 parse 和 mapper 写入前;保护项未被删除。', '认可“无阻断问题”,但必须列检查范围和残余风险。'),
|
||
('测试覆盖不足', '新增测试只断言 "3" 抛异常。', '建议补 null、空白、"1"、"2" 与 mapper 交互断言。'),
|
||
('残余风险', '异常返回语义、批量原子性、跨节点缓存并非此 diff 的直接变更。', '可列后续集成验证;不能凭空断言已验证。'),
|
||
], [1950, 4200, 3210])
|
||
add_paragraph(doc, '参考 PR 要点:背景是前端限制不能替代服务端边界;实现是在 Service 白名单化 status;测试写明实际已跑或未跑状态;风险是业务收紧会拒绝此前错误状态,必要时回退本次白名单提交。', after=4)
|
||
add_callout(doc, '资产答案', '可复用资产应围绕“状态变更服务端边界”设计:输入固定提交、diff 和状态常量;检查白名单、校验顺序、权限/关键账号保护、缓存/审计、副作用和正负例;输出 Review、证据表、测试矩阵和残余风险。')
|
||
|
||
add_heading(doc, 'AI 使用记录的判分口径', 1)
|
||
add_paragraph(doc, '不提供统一的逐字 Prompt。可得满分的记录需要写清:使用 AI/TRAE 做了什么、建议摘要、采纳或否决、人工判断和证据/验证状态。工具名称可以不同;缺少 trae-usage-record.md 时,任务 3 的该项 2 分为 0 分。')
|
||
|
||
doc.core_properties.title = 'TRAE 编程培训考试评估卷(阅卷参考答案)'
|
||
doc.core_properties.subject = '系统用户状态管理综合卷阅卷参考'
|
||
doc.core_properties.author = 'TRAE 培训'
|
||
doc.save(TARGET)
|
||
print(TARGET)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
build_document()
|