diff --git a/README.md b/README.md index f2b3306..7b9ea06 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,11 @@ novel-plus 5.x 已集成 Spring 官方最新发布的 Spring AI 框架,并推 1. v5.0.0 版本在小说章节发布页面的文本编辑器中集成了多项智能编辑功能,包括 AI 扩写、缩写、续写及文本润色等。这些功能的设计灵感来源于百家号文章编辑器中的 AI 助手。 2. v5.1.0 版本在小说发布页面,新增 AI 生成封面图功能。若作家未上传自定义封面图,系统将根据小说信息自动生成封面图。 -目前,AI 功能仍处于实验阶段,仅实现了基础的核心功能。我们非常重视用户的实际使用体验和反馈,未来将根据用户需求和使用情况,持续优化和调整该功能。如果用户反馈积极,我们计划进一步开发更高级的 -AI 功能,例如自动生成有声小说、智能情节推荐等,以全面提升 novel-plus 的创作能力和用户体验。 +目前,AI 功能仍处于实验阶段,仅实现了基础的核心功能。我们非常重视用户的实际使用体验和反馈,未来将根据用户需求和使用情况,持续优化和调整该功能。如果用户反馈积极,我们计划进一步开发更高级的 AI 功能,例如自动生成有声小说、智能情节推荐等,以全面提升 novel-plus 的创作能力和用户体验。 我们将持续关注 AI 技术的发展,并致力于将其与小说创作场景深度融合,为用户带来更智能、更便捷的创作工具。 -由于 DeepSeek 官方 API 目前不可用,novel-plus 项目默认使用的是第三方[硅基流动](https://cloud.siliconflow.cn/i/DOgMRH9S) -提供的 API,采用的 AI 模型有对话模型`deepseek-ai/DeepSeek-R1-Distill-Llama-8B`(DeepSeek-R1 的蒸馏版本,免费使用)和生图模型`Kwai-Kolors/Kolors`(快手 Kolors 团队开发的文本到图像生成模型,免费使用)。只需注册一个硅基流动账号,创建一个 -API 密钥,并将其添加到 novel-plus 项目 novel-front 模块的 yaml 配置文件中,即可体验 novel-plus 项目的 AI 写作功能。 +novel-plus 项目默认使用的是第三方大模型服务平台[硅基流动](https://cloud.siliconflow.cn/i/DOgMRH9S)提供的 API(兼容 OpenAI 的相关接口,可直接通过 Spring AI 框架调用),采用的 AI 模型有对话模型`deepseek-ai/DeepSeek-R1-Distill-Llama-8B`(DeepSeek-R1 的蒸馏版本,免费使用)和生图模型`Kwai-Kolors/Kolors`(快手 Kolors 团队开发的文本到图像生成模型,免费使用)。只需注册一个硅基流动账号,创建一个 API 密钥,并将其添加到 novel-plus 项目 novel-front 模块的 yaml 配置文件中,即可体验 novel-plus 项目的 AI 写作功能。 ```yaml spring: @@ -104,7 +101,7 @@ spring: model: deepseek-ai/DeepSeek-R1-Distill-Llama-8B ``` -> ⚠️ novel-plus 项目默认使用的都是免费 AI 模型,生成效果有限。如果对生成内容有更高的要求,建议选用付费的 AI 模型。 +⚠️ novel-plus 项目默认使用的都是免费 AI 模型,生成效果有限。如果对生成内容有更高的要求,建议选用付费的 AI 模型。 ## 增值服务 diff --git a/doc/sql/20250630.sql b/doc/sql/20250630.sql new file mode 100644 index 0000000..23ef4c6 --- /dev/null +++ b/doc/sql/20250630.sql @@ -0,0 +1,3 @@ +alter table book_comment add column location varchar(50) DEFAULT NULL COMMENT '地理位置' after comment_content ; + + diff --git a/doc/sql/novel_plus.sql b/doc/sql/novel_plus.sql index 57748f9..b73201b 100644 --- a/doc/sql/novel_plus.sql +++ b/doc/sql/novel_plus.sql @@ -3153,4 +3153,9 @@ where menu_id = 104; delete from sys_menu -where menu_id = 57; \ No newline at end of file +where menu_id = 57; + + +alter table book_comment add column location varchar(50) DEFAULT NULL COMMENT '地理位置' after comment_content ; + + diff --git a/novel-common/src/main/java/com/java2nb/novel/core/utils/IpUtil.java b/novel-common/src/main/java/com/java2nb/novel/core/utils/IpUtil.java index 754e93d..53f836d 100644 --- a/novel-common/src/main/java/com/java2nb/novel/core/utils/IpUtil.java +++ b/novel-common/src/main/java/com/java2nb/novel/core/utils/IpUtil.java @@ -1,11 +1,21 @@ package com.java2nb.novel.core.utils; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +@Slf4j public class IpUtil { /** * 获取真实IP + * * @param request 请求体 * @return 真实IP */ @@ -31,4 +41,27 @@ public class IpUtil { } return ip; } + + /** + * 获取本机公网IP + */ + public static String getPublicIP() { + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://httpbin.org/ip")) + .GET() + .timeout(Duration.ofSeconds(5)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return new ObjectMapper().readTree(response.body()).get("origin").asText(); + } + } catch (Exception e) { + log.error("获取本机公网IP异常", e); + } + return null; + } } diff --git a/novel-common/src/main/java/com/java2nb/novel/entity/BookComment.java b/novel-common/src/main/java/com/java2nb/novel/entity/BookComment.java index 98c48b2..095a716 100644 --- a/novel-common/src/main/java/com/java2nb/novel/entity/BookComment.java +++ b/novel-common/src/main/java/com/java2nb/novel/entity/BookComment.java @@ -14,6 +14,9 @@ public class BookComment { @Generated("org.mybatis.generator.api.MyBatisGenerator") private String commentContent; + @Generated("org.mybatis.generator.api.MyBatisGenerator") + private String location; + @Generated("org.mybatis.generator.api.MyBatisGenerator") private Integer replyCount; @@ -56,6 +59,16 @@ public class BookComment { this.commentContent = commentContent == null ? null : commentContent.trim(); } + @Generated("org.mybatis.generator.api.MyBatisGenerator") + public String getLocation() { + return location; + } + + @Generated("org.mybatis.generator.api.MyBatisGenerator") + public void setLocation(String location) { + this.location = location == null ? null : location.trim(); + } + @Generated("org.mybatis.generator.api.MyBatisGenerator") public Integer getReplyCount() { return replyCount; diff --git a/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentDynamicSqlSupport.java b/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentDynamicSqlSupport.java index cc0fd4a..8fe3ac0 100644 --- a/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentDynamicSqlSupport.java +++ b/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentDynamicSqlSupport.java @@ -19,6 +19,9 @@ public final class BookCommentDynamicSqlSupport { @Generated("org.mybatis.generator.api.MyBatisGenerator") public static final SqlColumn commentContent = bookComment.commentContent; + @Generated("org.mybatis.generator.api.MyBatisGenerator") + public static final SqlColumn location = bookComment.location; + @Generated("org.mybatis.generator.api.MyBatisGenerator") public static final SqlColumn replyCount = bookComment.replyCount; @@ -39,6 +42,8 @@ public final class BookCommentDynamicSqlSupport { public final SqlColumn commentContent = column("comment_content", JDBCType.VARCHAR); + public final SqlColumn location = column("location", JDBCType.VARCHAR); + public final SqlColumn replyCount = column("reply_count", JDBCType.INTEGER); public final SqlColumn auditStatus = column("audit_status", JDBCType.TINYINT); diff --git a/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentMapper.java b/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentMapper.java index 2ba063e..9d93f76 100644 --- a/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentMapper.java +++ b/novel-common/src/main/java/com/java2nb/novel/mapper/BookCommentMapper.java @@ -29,7 +29,7 @@ import static org.mybatis.dynamic.sql.SqlBuilder.isEqualTo; @Mapper public interface BookCommentMapper { @Generated("org.mybatis.generator.api.MyBatisGenerator") - BasicColumn[] selectList = BasicColumn.columnList(id, bookId, commentContent, replyCount, auditStatus, createTime, createUserId); + BasicColumn[] selectList = BasicColumn.columnList(id, bookId, commentContent, location, replyCount, auditStatus, createTime, createUserId); @Generated("org.mybatis.generator.api.MyBatisGenerator") @SelectProvider(type=SqlProviderAdapter.class, method="select") @@ -58,6 +58,7 @@ public interface BookCommentMapper { @Result(column="id", property="id", jdbcType=JdbcType.BIGINT, id=true), @Result(column="book_id", property="bookId", jdbcType=JdbcType.BIGINT), @Result(column="comment_content", property="commentContent", jdbcType=JdbcType.VARCHAR), + @Result(column="location", property="location", jdbcType=JdbcType.VARCHAR), @Result(column="reply_count", property="replyCount", jdbcType=JdbcType.INTEGER), @Result(column="audit_status", property="auditStatus", jdbcType=JdbcType.TINYINT), @Result(column="create_time", property="createTime", jdbcType=JdbcType.TIMESTAMP), @@ -92,6 +93,7 @@ public interface BookCommentMapper { c.map(id).toProperty("id") .map(bookId).toProperty("bookId") .map(commentContent).toProperty("commentContent") + .map(location).toProperty("location") .map(replyCount).toProperty("replyCount") .map(auditStatus).toProperty("auditStatus") .map(createTime).toProperty("createTime") @@ -105,6 +107,7 @@ public interface BookCommentMapper { c.map(id).toProperty("id") .map(bookId).toProperty("bookId") .map(commentContent).toProperty("commentContent") + .map(location).toProperty("location") .map(replyCount).toProperty("replyCount") .map(auditStatus).toProperty("auditStatus") .map(createTime).toProperty("createTime") @@ -118,6 +121,7 @@ public interface BookCommentMapper { c.map(id).toPropertyWhenPresent("id", record::getId) .map(bookId).toPropertyWhenPresent("bookId", record::getBookId) .map(commentContent).toPropertyWhenPresent("commentContent", record::getCommentContent) + .map(location).toPropertyWhenPresent("location", record::getLocation) .map(replyCount).toPropertyWhenPresent("replyCount", record::getReplyCount) .map(auditStatus).toPropertyWhenPresent("auditStatus", record::getAuditStatus) .map(createTime).toPropertyWhenPresent("createTime", record::getCreateTime) @@ -157,6 +161,7 @@ public interface BookCommentMapper { return dsl.set(id).equalTo(record::getId) .set(bookId).equalTo(record::getBookId) .set(commentContent).equalTo(record::getCommentContent) + .set(location).equalTo(record::getLocation) .set(replyCount).equalTo(record::getReplyCount) .set(auditStatus).equalTo(record::getAuditStatus) .set(createTime).equalTo(record::getCreateTime) @@ -168,6 +173,7 @@ public interface BookCommentMapper { return dsl.set(id).equalToWhenPresent(record::getId) .set(bookId).equalToWhenPresent(record::getBookId) .set(commentContent).equalToWhenPresent(record::getCommentContent) + .set(location).equalToWhenPresent(record::getLocation) .set(replyCount).equalToWhenPresent(record::getReplyCount) .set(auditStatus).equalToWhenPresent(record::getAuditStatus) .set(createTime).equalToWhenPresent(record::getCreateTime) @@ -179,6 +185,7 @@ public interface BookCommentMapper { return update(c -> c.set(bookId).equalTo(record::getBookId) .set(commentContent).equalTo(record::getCommentContent) + .set(location).equalTo(record::getLocation) .set(replyCount).equalTo(record::getReplyCount) .set(auditStatus).equalTo(record::getAuditStatus) .set(createTime).equalTo(record::getCreateTime) @@ -192,6 +199,7 @@ public interface BookCommentMapper { return update(c -> c.set(bookId).equalToWhenPresent(record::getBookId) .set(commentContent).equalToWhenPresent(record::getCommentContent) + .set(location).equalToWhenPresent(record::getLocation) .set(replyCount).equalToWhenPresent(record::getReplyCount) .set(auditStatus).equalToWhenPresent(record::getAuditStatus) .set(createTime).equalToWhenPresent(record::getCreateTime) diff --git a/novel-front/pom.xml b/novel-front/pom.xml index e69c148..250246a 100644 --- a/novel-front/pom.xml +++ b/novel-front/pom.xml @@ -61,6 +61,12 @@ org.springframework.ai spring-ai-openai-spring-boot-starter + + + org.lionsoul + ip2region + 2.7.0 + diff --git a/novel-front/src/main/java/com/java2nb/novel/controller/BookController.java b/novel-front/src/main/java/com/java2nb/novel/controller/BookController.java index b5d1faf..65dcf97 100644 --- a/novel-front/src/main/java/com/java2nb/novel/controller/BookController.java +++ b/novel-front/src/main/java/com/java2nb/novel/controller/BookController.java @@ -2,12 +2,14 @@ package com.java2nb.novel.controller; import com.java2nb.novel.core.bean.UserDetails; import com.java2nb.novel.core.enums.ResponseStatus; +import com.java2nb.novel.core.utils.IpUtil; import com.java2nb.novel.entity.Book; import com.java2nb.novel.entity.BookCategory; import com.java2nb.novel.entity.BookComment; import com.java2nb.novel.entity.BookIndex; import com.java2nb.novel.service.BookContentService; import com.java2nb.novel.service.BookService; +import com.java2nb.novel.service.IpLocationService; import com.java2nb.novel.vo.BookCommentVO; import com.java2nb.novel.vo.BookSettingVO; import com.java2nb.novel.vo.BookSpVO; @@ -37,6 +39,8 @@ public class BookController extends BaseController { private final Map bookContentServiceMap; + private final IpLocationService ipLocationService; + /** * 查询首页小说设置列表数据 */ @@ -158,6 +162,7 @@ public class BookController extends BaseController { if (userDetails == null) { return RestResult.fail(ResponseStatus.NO_LOGIN); } + comment.setLocation(ipLocationService.getLocation(IpUtil.getRealIp(request))); bookService.addBookComment(userDetails.getId(), comment); return RestResult.ok(); } diff --git a/novel-front/src/main/java/com/java2nb/novel/core/config/IpLocationConfig.java b/novel-front/src/main/java/com/java2nb/novel/core/config/IpLocationConfig.java new file mode 100644 index 0000000..bcd939c --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/core/config/IpLocationConfig.java @@ -0,0 +1,55 @@ +package com.java2nb.novel.core.config; + +import lombok.extern.slf4j.Slf4j; +import org.lionsoul.ip2region.xdb.Searcher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * IP 地址定位配置类 + * + * @author xiongxiaoyang + * @date 2025/6/30 + */ +@Slf4j +@Configuration +public class IpLocationConfig { + + /** + * 使用 {@link Searcher} 实现高效的本地 IP 查询服务, 创建基于内存的 IP 地址查询对象,支持并发访问且仅需初始化一次。 + * + *

该方法会将 ip2region.xdb 数据库文件加载到内存中, + * 并构建一个线程安全的 {@link Searcher} 实例,可用于高效、并发的 IP 地址定位查询。

+ * + *

{@link Searcher} 实例是线程安全的,可以作为全局单例在整个应用中跨线程使用。

+ * + *

通过配置 destroyMethod="close",确保在 Spring 容器关闭时自动释放底层资源。

+ */ + @Bean(destroyMethod = "close") + public Searcher searcher() throws IOException { + // 1、从 classpath 加载整个 xdb 到内存。 + try (InputStream inputStream = new ClassPathResource("ip2region.xdb").getInputStream()) { + File tempDbFile = File.createTempFile("ip2region", ".xdb"); + try (FileOutputStream outputStream = new FileOutputStream(tempDbFile)) { + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + } + // 确保程序退出时删除临时文件 + tempDbFile.deleteOnExit(); + byte[] cBuff = Searcher.loadContentFromFile(tempDbFile.getPath()); + + // 2、使用上述的 cBuff 创建一个完全基于内存的查询对象。 + return Searcher.newWithBuffer(cBuff); + } + } + +} diff --git a/novel-front/src/main/java/com/java2nb/novel/service/IpLocationService.java b/novel-front/src/main/java/com/java2nb/novel/service/IpLocationService.java new file mode 100644 index 0000000..712bddf --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/service/IpLocationService.java @@ -0,0 +1,24 @@ +package com.java2nb.novel.service; + +/** + * IP 地址定位服务类 + * + *

该服务用于实现 IP 地址到地理位置的查询功能, + * 包括国家、省份、城市等信息。

+ * + *

此类设计为 Spring 管理的 Service Bean,支持在 Controller 或其他 Service 中注入使用。

+ * + * @author xiongxiaoyang + * @date 2025/6/30 + */ +public interface IpLocationService { + + /** + * 根据 IP 地址查询地理位置信息 + * + * @param ip 待查询的 IP 地址(IPv4) + * @return 如果是中国 IP,返回省份;否则返回国家 + */ + String getLocation(String ip); + +} diff --git a/novel-front/src/main/java/com/java2nb/novel/service/impl/IpLocationServiceImpl.java b/novel-front/src/main/java/com/java2nb/novel/service/impl/IpLocationServiceImpl.java new file mode 100644 index 0000000..e3c3241 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/service/impl/IpLocationServiceImpl.java @@ -0,0 +1,56 @@ +package com.java2nb.novel.service.impl; + +import com.java2nb.novel.core.utils.IpUtil; +import com.java2nb.novel.service.IpLocationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.lionsoul.ip2region.xdb.Searcher; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * IpLocationService 实现类 + * + * @author xiongxiaoyang + * @date 2025/6/30 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class IpLocationServiceImpl implements IpLocationService { + + private final Searcher searcher; + + @Override + public String getLocation(String ip) { + try { + // 示例返回:"中国|0|湖北省|武汉市|电信" + String region = searcher.search(ip); + String[] regions = region.split("\\|"); + if (regions.length > 0) { + // 国家 + String country = regions[0]; + if ("0".equals(country)) { + // 内网IP,直接获取本机公网IP + String publicIp = IpUtil.getPublicIP(); + log.info("内网IP:{},本机公网IP:{}", ip, publicIp); + if (StringUtils.hasText(publicIp)) { + return getLocation(publicIp); + } + } else if ("中国".equals(country)) { + // 是中国,则返回省份(第三个字段) + String province = regions.length > 2 ? regions[2] : "未知地区"; + // 去掉最后一个“省”字 + return province.endsWith("省") ? province.substring(0, province.length() - 1) : province; + } else { + // 非中国,返回国家名 + return country; + } + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return "未知地区"; + } + +} diff --git a/novel-front/src/main/resources/ip2region.xdb b/novel-front/src/main/resources/ip2region.xdb new file mode 100644 index 0000000..7052c05 Binary files /dev/null and b/novel-front/src/main/resources/ip2region.xdb differ diff --git a/novel-front/src/main/resources/mybatis/mapping/BookCommentMapper.xml b/novel-front/src/main/resources/mybatis/mapping/BookCommentMapper.xml index ea38ff2..eaa0fe9 100644 --- a/novel-front/src/main/resources/mybatis/mapping/BookCommentMapper.xml +++ b/novel-front/src/main/resources/mybatis/mapping/BookCommentMapper.xml @@ -4,7 +4,7 @@