[{"data":1,"prerenderedAt":2083},["ShallowReactive",2],{"blog-nuxt-dotnet-jwt-auth":3,"blog-adjacent-nuxt-dotnet-jwt-auth":534,"blog-related-nuxt-dotnet-jwt-auth":641},{"id":4,"title":5,"body":6,"category":519,"cover":520,"date":521,"description":522,"draft":523,"extension":524,"meta":525,"navigation":237,"path":526,"readingTime":131,"seo":527,"stem":528,"tags":529,"__hash__":533},"blogs\u002Fblogs\u002Fnuxt-dotnet-jwt-auth.md","Nuxt 3 与 .NET 8 的 JWT 认证实践",{"type":7,"value":8,"toc":512},"minimark",[9,14,18,21,44,48,51,153,157,168,472,476,483,486,502,505,508],[10,11,13],"h2",{"id":12},"为什么选-jwt","为什么选 JWT",[15,16,17],"p",{},"在前后端分离的项目里，JWT 是最常见的认证方案之一。它把用户身份编码到一个自包含的 token 里，服务器无需维护 session 状态，天然适合分布式部署。",[15,19,20],{},"但 JWT 不是银弹。它有三个必须正视的问题：",[22,23,24,32,38],"ol",{},[25,26,27,31],"li",{},[28,29,30],"strong",{},"token 一旦签发无法吊销","，除非维护 blacklist",[25,33,34,37],{},[28,35,36],{},"payload 可被解码","（base64 不是加密），不能放敏感信息",[25,39,40,43],{},[28,41,42],{},"过期时间权衡","：短则频繁登录，长则安全风险",[10,45,47],{"id":46},"后端net-8-配置","后端：.NET 8 配置",[15,49,50],{},"在 Program.cs 里加入 JWT 认证：",[52,53,58],"pre",{"className":54,"code":55,"language":56,"meta":57,"style":57},"language-csharp shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n    .AddJwtBearer(options =>\n    {\n        options.TokenValidationParameters = new TokenValidationParameters\n        {\n            ValidateIssuer = true,\n            ValidateAudience = true,\n            ValidateLifetime = true,\n            ValidateIssuerSigningKey = true,\n            ValidIssuer = builder.Configuration[\"Jwt:Issuer\"],\n            ValidAudience = builder.Configuration[\"Jwt:Audience\"],\n            IssuerSigningKey = new SymmetricSecurityKey(\n                Encoding.UTF8.GetBytes(builder.Configuration[\"Jwt:Key\"]))\n        };\n    });\n","csharp","",[59,60,61,69,75,81,87,93,99,105,111,117,123,129,135,141,147],"code",{"__ignoreMap":57},[62,63,66],"span",{"class":64,"line":65},"line",1,[62,67,68],{},"builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n",[62,70,72],{"class":64,"line":71},2,[62,73,74],{},"    .AddJwtBearer(options =>\n",[62,76,78],{"class":64,"line":77},3,[62,79,80],{},"    {\n",[62,82,84],{"class":64,"line":83},4,[62,85,86],{},"        options.TokenValidationParameters = new TokenValidationParameters\n",[62,88,90],{"class":64,"line":89},5,[62,91,92],{},"        {\n",[62,94,96],{"class":64,"line":95},6,[62,97,98],{},"            ValidateIssuer = true,\n",[62,100,102],{"class":64,"line":101},7,[62,103,104],{},"            ValidateAudience = true,\n",[62,106,108],{"class":64,"line":107},8,[62,109,110],{},"            ValidateLifetime = true,\n",[62,112,114],{"class":64,"line":113},9,[62,115,116],{},"            ValidateIssuerSigningKey = true,\n",[62,118,120],{"class":64,"line":119},10,[62,121,122],{},"            ValidIssuer = builder.Configuration[\"Jwt:Issuer\"],\n",[62,124,126],{"class":64,"line":125},11,[62,127,128],{},"            ValidAudience = builder.Configuration[\"Jwt:Audience\"],\n",[62,130,132],{"class":64,"line":131},12,[62,133,134],{},"            IssuerSigningKey = new SymmetricSecurityKey(\n",[62,136,138],{"class":64,"line":137},13,[62,139,140],{},"                Encoding.UTF8.GetBytes(builder.Configuration[\"Jwt:Key\"]))\n",[62,142,144],{"class":64,"line":143},14,[62,145,146],{},"        };\n",[62,148,150],{"class":64,"line":149},15,[62,151,152],{},"    });\n",[10,154,156],{"id":155},"前端nuxt-3-拦截器","前端：Nuxt 3 拦截器",[15,158,159,160,163,164,167],{},"在 Nuxt 里用 ",[59,161,162],{},"useFetch"," 的 ",[59,165,166],{},"onRequest"," 钩子统一注入 token：",[52,169,173],{"className":170,"code":171,"language":172,"meta":57,"style":57},"language-typescript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","export const useApi = () => {\n  const token = useCookie('auth-token')\n\n  return $fetch.create({\n    baseURL: useRuntimeConfig().public.apiBase,\n    onRequest({ options }) {\n      if (token.value) {\n        options.headers = {\n          ...options.headers,\n          Authorization: `Bearer ${token.value}`\n        }\n      }\n    },\n    onResponseError({ response }) {\n      if (response.status === 401) {\n        navigateTo('\u002Flogin')\n      }\n    }\n  })\n}\n","typescript",[59,174,175,202,233,239,258,285,302,323,337,351,376,381,386,391,405,430,447,452,458,466],{"__ignoreMap":57},[62,176,177,181,185,189,193,196,199],{"class":64,"line":65},[62,178,180],{"class":179},"s7zQu","export",[62,182,184],{"class":183},"spNyl"," const",[62,186,188],{"class":187},"sTEyZ"," useApi ",[62,190,192],{"class":191},"sMK4o","=",[62,194,195],{"class":191}," ()",[62,197,198],{"class":183}," =>",[62,200,201],{"class":191}," {\n",[62,203,204,207,210,213,217,221,224,228,230],{"class":64,"line":71},[62,205,206],{"class":183},"  const",[62,208,209],{"class":187}," token",[62,211,212],{"class":191}," =",[62,214,216],{"class":215},"s2Zo4"," useCookie",[62,218,220],{"class":219},"swJcz","(",[62,222,223],{"class":191},"'",[62,225,227],{"class":226},"sfazB","auth-token",[62,229,223],{"class":191},[62,231,232],{"class":219},")\n",[62,234,235],{"class":64,"line":77},[62,236,238],{"emptyLinePlaceholder":237},true,"\n",[62,240,241,244,247,250,253,255],{"class":64,"line":83},[62,242,243],{"class":179},"  return",[62,245,246],{"class":187}," $fetch",[62,248,249],{"class":191},".",[62,251,252],{"class":215},"create",[62,254,220],{"class":219},[62,256,257],{"class":191},"{\n",[62,259,260,263,266,269,272,274,277,279,282],{"class":64,"line":89},[62,261,262],{"class":219},"    baseURL",[62,264,265],{"class":191},":",[62,267,268],{"class":215}," useRuntimeConfig",[62,270,271],{"class":219},"()",[62,273,249],{"class":191},[62,275,276],{"class":187},"public",[62,278,249],{"class":191},[62,280,281],{"class":187},"apiBase",[62,283,284],{"class":191},",\n",[62,286,287,290,293,297,300],{"class":64,"line":95},[62,288,289],{"class":219},"    onRequest",[62,291,292],{"class":191},"({",[62,294,296],{"class":295},"sHdIc"," options",[62,298,299],{"class":191}," })",[62,301,201],{"class":191},[62,303,304,307,310,313,315,318,321],{"class":64,"line":101},[62,305,306],{"class":179},"      if",[62,308,309],{"class":219}," (",[62,311,312],{"class":187},"token",[62,314,249],{"class":191},[62,316,317],{"class":187},"value",[62,319,320],{"class":219},") ",[62,322,257],{"class":191},[62,324,325,328,330,333,335],{"class":64,"line":107},[62,326,327],{"class":187},"        options",[62,329,249],{"class":191},[62,331,332],{"class":187},"headers",[62,334,212],{"class":191},[62,336,201],{"class":191},[62,338,339,342,345,347,349],{"class":64,"line":113},[62,340,341],{"class":191},"          ...",[62,343,344],{"class":187},"options",[62,346,249],{"class":191},[62,348,332],{"class":187},[62,350,284],{"class":191},[62,352,353,356,358,361,364,367,369,371,373],{"class":64,"line":119},[62,354,355],{"class":219},"          Authorization",[62,357,265],{"class":191},[62,359,360],{"class":191}," `",[62,362,363],{"class":226},"Bearer ",[62,365,366],{"class":191},"${",[62,368,312],{"class":187},[62,370,249],{"class":191},[62,372,317],{"class":187},[62,374,375],{"class":191},"}`\n",[62,377,378],{"class":64,"line":125},[62,379,380],{"class":191},"        }\n",[62,382,383],{"class":64,"line":131},[62,384,385],{"class":191},"      }\n",[62,387,388],{"class":64,"line":137},[62,389,390],{"class":191},"    },\n",[62,392,393,396,398,401,403],{"class":64,"line":143},[62,394,395],{"class":219},"    onResponseError",[62,397,292],{"class":191},[62,399,400],{"class":295}," response",[62,402,299],{"class":191},[62,404,201],{"class":191},[62,406,407,409,411,414,416,419,422,426,428],{"class":64,"line":149},[62,408,306],{"class":179},[62,410,309],{"class":219},[62,412,413],{"class":187},"response",[62,415,249],{"class":191},[62,417,418],{"class":187},"status",[62,420,421],{"class":191}," ===",[62,423,425],{"class":424},"sbssI"," 401",[62,427,320],{"class":219},[62,429,257],{"class":191},[62,431,433,436,438,440,443,445],{"class":64,"line":432},16,[62,434,435],{"class":215},"        navigateTo",[62,437,220],{"class":219},[62,439,223],{"class":191},[62,441,442],{"class":226},"\u002Flogin",[62,444,223],{"class":191},[62,446,232],{"class":219},[62,448,450],{"class":64,"line":449},17,[62,451,385],{"class":191},[62,453,455],{"class":64,"line":454},18,[62,456,457],{"class":191},"    }\n",[62,459,461,464],{"class":64,"line":460},19,[62,462,463],{"class":191},"  }",[62,465,232],{"class":219},[62,467,469],{"class":64,"line":468},20,[62,470,471],{"class":191},"}\n",[10,473,475],{"id":474},"刷新-token-的陷阱","刷新 token 的陷阱",[15,477,478,479,482],{},"很多教程只讲 access token，不讲 refresh token。实际生产环境里，",[28,480,481],{},"没有 refresh token 的 JWT 方案是不完整的","。",[15,484,485],{},"关键设计：",[487,488,489,492,499],"ul",{},[25,490,491],{},"access token：15 分钟有效期，放在内存或 cookie",[25,493,494,495,498],{},"refresh token：7 天有效期，",[28,496,497],{},"必须"," HttpOnly cookie，防止 XSS 窃取",[25,500,501],{},"刷新接口：服务端校验 refresh token，签发新的 access token",[10,503,504],{"id":504},"总结",[15,506,507],{},"JWT 认证看似简单，但实际落地有不少细节。希望这篇文章能帮你避开常见的坑。",[509,510,511],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}",{"title":57,"searchDepth":71,"depth":71,"links":513},[514,515,516,517,518],{"id":12,"depth":71,"text":13},{"id":46,"depth":71,"text":47},{"id":155,"depth":71,"text":156},{"id":474,"depth":71,"text":475},{"id":504,"depth":71,"text":504},"tech","\u002Fimages\u002Ftravel\u002Fhome-placeholder-1.jpg","2026-04-10","从前端登录到后端校验，一套可落地的最佳实践，包括刷新 token、跨域 cookie 和前后端错误处理的细节。",false,"md",{},"\u002Fblogs\u002Fnuxt-dotnet-jwt-auth",{"title":5,"description":522},"blogs\u002Fnuxt-dotnet-jwt-auth",[530,531,532],"Nuxt",".NET","Auth","zTBCAyICqoS0EMy1UunGXyWApv3FEjl9Jpg1kjGcs6Y",{"prev":535,"next":587},{"id":536,"title":537,"body":538,"category":519,"cover":576,"date":577,"description":578,"draft":523,"extension":524,"meta":579,"navigation":237,"path":580,"readingTime":107,"seo":581,"stem":582,"tags":583,"__hash__":586},"blogs\u002Fblogs\u002Fmy-post.md","Test Upload",{"type":7,"value":539,"toc":574},[540,545,548,555,558,563,566,568,572],[541,542,544],"h1",{"id":543},"hello","Hello",[15,546,547],{},"This is a diagram:",[15,549,550],{},[551,552],"img",{"alt":553,"src":554},"System Architecture","https:\u002F\u002Fimages.xtop.dev\u002Fblogs\u002F2026\u002F04\u002Fdiagram.jpg",[15,556,557],{},"And here's a screenshot:",[15,559,560],{},[551,561],{"alt":562,"src":554},"Login Screen",[541,564,544],{"id":565},"hello-1",[15,567,547],{},[15,569,570],{},[551,571],{"alt":553,"src":554},[15,573,557],{},{"title":57,"searchDepth":71,"depth":71,"links":575},[],null,"2026-04-20","Testing image upload script.",{},"\u002Fblogs\u002Fmy-post",{"title":537,"description":578},"blogs\u002Fmy-post",[584,585],"test","Kyoto","ehRE3Hc357Sngqa9Tb741BTvx_bCJFJSOhzFSqtdTFs",{"id":588,"title":589,"body":590,"category":630,"cover":631,"date":632,"description":633,"draft":523,"extension":524,"meta":634,"navigation":237,"path":635,"readingTime":107,"seo":636,"stem":637,"tags":638,"__hash__":640},"blogs\u002Fblogs\u002Fkyoto-spring-diary.md","Kyoto in Spring",{"type":7,"value":591,"toc":625},[592,595,599,602,605,609,612,615,619,622],[15,593,594],{},"Spring in Kyoto is everything you've been told it is, and also nothing like it.",[10,596,598],{"id":597},"arashiyama-at-6-am","Arashiyama at 6 AM",[15,600,601],{},"Everyone photographs the bamboo grove. Almost no one does it at dawn. I walked in alone at six in the morning, mist still hanging between the stalks, the only sound my own footsteps on gravel. By nine the crowds would be three-deep on the path.",[15,603,604],{},"If you go, go early. If you can't go early, go in the rain.",[10,606,608],{"id":607},"a-breakfast-ritual","A breakfast ritual",[15,610,611],{},"Every morning I walked ten minutes from the guesthouse to a small cafe called Kissa Madoka. The owner, an old woman who spoke no English, would nod me toward my usual seat by the window. Thick toast, a soft-boiled egg, a small salad, and a pot of pour-over coffee.",[15,613,614],{},"She never asked what I wanted. She remembered from day two.",[10,616,618],{"id":617},"the-quiet-temples","The quiet temples",[15,620,621],{},"Everyone visits Kinkaku-ji and Fushimi Inari. They're worth it — barely. The temples that stayed with me were the ones whose names I never learned.",[15,623,624],{},"Travel, I think, is really about collecting these.",{"title":57,"searchDepth":71,"depth":71,"links":626},[627,628,629],{"id":597,"depth":71,"text":598},{"id":607,"depth":71,"text":608},{"id":617,"depth":71,"text":618},"travel","\u002Fimages\u002Ftravel\u002Fhome-placeholder-3.jpg","2026-04-02","Ten days of temples, noodles, and quiet mornings in old wooden houses. A slower kind of travel.",{},"\u002Fblogs\u002Fkyoto-spring-diary",{"title":589,"description":633},"blogs\u002Fkyoto-spring-diary",[639,585],"Japan","QjUi5_JuDS8CmJs1x1Wyac5KvBLt7PcO9Yh6k4oLlIc",[642,1181,1213],{"id":643,"title":644,"body":645,"category":519,"cover":1168,"date":577,"description":1169,"draft":523,"extension":524,"meta":1170,"navigation":237,"path":1171,"readingTime":576,"seo":1172,"stem":1173,"tags":1174,"__hash__":1180},"blogs\u002Fblogs\u002Fbuilding-xtop-dev.md","Building xtop.dev in Three Days: What AI Got Right and Wrong",{"type":7,"value":646,"toc":1145},[647,650,653,656,660,663,666,677,683,687,690,726,729,735,741,751,755,761,770,776,779,783,786,791,794,801,804,811,822,837,841,848,851,855,858,862,865,873,876,881,885,888,905,908,915,919,922,925,929,932,936,939,942,945,949,952,958,962,1034,1037,1041,1044,1056,1062,1068,1082,1085,1089,1092,1124,1129,1132,1135],[15,648,649],{},"Three days ago, this site was a Dynadot parking page. Today it's a full personal site — six pages, a content system, a working contact form backed by a real .NET API, deployed across Cloudflare Pages and a $5 VPS.",[15,651,652],{},"I built most of it pairing with Claude.",[15,654,655],{},"This isn't another \"AI wrote my code!\" post. It's more honest than that — a build log of what AI actually helped with, what it got wrong, and the decisions I still had to make myself.",[10,657,659],{"id":658},"why-bother-with-a-personal-site","Why bother with a personal site",[15,661,662],{},"I've been freelancing on and off for a few years. Most of my leads came through referrals and a pinned GitHub repo. That worked, but it put a ceiling on who could find me.",[15,664,665],{},"A personal site does three things a GitHub profile can't:",[22,667,668,671,674],{},[25,669,670],{},"It tells a story, not just a resume",[25,672,673],{},"It ranks for long-tail searches (\"nuxt dotnet freelance developer\")",[25,675,676],{},"It's where cold leads can actually reach you",[15,678,679,680,249],{},"The goal wasn't perfection. It was ",[28,681,682],{},"something real, shipped, that represents me",[10,684,686],{"id":685},"the-stack-and-why","The stack, and why",[15,688,689],{},"I picked the stack before talking to the AI. This matters. If you let the AI pick, you get whatever is trendy in its training data.",[487,691,692,698,708,714,720],{},[25,693,694,697],{},[28,695,696],{},"Frontend",": Nuxt 4 + Nuxt UI 4 + Tailwind v4 + TypeScript",[25,699,700,703,704,707],{},[28,701,702],{},"Content",": ",[59,705,706],{},"@nuxt\u002Fcontent"," v3 (Markdown-driven blog, projects, travel)",[25,709,710,713],{},[28,711,712],{},"Backend",": .NET 8 Minimal API (just for the contact form)",[25,715,716,719],{},[28,717,718],{},"Hosting",": Cloudflare Pages (frontend) + $5 VPS (backend)",[25,721,722,725],{},[28,723,724],{},"Images",": Cloudflare R2 with a custom domain",[15,727,728],{},"Three constraints drove these choices:",[15,730,731,734],{},[28,732,733],{},"It should stay cheap."," Total cost is around $5\u002Fmonth. Cloudflare Pages and R2 are effectively free at my scale.",[15,736,737,740],{},[28,738,739],{},"It should show what I can actually do."," Having a real .NET backend on a real VPS — even just for a contact form — is a live demo of my full-stack work. A static-only site can't claim that.",[15,742,743,746,747,750],{},[28,744,745],{},"It should be fun to maintain."," All content lives in Markdown files inside ",[59,748,749],{},"content\u002F",". Writing a blog post is just opening VSCode and typing. No CMS. No database. No admin panel.",[10,752,754],{"id":753},"three-days-roughly","Three days, roughly",[15,756,757,760],{},[28,758,759],{},"Day 1 — Scaffolding and structure."," Six pages wired up with placeholder content. Design tokens locked in: Playfair Display serif, teal palette, lots of whitespace. The shape of the site was visible by evening.",[15,762,763,766,767,769],{},[28,764,765],{},"Day 2 — Content system and backend."," Migrated mock data into ",[59,768,706],{}," across three collections (blogs, projects, travel). Built the .NET 8 contact API with SMTP email delivery and rate limiting. Wired the form to the backend.",[15,771,772,775],{},[28,773,774],{},"Day 3 — Polish and ship."," Error page, SEO meta tags, mobile breakpoints, image pipeline to R2, DNS, SSL, deployment. All the finish-tax work that turns a prototype into a live site.",[15,777,778],{},"No weekend work magic. Just focused days with a collaborator that never slept.",[10,780,782],{"id":781},"where-ai-actually-helped","Where AI actually helped",[15,784,785],{},"Let me be specific, because \"AI helped me build this\" usually means nothing.",[787,788,790],"h3",{"id":789},"scaffolding-that-would-have-taken-a-full-day-took-two-hours","Scaffolding that would have taken a full day took two hours",[15,792,793],{},"I described the six pages I wanted with rough layouts. Claude generated the initial Vue files with proper structure, placeholder content, and consistent styling tokens. I reviewed, caught inconsistencies, and iterated.",[15,795,796,797,800],{},"This is the sweet spot for AI right now — ",[28,798,799],{},"bulk generation of familiar patterns",". It knows what a blog list page looks like. It knows how Nuxt pages are structured. It can write fifteen similar-but-not-identical components in ten minutes.",[15,802,803],{},"Doing this by hand would have been a full day of copy-paste-modify. I'd have been bored. I'd have introduced bugs.",[787,805,807,808,810],{"id":806},"migration-from-mock-data-to-nuxtcontent-was-painless","Migration from mock data to ",[59,809,706],{}," was painless",[15,812,813,814,817,818,821],{},"I started Day 1 with hardcoded arrays of blog posts in a composable. Day 2 morning, I wanted Markdown files instead. I described the migration in two sentences. Claude rewrote the pages to use ",[59,815,816],{},"queryCollection()",", added the right schema to ",[59,819,820],{},"content.config.ts",", and walked me through the directory structure.",[15,823,824,825,828,829,832,833,836],{},"The migration across three collections took about thirty minutes. The nested travel structure (",[59,826,827],{},"content\u002Ftravel\u002Fjapan\u002Fxxx.md",") needed a judgment call — I wanted ",[59,830,831],{},"\u002Ftravel\u002F[slug]"," routing instead of ",[59,834,835],{},"\u002Ftravel\u002F[country]\u002F[slug]",", which forced a different query pattern. I made that call. Claude implemented it.",[787,838,840],{"id":839},"debugging-the-weird-stuff","Debugging the weird stuff",[15,842,843,844,847],{},"On Day 3 I hit a Vue 3 hydration mismatch warning that seemed unrelated to anything I'd just changed. I pasted the stack trace. Claude diagnosed it as an SSR issue with my scroll-reveal composable — CSS was applying ",[59,845,846],{},"opacity: 0"," during hydration, before the IntersectionObserver could kick in, creating a mismatch between the server HTML and the hydrated state.",[15,849,850],{},"The fix was subtle: move the \"hidden state\" from CSS to a JS-applied class, so the server HTML matched what the client first renders. I wouldn't have figured that out alone in under an hour. Claude found it in one exchange.",[10,852,854],{"id":853},"where-ai-didnt-help","Where AI didn't help",[15,856,857],{},"Equally specific, because this matters more.",[787,859,861],{"id":860},"taste","Taste",[15,863,864],{},"Claude can generate a hero section. It cannot tell me whether a serif font for headings is right for this site.",[15,866,867,868,872],{},"That decision — Playfair Display serif, teal palette, lots of whitespace — was mine. The AI can execute a design. It cannot make the design ",[869,870,871],"em",{},"feel"," like me.",[15,874,875],{},"Every choice that touched identity was mine: the color palette, the typography, the copywriting voice, the \"Lost at sea\" tone on the 404 page. When I let Claude write copy, it came back as generic marketing-speak. When I wrote the copy and asked Claude to polish tone, it was better.",[15,877,878],{},[28,879,880],{},"AI is a great executor, a poor art director.",[787,882,884],{"id":883},"deciding-what-not-to-build","Deciding what not to build",[15,886,887],{},"In three days, the discipline to cut matters more than the speed to build. I considered adding:",[487,889,890,893,896,899,902],{},[25,891,892],{},"A newsletter signup",[25,894,895],{},"Comments on blog posts",[25,897,898],{},"An analytics dashboard",[25,900,901],{},"A WebGL-animated hero section",[25,903,904],{},"i18n for English + Chinese",[15,906,907],{},"I built none of them. Not one.",[15,909,910,911,914],{},"Each would have added a day of work and zero client acquisition. Claude would have happily helped me build all five. The discipline to ",[28,912,913],{},"cut"," features is a human skill. AI defaults to \"yes, let's build that\" because it has no stake in my time.",[787,916,918],{"id":917},"product-positioning","Product positioning",[15,920,921],{},"The hardest part of the site isn't the code. It's the Services page — deciding what I offer, what I don't, what pricing tier I show, what process I describe.",[15,923,924],{},"I spent more time on that one page than on any other. Claude helped me draft and iterate, but the actual choices — \"I don't do WordPress sites\", \"I'm fine with three-week projects but not three-month ones\", \"my minimum is $1,000\" — those were me reading my own calendar and bank account.",[10,926,928],{"id":927},"the-parts-nobody-talks-about","The parts nobody talks about",[15,930,931],{},"Two surprises worth flagging if you're about to do something similar:",[787,933,935],{"id":934},"the-finish-tax","The \"finish\" tax",[15,937,938],{},"Day 3 took longer than Day 1 and Day 2 combined. Error pages, SEO meta tags, mobile breakpoints, the contact form rate limiter, the hydration bug, the R2 image upload pipeline, the deployment guide, the DNS configuration, the SSL renewal cron.",[15,940,941],{},"None of this is glamorous. All of it is required.",[15,943,944],{},"If you've built a side project and felt like you were \"almost done\" for the last stretch — yeah, you were. The finish tax is real, and it doesn't care how fast the scaffolding went.",[787,946,948],{"id":947},"the-deployment-rabbit-hole","The deployment rabbit hole",[15,950,951],{},"I initially tried Cloudflare Workers because it sounded fancier. Spent a morning on it before I realized it was the wrong tool — I didn't need edge compute, I needed static hosting. Switched to Cloudflare Pages and was deployed in ten minutes.",[15,953,954,957],{},[28,955,956],{},"Sometimes the answer is \"use the simpler thing.\""," AI tends to suggest more powerful tools than you need. Push back.",[10,959,961],{"id":960},"what-it-cost","What it cost",[963,964,965,978],"table",{},[966,967,968],"thead",{},[969,970,971,975],"tr",{},[972,973,974],"th",{},"Item",[972,976,977],{},"Monthly",[979,980,981,990,998,1006,1014,1022],"tbody",{},[969,982,983,987],{},[984,985,986],"td",{},"Domain (xtop.dev)",[984,988,989],{},"~$0.60 (amortized)",[969,991,992,995],{},[984,993,994],{},"VPS ($5\u002Fmonth, for .NET API)",[984,996,997],{},"$5.00",[969,999,1000,1003],{},[984,1001,1002],{},"Cloudflare Pages",[984,1004,1005],{},"$0 (free tier, unlimited bandwidth)",[969,1007,1008,1011],{},[984,1009,1010],{},"Cloudflare R2 (image storage)",[984,1012,1013],{},"$0 (under 10GB)",[969,1015,1016,1019],{},[984,1017,1018],{},"SMTP (QQ Mail for contact form)",[984,1020,1021],{},"$0",[969,1023,1024,1029],{},[984,1025,1026],{},[28,1027,1028],{},"Total",[984,1030,1031],{},[28,1032,1033],{},"~$5.60\u002Fmonth",[15,1035,1036],{},"First client inquiry pays for 10+ years of hosting.",[10,1038,1040],{"id":1039},"would-i-do-it-again-this-way","Would I do it again this way?",[15,1042,1043],{},"Mostly, yes.",[15,1045,1046,1049,1050,1052,1053,249],{},[28,1047,1048],{},"Kept",": Nuxt + ",[59,1051,706],{}," + Cloudflare Pages. Markdown-driven sites are a joy to maintain. Every new post is one file, one ",[59,1054,1055],{},"git push",[15,1057,1058,1061],{},[28,1059,1060],{},"Kept with caveats",": The .NET backend. Overkill for just a contact form, but it's a live portfolio piece — \"this developer can actually deploy a Linux VPS with Nginx and systemd\" — that a Vercel serverless function can't replicate.",[15,1063,1064,1067],{},[28,1065,1066],{},"Would change",": I'd pick the typography and color palette in the first hour instead of drifting into it. Deciding design late caused rework. On a three-day timeline, two hours of rework is a lot.",[15,1069,1070,1073,1074,1077,1078,1081],{},[28,1071,1072],{},"Surprised me",": How much AI helped with the ",[869,1075,1076],{},"boring"," parts (scaffolding, refactors, deployment scripts) versus the ",[869,1079,1080],{},"interesting"," parts (design, positioning, voice). The boring parts are where AI shines. The interesting parts stay human.",[15,1083,1084],{},"Three days is possible because AI compresses the boring parts. It doesn't compress the interesting ones.",[10,1086,1088],{"id":1087},"if-youre-thinking-of-doing-this","If you're thinking of doing this",[15,1090,1091],{},"My suggestions, in order of importance:",[22,1093,1094,1100,1106,1112,1118],{},[25,1095,1096,1099],{},[28,1097,1098],{},"Pick your stack before you open the AI chat."," Otherwise you'll build a trendy stack instead of one you'll enjoy maintaining.",[25,1101,1102,1105],{},[28,1103,1104],{},"Ship one version end-to-end before you polish."," My first version was ugly. Shipping it unlocked every subsequent decision.",[25,1107,1108,1111],{},[28,1109,1110],{},"Write your own copy."," Let AI edit it.",[25,1113,1114,1117],{},[28,1115,1116],{},"Cut features ruthlessly."," A focused site out-performs a bloated one.",[25,1119,1120,1123],{},[28,1121,1122],{},"Get to the contact form fast."," Everything upstream of the contact form is scaffolding for the contact form.",[15,1125,1126,1127,249],{},"The site took three days. It works. People can reach me. I can write new posts in Markdown and ",[59,1128,1055],{},[15,1130,1131],{},"That's the whole pitch.",[1133,1134],"hr",{},[15,1136,1137],{},[869,1138,1139,1140,249],{},"Thanks for reading. If this resonated and you need a developer who pairs well with AI but still makes the hard calls himself, ",[1141,1142,1144],"a",{"href":1143},"\u002Fservices#contact","my inbox is open",{"title":57,"searchDepth":71,"depth":71,"links":1146},[1147,1148,1149,1150,1156,1161,1165,1166,1167],{"id":658,"depth":71,"text":659},{"id":685,"depth":71,"text":686},{"id":753,"depth":71,"text":754},{"id":781,"depth":71,"text":782,"children":1151},[1152,1153,1155],{"id":789,"depth":77,"text":790},{"id":806,"depth":77,"text":1154},"Migration from mock data to @nuxt\u002Fcontent was painless",{"id":839,"depth":77,"text":840},{"id":853,"depth":71,"text":854,"children":1157},[1158,1159,1160],{"id":860,"depth":77,"text":861},{"id":883,"depth":77,"text":884},{"id":917,"depth":77,"text":918},{"id":927,"depth":71,"text":928,"children":1162},[1163,1164],{"id":934,"depth":77,"text":935},{"id":947,"depth":77,"text":948},{"id":960,"depth":71,"text":961},{"id":1039,"depth":71,"text":1040},{"id":1087,"depth":71,"text":1088},"https:\u002F\u002Fimages.xtop.dev\u002Fblogs\u002F2026\u002F04\u002Fbuilding-xtop-dev.jpg","A build log of shipping my personal developer site in 72 hours with Nuxt 4, .NET 8, and an AI collaborator. What worked, what didn't, and what I'd do differently.",{},"\u002Fblogs\u002Fbuilding-xtop-dev",{"title":644,"description":1169},"blogs\u002Fbuilding-xtop-dev",[1175,1176,1177,1178,1179],"nuxt","dotnet","ai","indie-dev","build-log","KO_zwwpqIxYIBaRJQFb0s1UShrLYr6m8C73Oq0-9yv4",{"id":536,"title":537,"body":1182,"category":519,"cover":576,"date":577,"description":578,"draft":523,"extension":524,"meta":1210,"navigation":237,"path":580,"readingTime":107,"seo":1211,"stem":582,"tags":1212,"__hash__":586},{"type":7,"value":1183,"toc":1208},[1184,1186,1188,1192,1194,1198,1200,1202,1206],[541,1185,544],{"id":543},[15,1187,547],{},[15,1189,1190],{},[551,1191],{"alt":553,"src":554},[15,1193,557],{},[15,1195,1196],{},[551,1197],{"alt":562,"src":554},[541,1199,544],{"id":565},[15,1201,547],{},[15,1203,1204],{},[551,1205],{"alt":553,"src":554},[15,1207,557],{},{"title":57,"searchDepth":71,"depth":71,"links":1209},[],{},{"title":537,"description":578},[584,585],{"id":1214,"title":1215,"body":1216,"category":519,"cover":2065,"date":2066,"description":2067,"draft":523,"extension":524,"meta":2068,"navigation":237,"path":2069,"readingTime":576,"seo":2070,"stem":2071,"tags":2072,"__hash__":2082},"blogs\u002Fblogs\u002Ftravel-dm-platform-retrospective-bilingual.md","A Ten-Year Retrospective: A Nationwide B2B Digital Publishing Platform for Travel DM Magazines",{"type":7,"value":1217,"toc":2039},[1218,1224,1226,1230,1233,1240,1244,1255,1258,1272,1279,1283,1286,1331,1337,1340,1344,1355,1358,1362,1365,1376,1383,1390,1394,1400,1403,1410,1413,1424,1427,1431,1438,1441,1461,1472,1476,1483,1490,1493,1513,1516,1520,1523,1529,1532,1558,1561,1565,1568,1594,1600,1604,1611,1614,1625,1628,1630,1632,1636,1641,1649,1651,1654,1657,1660,1663,1674,1677,1691,1698,1702,1705,1750,1756,1759,1763,1774,1777,1781,1784,1795,1802,1808,1812,1818,1821,1828,1831,1842,1845,1849,1856,1859,1879,1889,1893,1899,1906,1909,1929,1932,1935,1938,1944,1947,1973,1976,1979,1982,2008,2014,2017,2024,2027,2036],[1219,1220,1221],"blockquote",{},[15,1222,1223],{},"Project timeline: 2006 — 2019\nStack: .NET (ASPX) \u002F SQL Server \u002F jQuery \u002F in-house automated sync tooling \u002F distributed file storage",[1133,1225],{},[10,1227,1229],{"id":1228},"preface","Preface",[15,1231,1232],{},"This is a project I worked on for ten years. It was shut down in 2019, partly because of the pandemic and partly because the industry had moved on. Looking back now, it carries every hallmark of that pre-mobile-internet era: ASPX, jQuery, hand-written SQL, file server clusters — none of which would show up on the first page of anyone's tech-selection deck today. But it ran steadily for a decade, and it actually solved a problem that no one else had solved well at the time.",[15,1234,1235,1236,1239],{},"I want to write down the engineering decisions behind it while I still remember the details — not to argue that any of it was sophisticated, but to remember ",[869,1237,1238],{},"why"," we did things the way we did.",[10,1241,1243],{"id":1242},"what-the-project-actually-did","What the project actually did",[15,1245,1246,1247,1250,1251,1254],{},"Before mobile internet really took hold — especially between 2009 and 2013 — ",[28,1248,1249],{},"pricing and itinerary information"," flowed between travel agencies across China primarily through a very traditional channel: ",[28,1252,1253],{},"DM magazines"," (direct-mail advertising magazines). Each province's branch would publish a weekly issue, laying out their products, prices, and departure dates, printing them, and shipping them to peer agencies. Downstream agencies flipped through the magazines, made phone calls, and booked products.",[15,1256,1257],{},"Two pain points were painfully obvious:",[487,1259,1260,1266],{},[25,1261,1262,1265],{},[28,1263,1264],{},"Poor timeliness."," Once a magazine was printed and shipped nationwide, several days had already passed — and prices may have already shifted.",[25,1267,1268,1271],{},[28,1269,1270],{},"Information silos."," A single agency would only subscribe to a handful of magazines. They couldn't see nationwide pricing, and they couldn't compare across regions.",[15,1273,1274,1275,1278],{},"What this platform did was ",[28,1276,1277],{},"digitize the entire magazine, issue by issue, and put it online"," as a B2B platform so that any travel agency in the country could browse the latest prices in real time. Simple in concept. Very much not simple in execution.",[10,1280,1282],{"id":1281},"the-real-challenge-was-volume-and-window","The real challenge was \"volume\" and \"window\"",[15,1284,1285],{},"The business shape of the project made its technical characteristics extreme:",[487,1287,1288,1298,1308,1317],{},[25,1289,1290,1293,1294,1297],{},[28,1291,1292],{},"High page density."," A provincial weekly typically ran ",[28,1295,1296],{},"200–300 pages",", and every page was a print-quality high-resolution sliced image (users needed to zoom in to read the fine print on pricing tables).",[25,1299,1300,1303,1304,1307],{},[28,1301,1302],{},"Synchronized nationwide publishing."," The platform covered ",[28,1305,1306],{},"25 branch offices",", all publishing on the same weekly cadence.",[25,1309,1310,1313,1314,249],{},[28,1311,1312],{},"Image volume per week."," Roughly ",[28,1315,1316],{},"5,000–7,500 high-resolution images",[25,1318,1319,1322,1323,1326,1327,1330],{},[28,1320,1321],{},"Extremely tight publishing window."," Every branch finalized their design on ",[28,1324,1325],{},"Thursday and Friday",". All of it had to be digitized and live by ",[28,1328,1329],{},"Saturday and Sunday",", ready for Monday's business open.",[15,1332,1333,1334],{},"In other words — ",[28,1335,1336],{},"a 48-hour weekend window to ingest a TB-scale pile of images accumulated over the week, with zero room for errors or missing pages.",[15,1338,1339],{},"Under that cadence, \"manual upload\" was a dead end from day one. If we had relied on operations staff clicking through an upload UI, we would have needed dozens of people pulling weekend overtime across 25 branches — and if even one person uploaded the wrong page number, downstream agencies would be looking at mismatched pricing sheets. The cost model didn't work and the error rate didn't work.",[10,1341,1343],{"id":1342},"my-core-solution-an-automated-pipeline","My core solution: an automated pipeline",[15,1345,1346,1347,1350,1351,1354],{},"The technical heart of this project wasn't the frontend reader, and wasn't the database itself — it was ",[28,1348,1349],{},"an automated import and distribution pipeline that I designed and wrote myself",". The goal was singular: ",[28,1352,1353],{},"keep humans away from the servers."," Once the designers finished a page and named and organized the files per convention, the tooling did the rest.",[15,1356,1357],{},"Four key design decisions, broken out below.",[787,1359,1361],{"id":1360},"_1-standardized-directory-to-schema-mapping","1. Standardized directory-to-schema mapping",[15,1363,1364],{},"The earliest version was the most naive one you can imagine — a web page where operations staff clicked \"upload\" one page at a time. It broke weekly.",[15,1366,1367,1368,1371,1372,1375],{},"What we did next was: ",[28,1369,1370],{},"turn the directory structure itself into a protocol."," Operations just had to name page numbers per convention and organize each issue into the prescribed hierarchy (province \u002F issue \u002F page). Once the import tool kicked off, it would ",[28,1373,1374],{},"automatically walk the entire directory tree",", parse out \"this is province X, issue Y, page Z, belonging to section W,\" and write the metadata straight into the database.",[15,1377,1378,1379,1382],{},"There's nothing technically profound about this design. But it converted ",[28,1380,1381],{},"uploading"," from \"manual data entry\" into a \"filesystem convention.\" Once that convention was in place, the operations team's job shifted from \"clicking a mouse\" to \"organizing folders\" — and the latter is scriptable, while the former isn't.",[15,1384,1385,1386,1389],{},"Today we'd call this thinking ",[28,1387,1388],{},"convention over configuration",". I didn't have the vocabulary back then — I just had an intuition that \"conventions should replace input.\" In hindsight, this is the single decision that kept the whole system alive for ten years.",[787,1391,1393],{"id":1392},"_2-renaming-files-by-database-primary-key-a-lesson-in-indexing","2. Renaming files by database primary key: a lesson in indexing",[15,1395,1396,1397],{},"Not long after the first version shipped, we hit a performance problem: ",[28,1398,1399],{},"magazine page loads got progressively slower, and query latency degraded non-linearly as the data grew.",[15,1401,1402],{},"The root cause was a classic lazy design I'd made early on — I was joining on filenames directly (the kind with Chinese characters and issue numbers baked in), which meant every single page load triggered a fuzzy match. Combined with an unoptimized SQL Server indexing strategy at the time, I'd walked myself straight into a performance pit.",[15,1404,1405,1406,1409],{},"When I rewrote it, I made one key change: ",[28,1407,1408],{},"during import, the program renames high-res originals according to the database primary key (PK) and re-establishes the relationship."," The physical filename on disk becomes a plain integer ID.",[15,1411,1412],{},"The payoff was multi-dimensional:",[487,1414,1415,1418,1421],{},[25,1416,1417],{},"Database indexes now ran entirely on integer PKs. Queries stabilized at millisecond latency.",[25,1419,1420],{},"Filenames no longer broke when issue numbers or section names changed.",[25,1422,1423],{},"Cross-server image migrations became trivial — since filenames carried no business semantics, migration scripts were extremely simple.",[15,1425,1426],{},"Today this reads as textbook \"don't let business semantics contaminate your storage layer.\" But around 2010, I only learned it after performance slapped me in the face.",[787,1428,1430],{"id":1429},"_3-distributed-file-storage-and-pipelined-distribution","3. Distributed file storage and pipelined distribution",[15,1432,1433,1434,1437],{},"A single file server was never going to handle 25 provinces writing simultaneously — you didn't need a load test to predict that. So from the start I designed the storage as a ",[28,1435,1436],{},"clustered distribution model",": after the import tool renamed a file, it would route the image to a designated file server per rules, and the database only stored \"which server, which path.\"",[15,1439,1440],{},"A few decisions that held up well:",[487,1442,1443,1449,1455],{},[25,1444,1445,1448],{},[28,1446,1447],{},"The upload logic was a pipeline"," (scan → parse → commit → rename → distribute). Each stage retried independently, and a failure at any step wouldn't roll back the whole batch.",[25,1450,1451,1454],{},[28,1452,1453],{},"Sharded by province."," The 25 provinces' uploads didn't block each other, so they could all run in parallel during the weekend window.",[25,1456,1457,1460],{},[28,1458,1459],{},"Image servers were fully separated from application servers."," The frontend served images from a dedicated domain, so the business IIS instances never got crushed by image traffic.",[15,1462,1463,1464,1467,1468,1471],{},"The end result was this: ",[28,1465,1466],{},"the 25 branches' massive image volumes could be fully ingested and distributed within the 48-hour window",", with cumulative storage exceeding ",[28,1469,1470],{},"1TB",". That number is nothing by today's standards, but for a mid-sized business system in China circa 2010, it was substantial.",[787,1473,1475],{"id":1474},"_4-the-frontend-reader-hd-zoom-drag-in-2010","4. The frontend reader: \"HD zoom + drag\" in 2010",[15,1477,1478,1479,1482],{},"This part was built on ",[28,1480,1481],{},"jQuery"," — yes, that jQuery. React didn't exist yet, and Vue hadn't even been conceived.",[15,1484,1485,1486,1489],{},"The frontend challenge was this: travel pricing sheets are information-dense. Small fonts, tight tables, lots of footnotes. Agency users had to be able to ",[28,1487,1488],{},"freely zoom and pan around a full magazine page"," to actually read the pricing and departure dates for a specific tour.",[15,1491,1492],{},"Key implementation decisions at the time:",[487,1494,1495,1501,1507],{},[25,1496,1497,1500],{},[28,1498,1499],{},"Tiered loading."," Thumbnails shipped first, full-resolution images loaded on demand. We never dumped a multi-megabyte image into the browser unprompted.",[25,1502,1503,1506],{},[28,1504,1505],{},"Zoom and drag implemented via CSS transforms",", not image resampling — which kept things smooth even at high zoom levels.",[25,1508,1509,1512],{},[28,1510,1511],{},"Gesture hit areas and inertia were hand-rolled",", because the jQuery ecosystem had no usable component for this at the time.",[15,1514,1515],{},"All of this is a one-line npm install today. Back then, it was written line by line. Looking at that code now, there's plenty I'd optimize — but it held up in production for ten years without a major incident.",[10,1517,1519],{"id":1518},"why-it-ran-for-ten-years","Why it ran for ten years",[15,1521,1522],{},"When we shut the platform down in 2019, I found myself asking the question too: how does a system written in ASPX + jQuery manage to serve travel agencies across China reliably for a decade?",[15,1524,1525,1526],{},"My after-the-fact conclusion: ",[28,1527,1528],{},"the tech stack wasn't what kept it alive. The architectural constraints were.",[15,1530,1531],{},"Specifically:",[487,1533,1534,1540,1546,1552],{},[25,1535,1536,1539],{},[28,1537,1538],{},"Turning uploads into a filesystem convention"," sidestepped the most fragile part of the human workflow.",[25,1541,1542,1545],{},[28,1543,1544],{},"Keeping business semantics out of filenames"," meant no naming-convention change on the business side ever contaminated the storage layer.",[25,1547,1548,1551],{},[28,1549,1550],{},"Province-level sharding plus a file server cluster"," made peak-window load horizontally scalable by default.",[25,1553,1554,1557],{},[28,1555,1556],{},"Separating the read and write paths"," meant production read traffic was never at the mercy of the operations-side upload surge.",[15,1559,1560],{},"All of this reads like common sense today. But at the time, each of these was a decision I pushed through the team only after \"the last incident\" had forced the point.",[10,1562,1564],{"id":1563},"looking-back-some-honest-reflections","Looking back, some honest reflections",[15,1566,1567],{},"Truthfully, there's plenty about this project I wouldn't do the same way now:",[487,1569,1570,1576,1582,1588],{},[25,1571,1572,1575],{},[28,1573,1574],{},"ASPX was a liability."," Every time we needed to add APIs or integrate with mobile later on, we had to work around it. If I'd had the courage to fully rearchitect toward a Web API + decoupled frontend in 2014, the following five years would have been considerably easier.",[25,1577,1578,1581],{},[28,1579,1580],{},"Too much got built in-house instead of using open source."," The gesture interaction layer in the frontend reader had mature alternatives by 2013, but \"it works\" kept us from revisiting it. That's a textbook form of implicit technical debt.",[25,1583,1584,1587],{},[28,1585,1586],{},"SQL Server's vertical scaling was straining hard by the later years."," I underestimated read\u002Fwrite separation and sharding approaches at the time, and I missed the best window to refactor.",[25,1589,1590,1593],{},[28,1591,1592],{},"The monitoring story was effectively non-existent."," A lot of issues were first reported by operations staff calling in — which would be unacceptable today.",[15,1595,1596,1597],{},"But some things held up — especially that automation pipeline. Ten years of stable operation proves one thing: ",[28,1598,1599],{},"if you can establish the right constraints at the process level, even an aging tech stack can sustain a system for a very long time.",[10,1601,1603],{"id":1602},"a-closing-thought","A closing thought",[15,1605,1606,1607,1610],{},"The platform wound down in 2019, partly because the pandemic hit the travel industry and partly for a deeper reason: ",[28,1608,1609],{},"mobile internet had fundamentally changed how travel agencies got information."," DingTalk groups, WeChat groups, and vertical SaaS had absorbed the intermediary \"magazine-online\" role the platform used to play.",[15,1612,1613],{},"It wasn't defeated. It was outgrown by its era. And that's fine. Something served the ten years it was built to serve, witnessed a whole industry's transformation, and got to exit with dignity — that's already a better ending than most engineers can expect.",[15,1615,1616,1617,1620,1621,1624],{},"While writing this retrospective I dug up the old codebase (I still have the backup). Seeing those ",[59,1618,1619],{},"\u003Casp:Repeater>"," and ",[59,1622,1623],{},"$.ajax"," calls again felt slightly surreal. But those 4 AM weekend moments — watching the import jobs for all 25 provinces finally come back green — those actually happened.",[15,1626,1627],{},"Noted. Archived.",[1133,1629],{},[1133,1631],{},[541,1633,1635],{"id":1634},"十年项目复盘一个全国性旅游-dm-杂志的数字化-b2b-发布平台","十年项目复盘：一个全国性旅游 DM 杂志的数字化 B2B 发布平台",[1219,1637,1638],{},[15,1639,1640],{},"项目周期:2006 — 2019\n技术栈:.NET (ASPX) \u002F SQL Server \u002F jQuery \u002F 自研自动化同步工具 \u002F 分布式文件存储",[1219,1642,1643],{},[15,1644,1645,1646],{},"📖 ",[869,1647,1648],{},"English version below — scroll down for the English translation.",[1133,1650],{},[10,1652,1653],{"id":1653},"写在前面",[15,1655,1656],{},"这是一个做了十年、然后在 2019 年随着疫情和行业转型一起关掉的项目。现在回头看,它身上带着非常典型的\"移动互联网前夜\"的时代痕迹:ASPX、jQuery、手写 SQL、文件服务器集群——没有一个是今天会被写进技术选型第一页的名字。但它确实稳定跑了十年,也确实解决了一个当时没人解决好的问题。",[15,1658,1659],{},"我想趁还记得细节的时候,把这个项目的工程决策记录下来——不是为了证明它有多先进,而是为了记住当年为什么要那样做。",[10,1661,1662],{"id":1662},"这个项目是干什么的",[15,1664,1665,1666,1669,1670,1673],{},"在移动互联网真正普及之前(尤其是 2009 到 2013 那几年),全国旅行社之间的",[28,1667,1668],{},"报价和线路信息","主要靠一个传统渠道流转:",[28,1671,1672],{},"DM 杂志","(直邮广告杂志)。各省公司每周出一本,把自己的产品、价格、出行日期排成版,印刷出来,寄给同业的旅行社。下游旅行社翻杂志、打电话、定产品。",[15,1675,1676],{},"这套流程有两个非常明显的痛点:",[487,1678,1679,1685],{},[25,1680,1681,1684],{},[28,1682,1683],{},"时效性差",":杂志印出来再寄到全国,最快也是几天后的事,价格可能已经变了。",[25,1686,1687,1690],{},[28,1688,1689],{},"信息孤岛",":一家旅行社只订几本杂志,看不到全国范围的报价,没法横向比价。",[15,1692,1693,1694,1697],{},"这个平台做的事情,就是把 DM 杂志",[28,1695,1696],{},"整本数字化搬到线上",",作为一个 B2B 平台让全国旅行社都能实时查阅。听起来简单,做起来是另一回事。",[10,1699,1701],{"id":1700},"真正的挑战在量和窗口上","真正的挑战在\"量\"和\"窗口\"上",[15,1703,1704],{},"项目的业务形态决定了它的技术特征非常极端:",[487,1706,1707,1717,1727,1736],{},[25,1708,1709,1712,1713,1716],{},[28,1710,1711],{},"页数密度大",":一个省份的周刊常规在 ",[28,1714,1715],{},"200–300 页",",每一页都是高清印刷级切片图(因为要能放大看清报价单的小字)。",[25,1718,1719,1722,1723,1726],{},[28,1720,1721],{},"全国同步出刊",":覆盖 ",[28,1724,1725],{},"25 个分公司",",每周同时出刊。",[25,1728,1729,1732,1733,482],{},[28,1730,1731],{},"单周图片总量",":约 ",[28,1734,1735],{},"5,000–7,500 张高清大图",[25,1737,1738,1741,1742,1745,1746,1749],{},[28,1739,1740],{},"发布窗口极窄",":所有分公司都是",[28,1743,1744],{},"周四、周五","完成设计出稿,",[28,1747,1748],{},"周六、周日","必须全部数字化上线,以便周一业务开盘。",[15,1751,1752,1753,482],{},"换句话说——",[28,1754,1755],{},"周末 48 小时的窗口,要吞掉一周里堆起来的 TB 级图片,并且不能出错、不能漏页",[15,1757,1758],{},"这种业务节奏下,\"人工上传\"这条路从第一天就是走不通的。如果靠运营同学点页面按钮传图,25 个分公司至少要配几十号人同时加班,而且只要有一个人传错页码,下游旅行社看到的报价单就会对不上。成本和错误率都不现实。",[10,1760,1762],{"id":1761},"我的核心方案一条自动化流水线","我的核心方案:一条自动化流水线",[15,1764,1765,1766,1769,1770,1773],{},"项目的技术核心,不在前端阅读器,也不在数据库本身,而在于",[28,1767,1768],{},"我自己设计并写的一套自动化导入和分发流水线","。目标只有一个:",[28,1771,1772],{},"让人不要碰服务器","。设计师那边出完图,按规范命名和分目录,剩下的事情工具自己做完。",[15,1775,1776],{},"下面拆开讲四个关键设计。",[787,1778,1780],{"id":1779},"_1-标准化目录映射机制","1. 标准化目录映射机制",[15,1782,1783],{},"最早期做过最朴素的版本——给运营一个网页,让他们一页一页点上传。一周崩一次。",[15,1785,1786,1787,1790,1791,1794],{},"后来做的事情是:",[28,1788,1789],{},"把目录结构本身变成协议","。业务端只要按约定命名页码、把每期杂志整理进规定的目录层级(省份 \u002F 期号 \u002F 页码),导入工具启动后会",[28,1792,1793],{},"自动扫描整棵目录树",",解析出\"这是哪个省、第几期、第几页、对应哪个栏目\",然后把元数据直接写进数据库。",[15,1796,1797,1798,1801],{},"这个设计本身没什么高深技术,但它把",[28,1799,1800],{},"上传","这件事从\"人工录入\"变成了\"文件系统约定\"。一旦约定建立起来,运营同学的工作就从\"点鼠标\"变成\"整理文件夹\"——后者是可以用脚本批处理的,前者不行。",[15,1803,1804,1805,1807],{},"这个选择背后的思路,今天叫 ",[28,1806,1388],{},",当年我没这个词汇,只是凭经验觉得\"让约定去替代输入\"。现在回看,这是整个系统能跑十年的根基。",[787,1809,1811],{"id":1810},"_2-用数据库主键重命名文件一个关于索引的教训","2. 用数据库主键重命名文件:一个关于索引的教训",[15,1813,1814,1815,482],{},"第一版系统上线没多久就遇到一个性能问题:",[28,1816,1817],{},"页面加载杂志时越来越慢,而且查询延迟随数据量增长呈现非线性恶化",[15,1819,1820],{},"查下来原因是早期做了一个典型的偷懒设计——直接用文件名(带中文和期号的那种)去做关联查找,相当于每次读一页图都要跑一次模糊匹配,加上当时 SQL Server 索引策略没优化好,很快就把自己卷进了性能坑里。",[15,1822,1823,1824,1827],{},"重构的时候做了一个关键调整:",[28,1825,1826],{},"导入过程中,程序会根据数据库主键(PK)对高清原图进行重命名和重新关联","。落到磁盘上的物理文件名就是一串整型 ID。",[15,1829,1830],{},"这个改动带来的收益是多重的:",[487,1832,1833,1836,1839],{},[25,1834,1835],{},"数据库索引完全走整型 PK,查询稳定在毫秒级;",[25,1837,1838],{},"文件名再也不会因为期号、栏目名改动而失效;",[25,1840,1841],{},"跨服务器迁移图片时,文件名本身不携带业务语义,迁移脚本极其简单。",[15,1843,1844],{},"今天看是教科书级别的\"不要让业务语义污染存储层\",但在 2010 年前后,这是我被性能打脸之后才学到的。",[787,1846,1848],{"id":1847},"_3-分布式文件存储与流水线分发","3. 分布式文件存储与流水线分发",[15,1850,1851,1852,1855],{},"单台文件服务器撑不住 25 个省同时写入——这件事不需要压测就能预判。所以从一开始就把文件存储设计成了",[28,1853,1854],{},"集群分发模式",":导入工具在重命名完文件之后,会按照规则把图片分发到指定的文件服务器上,数据库里只存\"这张图落在哪台服务器的哪个路径\"。",[15,1857,1858],{},"几个当时做得还算对的决策:",[487,1860,1861,1867,1873],{},[25,1862,1863,1866],{},[28,1864,1865],{},"上传逻辑做成流水线","(扫描 → 解析 → 入库 → 重命名 → 分发),每一环可以独立重试,中间任何一步失败都不会让整批数据回滚;",[25,1868,1869,1872],{},[28,1870,1871],{},"按省份做分片",",25 个省的上传互不阻塞,这样周末窗口里大家可以并行跑;",[25,1874,1875,1878],{},[28,1876,1877],{},"图片服务器和应用服务器彻底分离",",前端读图走独立域名,避免压垮业务 IIS。",[15,1880,1881,1882,1885,1886,1888],{},"最终效果是:",[28,1883,1884],{},"25 个分公司的海量图片能在 48 小时的窗口内全部完成入库和分发",",累计数据量超过 ",[28,1887,1470],{},"。这个数字今天看不算什么,但在 2010 年前后的国内中小型业务系统里,是个不小的量。",[787,1890,1892],{"id":1891},"_4-前端阅读器2010-年的高清缩放-拖拽","4. 前端阅读器:2010 年的\"高清缩放 + 拖拽\"",[15,1894,1895,1896,1898],{},"这一块是基于 ",[28,1897,1481],{}," 做的——对,就是那个 jQuery。当时还没有 React,Vue 连影子都没有。",[15,1900,1901,1902,1905],{},"前端的挑战在于:旅游报价单里的信息非常密,字小、表格密、脚注多。旅行社用户必须能",[28,1903,1904],{},"在一张整页杂志上自由缩放和平移",",才能看清某一条线路的具体报价和发团日期。",[15,1907,1908],{},"当年实现的几个关键点:",[487,1910,1911,1917,1923],{},[25,1912,1913,1916],{},[28,1914,1915],{},"分级加载",":缩略图先出,全尺寸图按需加载,避免一开始就怼一张几 MB 的大图进浏览器;",[25,1918,1919,1922],{},[28,1920,1921],{},"缩放和拖拽基于 CSS transform",",不走图片重采样,保证高清下依然流畅;",[25,1924,1925,1928],{},[28,1926,1927],{},"手势交互的命中区和惯性","手写了一版,因为 jQuery 生态里没有现成的能用的组件。",[15,1930,1931],{},"放在今天这些都是一个开源库解决的事情,但当年是一行一行写出来的。这部分代码现在回看有不少可以优化的地方,但它在生产环境里撑了十年没出大问题。",[10,1933,1934],{"id":1934},"它为什么能跑十年",[15,1936,1937],{},"2019 年关掉这个平台的时候,我自己也想过这个问题:一个用 ASPX + jQuery 写的系统,凭什么能稳定服务全国旅行社十年?",[15,1939,1940,1941,482],{},"我事后的结论是:",[28,1942,1943],{},"技术选型不是它活下来的原因,架构约束是",[15,1945,1946],{},"具体说:",[487,1948,1949,1955,1961,1967],{},[25,1950,1951,1954],{},[28,1952,1953],{},"把上传变成文件系统约定",",绕开了人工环节最脆弱的部分;",[25,1956,1957,1960],{},[28,1958,1959],{},"让文件名不携带业务语义",",任何一次业务侧命名规则变化都不会污染存储层;",[25,1962,1963,1966],{},[28,1964,1965],{},"按省份分片 + 文件服务器集群",",让峰值窗口的压力天然可以横向扩展;",[25,1968,1969,1972],{},[28,1970,1971],{},"读写路径分离",",让业务读请求永远不会被运营侧的上传洪峰影响。",[15,1974,1975],{},"这些东西放在今天都是常识,但当年在团队里推行的时候,其实每一个都是被\"上一次事故\"逼出来的。",[10,1977,1978],{"id":1978},"一些今天回看的反思",[15,1980,1981],{},"老实说,这个项目有很多我现在不会再那样做的地方:",[487,1983,1984,1990,1996,2002],{},[25,1985,1986,1989],{},[28,1987,1988],{},"ASPX 是个负债","。后期业务要加接口、要对接移动端,每次都得绕。如果 2014 年有勇气整体重构到 Web API + 前后端分离,后面五年会轻松很多。",[25,1991,1992,1995],{},[28,1993,1994],{},"自研了太多本该用开源的东西","。前端阅读器那套手势交互,2013 年之后已经有成熟方案了,但因为\"能跑\"就没动过,这是一种隐性技术债。",[25,1997,1998,2001],{},[28,1999,2000],{},"SQL Server 的垂直扩容撑到后期已经很吃力","。当时对读写分离和分库分表的方案评估不足,错过了最佳重构窗口。",[25,2003,2004,2007],{},[28,2005,2006],{},"监控体系几乎等于没有","。很多问题是靠运营电话反馈才发现的,这在今天是不可接受的。",[15,2009,2010,2011,482],{},"但它也有做对的部分——尤其是那条自动化流水线。十年的稳定运行证明:",[28,2012,2013],{},"只要你能在流程上建立起好的约束,哪怕底层技术栈老旧,系统也能活得很久",[10,2015,2016],{"id":2016},"一点感慨",[15,2018,2019,2020,2023],{},"这个平台在 2019 年关停,一部分是因为疫情冲击了整个旅游行业,另一部分更深层的原因是——",[28,2021,2022],{},"移动互联网已经彻底改变了旅行社之间的信息获取方式","。钉钉、微信群、垂直 SaaS 把这个\"杂志线上化\"的中间层需求消化掉了。",[15,2025,2026],{},"它不是被打败的,是被时代翻过去的。这挺好。一个东西服务了它该服务的十年,也见证了一个行业形态的变化,能体面地退场,已经是工程师能期待的不错的结局了。",[15,2028,2029,2030,2032,2033,2035],{},"写这篇复盘的时候翻了一下当年的代码(还有备份),看着那些 ",[59,2031,1619],{}," 和 ",[59,2034,1623],{}," 有点恍惚。但那些在周末通宵盯着 25 个省份图片全部导入成功的凌晨四点,是确实存在过的。",[15,2037,2038],{},"记一笔,存档。",{"title":57,"searchDepth":71,"depth":71,"links":2040},[2041,2042,2043,2044,2050,2051,2052,2053,2054,2055,2056,2062,2063,2064],{"id":1228,"depth":71,"text":1229},{"id":1242,"depth":71,"text":1243},{"id":1281,"depth":71,"text":1282},{"id":1342,"depth":71,"text":1343,"children":2045},[2046,2047,2048,2049],{"id":1360,"depth":77,"text":1361},{"id":1392,"depth":77,"text":1393},{"id":1429,"depth":77,"text":1430},{"id":1474,"depth":77,"text":1475},{"id":1518,"depth":71,"text":1519},{"id":1563,"depth":71,"text":1564},{"id":1602,"depth":71,"text":1603},{"id":1653,"depth":71,"text":1653},{"id":1662,"depth":71,"text":1662},{"id":1700,"depth":71,"text":1701},{"id":1761,"depth":71,"text":1762,"children":2057},[2058,2059,2060,2061],{"id":1779,"depth":77,"text":1780},{"id":1810,"depth":77,"text":1811},{"id":1847,"depth":77,"text":1848},{"id":1891,"depth":77,"text":1892},{"id":1934,"depth":71,"text":1934},{"id":1978,"depth":71,"text":1978},{"id":2016,"depth":71,"text":2016},"https:\u002F\u002Fimages.xtop.dev\u002Fblogs\u002F2026\u002F04\u002Ftravel-dm-platform-retrospective-bilingual.webp","2026-04-21","A ten-year retrospective on building a nationwide B2B platform that digitized weekly travel DM magazines across 25 branches — and why an ASPX + jQuery stack survived a decade.",{},"\u002Fblogs\u002Ftravel-dm-platform-retrospective-bilingual",{"title":1215,"description":2067},"blogs\u002Ftravel-dm-platform-retrospective-bilingual",[2073,2074,2075,2076,2077,2078,2079,2080,2081],"retrospective","system-design","legacy-systems","automation","b2b","distributed-storage","convention-over-configuration",".net","jquery","bWmkB1R3z-PDsF6X4cymuI8s9Ri7jlZORXAGEUKazjI",1776954008088]