Openship 监控体系深度解析:边缘计数、控制面收集的零成本可观测性架构
2026/9/15 9:47:16 网站建设 项目流程

Openship 监控体系深度解析:边缘计数、控制面收集的零成本可观测性架构

【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship

本文以 Openship(自托管部署平台)的 Monitoring 模块为对象,系统讲解其"流量统计近乎免费、资源采样按预算执行"的设计哲学:如何让每次访问的开销只有微秒级、如何把逐请求计数与 Postgres 落库彻底解耦、Top Paths 为何必须显式开启,以及真实访客 IP 在 Cloudflare 与免费域名两种前置场景下的恢复机制。读完你将掌握这套"边缘(OpenResty)计数 → 控制面(API)定时归集"架构的完整链路,并能直接对照仓库源码验证每一个性能数字与配置项。

一图看懂:三个问题与一个设计原则

Openship 的每个项目都带有一个 Monitoring 标签页,它回答三个问题:我的应用现在在用什么资源、流量从哪里来、返回了什么结果。而它最核心的设计目标是——回答这些问题时,访问者不需要付出任何代价。项目在 docs/monitoring.md 中明确写道:"我们收集分析数据"通常意味着"每个请求都多干了活",因此这份文档把架构与实测成本摆在了台面上。

先看它的"短版本"结论表:

维度数值
加到访客请求上的工作量内存计数器更新约~1.4 µs(开启 Top Paths 后约 +3.1 µs),外加一次国家查询(缓存命中 0.01 µs / 未命中 2.5 µs)——且全部发生在响应已发送之后
请求路径上的 I/O——没有 socket、没有 HTTP 调用、没有文件写入、没有数据库
每请求写入 Postgres 的行数
每请求写入 Postgres 的行数(按天)每个域名 ≤1440 行,外加每域名 1 行、每服务 288 行
控制面是否在请求路径上。访客请求永远不会到达 API 或数据库

贯穿全文的设计原则一句话概括:边缘负责计数,控制面负责归集(the edge counts, the control plane collects)。请求只是在共享内存里累加计数器;一个定时任务每 30 分钟把这些已经聚合好的数字搬进 Postgres。任何逐请求的数据都绝不会跨越进程边界。

请求路径:log_by_lua阶段的纯共享字典计数

流量测量发生在 OpenResty 内部——也就是那个已经在终止 TLS 并向你的容器反向代理的同一个进程。它只运行一个 Lua 处理器 site_logger.lua,挂在 nginx 的log_by_lua阶段。

这个阶段的选择至关重要:它运行在响应已经写回客户端之后。因此测量一个请求不可能延迟它——当测量发生时,访客手里已经拿到了自己的字节。源码开头的注释也印证了这一点:"OpenResty log_by_lua - runs AFTER the response is sent. Pure shared-dict analytics. No Redis, no timers for counters, no batching. Every incr() is atomic across workers."(见 site_logger.lua)。

处理器对每个请求做的工作:

  • 约十几次incr/safe_add调用,目标是ngx.shared.DICT共享字典区——这是 nginx 自身的共享内存,进程内、跨 worker 原子。其中几次是有条件的:响应时间只在非零时累加,"页面请求"计数器只统计非静态 URL,独立访客计数器只在该访客当天第一次请求时递增;
  • 一次共享字典get,用于检查该主机是否开启了按路径收集(即下文要讲的 Top Paths 开关);
  • 一次set,写入一个固定 1000 槽位的环形缓冲区,保存最近的原始请求,供 Logs 标签页和实时地图使用。

整个处理器里没有 socket、没有 HTTP 客户端、没有文件句柄、没有数据库驱动。由此推出两个结论:它不可能阻塞在网络往返上,也不可能以影响响应的方式失败。

聚合而非插入:成本随流量增长保持平坦

请求不会创建一条记录,它只是给已经存在的计数器加一。源码中的键结构如下(见 site_logger.lua):

s:{domain}:{minute}:r request count for this minute s:{domain}:{minute}:i / :o bytes in / out g:{domain}:{day}:{CC} hits from this country today g:{domain}:{day}:s:{code} responses with this exact status today

某一分钟内的第 1000 个请求和第 1 个请求触碰的键完全相同。每秒 10 个请求和每秒 10000 个请求写入的键数量一样——只是键里的数值不同。此外,Lua 端还通过incr(无init时对缺失键返回 nil,正好充当存在性探测)实现了"常见路径只做一次哈希操作"的bump_indexed写法(见 site_logger.lua),让热点路径上的开销进一步收敛。

一切无界的东西都有界

计数器键按 (domain, minute) 和 (domain, day) 固定,但有三样东西会随恶意输入膨胀,因此每一项都被封顶:

  • 路径(Paths)——默认关闭(见下文);开启时,URL 模糊扫描器会为每个探测的 URL 铸造一个永久键,所以查询字符串被剥离、数字与 UUID 段折叠为:id,且每个域名每天超过 2000 个不同路径后,尾部折叠进单一的other桶。源码中对应PATH_CARDINALITY_CAP = 2000(见 site_logger.lua)与normalize_path的折叠逻辑(见 site_logger.lua);
  • 原始请求日志——固定 1000 槽位的环形缓冲区,1 小时 TTL。槽位 1001 覆盖槽位 1,该共享区永不增长。源码中RING = 1000,写入采用seq % RING取模(见 site_logger.lua 与 site_logger.lua);
  • 独立访客——每个访客按天加盐哈希,存放在独立的 64 MB 共享区(约每天 100 万独立访客)。超过后该区 LRU 逐出、计数会偏低,UI 会被告知这一点:GET /status上报每个区的剩余空间,仪表盘把该数值标记为"近似值",而不是把一个逐出伪影当成测量结果。

每个共享区都单独、刻意地设定大小,因此某个区的高基数洪泛不会把另一个区的计数器逐出。这些尺寸在 openresty-lua.ts 的EDGE_SHARED_DICTS中集中声明:analytics256m(分钟桶计数、每日地理、总量)、request_data128m(原始日志环形缓冲,兼作实时 SSE 源)、rules32m(路由规则缓存)、rl_counters32m(限流计数,与规则区分开以免高基数洪泛逐出规则集)、visitors64m(独立访客标记)。配套的 edge-shared-dicts.test.ts 测试还专门钉死了"声明尺寸"与"OpenResty 模块迁移脚本里就地改尺寸"两个写入方必须收敛到同一目标值,防止全新安装拿到迁移本要修正的旧尺寸。

实测成本:微秒级的账

官方在真实的openship-edge镜像里跑处理器所写的那些共享字典写操作做基准测试(aarch64、2 vCPU 容器、30 万次迭代、5 组样本):

场景每请求
仅计数器2.86 – 3.18 µs
计数器 + 原始日志环形记录4.11 – 4.40 µs

约 1.3 µs 的差额几乎全部来自原始日志记录的cjson.encode。按维度拆解,这解释了为什么其中一项是可选开启的:

维度每请求
paths(整个代码块)1.72 µs
—— 其中字符串处理(normalize_path、静态资源检查)1.38 µs
分钟桶(5 ×incr0.61 µs
国家(2 ×incr0.24 µs
状态码(1 ×incr0.14 µs
可选开关本身的检查(1 ×get0.07 µs

而国家查询不是计数器,值得单独一行——它是路径上缓存未命中时最贵的东西:

场景每请求
国家:LRU 命中(回头客)0.010 µs
国家:mmdb 查询(LRU 未命中)2.3 – 2.8 µs

这条路径上没有磁盘,而且缓存的存在不是为了省一次磁盘读。数据库(MMDB)是内存映射的,首次触碰后常驻页缓存、跨 worker 共享,每次查询都没有 read 系统调用——"数据库在内存里"正是 mmap 的意义。它每个 worker 只打开一次。

一次查询的几微秒是CPU 而非 I/O。实测把整个 8.7 MB 文件读入页缓存后,对相同地址重复三次:

raw mmdb walk, pass 1 (cold pages) 5.550 µs raw mmdb walk, pass 2 (warm) 5.450 µs raw mmdb walk, pass 3 (warm) 5.450 µs

完全平坦——没有任何东西在等存储。那个时间就是 MMDB 格式本身:一棵按地址位逐节点下探的二分搜索树(IPv6 最多 128 跳),加一次数据段解码和一次 FFI 穿越。把同样的字节复制进 Lua 堆会跑同样的遍历、花更多钱——因为把 140 万个节点做成 Lua 表,是每个 worker 数百 MB 的 GC 托管对象,而不是共享的 8.7 MB。

所以 LRU 不是磁盘缓存,而是对这棵树的遍历做记忆化(memoization)——这正是它值约 250 倍的原因,也是移除它会变成"每个请求都付全量遍历"而非"消除一次未命中"的原因。现实成本因此随 IP 多样性变化:有常客的站点每请求约 0.01 µs;被全新地址逐个扫描的站点每请求付个位数微秒。

给个量级参照:在耗时 5 ms 的请求上,4 µs 约占0.08%;在一台 1000 req/s 的机器上,约占一颗 CPU 核的 0.4%。

三个诚实的注意事项:第一,这里测的是分析工作本身,且在紧密循环中驱动;真实请求还要付 nginx 自己日志阶段的固定开销——无论你是否收集任何东西它都存在——而且不会有同样热的缓存。第二,数字来自一台机器,把它当作数量级而非你硬件的保证。第三,它不是零:它很小、被测量过、且发生在响应发送之后。

Top Paths 是显式开关:为什么按路径计数要付费

上面每个维度"几乎免费"的原因是边缘本来就在处理请求——只有一个例外。按路径计数占了约 3.0 µs 计数器路径中的1.72 µs(57%),其中 1.38 µs 是字符串工作:剥离查询、把/orders/48219折叠成/orders/:id、检查 URL 是否为静态资源。它也是基数最高的维度(每域名每天最多 2000 个键,对比约 200 个国家和几十个状态码),还是每日汇总里最大的列。

因此它是逐项目开关,默认关闭,包括在开关出现之前就存在的项目——没有人主动选择承担这个成本,所以没有人该被迫继续支付它。从 Monitoring 标签页的 Top Paths 卡片上打开它,同一张卡片的 ⋯ 菜单可以再关掉。关闭时计数器路径降到约1.4 µs每请求。

机制上:开关是 Postgres 里的project.collect_paths字段,被推送到项目每个主机名的边缘rules共享字典,键为cfg:{host}:paths。日志处理器用一次get(0.07 µs)读取它,键缺失时跳过整个代码块。刻意放在rules区而不是analytics区——后者是 256 MB 计数器在 LRU 压力下的高频换出区,若开关键在那里被逐出,就会悄无声息地改变收集内容。这套推送逻辑实现在 analytics-config.service.ts:pushProjectAnalyticsConfig每次调用都会把所有主机(含已删除域名对应的旧主机名,用于清理残留键)打包成一次/analytics/config往返推给边缘——通过 SSH 隧道时,一次往返远比逐主机往返便宜。

让边缘与数据库保持同步

开关缓存在 RAM 里,因此恰好有两种漂移方式,且两种都是已知的:

事件开关存活吗?
openresty -s reload(一次路由变更)存活——共享内存区在 reload 后保留
完全重启(重启、docker restart、镜像升级)不存活——共享区被清空

缺失意味着关闭,所以重启后的机器会停止收集路径,而数据库仍认为它应该收集。有三件事防止这变成一次静默、永久的失配:

  1. 每次 route apply 都会重新推送——任何部署或域名变更都会立即修正它;
  2. 30 分钟一次的分析归集会重新断言每个项目在每台边缘上的设置,每台服务器一次往返。这把重启后的漂移限制在一次归集周期内,而不是等到下一次部署——对一个稳定的项目,那可能要等几周。对应 analytics-scraper.ts 中reconcileAnalyticsConfig的调用:既然归集已经访问每台服务器,就顺手把逐主机开关重新推一遍,写操作是幂等的,无条件推送比"先读再条件写"便宜;
  3. 关闭只有一个状态。边缘删除键而不是存0,所以"从未设置""显式禁用""重启丢失"是同一个值。两种"关闭"的表示,正是读者会把其中一个当成"开启"的根源。

诚实的总结:这是最多 30 分钟窗口的最终一致,而不是事务性。而这个窗口正是这套分析数据本身一直生活在其中的那个窗口——计数器本来就在 RAM 里,完全重启会丢失尚未被抓取的部分,所以这个开关不比它所管辖的数据更弱。

进入 Postgres:30 分钟归集与行数预算

边缘把计数器放在 RAM 里并带 TTL(分钟桶 24h,每日汇总 48h)。一个定时任务在它们过期前把它们搬出去。

analytics:scrape,每 30 分钟一次(cron 为13,43 * * * *,定义见 job.registry.ts):

  1. 每台服务器一个连接——不是每项目、不是每域名;
  2. 一次POST /analytics/collect覆盖该机器上的所有域名,由单次共享字典扫描服务。(以前是 N 个域名的 2N+1 次无界扫描;在一台 50 域名的机器上,那就是每轮归集对 256 MB 共享区做 101 次全量扫描。get_keys会遍历整个共享区并在此期间持有字典锁,压制所有 nginx worker——analytics-scraper.ts 注释记录了这段历史);
  3. 分钟桶被原子地读出并删除,然后批量 upsert;
  4. 每日汇总不删除:边缘保留当天累计值,所以每次归集都重读全天、upsert 覆盖。重复抓取是幂等的。

服务器串行处理。每一台都意味着一次 SSH 连接或docker exec,同时轰炸五十台机器正是"指标归集变成一次事故"的方式。没有任何东西在等这个 tick(analytics-scraper.ts 明确说明:串行而非Promise.all,一次不可达的失败被scrapeServerIfStale吞掉,不会让任务失败或卡住后续服务器)。

打开标签页也会触发一次归集,为了在定时计划之外保持新鲜度。这条路径有陈旧度门控(跨进程,所以第二个仪表盘标签页不会重复触发)和 in-flight 去重(窗口内的重复读取不会再次命中服务器)。实现上scrapeServerIfStale用 cacheStore 记录lastAt:{serverId}键、以 60 秒 TTL 节流,并用进程内inflightScrapeMap 合并并发调用(analytics-scraper.ts)。归集器的窗口逻辑也很关键:toMinute = now - 1,因为当前分钟仍在累加,立即刷新会截断它;起始分钟从getLastScrapedMinute继续,默认回退最近一小时。

会产生多少行

稳态、保留期稳定之后:

每天行数保留期稳态
server_analytics(分钟桶)每域名 ≤144090 天每域名 ≤129,600
server_analytics_geo(每日汇总)每域名 1400 天每域名 400
—— 其paths仅当 Top Paths 开启时
resource_usage(5 分钟采样)每服务 28830 天每服务 8,640

≤1440是因为桶行只存在于实际有流量的那一分钟——收集器只在某分钟的计数器存在时才输出该桶。每小时只有 cron 流量的站点每天写约 24 行,而不是 1440。1440 是"该域名每分钟至少一个请求"的天花板。

因此,一个繁忙域名上的五服务项目最终稳定在约173,000 行——几十 MB,写入以每小时两小批到达,而不是一条流。

剪枝在夜间运行:analytics:retention-prune(04:23)和resources:retention-prune(04:29),两者的 cron 与云模式下的可用性门控都定义在 job.registry.ts。

唯一不免费的部分:资源采样为什么另起炉灶

资源采样与流量在本质上不同,值得理解为什么。

流量计数是免费的:边缘本来就在处理请求,递增计数器只是顺带。资源用量则需要一次主动探测——对每个容器执行docker stats——而 daemon 必须采集两个 CPU 样本才能算出一个百分比,所以每次调用会占用它大约一秒——比健康检查用的廉价docker inspect(约 2 ms)高出几个数量级。

这一个事实驱动了整个设计(实现在 usage-sampler.ts):

  • 节奏就是分辨率。resources:sample每 5 分钟运行一次(1-59/5 * * * *),每服务每天 288 个点。更快地采样是在以真实且不断增长的价格换取细节;
  • 有界并发——每台服务器最多 8 个在途调用(SAMPLE_CONCURRENCY = 8),所以 20 服务的栈不会同时发出 20 个一秒级调用;
  • 每台服务器的预算——每次归集 120 个样本(MAX_SAMPLES_PER_SERVER = 120)。溢出会被计数并在任务摘要中报告,绝不静默丢弃——否则会读成"那些容器是空闲的";
  • 跳过已停止的容器——免费,且避免为一个无话可说的容器付出一秒。

采样任务刻意独立于健康检查任务(services:health-watch),因为健康检查承诺"一切正常的 tick 不写数据库",而采样器按定义每次都会写;健康检查还会被容器事件在秒级突发地重复触发,那正是时间序列最忌讳的;且健康检查被selfhosted门控,但云运行时实现了同样的 usage 接口,骑上那个门控会让云项目完全没有历史数据(usage-sampler.ts)。另外,采样按bucketMinuteFor把 epoch 分钟向下取整到RESOURCE_BUCKET_MINUTES的倍数,再配合onConflictDoNothing,使同窗口内的重跑成为幂等空操作(usage-sampler.ts)。

标签页里的实时每秒视图是一条独立的 SSE 流,只在标签页打开时存在,它从数据库读取服务列表是每条流一次而非每个 tick 一次。

收集什么、不收集什么:计数而非身份

访客计数是一个计数,从来不是一种身份。地址用一个盐做哈希,这个盐在你机器上生成、每天轮换、除那个共享字典区外从不写入任何地方——所以标记键不可逆推为 IP,第二天就一文不值。只有最终得到的计数器到达 Postgres。任何一层都不存在逐访客的行。源码在 site_logger.lua 中实现了这一点:vsalt:{day}键由第一个写入者生成(safe_add保证竞态下只有一个赢家),vd:{domain}:{day}:{hash}标记键以safe_add去重——它只在访客当天第一次请求时成功,因此计数器恰好每独立访客递增一次。

原始请求日志(IP、路径、User-Agent)存在于边缘的 1000 槽环形缓冲区中一小时,控制面从不持久化。而且原始日志在写入前就做了凭据清洗:查询字符串(常携带 OAuth 码、签名 URL、token)被完全剔除,两个已知的携带凭据路由/accept-invite/:token/api/auth/invitation-preview/:token在进入聚合或环形缓冲之前就被折叠(site_logger.lua),并有专门的隐私测试 site-logger-privacy.test.ts 钉死这两个返回值。此外,日志处理器里每个子块(GeoIP、访客、路径、状态码、环形缓冲)都分别pcall隔离——因为log_by_lua里抛错会中止处理器剩余部分,一次坏的共享字典调用会静默清零其后所有计数器,这正是当初"paths 和 statuses 读起来像没有流量而 requests 和带宽一切正常"这类 bug 的来源(site_logger.lua)。

位于 Cloudflare 或其他代理之后

如果边缘位于某个代理之后,连接的对端是代理,而不处理这一点的话,每个访客的国家都会解析到 PoP、独立访客计数会塌缩到 PoP 的数量级、按 IP 的限流会把某个 PoP 后面的所有人装进同一个桶。

Openship 用 nginx 的realip模块配合 Cloudflare 发布的地址段恢复真实客户端地址,尊重CF-Connecting-IP。信任锚定在谁打开了这个 socket:只有当对端是可信地址时头才被采信,所以你自己在源站发送它毫无作用。为什么用CF-Connecting-IP而不是X-Forwarded-For,实现在 edge-real-ip.ts 中有完整论证,核心两条:

  • Cloudflare覆写CF-Connecting-IP——客户端发送的任何值都被丢弃,到达我们手里的值完全是 Cloudflare 自己对客户端地址的陈述,不含任何客户端提供的内容;
  • Cloudflare 对X-Forwarded-For追加——到达的值形如<客户端塞的>, <真实客户端>,是一个部分受客户端控制的列表。正确读取需要real_ip_recursive on并从往左走;取最左项(朴素读法,也是大量代码的做法)等于让客户端自己选地址——也就自己选限流桶和封禁规则。

set_real_ip_from不是"信任这个头",而是"仅当 TCP 对端是这些地址之一时信任这个头"。直接 curl 源站并携带伪造CF-Connecting-IP: 1.2.3.4的访客不是从 Cloudflare 地址连接的,nginx 会忽略该头并保留其真实对端地址——信任锚定在客户端无法伪造的"谁开了 socket"上。

对于非 Cloudflare 代理,设置OPENSHIP_EDGE_TRUSTED_PROXIES(逗号分隔的 CIDR)以及可选的OPENSHIP_EDGE_REAL_IP_HEADER。值得注意的实现细节:edgeTrustedProxies丢弃格式错误的条目而非透传——一个坏 token 会让nginx -t失败,失败的测试意味着 reload 被拒绝、该机器上任何路由变更都无法落地,所以一个环境变量的拼写错误就会卡死整台机器的所有路由;对只会扩大信任范围的值,"被静默忽略"是更好的失败方式(edge-real-ip.ts)。生成的openship-real-ip.conf以 include 文件形式落盘而非拼进http {},因为裸机收敛是 append-only 的grep || sed,无法更新已写内容,而会随 Cloudflare 新增地址段增长的信任列表必须可整文件覆写(edge-real-ip.ts)。

免费的*.opsh.io域名

免费域名由 Openship Cloud 自己的边缘提供服务,它通过纯 :80 代理到你的机器,并把访客写在X-Real-IP里——这与 Cloudflare 路径是不同的头,而 nginx 每个配置作用域只允许一个real_ip_header。所以这些 vhost(且只有这些 vhost)携带它们自己的realip块,由registerRoute发射到server { }内部。

这个块信任任何对端的X-Real-IP,与 Cloudflare 路径不同。它必须如此:Cloud 边缘从自己的地址到达你的机器,opsh.io被 Cloudflare 代理只覆盖访客→边缘这一段,而一个不包含真实出口地址的对端列表会静默忽略该头——让每个免费域名访客都被记为边缘。边界是server_name:它只作用于<slug>.opsh.io这个主机名,它的唯一合法路径就是那个边缘。自定义域名继续使用对端锚定的 http 作用域块,且绝不能把这个块扩到自定义域名上去(edge-real-ip.ts,cloudEdgeRealIpConf发射real_ip_header X-Real-IP;real_ip_recursive off;set_real_ip_from 0.0.0.0/0;::/0;,并以内联注释说明了该信任为何只能锚定到 vhost)。

判断逻辑isCloudFrontedHost刻意使用字面量云域名SYSTEM.DOMAINS.CLOUD_DOMAIN)而不是 API 的getRoutingBaseDomain():当设置了HOST_DOMAIN时,"托管"主机名住在操作者自己的通配符 zone 上、直指本机、前面没有任何东西——在那里放一个信任头块的块,等于相信一个没有任何代理会设置的头,即把任意选择源地址的权利白送给每个访客(edge-real-ip.ts)。

已有 vhost 会在下一次 route apply 时捡起这个块——部署、"Ensure edge"、域名保存或 Retry routing 都会触发;而 edge-ensure 路径还会重放任何盖戳低于当前VHOST_GENERATION的 vhost,所以仅一次升级不再会让一个已生成配置的修复落空。

在 Openship Cloud(SaaS)上

在 SaaS 上,流量分析数据位于 Oblien 的边缘,按需读取——从不被抓取进本地数据库。因此analytics:scrapeanalytics:retention-prune两个任务在那里都不可用,因为没有本地数据可收集或剪枝(job.registry.ts 明确注释"Cloud 没有受管服务器也没有 OpenResty")。资源用量确实会在云上采样(云运行时实现了同一个 usage 接口),所以采样和它的保留剪枝在该模式下照常运行。归集器内部也有一道硬停止:runAnalyticsScrapeSweepenv.CLOUD_MODE下直接返回空结果,scrapeServerIfStale同样——共享的分析读取绝不能从 SaaS 拨号到租户的服务器(analytics-scraper.ts)。

无论哪种形态,仪表盘只读取一种形状:一个解析器报告项目的流量来源是自托管还是云,标签页本身并不知道差异。

延伸阅读

  • 边缘日志处理器完整实现:packages/adapters/src/infra/lua/site_logger.lua
  • 共享字典尺寸声明与 OpenResty 配置生成:packages/adapters/src/infra/openresty-lua.ts
  • 真实客户端 IP 恢复:packages/adapters/src/infra/edge-real-ip.ts
  • Top Paths 开关推送与重断言:apps/api/src/modules/analytics/analytics-config.service.ts
  • 30 分钟归集任务:apps/api/src/modules/system/analytics-scraper.ts
  • 资源采样任务:apps/api/src/modules/monitoring/usage-sampler.ts
  • 任务注册与 cron 定义:apps/api/src/modules/jobs/job.registry.ts
  • 遥测隐私测试:packages/adapters/src/infra/site-logger-privacy.test.ts

【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询