diff --git a/README.md b/README.md
index f4f1aab..007192b 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
- 可针对所有用户、匿名用户或指定角色启用验证,ALTCHA 支持手动和自动验证模式
- 支持 Gravatar 头像,可自定义头像服务镜像地址和适用用户范围
- 支持显示评论者设备信息
+- 支持显示评论和回复的 IP 属地
- 支持通过 CSS 变量自定义样式,适配明亮和暗黑模式
## 使用方式
@@ -27,6 +28,12 @@
> 需要注意的是,此插件需要主题进行适配,不会主动在内容页加载评论组件。
+## IP 属地
+
+在插件「基本设置」中开启「显示 IP 属地」,即可在评论和回复中展示属地:中国大陆显示省份,其他地区显示国家或地区,无法识别时显示「未知」。开启后,历史评论也会逐步补充可识别的属地。
+
+此功能默认关闭,属地信息由 [ip2region](https://github.com/lionsoul2014/ip2region) 提供,仅供参考。关闭功能只会隐藏展示,不会删除已保存的属地信息。
+
## 主题适配
主题可通过 Halo 的自定义标签接入评论组件,并通过 CSS 变量自定义样式和配色。
diff --git a/build.gradle b/build.gradle
index 74e7613..3993ca2 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,89 @@ test {
sourceSets.main.resources.exclude 'ui/**', 'static/**'
+// Pin the source data and official maker together; updates must refresh the checksum.
+def ipLocationRevision = '6a276cc7c4f9f2d7ff1d8ace14b91af59752f943'
+def ipLocationChecksum = '06db87f5525f9db4ae7881f8799462cce76c0c264123153525506bce5d577ac2'
+def ipLocationArchive = layout.buildDirectory.file('ip-location-maker/source.zip')
+def ipLocationSource = layout.buildDirectory.dir('ip-location-maker/upstream')
+def ipLocationResources = layout.buildDirectory.dir('generated/ip-location')
+def downloadIpLocationSource = tasks.register('downloadIpLocationSource') {
+ inputs.property('revision', ipLocationRevision)
+ inputs.property('checksum', ipLocationChecksum)
+ outputs.file(ipLocationArchive)
+ doLast {
+ def target = ipLocationArchive.get().asFile
+ target.parentFile.mkdirs()
+ 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) != ipLocationChecksum) {
+ def connection = URI.create("https://codeload.github.com/lionsoul2014/ip2region/zip/${ipLocationRevision}").toURL().openConnection()
+ connection.connectTimeout = 30000
+ connection.readTimeout = 60000
+ connection.inputStream.withCloseable { input ->
+ target.withOutputStream { output -> output << input }
+ }
+ if (digest(target) != ipLocationChecksum) {
+ throw new GradleException('IP location source checksum mismatch')
+ }
+ }
+ }
+}
+def extractIpLocationSource = tasks.register('extractIpLocationSource', Sync) {
+ dependsOn downloadIpLocationSource
+ from({ zipTree(ipLocationArchive.get().asFile) }) {
+ include '*/maker/java/src/main/java/**', '*/data/ipv4_source.txt',
+ '*/data/ipv6_source.txt', '*/LICENSE.md'
+ eachFile { path = path.substring(path.indexOf('/') + 1) }
+ includeEmptyDirs = false
+ }
+ into ipLocationSource
+}
+def compileIpLocationMaker = tasks.register('compileIpLocationMaker', JavaCompile) {
+ dependsOn extractIpLocationSource
+ source ipLocationSource.map { it.dir('maker/java/src/main/java') }
+ classpath = files()
+ destinationDirectory = layout.buildDirectory.dir('ip-location-maker/classes')
+}
+def prepareIpLocationDatabase = tasks.register('prepareIpLocationDatabase')
+[4, 6].each { version ->
+ def database = ipLocationResources.map { it.file("ip-location/ip2region_v${version}.xdb") }
+ def sourceData = ipLocationSource.map { it.file("data/ipv${version}_source.txt") }
+ def generateDatabase = tasks.register("generateIpLocationV${version}", JavaExec) {
+ dependsOn compileIpLocationMaker
+ classpath = files(compileIpLocationMaker.flatMap { it.destinationDirectory })
+ mainClass = 'org.lionsoul.ip2region.MakerApp'
+ maxHeapSize = '512m'
+ inputs.file(sourceData)
+ inputs.property('fields', '0,1,4')
+ outputs.file(database)
+ doFirst {
+ database.get().asFile.parentFile.mkdirs()
+ args = ["--src=${sourceData.get().asFile}", "--dst=${database.get().asFile}",
+ "--version=ipv${version}", '--field-list=0,1,4']
+ }
+ }
+ prepareIpLocationDatabase.configure { dependsOn generateDatabase }
+}
+
tasks.named('processResources', Copy) {
+ dependsOn tasks.named('prepareIpLocationDatabase')
+ from(ipLocationResources) {
+ include 'ip-location/*.xdb'
+ }
+ from(ipLocationSource.map { it.file('LICENSE.md') }) {
+ into 'ip-location'
+ rename { 'LICENSE.txt' }
+ }
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..47b59e3 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 源码归档并校验 SHA-256,使用官方 Java maker 的 `--field-list=0,1,4` 从 IPv4/IPv6 数据中仅保留国家、省份、国家代码,再将精简库和许可证打入 JAR。maker 使用项目的 JDK 编译,仅在构建时运行,不打入插件。更新数据时需要同时修改 `build.gradle` 中的提交和归档校验值,并运行属地解析测试。数据库生成在 `build/generated/ip-location`,不提交二进制文件。
+
+运行时首次解析会将数据库解压到临时目录(约 23 MiB),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。
diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts
index 9227d86..4e16d51 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;
@@ -105,6 +108,8 @@ export class BaseCommentItem extends LitElement {
${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html`
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 or unknown only when enabled`, 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); + if (badge) expect(badge.textContent).toContain(location || 'Unknown'); + 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, + '