第一次个人作业
第一次个人作业
| 这个作业属于哪个课程 | 首页 - 计科24级56班 - 广东工业大学 - 班级博客 - 博客园 |
|---|---|
| 这个作业要求在哪里 | 第一周作业 - 作业 - 计科24级56班 - 班级博客 - 博客园 |
| 这个作业的目标 | 写一篇随笔博客 |
一、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 5 | 5 |
| · Estimate | · 估计这个任务需要多少时间 | 5 | 5 |
| Development | 开发 | 20 | 35 |
| · Analysis | · 需求分析(包括学习新技术) | 15 | 15 |
| · Design Spec | · 生成设计文档 | 5 | 15 |
| · Design Review | · 设计复审 | 5 | 5 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 5 | 5 |
| · Design | · 具体设计 | 10 | 5 |
| · Coding | · 具体编码 | 15 | 15 |
| · Code Review | · 代码复审 | 5 | 5 |
| · Test | · 测试(自我测试、修改代码、提交修改) | 10 | 20 |
| Reporting | 报告 | 5 | 5 |
| · Test Report | · 测试报告 | 5 | 5 |
| · Size Measurement | · 计算工作量 | 5 | 5 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 5 | 10 |
| 合计 | 120 | 150 |
二、计算模块接口的设计与实现过程
2.1 需求拆解
程序要做的事情可以拆成四步:
- 从命令行拿到三个绝对路径(原文、抄袭版、答案文件);
- 把两个文件读成文本,并处理编码问题;
- 计算两段文本的重复率;
- 把结果按"两位小数"写入答案文件。
其中第 3 步是核心,其余三步是必要的工程包装。基于"高内聚、低耦合"的原则,程序被拆成 7 个模块:
main.py 程序入口,只有 4 行有效代码
└── dupcheck/ 核心包
├── cli.py 参数解析 + 流程编排 + 退出码
├── text_io.py 多编码文件读取 + 答案写出
├── preprocessor.py 文本规范化 + n-gram 切分
├── similarity.py 核心算法:多特征加权相似度
├── exceptions.py 自定义异常体系
├── utils.py 通用工具(数值截断)
└── __init__.py 包出口,统一暴露 API
2.2 模块调用关系
flowchart TD
A[main.py] -->|sys.argv| B[cli.main]
B --> C[cli.parse_args]
C -->|InvalidArgumentError| X[stderr 提示 + 退出码 2]
B --> D[cli.check_similarity]
D --> E[text_io.read_text]
E -->|EmptyDocumentError| F[降级为空串]
E -->|FileAccessError / EncodingDetectionError| Y[stderr 提示 + 退出码 1]
D --> G[similarity.compute_similarity]
G --> H[preprocessor.normalize]
G --> I[_count_ngrams 切片计数]
G --> J[multiset_cosine / dice / jaccard]
D --> K[text_io.write_result]
K --> L[答案文件 0.49]

2.3 关键算法的设计
核心矛盾:抄袭版论文经过了增删改,逐字比较(编辑距离)会"一错到底",而单纯比较词集合又会丢失顺序信息。
设计选择:论文被改写后,局部的连续字片段依然会大量保留,所以选择字符级 n-gram作为基本特征,并把多种度量做加权融合:
| 特征 | 权重 | 解决什么问题 |
|---|---|---|
unigram_cosine |
0.10 | 单字层面的用词是否接近 |
bigram_cosine |
0.30 | 二字词语是否被保留 |
trigram_cosine |
0.25 | 三字短语是否被保留 |
trigram_dice |
0.25 | 片段"是否出现",对出现次数差异敏感 |
trigram_jaccard |
0.10 | 与 Dice 互补,抑制短文本的虚高得分 |
计算流程:
- 规范化(
normalize):NFKC 把全角字符折叠为半角 →casefold()统一大小写 → 只保留汉字/字母/数字。这样"晴天,天气晴朗"和"晴天 天气 晴朗"会得到完全相同的序列,标点和空白不再干扰结果。
(第 2、3 步的顺序不能调换,原因见 3.4 节末尾的单元测试。) - 切分(
build_ngrams为该模块公开的参考实现,生产路径使用等价的_count_ngrams):把规范化文本切成 1 元、2 元、3 元的连续片段,用collections.Counter统计频次。 - 度量:对 1/2/3 元片段算余弦相似度(词频向量),对 3 元片段再算 Dice 与 Jaccard(集合)。
- 融合(
_weighted_average):按权重加权平均,最后截断到[0, 1]。
算法的独到之处:
- 多粒度融合:单独用三元片段对短文本过于苛刻(题目样例只有十几个字),单独用单字又过于宽松。1~3 元加权后,短文和长文都能给出合理结果。
- 完全 O(n):全程只做切片、计数、求交并,没有任何 O(n²) 的序列比对(这一点由性能分析驱动,详见第三节)。
- 权重可配置:所有权重集中在
DEFAULT_WEIGHTS一个字典里,单元测试可以用自定义权重做定向验证(例如只开unigram_cosine验证 cosine 本身正确)。 - 可解释:
explain_similarity()会返回每个特征的取值,调试和写博客时能直接看到"分数是怎么来的"。
样例的实测明细:
{
"length_a": 19,
"length_b": 17,
"features": {
"unigram_cosine": 0.9074,
"bigram_cosine": 0.6149,
"trigram_cosine": 0.3757,
"trigram_dice": 0.3750,
"trigram_jaccard": 0.2308
},
"score": 0.4860
}
即答案文件输出 0.49。
2.4 关键函数流程
flowchart TD
S[compute_similarity text_a text_b] --> N1[normalize text_a]
S --> N2[normalize text_b]
N1 --> E{任一方为空?}
N2 --> E
E -->|是| R0[返回 0.0]
E -->|否| EQ{两者完全相同?}
EQ -->|是| R1[返回 1.0]
EQ -->|否| F[_extract_features]
F --> C1[统计 1/2/3 元片段 Counter]
F --> C2[计算 5 个特征值]
C2 --> W[_weighted_average 加权平均]
W --> CL[clamp 到 0..1]
CL --> OUT[返回重复率]

三、计算模块接口部分的性能改进
3.1 改进前的瓶颈
第一版原型的思路是"统计特征 + 结构特征":除了 n-gram 之外,再调用 difflib.SequenceMatcher 计算最长匹配块比率,用来刻画整体结构。
用 cProfile 分析 2 万字符的输入时,总耗时 2.881 秒,耗时排名如下(节选):
ncalls tottime percall cumtime percall function
1 0.000 0.000 2.881 2.881 similarity.py:compute_similarity
1 0.000 0.000 2.844 2.844 similarity.py:sequence_similarity
1 0.000 0.000 2.837 2.837 difflib.py:597(ratio)
157 2.224 0.014 2.836 0.018 difflib.py:305(find_longest_match)
10473390 0.612 0.000 0.612 0.612 {method 'get' of 'dict' objects}
结论非常明确:99% 的时间花在 difflib.find_longest_match 上。把文本长度提高到 8000 字符、并施加约 2% 的密集改动时,单次调用需要 3.356 秒——已经逼近评测"5 秒内给出答案"的红线,再长一点必然超时。
【在此处插入 cProfile / 性能分析工具的截图】
3.2 改进思路
difflib 的最坏复杂度是 O(n·m),而查重场景的输入长度完全不可控,因此靠调参或加长度上限都无法根治(加了上限又会在阈值附近造成结果跳变)。
最终采取的做法是彻底移除 O(n·m) 的序列比对,只保留统计类特征:
- 删除
difflib结构特征,全部使用 O(n) 的 n-gram 余弦 / Dice / Jaccard; - 等价替换:把原来由结构特征承担的"整体相似"职责,交给更高阶的 n-gram(2 元、3 元)——它们本身就隐含了顺序信息;
- 补齐权重:新增
trigram_jaccard,把原sequence_ratio的权重按比例分摊到 n-gram 特征上,保证权重和仍为 1; - 细节优化:计算余弦时始终遍历"键更少"的那个向量;三元组的
Counter直接转成set复用,避免重复切片。
3.3 第一轮改进的效果
同样用 cProfile 分析 2 万字符输入:
121897 function calls (121883 primitive calls) in 0.032 seconds
ncalls tottime percall cumtime percall function
2 0.000 0.000 0.019 0.009 preprocessor.py(normalize)
2 0.003 0.002 0.015 0.007 {method 'join' of 'str' objects}
1 0.000 0.000 0.013 0.013 similarity.py(_extract_features)
6 0.000 0.000 0.012 0.002 similarity.py(_count_ngrams)
37461 0.005 0.000 0.012 0.000 preprocessor.py(<genexpr>)
6 0.007 0.001 0.007 0.001 preprocessor.py(<listcomp>)
40360 0.005 0.000 0.007 0.000 preprocessor.py(_is_meaningful)
总耗时 2.881 s → 0.032 s,提升约 90 倍,并且复杂度从 O(n·m) 降为 O(n),输入再大也不会"爆炸"。
各规模实测(python tools/benchmark.py):
| 文本规模 | 改进前(含 difflib) | 第一轮改进后 | 速度 |
|---|---|---|---|
| 1 万字符 | 0.781 s | 0.0152 s | 66 万字/秒 |
| 5 万字符 | 0.058 s(该规模已跳过 difflib) | 0.0546 s | 92 万字/秒 |
| 10 万字符 | 0.127 s | 0.1108 s | 90 万字/秒 |
| 50 万字符 | 0.666 s | 0.6073 s | 82 万字/秒 |
与教科书式的 O(n·m) 动态规划基线对比(python tools/benchmark.py --naive,输入 1200 字符):
动态规划基线: 得分 0.9733, 耗时 0.1814 秒
当前实现: 得分 0.9272, 耗时 0.0015 秒
加速比: 117.8x
动态规划基线的耗时随规模平方增长,1200 字符就需要 0.18 秒,外推到 2 万字符是 50 秒以上,完全无法满足评测要求。
3.4 第二轮改进:性能分析是一个循环
第一轮结束后再回看那张排名表,会发现一个值得警惕的现象:热点已经完全落到"预处理 + 切片计数"这一层,而不再是算法本身。
既然可优化空间都集中在这里,就值得再挖一轮。改动前先在同一台机器上重新采集基线(2 万字符):
121897 function calls (121883 primitive calls) in 0.032 seconds
37461 0.005 0.000 0.012 0.000 preprocessor.py(<genexpr>) ← 逐字符过滤
40360 0.005 0.000 0.007 0.000 preprocessor.py(_is_meaningful) ← 4 万次 Python 函数调用
6 0.007 0.001 0.007 0.001 preprocessor.py(<listcomp>) ← n-gram 切片
三处问题与对应改法:
| # | 问题 | 原因 | 改法 |
|---|---|---|---|
| 1 | normalize 占总耗时约 58% |
逐字符调用 str.isalnum(),2 万字符就产生约 4 万次 Python 层函数调用 |
① 整段文本已全是有效字符时直接返回(纯汉字论文很常见);② 否则用正则 [\W_]+ 在 C 层一次性剔除 |
| 2 | _count_ngrams 每个规模都多建一个中间列表 |
Counter(build_ngrams(...)) 先用列表推导式生成 list 再计数 |
一元片段直接 Counter(text);二元/三元用 zip 把错位切片对齐、由 str.join 拼接,让 Counter 直接消费迭代器 |
| 3 | _extract_features 有两次多余的集合拷贝 |
set(trigram_counter) 复制了全部键 |
直接使用 Counter 的键视图参与交并运算 |
正确性怎么保证? 三处改动都是"换实现、不换语义",因此验证也必须针对语义本身:
-
全码位等价性验证:正则
[^\W_]与str.isalnum()是否真的等价,不能只抽几个字符试一下,而是把
全部 1114112 个 Unicode 码位拼成一个字符串,比较两种过滤方式的结果:码位总数 : 1114112 isalnum 保留数: 133022 filter 一致 : True regex 一致 : True -
双实现互验:
_count_ngrams(优化实现)与build_ngrams(照定义写的参考实现)逐规模比对,
并锁定"文本长度小于 n"的边界行为; -
端到端比对:优化前后所有测试的分数逐项一致(样例
0.49、长文0.67、无关文0.00均未变)。
3.5 第二轮改进的效果与当前最大热点
同样 2 万字符,cProfile 总耗时 0.032 s → 0.017 s:
3734 function calls (3706 primitive calls) in 0.017 seconds
ncalls tottime percall cumtime percall function
6 0.011 0.002 0.011 0.002 {built-in method _collections._count_elements}
2 0.004 0.002 0.004 0.002 {built-in method unicodedata.normalize}
2 0.001 0.000 0.001 0.000 {method 'sub' of 're.Pattern' objects}
特别注意函数调用次数:121897 → 3734,减少了 97%。这正是"把 Python 层循环换成 C 层调用"带来的收益。
各规模前后对比(同一台机器立即复测):
| 文本规模 | 第二轮前 | 第二轮后 | 提升 |
|---|---|---|---|
| 1 万字符 | 0.0152 s | 0.0081 s | 1.9× |
| 5 万字符 | 0.0546 s | 0.0410 s | 1.3× |
| 10 万字符 | 0.1108 s | 0.0829 s | 1.3× |
| 20 万字符 | — | 0.1654 s | — |
| 50 万字符 | 0.6073 s | 0.4173 s | 1.5× |
吞吐量从约 82 万字/秒提升到约 120 万字/秒;与动态规划基线的加速比从 117.8× 提升到 139.7×。
当前程序中消耗最大的函数(20 万字符,按 tottime 排序):
| 排名 | 函数 | tottime | 说明 |
|---|---|---|---|
| 1 | _collections._count_elements(C) |
0.120 s | Counter 的计数本体,共 6 次调用 |
| 2 | unicodedata.normalize(C) |
0.043 s | NFKC 全角折叠,2 次调用 |
| 3 | re.Pattern.sub(C) |
0.008 s | 噪声字符剔除,2 次调用 |
结论:前三名全部是 C 层内建函数,Python 层已经没有任何值得一提的开销
(_count_ngrams、multiset_cosine 等函数的 tottime 都已降到 0.001 s 量级)。
要再往下提速只能更换实现语言或引入专用扩展库,对本作业而言收益与代价不成比例,因此性能改进到此结束。

复现方式:
python tools/benchmark.py --svg docs/profile.svg --size 200000。
也可以导出原始数据后用可视化工具出图:python tools/benchmark.py --profile --dump profile.out,
再执行python -m snakeviz profile.out或gprof2dot -f pstats profile.out | dot -Tpng -o profile.png。
四、计算模块部分单元测试展示
4.1 测试组织
测试使用 Python 标准库 unittest 编写,共 75 个测试用例,分成 6 个文件:
| 测试文件 | 用例数 | 测试对象 |
|---|---|---|
test_exceptions.py |
6 | 异常的继承关系与默认提示 |
test_preprocessor.py |
14 | 文本规范化与 n-gram 切分 |
test_similarity.py |
25 | 各度量函数、整体得分、n-gram 计数优化 |
test_text_io.py |
16 | 多编码读取、答案写出、IO 失败 |
test_cli.py |
9 | 参数解析与端到端流程 |
test_main.py |
5 | 以子进程运行 main.py |
4.2 构造测试数据的思路
- 正常输入:使用题目给出的样例文本,以及自己编写的长文与"加了一段"的抄袭版;
- 边界输入:空串、单字符、纯标点、只有空白字符的文件、超长文本;
- 异常输入:文件不存在、路径是目录、GBK 编码文件、无法解码的二进制文件、
open直接抛OSError; - 关系验证:不比对绝对分数,而是断言"更相似的一对得分必然更高",避免把测试写死在对某个算法的偏好上;
- 端到端验证:直接以子进程方式执行
python main.py a.txt b.txt ans.txt,检查退出码与答案文件内容。
4.3 部分测试代码
(1)底层度量函数的正、反、边界样例
class SetMetricTest(unittest.TestCase):
"""集合类相似度指标。"""
def test_dice_identical_sets(self):
self.assertAlmostEqual(dice_coefficient({"a", "b"}, {"a", "b"}), 1.0)
def test_dice_disjoint_sets(self):
self.assertEqual(dice_coefficient({"a"}, {"b"}), 0.0)
def test_jaccard_partial_overlap(self):
self.assertAlmostEqual(jaccard_similarity({"a", "b"}, {"b", "c"}), 1.0 / 3.0)
class CosineTest(unittest.TestCase):
"""多重集余弦相似度。"""
def test_identical_counters(self):
self.assertAlmostEqual(multiset_cosine(Counter("ab"), Counter("ab")), 1.0)
def test_cosine_is_symmetric(self):
left = multiset_cosine(Counter("banana"), Counter("bandana"))
right = multiset_cosine(Counter("bandana"), Counter("banana"))
self.assertAlmostEqual(left, right)
(2)整体得分的单调性验证
def test_more_similar_pair_scores_higher(self):
base = "软件工程的个人项目要求实现一个论文查重程序,需要输入三个文件路径。"
close = "软件工程的个人项目要求实现一个论文查重程序,需要输入三个文件路径!"
far = "今天的晚饭是番茄炒蛋和米饭,饭后我要去操场跑步锻炼身体。"
self.assertGreater(compute_similarity(base, close), compute_similarity(base, far))
(3)端到端测试:真正执行 python main.py
class MainScriptTest(unittest.TestCase):
def _run(self, arguments):
return subprocess.run(
[sys.executable, MAIN_SCRIPT] + list(arguments),
cwd=PROJECT_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
def test_sample_pair_outputs_expected_value(self):
with tempfile.TemporaryDirectory() as temp_dir:
answer = os.path.join(temp_dir, "ans.txt")
result = self._run([
os.path.join(DATA_DIR, "original_example.txt"),
os.path.join(DATA_DIR, "suspect_example.txt"),
answer,
])
self.assertEqual(result.returncode, 0)
with open(answer, encoding="utf-8") as handle:
self.assertEqual(handle.read(), "0.49")
(4)性能回归测试:防止有人把 O(n²) 改回去
class PerformanceRegressionTest(unittest.TestCase):
def test_long_text_finishes_well_under_limit(self):
text = "软件工程论文查重算法性能优化" * 5000 # 约 7 万字符
start = time.perf_counter()
score = compute_similarity(text, text[: len(text) // 2])
elapsed = time.perf_counter() - start
self.assertLess(elapsed, 5.0)
self.assertGreater(score, 0.0)
(5)性能优化后的"双实现互验"
第 3.4 节把 Counter(build_ngrams(...)) 换成了 zip + str.join 的写法。为了保证优化没有偷偷改变结果,
测试里同时保留"照定义写"的参考实现,让两条路径互相验证:
class CountNgramsTest(unittest.TestCase):
"""_count_ngrams 的优化实现必须与直观的参考实现完全等价。"""
def test_matches_reference_implementation(self):
text = "软件工程论文查重算法性能优化"
for size in (1, 2, 3):
with self.subTest(size=size):
self.assertEqual(
_count_ngrams(text, size),
Counter(build_ngrams(text, size)),
)
def test_text_shorter_than_size_yields_empty_counter(self):
with self.subTest(size=3):
self.assertEqual(_count_ngrams("ab", 3), Counter())
self.assertEqual(build_ngrams("ab", 3), [])
(6)锁定"先折叠再过滤"的顺序
casefold() 会把某些字符展开成"基字符 + 组合符号",而组合符号不是有效字符。这条用例把顺序钉死,
避免以后有人为了性能把两步调换顺序而悄悄改变结果:
def test_combining_mark_from_casefold_is_removed(self):
# 'İ'.casefold() 会展开成 'i' + U+0307(组合符号),而组合符号不是有效字符。
# 这条用例锁定了"先 casefold 再过滤"的顺序:顺序反了结果就会变成 'i̇'。
self.assertEqual(normalize("İ"), "i")
4.4 测试结果与覆盖率
$ python -m unittest discover -s tests -t .
----------------------------------------------------------------------
Ran 75 tests in 3.412s
OK
覆盖率参数(branch = True、排除 tests/ 与 tools/)已固化在 .coveragerc 中,
所以统计覆盖率只需要两条命令:
$ python -m coverage run -m unittest discover -s tests -t .
$ python -m coverage report -m
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------------------
dupcheck\__init__.py 5 0 0 0 100%
dupcheck\cli.py 41 0 2 0 100%
dupcheck\exceptions.py 13 0 0 0 100%
dupcheck\preprocessor.py 18 0 8 0 100%
dupcheck\similarity.py 70 0 26 0 100%
dupcheck\text_io.py 44 0 16 0 100%
dupcheck\utils.py 4 0 0 0 100%
main.py 2 0 0 0 100%
----------------------------------------------------------------------
TOTAL 197 0 52 0 100%
语句覆盖率 100%,分支覆盖率 100%(52 个分支全部走到,BrPart 为 0),无遗漏行。
生成带逐行高亮的 HTML 报告便于截图:
$ python -m coverage html
$ start htmlcov\index.html

五、计算模块部分异常处理说明
程序定义了 4 种自定义异常,全部继承自 DuplicateCheckError,调用方只要捕获这个基类就能兜住所有已知错误,不会出现未捕获异常导致的异常退出。
5.1 InvalidArgumentError(命令行参数个数错误)
- 设计目标:评测机固定传 3 个参数,一旦参数个数不对,说明调用方式错了,必须立刻给出明确提示,而不是继续跑下去算出一个无意义的分数。
- 处理策略:向
stderr打印用法说明,返回退出码 2(与argparse的约定一致)。 - 对应测试:
def test_wrong_argument_count_returns_usage_error(self):
self.assertEqual(main([]), EXIT_USAGE_ERROR)
- 错误场景:
python main.py a.txt b.txt(少传了答案文件)。
5.2 FileAccessError(无法访问文件)
- 设计目标:把"文件不存在 / 路径是目录 / 磁盘读取失败 / 答案文件不可写"统一成一种可预期的错误,避免把
OSError、IsADirectoryError等底层异常泄漏给用户。 - 处理策略:向
stderr打印具体路径,返回退出码 1。 - 对应测试:
def test_missing_file_raises_file_access_error(self):
with self.assertRaises(FileAccessError):
read_text(os.path.join(DATA_DIR, "not_exists.txt"))
def test_directory_path_raises_file_access_error(self):
with self.assertRaises(FileAccessError):
read_text(DATA_DIR)
- 错误场景:把目录当作输入文件传入;
open()因权限或磁盘错误而失败(用mock模拟OSError验证)。
5.3 EncodingDetectionError(无法识别编码)
- 设计目标:论文文件可能来自 Word、记事本、网盘,编码不一定是 UTF-8。程序按
UTF-8 → UTF-8-SIG → GB18030 → BIG5 → UTF-16的顺序尝试解码;如果全部失败,说明文件根本不是文本,此时必须明确报错而不是返回乱码结果。 - 处理策略:
FileAccessError的子类,向stderr打印路径,返回退出码 1。 - 对应测试:
def test_undecodable_file_raises_encoding_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = os.path.join(temp_dir, "binary.txt")
with open(path, "wb") as handle:
handle.write(b"\xff\xfe\x00\x01\xff")
with self.assertRaises(EncodingDetectionError):
read_text(path, encodings=("utf-8",))
- 错误场景:输入的是一个二进制文件(例如误传了
.docx而不是.txt)。
5.4 EmptyDocumentError(文档内容为空)
- 设计目标:空文件是合法输入——"空文档与任何文档的重复率是 0"是一个有意义的结论,不应该让整个程序失败。因此它被单独定义成一个异常,由上层
cli捕获并降级处理。 - 处理策略:打印警告,把该文档按空串参与计算,最终写出
0.00,退出码 0。 - 对应测试:
def test_empty_document_is_downgraded_to_zero(self):
with tempfile.TemporaryDirectory() as temp_dir:
empty = os.path.join(temp_dir, "empty.txt")
open(empty, "w", encoding="utf-8").close()
answer = os.path.join(temp_dir, "ans.txt")
code = main([empty, SUSPECT, answer])
self.assertEqual(code, EXIT_SUCCESS)
self.assertEqual(_read_answer(answer), "0.00")
- 错误场景:抄袭版论文内容为空,或整篇只有换行和空格。
5.5 异常处理的边界:不越权访问文件
程序全程只读写三个路径:两个输入文件 + 一个答案文件。它不联网、不读取环境变量、不执行任何系统命令,也不存在 system("shutdown") 之类的危险调用。
六、实际耗时汇总
见第一节 PSP 表格的"实际耗时"列,本次开发实际总耗时 150 分钟,其中开发占了大头(约 30 分钟)。

浙公网安备 33010602011771号