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()