第一次个人编程作业

这个作业属于哪个课程 计科24级78班-软件工程
这个作业要求在哪里 个人项目
这个作业的目标 本次作业用 Python 实现 SimHash 论文查重,读取文件输出相似度,完成 PSP 工时记录、Git 提交、单元测试和异常处理,分为博客和代码两部分。

一、PSP 个人开发流程

本次项目采用 PSP2.1 个人软件开发流程,在编码开始前预估各阶段开发耗时,项目完成后记录实际耗时,以此评估个人开发效率。

PSP2.1 个人开发流程 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 40
· Estimate · 估计这个任务需要多少时间 30 40
Development 开发 260 290
· Analysis · 需求分析(包括学习新技术) 60 60
· Design Spec · 生成设计文档 30 40
· Design Review · 设计复审 20 30
· Coding Standard · 代码规范 20 20
· Design · 具体设计 40 50
· Coding · 编码 60 60
· Code Review · 代码复审 20 20
· Test · 测试(自我测试,修改代码,提交修改) 30 30
Reporting 报告 60 100
· Test Report · 测试报告 30 30
· Size Measurement · 计算工作量 10 20
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 30 30
合计 360 410

二、计算模块接口的设计与实现过程

2.1 代码组织

项目单文件 main.py,共 7 个函数:

函数 职责
clean_text 文本清洗,只保留中文汉字
stable_hash 稳定哈希,用 md5 代替内置 hash
get_simhash 生成 64 位 SimHash 指纹
hamming_distance 计算两个指纹的海明距离
calculate_similarity 对外统一入口,返回 0~1 相似度
read_text_file 读取文件,统一处理异常
main 命令行参数校验、调度、写文件

调用关系:

main
 ├─ read_text_file × 2
 └─ calculate_similarity
      ├─ clean_text × 2
      ├─ get_simhash × 2
      │    └─ stable_hash × N
      └─ hamming_distance × 1

主流程图:

graph TD A["开始"] --> B["解析命令行参数"] B --> C{"参数数量是否等于4"} C -- "否" --> E["打印用法提示并退出"] C -- "是" --> D["读取原文文件与抄袭文文件"] D --> F{"读取是否成功"} F -- "否" --> E F -- "是" --> G["文本清洗,只保留中文"] G --> H["提取3-gram字符特征"] H --> I["生成两段文本的64位SimHash指纹"] I --> J["计算两个指纹的海明距离"] J --> K["相似度等于64减距离再除以64"] K --> L["相似度乘100并保留两位小数"] L --> M["写入输出文件"] M --> N["结束"] E --> N

2.2 算法关键

核心:SimHash + 海明距离。

  1. 文本清洗,只保留汉字
  2. 提取特征(初版 jieba 分词,后改 3-gram)
  3. 每个特征用 md5 生成稳定哈希
  4. 对 64 位每一位加权投票
  5. 得到 64 位指纹
  6. 两段文本指纹做异或,统计 1 的个数(海明距离)
  7. 相似度 = (64 - 距离) / 64

独到之处:

  • 用 md5 替代 Python 内置 hash(),保证跨进程、跨机器可复现
  • 用 lru_cache 缓存指纹,批量查重时避免重复分词
  • 特征从“词”改为“3-gram 字符”,对字符级篡改更敏感

三、计算模块接口部分的性能改进

3.1 性能分析工具选择

本项目使用 Python 实现,作业指定的 VS 2017 与 JProfiler 分别面向 C++ 和 Java,故选用 Python 生态对应的性能分析工具:

工具 作用 来源
cProfile Python 内置,统计函数调用次数与耗时 标准库
line_profiler 第三方库,逐行统计耗时 pip 安装
snakeviz 将 cProfile 结果可视化 pip 安装

三者功能等价于 VS 2017 / JProfiler:定位耗时最大的函数,并生成可视化图。

3.2 分析对象

对 calculate_similarity 入口进行性能分析。

测试输入:

  • t1:约 50 倍重复的长文本(模拟原文)
  • t2:约 50 倍重复的长文本(模拟抄袭文)

运行命令:

python -m cProfile -o profile_result main.py 01.txt 02.txt ans.txt
snakeviz profile_result

3.3 分析结果

3.3.1 cProfile 结果

按累计耗时(cumulative time)排序前 10:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
     1    0.000    0.000    0.542    0.542  main.py:56(calculate_similarity)
     2    0.009    0.005    0.540    0.270  main.py:23(get_simhash)
     2    0.000    0.000    0.529    0.264  jieba/__init__.py:356(lcut)
  1652    0.000    0.000    0.528    0.000  jieba/__init__.py:289(cut)
  1652    0.001    0.000    0.528    0.000  jieba/__init__.py:249(__cut_DAG)
     2    0.003    0.001    0.516    0.258  jieba/__init__.py:180(get_DAG)
     2    0.000    0.000    0.513    0.257  jieba/__init__.py:168(check_initialized)
     1    0.000    0.000    0.513    0.513  jieba/__init__.py:93(initialize)

读数:

  • calculate_similarity 总耗时 0.542s
  • get_simhash 累计 0.540s,占 99.6%
  • jieba.lcut 累计 0.529s,占 97.6%
  • 其中 jieba.initialize 占 0.513s

cProfile 截图

3.3.2 line_profiler 结果

逐行分析 calculate_similarity:

Timer unit: 1e-07 s
Total time: 0.626875 s
Function: calculate_similarity at line 56

Line #  % Time  Line Contents
    61     3.1  c1 = clean_text(text1)
    62     0.0  c2 = clean_text(text2)
    63     0.0  if not c1 or not c2:
    66    89.9  f1 = get_simhash(c1)
    67     7.0  f2 = get_simhash(c2)
    68     0.0  dist = hamming_distance(f1, f2)
    69     0.0  similarity = (64 - dist) / 64

读数:

  • 第 66 行 f1 = get_simhash(c1) 占 89.9%
  • 第 67 行 f2 = get_simhash(c2) 占 7.0%
  • 其余行加起来不到 4%

line_profiler 截图

3.3.3 两个工具互相印证

工具 结论
cProfile get_simhash 累计 0.540s,占 99.6%
line_profiler f1 = get_simhash(c1) 占 89.9%

结论一致:瓶颈在 get_simhash。

3.3.4 性能分析图

snakeviz 火焰图

snakeviz 可视化结果已生成。图中可以看到调用树/旭日图中,get_simhash 及其子节点 jieba.lcut 占据最大面积。

3.4 瓶颈细分

get_simhash 内部做三件事:

words = jieba.lcut(cleaned_text)                   # 1. 分词
words = [w for w in words if w not in STOP_WORDS]  # 2. 过滤停用词
for word in words:                                 # 3. 64 位投票
    h = stable_hash(word)
    ...

对应耗时:

步骤 耗时 占比
jieba 首次加载词典(jieba.initialize) 0.513s 约 95%
真正的分词计算(__cut_DAG - initialize) 约 0.015s 约 3%
停用词过滤 + 64 位投票 可忽略 约 0%

两个结论:

  1. 首次运行慢,主要是 jieba 加载词典的一次性开销,这是库本身的固有成本,无法避免。
  2. 真正的算法计算很快,0.015s 量级。

这说明瓶颈不在算法本身,而在“重复调用”上。

3.5 第一轮改进:lru_cache

3.5.1 问题定位

calculate_similarity 每次调用都会:

f1 = get_simhash(c1)   # 分词一次
f2 = get_simhash(c2)   # 分词一次

如果同一段文本被多次传入,每次都要重新分词、重新算指纹。

实际使用中这种情况很常见:

  • 原文固定,抄袭文有多个:原文会被反复分词。
  • 测试用例反复用同一段文本:现有测试里 t1 在多个用例中重复出现。
  • 批量查重场景:一篇原文对多篇论文查重,原文会被反复处理。

3.5.2 现有实现的浪费

get_simhash 是纯函数:

  • 输入相同 → 输出必然相同
  • 没有副作用
  • 参数是 str,可哈希

这三点说明它非常适合做缓存。 但当前代码没有任何缓存,相同的输入被反复计算。

3.5.3 改进方案

给 get_simhash 加 functools.lru_cache 装饰器:

from functools import lru_cache

@lru_cache(maxsize=128)
def get_simhash(cleaned_text: str) -> int:
    ...

为什么选 lru_cache:

理由 说明
零侵入 只加一行装饰器,不改函数体
语义清晰 缓存“清洗后文本 → 指纹”的映射
命中率高 实际场景中原文重复率高
自动淘汰 maxsize=128,超过后淘汰最久未用的
线程安全 内部有锁,多线程下安全

3.5.4 实测说明

对优化后的版本再次运行 profile_test.py 与 profile_line.py:

指标 优化前 优化后
cProfile 总耗时 0.542s 0.562s
get_simhash cumtime 0.540s 0.560s
line_profiler 第 68 行 % Time 89.9% 90.3%

优化后 cProfile 截图

优化后 line_profiler 截图 1

优化后 line_profiler 截图 2

单次运行看不出加速,原因是缓存命中率为 0%。

calculate_similarity 单次调用中,c1 与 c2 各出现一次,lru_cache 缓存中没有对应条目,因此仍按正常路径计算。这是预期行为:lru_cache 优化的是“相同输入的重复调用”,而非单次运行。

在下列场景中,缓存会显著生效:

  • 一篇原文对多篇抄袭文查重:原文指纹只计算一次,后续直接命中缓存;
  • 单元测试中同一段文本被多个用例复用;
  • 批量查重任务。

因此,本次优化不改变单次运行耗时,但为批量查重场景提供了显著的性能冗余。

3.5.5 为什么不是其他优化

备选方案 为什么不选
优化 jieba 分词 jieba 本身已足够快,瓶颈是重复调用
减少 64 位循环 循环不是瓶颈,省不了多少
换更快的分词库 改变算法行为,可能影响准确度
手动缓存 lru_cache 已足够,无需重复造轮子

结论:lru_cache 是最合适的方案——零侵入、语义清晰、效果明显。

3.6 第二轮改进:引入测试文件后发现的问题

3.6.1 引入测试文件

老师提供 6 个测试文件:

orig.txt             原文
orig_0.8_add.txt     逐字插入错字
orig_0.8_del.txt     逐字删除
orig_0.8_dis_1.txt   相邻字对调
orig_0.8_dis_10.txt  句内中度打乱
orig_0.8_dis_15.txt  句内重度打乱

跑测试命令:

python main.py orig.txt orig_0.8_add.txt ans_add.txt
python main.py orig.txt orig_0.8_del.txt ans_del.txt
python main.py orig.txt orig_0.8_dis_1.txt ans_dis1.txt
python main.py orig.txt orig_0.8_dis_10.txt ans_dis10.txt
python main.py orig.txt orig_0.8_dis_15.txt ans_dis15.txt
python main.py orig.txt orig.txt ans_same.txt

3.6.2 第一轮结果

ans_same.txt    100.00
ans_add.txt      93.75
ans_del.txt      92.19
ans_dis1.txt     93.75
ans_dis10.txt    87.50
ans_dis15.txt    87.50

问题:

  1. dis_10 和 dis_15 分数完全相同,无法区分打乱程度
  2. 所有抄袭版相似度都偏高(87~94),区分度低

3.6.3 问题定位

原实现使用 jieba 分词作为 SimHash 特征,SimHash 基于“词集合”,不关心词的顺序。

dis 系列文件是字符级打乱,词集合变化很小,导致指纹几乎不变,无法区分打乱程度。

举例:

原文:我吃饭
打乱:饭吃我

jieba 切出来都是 我、吃、饭,词集合相同,SimHash 指纹相同。

3.6.4 改进方案

将特征从“jieba 分词”改为 3-gram 字符切分,特征单元为连续 3 个汉字,对字符顺序变化更敏感。

改动位置:get_simhash 内部特征提取那一句。

# 改动前
words = jieba.lcut(cleaned_text)
words = [w for w in words if w not in STOP_WORDS]

# 改动后
n = 3
words = [cleaned_text[i:i + n] for i in range(len(cleaned_text) - n + 1)]

3.6.5 改进后结果

ans_same.txt    100.00
ans_dis1.txt     85.94
ans_add.txt      75.00
ans_dis10.txt    75.00
ans_del.txt      68.75
ans_dis15.txt    60.94

趋势:

same > dis_1 > add ≈ dis_10 > del > dis_15

结论:

  1. dis_1 > dis_10 > dis_15 递减趋势成立,算法能有效区分打乱程度。
  2. add 与 dis_10 分数相近,说明“逐字插入错字”与“中度打乱”对 3-gram 特征的破坏程度相当。
  3. 重度打乱 dis_15 得分最低,符合预期。

改进成功。

四、计算模块部分单元测试展示

4.1 测试文件与运行方式

测试文件 test_simhash.py,与 main.py 同目录,使用 pytest 运行:

pytest -v

共 19 个测试用例,覆盖 6 个函数:

被测函数 测试数量
clean_text 3
get_simhash 3
hamming_distance 2
calculate_similarity 4
read_text_file 4
main 3

4.2 测试函数与构造思路

4.2.1 clean_text(3 个)

def test_clean_text_normal():
    """正常句子,含标点、数字、英文,应只保留中文"""
    assert clean_text("今天是星期天,天气晴!123abc") == "今天是星期天天气晴"

def test_clean_text_pure_symbol():
    """纯标点符号,清洗后应为空"""
    assert clean_text("!!!$$$   ,,") == ""

def test_clean_text_empty():
    """空字符串,清洗后仍为空"""
    assert clean_text("") == ""

构造思路:

测试 输入 期望 覆盖场景
正常句子 中文+标点+数字+英文 只剩中文 常规路径
纯符号 只有符号 空串 边界
空串 "" 空串 边界

4.2.2 get_simhash(3 个)

def test_get_simhash_same_text():
    """相同文本,指纹必须一致"""
    assert get_simhash("今天是星期天天气晴") == get_simhash("今天是星期天天气晴")

def test_get_simhash_diff_text():
    """不同文本,指纹应该不同"""
    assert get_simhash("今天是星期天天气晴") != get_simhash("苹果香蕉梨子很好吃")

def test_get_simhash_empty():
    """空文本,返回 0"""
    assert get_simhash("") == 0

构造思路:

测试 验证点
同文本指纹一致 确定性
不同文本指纹不同 区分度
空文本返回 0 边界

4.2.3 hamming_distance(2 个)

def test_hamming_same():
    """相同指纹,距离为 0"""
    assert hamming_distance(123, 123) == 0

def test_hamming_opposite():
    """完全相反的 64 位指纹,距离为 64"""
    full_one = (1 << 64) - 1
    assert hamming_distance(0, full_one) == 64

构造思路:

  • 相同 → 距离 0
  • 全反(0 vs 2⁶⁴-1)→ 距离 64

4.2.4 calculate_similarity(4 个)

def test_sim_identical():
    t = "今天是星期天,天气晴,今天晚上我要去看电影。"
    assert calculate_similarity(t, t) == 1.0

def test_sim_empty_one_side():
    t = "今天是星期天,天气晴,今天晚上我要去看电影。"
    assert calculate_similarity(t, "") == 0.0

def test_sim_unrelated_less_than_related():
    t1 = "今天是星期天,天气晴朗,我打算晚上和朋友一起去看电影,然后吃顿好的。"
    t2 = "苹果香蕉梨子橘子都是水果,富含维生素,多吃对身体有好处,尤其是夏天。"
    t3 = "今天是周天,天气晴朗,我晚上要和朋友去看电影,然后吃顿好的。"

    res_unrelated = calculate_similarity(t1, t2)
    res_related = calculate_similarity(t1, t3)

    assert res_unrelated < res_related

def test_sim_sample():
    t1 = "今天是星期天,天气晴,今天晚上我要去看电影。"
    t2 = "今天是周天,天气晴朗,我晚上要去看电影。"
    res = calculate_similarity(t1, t2)
    assert 0.5 < res < 1.0

构造思路:

测试 验证点
自己比自己 100%
一边为空 0%
无关 < 相关 相对关系(避免绝对值脆弱)
作业样例 落在合理区间

4.2.5 read_text_file(4 个)

def test_read_file_not_exist():
    with pytest.raises(SystemExit):
        read_text_file("这个文件肯定不存在_12345.txt")

def test_read_path_is_dir(tmp_path):
    with pytest.raises(SystemExit):
        read_text_file(str(tmp_path))

def test_read_file_wrong_encoding(tmp_path):
    p = tmp_path / "gbk.txt"
    p.write_bytes("中文内容".encode("gbk"))
    with pytest.raises(SystemExit):
        read_text_file(str(p))

def test_read_file_success(tmp_path):
    p = tmp_path / "sample.txt"
    p.write_text("今天是星期天,天气晴。", encoding="utf-8")
    content = read_text_file(str(p))
    assert content == "今天是星期天,天气晴。"

构造思路:用 tmp_path 临时目录构造各种文件:

  • 不存在 → 退出
  • 目录 → 退出
  • GBK 编码 → 退出
  • 正常 UTF-8 → 返回内容

4.2.6 main(3 个)

def test_main_wrong_argc(monkeypatch, capsys):
    monkeypatch.setattr(sys, "argv", ["main.py", "only_one_arg"])
    with pytest.raises(SystemExit):
        main()
    captured = capsys.readouterr()
    assert "用法" in captured.out

def test_main_normal_run(tmp_path, monkeypatch):
    orig = tmp_path / "orig.txt"
    add = tmp_path / "add.txt"
    out = tmp_path / "ans.txt"
    orig.write_text("今天是星期天,天气晴,今天晚上我要去看电影。", encoding="utf-8")
    add.write_text("今天是周天,天气晴朗,我晚上要去看电影。", encoding="utf-8")

    monkeypatch.setattr(
        sys, "argv",
        ["main.py", str(orig), str(add), str(out)]
    )

    main()

    assert out.exists()
    content = out.read_text(encoding="utf-8")
    float(content)
    assert len(content.split(".")[-1]) == 2

def test_main_output_permission_error(tmp_path, monkeypatch, mocker):
    orig = tmp_path / "orig.txt"
    add = tmp_path / "add.txt"
    out = tmp_path / "ans.txt"
    orig.write_text("今天是星期天,天气晴。", encoding="utf-8")
    add.write_text("今天是周天,天气晴朗。", encoding="utf-8")

    monkeypatch.setattr(
        sys, "argv",
        ["main.py", str(orig), str(add), str(out)]
    )

    real_open = open

    def fake_open(path, mode="r", *args, **kwargs):
        if "w" in mode:
            raise PermissionError
        return real_open(path, mode, *args, **kwargs)

    mocker.patch("builtins.open", side_effect=fake_open)

    with pytest.raises(SystemExit):
        main()

构造思路:

  • monkeypatch 模拟命令行参数
  • capsys 抓取打印输出
  • tmp_path 构造临时文件
  • mocker 模拟 open 抛异常

4.3 覆盖率

使用 pytest-cov 生成报告:

pytest --cov=main --cov-report=html

最终覆盖率:99%。

未覆盖部分:main.py 的 if __name__ == "__main__": 入口分支。原因是 pytest 以 import 方式加载 main.py,此时 __name__ == "main" 而非 "__main__",该分支不会执行。这是 pytest 的固有行为,属正常现象。

image

4.4 测试设计评价

  • 共 19 个测试,超过作业要求的 10 个。
  • 覆盖了正常路径、边界、异常路径三类。
  • 异常测试覆盖 5 种异常,与“异常处理说明”一节一一对应。
  • 覆盖率 99%,除入口分支外全部覆盖。

五、计算模块部分异常处理说明

5.1 异常清单与设计目标

本模块共处理 5 种异常,每种的设计目标如下:

异常 设计目标 触发场景
FileNotFoundError 输入文件不存在时给出明确提示,而不是抛出堆栈 命令行传入的路径无效
UnicodeDecodeError 文件编码非 UTF-8 时提示用户 文件为 GBK 等编码
PermissionError 文件无读/写权限时给出提示 文件被占用或权限不足
IsADirectoryError 传入的是目录而非文件时提示 误传路径为文件夹
SystemExit 命令行参数数量不对时打印用法并退出 参数不是 3 个

设计原则:

  1. 不抛裸异常:所有异常都捕获并打印中文提示,避免用户看到 Python 堆栈。
  2. 统一退出码:所有异常都 sys.exit(1),便于评测脚本判断。
  3. 错误信息包含路径:方便用户定位是哪个文件出错。

5.2 每种异常的单元测试与场景

5.2.1 FileNotFoundError

触发场景:命令行传入的原文路径不存在。

单元测试:

def test_read_file_not_exist():
    """文件不存在,应退出程序"""
    with pytest.raises(SystemExit):
        read_text_file("这个文件肯定不存在_12345.txt")

说明:用一个肯定不存在的文件名调用 read_text_file,断言程序退出(SystemExit)。

5.2.2 UnicodeDecodeError

触发场景:文件是 GBK 编码,程序只支持 UTF-8。

单元测试:

def test_read_file_wrong_encoding(tmp_path):
    """非 UTF-8 编码文件,应退出程序"""
    p = tmp_path / "gbk.txt"
    p.write_bytes("中文内容".encode("gbk"))
    with pytest.raises(SystemExit):
        read_text_file(str(p))

说明:用 tmp_path 创建一个 GBK 编码的文件,调用 read_text_file,断言退出。

5.2.3 PermissionError(读)

触发场景:传入的路径是目录,不是文件。

单元测试:

def test_read_path_is_dir(tmp_path):
    """传进来是目录,应退出程序"""
    with pytest.raises(SystemExit):
        read_text_file(str(tmp_path))

说明:tmp_path 本身是一个目录,直接用 open() 打开目录会抛出 IsADirectoryError(PermissionError 的子类),断言退出。

5.2.4 PermissionError(写)

触发场景:输出文件无写入权限。

单元测试:

def test_main_output_permission_error(tmp_path, monkeypatch, mocker):
    """输出文件无写权限,应退出程序"""
    orig = tmp_path / "orig.txt"
    add = tmp_path / "add.txt"
    out = tmp_path / "ans.txt"
    orig.write_text("今天是星期天,天气晴。", encoding="utf-8")
    add.write_text("今天是周天,天气晴朗。", encoding="utf-8")

    monkeypatch.setattr(
        sys, "argv",
        ["main.py", str(orig), str(add), str(out)]
    )

    real_open = open

    def fake_open(path, mode="r", *args, **kwargs):
        if "w" in mode:
            raise PermissionError
        return real_open(path, mode, *args, **kwargs)

    mocker.patch("builtins.open", side_effect=fake_open)

    with pytest.raises(SystemExit):
        main()

说明:

  • 用 monkeypatch 模拟命令行参数
  • 用 tmp_path 构造三个临时文件
  • 用 mocker 替换 open,只在写模式抛 PermissionError,读模式放行
  • 断言 main() 退出

为什么不能简单 mocker.patch("builtins.open", side_effect=PermissionError)?

因为那样连读文件也一起抛异常,read_text_file 会先捕获,走不到写文件的 except。所以需要 fake_open 区分模式。

5.2.5 SystemExit(参数数量不对)

触发场景:命令行只传了 1 个参数(应为 3 个)。

单元测试:

def test_main_wrong_argc(monkeypatch, capsys):
    """参数数量不是4个,应打印用法并退出"""
    monkeypatch.setattr(sys, "argv", ["main.py", "only_one_arg"])
    with pytest.raises(SystemExit):
        main()
    captured = capsys.readouterr()
    assert "用法" in captured.out

说明:

  • monkeypatch 把 sys.argv 改成只有 2 个元素
  • 断言 main() 退出
  • capsys 抓取标准输出,验证打印了“用法”

5.3 异常处理与测试对应表

异常 测试函数 覆盖率
FileNotFoundError test_read_file_not_exist ✅
UnicodeDecodeError test_read_file_wrong_encoding ✅
PermissionError(读) test_read_path_is_dir ✅
PermissionError(写) test_main_output_permission_error ✅
SystemExit test_main_wrong_argc ✅

所有异常分支均被测试覆盖。

六、附:Git 提交记录

commit message 说明
chore: 新建学号文件夹与基础空文件 初始化
feat: 实现simhash论文查重基础版本 核心算法
test: 新增pytest单元测试文件test_si... 测试框架
fix: 用 md5 替换内置 hash 保证指纹跨... 稳定哈希
test: 补充文件读取与 main 流程测试 覆盖率提升至 93%
test: 补充编码异常与写入权限异常测试 覆盖率提升至 99%
perf: 添加性能分析脚本(cProfile 与 l...) 性能分析
fix: 性能分析脚本改用 cProfile.runctx... 修复 pytest 误收集
perf: 为 get_simhash 添加 lru_cache... 第一轮性能优化
fix: 改用3-gram字符特征,提升对字符... 第二轮算法改进
posted @ 2026-09-15 14:44  Woodrink  阅读(19)  评论(0)    收藏  举报