303 lines
17 KiB
Python
303 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from docx import Document
|
|
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_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
|
|
from docx.text.paragraph import Paragraph
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE = ROOT / "docs" / "reference_site_prd" / "移动端任务与账户平台需求文档.docx"
|
|
OUT_DIR = ROOT / "docs" / "reference_site_prd_v2"
|
|
OUTPUT = OUT_DIR / "移动端任务与账户平台需求文档 V2.docx"
|
|
|
|
INK = "18202F"
|
|
MUTED = "667085"
|
|
GRID = "D9D9D9"
|
|
|
|
|
|
def set_font(run, size=10.5, bold=False, color=INK):
|
|
run.font.name = "Heiti SC"
|
|
run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), "Heiti SC")
|
|
run.font.size = Pt(size)
|
|
run.font.bold = bold
|
|
run.font.color.rgb = RGBColor.from_string(color)
|
|
|
|
|
|
def replace_all(doc, old: str, new: str):
|
|
for paragraph in list(doc.paragraphs) + list(doc.sections[0].header.paragraphs):
|
|
if old not in paragraph.text:
|
|
continue
|
|
full = paragraph.text.replace(old, new)
|
|
for run in paragraph.runs:
|
|
run.text = ""
|
|
run = paragraph.runs[0] if paragraph.runs else paragraph.add_run()
|
|
run.text = full
|
|
style = paragraph.style.name if paragraph.style else ""
|
|
if style == "Title":
|
|
set_font(run, 25, True, "000000")
|
|
elif style.startswith("Heading"):
|
|
set_font(run, 14, True, "000000")
|
|
else:
|
|
set_font(run, 10.5)
|
|
|
|
|
|
def find_paragraph(doc, exact: str):
|
|
for p in doc.paragraphs:
|
|
if p.text.strip() == exact:
|
|
return p
|
|
raise ValueError(f"Paragraph not found: {exact}")
|
|
|
|
|
|
def add_before(anchor, text: str, style: str | None = None, size=10.5, bold=False, color=INK,
|
|
space_before=0, space_after=6):
|
|
p = OxmlElement("w:p")
|
|
anchor._p.addprevious(p)
|
|
paragraph = Paragraph(p, anchor._parent)
|
|
if style:
|
|
paragraph.style = style
|
|
paragraph.paragraph_format.space_before = Pt(space_before)
|
|
paragraph.paragraph_format.space_after = Pt(space_after)
|
|
paragraph.paragraph_format.line_spacing = 1.25
|
|
set_font(paragraph.add_run(text), size, bold, color)
|
|
return paragraph
|
|
|
|
|
|
def add_heading_before(anchor, text: str, level=2):
|
|
sizes = {1: 18, 2: 14, 3: 11.5}
|
|
return add_before(anchor, text, f"Heading {level}", sizes[level], True, "000000", 10, 6)
|
|
|
|
|
|
def add_bullet_before(anchor, text: str):
|
|
return add_before(anchor, text, "List Bullet", 10.2, False, INK, 0, 3)
|
|
|
|
|
|
def set_cell(cell, text, header=False, shade=None, align=WD_ALIGN_PARAGRAPH.LEFT, size=8.6):
|
|
cell.text = str(text)
|
|
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
|
tc_pr = cell._tc.get_or_add_tcPr()
|
|
mar = OxmlElement("w:tcMar")
|
|
for side in ("top", "start", "bottom", "end"):
|
|
node = OxmlElement(f"w:{side}")
|
|
node.set(qn("w:w"), "100")
|
|
node.set(qn("w:type"), "dxa")
|
|
mar.append(node)
|
|
tc_pr.append(mar)
|
|
borders = OxmlElement("w:tcBorders")
|
|
for edge in ("top", "left", "bottom", "right"):
|
|
node = OxmlElement(f"w:{edge}")
|
|
node.set(qn("w:val"), "single")
|
|
node.set(qn("w:sz"), "6")
|
|
node.set(qn("w:color"), GRID)
|
|
borders.append(node)
|
|
tc_pr.append(borders)
|
|
if shade:
|
|
shd = OxmlElement("w:shd")
|
|
shd.set(qn("w:fill"), shade)
|
|
tc_pr.append(shd)
|
|
p = cell.paragraphs[0]
|
|
p.alignment = align
|
|
p.paragraph_format.space_after = Pt(0)
|
|
for run in p.runs:
|
|
set_font(run, size, header, "FFFFFF" if header else INK)
|
|
|
|
|
|
def table_before(doc, anchor, headers, rows, widths):
|
|
table = doc.add_table(rows=1, cols=len(headers))
|
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
table.autofit = False
|
|
table.rows[0]._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit"))
|
|
repeat = OxmlElement("w:tblHeader")
|
|
repeat.set(qn("w:val"), "true")
|
|
table.rows[0]._tr.get_or_add_trPr().append(repeat)
|
|
for i, h in enumerate(headers):
|
|
set_cell(table.rows[0].cells[i], h, True, "334E7D", WD_ALIGN_PARAGRAPH.CENTER)
|
|
for ridx, row in enumerate(rows):
|
|
tr = table.add_row()
|
|
tr._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit"))
|
|
for i, value in enumerate(row):
|
|
set_cell(tr.cells[i], value, False, "F3F6FB" if ridx % 2 else None,
|
|
WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT)
|
|
for row in table.rows:
|
|
for i, width in enumerate(widths):
|
|
row.cells[i].width = Inches(width)
|
|
anchor._p.addprevious(table._tbl)
|
|
return table
|
|
|
|
|
|
def add_revision_summary(doc):
|
|
anchor = find_paragraph(doc, "1 文档范围与方法")
|
|
add_heading_before(anchor, "0 后台反向校验结论", 1)
|
|
add_before(anchor,
|
|
"后台走查表明,原 V1 已覆盖账户、任务订单、储入取出和客服等主链路,但对后台可配置项、人工审核、任务大厅、会员限制和运营动作的用户端反馈描述不足。V2 将后台视为业务规则的管理端,并补齐移动端必须展示的状态、原因、时效和恢复路径。")
|
|
table_before(doc, anchor,
|
|
["校验主题", "后台证据", "V1 缺口", "V2 调整"],
|
|
[
|
|
("任务模型", "商品订单、任务大厅、任务列表、会员任务审核并存", "容易把所有任务理解为自动领取订单", "拆分自动订单与人工任务两条链路"),
|
|
("资金审核", "充值与提现支持人工通过或驳回", "状态虽有定义,但用户通知与补件不够明确", "增加审核进度、驳回原因、补件和到账凭证"),
|
|
("会员治理", "后台可改等级、禁用、账变和奖励", "移动端缺少被限制后的统一反馈", "增加受限状态页、原因、期限、申诉和台账说明"),
|
|
("运营配置", "营业时间、金额门槛、页面开关、Banner 和文本可配置", "前台规则散落在页面描述中", "建立配置版本、发布时间和客户端降级契约"),
|
|
("内容真实性", "存在假人和弹幕入口", "只提出风险,未明确产品取舍", "生产环境不展示伪造用户行为;演示数据必须标识"),
|
|
("异常恢复", "任务大厅接口异常,客服页可能空白", "通用错误处理不够落地", "每个核心入口增加错误码、重试和备用路径"),
|
|
], [1.0, 2.0, 1.9, 2.0])
|
|
add_before(anchor, "V2 的核心原则是:后台每一次影响用户权益的操作,都必须在移动端形成可解释、可追踪、可申诉的结果。", size=10.5, bold=True)
|
|
|
|
|
|
def add_business_closure(doc):
|
|
anchor = find_paragraph(doc, "5 页面级功能需求")
|
|
add_heading_before(anchor, "4.4 后台操作与移动端反馈闭环", 2)
|
|
for text in [
|
|
"后台修改等级、佣金、每日次数或提现限制时,移动端必须显示规则版本和生效时间。进行中的订单继续使用创建时的规则快照。",
|
|
"后台审核充值、提现或会员任务后,移动端记录页同步显示审核节点、处理时间、结果和原因,并通过站内通知提醒用户。",
|
|
"后台禁用、冻结或限制会员时,移动端不得只表现为接口失败。系统应进入受限状态页,说明受影响功能、期限、原因类别和申诉入口。",
|
|
"后台执行余额调整或奖励发放时,移动端账户明细必须产生独立流水,展示业务单号、变动方向、原因摘要和处理主体类型。",
|
|
"后台下线 Banner、内容、商品、任务或客服入口后,移动端按配置版本刷新。已打开的页面应显示已下线状态,不得继续提交。",
|
|
]:
|
|
add_bullet_before(anchor, text)
|
|
|
|
# The V1 builder placed an explicit page break between sections 4 and 5.
|
|
# The new 4.4 content now occupies that boundary, so remove the old break to
|
|
# avoid leaving section 4.3 alone on a mostly empty page.
|
|
paragraphs = doc.paragraphs
|
|
closure = find_paragraph(doc, "4.4 后台操作与移动端反馈闭环")
|
|
closure_index = next(i for i, paragraph in enumerate(paragraphs) if paragraph._p is closure._p)
|
|
for candidate in reversed(paragraphs[:closure_index]):
|
|
if candidate._p.xpath('.//w:br[@w:type="page"]'):
|
|
candidate._element.getparent().remove(candidate._element)
|
|
break
|
|
|
|
|
|
def add_mobile_pages(doc):
|
|
anchor = find_paragraph(doc, "6 数据模型与状态机")
|
|
add_heading_before(anchor, "5.11 任务大厅与会员任务", 2)
|
|
add_before(anchor, "后台同时存在任务大厅、任务列表和会员任务审核,因此移动端需要把人工任务与自动领取订单分开设计,避免共用状态和文案。")
|
|
table_before(doc, anchor,
|
|
["环节", "移动端需求", "后台对应", "关键状态"],
|
|
[
|
|
("任务大厅", "展示任务名称、图标、奖励、适用等级、剩余名额和截止时间", "任务大厅配置", "未开始、可申请、已满、已结束、已下线"),
|
|
("任务详情", "展示说明、材料要求、审核标准、预计时长和风险提示", "任务列表", "可申请、不可申请、已申请"),
|
|
("提交材料", "支持文本、图片或文件,提交前预览并可撤回", "会员任务审核", "草稿、已提交、审核中"),
|
|
("审核结果", "显示通过、驳回或补件原因,允许查看历史版本", "会员任务审核", "通过、驳回、待补件、已取消"),
|
|
("奖励入账", "审核通过后生成奖励流水和通知", "奖励及账变", "待入账、已入账、入账失败"),
|
|
], [0.9, 2.55, 1.45, 1.85])
|
|
add_heading_before(anchor, "5.12 会员受限与运营通知", 2)
|
|
for text in [
|
|
"会员状态至少包括正常、临时限制、冻结、禁用和注销中;登录后优先展示状态说明,而不是让各页面分别报错。",
|
|
"站内通知按系统公告、审核结果、资金结果、安全提醒和规则变更分类,重要通知保留已读时间和内容版本。",
|
|
"荣誉榜、滚动消息和弹幕只能使用可证明的真实业务数据。测试或演示数据必须与生产数据隔离并明确标识。",
|
|
"客服入口不可用时,显示服务时间、错误编号、重试按钮和经审核的备用渠道;不得把空白 iframe 作为最终状态。",
|
|
]:
|
|
add_bullet_before(anchor, text)
|
|
|
|
|
|
def add_contracts(doc):
|
|
anchor = find_paragraph(doc, "8 管理后台需求")
|
|
add_heading_before(anchor, "7.2 后台配置与移动端契约", 2)
|
|
table_before(doc, anchor,
|
|
["后台配置", "移动端消费方式", "校验与降级"],
|
|
[
|
|
("营业与交易时间", "展示当前可用状态和下一开放时间", "结束时间必须晚于开始时间;配置无效时关闭提交并告警"),
|
|
("金额与次数门槛", "表单提示、按钮状态和服务端校验使用同一版本", "客户端仅展示,服务端最终裁决"),
|
|
("会员等级", "等级页、领取资格、佣金和提现限制统一读取", "订单创建时保存规则快照"),
|
|
("Banner 与文本", "按排序、有效期、语言和跳转类型渲染", "危险链接拦截;富文本白名单"),
|
|
("商品与任务", "仅展示已发布且适用于当前用户的条目", "已下线条目不可新建申请,历史记录仍可查看"),
|
|
("客服入口", "按在线状态、时间和排序展示", "第三方失败时回退到备用联系方式"),
|
|
], [1.35, 3.0, 2.4])
|
|
add_before(anchor, "所有配置响应应包含 configVersion、effectiveAt 和 updatedAt。客户端缓存必须按版本失效,关键交易提交时还要携带规则版本,由服务端拒绝过期规则并返回可读提示。")
|
|
|
|
|
|
def add_acceptance(doc):
|
|
anchor = find_paragraph(doc, "13 交付优先级与里程碑")
|
|
add_heading_before(anchor, "12.1 后台联动补充验收", 2)
|
|
for idx, text in enumerate([
|
|
"后台发布新等级规则后,移动端在生效时间后展示新规则;存量订单仍按原规则结算。",
|
|
"后台驳回充值、提现或任务申请后,移动端显示同一原因码、用户文案、处理时间和补救入口。",
|
|
"后台冻结会员后,移动端所有受影响写操作均被服务端拒绝,并统一跳转受限状态页;解除后无需重新安装或清缓存。",
|
|
"后台余额调整和奖励发放均生成不可变流水;移动端可从通知跳转至对应流水详情。",
|
|
"任务大厅服务不可用时,移动端保留其他导航功能,显示错误编号并支持重试,不出现空白页或无限加载。",
|
|
"客服第三方页面失败时,移动端展示备用渠道,且不泄露令牌、手机号、银行卡或完整会话参数。",
|
|
"后台下线商品或任务后,新申请立即禁止;已申请记录仍可查看状态和审核结果。",
|
|
"生产环境的榜单、弹幕和消息不得混入测试账号或伪造行为数据。",
|
|
], 11):
|
|
add_before(anchor, f"{idx}. {text}")
|
|
|
|
|
|
def add_pending_questions(doc):
|
|
anchor = find_paragraph(doc, "在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。")
|
|
add_heading_before(anchor, "15.1 后台联动待确认", 2)
|
|
for text in [
|
|
"任务大厅与自动领取订单是否属于两个独立业务,还是同一任务的不同入口。",
|
|
"充值、提现和会员任务的驳回原因由固定字典选择,还是允许运营自由输入。",
|
|
"会员禁用、冻结、等级调整和余额调整分别由哪些角色发起、复核与申诉处理。",
|
|
"规则配置采用立即生效、定时发布还是审批后发布;存量订单使用哪一版规则。",
|
|
"荣誉榜与弹幕是否保留。若保留,只能展示真实、可审计且已脱敏的数据。",
|
|
]:
|
|
add_bullet_before(anchor, text)
|
|
|
|
|
|
def remove_page_break_before(doc, heading_text: str):
|
|
paragraphs = doc.paragraphs
|
|
target = find_paragraph(doc, heading_text)
|
|
target_index = next(i for i, paragraph in enumerate(paragraphs) if paragraph._p is target._p)
|
|
for candidate in reversed(paragraphs[:target_index]):
|
|
if candidate._p.xpath('.//w:br[@w:type="page"]'):
|
|
candidate._element.getparent().remove(candidate._element)
|
|
return
|
|
|
|
|
|
def normalize_layout(doc):
|
|
# Fill the otherwise nearly empty customer-service page and reset inherited
|
|
# indentation that clipped the first digit of a top-of-page heading.
|
|
remove_page_break_before(doc, "5.7 我的与个人信息")
|
|
remove_page_break_before(doc, "\u20605.10 账户明细与记录")
|
|
for text in ("5.7 我的与个人信息", "5.9 取出与收款绑定", "5.10 账户明细与记录"):
|
|
paragraph = next(p for p in doc.paragraphs if p.text.strip().endswith(text))
|
|
paragraph.paragraph_format.left_indent = Inches(0)
|
|
paragraph.paragraph_format.first_line_indent = Inches(0)
|
|
|
|
# The source document predates the table-row split safeguard used by the
|
|
# backend PRD. Apply it globally so milestone and interface rows do not break
|
|
# across pages.
|
|
for table in doc.tables:
|
|
for row in table.rows:
|
|
tr_pr = row._tr.get_or_add_trPr()
|
|
if not tr_pr.xpath('./w:cantSplit'):
|
|
tr_pr.append(OxmlElement("w:cantSplit"))
|
|
|
|
|
|
def update_metadata(doc):
|
|
doc.core_properties.title = "移动端任务与账户平台需求文档 V2"
|
|
doc.core_properties.subject = "基于后台功能的移动端需求反向校验修订版"
|
|
doc.core_properties.author = "Product Requirement Analysis"
|
|
doc.core_properties.keywords = "需求文档, 移动端, 后台联动, 任务, 资金, 审核, 原型"
|
|
|
|
|
|
def main():
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
doc = Document(SOURCE)
|
|
replace_all(doc, "移动端任务与账户平台需求文档", "移动端任务与账户平台需求文档 V2")
|
|
replace_all(doc, "参考站点功能拆解与重构建议", "后台反向校验修订版")
|
|
replace_all(doc, "文档版本:V1.0", "文档版本:V2.0")
|
|
replace_all(doc,
|
|
"结论:参考站点由账户、任务订单、余额台账、储入取出、会员等级、内容展示和在线客服七个域构成。建议复刻页面结构与业务闭环,但重写资金、隐私、安全和合规机制。",
|
|
"结论:移动端主链路可以保留,但必须新增人工任务、审核结果、会员受限、配置版本和后台账变反馈。资金、权限、隐私和内容真实性仍按高风险能力重构。")
|
|
add_revision_summary(doc)
|
|
add_business_closure(doc)
|
|
add_mobile_pages(doc)
|
|
add_contracts(doc)
|
|
add_acceptance(doc)
|
|
add_pending_questions(doc)
|
|
normalize_layout(doc)
|
|
update_metadata(doc)
|
|
doc.save(OUTPUT)
|
|
print(OUTPUT)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|