[vscs] 从百度网盘到“架构设计”:一次狼狈的下载任务复盘
Nov 25, 2025 - ⧖ 21 min我原本只是想帮小舅“下点视频”。最终却搞得很狼狈,还做了很多无用功。所以事后做了比较详细的复盘。
这篇文章复盘:我怎么一步步把一个小需求做成灾难,又怎么把它收敛成一个最短链路的方案,并把它抽象成一套“变与不变”的决策方法。
前言:我只是想下点视频
场景很简单:
一份 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盘里,闪送给我小舅。
归根到底都是没有想清楚前面两个问题
Tip
写在最前面的“需求澄清”,是这次复盘里最关键的一段。
我真正要的不是“下载工具”,而是一个小型系统,满足:
- 可交付:别人能稳定地在线看/下载(不是我家宽带顶着)
- 可追踪:每条记录是否成功、失败原因是什么、下一步怎么处理,都能自动汇总
- 可恢复:中断了能续跑,重复跑不会把事情搞得更乱(幂等)
- 链路尽量短:每加一跳,就多一类失败场景和维护成本
一句话:这是一个 “内容搬运 + 数据清洗 + 交付分发” 的流水线,而不是“脚本下载”。
第一版:本地下载 + Cloudflare Tunnel 的灾难
我第一版的直觉架构如下:
Excel(导出CSV)
↓
本地脚本:jq解析
↓
BaiduPCS-Go 下载到本地磁盘
↓
Cloudflare Tunnel:把我本地目录穿透出去
↓
别人用 URL 直接访问/在线播放
Caution
为啥该方案不可用?
- (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盘邮出去之后,复盘了一下这个事
此时我的思考(仍然)是:
Tip
- 我为啥会犯这些错误呢?
- 怎么才能在做设计时,一开始就考虑到大部分这类问题?
- 我发现这种需求,很难用上面这个“架构设计框架”来套用,是吗?还是说要做一些调整?
简而言之就是,为什么会这么狼狈?我做错了什么?以后怎么改进?
当时的想法是
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 的最小可行方案
Tip
做完上一版的架构设计之后,我用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:挂载我的网盘,提供统一浏览/播放/分享
↓
验证 + 导出失败清单(按原因分组)
Tip
最终需要按照最佳实践来实操一下上面说的最终的解决方案
核心流程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 之类的,均不支持该操作
Tip
所以最终选择使用 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分组输出(这一步是“体面”的来源)
总结:从这次迭代里,我学到的几个架构设计习惯
Tip
- 脏数据
- 状态机
- 链路长度
习惯 1:先定“交付形态”,再写“处理脚本”
先回答这三个问题:
- 谁来用?怎么用?(在线看 vs 下载、单人 vs 多人)
- 在什么网络环境下用?(家宽上行、跨境、移动端)
- “成功”的验收标准是什么?(能播、能分享、能追踪)
习惯 2:把脏数据当成默认值
凡是“Excel + 人工录入 + 文本字段”,都默认:
- 缺字段、错格式、混用标点、大小写、全角半角
- 需要规则化、需要验证、需要报告
习惯 3:任何批处理都要有“状态机”
只要任务规模到了“几十条以上”,你就需要:
- 状态表(任务、attempt、最后错误)
- 可恢复(断点续跑)
- 可审计(失败原因可汇总)
习惯 4:把链路砍到最短
每增加一跳,你就需要新增一套:
- 失败分类
- 监控与告警
- 成本与权限
- 数据一致性策略
在个人/小团队的小需求里,短链路几乎永远赢。
方法论:这类问题里的「变」与「不变」怎么落地?
这部分聚焦“怎么做、怎么选”。
如果现在让我来设计这个实现,我为什么会把以下这些分别作为“变与不变”。
首先,这个需求本身很简单,从 Excel里收集“视频URL”,最终给用户提供一个可以稳定且流畅观看相应视频的 webpage。并且事后统计,我能知道哪些失败以及为什么。
那么这里就有几个疑问:
- Excel里是否有脏数据?
- 对程序来说,数据源是Excel还是CSV?
- 需要下载的文件是什么格式?
- 多大数据量?100GB
- 能保证视频一定能下载成功吗?
- 整个task是否有时间限制?大概多少?
- 事后统计需要哪些维度的数据?
所以可以提取到以下关键字:
- 有脏数据
- 视频
- 断点续传及retry
- 大概半天时间
- 事后统计
针对这几点,所以产生以下需求:
<TableRender columns={[ { key: "item", label: "需求/约束" }, { key: "decision", label: "决策/动作" }, { key: "reason", label: "原因/说明" }, ]} rows={[ { item: "有脏数据", decision: "洗数据,用 DuckDB 作为解析+存储", reason: "DuckDB 既能做 SQL 清洗也能落地存储,比 jq/python+JSON/CSV 组合更一体", }, { item: "视频", decision: "落到 CDN/网盘", reason: "视频是大流量,必须走带宽/吞吐友好的落点,否则无法流畅播放", }, { item: "断点续传/Retry", decision: "保留断点与重试能力", reason: "大文件/网络抖动常见,缺少重试会导致任务雪崩", }, { item: "容错", decision: "预建状态机,幂等写入,失败分类落表", reason: "同一 id 重跑只更新状态,所有失败必须可审计", }, { item: "半天时间", decision: "不下载落地,直接 transfer 到网盘/云端", reason: "100GB 在 10MB/s 需约 2.7h,且 VPS 磁盘不足;链路越短越好,一跳优于两跳", }, { item: "事后统计", decision: "默认存在失败模型,按类型落表", reason: "覆盖脏数据、链接失效、频控、登录过期、磁盘/配额不足、网络抖动等维度", }, ]} />
Tip
其实可以看到以上这些本身就是一些最基本的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)
用决策表收尾
<TableRender
columns={[
{ key: "question", label: "问题" },
{ key: "constraint", label: "不变的约束" },
{ key: "choice", label: "本case选择" },
{ key: "fallback", label: "若条件变化怎么调" },
]}
rows={[
{
question: "最终交付落点",
constraint: "必须公网可稳定访问",
choice: "个人百度网盘+OpenList",
fallback: "换成 OSS/CDN 时,只替换执行器与挂载入口",
},
{
question: "状态追踪",
constraint: "必须有幂等任务表",
choice: "DuckDB tasks",
fallback: "可换 SQLite/Postgres,但表结构不变",
},
{
question: "链路长度",
constraint: "越短越好",
choice: "直接转存,无本地下/传",
fallback: "若平台不支持转存,才退回“下+传”",
},
{
question: "失败与恢复",
constraint: "默认存在,需可重试可审计",
choice: "attempts + error_type",
fallback: "加报警/速率限制也放在同一状态机上",
},
]}
/>
Tip
一句话:
先把“不变”固化成可回放的流水线(表结构 + 状态机 + 幂等脚本),再只替换“变”的部分(解析规则、执行器、落点)。
这样同类需求来一百次,决策动作都收敛在配置层。
那么,最后一个问题
按照这套“变与不变”的决策逻辑,为啥之前的两个方案不好呢?
第一版:本地下载 + Cloudflare Tunnel
- 违背“不变”的交付形态:公网稳定访问要求高上行和长连接,本地+穿透先天不匹配;链路长(下→穿透→播)却没有状态机。
- 忽视“输入不可信”:没清洗、没任务表,失败无法追踪/补偿,幂等缺失,导致“反推未下完的十几条”这种狼狈。
- “变”没被配置化:带宽、失败分类、重试策略都悬空,工具换成再多(jq、PCS)也救不了。
第二版:VPS 流式下载 + CDN(正确但过重)
- 不符合“链路越短越好”的不变原则:落点/交付已定(公网可访问),却引入下载、转码、切片、对象存储、CDN多跳,增加失败面和成本。
- 需求规模小、一次性,却套了“企业级”变体,决策与约束不匹配(时间窗口、资源规模、维护成本)。
- 状态机虽补上,但“变”没有约束到最简实现:核心任务只是“转存+可浏览”,最短链路的转存方案即可满足,不需要整套流式分发栈。
回头看这次折腾,真正让我狼狈的不是 Cloudflare Tunnel,也不是最后那十几个死活下不下来的链接,而是我一开始把“实现”当成了“决策”。
下次再遇到类似需求,我会先把“不变”的东西冻结下来:用户到底要什么、成功标准是什么、链路必须具备哪些NFR(带宽、可恢复、可观测)、以及每条记录的状态机。只要这些不变的骨架立住了,工具怎么换都只是配置:DuckDB 也好、SQLite 也好,AList 也好、CDN 也好,都只是把同一条链路跑通的不同实现。
站在云端思考不变,再回到地面选择变化——这才是我从“小工具”里练到的系统设计。
结语:小工具也是系统设计的练习场
我这次狼狈的根因,并非技术能力,而是没在一开始站在“交付”视角看问题:
- 我以为自己在写脚本
- 但我实际上在做一个小系统
好消息是:这种“狼狈”很划算。
它让你在一个低成本场景里,把架构设计里最核心的肌肉练出来:
- 需求澄清
- 变与不变
- 状态机与可恢复
- 缩短链路
- 以交付反推设计
下次再遇到类似问题,我希望自己能更体面一点:
先把系统的“骨架”(不变)搭好,再用配置去适配具体的“皮肤”(变化)。
附录 A:解析百度分享文本(jq)
Caution
补充这部分
解析百度分享文本
# 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(节选)
Caution
补充这部分
比较最终导出Excel的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);