From e907cecb16b7f371216ec421d302cc26826e32f3 Mon Sep 17 00:00:00 2001
From: Ryan Wang
Date: Fri, 30 Oct 2026 16:12:22 +0800
Subject: [PATCH 1/6] feat: display IP locations for comments and replies
---
README.md | 7 +
build.gradle | 47 ++++++
dev/development.md | 6 +
.../comment-widget/src/base-comment-item.ts | 5 +
packages/comment-widget/src/comment-item.ts | 1 +
.../src/generated/locales/es.ts | 1 +
.../src/generated/locales/zh-CN.ts | 1 +
.../src/generated/locales/zh-TW.ts | 1 +
packages/comment-widget/src/reply-item.ts | 1 +
packages/comment-widget/src/types/index.ts | 1 +
.../tests/ip-location.browser.test.js | 76 ++++++++++
packages/comment-widget/xliff/es.xlf | 4 +
packages/comment-widget/xliff/zh-CN.xlf | 4 +
packages/comment-widget/xliff/zh-TW.xlf | 4 +
.../comment/widget/SettingConfigGetter.java | 3 +-
.../location/CommentIpLocationReconciler.java | 26 ++++
.../IpLocationBackfillReconciler.java | 95 +++++++++++++
.../widget/location/IpLocationResolver.java | 119 ++++++++++++++++
.../widget/location/IpLocationService.java | 71 ++++++++++
.../location/ReplyIpLocationReconciler.java | 26 ++++
src/main/resources/extensions/settings.yaml | 5 +
.../IpLocationBackfillReconcilerTest.java | 103 ++++++++++++++
.../location/IpLocationResolverTest.java | 57 ++++++++
.../location/IpLocationServiceTest.java | 134 ++++++++++++++++++
24 files changed, 797 insertions(+), 1 deletion(-)
create mode 100644 packages/comment-widget/tests/ip-location.browser.test.js
create mode 100644 src/main/java/run/halo/comment/widget/location/CommentIpLocationReconciler.java
create mode 100644 src/main/java/run/halo/comment/widget/location/IpLocationBackfillReconciler.java
create mode 100644 src/main/java/run/halo/comment/widget/location/IpLocationResolver.java
create mode 100644 src/main/java/run/halo/comment/widget/location/IpLocationService.java
create mode 100644 src/main/java/run/halo/comment/widget/location/ReplyIpLocationReconciler.java
create mode 100644 src/test/java/run/halo/comment/widget/location/IpLocationBackfillReconcilerTest.java
create mode 100644 src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java
create mode 100644 src/test/java/run/halo/comment/widget/location/IpLocationServiceTest.java
diff --git a/README.md b/README.md
index f4f1aab..e3dde3f 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
- 可针对所有用户、匿名用户或指定角色启用验证,ALTCHA 支持手动和自动验证模式
- 支持 Gravatar 头像,可自定义头像服务镜像地址和适用用户范围
- 支持显示评论者设备信息
+- 支持离线解析并显示评论和回复的 IP 属地,开启后自动补齐历史评论
- 支持通过 CSS 变量自定义样式,适配明亮和暗黑模式
## 使用方式
@@ -27,6 +28,12 @@
> 需要注意的是,此插件需要主题进行适配,不会主动在内容页加载评论组件。
+## IP 属地
+
+在插件「基本设置」中开启「显示 IP 属地」,评论和回复会显示发表时 IP 对应的省份(中国大陆)或国家/地区。默认关闭,内网、无效或无法定位的 IP 不显示属地。新评论异步解析,历史评论分批补齐;缺少原始 IP 的记录无法补齐。反向代理需要正确传递客户端 IP,否则只能解析代理的地址。
+
+归属地由随插件打包的 [ip2region](https://github.com/lionsoul2014/ip2region) IPv4/IPv6 数据库离线解析,不发送 IP 给第三方。结果保存后不会随数据库升级自动改写。关闭开关仅隐藏组件中的展示,已保存的地区仍可能通过公开接口读取;原始 IP 不写入属地元数据。
+
## 主题适配
主题可通过 Halo 的自定义标签接入评论组件,并通过 CSS 变量自定义样式和配色。
diff --git a/build.gradle b/build.gradle
index 74e7613..0492e0a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,6 +27,7 @@ dependencies {
compileOnly 'run.halo.app:api'
implementation 'org.altcha:altcha:2.0.3'
implementation 'org.json:json:20240303'
+ implementation 'org.lionsoul:ip2region:3.3.7'
testImplementation 'run.halo.app:api'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
@@ -39,7 +40,53 @@ test {
sourceSets.main.resources.exclude 'ui/**', 'static/**'
+// Pin the data separately from the Java client; updates must refresh these checksums.
+def ipLocationRevision = '6a276cc7c4f9f2d7ff1d8ace14b91af59752f943'
+def ipLocationFiles = [
+ 'ip2region_v4.xdb': '46249ad1b33bcaa4cc37475b93746cec2b00acffc3316cc4f56167ac821ea2ad',
+ 'ip2region_v6.xdb': '939f6b46bd2b8bec3cf7c5ceb8ba782266ae9b1f35b5ba7916700dec0b7506ed',
+ 'LICENSE.txt': 'fe01f2f8fcaafac539154e6aa80b0b7f8af54e01dc4d52322f72971991c6280e'
+]
+def ipLocationResources = layout.buildDirectory.dir('generated/ip-location')
+tasks.register('prepareIpLocationDatabase') {
+ inputs.property('revision', ipLocationRevision)
+ inputs.property('checksums', ipLocationFiles)
+ outputs.dir(ipLocationResources)
+ doLast {
+ def directory = ipLocationResources.get().dir('ip-location').asFile
+ directory.mkdirs()
+ ipLocationFiles.each { name, checksum ->
+ def target = new File(directory, name)
+ def digest = { File file ->
+ def sha = java.security.MessageDigest.getInstance('SHA-256')
+ file.withInputStream { stream ->
+ byte[] buffer = new byte[8192]
+ int count
+ while ((count = stream.read(buffer)) != -1) {
+ sha.update(buffer, 0, count)
+ }
+ }
+ sha.digest().encodeHex().toString()
+ }
+ if (!target.exists() || digest(target) != checksum) {
+ def path = name == 'LICENSE.txt' ? 'LICENSE.md' : "data/${name}"
+ def connection = URI.create("https://raw.githubusercontent.com/lionsoul2014/ip2region/${ipLocationRevision}/${path}").toURL().openConnection()
+ connection.connectTimeout = 30000
+ connection.readTimeout = 60000
+ connection.inputStream.withCloseable { input ->
+ target.withOutputStream { output -> output << input }
+ }
+ if (digest(target) != checksum) {
+ throw new GradleException("IP location database checksum mismatch: ${name}")
+ }
+ }
+ }
+ }
+}
+
tasks.named('processResources', Copy) {
+ dependsOn tasks.named('prepareIpLocationDatabase')
+ from(ipLocationResources)
dependsOn project(':packages').tasks.named('pnpmBuild')
from(project(':packages').layout.projectDirectory.dir('ui/build/dist')) {
into 'ui'
diff --git a/dev/development.md b/dev/development.md
index 6086aeb..ee5c10a 100644
--- a/dev/development.md
+++ b/dev/development.md
@@ -22,3 +22,9 @@ cd path/to/plugin-comment-widget
```
Halo 插件的详细开发文档可查阅 [插件开发](https://docs.halo.run/category/%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91)。
+
+## IP 属地数据库
+
+`processResources` 会通过 `prepareIpLocationDatabase` 下载固定提交的 ip2region IPv4/IPv6 数据库和许可证,校验 SHA-256 后打入 JAR。更新数据时需要同时修改 `build.gradle` 中的提交和校验值,并运行属地解析测试。数据库生成在 `build/generated/ip-location`,不提交二进制文件。
+
+运行时首次解析会将数据库解压到临时目录(约 47 MiB),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。
diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts
index 9227d86..1372cdb 100644
--- a/packages/comment-widget/src/base-comment-item.ts
+++ b/packages/comment-widget/src/base-comment-item.ts
@@ -43,6 +43,9 @@ export class BaseCommentItem extends LitElement {
@property({ type: String })
ua: string | undefined;
+ @property({ type: String })
+ ipLocation: string | undefined;
+
@property({ type: Boolean })
private: boolean | undefined;
@@ -107,6 +110,8 @@ export class BaseCommentItem extends LitElement {
A comment
', + approved: true, + creationTime: '2026-10-30T00:00:00Z', + // Even privileged callers must never display an IP as a location fallback. + ipAddress: '1.0.1.1', + }, + owner: { displayName: 'Reader' }, + stats: { upvote: 0 }, + status: { visibleReplyCount: 0 }, + }; +} + +async function mount(tag, showIpLocation, ipLocation) { + const host = document.createElement('div'); + new ContextProvider(host, { + context: configMapDataContext, + initialValue: { basic: { showIpLocation } }, + }); + const item = document.createElement(tag); + item.comment = target('comment', ipLocation); + if (tag === 'reply-item') item.reply = target('reply', ipLocation); + host.append(item); + document.body.append(host); + await item.updateComplete; + const base = item.shadowRoot.querySelector('base-comment-item'); + await base.updateComplete; + return { host, item, base }; +} + +for (const tag of ['comment-item', 'reply-item']) { + test(`${tag} displays location only when enabled and available`, async () => { + for (const enabled of [true, false, undefined]) { + for (const location of ['浙江省', undefined]) { + const { host, base } = await mount(tag, enabled, location); + const badge = base.shadowRoot.querySelector('.item-ip-location'); + expect(!!badge).toBe(!!enabled && !!location); + if (badge) expect(badge.textContent).toContain('浙江省'); + expect(base.shadowRoot.textContent).not.toContain('1.0.1.1'); + host.remove(); + } + } + }); + + test(`${tag} renders annotations as text and reacts to refreshed data`, async () => { + const { item, base } = await mount( + tag, + true, + '