主题
图片服务与存储
8080 端口的图片 WAR 只剩两个上传/删除接口和一堆静态资源;真正的图片存储早已收敛到 S3,本地 HTTP 多节点上传实现被同名 Bean 覆盖后废弃,但配置键、目录挂载与清理任务仍按"本地落盘"时代的形态保留着。
1. 职责与形态
captcha-image 是 WAR,容器端口 8080,源码仅 5 个 Java 文件(api/UploadController、api/entity/ApiResponse、api/entity/RetCode、service/ImageProcessService、service/TokenUtils)。它承担三件事:
| 职责 | 载体 | 现状 |
|---|---|---|
| 图片上传/删除接收入口 | /api/image/upload、/api/image/delete | 可用,但仅在使用"本地落盘"模式时被调用 |
| 图片实际存储 | S3UploadService(在 captcha-base,被所有模块共享) | 当前唯一生效实现 |
| 静态资源分发 | webapp/static/**(JS SDK)、war 内 image/**(图标/降级图/空间素材) | 由 war 打包(captcha-image/pom.xml:128-156) |
Servlet 映射是 /*(captcha-image/src/main/webapp/WEB-INF/web.xml:37-40),且 application-context-image.xml 只做了 component-scan(排除 @Controller)与 image.properties 装载,没有声明 <mvc:resources>(captcha-image/src/main/resources/application-context-image.xml:17-28)。因此 /image/*、/static/* 的静态可达性完全依赖容器默认 Servlet 行为,属部署时必须实测的项,不能只看 部署说明.md 里的"可直接访问"。
2. 接口清单
| 方法 | 路径 | 入参 | 返回 | 位置 |
|---|---|---|---|---|
| POST | /api/image/upload | file(MultipartFile) | {code:0, data:"<文件名>"} | captcha-image/src/main/java/com/netease/is/nc/image/api/UploadController.java:37-62 |
| POST | /api/image/delete | key(文件名) | {code:0, data:"<key>"} | captcha-image/src/main/java/com/netease/is/nc/image/api/UploadController.java:65-78 |
| GET | /image/** | — | 静态图片 | war 内 image 目录 |
| GET | /static/** | — | JS SDK、latest.json | captcha-image/src/main/webapp/static/ |
返回码只有三个(captcha-image/src/main/java/com/netease/is/nc/image/api/entity/RetCode.java:11-13):
| code | 枚举 | 语义 |
|---|---|---|
| 0 | OK | 成功 |
| 1 | PARAM_ERROR | file 为空 |
| 2 | SERVICE_ERROR | 落盘 IO 异常 / 删除失败 |
上传行为细节:未带文件名时用 TokenUtils.newUUIDToken() + ".jpg" 兜底并额外打一条 info 日志(UploadController.java:46-49);同名文件先删再写(ImageProcessService.java:53-57);写入用 FileUtils.writeByteArrayToFile 而非 ImageIO.write,注释明确"don't use ImageIO.write, quality lost"(:60-62)。
这个接口没有任何鉴权:web.xml 里只有编码 Filter(captcha-image/src/main/webapp/WEB-INF/web.xml:10-22),TokenUtils 只剩 newUUIDToken() / getKey() 两个纯字符串方法(captcha-image/src/main/java/com/netease/is/nc/image/service/TokenUtils.java:15-25),并无签名校验。知识库文档中"上传需 token 鉴权"的描述与当前代码不符——能访问 8080 的人就能写文件,交付时应靠网络层限制。
3. S3UploadService 为何是唯一生效实现
三份实现都在 captcha-base/src/main/java/com/netease/is/nc/base/upload/,共用接口 ImageUploadService:
| 类 | 注解 | 说明 |
|---|---|---|
S3UploadService | @Lazy + @Component(value = "httpUploadService")(S3UploadService.java:29-32) | 包装 AWS S3 客户端,afterPropertiesSet 建 AmazonS3(:141-162) |
HttpUploadService | //@Component(value = "httpUploadService") 被注释掉(HttpUploadService.java:29-31) | 按 image.upload.server 逐节点 POST /api/image/upload(:95-129) |
ImageUploadService | 接口,含 getUrlByKey 与 getDownloadUrlByKey 默认方法 | 调用方统一依赖接口 |
Bean 名两者都是 httpUploadService:HttpUploadService 的注解被注释后不再注册,S3UploadService 以同一个旧名字注册,于是全仓所有 @Resource ImageUploadService / @Resource(name="httpUploadService") 注入的都是 S3 实现——例如清理任务里仍写着 @Resource(name = "httpUploadService")(captcha-schedule/src/main/java/com/netease/is/nc/scheduler/job/clean/ImageCleanUpWorker.java:37-38),实际删的是 S3 对象。
本地 HTTP 上传被废弃的原因,代码与文档都指向同一点:多节点各自本地落盘 → 各节点只持有自己收到的那份图。旧实现的兜底也印证这个坑——节点列表为空直接抛"上传图片失败"(HttpUploadService.java:110-115),任一节点失败整体抛异常(:124-127),节点漏配就表现为前端"偶发取不到图"。改用对象存储后,一致性由对象存储保证,多节点同步问题整体消失。
4. image.properties 逐项解析
captcha-image/src/main/resources/image.properties 全文只有一行:
properties
upload_file_path=/java-web-dir/custom-image/| 键 | 含义 | 约束 |
|---|---|---|
upload_file_path | ImageProcessService 的落盘根目录 | 为空/不存在时 @PostConstruct 自动 mkdirs()(ImageProcessService.java:35-44);必须挂持久卷,否则 Pod 重启即丢;多副本必须 RWX 共享,否则各副本只见自己的文件 |
模板文件 captcha-image/src/main/resources/conf/image.properties.template 用于交付前覆盖,application-context-image.xml:18-24 同时注册了 PropertyPlaceholderConfigurer(ignoreUnresolvablePlaceholders=true)与 imageProperties 两个视图,代码里用 #{imageProperties['upload_file_path']} 取值。注意 image.properties 未纳入版本管理(仓库里只有 template + 一个本地版本),部署前必须确认目标环境已覆盖。
与图片相关的其余键都在 captcha-base/src/main/resources/base.properties:
| 键 | 现值示例 | 作用 |
|---|---|---|
s3.accessKey / s3.secretKey | — | S3 凭证(S3UploadService.java:36-40) |
s3.endPoint / s3.region | s3.ap-east-1.amazonaws.com / ap-east-1 | 端点与区域 |
s3.bucketName | yiduncaptchatest | 桶名;putObject 用它(:74) |
s3.prefix | 未配置(bean['s3.prefix'] 取不到即为 null) | 只作用于 getUrlByKey:tempUrl = prefix + bucketName(:56-61),而 putObject 始终用 bucketName。一旦配上 prefix 就会出现"上传成功但返回 URL 指向另一个桶"的错位,配前必须让 prefix 与桶命名约定一致 |
image.download.server | https://s3.ap-east-1.amazonaws.com/yiduncaptchatest | server 侧拼 CDN/取图地址用(captcha-server/src/main/java/com/netease/is/nc/server/abst/AbstractController.java:74-75) |
image.upload.server | http://nc-image-private.test.dun.163.com | 仅旧 HttpUploadService 与 schedule 旧方案使用,现为遗留键 |
static.server / getconf.static.servers | 指向 image 域名 | JS SDK 静态资源地址下发 |
5. 取图 URL 结构与鉴权
对象 key 的生成规则固定为 "image/" + fileName(S3UploadService.java:169-175,未传名则 UUID + .jpg)。客户端最终拿到的地址有两条构造路径,当前生效的是第二条:
S3UploadService.getUrlByKey(key):pathStyleAccessEnabled(true)(:149)→https://{endPoint}/{bucket or prefix+bucket}/image/{file};- server 侧
getCdnUrlByKey(captchaId, key):String.format("%s/image/%s", imageDomain, key),其中imageDomain默认取image.download.server,可被sys_conf中按 captchaId 配置的图片域名覆盖(captcha-server/src/main/java/com/netease/is/nc/server/abst/AbstractController.java:347-353)。
所以稳定形态是 https://s3.ap-east-1.amazonaws.com/yiduncaptchatest/image/<uuid>.jpg,把 image.download.server 换成客户 CDN 即可整体切流,代码不用动。
鉴权与防盗链必须分清责任边界:
| 关注点 | 现状 | 证据 |
|---|---|---|
| 取图鉴权 | 无:对象为匿名可读(桶策略决定),image 模块也不校验 | §2 已说明无 Filter/Security |
| 防盗链 | 只在 server 取图接口 上做 referer 域名校验,且仅对 RunEnv.WEB / UNKNOWN 生效;域名清单优先取 sys_conf,回退 security.restrict.domains | AbstractController.java:403-409(isDomainAllowAccess),配置见 captcha-base/src/main/resources/base.properties:36 |
| 缓存头 | 仓库内没有任何 Cache-Control / Expires 设置;静态资源沿容器默认,S3 对象沿桶/前端 CDN 默认 | 全仓无相关配置 |
| 上传鉴权 | 无(见 §2) | captcha-image/src/main/webapp/WEB-INF/web.xml:10-22 |
结论:防盗链是"取图 API 的参数校验",不是图片 URL 级别的保护;一旦图片域名直连且桶公开可读,绕过 SDK 直接拉图无法阻止。要真正防盗链,得在对象存储/CDN 层配置 referer 白名单或签名 URL(当前代码不生成签名 URL)。
6. 集群同步机制与代码路径
| 方案 | 机制 | 代码/配置 | 状态 |
|---|---|---|---|
| 对象存储(现行) | 所有节点写同一个桶,读同一个桶,天然一致 | S3UploadService + s3.* | 生效 |
| 方案一:多节点逐个上传 | schedule 读 image.upload.server 节点列表,循环 POST 每个节点的 /api/image/upload | HttpUploadService.sendData / sendFileData(captcha-base/src/main/java/com/netease/is/nc/base/upload/HttpUploadService.java:89-129)+ captcha-schedule/src/main/resources/produce.properties 的 image.upload.server | 已停用(@Component 被注释) |
| 方案二:共享存储 | 各节点把 upload_file_path 挂到同一 ceph/nfs 目录 | captcha-image/部署说明.md:33-35 | 可选,需客户提供共享存储 |
生产者的调用链是统一的:captcha-schedule 与 captcha-server 都通过 ImageUploadService 注入 → uploadBytes / uploadJpegBytes / uploadPngBytes(captcha-schedule/src/main/java/com/netease/is/nc/scheduler/abst/AbstractCreateService.java:174、:222、:302-334)→ S3 putObject。改上传实现只需换 Bean,调用方零改动——这也是当年能平滑切 S3 的结构原因。
7. 持久卷、容量与清理
| 项 | 要求 | 依据 |
|---|---|---|
| 需要持久卷的模块 | 仅 captcha-image(且只在本地落盘模式下需要) | captcha-image/src/main/resources/image.properties:1、部署说明.md:16-25 |
| 目录 | /java-web-dir/custom-image/ | 同上 |
| 多副本 | 必须 RWX(共享存储)或改用对象存储 | 本地目录各副本独立 |
| 其余写目录 | /tmp、日志、.runtime/applib 可用 emptyDir | 无状态 |
清理由 captcha-schedule 承担,与存储实现无关(它对接口编程):
ImageCleanUpWorker:@Scheduled(fixedDelay = 30000),每批 300 条,循环拉取"已失效"的nc_image_output记录,再调imageUploadService.deleteFile(key)删对象(captcha-schedule/src/main/java/com/netease/is/nc/scheduler/job/clean/ImageCleanUpWorker.java:53-68、:170)。fixedDelay语义是"上一轮结束后再等 30 秒",避免任务叠加。- 行为日志索引由
EsIndexCleanUpWorker按retention.days=180删过期按日索引,与图片无关但常被一起问。
容量估算口径:单张拼图/点选底图量级在百 KB 级,库存量由 captcha-schedule 的预生成参数决定;对象存储模式下容量压力落在桶上,本地模式下才需要盯磁盘水位。
8. 风险清单
| 风险 | 说明 | 建议 |
|---|---|---|
| 上传接口裸奔 | /api/image/upload、/api/image/delete 无鉴权、无大小限制校验 | 网络层限制来源,或前置网关白名单 |
s3.prefix 语义不对称 | putObject 忽略 prefix,getUrlByKey 使用 prefix | 不要配 prefix,或统一改造 |
| 落盘目录 = 打包目录的错觉 | /image 由 war 内目录提供,upload_file_path 在 war 之外,二者不自动打通 | 本地模式下必须做软链/挂载映射,否则"上传成功但取不到" |
| 静态可达性未验证 | DispatcherServlet 映射 /* 且无 mvc:resources | 部署后必须实测 /image/<某图> 与 /static/latest.json |
| 遗留键误导 | image.upload.server / image.download.server 名字相似、用途不同,前者已废 | 交付文档中明确标注,避免客户把上传地址填成 image.upload.server |
| 凭证明文 | base.properties 里 S3 AK/SK 为明文,且仓库中带真实测试凭证 | 交付时用 ConfigMap 覆盖并向客户申请独立密钥,禁止沿用测试凭证 |