news 2026/10/9 13:02:47

SpringBoot集成OCR实战:选型、异步处理与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot集成OCR实战:选型、异步处理与避坑指南

简介:面向Spring Boot开发者的OCR功能集成示例,适合已有Java基础、正为Web系统增加文字识别能力的开发者,演示如何将Tesseract或云端OCR服务嵌入应用,解决图片文字提取、票据与文档自动识别等场景问题。压缩包仅9KB,共7个文件,包含3个Java源文件、1个XML配置、1个properties配置文件,另附mvnw与cmd启动脚本;Java源码对应控制器与识别逻辑,XML和properties承担依赖与运行参数配置,启动脚本便于本地验证,源码、配置与运行入口齐全,结构精炼,便于快速通读。当前已有410人学习下载。通过该示例,可理清依赖引入、REST接口设计、图片上传、调用OCR服务与结果解析的完整衔接,理解本地引擎与云API两种接入方式的差异;示例对文件安全校验、异步处理等生产环节的提示,也为后续扩展为高可用识别服务提供了实用参考。整体上是一份轻量、可运行的入门范本。

1. SpringBoot集成OCR功能demo:它到底解决什么问题

SpringBoot集成OCR功能demo,一句话讲就是让后端服务接住上传的图片,调用本地OCR引擎把文字识别出来,再通过HTTP接口交还给调用方。多数人以为难点在识别率,真正动手才发现坑在调用方式、超时控制、并发和临时文件上。单张图片跑通很简单,一并发就卡死、一中文就乱码、一重启就堆磁盘,这些才是让demo变成可用系统前必须蹚平的路。这篇适合刚拿到图片识别需求的后端开发者,也适合给老系统补OCR能力的全栈工程师,目标是让你从零建起一个能接入真实业务流程的OCR接口,而不是停留在截图教程。

2. 选型先行:OCR引擎、调用方式与SpringBoot的边界

2.1 三种OCR集成路线:离线命令行、本地SDK、云端API

先亮结论:demo和正式项目,我都建议从离线命令行切入。原因不是命令行识别率最高,而是它把“OCR引擎”和“业务代码”用进程边界隔开,Java进程不会因为引擎崩溃而陪葬。下面这张表是三条路线的对比,维度直接对应落地时最关心的几件事。

路线部署成本单张延迟升级/更换风险并发控制成本
离线命令行低,一个可执行文件加语言包中,进程启动加识别耗时低,换命令换参数即可低,外部进程数与线程池一致
本地SDK/JNI绑定中,原生库依赖复杂低,省去进程启动高,引擎版本和Java库强耦合高,锁和线程安全容易踩坑
云端API零安装,但要申请密钥高,多一次网络往返取决于服务商接口变动受配额限制,并发上不去要钱

补充一个判断维度:如果你的图片以中文为主,离线引擎需要额外准备中文语言包;如果只是英文票据,老牌开源引擎默认英文模型就够用。云端API在demo阶段看着省事,但网络超时、鉴权、配额三件事会频繁打断你的开发节奏。离线命令行把这些都变成本地可控因素,排错路径短得多。

2.2 为什么用进程调用而不是JNI:隔离与可换引擎

从JVM角度看,JNI把原生库和Java堆放进同一个进程。一旦OCR引擎内部有未捕获的异常指针,整个SpringBoot进程都会退出,而且日志基本看不到原因。用ProcessBuilder调用独立进程,引擎崩溃后Java侧只收到非0退出码或信号,问题可诊断,引擎也可替换。

调用层做成统一接口后,前端的实现可以是老牌C/C++离线程序,也可以是深度学习框架导出的命令行工具,两者都吐文本。只要参数对齐,升级引擎对业务代码透明。这一层值得在demo里就做出来,别等以后换引擎再重构。另外,命令行模式天然支持“先跑通再封装——你可以在shell里验证引擎行为,确认无误后再写Java调用,开发效率比直接怼JNI高不少。

2.3 本地环境准备:先把引擎跑通,再写Java

我习惯的顺序是:先装引擎,跑通一条命令行;再写Java调用;最后才碰SpringBoot。很多demo在Java里报错,最后定位到引擎还没装好,白白浪费时间。

# 1. 检查JDK与Maven,SpringBoot 2.7要求JDK8/11,Maven 3.6+ java -version mvn -version # 2. 安装OCR引擎,装着后用which确认可执行文件已进PATH # 如果部署机不便改PATH,记下绝对路径,后面配置里直接用 which ocr-engine || /opt/ocr/ocr-engine --version # 3. 先用一张带文字的PNG验证命令行,能输出文字再继续 ocr-engine --lang chi_sim --psm 6 --output txt sample.png

参数说明:--lang指定识别语言,chi_sim是中文简体语言包的标识;--psm 6表示把整张图当作一个文本块,适合单段落验证。如果图片是横排表格或分栏,psm要按引擎实际支持的值调整,逐档试错。这里的ocr-engine是占位命令,换成你选定引擎的真实命令即可。

注意:引擎安装路径不要放在含空格或中文的目录下。ProcessBuilder是把命令按列表传给系统的,不走shell,空格路径不一定出错,但会让排查变难,先避开这个隐患。

2.4 选型的边界:什么时候该换路线

离线命令行不是万能解。如果单张图片识别耗时超过3秒且日均请求量上万,命令行每次启动进程的开销会变得刺眼,这时要么改成引擎进程常驻,要么评估云端API的批量识别模式。再一个边界是语言扩展:离线引擎新增小语种往往要重新训练模型,而云端API通常开箱即用。我的建议是:技术验证和中小流量用离线命令行,团队没人愿意维护引擎、图片格式又杂时再考虑云端。

3. 搭一个能跑的SpringBoot OCR服务:从配置到接口

3.1 pom.xml与application.yml:先定上传上限、超时与临时目录

搭建这个demo的核心依赖只有Web和参数校验。模板引擎、ORM都不需要,OCR引擎是外部进程,不参与Java堆内计算。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

版本号选2.7.x是因为它适配JDK8到17,社区维护周期长;如果你的脚手架生成的是3.x,配置属性基本兼容,注意Jackson序列化行为有些差异。下一步是application.yml,这里面四个参数决定了这个OCR接口能不能扛住实际使用。

spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB ocr: engine: command: /opt/ocr/ocr-engine timeout-seconds: 30 working-dir: /var/tmp/ocr/run temp: dir: /var/tmp/ocr/incoming keep-hours: 2

参数说明:max-file-size限制单文件大小,max-request-size限制整个请求体,双重约束避免恶意大包打爆内存。command用绝对路径,别依赖PATH变量,生产环境部署方式会频繁改环境变量。timeout-seconds是引擎识别的最长等待时间,超过就杀进程,防止单张图把接口拖死。working-dir是引擎子进程的工作目录,temp.dir是上传图片的暂存目录,两者分开,方便各自设置清理策略。目录要先建好,应用账号要有写权限。

3.2 用配置类绑定参数,别把路径散落在Controller里

@Component @ConfigurationProperties(prefix = "ocr") public class OcrProperties { private final Engine engine = new Engine(); private final Temp temp = new Temp(); public static class Engine { private String command; private int timeoutSeconds = 30; private Path workingDir; // getter / setter 省略 } public static class Temp { private Path dir; private int keepHours = 2; // getter / setter 省略 } // getter 方法省略 }

逻辑说明:@ConfigurationProperties把yml里ocr.*的值绑定成强类型对象,Controller和Service只依赖OcrProperties,不反复读字符串常量。后期改引擎命令或超时,只动yml,不动Java代码。这里没有把@Value写在每个字段上,因为字段多了之后那种写法又散又难维护。

3.3 封装引擎调用层:用ProcessBuilder接住超时与崩溃

@Component public class OcrEngineClient { private final OcrProperties props; public OcrEngineClient(OcrProperties props) { this.props = props; } public String recognize(Path imagePath) { ProcessBuilder pb = new ProcessBuilder( props.getEngine().getCommand(), "--lang", "chi_sim", "--psm", "6", "--output", "txt", imagePath.toString() ); pb.directory(props.getEngine().getWorkingDir().toFile()); pb.redirectErrorStream(true); try { Process p = pb.start(); boolean finished = p.waitFor(props.getEngine().getTimeoutSeconds(), TimeUnit.SECONDS); if (!finished) { p.destroyForcibly(); throw new OcrTimeoutException("OCR引擎执行超时"); } try (BufferedReader reader = new BufferedReader( new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { return reader.lines().collect(Collectors.joining("\n")).trim(); } } catch (IOException e) { throw new OcrRuntimeException("OCR引擎启动失败", e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new OcrRuntimeException("等待OCR结果时被中断", e); } } }

逻辑说明:pb.start()只是启动进程,真正要等的是waitFor(timeout),这一步是demo最容易漏的。redirectErrorStream(true)把标准错误合并到标准输出,避免Java读stdout时管道被stderr写满导致进程阻塞,这是常见的隐性死锁。读取一律用UTF_8,否则Windows或部分Linux环境下中文会乱码。拿到结果后trim掉首尾空白,空白识别结果不等于没文字,可能是语言包没装,日志里要单独记一条。

3.4 图片上传与基础校验:格式、大小与文件名

private static final Set<String> ALLOWED_EXT = Set.of("png", "jpg", "jpeg", "bmp"); public Path saveUpload(MultipartFile file) { if (file == null || file.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件为空"); } String original = file.getOriginalFilename(); String suffix = getSuffix(original); if (!ALLOWED_EXT.contains(suffix)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "不支持的图片格式"); } if (file.getSize() <= 0 || file.getSize() > 10 * 1024 * 1024) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件大小非法"); } try { Path dir = props.getTemp().getDir(); Files.createDirectories(dir); Path target = dir.resolve("ocr_" + UUID.randomUUID() + "." + suffix); file.transferTo(target); return target; } catch (IOException e) { throw new OcrRuntimeException("保存上传文件失败", e); } } private String getSuffix(String name) { if (name == null || !name.contains(".")) { return ""; } return name.substring(name.lastIndexOf('.') + 1).toLowerCase(); }

这段处理的不是识别精度,而是把明显不合理的图片拦在引擎之前。生成文件名用UUID,而不是保留原始名,是为了绕开中文、空格和潜在路径穿越。只限制扩展名而不校验图片内容,是因为引擎会对损坏图片返回空结果,没必要在后端用ImageIO完整解码一遍,那反而可能触发OOM。

3.5 同步识别接口:demo阶段先跑通链路

@RestController @RequestMapping("/api/ocr") public class OcrController { private final OcrEngineClient engineClient; private final OcrProperties props; public OcrController(OcrEngineClient engineClient, OcrProperties props) { this.engineClient = engineClient; this.props = props; } @PostMapping("/sync") public String sync(@RequestParam("file") MultipartFile file) throws IOException { Path tmp = saveUpload(file); try { return engineClient.recognize(tmp); } finally { Files.deleteIfExists(tmp); } } }

同步接口的优势是预览阶段好调试,你可以在浏览器或curl里一发就拿到结果,引擎参数调优效率高。finally里删临时文件是必须的,不然跑几次测试就多几个文件。这个接口只保证“能跑通”,不保证“能上生产”,因为HTTP请求线程会一直占用到识别结束,下一章就是解决这个问题的。

4. 同步接口迟早翻车:给OCR加线程池、超时与排队

4.1 一个30秒的慢请求如何拖垮Tomcat

Tomcat默认max-threads是200。OCR单张耗时3到30秒不等,同步接口下每个请求占一个Tomcat线程30秒,20个并发就把线程池占掉大半,其他普通接口开始排队。真正问题不在识别本身,而在把慢操作放在HTTP请求线程里。异步化不是可选项,是必经之路。这个认知越早建立,后面返工越少。

4.2 自建线程池:corePoolSize、队列与拒绝策略

@Bean("ocrExecutor") public ExecutorService ocrExecutor() { int cores = Runtime.getRuntime().availableProcessors(); return new ThreadPoolExecutor( cores, Math.max(cores * 2, 4), 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(256), r -> new Thread(r, "ocr-worker-" + counter.incrementAndGet()), new ThreadPoolExecutor.CallerRunsPolicy() ); }

参数说明:OCR是CPU密集加外部进程等待型任务,core设为CPU核数即可。core太小,多张图排队时间长;core太大,多个引擎进程同时抢CPU,识别速度反而下降。队列256是等待识别的图片数,不是并发数。CallerRunsPolicy表示队列满后由提交线程自己执行,对OCR这种宁可慢也不要丢任务的场景是合适的。ThreadFactory里用AtomicInteger计数,日志里能清楚看出是哪个线程在处理。

4.3 把识别变成任务ID:用CompletableFuture替代同步阻塞

@Service public class OcrTaskService { private final ConcurrentHashMap<String, CompletableFuture<String>> taskStore = new ConcurrentHashMap<>(); private final OcrEngineClient engineClient; private final ExecutorService ocrExecutor; public OcrTaskService(OcrEngineClient engineClient, ExecutorService ocrExecutor) { this.engineClient = engineClient; this.ocrExecutor = ocrExecutor; } public String submit(Path imagePath) { String taskId = UUID.randomUUID().toString(); CompletableFuture<String> future = CompletableFuture.supplyAsync( () -> engineClient.recognize(imagePath), ocrExecutor ); future.whenComplete((result, error) -> { try { Files.deleteIfExists(imagePath); } catch (IOException ignored) { // 删除失败交给定时清理兜底 } }); taskStore.put(taskId, future); return taskId; } public CompletableFuture<String> getFuture(String taskId) { return taskStore.get(taskId); } }

逻辑说明:supplyAsync把识别任务丢给线程池,主线程立即返回taskId,HTTP请求秒回。whenComplete里做临时文件清理,避免finally遗忘。ConcurrentHashMap存Future只适合单节点部署,多实例时要换成Redis或数据库,但任务状态流转的思路一致。这个结构也方便后续加“识别结果缓存”:相同图片再次提交时,直接返回历史结果。

4.4 轮询还是回调:demo选轮询,代码最少

轮询的代价是调用方要主动拉结果,好处是服务端不需要外呼接口,联调成本低。回调需要对方提供一个接收地址,如果对方是前端页面,跨域和网络穿透都是麻烦。WebSocket实时性最好,但给一个demo增加维护成本。我一般建议demo和初期版本都先做轮询。

@PostMapping("/submit") public Map<String, String> submit(@RequestParam("file") MultipartFile file) { Path tmp = saveUpload(file); String taskId = taskService.submit(tmp); return Map.of("taskId", taskId); } @GetMapping("/tasks/{taskId}") public Map<String, Object> query(@PathVariable String taskId) { CompletableFuture<String> future = taskService.getFuture(taskId); if (future == null) { return Map.of("status", "not_found"); } if (future.isDone()) { return Map.of("status", "done", "text", future.getNow("")); } return Map.of("status", "running"); }

注意Map.of是Java 9以后的写法,如果项目还在Java 8,换成HashMap手动put。这里没有用Spring的@Async,原因有两个:@Async基于代理,同类内部调用会失效,排查起来绕弯子;线程池满了之后的降级行为也难精细控制。显式注入ExecutorService,所有路径都在你眼皮底下。

5. 常见问题与避坑排查:OCR集成高频故障与修复

5.1 引擎进程启动失败,退出码非0

表现是Java侧抛IOException,消息里带error=2,或者waitFor返回非0。定位这个问题的第一步是把Java里的命令字符串原封不动放到shell里,用应用账号身份执行一遍。常见原因是command用了相对路径,而JVM的工作目录和引擎安装目录不一致;也可能是引擎依赖的动态库没找到。

sudo -u webapp /opt/ocr/ocr-engine --version

如果shell能跑通而Java不行,检查ProcessBuilder里传参是否拆得正确。ProcessBuilder不走shell,所有参数都是原样传递,不存在引号嵌套问题,但也因此不会自动去PATH里找命令。建议command写成绝对路径,工作目录用pb.directory()显式指定,启动前加一行日志把命令和目录打出来。

5.2 中文识别结果乱码,Java字符串变成问号

表现是命令行直接跑引擎输出正常,Java读出来全是???,或者SpringBoot接口返回JSON后前端拿到乱码。前者是流读取编码问题,后者是响应编码被某个过滤器改了。解决方式是把InputStreamReader硬编码为UTF_8,不要用平台默认编码;引擎输出参数里如果有encoding选项,显式设为UTF_8。SpringBoot这边,确认spring.http.encoding.force-response没被改成其他字符集。排查时先用curl看响应头里的charset,再用jstack看进程内是否有多个编码过滤器在打架。

5.3 临时文件堆积,把磁盘打满

表现是/var/tmp/ocr/incoming下不断出现ocr_开头的文件,磁盘使用率持续上涨。原因是删除只写了成功路径,异常路径直接抛出去,finally块没覆盖到;或者识别线程被destroyForcibly杀掉时,文件还没处理完。解决方式是把清理逻辑放到独立定时任务里,扫描超过keep-hours的文件直接删除。

@Scheduled(fixedDelay = 3600_000) public void cleanExpiredFiles() { long now = System.currentTimeMillis(); long maxAge = props.getTemp().getKeepHours() * 3600_000L; try (Stream<Path> files = Files.list(props.getTemp().getDir())) { files.filter(p -> { try { return now - Files.getLastModifiedTime(p).toMillis() > maxAge; } catch (IOException e) { return false; } }).forEach(p -> p.toFile().delete()); } catch (IOException e) { log.warn("清理OCR临时目录失败", e); } }

注意@Scheduled需要启动类加@EnableScheduling。这个定时任务的坑在于它可能删掉正在识别中的图片,所以临时文件的命名里要带taskId,扫描时过滤掉taskStore里还存在的任务,或者干脆把保留时间放宽到大于最大超时时间。

5.4 并发一高识别就卡死,接口大面积超时

表现是单张测试秒出,压测打到20并发时所有任务都超时,引擎进程CPU很闲。发动机引擎只有单进程实例,多个线程同时调用同一个引擎,底层库内部可能死锁;或者线程池core设太大,20个进程同时起来,CPU和内存都被抢完。解决方式是在Service层加一个信号量限制引擎的并发访问数,比如Semaphore(2),超过就排队,而不是无脑起线程。

压测时盯三个指标:引擎进程数、系统Load、队列深度。Load高而引擎进程少,说明线程池overhead过大;引擎进程多而CPU空闲,说明引擎内部在等待IO或锁。线程池参数要根据实测回调,不是照抄博客里的数字。

5.5 超大图导致JVM内存OOM

表现是传一张几十MB的高清长图,接口报OutOfMemoryError,严重时整台机器被OOM Killer杀掉。这里的坑在于文件体积和像素尺寸是两回事:文件5MB的长图,解码成BufferedImage可能占几百MB。所以只限制文件大小远远不够。

try (ImageInputStream iis = ImageIO.createImageInputStream(tmpFile); ImageReader reader = ImageIO.getImageReaders(iis).next()) { reader.setInput(iis); int width = reader.getWidth(0); int height = reader.getHeight(0); if ((long) width * height > 4000L * 4000L) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "图片像素超限,请压缩后重试"); } }

这个手法用ImageReader只读取图片元数据,不把像素加载进内存,所以不会有解码OOM风险。真正的图片压缩交给前端做,后端只做防线。如果你们的产品不允许直接拒绝用户,至少要在返回信息里写明“请将图片缩放到4000像素以内”。

6. 验证与进阶:从demo到能扛住真实流量的雏形

6.1 上线前先跑三个验证

先验证同步接口,确保引擎参数正确;再验证异步提交与轮询,确保任务状态流转没断;最后用并发脚本测线程池。下面这条命令是demo阶段够用的验证组合。

# 1. 单图验证:确认结果里出现预期关键字 curl -F "file=@sample.png" http://localhost:8080/api/ocr/sync # 2. 异步提交拿taskId,再轮询结果 curl -F "file=@sample.png" http://localhost:8080/api/ocr/submit curl http://localhost:8080/api/ocr/tasks/你的taskId # 3. 并发验证:50个并发提交,观察线程池是否有拒绝 for i in $(seq 1 50); do curl -F "file=@sample.png" http://localhost:8080/api/ocr/submit & done wait

6.2 两个值得先做的进阶点

一个是识别结果缓存。同一张图片被重复提交很常见,按文件内容算SHA-256,命中后直接返回上次文本,能省掉大部分引擎消耗。缓存建议放到外部存储,进程内缓存容量难控制,而且识别结果会占用堆内存。另一个是引擎进程常驻化。ProcessBuilder每次启动引擎要几百毫秒,等QPS上到2以上就变成瓶颈。常见方案是预启动N个引擎进程,Java通过标准输入或本地端口把图片路径传进去,再异步读结果。这个改造复杂度不算低,但收益是单次识别从“进程启动加识别”降到“纯识别”。

6.3 一个值得养成的习惯

我习惯把外部进程调用统一收敛到一个包里,业务代码只依赖接口和参数对象,不直接碰ProcessBuilder。每次调整引擎参数只改yml,不碰Java代码;每次更换引擎只替换一个实现类,业务层无感。这样一个demo交付出去,接手的同事不会在Controller里翻出一堆进程调用代码。希望帮到你。

本文还有配套的精品资源,点击获取

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

WinForm自定义滚动条:GDI+全接管绘制与交互实现

简介&#xff1a;本资源是一份面向C# WinForm开发者的自定义滚动条控件实践项目&#xff0c;聚焦解决原生VScrollBar/HScrollBar外观单一、难以适配UI主题的问题。通过继承重写OnPaint方法&#xff0c;完整实现了拖块颜色、轨道颜色的自由配置&#xff0c;并支持线条与矩形两种…

作者头像 李华
网站建设 2026/10/9 13:00:30

JavaWeb超市订单管理系统课设:从四层架构到答辩避坑全指南

简介&#xff1a;基于Javaweb的超市订单管理系统课程设计项目&#xff0c;是一份面向计算机专业学生的完整课设参考方案&#xff0c;涵盖了登录鉴权、供应商管理、订单管理、用户管理等典型业务模块。压缩包共135个文件&#xff0c;包含25个Java源文件、24个JSP页面、24个JavaS…

作者头像 李华
网站建设 2026/10/9 12:58:24

微信聊天记录训练专属聊天机器人:从数据解密到模型部署

简介&#xff1a;这是一份基于Python利用微信聊天记录训练专属聊天机器人的项目资源包&#xff0c;面向高校毕业设计、课程设计以及想构建个性化对话模型的开发者&#xff0c;解决“如何将个人聊天数据转化为可交互AI模型”这一课题&#xff0c;覆盖数据解密、清洗、训练与调用…

作者头像 李华
网站建设 2026/10/9 12:57:05

pstack-claude:用进程栈破解Claude Code卡顿之谜

如果你跟我一样&#xff0c;把 Claude Code 当成日常项目的编程副驾&#xff0c;一定遇到过这种场景&#xff1a;任务跑到一半&#xff0c;终端像死机一样卡住&#xff0c;光标闪烁&#xff0c;日志也不更新。重启吧&#xff0c;舍不得进度&#xff1b;不重启吧&#xff0c;又不…

作者头像 李华
网站建设 2026/10/9 12:54:19

C语言获取并设置鼠标位置:GetCursorPos 与 SetCursorPos 实战大纲

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华