from __future__ import annotations from pathlib import Path from datetime import date from PIL import Image, ImageDraw, ImageFont from docx import Document from docx.enum.section import WD_SECTION from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Inches, Pt, RGBColor ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "docs" / "reference_site_prd" ASSET_DIR = OUT_DIR / "assets" OUT_DOCX = OUT_DIR / "移动端任务与账户平台需求文档.docx" FONT_PATH = "/System/Library/Fonts/STHeiti Light.ttc" INK = "18202F" BLUE = "4F79E8" PALE_BLUE = "EAF1FF" PALE = "F6F8FC" GOLD = "D8AD58" RED = "C64F48" GRID = "D9D9D9" MUTED = "667085" def font(size: int, bold: bool = False): try: return ImageFont.truetype(FONT_PATH, size=size) except Exception: return ImageFont.load_default() def rounded(draw, box, radius=18, fill="white", outline=None, width=1): draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width) def text(draw, xy, value, size=24, fill="#18202F", bold=False, anchor=None): draw.text(xy, value, font=font(size, bold), fill=fill, anchor=anchor) def phone_base(title: str): im = Image.new("RGB", (460, 860), "#F3F5FA") d = ImageDraw.Draw(im) rounded(d, (8, 8, 452, 852), 34, "#F3F5FA", "#D8DEE9", 2) text(d, (230, 42), title, 28, anchor="mm") return im, d def nav(d, active: str): rounded(d, (14, 790, 446, 842), 24, "white") items = ["首页", "记录", "领取", "客服", "我的"] for i, item in enumerate(items): x = 52 + i * 89 color = "#4F79E8" if item == active else "#4B5565" d.ellipse((x - 9, 802, x + 9, 820), fill=color) text(d, (x, 831), item, 15, color, anchor="mm") def home_proto(): im, d = phone_base("首页") rounded(d, (20, 78, 440, 220), 18, "#2459D9") text(d, (230, 145), "运营活动横幅", 34, "white", anchor="mm") for i, label in enumerate(["领取", "储入", "取出", "手工"]): x = 65 + i * 110 d.ellipse((x - 22, 238, x + 22, 282), fill=["#6C8FF1", "#6DD0B5", "#D77B6C", "#E2B362"][i]) text(d, (x, 301), label, 19, anchor="mm") rounded(d, (20, 322, 440, 422), 28, "#DCEBFF") text(d, (42, 347), "账户余额", 18, "#667085") text(d, (42, 395), "Rmb$ 300,019.13", 32) text(d, (20, 455), "荣誉榜", 23) for i in range(3): y = 486 + i * 68 rounded(d, (20, y, 440, y + 56), 20, "white") d.ellipse((34, y + 9, 72, y + 47), fill="#DDE5F5") text(d, (88, y + 19), f"用户{i+1} 积分 {5200-i*430}.00", 16) rounded(d, (340, y + 12, 425, y + 44), 16, "#E3BB6F") text(d, (382, y + 29), "领取成功", 13, anchor="mm") text(d, (20, 705), "会员等级 · 公司简介", 20) nav(d, "首页") return im def records_proto(): im, d = phone_base("记录") text(d, (28, 82), "剩余可用资产", 17, "#667085") text(d, (28, 126), "Rmb$ 300,019.13", 34) for i, tab in enumerate(["全部", "待处理", "已完成"]): text(d, (55 + i * 92, 184), tab, 18, "#18202F" if i == 0 else "#77808F", anchor="mm") for i, (name, price, comm) in enumerate([("商品订单 A", "¥298", "¥14.90"), ("商品订单 B", "¥235", "¥4.23")]): y = 220 + i * 190 rounded(d, (18, y, 442, y + 168), 22, "white") text(d, (32, y + 25), "2026-10-07 16:25", 15, "#667085") rounded(d, (32, y + 50, 150, y + 142), 12, "#D8E4F8") text(d, (168, y + 64), name, 19) text(d, (168, y + 101), f"商品价格 {price}", 16) rounded(d, (168, y + 117, 422, y + 150), 10, "#F4D5A0") text(d, (185, y + 134), f"佣金 {comm}", 16, "#C64F48", anchor="lm") text(d, (384, y + 134), "已完成", 15, anchor="mm") nav(d, "记录") return im def receive_proto(): im, d = phone_base("领取") rounded(d, (18, 72, 442, 320), 18, "#DCEEFF") text(d, (35, 102), "至尊会员", 30) text(d, (35, 138), "佣金费率 5%", 18) d.ellipse((165, 165, 295, 295), fill="#BED8FF") rounded(d, (148, 250, 312, 298), 24, "#4F79E8") text(d, (230, 275), "自动领取", 22, "white", anchor="mm") text(d, (230, 350), "全力领取中 结果将在下方发放", 16, "#667085", anchor="mm") text(d, (22, 398), "今日战果", 23) cards = [("总资产", "300019.13"), ("昨日佣金", "0"), ("今日佣金", "14.9"), ("今日领取数", "1")] for i, (label, val) in enumerate(cards): x = 20 + (i % 2) * 212 y = 430 + (i // 2) * 104 rounded(d, (x, y, x + 200, y + 88), 14, ["#E4E1FF", "#FFF0CF", "#FFE2E4", "#DDF6FF"][i]) text(d, (x + 14, y + 25), label, 15, "#667085") text(d, (x + 14, y + 62), val, 22) text(d, (22, 655), "规则提示:营业时间、余额门槛、60 分钟时限", 15, "#667085") nav(d, "领取") return im def mine_proto(): im, d = phone_base("我的") rounded(d, (20, 78, 440, 166), 22, "#CCE4FF") d.ellipse((38, 96, 92, 150), fill="#AFC8EA") text(d, (110, 108), "用户昵称", 22) text(d, (110, 139), "邀请码 ******", 15, "#667085") rounded(d, (20, 188, 440, 315), 25, "#F5D37D") text(d, (42, 226), "Rmb$ 300,019.13", 30) rounded(d, (48, 257, 177, 297), 19, "#B9A95F") rounded(d, (200, 257, 329, 297), 19, "white") text(d, (112, 278), "立即取出", 17, anchor="mm") text(d, (264, 278), "立即储入", 17, anchor="mm") text(d, (22, 355), "更多功能", 22) for i, label in enumerate(["个人信息", "储入记录", "取出记录", "账户明细", "弹窗记录", "退出登录"]): y = 390 + i * 58 rounded(d, (20, y, 440, y + 48), 10, "white") text(d, (46, y + 25), label, 18, anchor="lm") text(d, (416, y + 25), ">", 18, "#667085", anchor="mm") nav(d, "我的") return im def recharge_proto(): im, d = phone_base("储入") cols = ["等级", "金额", "赠送", "单数", "比例"] d.rounded_rectangle((14, 76, 446, 114), radius=10, fill="#4F79E8") for i, c in enumerate(cols): x = 24 + i * 82 text(d, (x, 95), c, 14, "white", anchor="mm") rows = [("普通", "500", "20", "10", "1.8%"), ("白银", "1888", "88", "20", "2.0%"), ("黄金", "10000", "188", "30", "2.2%"), ("铂金", "30000", "388", "40", "3.0%"), ("钻石", "50000", "588", "50", "3.5%"), ("星耀", "88888", "1888", "80", "4.0%"), ("至尊", "188888", "2888", "1000", "5.0%")] for r, row in enumerate(rows): y = 126 + r * 42 if r % 2: d.rectangle((14, y - 17, 446, y + 20), fill="#EEF3FC") for i, c in enumerate(row): text(d, (24 + i * 82, y), c, 13, anchor="mm") text(d, (22, 455), "输入金额", 16) rounded(d, (20, 478, 440, 530), 18, "white", "#D9D9D9") text(d, (22, 565), "姓名", 16) rounded(d, (20, 588, 440, 640), 18, "white", "#D9D9D9") rounded(d, (20, 690, 440, 746), 25, "#4F79E8") text(d, (230, 718), "提交", 21, "white", anchor="mm") return im def bind_proto(): im, d = phone_base("绑定信息") labels = ["姓名", "银行名", "银行账户", "人民币编码"] for i, lab in enumerate(labels): y = 78 + i * 100 text(d, (24, y), lab, 17) rounded(d, (20, y + 24, 440, y + 78), 18, "white", "#D9D9D9") text(d, (24, 490), "支付宝收款码", 17) rounded(d, (20, 520, 156, 650), 16, "white", "#C9CFDA") text(d, (88, 585), "+", 42, anchor="mm") text(d, (210, 490), "微信收款码", 17) rounded(d, (206, 520, 342, 650), 16, "white", "#C9CFDA") text(d, (274, 585), "+", 42, anchor="mm") rounded(d, (20, 700, 440, 756), 25, "#4F79E8") text(d, (230, 728), "提交", 21, "white", anchor="mm") return im def service_proto(): im, d = phone_base("在线客服") text(d, (28, 92), "储入与问题处理请联系在线客服", 18) for i in range(3): x = 18 + i * 144 rounded(d, (x, 148, x + 132, 330), 20, "white") d.ellipse((x + 42, 176, x + 90, 224), fill="#2C3440") text(d, (x + 66, 253), f"在线客服 {i+1}", 16, anchor="mm") text(d, (x + 66, 281), "9:00-22:00", 13, "#667085", anchor="mm") rounded(d, (x + 16, 295, x + 116, 327), 16, "#4F79E8") text(d, (x + 66, 312), "联系客服", 14, "white", anchor="mm") text(d, (20, 390), "进入后嵌入第三方即时聊天页", 17, "#667085") nav(d, "客服") return im def mall_proto(): im, d = phone_base("手工商城") rounded(d, (18, 78, 442, 210), 18, "#285FD8") text(d, (230, 144), "手工任务横幅", 30, "white", anchor="mm") for i in range(6): x = 18 + (i % 2) * 214 y = 228 + (i // 2) * 172 rounded(d, (x, y, x + 204, y + 156), 16, "white") rounded(d, (x + 8, y + 8, x + 196, y + 88), 12, "#DDE6F4") text(d, (x + 13, y + 110), f"手工商品 {i+1} 工资说明", 15) text(d, (x + 13, y + 137), "已领取 0.8", 13, "#667085") return im def composite(path: Path, title: str, left, right, left_label: str, right_label: str): canvas = Image.new("RGB", (1200, 980), "white") d = ImageDraw.Draw(canvas) text(d, (600, 34), title, 28, anchor="mm") canvas.paste(left.resize((460, 860)), (105, 74)) canvas.paste(right.resize((460, 860)), (635, 74)) text(d, (335, 955), left_label, 17, "#667085", anchor="mm") text(d, (865, 955), right_label, 17, "#667085", anchor="mm") canvas.save(path, quality=92) def set_cell_shading(cell, fill): tc_pr = cell._tc.get_or_add_tcPr() shd = tc_pr.find(qn("w:shd")) if shd is None: shd = OxmlElement("w:shd") tc_pr.append(shd) shd.set(qn("w:fill"), fill) def set_cell_border(cell, color=GRID, size="6"): tc_pr = cell._tc.get_or_add_tcPr() borders = tc_pr.first_child_found_in("w:tcBorders") if borders is None: borders = OxmlElement("w:tcBorders") tc_pr.append(borders) for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): tag = "w:" + edge node = borders.find(qn(tag)) if node is None: node = OxmlElement(tag) borders.append(node) node.set(qn("w:val"), "single") node.set(qn("w:sz"), size) node.set(qn("w:color"), color) def set_cell_margins(cell, top=100, start=110, bottom=100, end=110): tc = cell._tc tc_pr = 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 m, v in (("top", top), ("start", start), ("bottom", bottom), ("end", end)): node = tc_mar.find(qn("w:" + m)) if node is None: node = OxmlElement("w:" + m) tc_mar.append(node) node.set(qn("w:w"), str(v)) node.set(qn("w:type"), "dxa") def add_table(doc, headers, rows, widths=None, font_size=8.8): table = doc.add_table(rows=1, cols=len(headers)) table.alignment = WD_TABLE_ALIGNMENT.CENTER table.autofit = False hdr = table.rows[0].cells header_props = table.rows[0]._tr.get_or_add_trPr() repeat_header = OxmlElement("w:tblHeader") repeat_header.set(qn("w:val"), "true") header_props.append(repeat_header) header_props.append(OxmlElement("w:cantSplit")) for i, h in enumerate(headers): hdr[i].text = h set_cell_shading(hdr[i], "334E7D") set_cell_border(hdr[i]) set_cell_margins(hdr[i]) hdr[i].vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER p = hdr[i].paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER for r in p.runs: r.font.color.rgb = RGBColor(255, 255, 255) r.font.bold = True r.font.size = Pt(font_size) for ridx, row in enumerate(rows): table_row = table.add_row() table_row._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit")) cells = table_row.cells for i, value in enumerate(row): cells[i].text = str(value) set_cell_border(cells[i]) set_cell_margins(cells[i]) if ridx % 2: set_cell_shading(cells[i], "F3F6FB") cells[i].vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER p = cells[i].paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT for r in p.runs: r.font.size = Pt(font_size) r.font.color.rgb = RGBColor.from_string(INK) if widths: for row in table.rows: for i, w in enumerate(widths): row.cells[i].width = Inches(w) doc.add_paragraph() return table def set_run_font(run, name="Heiti SC", size=10.5, bold=None, color=INK): run.font.name = name run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), "Heiti SC") run.font.size = Pt(size) run.font.color.rgb = RGBColor.from_string(color) if bold is not None: run.font.bold = bold def p(doc, content="", style=None, bold_lead=None): par = doc.add_paragraph(style=style) par.paragraph_format.space_after = Pt(6) par.paragraph_format.line_spacing = 1.28 if bold_lead and content.startswith(bold_lead): a = par.add_run(bold_lead) set_run_font(a, bold=True) b = par.add_run(content[len(bold_lead):]) set_run_font(b) else: r = par.add_run(content) set_run_font(r) return par def bullet(doc, content, level=0): par = doc.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2") par.paragraph_format.space_after = Pt(3) par.paragraph_format.line_spacing = 1.2 set_run_font(par.add_run(content), size=10.2) return par def numbered(doc, content): par = doc.add_paragraph(style="List Number") par.paragraph_format.space_after = Pt(4) set_run_font(par.add_run(content), size=10.2) return par def heading(doc, value, level=1): h = doc.add_heading(value, level=level) h.paragraph_format.space_before = Pt(10 if level == 1 else 7) h.paragraph_format.space_after = Pt(6) return h def page_break(doc): doc.add_page_break() def add_picture(doc, path, caption, width=6.6, page_break_before=False): par = doc.add_paragraph() par.paragraph_format.page_break_before = page_break_before if page_break_before: par.paragraph_format.space_before = Pt(18) par.alignment = WD_ALIGN_PARAGRAPH.CENTER par.add_run().add_picture(str(path), width=Inches(width)) cap = doc.add_paragraph() cap.alignment = WD_ALIGN_PARAGRAPH.CENTER cap.paragraph_format.space_after = Pt(8) set_run_font(cap.add_run(caption), size=9, color=MUTED) def add_page_number(paragraph): paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER run = paragraph.add_run() fld_char1 = OxmlElement("w:fldChar") fld_char1.set(qn("w:fldCharType"), "begin") instr = OxmlElement("w:instrText") instr.set(qn("xml:space"), "preserve") instr.text = "PAGE" fld_char2 = OxmlElement("w:fldChar") fld_char2.set(qn("w:fldCharType"), "end") run._r.append(fld_char1) run._r.append(instr) run._r.append(fld_char2) def build_assets(): ASSET_DIR.mkdir(parents=True, exist_ok=True) pairs = [ ("prototype_01.png", "核心导航与内容列表", home_proto(), records_proto(), "首页", "订单记录"), ("prototype_02.png", "领取与个人中心", receive_proto(), mine_proto(), "领取中心", "我的"), ("prototype_03.png", "资金入口与收款信息", recharge_proto(), bind_proto(), "储入申请", "收款信息绑定"), ("prototype_04.png", "客服与手工任务", service_proto(), mall_proto(), "在线客服", "手工商城"), ] out = [] for name, title, left, right, ll, rl in pairs: path = ASSET_DIR / name composite(path, title, left, right, ll, rl) out.append(path) return out def build_doc(): OUT_DIR.mkdir(parents=True, exist_ok=True) figures = build_assets() doc = Document() sec = doc.sections[0] sec.page_width = Inches(8.5) sec.page_height = Inches(11) sec.top_margin = Inches(0.68) sec.bottom_margin = Inches(0.65) sec.left_margin = Inches(0.72) sec.right_margin = Inches(0.72) styles = doc.styles normal = styles["Normal"] normal.font.name = "Heiti SC" normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Heiti SC") normal.font.size = Pt(10.5) normal.font.color.rgb = RGBColor.from_string(INK) for name, size in (("Title", 27), ("Heading 1", 18), ("Heading 2", 14), ("Heading 3", 11.5)): st = styles[name] st.font.name = "Heiti SC" st._element.rPr.rFonts.set(qn("w:eastAsia"), "Heiti SC") st.font.size = Pt(size) st.font.color.rgb = RGBColor(0, 0, 0) st.font.bold = True header = sec.header.paragraphs[0] header.alignment = WD_ALIGN_PARAGRAPH.RIGHT set_run_font(header.add_run("移动端任务与账户平台需求文档"), size=8.5, color=MUTED) add_page_number(sec.footer.paragraphs[0]) title = doc.add_paragraph(style="Title") title_pr = title._p.get_or_add_pPr() border = title_pr.find(qn("w:pBdr")) if border is not None: title_pr.remove(border) border = OxmlElement("w:pBdr") for edge in ("top", "left", "bottom", "right", "between"): node = OxmlElement("w:" + edge) node.set(qn("w:val"), "nil") border.append(node) title_pr.append(border) title.alignment = WD_ALIGN_PARAGRAPH.LEFT title.paragraph_format.space_before = Pt(70) title.paragraph_format.space_after = Pt(18) set_run_font(title.add_run("移动端任务与账户平台需求文档"), size=27, bold=True, color="000000") sub = doc.add_paragraph() set_run_font(sub.add_run("参考站点功能拆解与重构建议"), size=16, bold=True, color="000000") sub.paragraph_format.space_after = Pt(30) p(doc, "目标:在不复制品牌素材、敏感数据与高风险业务表述的前提下,复刻参考站点的信息架构和交互骨架,并形成可开发、可验收、可审计的产品需求基线。") p(doc, "主要读者:产品负责人、交互设计师、前端开发、后端开发、测试、风控与运营。") p(doc, f"观察日期:{date(2026, 10, 7).isoformat()} 参考环境:移动端网页 文档版本:V1.0") p(doc, "结论:参考站点由账户、任务订单、余额台账、储入取出、会员等级、内容展示和在线客服七个域构成。建议复刻页面结构与业务闭环,但重写资金、隐私、安全和合规机制。") page_break(doc) heading(doc, "1 文档范围与方法", 1) p(doc, "本需求基于登录后的页面逐项浏览、按钮与导航行为检查、页面状态观察,以及浏览器中可见网络请求和前端资源分析。本文把结论分为三类:实测行为、前端可见实现、建议需求。敏感手机号、邀请码、银行卡等信息已脱敏,不作为原型内容。") heading(doc, "1.1 已覆盖范围", 2) for item in [ "登录页现状、注册页字段、会话与路由保护。", "首页、荣誉榜、会员等级、平台简介、规则说明、代理合作、公司资质。", "订单记录筛选、领取中心、订单资金变化、手工商城。", "储入、取出、收款信息绑定、储入记录、取出记录、账户明细。", "个人信息、头像上传、登录密码、交易密码、通知记录、在线客服。", "可观察接口、响应结构、关键数据字段与前端安全实现。", ]: bullet(doc, item) heading(doc, "1.2 未执行操作", 2) p(doc, "为避免生成真实订单、变更资金、上传个人文件、修改密码或退出当前会话,本文未点击自动领取、订单提交、储入提交、取出确认、收款信息提交、头像上传、密码提交和退出登录。对应需求由页面字段、前端接口定义与上下游数据状态推导,并标记为待联调确认。") heading(doc, "2 产品定位与角色", 1) p(doc, "产品可定位为移动端会员任务与佣金账户平台。用户通过注册、储入余额、领取任务订单、完成任务、获得本金返还与佣金,并可查看台账或发起取出。平台通过等级、任务数量和费率管理用户权益,通过客服处理储入、异常订单和账户问题。") add_table(doc, ["角色", "主要目标", "关键权限"], [ ("游客", "了解平台并注册", "登录、注册、查看隐私政策"), ("会员", "领取任务并查看收益", "首页、任务、订单、余额、客服、个人设置"), ("高级会员", "按等级获得更多任务和更高费率", "等级权益、额度与次数受配置控制"), ("客服", "处理储入、取出、异常任务", "会话、工单、用户与交易查询"), ("运营", "管理内容、等级、任务和公告", "配置、发布、审核、数据看板"), ("财务与风控", "审核资金和异常行为", "充值审核、提现审核、台账、风控规则"), ("管理员", "维护权限与系统参数", "RBAC、审计日志、系统配置"), ], [0.8, 2.1, 3.5]) heading(doc, "3 信息架构与页面路由", 1) add_table(doc, ["一级模块", "页面或路由", "核心内容", "入口"], [ ("认证", "/login /register", "手机号登录、账户名、推荐码、验证码", "未登录访问"), ("首页", "/home", "活动图、快捷入口、余额、荣誉榜、会员等级、公司介绍", "底部首页"), ("任务", "/receive /orderDetail", "领取、等待、任务详情、提交完成", "底部领取"), ("记录", "记录页", "全部、待处理、已完成订单", "底部记录"), ("手工", "/mall", "手工任务卡片并引导客服", "首页快捷入口"), ("资金", "/recharge /withdrawal", "储入、取出、余额、交易密码", "首页与个人中心"), ("账户", "/mine /personalInfo", "用户资料、信用分、邀请码、设置", "底部我的"), ("明细", "/depositRecord /withdrawalRecord /bill", "储入、取出、余额台账", "我的"), ("客服", "/customerService /iframeCustom", "客服列表与第三方会话", "底部客服"), ("内容", "/html/{id}", "平台简介、规则、代理、资质", "首页公司简介"), ], [0.7, 1.45, 3.1, 1.0]) heading(doc, "3.1 建议导航原则", 2) for item in [ "底部五栏固定为首页、记录、领取、客服、我的;详情页隐藏底部栏并保留明确返回。", "资金页面必须显示币种、可用余额、冻结余额、预计到账和手续费,不使用含义不明的“人民币编码”。", "所有金额变动都能跳转至同一业务单据,并在台账中保持可解释、可追溯。", "客服 iframe 需明确第三方来源、隐私边界和会话状态,失败时提供备用联系方式。", ]: bullet(doc, item) heading(doc, "4 核心业务闭环", 1) heading(doc, "4.1 任务订单闭环", 2) for item in [ "用户进入领取中心,系统校验登录态、营业时间、会员等级、最低可用余额、未完成任务与风控状态。", "用户点击领取后,系统创建或匹配一笔任务订单,返回任务编号、商品、金额、佣金、截止时间与状态。", "任务确认时,台账先记录交易扣款;完成后记录本金返还和佣金入账。参考数据中同一业务单号出现 -298、+298、+14.9 三条流水。", "订单必须支持待领取、匹配中、待处理、处理中、已完成、超时、冻结、取消与失败,并记录每次状态变化。", "任何补差额、冻结或信用分变化都必须有明确原因、复核入口和申诉渠道;不得依赖口头客服指令。", ]: numbered(doc, item) heading(doc, "4.2 储入与取出闭环", 2) p(doc, "储入页面按等级展示金额、赠送额、每日单数和佣金比例,用户填写金额与姓名后提交。参考站点还要求联系客服获取收款方式。新系统应改为平台内生成可核验的支付订单,禁止客服通过聊天发送临时个人账户。") p(doc, "取出前需绑定姓名、银行名、银行账户与收款码,随后输入金额和交易密码。新系统应只保留与实际支付通道必要的字段,敏感数据加密存储,收款码上传须做病毒扫描、内容校验与访问控制。") heading(doc, "4.3 会员与代理闭环", 2) p(doc, "参考站点设置普通、白银、黄金、铂金、钻石、星耀、至尊七档,充值门槛、赠送额、每日单数和佣金比例逐级上升,并存在三级推荐奖励。建议将等级规则做成版本化配置,禁止前端写死;代理奖励必须在合规评估通过后再启用。") page_break(doc) heading(doc, "5 页面级功能需求", 1) heading(doc, "5.1 登录与注册", 2) add_table(doc, ["页面", "字段与控件", "校验与行为", "验收要点"], [ ("登录", "手机号、密码、记住密码、登录、去注册、隐私政策", "手机号格式;密码非空;错误次数限制;登录成功进入首页", "不在本地明文保存密码;支持会话过期提示"), ("注册", "账户名、11 位手机号、密码、推荐码、图片验证码、隐私政策", "账户名必填;手机号唯一;验证码校验;推荐码可选或按运营配置", "提交前明确同意协议;防批量注册;注册成功自动登录或返回登录"), ("会话", "Authorization 令牌", "401 清除会话并返回登录;403 显示可解释错误", "支持刷新、撤销、设备管理与退出所有设备"), ], [0.65, 2.1, 2.35, 1.55]) heading(doc, "5.2 首页", 2) for item in [ "展示轮播图或单图运营位,支持后台排序、上下线、跳转类型和有效期。", "快捷入口为领取、储入、取出、手工;按后台开关显示,关闭时给出原因而不是静默失效。", "账户卡展示昵称、可用余额与币种;点击可进入账户明细。", "荣誉榜为脱敏昵称、积分和结果状态,要求数据真实性标识、刷新频率与隐私策略。", "会员等级展示当前等级、费率和下一等级条件;等级图不得只是一张不可访问的图片。", "公司简介包括平台简介、规则说明、代理合作和公司资质,内容由 CMS 管理并保留版本。", ]: bullet(doc, item) heading(doc, "5.3 记录与订单详情", 2) p(doc, "记录页显示可用资产,提供全部、待处理、已完成三种筛选。列表项包括创建时间、商品图、商品名、商品价格、佣金与状态。分页默认 10 条,支持下拉刷新、空状态、加载失败重试和详情跳转。") p(doc, "实测发现先切换待处理、再切换已完成时曾短暂出现空列表,而重新加载全部后可见已完成记录。验收时需覆盖筛选参数、缓存状态和并发请求覆盖问题。") add_table(doc, ["状态", "用户可见文案", "允许操作", "系统约束"], [ ("匹配中", "正在匹配任务", "取消等待", "只允许一个匹配请求"), ("待处理", "请在截止时间前完成", "查看详情、提交", "倒计时与服务端时间一致"), ("处理中", "已提交,等待确认", "查看详情、联系客服", "防重复提交"), ("已完成", "本金与佣金已入账", "查看流水", "三笔或组合流水必须可核对"), ("超时或冻结", "显示原因与处理路径", "申诉、联系客服", "需要人工复核和审计"), ("失败或取消", "显示失败原因", "重新领取", "不得产生不一致余额"), ], [0.8, 2.1, 1.4, 2.35]) heading(doc, "5.4 领取中心", 2) p(doc, "领取中心包含会员等级、佣金费率、主领取按钮、匹配进度、今日战果和规则说明。点击主按钮前需完成资格预检;按钮点击后立即进入不可重复提交的 loading 状态;匹配成功跳转订单详情,失败显示可恢复原因。") bullet(doc, "预检项:营业时间、账户状态、信用或风控状态、最低余额、当日次数、未完成订单、等级商品范围。") bullet(doc, "统计项:总资产、昨日佣金、今日佣金、今日领取数;所有统计口径需要后台统一定义。") bullet(doc, "规则文案需要 CMS 管理,禁止把风控与资金规则硬编码在页面。") heading(doc, "5.5 手工商城", 2) p(doc, "页面以双列卡片展示图片、任务名称、份数、工资与已领取数;点击卡片实测直接进入客服页。建议增加任务详情、适用人群、交付方式、预计周期、库存、审核标准和风险提示,再由用户主动发起咨询。") p(doc, "参考站点一条商品名称中可见脚本标签文本,说明内容过滤不足。新系统必须对运营内容做 HTML 转义、富文本白名单、图片域名白名单和持久型 XSS 检测。") heading(doc, "5.6 客服", 2) p(doc, "客服页展示多个客服入口与工作时间,点击后在站内 iframe 打开第三方聊天。聊天页含历史会话、系统消息、文本框、图片与视频上传。需求上应增加服务状态、排队人数、会话编号、隐私声明、附件大小与类型限制、敏感信息提醒和会话评价。") page_break(doc) heading(doc, "5.7 我的与个人信息", 2) add_table(doc, ["功能", "需求", "边界或风险"], [ ("资料概览", "头像、昵称、邀请码、会员等级、信用状态、余额", "手机号等敏感字段默认脱敏"), ("储入与取出", "提供余额入口与状态提示", "资金操作必须二次确认与风控"), ("收款信息", "姓名、银行、账户、可选支付通道", "加密、最小化收集、修改留痕"), ("头像", "上传、裁剪、预览、替换", "文件扫描、大小和格式校验"), ("密码", "旧密码、新密码、确认密码", "强度、频控、成功后令牌失效"), ("交易密码", "独立于登录密码", "不得用无盐 MD5;失败次数锁定"), ("通知", "公告列表、未读、已读、详情", "重要规则变更需留存阅读证据"), ("退出", "清除令牌和本地用户数据", "保留确认,避免误触"), ], [1.0, 3.1, 2.45]) heading(doc, "5.8 储入", 2) p(doc, "页面展示等级规则表和金额、姓名输入框。参考规则为普通 500、白银 1888、黄金 10000、铂金 30000、钻石 50000、星耀 88888、至尊 188888;费率从 1.8% 到 5%。规则说明和表格存在个别口径差异,必须以后台版本化配置为唯一事实源。") bullet(doc, "提交前展示收款主体、订单号、有效期、金额、手续费和风险提示。") bullet(doc, "储入订单状态:待支付、待审核、成功、失败、过期、取消;失败必须提供原因。") bullet(doc, "若需要上传付款凭证,必须经过用户明确选择,并限制为图片或 PDF。") heading(doc, "5.9 取出与收款绑定", 2) p(doc, "取出页面展示余额、金额、已绑定收款信息摘要和交易密码。若未绑定,弹窗提示并引导至绑定页。绑定页实测字段为姓名、银行名、银行账户、人民币编码、支付宝收款码和微信收款码。建议删除含义不清或非必要字段,并根据支付渠道动态展示。") bullet(doc, "金额校验:大于 0、不超过可用余额、符合单笔最小值和最大值,并显示手续费与到账金额。") bullet(doc, "状态:待审核、审核中、处理中、成功、失败、驳回、取消;用户可查看原因和预计时间。") bullet(doc, "交易密码只在提交时使用;服务端采用强哈希校验并配合频率限制、设备风控和二次验证。") page_break(doc) heading(doc, "\u20605.10 账户明细与记录", 2) p(doc, "账户明细需按业务单号聚合展示变动类型、变动金额、变动前余额、变动后余额、时间、状态和关联单据。储入记录显示订单号、状态、金额、创建时间;取出记录字段同构,并增加手续费、到账金额与失败原因。") heading(doc, "6 数据模型与状态机", 1) add_table(doc, ["实体", "关键字段", "说明"], [ ("User", "id, username, mobile, avatar, status, inviteCode", "用户主档,手机号单独加密与索引"), ("Membership", "levelId, rate, dailyLimit, withdrawLimits, effectiveAt", "版本化等级权益"), ("Wallet", "available, frozen, currency, version", "使用乐观锁或账户序列保证一致性"), ("LedgerEntry", "bizNo, type, direction, amount, before, after", "不可变台账,支持幂等与冲正"), ("TaskOrder", "orderNo, productId, amount, commission, status, deadline", "任务生命周期"), ("DepositOrder", "depositNo, amount, channel, status, proofUrl", "储入生命周期"), ("WithdrawalOrder", "withdrawNo, amount, fee, netAmount, status", "取出生命周期"), ("PayoutProfile", "realName, bankName, maskedAccount, qrAssetIds", "敏感字段加密与脱敏"), ("Content", "type, title, body, version, status, publishedAt", "公告、规则与公司资料"), ("SupportSession", "sessionNo, provider, status, openedAt, closedAt", "第三方客服映射"), ("AuditLog", "actor, action, target, before, after, ip, device", "资金与权限操作强制留痕"), ], [1.05, 3.55, 2.0]) heading(doc, "6.1 幂等与一致性", 2) for item in [ "领取、提交订单、储入回调、取出申请和密码修改都必须使用幂等键。", "钱包余额只能由服务端台账事务修改;前端显示值不参与结算。", "订单状态转换由服务端状态机控制,不允许客户端任意设置。", "重复回调、网络重试和并发点击不得重复扣款、返款或发佣金。", "所有涉及金额的接口返回业务单号和最新余额版本。", ]: bullet(doc, item) heading(doc, "7 接口需求", 1) p(doc, "下表分为实测请求与前端资源中可见接口。实测接口在浏览器中返回 HTTP 200,响应外层为 Base64 字符串,解码后为 JSON,统一包含 success、code、data、msg。建议新系统直接使用标准 JSON,不把 Base64 当作安全措施。") add_table(doc, ["方法", "接口", "用途", "主要请求或返回字段", "证据"], [ ("POST", "/wap-api/doLogin", "登录", "username, password;token, users", "前端实现与已发生请求"), ("POST", "/wap-api/doRegister", "注册", "nickName, username, password, inviteCode, code, uuid", "前端实现"), ("GET", "/wap-api/captchaImage", "图片验证码", "uuid, img, captchaBol", "前端实现"), ("GET", "/wap-api/profile", "用户概要", "tel, username, headpic, inviteCode, level, levelRate, balance, xyf", "实测"), ("GET", "/wap-api/banner/list", "首页横幅", "图片地址数组", "实测"), ("GET", "/wap-api/home", "首页开关与内容编号", "SHOP_STATUS, TASK, POP_MESSAGE, 内容 ID", "实测"), ("GET", "/wap-api/user/data/home", "荣誉榜", "name, avatar, balance", "实测"), ("GET", "/wap-api/ctrl/level-list", "等级配置", "name, orderNum, num, giveNum, bili, 提现限制", "实测"), ("GET", "/wap-api/support", "客服列表", "qq, url, workTime, 分页", "实测"), ("GET", "/wap-api/convey/page", "订单分页", "pageNo, pageSize, status;订单与商品字段", "实测"), ("POST", "/wap-api/convey/order", "领取或匹配订单", "无显式参数;返回订单", "前端实现,未执行"), ("POST", "/wap-api/convey/order/submit/{id}", "提交任务", "订单 id", "前端实现,未执行"), ("GET", "/wap-api/convey/count", "领取统计", "balance, countToday, countNumToday, countNumYesterday, content", "实测"), ("GET", "/wap-api/convey/unpay", "未完成订单检查", "djs 等状态", "实测"), ("GET", "/wap-api/product/page", "手工商品分页", "分页条件;商品卡片", "前端实现"), ("GET", "/wap-api/platform-config/trade-wait-time", "任务等待时间", "秒数或分钟数", "实测"), ("GET", "/wap-api/bank/current/info", "当前收款信息", "收款资料摘要", "前端实现"), ("POST", "/wap-api/bank/save", "保存收款信息", "姓名、银行、账户、收款码", "前端实现,未执行"), ("POST", "/wap-api/recharge/recharge", "创建储入申请", "amount, name 等", "前端实现,未执行"), ("GET", "/wap-api/recharge/page", "储入记录分页", "分页与状态", "前端实现"), ("POST", "/wap-api/deposit/do_deposit", "创建取出申请", "amount, paypassword 等", "前端实现,未执行"), ("GET", "/wap-api/deposit/page", "取出记录分页", "分页与状态", "前端实现"), ("GET", "/wap-api/balance-log/page", "账户流水", "分页;类型、金额、业务单号", "前端实现与页面实测"), ("POST", "/wap-api/users/setPwd", "修改登录密码", "oldPwd, newPwd", "前端实现,未执行"), ("POST", "/wap-api/users/setPwd2", "修改交易密码", "oldPwd, newPwd", "前端实现,未执行"), ("POST", "/wap-api/local/upload", "文件上传", "multipart file", "前端实现,未执行"), ("GET/POST", "/wap-api/user-tc/select /read/{id} /page", "弹窗通知", "公告、已读、分页", "实测与前端实现"), ], [0.55, 1.65, 1.25, 2.45, 0.85], 7.2) heading(doc, "7.1 新系统接口规范", 2) for item in [ "统一使用 HTTPS、JSON、明确的 HTTP 状态码、traceId、错误码和可本地化 message。", "Authorization 使用短期访问令牌与可撤销刷新令牌;敏感操作增加一次性挑战或二次验证。", "列表接口统一 page、pageSize、sort、status,返回 items、total、hasNext。", "金额使用最小货币单位整数或高精度 Decimal,禁止浮点结算。", "上传接口先申请受限上传凭证,服务端扫描后再绑定业务。", "所有写接口要求 Idempotency-Key、设备信息与审计上下文。", ]: bullet(doc, item) heading(doc, "8 管理后台需求", 1) add_table(doc, ["模块", "主要能力", "审批与审计"], [ ("用户管理", "查询、状态、等级、信用、设备、邀请关系", "冻结与解冻需理由和双人复核"), ("等级配置", "门槛、赠送、次数、费率、提现限制、商品范围", "版本化发布,可回滚"), ("任务商品", "商品、图片、金额、佣金、库存、上下线", "内容过滤和审核流"), ("任务订单", "状态、倒计时、资金流水、异常处理", "人工改状态必须双人审批"), ("储入管理", "支付订单、回调、凭证、审核、对账", "每日对账与差错处理"), ("取出管理", "审核、通道、手续费、失败重试", "大额与异常规则"), ("内容管理", "横幅、规则、简介、资质、公告", "预览、定时、版本与发布人"), ("客服管理", "客服入口、工作时间、会话映射、工单", "第三方访问最小权限"), ("风控", "设备、IP、频次、关联账户、异常金额", "命中规则可解释并可申诉"), ("权限审计", "RBAC、操作日志、导出日志", "关键权限定期复核"), ], [1.0, 3.65, 2.1]) heading(doc, "9 非功能需求", 1) heading(doc, "9.1 安全", 2) for item in [ "密码使用 Argon2id 或 bcrypt 加盐存储。参考站点在浏览器端对密码做 MD5,这不能替代服务端强哈希。", "输入与富文本做上下文相关转义;运营内容采用白名单;CSP 禁止任意外部脚本。", "银行卡和手机号加密存储,日志与埋点禁止记录完整值;展示统一脱敏。", "接口启用 CSRF 或同站策略、速率限制、风控挑战、签名回调和重放保护。", "第三方客服 iframe 设置最小 sandbox 权限、allowlist 域名和清晰的数据传输告知。", "上传文件限制 MIME、扩展名、体积与像素,执行恶意内容扫描,使用隔离存储和短期访问 URL。", ]: bullet(doc, item) heading(doc, "9.2 性能与可用性", 2) for item in [ "首屏 LCP 目标小于 2.5 秒,核心 API P95 小于 800 毫秒,写操作 P95 小于 1.5 秒。", "按钮防抖、请求取消、重试与离线提示;长列表分页和图片懒加载。", "资金写入接口可用性目标 99.95%,客服与内容接口目标 99.9%。", "客户端时间仅用于展示,截止时间与状态以服务端为准。", ]: bullet(doc, item) heading(doc, "9.3 可访问性与国际化", 2) p(doc, "按钮和导航必须有语义标签与至少 44×44 像素触控区域;颜色不作为唯一状态标识;支持字体放大。简体、繁体和术语需统一,避免同一页面混用“储入/充值”“取出/提现”“领取/抢单”。") heading(doc, "10 合规与业务风险", 1) p(doc, "参考站点涉及先储入、任务扣款、返本付佣、推荐奖励、信用分和提现限制。此类模式在不同司法辖区可能触及支付、消费者保护、反洗钱、网络营销或其他监管要求。上线前需由法务和持牌支付机构评估,不应直接复制其资金规则、免责条款或客服收款模式。") add_table(doc, ["风险", "参考站点表现", "新系统要求", "优先级"], [ ("资金真实性", "客服提供收款方式,失败责任不清", "使用合规支付通道、订单化收款与自动对账", "P0"), ("诱导与不透明", "高等级对应更高储入和收益", "明确费用、风险、无保证收益,禁止误导", "P0"), ("推荐奖励", "三级佣金 16% 8% 4%", "法务审查后决定是否启用", "P0"), ("账户冻结", "规则含冻结、补差额、信用分", "冻结需合法依据、通知、申诉和复核", "P0"), ("内容安全", "商品名出现脚本标签文本", "CMS 白名单与持久型 XSS 防护", "P0"), ("密码安全", "前端无盐 MD5", "服务端强哈希、MFA、频控", "P0"), ("隐私", "收集银行账户和收款码", "最小化、加密、授权、删除与访问审计", "P0"), ("数据一致性", "筛选短暂空列表、规则口径差异", "单一配置源、请求竞态处理、自动化测试", "P1"), ], [1.0, 2.0, 2.9, 0.65]) heading(doc, "11 埋点与运营指标", 1) add_table(doc, ["事件", "触发时机", "关键属性"], [ ("auth_login_result", "登录成功或失败", "result, errorCode, deviceRisk"), ("home_shortcut_click", "点击首页快捷入口", "target, userLevel"), ("order_claim_click", "点击领取", "eligibilityResult, balanceBucket, level"), ("order_state_change", "订单状态变化", "orderNo, from, to, reason"), ("deposit_submit", "提交储入申请", "amountBucket, channel, result"), ("withdraw_submit", "提交取出申请", "amountBucket, fee, result"), ("support_open", "打开客服会话", "provider, entry, workStatus"), ("content_view", "查看规则或公告", "contentId, version, readDuration"), ("security_challenge", "触发安全验证", "scene, result, ruleId"), ], [1.35, 2.2, 3.0]) p(doc, "核心指标包括注册转化、首个任务完成率、领取成功率、订单超时率、储入成功率、取出成功率与处理时长、客服转人工率、异常账户率、账务差错率和投诉率。涉及金额与身份的数据只做区间化或匿名化统计。") heading(doc, "12 验收标准", 1) for idx, item in enumerate([ "未登录用户只能访问登录、注册和公开协议;受保护路由统一拦截。", "同一用户重复点击领取或提交订单,不产生重复业务单和重复资金流水。", "订单完成后,订单状态、钱包余额、三类流水和统计数据在一次事务或可验证最终一致性中完成。", "储入与取出所有状态均可在记录页查询,失败和驳回有明确原因。", "等级规则由后台发布后前台一致生效,表格、领取校验与提现限制不存在口径差异。", "手机号、银行卡、邀请码和交易密码不出现在客户端日志、服务端普通日志或分析平台。", "富文本、商品名、公告和客服消息中的脚本或危险 HTML 不能执行。", "接口 401、403、429、5xx、超时和断网场景均有清晰恢复路径。", "移动端 320 至 430 像素宽度可用,底部导航不遮挡内容,键盘弹出时表单按钮可达。", "关键资金和权限操作在后台可按用户、业务单号和时间完整审计。", ], 1): p(doc, f"{idx}. {item}") heading(doc, "13 交付优先级与里程碑", 1) add_table(doc, ["阶段", "范围", "退出条件"], [ ("MVP 认证与账户", "登录注册、资料、钱包、台账、内容、客服", "权限、安全、审计基线通过"), ("MVP 任务闭环", "任务列表、领取、详情、提交、状态机、佣金", "幂等和账务一致性测试通过"), ("资金闭环", "储入、取出、收款绑定、支付通道、对账", "支付与法务合规评审通过"), ("运营能力", "等级、公告、横幅、商品、通知、后台", "配置版本和发布审核可用"), ("增长与优化", "邀请关系、数据看板、体验优化", "合规批准且核心指标稳定"), ], [1.1, 3.55, 2.0]) page_break(doc) heading(doc, "14 低保真原型", 1) p(doc, "以下原型根据实测页面重新绘制,用于确认页面层级、组件位置和交互入口,不复制原站品牌素材、头像或敏感数据。视觉设计阶段应重新建立色彩、字体、图标和组件规范。") for idx, fig in enumerate(figures, 1): add_picture(doc, fig, f"图 {idx} 参考站点功能复刻原型", width=6.2, page_break_before=(idx > 1)) final_heading = heading(doc, "15 待确认事项", 1) final_heading.paragraph_format.page_break_before = True for item in [ "目标产品是否真实涉及用户资金储入与取出,还是仅做演示积分。", "任务来源、商品真实性、结算主体和佣金承担方。", "目标市场与适用法律、支付通道和实名要求。", "是否保留会员等级、推荐奖励、信用分和冻结机制。", "客服使用自建系统还是第三方服务,允许传输哪些用户数据。", "管理后台的组织结构、审批层级和财务对账责任人。", "品牌名、语言版本、视觉方向与是否需要 App 壳或 PWA。", ]: bullet(doc, item) p(doc, "在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。") doc.core_properties.title = "移动端任务与账户平台需求文档" doc.core_properties.subject = "参考站点功能拆解与重构建议" doc.core_properties.author = "Product Requirement Analysis" doc.core_properties.keywords = "需求文档, 移动端, 任务订单, 账户, 接口, 原型" doc.save(OUT_DOCX) print(OUT_DOCX) if __name__ == "__main__": build_doc()