
前阵子我收到一个项目压缩包解压以后文件夹里空空荡荡只有一层目录名写着“哥本哈士奇(aspnetx)排”。原以为对方传错了文件翻遍了聊天记录也没找到任何补充说明。这种“零信息需求”我在项目里遇过好几次通常只能靠标题里的蛛丝马迹把需求反推出来。这一次的标题倒是给了我不少线索哥本哈士奇一看就是项目代号aspnetx 像是某个开发者的社区 ID“排”字十有八九指排行榜。合在一起就是一个叫“哥本哈士奇”的内容要做成排行榜项目。最后我用两个晚上把一个空壳标题做成了可以跑起来的热词趣味排行小网站名字就叫“哥本哈士奇排”。这篇文章会把拆解标题、选技术栈、设计排行算法、过内容审核到最后踩坑修复的完整链路都交代清楚适合正在做外包、独立开发或者单纯想学“从一行项目名反推产品需求”的开发者参考。1. 把没头没尾的标题翻译成需求文档1.1 “哥本哈士奇”的性格怎么定第一个要解决的是这个“哥本哈士奇”到底是个什么东西我的第一版必须在几天内交付所以没有时间考证出处只能从语感上定性。这个词读起来带着一种“假装高级、又确实离谱”的喜感——哥本哈士奇像是穿着一件性冷淡风外套的哈士奇正经中透着傻气。对产品来说这个气质直接决定了页面配色、文案风格和能接受的内容类型。既然栏目性格是“北欧冷淡混搭二哈式傻乐”那内容就不能做得太严肃、太说教、太像数据大屏。排行榜上出现的应该是一些带梗的短句、流行语、生活观察比如地铁早高峰、周一早上、深夜刷手机这些日常场景的搞笑化表达。于是我把需求草案写成一个以搞怪短句和流行语为素材以“投票 时效”为机制做每日热榜的轻社区。标题里的“排”就是核心动作——每天给这些热词算热度、排出先后。需求一句话用户打开页面三十秒看完当天前五名顺手投一票然后关掉页面。不做资讯流不做强制注册不搞复杂互动。1.2 从“排”字倒推出来的MVP范围标题越短越要克制。空标题不等于要空想而是要把能确定的最小动作找出来。“排”字是唯一的动词优先级自然最高。我给 MVP 做了一张功能优先级表优先级功能说明P0一句话投稿用户提交候选热词P0每日排行榜按热度公式排 Top20P0投票轻量化互动不做账号体系P0基础审核关键词过滤 人工抽检P1每日榜单快照保留历史榜供回溯P1页面署名底部标注 aspnetxP2用户体系登录、关注、评论全部后置第一版只做 P0P1 看情况加P2 一律推后。这里有个很实用的经验需求越含糊越不要急着猜复杂功能。标题里只有“排”这个动作说明用户真正想要的可能只是一个能排序的东西。排序之外的一切都可以是先跑起来再补。我还特别克制了一点没有做单独的后台管理页面。审核功能先用一个极简命令行工具和数据库直查兜底等榜单真的跑出量了再补管理界面。在一行字标题里看到“排行榜”三个字就去设计用户注册、评论、分享裂变那是在给不存在的需求加戏。2. 技术选型为什么我押注ASP.NET Core2.1 从“aspnetx”顺藤摸瓜标题里的“aspnetx”其实给了第二个线索作者十有八九是 ASP.NET 圈子里的人或者希望项目往这个方向靠。我自己的主力栈也是 .NET所以没有纠结语言选择直接用 ASP.NET Core 8 的 Razor Pages 模板开始搭。选 Razor Pages 而不是 MVC、Blazor 或者前后端分离是出于两点考虑。第一这个排行榜页面以服务端渲染为主Razor Pages 学习成本和后期维护成本最低第二后面要加实时推送Razor Pages 和 SignalR 的配合非常自然不用像前后端分离那样单独准备跨域配置。不同的技术方案适合不同的场景我习惯列个表快速过一遍方案适合场景我为什么没选Razor Pages服务端渲染、SEO 友好最终采用MVC接口多、团队分工细项目规模不大没必要Blazor WebAssembly复杂交互、内网工具首屏加载太重内容站不划算前后端分离多端复用接口没有人催 App 端先不做“为什么不用 Python 或 Node”是每次选型都会被问的事。我的答案很简单项目只有标题没有正文意味着团队里大概率只有一个人能长期维护。这时候选自己最熟、生态最稳、单机部署最简单的技术栈比追新语言新框架更重要。2.2 项目骨架、表结构和依赖创建项目的命令如下dotnet new webapp -n CopenhuskyRank cd CopenhuskyRank dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.AspNetCore.SignalR工程名我用的是 CopenhuskyRank英文目录名只为避免中文路径在某些环境下的编码问题中文只出现在页面显示上。数据库先选 SQLite目的是让开发环境零成本启动以后数据量上来了再平滑迁到 PostgreSQL 或者 SQL Server 都行EF Core 在这件事上切换成本很低。表结构刻意从简只做了三张表热词表、投票表、审核日志表。热词表记录内容、提交时间、审核状态投票表记录谁在什么时间对哪条热词投过票审核日志表登记每次投稿命中的过滤规则和处理结果。就这些没有会员表没有操作日志表没有用户偏好表。有人可能会问标题里都带 aspnetx 了怎么不直接上领域事件、GRPC、分布式锁这些高级特性我的经验是小项目首先考虑的是“换个人也能立刻接手”。复杂架构固然好看但只有一行标题的项目通常没有时间让你从架构讲起。3. 排行算法让热词在24小时内“排”得出名堂3.1 热度公式与8小时半衰期排行榜不能简单地按点赞数倒序。如果只看点赞数老词会永远压着新词“每日榜”就名存实亡。我参考了技术社区常见的“热度衰减”做法每条热词都有一个发布时间越新的词权重越高同时保留一定的投票累加。最终使用的打分公式是score likes * exp(-ageHours / halfLife) log1p(likes) 1其中 halfLife 取 8 小时意思是这条热词的热度每过 8 小时就衰减到原来的 36.8% 左右。log1p(likes) 的作用是给点赞数做对数压缩避免单条高票词统治榜单时新词完全失去露脸机会。最后的加 1 保证刚发布、还没有人投过票的冷启动词也有一个很小的基准分不至于在榜上一开始就垫底。写成 C# 方法大概是这个样子public record HotWord(int Id, string Content, int Likes, DateTime CreatedAt); static double Score(HotWord w, DateTime now) { var ageHours (now - w.CreatedAt).TotalHours; var decay Math.Exp(-ageHours / 8.0); return w.Likes * decay Math.Log(w.Likes 1) 1; }这组参数有一个好处用户能直观理解。一个短句发布后 8 小时左右是最热窗口之后慢慢冷却第二天榜单自动换血。如果半衰期设得太短榜单会频繁跳变第一眼看过去没有稳定性设得太长每日榜又成了旧词榜。实测下来 8 到 12 小时对“每日趣味榜”这个场景比较合适。3.2 为什么不套用ELO有人会建议用 ELO 做两两 PK 再出榜但 ELO 的前提是对象能两两配对并产生胜负结果。热词榜单更像信息流排序每天有几十个新词涌入不能两两打循环赛。强行套 ELO会遇到“今天的第三名和昨天第二名谁更强”这种永远说不清的争议。直接按公式算分反而更透明每一条热词的分数都能解释清楚用户看得懂运营也方便排查为什么某条内容会爬到第一名。排行榜本质上是给用户看的结果算法越透明信任成本越低。3.3 防刷投票的第一道防线只要排行榜是公开的刷票就一定会来。第一版我并没有上很重的风控只做了一组简单但有效的防线用 IP UserAgent 合并成一个指纹同一个指纹 60 秒内只允许投一票同一个指纹对同一条热词一天最多投 10 票高频投票触发临时冷却前端给出友好提示而不是直接封禁计数器更新必须原子化不能在服务端先读后写。单机部署时这个节流器用 ConcurrentDictionary 就能实现public class VoteThrottle { private readonly ConcurrentDictionarystring, long _firstSeen new(); public bool Allow(string key, DateTime now, int windowSeconds 60) { var nowTicks now.Ticks; if (_firstSeen.TryGetValue(key, out var first)) { if (nowTicks - first TimeSpan.FromSeconds(windowSeconds).Ticks) return false; } _firstSeen[key] nowTicks; return true; } }这套方案在单实例部署时完全够用。以后如果横向扩容把 throttle 换成 Redis 的 SETEX 或者 Lua 脚本校验即可业务侧不需要改。4. 内容安全开放投稿不等于乱投稿4.1 投稿框只留一句话只要开放投稿就必须想清楚用户会往库里扔什么。我的做法很保守投稿框只允许填一句话长度限制在 1 到 30 个字之间。数据库里 content 字段类型设置成适当长度的 VARCHAR直接卡死上限。这个设计有两个好处。第一审核成本极低运营扫一眼就能判断是好是坏第二存储和展示干净不会出现用户贴一大段广告文撑爆整个卡片。做内容社区投稿路径越短单条内容的质量边界越容易控制。4.2 四层过滤机制内容安全不能只靠一道闸门。我把整个流程拆成了四层层级处理逻辑动作第一层长度、空值、重复检查不满足直接拒绝第二层正则和关键词规则匹配高危直接拦截第三层待审列表人工抽检每日抽 20%第四层用户举报累计三次自动下线第二层的关键词库不能只建一个死列表。我会把规则分成“高危精确命中”和“低危模糊命中”两类高危规则直接拦截低危规则放进展后抽检池。比如明显的外链推广、导流广告、联系方式这类内容划进高危擦了边但看不清意图的内容划进低危。这些规则的代码我放在一个独立的过滤服务里没有混进排行榜业务逻辑。这样以后换云厂商的文本安全接口、扩充关键词库、调整阈值都只需要改这一处不会动到排名代码。审核是业务逻辑的旁路通道规则如果写死在控制器里后面会非常痛苦。4.3 自动审核和人工兜底的分工做过内容运营的人都清楚自动审核永远不可能做到百分百准确。卡得太死正常用户抱怨投稿被吞放得太松热榜分分钟变成垃圾场。我给自己定的目标很简单高危内容自动拦截的准确率争取做到很高剩下的 1% 靠人工兜底。上线之后遇到“看起来无害、实际是引流”的短句人工处理完就把它加进规则库让过滤规则跟着实际案例慢慢长。技术手段永远在和一个动态对手博弈留好人工通道和举报入口比追求一次完美的自动审核更现实。5. 实时排行榜的交互与空状态设计5.1 用SignalR把“热”推给用户排行榜如果只有手动刷新页面才会变化“热”的感觉就少了一半。我用 SignalR 做了一个轻量推送通道每当有人投票导致前 20 名顺序发生明显变化时后端广播一次最新 Top20前端收到后只更新榜单区域不刷新整个页面。public class RankHub : Hub { public async Task NotifyRankChanged(object topList) { await Clients.All.SendAsync(OnRankChanged, topList); } }这里有一个重要的教训不要在每个投票请求里都触发推送。高频操作下客户端会在一秒钟内收到十几条更新页面疯狂跳动用户根本没法看。实际做法是后端每 30 秒检查一次或者当前 5 名变化超过一半时再推一次前端再做一次节流合并把推送频率降到用户可以接受的程度。5.2 空页面反而决定第一印象还有一个特别容易被忽略的点空状态。我第一次打开页面时数据库里什么都没有排行榜区域一片空白。这时候普通用户只会觉得程序坏了而不是觉得“这站还没内容”。我给空状态加了两样东西一段引导文案和一组种子短句。种子短句是我自己写的一些无害搞笑内容比如“地铁早高峰以挤兑散”、“周一早八点人类驯服早起失败”这类。这些种子数据永远不参与真实排名只显示在榜单最下方的“开胃菜”区域。这个设计上线后变成了意外亮点。用户一进来看到几条有调性的搞笑短句马上就能get到这个站的风格投稿意愿也会高不少。以后再有人递给我一个只有名字没有正文的项目我都会第一时间把空页面方案写进需求清单——第一印象都在这里。6. 上线前踩到的三个坑6.1 每日榜单被时区偷走了一天第一个坑是最常见的时区问题。开发时我直接用 DateTime.Now本地时间怎么测怎么准。可部署到服务器以后环境是 UTC每天凌晨 0 点截榜时服务器按 UTC 计算实际等于北京时间的早上 8 点“每日榜”经常把清晨的新词归到前一天。修复方式也简单所有时间统一用 DateTime.UtcNow 存储需要展示榜单边界时再用 TimeZoneInfo 转换到东八区进行比较。凡是系统里带“每日”两个字的时间字段一律 UTC 存储只在展示层转换这个原则建议直接写进项目规范。6.2 并发投票悄悄丢计数第二个坑出现在一次小规模并发测试。我用脚本同时发出 50 次投票请求结果数据库里只多了 40 票。根因是老代码先 SELECT 再 UPDATE多个并发请求读到同一个旧值后写覆盖前写计数自然少了。计数器累加绝对不能“先读后写”。要么用 Redis 的 INCR 做原子递增要么直接在数据库事务里更新。我当时没接 Redis用 SQLite 也能安全处理BEGIN; UPDATE HotWords SET Likes Likes 1 WHERE Id id; INSERT INTO Votes (HotWordId, VoterKey, CreatedAt) VALUES (id, key, now); COMMIT;核心原则很简单投票数是高频写操作必须让“累加”这个动作在数据库或者缓存层完成而不是在应用层拼接业务代码去模拟。6.3 爬虫用重复短句刷爆榜单第三个坑是爬虫。上线半天后榜单里混进大量重复短句每条都有几十票。查日志后发现同一个 UserAgent 在规律性地调投票接口特征非常明显。处理上做了三层第一层识别明显非浏览器的 UserAgent第二层对同一个 UA 加 10 分钟频率限制第三层加日志标记重复内容遇到后缀相同的文本直接批量下线。爬到的问题本质上和刷票一样核心是留好日志、留好下线通道。我把用户举报按钮做得比删除按钮更醒目也是这个原因——让用户帮你发现异常内容永远比运维盯着日志快得多。这个项目从只有一个文件名的压缩包到能正常跑起来的排行榜前后用了不到两天。最大的体会是标题里的信息量往往比想象中更大“哥本哈士奇”定义产品气质“aspnetx”暗示技术谱系“排”圈定核心动作。把这三个词翻译成需求比拿到一份写满了但没人执行的文档有用得多。以后再遇到只有一行字项目名的需求别急着抱怨按“气质—技术—动作”的顺序拆一遍大概率能拆出一张能直接落地的需求清单。