news 2026/9/13 1:43:58

Cherry Studio DataApi 系统详解:从 Renderer 到 SQLite 的类型安全 IPC 数据管道

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cherry Studio DataApi 系统详解:从 Renderer 到 SQLite 的类型安全 IPC 数据管道

Cherry Studio DataApi 系统详解:从 Renderer 到 SQLite 的类型安全 IPC 数据管道

【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio

DataApi 是 Cherry Studio 主进程与渲染进程之间用于业务数据读写的类型安全 IPC 通信体系:它把 React 组件中的查询与变更直接映射为主进程内 Handler → Service → SQLite(Drizzle ORM)的标准分层调用,并提供 RESTful 语义、自动重试、按需取数与跨窗口数据变更通知。读完本文,你将掌握 DataApi 的架构分层、使用边界、Renderer 端四大 Hook 的完整用法、Main 端 Handler/Service 的编写规范,以及从 Schema 定义到错误处理的端到端实践。

DataApi 的定位:只服务"业务数据"

DataApi 面向的数据必须同时满足以下特征(见>// schemas/topic.ts export type TopicSchemas = { '/topics': { GET: { response: PaginatedResponse<Topic> } POST: { body: CreateTopicDto; response: Topic } } '/topics/:id': { GET: { params: { id: string }; response: Topic } } }

Schema 组合时经AssertValidSchemas做编译期校验(定义于 types.ts):只允许合法 HTTP 方法,且每个端点必须声明response字段,缺失即编译报错:

// schemas/apiSchemas.ts export type ApiSchemas = AssertValidSchemas<TopicSchemas & MessageSchemas>

类型系统由此获得三项能力:

  • 路径解析api.get('/topics/abc123')能自动映射到 Schema 路径/topics/:id,TypeScript 知道返回Topic
  • Handler 穷尽性检查ApiImplementation类型保证 Schema 中每个端点都有对应 Handler,缺失即编译错误。
  • 类型安全客户端ApiClient提供完整推导的方法——api.post('/topics', { body: { name: 'New' } })的 body 被强类型为CreateTopicDto

Schema 文件组织规则

Schema 文件按被操作/返回实体的领域组织,而非 URL 前缀。父资源只起到作用域作用,不决定归属文件:

路由返回实体归属文件
/topics/:topicId/messagesMessagemessages.ts
/topics/:topicId/treeTree(Message 派生视图)messages.ts
/topics/:id/active-nodeActiveNodeResponse(Topic 状态)topics.ts

当路由的 URL 父级与返回实体不一致时,以实体为准

分页类型

  • 请求参数:OffsetPaginationParamspage?limit?)、CursorPaginationParamscursor?limit?,cursor 是排他边界,游标项本身不返回)、SortParamsSearchParams
  • 响应类型:OffsetPaginationResponse<T>itemstotalpage)、CursorPaginationResponse<T>itemsnextCursor?)、两者联合的PaginationResponse<T>(用isOffsetPaginationResponse/isCursorPaginationResponse收窄)。

各分页 Hook 会把自己的路径泛型约束到匹配的分页形态——把 cursor 路径传给usePaginatedQuery或把 offset 路径传给useInfiniteQuery编译期错误,而不是静默的运行期挂起。完整的 offset vs cursor 选型与游标线格式(<key>:<id>)见 分页指南。

Renderer 端:四大 Hook 的完整用法

React 端入口是@data/hooks/useDataApi(实现见 useDataApi.ts),基于 SWR 提供缓存与自动重新校验。

useQuery(GET 请求)

import { useQuery } from '@data/hooks/useDataApi' // 基础用法 const { data, isLoading, error } = useQuery('/topics') // 带查询参数 const { data: messages } = useQuery('/messages', { query: { topicId: 'abc123', page: 1, limit: 20 } }) // 路径参数(从路径自动推断) const { data: topic } = useQuery('/topics/abc123') // 条件取数 const { data } = useQuery('/topics', { enabled: !!topicId }) // 手动刷新 const { data, mutate, refetch } = useQuery('/topics') refetch() // 或 await mutate()

useMutation(POST/PUT/PATCH/DELETE)

import { useMutation } from '@data/hooks/useDataApi' // 创建(POST)——返回 201 const { trigger: createTopic, isLoading } = useMutation('POST', '/topics') const newTopic = await createTopic({ body: { name: 'New Topic' } }) // 整体替换(PUT) const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123') await replaceTopic({ body: { name: 'Updated Name', description: '...' } }) // 局部更新(PATCH) const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123') await updateTopic({ body: { name: 'New Name' } }) // 删除(Handler 返回 undefined 时自动推断 204) const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123') await deleteTopic() // 成功后自动刷新其他查询 const { trigger } = useMutation('POST', '/topics', { refresh: ['/topics'], // 成功后刷新这些缓存键 onSuccess: (data) => logger.info('Created:', data) })

数据 Hook 返回的函数(triggerinvalidaterefetchnextPageprevPagereset等)在重渲染间保持稳定身份,与 SWR 自身的mutate/trigger行为一致——可以直接放进useCallback/useEffect依赖数组,无需用 ref 包裹或从依赖中省略。useMutationtrigger通过 ref 读取 options,因此内联的options对象不会导致其身份频繁变动。

useInfiniteQuery(游标无限滚动)

适用于"加载更多"式无限滚动 UI。Hook 暴露原始pages数组,消费方用useInfiniteFlatItems推导扁平列表,并显式选择与端点分页形态及容器布局匹配的顺序——永远不要假设页加载顺序等于条目显示顺序:

import { useInfiniteQuery, useInfiniteFlatItems } from '@data/hooks/useDataApi' // 简单信息流:第 0 页最新、页内倒序——页序与显示序一致 const { pages, hasNext, loadNext, isLoading } = useInfiniteQuery('/feed') const items = useInfiniteFlatItems(pages) // 分支遍历 + column-reverse 聊天容器:第 0 页最新、页内升序。 // reverseItems: true 翻转每页,使扁平输出为最新在前,直接喂给倒置布局 const { pages, hasNext, loadNext } = useInfiniteQuery('/topics/:topicId/messages', { params: { topicId } }) const messages = useInfiniteFlatItems(pages, { reverseItems: true }) const activeNodeId = pages[0]?.activeNodeId ?? null // 顶层元数据,无需强转 // 非 column-reverse 容器中按时间升序渲染:翻转页序 const items = useInfiniteFlatItems(pages, { reversePages: true })

pages在 SWR 底层数据未变时跨重渲染引用稳定,因此useInfiniteFlatItems(pages)可以跳过重复计算。

usePaginatedQuery(offset 翻页)

适用于带上一页/下一页控件的逐页导航:

import { usePaginatedQuery } from '@data/hooks/useDataApi' const { items, page, total, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics', { limit: 10 })

分页 Hook 选型

使用场景Hook
无限滚动、聊天、信息流useInfiniteQuery
翻页导航、表格usePaginatedQuery
手动控制useQuery

动态路径与缓存失效

Hook 既接受具体路径(id 已内联,如/providers/abc123),也接受模板路径:placeholders+ 独立params选项)。两种形式产生逐字节一致的 SWR 缓存键,因此一种形式读取、另一种形式刷新依然保持一致:

// 具体路径——id 在调用方稳定(props、Hook 参数) const { data } = useQuery(`/providers/${providerId}`) // 模板路径——单个 Hook 实例在生命周期内处理不同 id //(侧边栏列表、命令面板、URL 处理器、循环中的行级操作) const { data } = useQuery('/providers/:providerId', { params: { providerId } }) const { trigger } = useMutation('DELETE', '/providers/:providerId/api-keys/:keyId', { refresh: ({ args }) => [ `/providers/${args.params.providerId}`, `/providers/${args.params.providerId}/api-keys` ] }) await trigger({ params: { providerId, keyId } })

选择依据<ProviderSettings providerId={id}>这种 props 稳定的 id 用具体路径;侧边栏"删除任意 Provider"、命令面板、URL 处理器等处理任意 id 的场景用模板路径;.map()内的行级操作每个 Hook 绑定一行,用具体路径。

模板 useMutation 并发触发警告useSWRMutation按路径管理isMutating/error状态,所以单个模板路径实例的所有 params 共享 loading 状态。在同一 Hook 实例上并发触发不同 id 会互相覆盖状态。正确做法是每行挂载一个绑定具体路径的 Hook。开发模式下,params 变化的并发触发会打印警告。

refresh 的三种形式与避坑

refresh声明 mutation 成功后要失效的 SWR 缓存键:

  1. 静态路径(精确匹配)refresh: ['/topics'],仅失效该键,用于确切知道受影响路径且与输入输出无关时。
  2. /*后缀(前缀匹配)refresh: ({ args }) => ['/providers',/providers/${args.params.providerId}/*],失效该资源下所有子路径。前缀中自动保留的尾部斜杠可防止/providers-archived这类兄弟路径的误匹配。/*的独特价值在于失效mutation 并不知道其 id 的子路径实例
  3. 函数形式(动态键)refresh: ({ args }) => [...]refresh: ({ result }) => [...],键依赖触发参数或服务端响应时使用。

必须避免的误用

  • 不要用/*做全缓存重置——['/*']/m*这类短前缀在开发模式会抛错,必须写完整路径段;
  • 静态数组够用时不要用函数形式(额外运行开销且掩盖意图);
  • 不要对高基数列表用/*(如/messages/*会重新校验所有打开窗口中的每个消息级查询),应改用携带具体父 id 的函数形式(/topics/${id}/messages);
  • 同一模块内不要混用模板路径与辅助函数表达同一资源;
  • refresh只针对 DataApi 键——非 SQLite 数据(Cache、Preference)有自己的失效机制。

Main 端实现:Handler 与 Service

编写 Handler

Handler 位于src/main/data/api/handlers/,职责只有三条:从请求提取参数、委托业务 Service、为 IPC 转换响应。每个模块的 Handler 记录必须标注HandlersFor<XxxSchemas>类型(这是该目录所有文件的标准形态,不是可选约定),它强制两条不变量:路径被收窄到本模块 Schema 内(拼写错误、跨模块泄漏均编译报错);Schema 声明的每个path + method都必须有 Handler(新增端点而无对应 Handler 是编译错误)。

// handlers/topics.ts import { topicService } from '@data/services/TopicService' import type { HandlersFor } from '@shared/data/api/types' import type { TopicSchemas } from '@shared/data/api/schemas/topics' export const topicHandlers: HandlersFor<TopicSchemas> = { '/topics': { GET: ({ query }) => { const { page = 1, limit = 20 } = query ?? {} return topicService.list({ page, limit }) }, POST: ({ body }) => { return topicService.create(body) } }, '/topics/:id': { GET: ({ params }) => topicService.getById(params.id), PUT: ({ params, body }) => topicService.replace(params.id, body), PATCH: ({ params, body }) => topicService.update(params.id, body), DELETE: ({ params }) => { topicService.delete(params.id) } } }

随后在handlers/apiHandlers.ts中合并注册:

export const allHandlers: ApiImplementation = { ...topicHandlers, ...messageHandlers }

编写 Service

Service 位于src/main/data/services/,承担业务校验、事务协调、领域工作流与 Drizzle ORM 数据访问。作用域硬性限制:DataApi Service 的领域工作流只能编排 SQLite 读写,绝不允许出现 fs/网络/进程/外部服务副作用——即使旁边紧跟着合法的数据库写入、即使嵌套得再深也不行(见 API 设计指南 —— 无非数据副作用硬规则)。

一个标准 Entity Service 的骨架(注意getById先查后改、validateTopicData业务校验、错误统一走DataApiErrorFactory):

// services/TopicService.ts import { eq, desc, sql } from 'drizzle-orm' import { application } from '@application' import { topicTable } from '@data/db/schemas/topic' import { DataApiErrorFactory } from '@shared/data/api/errors' export class TopicService { private get db() { return application.get('DbService').getDb() } list(options: { page: number; limit: number }) { const { page, limit } = options const offset = (page - 1) * limit const items = this.db.select().from(topicTable) .orderBy(desc(topicTable.updatedAt)) .limit(limit).offset(offset).all() const countResult = this.db.select({ count: sql<number>`count(*)` }) .from(topicTable).all() return { items, total: countResult[0].count, page, limit } } getById(id: string) { const topic = this.db.select().from(topicTable) .where(eq(topicTable.id, id)).limit(1).get() if (!topic) { throw DataApiErrorFactory.notFound('Topic', id) } return topic } create(data: CreateTopicDto) { this.validateTopicData(data) const topic = this.db.insert(topicTable).values(data).returning().get() return topic } update(id: string, data: Partial<UpdateTopicDto>) { this.getById(id) // 不存在则抛错 const topic = this.db.update(topicTable) .set(data).where(eq(topicTable.id, id)).returning().get() return topic } delete(id: string) { this.getById(id) // 不存在则抛错 this.db.delete(topicTable).where(eq(topicTable.id, id)).run() } private validateTopicData(data: CreateTopicDto) { if (!data.name?.trim()) { throw DataApiErrorFactory.validation({ name: ['Name is required'] }) } } } export const topicService = new TopicService()

上述list()展示的是offset 分页形态;真实TopicService是游标分页(listByCursor)。两种服务端分页模式与选择依据见 分页指南。游标(keyset)列表的编解码必须复用services/utils/keysetCursor.ts中的decodeListCursor/encodeCursor<key>:<id>线格式)与keysetOrdering(keyCol, idCol, { major, tie }),禁止手写游标编解码、keyset WHERE 元组或 ORDER BY。

事务与跨 Service 表访问

带事务的 Service 方法写法如下(主库使用同步 better-sqlite3,事务回调与其中的 Drizzle 调用必须保持同步,返回 Promise 非法):

createTopicWithMessage(data: CreateTopicWithMessageDto) { return application.get('DbService').withWriteTx((tx) => { const [topic] = tx.insert(topicTable).values(data.topic).returning().all() const [message] = tx.insert(messageTable).values({ ...data.message, topicId: topic.id }).returning().all() return { topic, message } }) }

每张表有且仅有一个属主 Service,按访问类型拆分规则:

  • (insert/update/delete)他人拥有的表:禁止。必须调用属主的方法(事务写入时把tx作为第一个参数传入;属主的变更方法接受Pick<DbType, 'delete' | 'insert' | ...>类型的最小操作集作为首参)。形状缺失时在属主上加方法,批量需求加批量方法(如purgeForEntitiesTx)。
  • 他人拥有的表:当内联是更简单的路径时允许。跨表 JOIN 一次往返合并属主表是合理的;只有读取需要属主已封装的业务逻辑时才走属主的读 API。

为什么写要严格:属主 Service 是表不变量的唯一真相源(唯一索引、orderKey语义、软删除、审计时间戳),并负责发出变更日志。外部写会把这些知识分散到每个调用方,同时让日志叙事失声。例如ProviderService.delete应调用pinService.purgeForEntitiesTx(tx, 'model', ids),而不是直接tx.delete(pinTable).where(...)

事务方法命名约定tx必须是第一个参数、方法名以Tx结尾、参数类型用Pick<DbType, '...'>只声明所需最小操作。可选的非 Tx 包装方法(薄db.transaction(...))仅在调用方需要拥有事务时提供。

循环依赖用dataServiceRegistry解决:当两个 Service 互相调用(A→B 且 B→A)时,顶层import { bService } from './BService'会形成值级导入环。不要用调用点的await import('./BService')掩盖——那会把调用方传染成 async、把边界从静态工具面前隐藏。正确做法是:兄弟模块底部自注册registerDataService('BService', bService),调用方在调用时getDataService('BService')解析。Registry 只以import type导入 Service,因此在导入图中是汇点,不会形成值环。只有真正成环的 Service 才进 registry,其余保持直接导入单例。单元测试驱动跨 Service 路径时必须先副作用导入兄弟模块(import '@data/services/BService')使其自注册,否则getDataService会抛 "not registered yet"。契约见 dataServiceRegistry.ts。

行 → 实体映射(rowToEntity)

每个 Entity Service 提供rowToEntity,用nullsToUndefined(来自services/utils/rowMappers.ts)完成 SQLiteNULL→ TypeScriptundefined的翻译。标准骨架:

function rowToMcpServer(row: typeof mcpServerTable.$inferSelect): McpServer { const clean = nullsToUndefined(row) return { ...clean, type: clean.type as McpServer['type'], // 收窄枚举 createdAt: timestampToISO(row.createdAt), updatedAt: timestampToISO(row.updatedAt) } }

判断规则:领域字段类型是T | null→ 直接用row.x(绕过clean,因为nullsToUndefined会把顶层null收窄为undefined,破坏T | null契约);领域字段类型是T?T→ 用clean.x(或...clean。当存在字段重命名、计算/合并字段、敏感数据脱敏(如apiKeys剥离)、判别式联合按变体剥离字段等复杂场景时,rowToEntity应手写而非依赖 spread。rowToEntity内出现row.x ?? '🌟'之类的兜底默认值是禁止的反模式——它的存在恰恰证明该列应该是带 DB DEFAULT 或$defaultFn的 NOT NULL。完整约定见>import { useDataChange } from '@data/hooks/useDataApi' // 保守的列表收敛:任何信号 → 重新拉取 const { refetch } = useQuery('/topics') useDataChange('/topics', () => refetch()) // 多端点,每次通知合并为一次回调 useDataChange(['/topics', '/topics/latest'], () => refreshAll()) // 按 ID 收敛:用 entityIds 过滤(缺省 = 不声明 = 视为相关) useDataChange('/topics/:id', (effects) => { if (effects.some((e) => !e.entityIds || e.entityIds.includes(myId))) mutate() }) // 非 React 代码:Service 上同样的设施(返回取消订阅函数) const unsubscribe = dataApiService.onDataChanged('/topics', (effects) => { ... })

语义要点(由 Phase A 契约冻结):

  • 精确端点匹配——无前缀/通配订阅;effects 由endpoint+ 可选kindprojection/membership/order)+dimension+entityIds组成;
  • 一次业务操作 = 一次回调——同一条通知的所有匹配条目合并到一次调用,不做跨通知聚合;
  • 端点之下全是消费者策略——dimension/entityIds 过滤、收敛选择、对自身写入回声的幂等处理都由消费者负责(发起窗口也会收到自己的信号);
  • 提示只收窄、不豁免——省略dimension/entityIds表示"未声明 → 假定相关",绝不表示"无影响";
  • 尽力投递——只投给存活且持续订阅的渲染进程(每窗口 FIFO)。订阅注册之前(含主进程引导期)的变更不会被补发,恢复手段是端点下一次变更、重新挂载或任何一次全新查询。

错误处理体系

错误体系集中在 errors.ts:ErrorCode枚举映射 HTTP 状态码,DataApiError类带可重试性检测,DataApiErrorFactory提供一致的错误创建。关键 API:

import { DataApiError, DataApiErrorFactory, ErrorCode } from '@shared/data/api/errors' // 推荐:工厂方法 throw DataApiErrorFactory.notFound('Topic', id) throw DataApiErrorFactory.validation({ name: ['Name is required'] }) throw DataApiErrorFactory.database(error, 'insert topic') throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') throw DataApiErrorFactory.conflict('Topic name already exists') throw DataApiErrorFactory.timeout('fetch topics', 3000) // 可重试性判断(自动重试逻辑使用) if (error instanceof DataApiError && error.isRetryable) { await retry(operation) } // 客户端错误 vs 服务端错误 if (error.isClientError) { /* 4xx:请求本身的问题 */ } else if (error.isServerError) { /* 5xx:服务端问题 */ }

自动视为可重试的错误码:SERVICE_UNAVAILABLE(503)、TIMEOUT(504)、RATE_LIMIT_EXCEEDED(429)、DATABASE_ERROR(500)、INTERNAL_SERVER_ERROR(500)、RESOURCE_LOCKED(423)。

IPC 序列化边界:错误经 IPC 传输时使用SerializedDataApiError结构(codemessagestatusdetails?requestContext?),堆栈不随 IPC 传输,排障依赖主进程日志;toJSON()/fromJSON()负责序列化与重建。

SQLite 约束翻译:Service 写库时,SQLite 约束违规(UNIQUE、FOREIGN KEY、CHECK、NOT NULL)以DrizzleQueryError抛出,真实错误埋在.cause链中。用withSqliteErrors(来自src/main/data/db/sqliteErrors.ts)统一翻译为DataApiError

import { defaultHandlersFor, withSqliteErrors } from '@data/db/sqliteErrors' const [row] = withSqliteErrors( () => this.db.insert(tagTable).values(dto).returning().all(), defaultHandlersFor('Tag', dto.name) )

defaultHandlersFor覆盖常见 CRUD 场景(UNIQUE → 409、FK → 404、CHECK/NOT NULL → 422),需要时可按种类展开覆盖;未识别的错误原样重抛。

Handler 状态码行为

ApiServer自动推断成功状态码:POST 恒为 201;DELETE 在 Handler 返回undefined时为 204、返回数据时为 200;GET/PUT/PATCH 恒为 200。需要自定义时返回{ data, status }(如return { data: task, status: SuccessStatus.ACCEPTED }返回 202),且status的类型被约束为合法的SuccessStatusCode,传 999 会编译报错。使用SuccessStatus常量(OK/CREATED/ACCEPTED/NO_CONTENT)避免魔法数字。完整状态码语义表(200/201/202/204/400/401/403/404/409/422/423/429/500/503/504)见 api-design-guidelines.md。

自动重试、超时与按需取数机制

Renderer 端 DataApiService 是纯通信设施(项目注释明确:它是 API Client / Gateway,类似 axios/fetch,零业务逻辑):

  • 自动重试:默认maxRetries: 2retryDelay: 1000msbackoffMultiplier: 2(指数退避);是否重试由DataApiError.isRetryable决定,4xx 客户端错误直接跳过;可用configureRetry()覆盖默认值。
  • 请求超时:默认 3 秒,通过Promise.raceDataApiErrorFactory.timeout(path, 3000)实现,过期请求自动取消(stale request 自动清理)。
  • 按需取数:不维护自动缓存层,每次useQuery都是即时拉取(SWR 仅作去重与缓存键管理);显式refresh/mutate控制失效。
  • 开发环境下DataApiDevtools记录每次请求的 start/success/error/retry 时间线,便于排查。

新增端点的完整步骤

在现有代码库中新增一个 DataApi 端点遵循五步(详见 contenteditable="false">【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 1:43:11

COLMAP + IMU 位姿估计实战指南:3 步把轨迹误差压到 1/3

COLMAP IMU 位姿估计实战指南&#xff1a;3 步把轨迹误差压到 1/3 【免费下载链接】colmap COLMAP - Structure-from-Motion and Multi-View Stereo 项目地址: https://gitcode.com/GitHub_Trending/co/colmap 无人机在树冠间快速穿行&#xff0c;机身一晃&#xff0c;…

作者头像 李华
网站建设 2026/9/13 1:41:33

Pixelle-Video 如何用 Docker Compose 部署并检查服务健康状态?

Pixelle-Video 如何用 Docker Compose 部署并检查服务健康状态&#xff1f; 【免费下载链接】Pixelle-Video &#x1f680; AI 全自动短视频引擎 | AI Fully Automated Short Video Engine 项目地址: https://gitcode.com/GitHub_Trending/pi/Pixelle-Video Pixelle-Vid…

作者头像 李华
网站建设 2026/9/13 1:41:21

STM32F429驱动DS1307:I2C实时时钟芯片驱动开发与调试实战

简介&#xff1a;围绕STM32F429与DS1307实时时钟芯片的I2C通信例程&#xff0c;面向嵌入式初学者及需要快速上手STM32外设开发的工程师。内容涵盖GPIO复用模式配置、I2C外设初始化、读写时序实现&#xff0c;以及通过HAL库完成时间设置与读取的完整思路&#xff0c;可作为学习I…

作者头像 李华