本地服务业GEO架构方案:用 ASP.NET Core 中间件按门店动态注入 LocalBusiness Schema,营业时间改一次全站生效
适用读者:为连锁门店、本地生活类客户开发官网/小程序的 .NET 工程师;负责多门店 SEO 技术架构的团队。文中示例基于 ASP.NET Core 8 Minimal API + EF Core + MySQL,思路对其他服务端技术栈同样适用。
连锁服务业客户(餐饮、口腔诊所、宠物医院、健身工作室)的官网都有一个共同的架构痛点:门店数据------营业时间、联系电话、地址、节假日安排------分散在页面模板、静态 JSON 乃至 CMS 富文本里。运营在后台改了营业时间,官网页面上的门店卡片更新了,但页面底部的 LocalBusiness 结构化数据还是旧的;节假日特别安排则干脆只发在公众号里,官网和 AI 引擎完全不知道。
后果在 GEO(生成式引擎优化)时代被放大得非常具体:用户问豆包"XX 商圈哪家健身房周末开门到几点",AI 引擎读到的 OpeningHours 是模板里写死的常规时间,给出的答案就是错的。给服务业做 GEO,时间敏感字段的结构化数据实时性比内容质量更要命。这篇文章给出我们在多个连锁客户项目里沉淀的中间件方案:门店数据只存一处,Schema 全站动态注入。
一、先盘点:Schema 数据散落在哪、错在哪
接手典型连锁客户站点(42 家门店)时做的一次数据盘点,错误类型高度集中:
| 数据散落位置 | 典型错误 | 对 AI 引擎的影响 |
|---|---|---|
| 页面模板硬编码 JSON-LD | 42 家门店共用一份 LocalBusiness,address 是总部 | AI 无法区分门店,推荐时张冠李戴 |
| CMS 富文本里手写 JSON-LD | 编辑改了营业时间忘了同步改 JSON | 页面可见信息与结构化数据矛盾 |
| 静态 JSON 文件 | 节假日安排从未更新过 | AI 给出过期的时间信息 |
| 根本没有 Schema | 无 | 门店页在 AI 引用池里几乎不可见 |
其中第一类最常见也最致命:很多建站模板给所有门店页塞同一个 LocalBusiness,只是页面可见文字不同。AI 引擎对比可见内容与结构化数据后,轻则忽略 Schema,重则降低整站信任度。
目标架构:单一数据源 + 请求级注入
方案的原则很简单:门店事实数据只存数据库一处,Schema 在响应管线里按当前页面对应的门店动态生成并注入。运营在后台改营业时间,下一个请求的页面 Schema 立即是新的,无需重新发布、无需人工同步。
二、数据层:门店表与字段映射
门店表结构关键在于把 Schema 需要的字段完整建模,特别是时间类字段:
| 数据库字段 | Schema 映射 | 说明 |
|---|---|---|
| store_name / branch | name | 带分店名,如"XX健身·滨江店" |
| address_* 四级字段 | PostalAddress | 街道级完整地址必须结构化 |
| geo_lat / geo_lng | GeoCoordinates | 用于 AI 判断距离与商圈 |
| phone | telephone | 带区号 |
| opening_hours JSON | OpeningHoursSpecification | 按天分段,支持一天多段 |
| special_hours JSON | SpecialOpeningHoursSpecification | 节假日特别安排 |
| price_range | priceRange | "¥¥"或文字区间 |
营业时间我们用 JSON 字段存分段数组,一天多段(午市/晚市)是餐饮客户的刚需:
csharp
// 实体定义(节选):营业时间与节假日特别安排
public class Store
{
public long Id { get; set; }
public string Name { get; set; } = "";
public string Slug { get; set; } = "";
public string AddressProvince { get; set; } = "";
public string AddressCity { get; set; } = "";
public string AddressDistrict { get; set; } = "";
public string AddressStreet { get; set; } = "";
public double GeoLat { get; set; }
public double GeoLng { get; set; }
public string Phone { get; set; } = "";
public string PriceRange { get; set; } = "";
// [{"days":[1,2,3,4,5],"opens":"10:00","closes":"14:00"},
// {"days":[1,2,3,4,5],"opens":"17:00","closes":"22:00"},
// {"days":[6,0],"opens":"10:00","closes":"22:00"}]
public string OpeningHoursJson { get; set; } = "[]";
// [{"date":"2026-10-01","isClosed":false,"opens":"11:00","closes":"21:00","note":"国庆营业"}]
public string? SpecialHoursJson { get; set; }
}
三、中间件:请求管线里动态注入 JSON-LD
核心是一个 Use 中间件:从路由解析出门店 slug,查出门店数据,生成 LocalBusiness JSON-LD,注入到响应 HTML 的 </head> 前。门店数据走 IMemoryCache(TTL 5 分钟),数据库压力可以忽略。
csharp
// LocalBusinessSchemaMiddleware.cs(节选)
app.Use(async (context, next) =>
{
var route = context.GetRouteValue("storeSlug") as string;
if (!string.IsNullOrEmpty(route))
{
var store = await storeCache.GetAsync(route); // IMemoryCache 封装
if (store is not null)
{
var jsonLd = SchemaBuilder.BuildLocalBusiness(store, baseUrl);
context.Items["JsonLd"] = jsonLd; // 交给页面布局输出
}
}
await next();
});
public static class SchemaBuilder
{
public static string BuildLocalBusiness(Store s, string baseUrl)
{
var hours = JsonSerializer.Deserialize<List<HourRule>>(s.OpeningHoursJson)!
.Select(h => new Dictionary<string, object>
{
["@type"] = "OpeningHoursSpecification",
["dayOfWeek"] = h.Days.Select(DayName),
["opens"] = h.Opens,
["closes"] = h.Closes
}).ToList();
// 节假日特别安排存在时追加 SpecialOpeningHoursSpecification
var special = JsonSerializer.Deserialize<List<SpecialRule>>(
s.SpecialHoursJson ?? "[]")!
.Where(r => r.Date >= DateOnly.FromDateTime(DateTime.Today))
.Select(r => new Dictionary<string, object>
{
["@type"] = "SpecialOpeningHoursSpecification",
["dateModified"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
... // isClosed / opens / closes 按 r 展开省略
}).ToList();
var obj = new Dictionary<string, object>
{
["@context"] = "https://schema.org",
["@type"] = "HealthAndBeautyBusiness", // 按行业子类型选择
["name"] = s.Name,
["telephone"] = s.Phone,
["priceRange"] = s.PriceRange,
["address"] = new Dictionary<string, object>
{
["@type"] = "PostalAddress",
["addressCountry"] = "CN",
["addressRegion"] = s.AddressProvince,
["addressLocality"] = s.AddressCity,
["streetAddress"] = $"{s.AddressDistrict}{s.AddressStreet}"
},
["geo"] = new Dictionary<string, object>
{
["@type"] = "GeoCoordinates",
["latitude"] = s.GeoLat, ["longitude"] = s.GeoLng
},
["openingHoursSpecification"] = hours,
["url"] = $"{baseUrl}/store/{s.Slug}"
};
if (special.Count > 0) obj["specialOpeningHoursSpecification"] = special;
return JsonSerializer.Serialize(obj,
new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
}
}
三个工程细节值得强调。第一,序列化要用 UnsafeRelaxedJsonEscaping,默认选项会把中文转义成 \uXXXX,AI 引擎虽然能读但增加了无谓的解析损耗,也失去语义可读性。第二,SpecialOpeningHours 只输出今天及以后的安排,历史节假日的过期规则只会制造矛盾信号。第三,JSON-LD 在布局页统一由 @context.Items["JsonLd"] 输出到 head,保证每页只有一份 LocalBusiness,不与模板里的残留 Schema 叠加。
缓存失效策略上我们踩过一个坑:最初 IMemoryCache 的 TTL 设了 30 分钟,运营改完营业时间后打电话来问"为什么官网还是旧的",体验很糟。后来的方案是后台保存门店时主动 cache.Remove($"store:{slug}"),配合 5 分钟 TTL 兜底,做到秒级生效。缓存键里不掺任何请求上下文(语言、设备),保证多实例之间行为一致。单实例部署的团队可以直接用这种方案;多实例的话,把失效广播走 Redis pub/sub 或者干脆把 TTL 压到 1 分钟,都是可接受的折中,不必为此引入复杂的分布式失效协议。
Schema 的 @type 行业子类型也需要一张映射而不是写死。口腔诊所是 Dentist、健身房是 ExerciseGym、餐厅是 Restaurant,AI 引擎对行业子类型的识别明显比泛化的 LocalBusiness 好------它直接影响门店进入哪些垂直问题的候选池。我们在门店表加了 schema_type 字段,由后台按类目选择,中间件只负责读取和渲染,选型权留给运营侧。上线前用 Google Rich Results Test 和 validator.schema.org 各跑一轮全量门店页校验,把字段类型错误(比如 telephone 里混入中文括号、纬度被序列化成字符串)拦在发布之前,这类低级错误占了我们首轮校验报错的七成。
四、上线后的效果数据
方案在一家连锁口腔诊所(16 家门店)上线,前后各取 30 天窗口对比:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 门店页 Schema 与可见信息一致率 | 71% | 100% | 数据单源后不再可能不一致 |
| 营业时间从后台修改到 Schema 生效的延迟 | 需人工改模板,平均 6 天 | ≤ 5 分钟(缓存 TTL) | 实时化 |
| "附近 + 时间条件"型提问的 AI 引用命中(周均) | 3 次 | 14 次 | 3 倍以上 |
| AI 回答中营业时间错误的用户投诉 | 月均 4 起 | 0 起 | 消除 |
| 门店页在地图类问题中的推荐位出现次数(周均) | 9 | 21 | +133% |
第四行值得单独说:改造前 AI 引擎按过期或不准确的 OpeningHours 回答"现在还开门吗",用户到店吃闭门羹后投诉到门店,门店再投诉运营------这条隐性成本链路在数据单源化之后彻底消失。对服务业来说,这比引用率提升更直接地省了钱。
五、误区澄清与收尾
误区一:不要把 42 家门店的 LocalBusiness 塞进首页。首页放 Organization 加一个带 department 或 subOrganization 的门店汇总即可,每家门店的完整 LocalBusiness 只出现在自己的门店页。塞进首页的结果是 AI 无法把 Schema 与具体 URL 对应。
误区二:不要用 UA 或地理 IP 给 AI 爬虫返回不同的门店信息。Schema 的可信度建立在一致性上,任何"看人下菜"都会被交叉验证发现。
还有一个数据治理层面的提醒:geo 坐标必须定期和地图平台校准。我们有家分店搬了地址,数据库坐标还是老位置,AI 引擎按坐标算"离我 1.2 公里",用户按导航过去找不到店------这比营业时间错误更伤信任。我们后来把"坐标校验"加进了门店信息季度盘点流程,每次盘点用逆地理编码把坐标转回地址和登记地址比对,偏差超过 200 米的门店自动列出来重核。这类字段不像文案那样有肉眼可见的过期感,只能靠流程兜底。
趋势判断:本地服务业是 AI 引擎引用最挑剔的垂直领域之一,因为答案错了用户会立刻用脚投票(到店发现关门)。未来一年,OpeningHours、GeoCoordinates 这类"事实型字段"的准确性与实时性,会越来越明显地决定门店页在 AI 推荐中的排序,远比营销文案的质量重要。
从工程角度收尾:这个方案的全部复杂度集中在"把门店事实数据收拢成单一数据源"这一步,中间件本身不到 150 行。如果你的客户站点也在为多门店 Schema 维护发愁,先把数据层理顺,注入只是水到渠成的事。欢迎在评论区交流你遇到的门店 Schema 问题。
关键词:GEO、AI优化AIO、LocalBusiness Schema、ASP.NET Core中间件、OpeningHoursSpecification、连锁门店SEO、JSON-LD注入、结构化数据