我原本只是想帮小舅“下点视频”。最终却搞得很狼狈,还做了很多无用功。所以事后做了比较详细的复盘。
这篇文章复盘:我怎么一步步把一个小需求做成灾难,又怎么把它收敛成一个最短链路的方案,并把它抽象成一套“变与不变”的决策方法。
前言:我只是想下点视频
场景很简单:
一份 Excel,里面有 300+ 条百度网盘分享文本(URL、提取码、描述混在一起),目标是把这些视频变成“方便查看/播放/分享”的形式。
当时我脑子里默认了两个前提:
- Excel 里的字段虽然乱,但“总能下载下来”(最多偶尔失败几条,手工补一下就行)
- 先全部下载到本地再说,后续怎么给别人看,到时候再决定
后面你会看到:这两个前提几乎把我推向了所有狼狈的分支。
回头看需求:我到底真正要什么?
回到当时的场景。拿到需求之后,我想,无非是三步
- 手动下载excel里的sheet为 CSV
- 用 nushell/python/jq 把 CSV 转 JSON (当时没想着要直接写入sqlite,用DB来做数据收敛),这里涉及到洗数据(因为它提供的excel里的这个字段格式很乱(比如说 有的 Baidu URL 并非 https 开头,有的是URL本身带着
?pwd有些则不带,需要自己获取,又或者是没添该字段)) - 用 BaiduPCS-Go 把这些视频下载到本地
可以看到
我做了两个错误决策:
- 1、忽视了这种excel本身(大概率)有很多脏数据。
- 2、我下意识想到的就是直接把视频下载到本地(现在来说,一开始就想茬了),并且也没想明白这些数据到了本地之后要怎么处理
注意以上两个问题分先后
我先是在没意识到
仍然记得那天晚上,一共370个视频,就只差最后10来个下载不到本地。但是因为我没有记录所有record是否已下载、错误状态、具体错误原因 导致还要重新根据已下载视频,找到所有未下载用户,并且手动统计每个用户的具体错误原因。并且写成文档。非常狼狈。
之后,在我决定把视频全部下载到本地之后,就不可避免出现了新问题。100多个GB的数据,是直接在本地做内网穿透,让他人公网访问?还是再传到网盘上?
走到这步,无论如何都是很尴尬的选择。所以最终在没有选择的情况下,被迫只能传到U盘里,闪送给我小舅。
归根到底都是没有想清楚前面两个问题
写在最前面的“需求澄清”,是这次复盘里最关键的一段。
我真正要的不是“下载工具”,而是一个小型系统,满足:
- 可交付:别人能稳定地在线看/下载(不是我家宽带顶着)
- 可追踪:每条记录是否成功、失败原因是什么、下一步怎么处理,都能自动汇总
- 可恢复:中断了能续跑,重复跑不会把事情搞得更乱(幂等)
- 链路尽量短:每加一跳,就多一类失败场景和维护成本
一句话:这是一个 “内容搬运 + 数据清洗 + 交付分发” 的流水线,而不是“脚本下载”。
第一版:本地下载 + Cloudflare Tunnel 的灾难
我第一版的直觉架构如下:
Excel(导出CSV)
↓
本地脚本:jq解析
↓
BaiduPCS-Go 下载到本地磁盘
↓
Cloudflare Tunnel:把我本地目录穿透出去
↓
别人用 URL 直接访问/在线播放
为啥该方案不可用?
- (1)带宽与体验不匹配 视频在线播放这件事,对“稳定上行带宽 + 断点/Range + 持续连接质量”要求很高。家宽 + 内网穿透这种组合,本质就是“把你家的上行当 CDN 用”,不现实。
- (2)我把“失败处理”当成了“偶发情况” 最致命的不是带宽,而是:我没有任何“状态表”。
那天的真实场景是:370 个视频就差最后十来个下载不下来。 如果我有一张表记录每条 record:
- 原始字段(raw)
- 解析结果(url / pwd / 文件名 hint)
- 下载状态(pending / success / failed)
- 失败原因(missing_url / missing_code / invalid_share / rate_limited / …)
- 重试次数、最后错误信息
那我只需要跑一次汇总查询,就知道“哪十几个为什么失败、怎么补”。
但我没有。于是我只能:
- 先从本地目录反推“哪些下载过”
- 再跟 Excel 做 diff
- 再手工统计失败原因
- 再写成文档
狼狈的核心原因:系统没有“记忆”。
第二版:企业级“VPS 流式传输 + CDN”架构(正确,但过重)
把U盘邮出去之后,复盘了一下这个事
此时我的思考(仍然)是:
- 我为啥会犯这些错误呢?
- 怎么才能在做设计时,一开始就考虑到大部分这类问题?
- 我发现这种需求,很难用上面这个“架构设计框架”来套用,是吗?还是说要做一些调整?
简而言之就是,为什么会这么狼狈?我做错了什么?以后怎么改进?
当时的想法是
Review一下前两天给我小舅写那个【把excel里的百度网盘URL视频,处理为方便查看的形式】。
我来简单说说,我一开始没想明白到底是否要发到CDN上,所以我只处理了下载到本地。之后做了 cloudflare tunnel 的内网穿透,来让别人通过URL直接访问我本地下载的这些视频。但是发现这种视频资源需要高带宽支撑,并不适合用内网穿透实现。
再则,我搞错了一个问题,也就是需要对所有这些视频是否下载到本地,以及具体错误,通过sqlite来标记状态。我以为从视频URL的获得到下载都不会有任何问题。结果耗费大量时间,都是为了处理这部分没有下载到的视频的URL以及相关处理了。
我主要犯了这两个错误。
想错了第一个问题,导致我犯了两个错误,如果早知道最终要存到CDN上,那我肯定就直接在VPS上(而非本地)跑了。如果早知道要到CDN上,那我肯定也会选择流式上传(也就是一边下载到本地,一边上传到CDN),不会等到后来已经全部下载到本地了,想传到CDN上,时间又不够。
所以我把这个需求,套入我的“系统架构设计”的workflow
发现以下问题:
错误 1:一开始没决定“最终落点”
我没有先回答这句:
这些 100+GB 的视频,最终应该落在哪里,才能让别人稳定访问?
如果最终一定要落到“公网可稳定访问”的地方(网盘 / 对象存储 / CDN),那么:
- 在本地全量下载再搬运,几乎一定会浪费时间
- 最优做法大概率是 “边转存/边校验” 或直接 “转存到云端落点”
错误 2:我默认“数据是干净的”
Excel 的分享文本字段天然是脏数据温床:
- URL 不一定有
https:// - 有的
?pwd=在 URL 里,有的在文本里,有的缺失 - 有的链接失效/权限不足/次数限制
- 有的描述里夹杂标点、空格、全角符号
我把“脏数据处理”当成了边角料,于是它在最后阶段以“不可收拾的人工统计”形式反噬我。
具体优化
我第二版一度走向“企业级正确”:
Excel → 清洗入库 → VPS 下载/转码/切片 → 对象存储 → CDN → 观看
↑
状态与重试
这套架构有两个价值:
- 把“交付能力”放到系统层面:VPS 与对象存储/CDN 才是为流量而生
- 逼我正视“状态机”:必须有任务表、失败分类、重试策略
但它的问题也很明显:链路长、决策多、每一段都要考虑失败与成本。
对这次“帮小舅看视频”的场景来说,属于“正确但过度”。
最终版:BaiduPCS-Go transfer + OpenList 的最小可行方案
做完上一版的架构设计之后,我用nushell手搓了一套(只是为了证明我能实现)。
但是又转头想到,上面这种架构是应该在正经项目里用golang来写,用shell来写,多少有点杯水车薪了,并不适用,也毫无意义。所以我又把这套方案删掉了。
正好那两天,在重新搭建 OpenList,就想到是否可以通过 OpenList 来简化操作(此时我的思路仍然是“一定是需要下载到本地的”)
胡渊鸣:Meshy AI,太极,MIT,清华姚班,图形学,物理仿真模拟,开源,商业化,勇气 ,智慧 | WhynotTV Podcast #2_哔哩哔哩_bilibili
[Meshy 奖学金] 当 CEO 重读 PhD:论智慧与勇气 里的 “敢于多花时间找正确的问题” 这部分
之后查了一下,OpenList 只提供 Aria/qBittorrent 之类的离线下载
仍然需要把视频下载到本地,然后再离线上传到。并不支持直接从 百度云“转储”到 Cloudflare R2 之类的CDN
那我为啥不直接把这些视频转存到我的百度云呢?
查了一下 BaiduPCS,果然支持该操作
这样子,链路就很短了,整个需求的核心功能也从“下载”转移到了“维护数据一致性”
链路越长,可能出问题的地方就越多,需要 handle 的失败场景就越多。
这次我最大的收获不是“又学了一个工具”,而是学会了:先砍链路,再谈优化。
最终我落地的最小闭环是:
Excel(.xlsx)
↓
导出 CSV / 或直接读 xlsx
↓
DuckDB 清洗 + 生成“任务表”(含 url/pwd/状态/错误)
↓
BaiduPCS-Go:把分享链接内容 transfer 到“我的网盘目录”
↓
OpenList:挂载我的网盘,提供统一浏览/播放/分享
↓
验证 + 导出失败清单(按原因分组)
最终需要按照最佳实践来实操一下上面说的最终的解决方案
核心流程4步:
- 1、【导入数据】
- 2、【洗数据、在table里补充相关字段】核心流程,需要验证数据是否与excel里的数据源保持一致
- 3、【transfer资源】通过 BaiduPCS-Go 把所有资源 transfer 到我的网盘里 # 需要
- 4、【验证】查看OpenList上mount的网盘,是否已经已经可以看到资源。 # 以及导出所有百度网盘URL有问题的用户,并根据 failed status 分组展示
Excel -> CSV -> DuckDB 清洗 -> Transfer到网盘
下面是我建议的“以后再遇到类似需求就照着做”的流程。
1)导入数据 (csvkit)
毫无疑问两个方案:
- 方案 A:Excel 手动导出 CSV(最稳)
- 方案 B:直接用 csvkit 读 xlsx(少一步手工)
goland内置的那个Database服务,不支持直接把excel导入到DB里。只支持导入CSV文件。
先后尝试了 nocodb, airtable 之类的,均不支持该操作
所以最终选择使用 csvkit 导入数据,使用 duckdb 来洗数据
2)洗数据、补字段 (DuckDB, 核心步骤)
你要产出一张“任务表”,至少包含:
- id # 稳定主键(可用行号+hash)
- raw_text # 原始分享文本
- baidu_url / baidu_code
- status:pending/success/failed
- error_type:missing_url / missing_code / invalid_share / …
- error_detail # 最后一次错误详情
- attempts # 重试计数
同时把“可疑项”提前打标出来:缺 URL、缺提取码、URL 里是否自带 pwd= 等。
为什么我最后选 DuckDB?
我试过 GoLand 内置 Database、nocodb 等等,最大的问题不是“能不能导入”,而是:
- 清洗表达力:字符串拆分、列表爆炸、规则化字段,非常吃 SQL 函数与数据类型
- 可复现:我希望清洗逻辑能像代码一样被版本化、可回放
- 可导出/可审计:能导出 DB / CSV / 报告,能验证和 diff
DuckDB 的优势在于:很多在 SQLite 里需要“手搓递归 CTE”的操作,在 DuckDB 里可以用内置函数一两行做完(比如 string_split + generate_series + list/array 操作)。
3)transfer 资源(直接转存,不需要下载)
用 BaiduPCS-Go 的 share transfer,把分享链接内容转存进“我的网盘目录”。
关键点:任务必须幂等
- 成功的不再重复跑
- 失败的按策略重试(指数退避/限制次数)
- 所有结果回写状态表
4)验证,并生成报告
- 用 OpenList 看 mount 的目录是否齐全、抽样播放
- 从状态表导出失败记录,并按
error_type分组输出(这一步是“体面”的来源)
总结:从这次迭代里,我学到的几个架构设计习惯
- 脏数据
- 状态机
- 链路长度
习惯 1:先定“交付形态”,再写“处理脚本”
先回答这三个问题:
- 谁来用?怎么用?(在线看 vs 下载、单人 vs 多人)
- 在什么网络环境下用?(家宽上行、跨境、移动端)
- “成功”的验收标准是什么?(能播、能分享、能追踪)
习惯 2:把脏数据当成默认值
凡是“Excel + 人工录入 + 文本字段”,都默认:
- 缺字段、错格式、混用标点、大小写、全角半角
- 需要规则化、需要验证、需要报告
习惯 3:任何批处理都要有“状态机”
只要任务规模到了“几十条以上”,你就需要:
- 状态表(任务、attempt、最后错误)
- 可恢复(断点续跑)
- 可审计(失败原因可汇总)
习惯 4:把链路砍到最短
每增加一跳,你就需要新增一套:
- 失败分类
- 监控与告警
- 成本与权限
- 数据一致性策略
在个人/小团队的小需求里,短链路几乎永远赢。
方法论:这类问题里的「变」与「不变」怎么落地?
这部分聚焦“怎么做、怎么选”。
如果现在让我来设计这个实现,我为什么会把以下这些分别作为“变与不变”。
首先,这个需求本身很简单,从 Excel里收集“视频URL”,最终给用户提供一个可以稳定且流畅观看相应视频的 webpage。并且事后统计,我能知道哪些失败以及为什么。
那么这里就有几个疑问:
- Excel里是否有脏数据?
- 对程序来说,数据源是Excel还是CSV?
- 需要下载的文件是什么格式?
- 多大数据量?100GB
- 能保证视频一定能下载成功吗?
- 整个task是否有时间限制?大概多少?
- 事后统计需要哪些维度的数据?
所以可以提取到以下关键字:
- 有脏数据
- 视频
- 断点续传及retry
- 大概半天时间
- 事后统计
针对这几点,所以产生以下需求:
| 需求/约束 | 决策/动作 | 原因/说明 |
|---|---|---|
| 有脏数据 | 洗数据,用 DuckDB 作为解析+存储 | DuckDB 既能做 SQL 清洗也能落地存储,比 jq/python+JSON/CSV 组合更一体 |
| 视频 | 落到 CDN/网盘 | 视频是大流量,必须走带宽/吞吐友好的落点,否则无法流畅播放 |
| 断点续传/Retry | 保留断点与重试能力 | 大文件/网络抖动常见,缺少重试会导致任务雪崩 |
| 容错 | 预建状态机,幂等写入,失败分类落表 | 同一 id 重跑只更新状态,所有失败必须可审计 |
| 半天时间 | 不下载落地,直接 transfer 到网盘/云端 | 100GB 在 10MB/s 需约 2.7h,且 VPS 磁盘不足;链路越短越好,一跳优于两跳 |
| 事后统计 | 默认存在失败模型,按类型落表 | 覆盖脏数据、链接失效、频控、登录过期、磁盘/配额不足、网络抖动等维度 |
其实可以看到以上这些本身就是一些最基本的NFR(非功能需求)
- 带宽/吞吐(视频就是大流量)
- 可恢复性(断点续传、幂等)
- 可观测性(每条记录状态、错误原因、可导出报表)
- 安全与权限(分享链接、访问控制)
- 状态机/账本:对“每条视频记录”必须有状态(pending/downloading/success/failed + reason)
把“不变”做成框架,把“变”当配置
通用 Pipeline(骨架)
import: 读 CSV → 表raw_linksclean: 规则化 → 表tasks(含状态列)execute: 按状态驱动 BaiduPCS-Goshare transferverify: 汇总 success/failed,抽样播放report: 导出失败清单(按error_type分组)
本案的配置项
- 解析器:DuckDB SQL / jq 解析百度分享文本
- 执行器:
BaiduPCS-Go share transfer <link> <dest> - 目标路径:
/apps/your-bucket/<date>/(可自定义) - 重试策略:
failed & attempts < 3→ 指数退避(30s, 2m, 5m)
用决策表收尾
| 问题 | 不变的约束 | 本case选择 | 若条件变化怎么调 |
|---|---|---|---|
| 最终交付落点 | 必须公网可稳定访问 | 个人百度网盘+OpenList | 换成 OSS/CDN 时,只替换执行器与挂载入口 |
| 状态追踪 | 必须有幂等任务表 | DuckDB `tasks` | 可换 SQLite/Postgres,但表结构不变 |
| 链路长度 | 越短越好 | 直接转存,无本地下/传 | 若平台不支持转存,才退回“下+传” |
| 失败与恢复 | 默认存在,需可重试可审计 | attempts + error_type | 加报警/速率限制也放在同一状态机上 |
一句话:
先把“不变”固化成可回放的流水线(表结构 + 状态机 + 幂等脚本),再只替换“变”的部分(解析规则、执行器、落点)。
这样同类需求来一百次,决策动作都收敛在配置层。
那么,最后一个问题
按照这套“变与不变”的决策逻辑,为啥之前的两个方案不好呢?
第一版:本地下载 + Cloudflare Tunnel
- 违背“不变”的交付形态:公网稳定访问要求高上行和长连接,本地+穿透先天不匹配;链路长(下→穿透→播)却没有状态机。
- 忽视“输入不可信”:没清洗、没任务表,失败无法追踪/补偿,幂等缺失,导致“反推未下完的十几条”这种狼狈。
- “变”没被配置化:带宽、失败分类、重试策略都悬空,工具换成再多(jq、PCS)也救不了。
第二版:VPS 流式下载 + CDN(正确但过重)
- 不符合“链路越短越好”的不变原则:落点/交付已定(公网可访问),却引入下载、转码、切片、对象存储、CDN多跳,增加失败面和成本。
- 需求规模小、一次性,却套了“企业级”变体,决策与约束不匹配(时间窗口、资源规模、维护成本)。
- 状态机虽补上,但“变”没有约束到最简实现:核心任务只是“转存+可浏览”,最短链路的转存方案即可满足,不需要整套流式分发栈。
回头看这次折腾,真正让我狼狈的不是 Cloudflare Tunnel,也不是最后那十几个死活下不下来的链接,而是我一开始把“实现”当成了“决策”。
下次再遇到类似需求,我会先把“不变”的东西冻结下来:用户到底要什么、成功标准是什么、链路必须具备哪些NFR(带宽、可恢复、可观测)、以及每条记录的状态机。只要这些不变的骨架立住了,工具怎么换都只是配置:DuckDB 也好、SQLite 也好,AList 也好、CDN 也好,都只是把同一条链路跑通的不同实现。
站在云端思考不变,再回到地面选择变化——这才是我从“小工具”里练到的系统设计。
结语:小工具也是系统设计的练习场
我这次狼狈的根因,并非技术能力,而是没在一开始站在“交付”视角看问题:
- 我以为自己在写脚本
- 但我实际上在做一个小系统
好消息是:这种“狼狈”很划算。
它让你在一个低成本场景里,把架构设计里最核心的肌肉练出来:
- 需求澄清
- 变与不变
- 状态机与可恢复
- 缩短链路
- 以交付反推设计
下次再遇到类似问题,我希望自己能更体面一点:
先把系统的“骨架”(不变)搭好,再用配置去适配具体的“皮肤”(变化)。
附录 A:解析百度分享文本(jq)
补充这部分
解析百度分享文本
# Parse a Baidu share text into structured fields.
# Usage: jq -n --arg raw "<string>" -f parse_baidu_link.jq
def trim: gsub("^\\s+"; "") | gsub("\\s+$"; "");
def collapse_ws:
gsub("\\r"; "")
| gsub("\\n"; " ")
| gsub("\\t"; " ")
| gsub(" +"; " ");
def first_match($text; $pattern):
([ $text | match($pattern) ] | first?);
def capture_named($match; $name):
if $match == null then null
else
($match.captures
| map(select(.name == $name))
| if length > 0 then .[0].string else null end)
end;
def sanitize_url($url):
if ($url // "") == "" then null
else
($url
| trim
| gsub("[,,。;;::、…\\)\\]】》〉>』」\"']+$"; "")
| if startswith("https://") then .
elif startswith("http://") then ("https://" + (ltrimstr("http://")))
elif startswith("http") then .
else "https://" + .
end)
end;
def capture_extension($text):
($text // "") as $body
| ($body | first_match(.; "(?i)(\\.)(?<ext>(mp4|mov|mkv|m4v|avi|wmv|flv|mp3|wav|flac|m4a))")) as $hit
| (capture_named($hit; "ext")) as $ext
| if $ext == null then null else ($ext | ascii_downcase) end;
def parse_share($text):
($text // "") as $raw
| ($raw | collapse_ws | trim) as $normalized
| (first_match($normalized; "(?<url>https?://pan\\.baidu\\.com/[\\w\\-_/\\?=]+)")) as $url_match
| (if $url_match == null then first_match($normalized; "(?<url>pan\\.baidu\\.com/[\\w\\-_/\\?=]+)") else $url_match end) as $url_match2
| (capture_named($url_match2; "url")) as $raw_url
| (sanitize_url($raw_url)) as $clean_url
| (first_match($normalized; "(?i)(提取码|密码)[:: ]*(?<code>[A-Za-z0-9]{4,8})")) as $code_match
| (capture_named($code_match; "code")) as $text_code
| (if $clean_url == null then null else first_match($clean_url; "(?i)[?&]pwd[=::]?(?<code>[A-Za-z0-9]{4,8})") end) as $url_code_match
| (capture_named($url_code_match; "code")) as $url_code
| (if ($text_code // "") != "" then ($text_code // "") else ($url_code // "") end) as $final_code
| (capture_extension($raw)) as $ext_hint
| { raw: $raw,
normalized: $normalized,
has_baidu_url: (($clean_url // "") != ""),
baidu_url: ($clean_url // ""),
baidu_code: ($final_code // ""),
url_contains_pwd: (if $clean_url == null then false else (($clean_url | test("(?i)\\?pwd"))) end),
extension_hint: ($ext_hint // ""),
issues: [
(if ($clean_url // "") == "" then "missing_url" else empty end),
(if ($final_code // "") == "" then "missing_code" else empty end)
] | map(select(. != null))
};
parse_share($ARGS.named.raw)
zzz
def char_code($c): [$c] | implode;
def csvsplit:
(. | explode) as $chars |
reduce range(0; $chars|length) as $idx (
{field:"", fields:[], in_quotes:false, skip:false};
if .skip then
.skip = false
else
($chars[$idx]) as $c |
if .in_quotes then
if $c == 34 then
if ($idx + 1 < ($chars|length)) and ($chars[$idx + 1] == 34) then
.field += char_code(34) | .skip = true
else
.in_quotes = false
end
else
.field += char_code($c)
end
else
if $c == 34 then
.in_quotes = true
elif $c == 44 then
.fields += [.field] | .field = ""
elif $c == 13 then
.
else
.field += char_code($c)
end
end
end
)
| .fields + [.field];
def parse_csv:
(split("\n")
| map(gsub("\r$"; ""))
| map(select(length > 0))) as $rows |
if ($rows|length) == 0 then []
else
($rows[0] | csvsplit) as $headers |
$rows[1:]
| map(
(. | csvsplit) as $cols |
reduce range(0; $headers|length) as $i (
{}; . + { ($headers[$i]): ($cols[$i] // "") }
)
)
end;
def process_csv($data):
$data | parse_csv
| map({
"Contact": .["电话"],
"Group": "独奏组",
"Name": .["姓名"],
"WhetherAdvance": should_advance(.["姓名"])
});
# 从输入读取数据并处理
. as $input | process_csv($input)
附录 B:SQLite vs DuckDB 的“拆名单 + 排序 + 输出”SQL(节选)
补充这部分
比较最终导出Excel的sql
- sqlite sql
- duckdb sql
WITH RECURSIVE
awards_sort AS (
SELECT '金奖' AS award, 1 AS sort_order UNION ALL
SELECT '银奖', 2 UNION ALL
SELECT '铜奖', 3 UNION ALL
SELECT '未来之星奖', 4 UNION ALL
SELECT '希望之星', 5 UNION ALL
SELECT '未获奖', 6
),
detail_rows AS (
SELECT
id AS detail_id,
group_name,
CASE
WHEN COALESCE(absent_final, 0) = 1 THEN '希望之星'
WHEN award IN ('金奖','银奖','铜奖','未来之星奖') THEN award
ELSE '未获奖'
END AS award_label,
name,
COALESCE(teacher, '') AS teacher,
num,
CASE WHEN group_name = '弦乐团' THEN name ELSE '' END AS ensemble_name,
member_list,
COALESCE(contact, '') AS contact,
COALESCE(address, '') AS address
FROM v_2025_final
),
group_totals AS (
SELECT group_name, award_label, COUNT(*) AS people_count, SUM(num) AS total_num
FROM detail_rows
GROUP BY group_name, award_label
),
member_split AS (
SELECT
dr.detail_id,
dr.group_name,
dr.award_label,
1 AS member_order,
CASE WHEN instr(dr.member_list,'、') > 0
THEN substr(dr.member_list,1,instr(dr.member_list,'、')-1)
ELSE dr.member_list END AS member_detail,
CASE WHEN instr(dr.member_list,'、') > 0
THEN substr(dr.member_list,instr(dr.member_list,'、')+1)
ELSE NULL END AS remainder
FROM detail_rows dr
WHERE dr.member_list IS NOT NULL AND dr.member_list <> ''
UNION ALL
SELECT
detail_id,
group_name,
award_label,
member_order + 1,
CASE WHEN instr(remainder,'、') > 0
THEN substr(remainder,1,instr(remainder,'、')-1)
ELSE remainder END,
CASE WHEN instr(remainder,'、') > 0
THEN substr(remainder,instr(remainder,'、')+1)
ELSE NULL END
FROM member_split
WHERE remainder IS NOT NULL AND remainder <> ''
),
member_lines AS (
SELECT detail_id, group_name, award_label, member_order, member_detail
FROM member_split
)
SELECT
group_name,
award_label AS award,
row_type,
people_count,
total_num,
name,
teacher,
num,
ensemble_name,
member_detail,
contact,
address
FROM (
-- 小计行
SELECT
g.group_name,
g.award_label,
'小计' AS row_type,
g.people_count,
g.total_num,
'' AS name,
'' AS teacher,
NULL AS num,
'' AS ensemble_name,
NULL AS member_detail,
'' AS contact,
'' AS address,
s.sort_order,
-1 AS detail_seq,
0 AS row_order,
0 AS member_order
FROM group_totals g
JOIN awards_sort s ON s.award = g.award_label
UNION ALL
-- 参赛明细
SELECT
d.group_name,
d.award_label,
'明细' AS row_type,
NULL,
NULL,
d.name,
d.teacher,
d.num,
d.ensemble_name,
NULL,
d.contact,
d.address,
s.sort_order,
d.detail_id,
1 AS row_order,
0 AS member_order
FROM detail_rows d
JOIN awards_sort s ON s.award = d.award_label
UNION ALL
-- 名单
SELECT
m.group_name,
m.award_label,
'名单' AS row_type,
NULL,
NULL,
'' AS name,
'' AS teacher,
NULL AS num,
'' AS ensemble_name,
m.member_detail,
'' AS contact,
'' AS address,
s.sort_order,
m.detail_id,
2 AS row_order,
m.member_order
FROM member_lines m
JOIN awards_sort s ON s.award = m.award_label
)
ORDER BY
group_name,
sort_order,
detail_seq,
row_order,
member_order;
WITH awards_sort(award, sort_order) AS (
VALUES ('金奖',1),('银奖',2),('铜奖',3),('未来之星奖',4),('希望之星',5),('未获奖',6)
),
detail_rows AS (
SELECT
id AS detail_id,
group_name,
CASE
WHEN COALESCE(absent_final,0)=1 THEN '希望之星'
WHEN award IN ('金奖','银奖','铜奖','未来之星奖') THEN award
ELSE '未获奖'
END AS award_label,
name,
COALESCE(teacher,'') AS teacher,
num,
CASE WHEN group_name='弦乐团' THEN name ELSE '' END AS ensemble_name,
member_list,
COALESCE(contact,'') AS contact,
COALESCE(address,'') AS address
FROM v_2025_final
),
group_totals AS (
SELECT group_name, award_label, COUNT(*) AS people_count, SUM(num) AS total_num
FROM detail_rows
GROUP BY 1,2
),
member_lines AS (
SELECT
d.detail_id,
d.group_name,
d.award_label,
gs.generate_series AS member_order,
TRIM(list_element(s.ss, gs.generate_series)) AS member_detail
FROM detail_rows d
CROSS JOIN LATERAL (SELECT string_split(member_list, '、') AS ss) s
CROSS JOIN LATERAL generate_series(1, array_length(s.ss)) gs
WHERE member_list IS NOT NULL AND member_list <> ''
)
SELECT
group_name,
award_label AS award,
row_type,
people_count,
total_num,
name,
teacher,
num,
ensemble_name,
member_detail,
contact,
address
FROM (
SELECT g.group_name, g.award_label, '小计' AS row_type,
g.people_count, g.total_num,
'' AS name, '' AS teacher, NULL AS num, '' AS ensemble_name,
NULL AS member_detail, '' AS contact, '' AS address,
s.sort_order, -1 AS detail_seq, 0 AS row_order, 0 AS member_order
FROM group_totals g JOIN awards_sort s ON s.award = g.award_label
UNION ALL
SELECT d.group_name, d.award_label, '明细',
NULL, NULL,
d.name, d.teacher, d.num, d.ensemble_name,
NULL, d.contact, d.address,
s.sort_order, d.detail_id, 1, 0
FROM detail_rows d JOIN awards_sort s ON s.award = d.award_label
UNION ALL
SELECT m.group_name, m.award_label, '名单',
NULL, NULL,
'' AS name, '' AS teacher, NULL, '' AS ensemble_name,
m.member_detail, '' AS contact, '' AS address,
s.sort_order, m.detail_id, 2, m.member_order
FROM member_lines m JOIN awards_sort s ON s.award = m.award_label
)
ORDER BY group_name, sort_order, detail_seq, row_order, member_order;
可以看到在这个场景下,sqlite的sql长达159行,而duckdb则只有82行
这也是为啥上面使用 DuckDB 而非 sqlite 的原因之一
SQLite 需要递归 CTE 去“手搓字符串拆分”;
DuckDB 可以用 string_split + generate_series + list/array 操作快速拉平。
• 原因主要是 DuckDB 的内置函数更强,能把你在 SQLite 里“手搓”的步骤用一两行代替:
- 划奖项排序:用 VALUES 一次列出 6 行,不必 UNION ALL 6 次。
- 拆名单:DuckDB 有 string_split() 直接把顿号分隔的名单转成列表,再用 generate_series +
list_element 拉平成多行;SQLite 里没有等价函数,只能写一个递归 CTE member_split 手动
instr/ substr 循环。
- 列转行顺序:generate_series(1, array_length(ss)) 自带递增序号,省掉递归里的 member_order +
1 逻辑。
- 其他小处:少量 COALESCE/CASE 与原样一致,但 DuckDB 允许在同一 CTE 里直接 JOIN LATERAL 取列
表长度和元素,避免额外子查询。
本质上,行数减少是因为 DuckDB 提供了“字符串拆分 + 序号 + 爆炸”的原生操作,取代了 SQLite 需要
的递归拆分模板。
附录 C:DuckDB 导出 sqlite
INSTALL sqlite;
LOAD sqlite;
EXPORT DATABASE 'path_to_your_output_file.sqlite' (FORMAT SQLITE);