Files
olons_admin/tools/build_reference_prd.py
T

795 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()