Update InfoGenie

This commit is contained in:
2026-04-03 21:18:29 +08:00
parent 6b3fcc1791
commit dbd72610c1
62 changed files with 3192 additions and 255 deletions

View File

@@ -1,7 +1,7 @@
# 复制为 .env.development / .env.production 后填写;勿提交真实密钥
VITE_API_URL=http://127.0.0.1:5002
VITE_AUTH_URL=https://auth.shumengya.top
VITE_AUTH_API_URL=https://auth.api.shumengya.top
VITE_DEBUG=true
# 须与 Go 后端 INFOGENIE_SITE_ADMIN_TOKEN 一致
VITE_SITE_ADMIN_GATE=
# 复制为 .env.development / .env.production 后填写;勿提交真实密钥
VITE_API_URL=http://127.0.0.1:5002
VITE_AUTH_URL=https://auth.shumengya.top
VITE_AUTH_API_URL=https://auth.api.shumengya.top
VITE_DEBUG=true
# 须与 Go 后端 INFOGENIE_SITE_ADMIN_TOKEN 一致
VITE_SITE_ADMIN_GATE=

View File

@@ -725,7 +725,7 @@ const AdminPage = () => {
{adminSection === 'ai-upstream' && (
<InfoCard>
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
此处配置会写入数据库表 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_ai_runtime</code><strong></strong> <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">ai_config.json</code>
此处配置会写入数据库表 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_ai_runtime</code>
密钥留空表示<strong>不修改</strong> API Key
</p>
{loadingAi ? (

View File

@@ -1,22 +1,22 @@
APP_ENV=development
APP_PORT=5002
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=infogenie-test
DB_USER=infogenie-test
DB_PASSWORD=
JWT_SECRET=
JWT_EXPIRE_DAYS=7
MAIL_HOST=
MAIL_PORT=465
MAIL_USERNAME=
MAIL_PASSWORD=
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
AUTH_CENTER_ADMIN_TOKEN=
INFOGENIE_SITE_ADMIN_TOKEN=
# REDIS_ENABLED=false
# REDIS_ADDR=127.0.0.1:6379
# REDIS_PASSWORD=
# REDIS_DB=10
# REDIS_KEY_PREFIX=infogenie:go:v1:
# REDIS_SITE_TTL=60
APP_ENV=development
APP_PORT=5002
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=infogenie-test
DB_USER=infogenie-test
DB_PASSWORD=
JWT_SECRET=
JWT_EXPIRE_DAYS=7
MAIL_HOST=
MAIL_PORT=465
MAIL_USERNAME=
MAIL_PASSWORD=
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
AUTH_CENTER_ADMIN_TOKEN=
INFOGENIE_SITE_ADMIN_TOKEN=
# REDIS_ENABLED=false
# REDIS_ADDR=127.0.0.1:6379
# REDIS_PASSWORD=
# REDIS_DB=10
# REDIS_KEY_PREFIX=infogenie:go:v1:
# REDIS_SITE_TTL=60

View File

@@ -13,7 +13,6 @@ RUN apk --no-cache add ca-certificates tzdata
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /out/server .
COPY ai_config.json ./
EXPOSE 5002
ENV APP_ENV=production
ENV APP_PORT=5002

View File

@@ -1,119 +1,119 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"infogenie-backend/config"
)
// Key suffixes完整 key = KeyPrefix + suffix
const (
KeySite60sDisabled = "site:60s-disabled"
KeySite60sSource = "site:60s-source"
KeySiteAIModelDisabled = "site:ai-model-disabled"
)
func KeySiteFeatureClicks(section string) string {
return "site:feature-clicks:" + section
}
var (
rdb *redis.Client
keyPrefix string
siteCacheTTL time.Duration
redisOn bool
)
// Init 在 REDIS_ENABLED 时连接并 Ping未启用时为空操作
func Init(rc config.RedisConfig) error {
if !rc.Enabled {
redisOn = false
rdb = nil
return nil
}
redisOn = true
keyPrefix = rc.KeyPrefix
siteCacheTTL = rc.SiteTTL
if siteCacheTTL <= 0 {
siteCacheTTL = 60 * time.Second
}
rdb = redis.NewClient(&redis.Options{
Addr: rc.Addr,
Password: rc.Password,
DB: rc.DB,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return fmt.Errorf("redis: %w", err)
}
return nil
}
// Enabled 表示已初始化且客户端可用
func Enabled() bool {
return redisOn && rdb != nil
}
func fullKey(suffix string) string {
return keyPrefix + suffix
}
// GetJSON 命中返回 true未启用或未命中返回 falseerr 仅表示 Redis/JSON 异常)
func GetJSON(ctx context.Context, suffix string, dest interface{}) (bool, error) {
if !Enabled() {
return false, nil
}
val, err := rdb.Get(ctx, fullKey(suffix)).Bytes()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
if err := json.Unmarshal(val, dest); err != nil {
return false, err
}
return true, nil
}
// SetJSON ttl<=0 时使用配置的 SiteTTL
func SetJSON(ctx context.Context, suffix string, v interface{}, ttl time.Duration) error {
if !Enabled() {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return err
}
if ttl <= 0 {
ttl = siteCacheTTL
}
return rdb.Set(ctx, fullKey(suffix), b, ttl).Err()
}
// Delete 删除若干后缀对应的 key失败仅打日志
func Delete(ctx context.Context, suffixes ...string) {
if !Enabled() || len(suffixes) == 0 {
return
}
keys := make([]string, 0, len(suffixes))
for _, s := range suffixes {
keys = append(keys, fullKey(s))
}
if err := rdb.Del(ctx, keys...).Err(); err != nil {
log.Printf("redis DEL 失败: %v keys=%v", err, keys)
}
}
// Ping 未启用时返回 nil
func Ping(ctx context.Context) error {
if !Enabled() {
return nil
}
return rdb.Ping(ctx).Err()
}
package cache
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"infogenie-backend/config"
)
// Key suffixes完整 key = KeyPrefix + suffix
const (
KeySite60sDisabled = "site:60s-disabled"
KeySite60sSource = "site:60s-source"
KeySiteAIModelDisabled = "site:ai-model-disabled"
)
func KeySiteFeatureClicks(section string) string {
return "site:feature-clicks:" + section
}
var (
rdb *redis.Client
keyPrefix string
siteCacheTTL time.Duration
redisOn bool
)
// Init 在 REDIS_ENABLED 时连接并 Ping未启用时为空操作
func Init(rc config.RedisConfig) error {
if !rc.Enabled {
redisOn = false
rdb = nil
return nil
}
redisOn = true
keyPrefix = rc.KeyPrefix
siteCacheTTL = rc.SiteTTL
if siteCacheTTL <= 0 {
siteCacheTTL = 60 * time.Second
}
rdb = redis.NewClient(&redis.Options{
Addr: rc.Addr,
Password: rc.Password,
DB: rc.DB,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return fmt.Errorf("redis: %w", err)
}
return nil
}
// Enabled 表示已初始化且客户端可用
func Enabled() bool {
return redisOn && rdb != nil
}
func fullKey(suffix string) string {
return keyPrefix + suffix
}
// GetJSON 命中返回 true未启用或未命中返回 falseerr 仅表示 Redis/JSON 异常)
func GetJSON(ctx context.Context, suffix string, dest interface{}) (bool, error) {
if !Enabled() {
return false, nil
}
val, err := rdb.Get(ctx, fullKey(suffix)).Bytes()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
if err := json.Unmarshal(val, dest); err != nil {
return false, err
}
return true, nil
}
// SetJSON ttl<=0 时使用配置的 SiteTTL
func SetJSON(ctx context.Context, suffix string, v interface{}, ttl time.Duration) error {
if !Enabled() {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return err
}
if ttl <= 0 {
ttl = siteCacheTTL
}
return rdb.Set(ctx, fullKey(suffix), b, ttl).Err()
}
// Delete 删除若干后缀对应的 key失败仅打日志
func Delete(ctx context.Context, suffixes ...string) {
if !Enabled() || len(suffixes) == 0 {
return
}
keys := make([]string, 0, len(suffixes))
for _, s := range suffixes {
keys = append(keys, fullKey(s))
}
if err := rdb.Del(ctx, keys...).Err(); err != nil {
log.Printf("redis DEL 失败: %v keys=%v", err, keys)
}
}
// Ping 未启用时返回 nil
func Ping(ctx context.Context) error {
if !Enabled() {
return nil
}
return rdb.Ping(ctx).Err()
}

View File

@@ -1,84 +1,84 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// 用法: go run . <host:port> [password]
// 例: go run . 10.1.1.100:6379
func main() {
addr := "10.1.1.100:6379"
pass := os.Getenv("REDIS_PASSWORD")
if len(os.Args) >= 2 {
addr = os.Args[1]
}
if len(os.Args) >= 3 {
pass = os.Args[2]
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
r := redis.NewClient(&redis.Options{Addr: addr, Password: pass})
defer r.Close()
if err := r.Ping(ctx).Err(); err != nil {
fmt.Fprintf(os.Stderr, "连接失败 %s: %v\n", addr, err)
os.Exit(1)
}
s, err := r.Info(ctx, "keyspace").Result()
if err != nil {
fmt.Fprintf(os.Stderr, "INFO keyspace: %v\n", err)
os.Exit(1)
}
used := make(map[int]int64)
for _, line := range strings.Split(s, "\r\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "db") {
continue
}
colon := strings.IndexByte(line, ':')
if colon <= 2 {
continue
}
dbStr := line[2:colon]
dbNum, err := strconv.Atoi(dbStr)
if err != nil {
continue
}
var keys int64
for _, part := range strings.Split(line[colon+1:], ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "keys=") {
keys, _ = strconv.ParseInt(strings.TrimPrefix(part, "keys="), 10, 64)
break
}
}
used[dbNum] = keys
}
fmt.Printf("Redis %s — 逻辑库 key 统计(INFO keyspace)\n\n", addr)
for i := 0; i < 16; i++ {
k, ok := used[i]
if !ok {
fmt.Printf(" db%-2d : 无记录(通常为 0 个 key / 未写入过)\n", i)
} else if k == 0 {
fmt.Printf(" db%-2d : 0 keys\n", i)
} else {
fmt.Printf(" db%-2d : %d keys (已占用)\n", i, k)
}
}
fmt.Println()
fmt.Println("说明: Redis 默认 16 个逻辑库(0-15);「无记录」一般可当空闲选用。")
fmt.Println("若 redis.conf 里 databases=N实际库数量可能更大可用 redis-cli CONFIG GET databases 查看。")
}
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// 用法: go run . <host:port> [password]
// 例: go run . 10.1.1.100:6379
func main() {
addr := "10.1.1.100:6379"
pass := os.Getenv("REDIS_PASSWORD")
if len(os.Args) >= 2 {
addr = os.Args[1]
}
if len(os.Args) >= 3 {
pass = os.Args[2]
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
r := redis.NewClient(&redis.Options{Addr: addr, Password: pass})
defer r.Close()
if err := r.Ping(ctx).Err(); err != nil {
fmt.Fprintf(os.Stderr, "连接失败 %s: %v\n", addr, err)
os.Exit(1)
}
s, err := r.Info(ctx, "keyspace").Result()
if err != nil {
fmt.Fprintf(os.Stderr, "INFO keyspace: %v\n", err)
os.Exit(1)
}
used := make(map[int]int64)
for _, line := range strings.Split(s, "\r\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "db") {
continue
}
colon := strings.IndexByte(line, ':')
if colon <= 2 {
continue
}
dbStr := line[2:colon]
dbNum, err := strconv.Atoi(dbStr)
if err != nil {
continue
}
var keys int64
for _, part := range strings.Split(line[colon+1:], ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "keys=") {
keys, _ = strconv.ParseInt(strings.TrimPrefix(part, "keys="), 10, 64)
break
}
}
used[dbNum] = keys
}
fmt.Printf("Redis %s — 逻辑库 key 统计(INFO keyspace)\n\n", addr)
for i := 0; i < 16; i++ {
k, ok := used[i]
if !ok {
fmt.Printf(" db%-2d : 无记录(通常为 0 个 key / 未写入过)\n", i)
} else if k == 0 {
fmt.Printf(" db%-2d : 0 keys\n", i)
} else {
fmt.Printf(" db%-2d : %d keys (已占用)\n", i, k)
}
}
fmt.Println()
fmt.Println("说明: Redis 默认 16 个逻辑库(0-15);「无记录」一般可当空闲选用。")
fmt.Println("若 redis.conf 里 databases=N实际库数量可能更大可用 redis-cli CONFIG GET databases 查看。")
}

View File

@@ -6,52 +6,70 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.12</version>
<relativePath/> <!-- lookup parent from repository -->
<relativePath/>
</parent>
<groupId>com.smyhub.infogenie</groupId>
<artifactId>infogenie-backend-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>infogenie-backend-java</name>
<description>infogenie-backend-java</description>
<description>InfoGenie Spring Boot backend — Java/Spring port of the Go backend</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<licenses><license/></licenses>
<developers><developer/></developers>
<scm><connection/><developerConnection/><tag/><url/></scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA / Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Bean Validation (@Valid, @NotBlank …) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MySQL driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Hot-reload in dev -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -2,8 +2,11 @@ package com.smyhub.infogenie.infogeniebackendjava;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class InfogenieBackendJavaApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,30 @@
package com.smyhub.infogenie.infogeniebackendjava.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Binds all app.* keys from application.yaml to a strongly-typed bean.
* Inject this anywhere you need application-level configuration.
*/
@Data
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
/** External auth-center base URL used to verify Bearer tokens. */
private String authCenterUrl = "https://auth.api.shumengya.top";
/** Shared secret for /api/admin/* endpoints (X-Site-Admin-Token header). */
private String siteAdminToken = "";
/** Whether Redis caching is enabled. */
private boolean redisEnabled = false;
/** Prefix prepended to every Redis key written by this service. */
private String redisKeyPrefix = "infogenie:java:";
/** Default TTL in seconds for cached site-config entries. */
private long redisSiteTtl = 300L;
}

View File

@@ -0,0 +1,49 @@
package com.smyhub.infogenie.infogeniebackendjava.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Configures a RedisTemplate with String keys and JSON values.
* The template is always declared; actual usage is guarded by
* AppProperties.redisEnabled checks in service code, so the application
* starts normally even when Redis is not available and REDIS_ENABLED=false.
*/
@Configuration
public class RedisConfig {
@Bean
public ObjectMapper redisObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper redisObjectMapper) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(redisObjectMapper);
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -0,0 +1,37 @@
package com.smyhub.infogenie.infogeniebackendjava.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* Provides shared RestTemplate instances used by service classes for outbound HTTP calls
* (auth-center verification, DeepSeek/Kimi AI API, health probe).
*/
@Configuration
public class RestTemplateConfig {
/** General-purpose client for auth-center and health probes. */
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(10))
.readTimeout(Duration.ofSeconds(30))
.build();
}
/**
* Dedicated client for AI API calls — longer read timeout to accommodate
* slow model inference on the DeepSeek / Kimi endpoints.
*/
@Bean("aiRestTemplate")
public RestTemplate aiRestTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(10))
.readTimeout(Duration.ofSeconds(120))
.build();
}
}

View File

@@ -0,0 +1,24 @@
package com.smyhub.infogenie.infogeniebackendjava.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Global CORS configuration — mirrors the Gin CORS middleware in the Go backend.
* Allows all origins in development; tighten origin list before production.
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.exposedHeaders("Content-Type", "Authorization", "X-Site-Admin-Token")
.allowCredentials(true)
.maxAge(86400);
}
}

View File

@@ -0,0 +1,308 @@
package com.smyhub.infogenie.infogeniebackendjava.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AIToolRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.ChatRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AIToolResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.ChatResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.ModelsResponse;
import com.smyhub.infogenie.infogeniebackendjava.filter.AuthCenterUser;
import com.smyhub.infogenie.infogeniebackendjava.service.AIModelService;
import com.smyhub.infogenie.infogeniebackendjava.service.AIToolService;
import com.smyhub.infogenie.infogeniebackendjava.util.AiResponseUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.RestTemplate;
import java.io.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* AI model endpoints — requires a valid Bearer token on every request.
*
* POST /api/aimodelapp/chat
* POST /api/aimodelapp/chat/stream (SSE)
* POST /api/aimodelapp/name-analysis
* POST /api/aimodelapp/variable-naming
* POST /api/aimodelapp/poetry
* POST /api/aimodelapp/translation
* POST /api/aimodelapp/classical_conversion
* POST /api/aimodelapp/expression-maker
* POST /api/aimodelapp/linux-command
* POST /api/aimodelapp/markdown_formatting
* POST /api/aimodelapp/kinship-calculator
* GET /api/aimodelapp/models
*/
@Slf4j
@RestController
@RequestMapping("/api/aimodelapp")
@RequiredArgsConstructor
public class AIModelController {
private final AIModelService aiModelService;
private final AIToolService aiToolService;
private final AiResponseUtil aiResponseUtil;
private final RestTemplate aiRestTemplate;
private final ObjectMapper objectMapper;
// ──────────────────────────── Auth guard ─────────────────────────────
private boolean isAuthenticated(HttpServletRequest req) {
return req.getAttribute(AuthCenterUser.AUTH_USER_ATTR) != null;
}
private ResponseEntity<Map<String, String>> unauthenticated() {
return ResponseEntity.status(401).body(Map.of("error", "未授权,请先登录"));
}
// ──────────────────────────── Chat ───────────────────────────────────
@PostMapping("/chat")
public ResponseEntity<?> chat(@RequestBody ChatRequest req,
HttpServletRequest httpReq) throws Exception {
if (!isAuthenticated(httpReq)) return unauthenticated();
List<Map<String, String>> messages = validateAndConvertMessages(req);
if (messages == null) {
return ResponseEntity.badRequest().body(Map.of("error", "消息列表无效或超出限制"));
}
String provider = req.getProvider() != null ? req.getProvider() : AIModelService.PROVIDER_DEEPSEEK;
String model = req.getModel();
String content = aiModelService.callAI(provider, model, messages);
ChatResponse resp = new ChatResponse();
resp.setContent(content);
resp.setProvider(provider);
resp.setModel(model != null ? model : AIModelService.DEFAULT_MODEL_DEEPSEEK);
resp.setTimestamp(System.currentTimeMillis());
return ResponseEntity.ok(resp);
}
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public void chatStream(@RequestBody ChatRequest req,
HttpServletRequest httpReq,
HttpServletResponse httpResp) throws Exception {
if (!isAuthenticated(httpReq)) {
httpResp.setStatus(401);
return;
}
List<Map<String, String>> messages = validateAndConvertMessages(req);
if (messages == null) {
httpResp.setStatus(400);
return;
}
String provider = req.getProvider() != null ? req.getProvider() : AIModelService.PROVIDER_DEEPSEEK;
AIModelService.StreamConfig streamCfg =
aiModelService.buildStreamConfig(provider, req.getModel(), messages);
httpResp.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
httpResp.setCharacterEncoding("UTF-8");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(streamCfg.apiKey());
String bodyJson = objectMapper.writeValueAsString(streamCfg.body());
RequestCallback requestCallback = clientHttpRequest -> {
clientHttpRequest.getHeaders().addAll(headers);
try (OutputStream os = clientHttpRequest.getBody()) {
os.write(bodyJson.getBytes());
}
};
try (PrintWriter writer = httpResp.getWriter()) {
aiRestTemplate.execute(streamCfg.url(), HttpMethod.POST, requestCallback,
clientHttpResponse -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(clientHttpResponse.getBody()))) {
String line;
while ((line = reader.readLine()) != null) {
writer.write(line + "\n");
writer.flush();
}
}
return null;
});
} catch (Exception e) {
log.warn("Stream proxy error: {}", e.getMessage());
}
}
// ──────────────────────────── Models ─────────────────────────────────
@GetMapping("/models")
public ResponseEntity<?> getModels(HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
ModelsResponse resp = new ModelsResponse();
resp.setModels(aiModelService.getAllowedModels());
resp.setDefaultProvider(AIModelService.PROVIDER_DEEPSEEK);
resp.setDefaultModel(AIModelService.DEFAULT_MODEL_DEEPSEEK);
return ResponseEntity.ok(resp);
}
// ──────────────────────────── AI Tools ───────────────────────────────
@PostMapping("/name-analysis")
public ResponseEntity<?> nameAnalysis(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.nameAnalysis(
aiResponseUtil.truncateInput(req.getInput()));
return ResponseEntity.ok(new AIToolResponse()
.addField("analysis", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/variable-naming")
public ResponseEntity<?> variableNaming(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.variableNaming(
aiResponseUtil.truncateInput(req.getInput()), req.getLanguage());
return ResponseEntity.ok(new AIToolResponse()
.addField("suggestions", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/poetry")
public ResponseEntity<?> poetry(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.poetry(
aiResponseUtil.truncateInput(req.getInput()), req.getStyle());
return ResponseEntity.ok(new AIToolResponse()
.addField("poem", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/translation")
public ResponseEntity<?> translation(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.translation(
aiResponseUtil.truncateInput(req.getInput()),
req.getTargetLanguage(), req.getSourceLanguage());
return ResponseEntity.ok(new AIToolResponse()
.addField("translation", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/classical_conversion")
public ResponseEntity<?> classicalConversion(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.classicalConversion(
aiResponseUtil.truncateInput(req.getInput()));
return ResponseEntity.ok(new AIToolResponse()
.addField("result", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/expression-maker")
public ResponseEntity<?> expressionMaker(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.expressionMaker(
aiResponseUtil.truncateInput(req.getInput()), req.getStyle());
return ResponseEntity.ok(new AIToolResponse()
.addField("expressions", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/linux-command")
public ResponseEntity<?> linuxCommand(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.linuxCommand(
aiResponseUtil.truncateInput(req.getInput()));
return ResponseEntity.ok(new AIToolResponse()
.addField("command", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/markdown_formatting")
public ResponseEntity<?> markdownFormatting(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.markdownFormatting(
aiResponseUtil.truncateInput(req.getInput()));
return ResponseEntity.ok(new AIToolResponse()
.addField("markdown", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
@PostMapping("/kinship-calculator")
public ResponseEntity<?> kinshipCalculator(@RequestBody AIToolRequest req,
HttpServletRequest httpReq) {
if (!isAuthenticated(httpReq)) return unauthenticated();
try {
String result = aiToolService.kinshipCalculator(
aiResponseUtil.truncateInput(req.getInput()));
return ResponseEntity.ok(new AIToolResponse()
.addField("result", result)
.addField("timestamp", System.currentTimeMillis()));
} catch (Exception e) {
return toolError(e);
}
}
// ──────────────────────────── Helpers ────────────────────────────────
private List<Map<String, String>> validateAndConvertMessages(ChatRequest req) {
if (req.getMessages() == null || req.getMessages().isEmpty()) return null;
if (req.getMessages().size() > aiResponseUtil.getMaxChatMsgCount()) return null;
return req.getMessages().stream()
.map(m -> Map.of("role", m.getRole(), "content",
aiResponseUtil.truncateInput(m.getContent())))
.collect(Collectors.toList());
}
private ResponseEntity<Map<String, String>> toolError(Exception e) {
log.error("AI tool error: {}", e.getMessage());
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}

View File

@@ -0,0 +1,96 @@
package com.smyhub.infogenie.infogeniebackendjava.controller;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminAIRuntimeRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminDisabledUpdateRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminSourceUpdateRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AIRuntimeResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.DiagnosticsResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
import com.smyhub.infogenie.infogeniebackendjava.service.AIRuntimeService;
import com.smyhub.infogenie.infogeniebackendjava.service.HealthService;
import com.smyhub.infogenie.infogeniebackendjava.service.SiteConfigService;
import com.smyhub.infogenie.infogeniebackendjava.util.AdminTokenUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Admin-only endpoints protected by X-Site-Admin-Token header.
*
* PUT /api/admin/site/60s-disabled
* PUT /api/admin/site/60s-source
* PUT /api/admin/site/ai-model-disabled
* GET /api/admin/site/ai-runtime
* PUT /api/admin/site/ai-runtime
* GET /api/admin/site/diagnostics
*/
@RestController
@RequestMapping("/api/admin/site")
@RequiredArgsConstructor
public class AdminController {
private final SiteConfigService siteConfigService;
private final AIRuntimeService aiRuntimeService;
private final HealthService healthService;
private final AdminTokenUtil adminTokenUtil;
@PutMapping("/60s-disabled")
public ResponseEntity<?> update60sDisabled(@RequestBody AdminDisabledUpdateRequest req,
HttpServletRequest request) {
if (!adminTokenUtil.isValid(request)) return unauthorized();
int count = siteConfigService.update60sDisabled(
req.getDisabled() != null ? req.getDisabled() : java.util.List.of());
return ResponseEntity.ok(Map.of("ok", true, "count", count));
}
@PutMapping("/60s-source")
public ResponseEntity<?> update60sSource(@RequestBody AdminSourceUpdateRequest req,
HttpServletRequest request) {
if (!adminTokenUtil.isValid(request)) return unauthorized();
try {
SiteSourceResponse resp = siteConfigService.update60sSource(req.getSourceId());
return ResponseEntity.ok(resp);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/ai-model-disabled")
public ResponseEntity<?> updateAiModelDisabled(@RequestBody AdminDisabledUpdateRequest req,
HttpServletRequest request) {
if (!adminTokenUtil.isValid(request)) return unauthorized();
int count = siteConfigService.updateAiModelDisabled(
req.getDisabled() != null ? req.getDisabled() : java.util.List.of());
return ResponseEntity.ok(Map.of("ok", true, "count", count));
}
@GetMapping("/ai-runtime")
public ResponseEntity<?> getAiRuntime(HttpServletRequest request) {
if (!adminTokenUtil.isValid(request)) return unauthorized();
AIRuntimeResponse resp = aiRuntimeService.getRuntimeResponse();
return ResponseEntity.ok(resp);
}
@PutMapping("/ai-runtime")
public ResponseEntity<?> updateAiRuntime(@RequestBody AdminAIRuntimeRequest req,
HttpServletRequest request) {
if (!adminTokenUtil.isValid(request)) return unauthorized();
AIRuntimeResponse resp = aiRuntimeService.updateRuntime(req);
return ResponseEntity.ok(Map.of("ok", true, "runtime", resp));
}
@GetMapping("/diagnostics")
public ResponseEntity<?> getDiagnostics(HttpServletRequest request) {
if (!adminTokenUtil.isValid(request)) return unauthorized();
DiagnosticsResponse resp = healthService.buildDiagnostics();
return ResponseEntity.ok(resp);
}
private ResponseEntity<Map<String, Object>> unauthorized() {
return ResponseEntity.status(401)
.body(Map.of("error", "无效或缺失的管理员令牌"));
}
}

View File

@@ -0,0 +1,37 @@
package com.smyhub.infogenie.infogeniebackendjava.controller;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AuthCheckResponse;
import com.smyhub.infogenie.infogeniebackendjava.filter.AuthCenterUser;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* GET /api/auth/check
* Optional auth — returns logged_in:true with basic user info if a valid token is present.
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@GetMapping("/check")
public ResponseEntity<AuthCheckResponse> check(HttpServletRequest request) {
AuthCheckResponse resp = new AuthCheckResponse();
AuthCenterUser user =
(AuthCenterUser) request.getAttribute(AuthCenterUser.AUTH_USER_ATTR);
if (user != null) {
resp.setLoggedIn(true);
AuthCheckResponse.UserInfo info = new AuthCheckResponse.UserInfo();
info.setAccount(user.getAccount());
info.setUsername(user.getUsername());
info.setEmail(user.getEmail());
resp.setUser(info);
} else {
resp.setLoggedIn(false);
}
return ResponseEntity.ok(resp);
}
}

View File

@@ -0,0 +1,54 @@
package com.smyhub.infogenie.infogeniebackendjava.controller;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.HealthResponse;
import com.smyhub.infogenie.infogeniebackendjava.service.HealthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* GET / — service banner
* GET /api/health — component health check
*/
@RestController
@RequiredArgsConstructor
public class HealthController {
private final HealthService healthService;
@GetMapping("/")
public ResponseEntity<Map<String, Object>> root() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("service", "InfoGenie Backend");
body.put("version", "3.3.0-java");
body.put("status", "running");
body.put("endpoints", new String[]{
"GET /api/health",
"GET /api/auth/check",
"GET /api/user/profile",
"GET /api/site/60s-disabled",
"GET /api/site/60s-source",
"GET /api/site/ai-model-disabled",
"GET /api/site/feature-card-clicks",
"POST /api/site/feature-card-clicks/increment",
"GET /api/admin/site/diagnostics",
"POST /api/aimodelapp/chat",
"POST /api/aimodelapp/chat/stream",
"GET /api/aimodelapp/models"
});
return ResponseEntity.ok(body);
}
/**
* Same as Go: always HTTP 200; degradation is expressed only in JSON {@code status} field.
* AdminPage treats non-2xx as a failed fetch and clears the monitoring payload.
*/
@GetMapping("/api/health")
public ResponseEntity<HealthResponse> health() {
return ResponseEntity.ok(healthService.buildHealthResponse());
}
}

View File

@@ -0,0 +1,68 @@
package com.smyhub.infogenie.infogeniebackendjava.controller;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.FeatureClickIncrementRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.FeatureClickResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteDisabledResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
import com.smyhub.infogenie.infogeniebackendjava.service.FeatureClickService;
import com.smyhub.infogenie.infogeniebackendjava.service.SiteConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Public site-configuration read endpoints and feature-card click tracking.
*
* GET /api/site/60s-disabled
* GET /api/site/60s-source
* GET /api/site/ai-model-disabled
* GET /api/site/feature-card-clicks?section=...
* POST /api/site/feature-card-clicks/increment
*/
@RestController
@RequestMapping("/api/site")
@RequiredArgsConstructor
public class SiteConfigController {
private final SiteConfigService siteConfigService;
private final FeatureClickService featureClickService;
@GetMapping("/60s-disabled")
public ResponseEntity<SiteDisabledResponse> get60sDisabled() {
return ResponseEntity.ok(
new SiteDisabledResponse(siteConfigService.get60sDisabled()));
}
@GetMapping("/60s-source")
public ResponseEntity<SiteSourceResponse> get60sSource() {
return ResponseEntity.ok(siteConfigService.get60sSource());
}
@GetMapping("/ai-model-disabled")
public ResponseEntity<SiteDisabledResponse> getAiModelDisabled() {
return ResponseEntity.ok(
new SiteDisabledResponse(siteConfigService.getAiModelDisabled()));
}
@GetMapping("/feature-card-clicks")
public ResponseEntity<?> getFeatureClicks(@RequestParam(required = false) String section) {
if (section == null || section.isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "section parameter is required"));
}
return ResponseEntity.ok(featureClickService.getClicksForSection(section));
}
@PostMapping("/feature-card-clicks/increment")
public ResponseEntity<?> incrementClick(@RequestBody FeatureClickIncrementRequest req) {
if (req.getSection() == null || req.getItemId() == null) {
return ResponseEntity.badRequest()
.body(Map.of("error", "section and item_id are required"));
}
FeatureClickResponse resp =
featureClickService.increment(req.getSection(), req.getItemId());
return ResponseEntity.ok(resp);
}
}

View File

@@ -0,0 +1,41 @@
package com.smyhub.infogenie.infogeniebackendjava.controller;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.ApiResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.UserProfileResponse;
import com.smyhub.infogenie.infogeniebackendjava.filter.AuthCenterUser;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* GET /api/user/profile
* Required auth — returns the full user profile from the auth-center token.
*/
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public ResponseEntity<?> profile(HttpServletRequest request) {
AuthCenterUser user =
(AuthCenterUser) request.getAttribute(AuthCenterUser.AUTH_USER_ATTR);
if (user == null) {
return ResponseEntity.status(401)
.body(ApiResponse.fail("未授权,请先登录"));
}
UserProfileResponse profile = new UserProfileResponse();
profile.setAccount(user.getAccount());
profile.setUsername(user.getUsername());
profile.setEmail(user.getEmail());
profile.setAvatar(user.getAvatar());
profile.setLevel(user.getLevel());
profile.setSproutCoins(user.getSproutCoins());
profile.setCheckinDays(user.getCheckinDays());
profile.setCheckinStreak(user.getCheckinStreak());
return ResponseEntity.ok(ApiResponse.ok(profile));
}
}

View File

@@ -0,0 +1,35 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
import lombok.Data;
/**
* Generic request body shared by all vertical AI-tool endpoints
* (name-analysis, variable-naming, poetry, translation, classical_conversion,
* expression-maker, linux-command, markdown_formatting, kinship-calculator).
*
* Not all fields are used by every endpoint; handlers read only what they need.
*/
@Data
public class AIToolRequest {
/** Primary text input (name, code variable, poem topic, text to translate, etc.). */
private String input;
/** Secondary context — used by some tools (e.g. source language for translation). */
private String context;
/** Target language for translation tool. */
private String targetLanguage;
/** Source language for translation tool. */
private String sourceLanguage;
/** Programming language for variable-naming tool. */
private String language;
/** Style hint (e.g. poem style, expression tone). */
private String style;
/** Relationship description for kinship-calculator. */
private String relationship;
}

View File

@@ -0,0 +1,27 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Request body for PUT /api/admin/site/ai-runtime.
* Matches Go {@code putAIRuntimeBody} and the admin UI ({@code snake_case} JSON).
* If api_key is blank or masked with "****", the existing key is preserved.
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AdminAIRuntimeRequest {
@JsonProperty("api_base")
private String apiBase;
@JsonProperty("api_key")
private String apiKey;
@JsonProperty("default_model")
private String defaultModel;
@JsonProperty("default_provider")
private String defaultProvider;
}

View File

@@ -0,0 +1,15 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
import lombok.Data;
import java.util.List;
/**
* Request body for PUT /api/admin/site/60s-disabled and PUT /api/admin/site/ai-model-disabled.
* The list replaces the entire disabled set in the database.
*/
@Data
public class AdminDisabledUpdateRequest {
private List<String> disabled;
}

View File

@@ -0,0 +1,17 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Request body for PUT /api/admin/site/60s-source.
* source_id must be either "self" or "official".
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AdminSourceUpdateRequest {
@JsonProperty("source_id")
private String sourceId;
}

View File

@@ -0,0 +1,30 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* Request body for POST /api/aimodelapp/chat and /api/aimodelapp/chat/stream.
*/
@Data
public class ChatRequest {
@NotEmpty(message = "messages must not be empty")
@Valid
private List<ChatMessage> messages;
/** Defaults to "deepseek" when absent. */
private String provider;
/** Defaults to "deepseek-chat" when absent. */
private String model;
@Data
public static class ChatMessage {
private String role;
private String content;
}
}

View File

@@ -0,0 +1,18 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Request body for POST /api/site/feature-card-clicks/increment.
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FeatureClickIncrementRequest {
private String section;
@JsonProperty("item_id")
private String itemId;
}

View File

@@ -0,0 +1,28 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Response for GET /api/admin/site/ai-runtime.
* The api_key field is masked to show only the last 4 characters.
*/
@Data
public class AIRuntimeResponse {
@JsonProperty("api_base")
private String apiBase;
/** Masked: "****xxxx" — never returns the raw key. */
@JsonProperty("api_key_hint")
private String apiKeyHint;
@JsonProperty("default_model")
private String defaultModel;
@JsonProperty("default_provider")
private String defaultProvider;
@JsonProperty("updated_at")
private String updatedAt;
}

View File

@@ -0,0 +1,41 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Generic response wrapper for all vertical AI-tool endpoints.
* The "payload" map is serialised as flat top-level JSON fields via @JsonAnyGetter,
* allowing each tool to return its own named fields alongside "success" and "timestamp".
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AIToolResponse {
private boolean success = true;
private long timestamp;
private String error;
private final Map<String, Object> payload = new LinkedHashMap<>();
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public AIToolResponse addField(String key, Object value) {
payload.put(key, value);
return this;
}
@JsonAnyGetter
public Map<String, Object> getExtraFields() {
return payload;
}
}

View File

@@ -0,0 +1,32 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
/**
* Generic wrapper used for most API responses.
* Mirrors Go handlers that return { "success": true, "data": {...} } or
* { "success": false, "error": "..." }.
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private boolean success;
private T data;
private String error;
public static <T> ApiResponse<T> ok(T data) {
ApiResponse<T> r = new ApiResponse<>();
r.success = true;
r.data = data;
return r;
}
public static <T> ApiResponse<T> fail(String error) {
ApiResponse<T> r = new ApiResponse<>();
r.success = false;
r.error = error;
return r;
}
}

View File

@@ -0,0 +1,24 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
/**
* Response for GET /api/auth/check.
* { "success": true, "logged_in": true/false, "user": {...} }
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AuthCheckResponse {
private boolean success = true;
private boolean loggedIn;
private UserInfo user;
@Data
public static class UserInfo {
private String account;
private String username;
private String email;
}
}

View File

@@ -0,0 +1,17 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import lombok.Data;
/**
* Response for POST /api/aimodelapp/chat.
* { "success": true, "content": "...", "provider": "deepseek", "model": "...", "timestamp": ... }
*/
@Data
public class ChatResponse {
private boolean success = true;
private String content;
private String provider;
private String model;
private long timestamp;
}

View File

@@ -0,0 +1,93 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Matches Go {@code GET /api/admin/site/diagnostics} JSON for the admin monitoring page.
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class DiagnosticsResponse {
private AppInfo app;
private MysqlInfo mysql;
private RedisInfo redis;
@JsonProperty("sixty_upstream")
private SixtyUpstreamInfo sixtyUpstream;
@JsonProperty("auth_center")
private AuthCenterInfo authCenter;
private MailInfo mail;
@Data
public static class AppInfo {
private String env;
@JsonProperty("listen_addr")
private String listenAddr;
@JsonProperty("listen_port")
private String listenPort;
}
@Data
public static class MysqlInfo {
private String host;
private String port;
private String database;
private String user;
@JsonProperty("password_configured")
private Boolean passwordConfigured;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class RedisInfo {
private boolean enabled;
private String addr;
@JsonProperty("logical_db")
private Integer logicalDb;
@JsonProperty("key_prefix")
private String keyPrefix;
@JsonProperty("site_cache_ttl_sec")
private Integer siteCacheTtlSec;
@JsonProperty("password_configured")
private Boolean passwordConfigured;
}
@Data
public static class SixtyUpstreamInfo {
@JsonProperty("source_id")
private String sourceId;
@JsonProperty("base_url")
private String baseUrl;
private String label;
}
@Data
public static class AuthCenterInfo {
@JsonProperty("api_url")
private String apiUrl;
}
@Data
public static class MailInfo {
private String host;
private Integer port;
private String username;
@JsonProperty("password_configured")
private Boolean passwordConfigured;
}
}

View File

@@ -0,0 +1,30 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
/**
* Response for GET /api/site/feature-card-clicks.
* { "section": "...", "counts": { "itemId": 42, ... } }
*
* Increment response also includes "item_id" and "count" fields.
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class FeatureClickResponse {
private String section;
/** Map of itemId → clickCount, used by the GET endpoint. */
private Map<String, Long> counts;
/** Single incremented item_id, used by the POST increment endpoint. */
@JsonProperty("item_id")
private String itemId;
/** Updated click count after increment. */
private Long count;
}

View File

@@ -0,0 +1,76 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Matches Go {@code GET /api/health} JSON so the admin UI ({@code normalizeHealthPayload}) parses it.
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class HealthResponse {
private String status;
private String timestamp;
/**
* Go uses {@code "connected"} / {@code "disconnected"}; frontend falls back to {@code mysql.ok}.
*/
private String database;
private MysqlHealth mysql;
private RedisHealth redis;
@JsonProperty("backend_api")
private BackendApiHealth backendApi;
@JsonProperty("sixty_api")
private SixtyApiHealth sixtyApi;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class MysqlHealth {
private boolean ok;
private String status;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class RedisHealth {
private boolean enabled;
private Boolean ok;
private String error;
}
@Data
public static class BackendApiHealth {
private boolean ok = true;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class SixtyApiHealth {
private boolean ok;
@JsonProperty("source_id")
private String sourceId;
@JsonProperty("base_url")
private String baseUrl;
private String label;
@JsonProperty("probe_url")
private String probeUrl;
@JsonProperty("http_status")
private int httpStatus;
@JsonProperty("latency_ms")
private long latencyMs;
private String error;
}
}

View File

@@ -0,0 +1,25 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* Response for GET /api/aimodelapp/models.
* { "success": true, "models": { "deepseek": [...], "kimi": [...] },
* "default_provider": "deepseek", "default_model": "deepseek-chat" }
*/
@Data
public class ModelsResponse {
private boolean success = true;
private Map<String, List<String>> models;
@JsonProperty("default_provider")
private String defaultProvider;
@JsonProperty("default_model")
private String defaultModel;
}

View File

@@ -0,0 +1,19 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import lombok.Data;
import java.util.List;
/**
* Response for GET /api/site/60s-disabled and GET /api/site/ai-model-disabled.
* { "disabled": ["item1", "item2", ...] }
*/
@Data
public class SiteDisabledResponse {
private List<String> disabled;
public SiteDisabledResponse(List<String> disabled) {
this.disabled = disabled;
}
}

View File

@@ -0,0 +1,20 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Response for GET /api/site/60s-source and PUT /api/admin/site/60s-source.
* { "source_id": "self", "base_url": "https://...", "label": "自建源" }
*/
@Data
public class SiteSourceResponse {
@JsonProperty("source_id")
private String sourceId;
@JsonProperty("base_url")
private String baseUrl;
private String label;
}

View File

@@ -0,0 +1,29 @@
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Response for GET /api/user/profile.
* Full user object from the auth-center token verification.
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserProfileResponse {
private String account;
private String username;
private String email;
private String avatar;
private Integer level;
@JsonProperty("sprout_coins")
private Integer sproutCoins;
@JsonProperty("checkin_days")
private Integer checkinDays;
@JsonProperty("checkin_streak")
private Integer checkinStreak;
}

View File

@@ -0,0 +1,49 @@
package com.smyhub.infogenie.infogeniebackendjava.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
/**
* Maps to the ai_configs table.
* Stores per-provider API credentials and model lists loaded from the database.
* Mirrors the Go model of the same name.
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "ai_configs")
public class AIConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String provider;
@Column(name = "api_key", length = 500)
private String apiKey;
@Column(name = "api_base", length = 500)
private String apiBase;
@Column(name = "default_model", length = 100)
private String defaultModel;
/**
* Comma-separated or JSON-encoded model list stored as TEXT in MySQL.
*/
@Column(columnDefinition = "TEXT")
private String models;
@Column(name = "is_enabled")
private Boolean isEnabled = true;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,24 @@
package com.smyhub.infogenie.infogeniebackendjava.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Maps to the site_60s_disabled table.
* Each row holds the ID of a 60s API item that is hidden from the frontend.
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "site_60s_disabled")
public class Site60sDisabled {
@Id
@Column(name = "feature_id", length = 100)
private String featureId;
public Site60sDisabled(String featureId) {
this.featureId = featureId;
}
}

View File

@@ -0,0 +1,27 @@
package com.smyhub.infogenie.infogeniebackendjava.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Maps to the site_60s_upstream table (singleton row id=1).
* source_id is either "self" or "official", determining which upstream
* 60s API base URL the backend proxies / references.
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "site_60s_upstream")
public class Site60sUpstream {
@Id
private Long id;
/**
* "self" → https://60s.api.shumengya.top
* "official" → https://60s.viki.moe
*/
@Column(name = "source_id", length = 20, nullable = false)
private String sourceId = "self";
}

View File

@@ -0,0 +1,24 @@
package com.smyhub.infogenie.infogeniebackendjava.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Maps to the site_ai_model_disabled table.
* Each row holds the app_id of an AI-model app that should be hidden from the frontend.
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "site_ai_model_disabled")
public class SiteAIModelDisabled {
@Id
@Column(name = "app_id", length = 100)
private String appId;
public SiteAIModelDisabled(String appId) {
this.appId = appId;
}
}

View File

@@ -0,0 +1,39 @@
package com.smyhub.infogenie.infogeniebackendjava.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
/**
* Maps to the site_ai_runtime table (singleton row id=1).
* When api_base and api_key are both non-empty, DeepSeek calls use this
* runtime-configured endpoint instead of the ai_configs table entry.
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "site_ai_runtime")
public class SiteAIRuntime {
@Id
private Long id;
@Column(name = "api_base", length = 500)
private String apiBase;
@Column(name = "api_key", length = 500)
private String apiKey;
@Column(name = "default_model", length = 100)
private String defaultModel;
@Column(name = "default_provider", length = 50)
private String defaultProvider;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,44 @@
package com.smyhub.infogenie.infogeniebackendjava.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.UpdateTimestamp;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* Maps to the site_feature_card_clicks table.
* Composite primary key: (section, item_id).
* Tracks how many times each feature card has been clicked per section.
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "site_feature_card_clicks")
@IdClass(SiteFeatureCardClick.ClickId.class)
public class SiteFeatureCardClick {
@Id
@Column(name = "section", length = 50)
private String section;
@Id
@Column(name = "item_id", length = 100)
private String itemId;
@Column(name = "click_count")
private Long clickCount = 0L;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Data
@NoArgsConstructor
public static class ClickId implements Serializable {
private String section;
private String itemId;
}
}

View File

@@ -0,0 +1,38 @@
package com.smyhub.infogenie.infogeniebackendjava.filter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Represents the user object returned by the auth-center verify endpoint.
* Stored as a request attribute under the key AUTH_USER_ATTR after a successful
* token verification so controllers can read it without re-calling the auth center.
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AuthCenterUser {
public static final String AUTH_USER_ATTR = "authCenterUser";
private String account;
private String username;
private String email;
private String avatar;
private Integer level;
@JsonProperty("sprout_coins")
private Integer sproutCoins;
@JsonProperty("checkin_days")
private Integer checkinDays;
@JsonProperty("checkin_streak")
private Integer checkinStreak;
@JsonProperty("auth_user")
private String authUser;
@JsonProperty("ban_reason")
private String banReason;
}

View File

@@ -0,0 +1,83 @@
package com.smyhub.infogenie.infogeniebackendjava.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
/**
* Intercepts every request and, if a Bearer token is present in the Authorization header,
* forwards it to the external auth-center for verification.
*
* On success the resulting AuthCenterUser is stored as a request attribute.
* On failure the attribute is simply absent — controllers decide how to react
* (401 for required-auth, ignored for optional-auth endpoints).
*
* Mirrors Go's OptionalJWTAuth / JWTAuth middleware pair.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final AppProperties appProperties;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
AuthCenterUser user = verifyToken(token);
if (user != null) {
request.setAttribute(AuthCenterUser.AUTH_USER_ATTR, user);
}
}
filterChain.doFilter(request, response);
}
private AuthCenterUser verifyToken(String token) {
try {
String url = appProperties.getAuthCenterUrl() + "/api/auth/verify";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Auth-Client", "infogenie");
headers.set("X-Auth-Client-Name", "万象口袋");
Map<String, String> body = Map.of("token", token);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> responseEntity =
restTemplate.postForEntity(url, entity, Map.class);
if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) {
Map<?, ?> responseBody = responseEntity.getBody();
Object userObj = responseBody.get("user");
if (userObj != null) {
String userJson = objectMapper.writeValueAsString(userObj);
return objectMapper.readValue(userJson, AuthCenterUser.class);
}
}
} catch (Exception e) {
log.debug("Auth-center token verification failed: {}", e.getMessage());
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
package com.smyhub.infogenie.infogeniebackendjava.repository;
import com.smyhub.infogenie.infogeniebackendjava.entity.AIConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AIConfigRepository extends JpaRepository<AIConfig, Long> {
Optional<AIConfig> findByProviderAndIsEnabledTrue(String provider);
}

View File

@@ -0,0 +1,9 @@
package com.smyhub.infogenie.infogeniebackendjava.repository;
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sDisabled;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface Site60sDisabledRepository extends JpaRepository<Site60sDisabled, String> {
}

View File

@@ -0,0 +1,9 @@
package com.smyhub.infogenie.infogeniebackendjava.repository;
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sUpstream;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface Site60sUpstreamRepository extends JpaRepository<Site60sUpstream, Long> {
}

View File

@@ -0,0 +1,9 @@
package com.smyhub.infogenie.infogeniebackendjava.repository;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIModelDisabled;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SiteAIModelDisabledRepository extends JpaRepository<SiteAIModelDisabled, String> {
}

View File

@@ -0,0 +1,9 @@
package com.smyhub.infogenie.infogeniebackendjava.repository;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIRuntime;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SiteAIRuntimeRepository extends JpaRepository<SiteAIRuntime, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.smyhub.infogenie.infogeniebackendjava.repository;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteFeatureCardClick;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface SiteFeatureCardClickRepository
extends JpaRepository<SiteFeatureCardClick, SiteFeatureCardClick.ClickId> {
List<SiteFeatureCardClick> findBySection(String section);
@Modifying
@Query(value = """
INSERT INTO site_feature_card_clicks (section, item_id, click_count, updated_at)
VALUES (:section, :itemId, 1, NOW())
ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()
""", nativeQuery = true)
void upsertIncrement(@Param("section") String section, @Param("itemId") String itemId);
}

View File

@@ -0,0 +1,155 @@
package com.smyhub.infogenie.infogeniebackendjava.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smyhub.infogenie.infogeniebackendjava.entity.AIConfig;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIRuntime;
import com.smyhub.infogenie.infogeniebackendjava.repository.AIConfigRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.*;
/**
* Proxies chat and AI-tool requests to DeepSeek or Kimi endpoints.
* Endpoint resolution mirrors the Go ai.go service:
* - DeepSeek: use SiteAIRuntime if api_base+api_key are set, else ai_configs table.
* - Kimi: always from ai_configs table (provider=kimi).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AIModelService {
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_KIMI = "kimi";
public static final String DEFAULT_MODEL_DEEPSEEK = "deepseek-chat";
private static final Set<String> ALLOWED_DEEPSEEK = Set.of(
"deepseek-chat", "deepseek-reasoner");
private static final Set<String> ALLOWED_KIMI = Set.of(
"kimi-k2-0905-preview", "kimi-k2-0711-preview");
private final AIConfigRepository aiConfigRepo;
private final AIRuntimeService aiRuntimeService;
@Qualifier("aiRestTemplate")
private final RestTemplate aiRestTemplate;
private final ObjectMapper objectMapper;
public Map<String, List<String>> getAllowedModels() {
return Map.of(
PROVIDER_DEEPSEEK, new ArrayList<>(ALLOWED_DEEPSEEK),
PROVIDER_KIMI, new ArrayList<>(ALLOWED_KIMI)
);
}
/**
* Calls the AI provider and returns the assistant message content.
* Retries up to {@code maxRetries} times for DeepSeek; once for Kimi.
*/
public String callAI(String provider, String model,
List<Map<String, String>> messages) throws Exception {
String resolvedProvider = provider == null ? PROVIDER_DEEPSEEK : provider;
String resolvedModel = resolveModel(resolvedProvider, model);
EndpointConfig ep = resolveEndpoint(resolvedProvider);
int maxAttempts = PROVIDER_DEEPSEEK.equals(resolvedProvider) ? 3 : 1;
Exception lastEx = null;
for (int i = 0; i < maxAttempts; i++) {
try {
return doChat(ep, resolvedModel, messages);
} catch (Exception e) {
lastEx = e;
log.warn("AI call attempt {}/{} failed: {}", i + 1, maxAttempts, e.getMessage());
if (i < maxAttempts - 1) Thread.sleep(1000L * (i + 1));
}
}
throw lastEx != null ? lastEx : new RuntimeException("AI call failed");
}
/**
* Returns the raw SSE stream URL and headers needed for streaming chat.
* The controller is responsible for proxying the response stream.
*/
public StreamConfig buildStreamConfig(String provider, String model,
List<Map<String, String>> messages) {
String resolvedProvider = provider == null ? PROVIDER_DEEPSEEK : provider;
String resolvedModel = resolveModel(resolvedProvider, model);
EndpointConfig ep = resolveEndpoint(resolvedProvider);
Map<String, Object> body = buildRequestBody(resolvedModel, messages, true);
return new StreamConfig(ep.url(), ep.apiKey(), body);
}
private String doChat(EndpointConfig ep, String model,
List<Map<String, String>> messages) throws Exception {
Map<String, Object> body = buildRequestBody(model, messages, false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(ep.apiKey());
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> response =
aiRestTemplate.postForEntity(ep.url(), entity, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("AI API returned " + response.getStatusCode());
}
JsonNode root = objectMapper.readTree(response.getBody());
return root.path("choices").path(0).path("message").path("content").asText();
}
private Map<String, Object> buildRequestBody(String model,
List<Map<String, String>> messages,
boolean stream) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", model);
body.put("messages", messages);
if (stream) body.put("stream", true);
return body;
}
private String resolveModel(String provider, String model) {
if (model == null || model.isBlank()) {
return DEFAULT_MODEL_DEEPSEEK;
}
Set<String> allowed = PROVIDER_KIMI.equals(provider) ? ALLOWED_KIMI : ALLOWED_DEEPSEEK;
return allowed.contains(model) ? model : DEFAULT_MODEL_DEEPSEEK;
}
private EndpointConfig resolveEndpoint(String provider) {
if (PROVIDER_DEEPSEEK.equals(provider)) {
SiteAIRuntime runtime = aiRuntimeService.getRuntime().orElse(null);
if (runtime != null
&& runtime.getApiBase() != null && !runtime.getApiBase().isBlank()
&& runtime.getApiKey() != null && !runtime.getApiKey().isBlank()) {
return new EndpointConfig(
runtime.getApiBase().stripTrailing() + "/chat/completions",
runtime.getApiKey());
}
}
String prov = PROVIDER_KIMI.equals(provider) ? PROVIDER_KIMI : PROVIDER_DEEPSEEK;
AIConfig cfg = aiConfigRepo.findByProviderAndIsEnabledTrue(prov)
.orElseThrow(() -> new RuntimeException(
"No enabled AI config for provider: " + prov));
String base = cfg.getApiBase().stripTrailing();
String url = PROVIDER_KIMI.equals(prov)
? base + "/v1/chat/completions"
: base + "/chat/completions";
return new EndpointConfig(url, cfg.getApiKey());
}
public record EndpointConfig(String url, String apiKey) {}
public record StreamConfig(String url, String apiKey, Map<String, Object> body) {}
}

View File

@@ -0,0 +1,63 @@
package com.smyhub.infogenie.infogeniebackendjava.service;
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminAIRuntimeRequest;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AIRuntimeResponse;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIRuntime;
import com.smyhub.infogenie.infogeniebackendjava.repository.SiteAIRuntimeRepository;
import com.smyhub.infogenie.infogeniebackendjava.util.AiResponseUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
/**
* Reads and updates the singleton SiteAIRuntime row (id=1).
* Used by the AI service to resolve the active DeepSeek endpoint.
*/
@Service
@RequiredArgsConstructor
public class AIRuntimeService {
private final SiteAIRuntimeRepository runtimeRepo;
private final AiResponseUtil aiResponseUtil;
public Optional<SiteAIRuntime> getRuntime() {
return runtimeRepo.findById(1L);
}
public AIRuntimeResponse getRuntimeResponse() {
SiteAIRuntime row = runtimeRepo.findById(1L).orElse(new SiteAIRuntime());
AIRuntimeResponse resp = new AIRuntimeResponse();
resp.setApiBase(row.getApiBase());
resp.setApiKeyHint(aiResponseUtil.maskApiKey(row.getApiKey()));
resp.setDefaultModel(row.getDefaultModel());
resp.setDefaultProvider(row.getDefaultProvider());
if (row.getUpdatedAt() != null) {
resp.setUpdatedAt(row.getUpdatedAt().toString());
}
return resp;
}
@Transactional
public AIRuntimeResponse updateRuntime(AdminAIRuntimeRequest req) {
SiteAIRuntime row = runtimeRepo.findById(1L).orElse(new SiteAIRuntime());
row.setId(1L);
if (req.getApiBase() != null) {
row.setApiBase(req.getApiBase());
}
if (!aiResponseUtil.isKeyPlaceholder(req.getApiKey())) {
row.setApiKey(req.getApiKey());
}
if (req.getDefaultModel() != null) {
row.setDefaultModel(req.getDefaultModel());
}
if (req.getDefaultProvider() != null) {
row.setDefaultProvider(req.getDefaultProvider());
}
runtimeRepo.save(row);
return getRuntimeResponse();
}
}

View File

@@ -0,0 +1,124 @@
package com.smyhub.infogenie.infogeniebackendjava.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Builds prompt strings and dispatches to the AIModelService for all vertical
* AI-tool endpoints (name-analysis, variable-naming, poetry, etc.).
* Mirrors the prompt-construction logic in the Go aimodel handler.
*/
@Service
@RequiredArgsConstructor
public class AIToolService {
private final AIModelService aiModelService;
public String nameAnalysis(String name) throws Exception {
String prompt = """
你是一个专业的姓名分析师,精通中国传统文化、五行八字和现代心理学。
请对以下姓名进行详细分析,包括:字义解读、五行属性、性格特征、事业运势、感情运势。
姓名:%s
请用结构化的格式输出分析结果。
""".formatted(name);
return callDeepSeek(prompt);
}
public String variableNaming(String description, String language) throws Exception {
String lang = (language == null || language.isBlank()) ? "通用" : language;
String prompt = """
你是一个专业的程序员,精通各种编程语言的命名规范。
请为以下功能/变量描述生成合适的变量名/函数名提供多种命名风格camelCase、snake_case、PascalCase等
编程语言:%s
描述:%s
请提供5-10个命名建议并简要说明每个命名的含义。
""".formatted(lang, description);
return callDeepSeek(prompt);
}
public String poetry(String topic, String style) throws Exception {
String styleHint = (style == null || style.isBlank()) ? "现代诗" : style;
String prompt = """
你是一位才华横溢的诗人,精通各种诗歌形式。
请以"%s"为主题,创作一首%s。
要求:意境优美,情感真挚,语言生动。
""".formatted(topic, styleHint);
return callDeepSeek(prompt);
}
public String translation(String text, String targetLanguage, String sourceLanguage) throws Exception {
String target = (targetLanguage == null || targetLanguage.isBlank()) ? "英语" : targetLanguage;
String source = (sourceLanguage == null || sourceLanguage.isBlank()) ? "自动检测" : sourceLanguage;
String prompt = """
你是一位专业翻译,精通多种语言。
请将以下文本从%s翻译成%s保持原文的语气、风格和文化内涵。
原文:%s
请提供翻译结果,如有必要可附上简短说明。
""".formatted(source, target, text);
return callDeepSeek(prompt);
}
public String classicalConversion(String text) throws Exception {
String prompt = """
你是一位古汉语专家,精通文言文与白话文的互译。
请判断以下文本是文言文还是白话文,并进行相应转换:
- 如果是白话文,请转换为文言文
- 如果是文言文,请转换为现代白话文
原文:%s
请提供转换结果,并简要说明转换要点。
""".formatted(text);
return callDeepSeek(prompt);
}
public String expressionMaker(String input, String style) throws Exception {
String tone = (style == null || style.isBlank()) ? "幽默风趣" : style;
String prompt = """
你是一个表达大师,擅长用各种风格表达同一个意思。
请将以下内容用%s的风格重新表达让表达更加生动有趣
原文:%s
请提供3-5种不同的表达方式。
""".formatted(tone, input);
return callDeepSeek(prompt);
}
public String linuxCommand(String description) throws Exception {
String prompt = """
你是一位Linux系统专家精通各种Shell命令和脚本。
请根据以下描述生成对应的Linux命令
描述:%s
请提供命令示例,并解释命令的各个参数含义。如果有多种实现方式,请列出常用的几种。
""".formatted(description);
return callDeepSeek(prompt);
}
public String markdownFormatting(String text) throws Exception {
String prompt = """
你是一位Markdown格式化专家。
请将以下文本整理并格式化为规范的Markdown格式合理使用标题、列表、代码块等元素
原文:%s
请输出格式化后的Markdown文本。
""".formatted(text);
return callDeepSeek(prompt);
}
public String kinshipCalculator(String relationship) throws Exception {
String prompt = """
你是一位精通中国亲属称谓的专家。
请解析以下亲属关系描述,给出正确的称谓:
关系描述:%s
请给出1.正式称谓 2.口语称谓 3.简要说明关系链。
""".formatted(relationship);
return callDeepSeek(prompt);
}
private String callDeepSeek(String userPrompt) throws Exception {
List<Map<String, String>> messages = List.of(
Map.of("role", "user", "content", userPrompt)
);
return aiModelService.callAI(AIModelService.PROVIDER_DEEPSEEK,
AIModelService.DEFAULT_MODEL_DEEPSEEK, messages);
}
}

View File

@@ -0,0 +1,104 @@
package com.smyhub.infogenie.infogeniebackendjava.service;
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.FeatureClickResponse;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteFeatureCardClick;
import com.smyhub.infogenie.infogeniebackendjava.repository.SiteFeatureCardClickRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Manages feature card click counts with Redis cache-aside (optional).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FeatureClickService {
private static final String KEY_CLICKS_PREFIX = "site:feature-clicks:";
private final SiteFeatureCardClickRepository clickRepo;
private final RedisTemplate<String, Object> redisTemplate;
private final AppProperties appProperties;
@SuppressWarnings("unchecked")
public FeatureClickResponse getClicksForSection(String section) {
String cacheKey = cacheKey(KEY_CLICKS_PREFIX + section);
if (appProperties.isRedisEnabled()) {
try {
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof Map<?, ?> map) {
FeatureClickResponse resp = new FeatureClickResponse();
resp.setSection(section);
resp.setCounts((Map<String, Long>) cached);
return resp;
}
} catch (Exception e) {
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
}
}
List<SiteFeatureCardClick> rows = clickRepo.findBySection(section);
Map<String, Long> counts = rows.stream()
.collect(Collectors.toMap(
SiteFeatureCardClick::getItemId,
SiteFeatureCardClick::getClickCount));
cacheWrite(cacheKey, counts);
FeatureClickResponse resp = new FeatureClickResponse();
resp.setSection(section);
resp.setCounts(counts);
return resp;
}
@Transactional
public FeatureClickResponse increment(String section, String itemId) {
clickRepo.upsertIncrement(section, itemId);
evict(cacheKey(KEY_CLICKS_PREFIX + section));
SiteFeatureCardClick.ClickId id = new SiteFeatureCardClick.ClickId();
id.setSection(section);
id.setItemId(itemId);
long count = clickRepo.findById(id)
.map(SiteFeatureCardClick::getClickCount)
.orElse(0L);
FeatureClickResponse resp = new FeatureClickResponse();
resp.setSection(section);
resp.setItemId(itemId);
resp.setCount(count);
return resp;
}
private String cacheKey(String suffix) {
return appProperties.getRedisKeyPrefix() + suffix;
}
private void cacheWrite(String key, Object value) {
if (!appProperties.isRedisEnabled()) return;
try {
redisTemplate.opsForValue().set(key, value,
Duration.ofSeconds(appProperties.getRedisSiteTtl()));
} catch (Exception e) {
log.warn("Redis write failed for {}: {}", key, e.getMessage());
}
}
private void evict(String key) {
if (!appProperties.isRedisEnabled()) return;
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.warn("Redis evict failed for {}: {}", key, e.getMessage());
}
}
}

View File

@@ -0,0 +1,257 @@
package com.smyhub.infogenie.infogeniebackendjava.service;
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.DiagnosticsResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.HealthResponse;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import javax.sql.DataSource;
import java.sql.Connection;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Health + diagnostics payloads aligned with the Go backend and {@code AdminPage.js}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HealthService {
private static final Pattern JDBC_MYSQL =
Pattern.compile("^jdbc:mysql://([^/?]+)(/([^?]+))?");
private static final DateTimeFormatter RFC3339 = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private final DataSource dataSource;
private final RedisTemplate<String, Object> redisTemplate;
private final RestTemplate restTemplate;
private final AppProperties appProperties;
private final SiteConfigService siteConfigService;
@Value("${server.port:5003}")
private String serverPort;
@Value("${spring.data.redis.host:localhost}")
private String redisHost;
@Value("${spring.data.redis.port:6379}")
private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.database:0}")
private int redisDatabase;
@Value("${APP_ENV:development}")
private String appEnv;
@Value("${MAIL_HOST:}")
private String mailHost;
@Value("${MAIL_PORT:465}")
private int mailPort;
@Value("${MAIL_USERNAME:}")
private String mailUsername;
@Value("${MAIL_PASSWORD:}")
private String mailPassword;
public HealthResponse buildHealthResponse() {
HealthResponse resp = new HealthResponse();
resp.setTimestamp(ZonedDateTime.now(ZoneId.systemDefault()).format(RFC3339));
boolean mysqlOk = pingMysql();
String dbStatus = mysqlOk ? "connected" : "disconnected";
resp.setDatabase(dbStatus);
HealthResponse.MysqlHealth mysql = new HealthResponse.MysqlHealth();
mysql.setOk(mysqlOk);
mysql.setStatus(dbStatus);
resp.setMysql(mysql);
resp.setRedis(buildRedisHealth());
HealthResponse.BackendApiHealth backend = new HealthResponse.BackendApiHealth();
backend.setOk(true);
resp.setBackendApi(backend);
resp.setSixtyApi(probeSixtyApi());
boolean sixtyOk = resp.getSixtyApi() != null && resp.getSixtyApi().isOk();
resp.setStatus(mysqlOk && sixtyOk ? "running" : "degraded");
return resp;
}
public DiagnosticsResponse buildDiagnostics() {
DiagnosticsResponse resp = new DiagnosticsResponse();
DiagnosticsResponse.AppInfo app = new DiagnosticsResponse.AppInfo();
app.setEnv(appEnv);
app.setListenAddr("0.0.0.0:" + serverPort);
app.setListenPort(serverPort);
resp.setApp(app);
JdbcMeta jdbc = resolveJdbcMeta();
DiagnosticsResponse.MysqlInfo mysql = new DiagnosticsResponse.MysqlInfo();
mysql.setHost(jdbc.host());
mysql.setPort(jdbc.port());
mysql.setDatabase(jdbc.database());
mysql.setUser(jdbc.user());
mysql.setPasswordConfigured(jdbc.passwordConfigured());
resp.setMysql(mysql);
resp.setRedis(buildRedisDiagnostics());
SiteSourceResponse src = siteConfigService.get60sSource();
DiagnosticsResponse.SixtyUpstreamInfo sixty = new DiagnosticsResponse.SixtyUpstreamInfo();
sixty.setSourceId(src.getSourceId());
sixty.setBaseUrl(src.getBaseUrl());
sixty.setLabel(src.getLabel());
resp.setSixtyUpstream(sixty);
DiagnosticsResponse.AuthCenterInfo ac = new DiagnosticsResponse.AuthCenterInfo();
ac.setApiUrl(appProperties.getAuthCenterUrl());
resp.setAuthCenter(ac);
DiagnosticsResponse.MailInfo mail = new DiagnosticsResponse.MailInfo();
mail.setHost(mailHost.isBlank() ? null : mailHost);
mail.setPort(mailPort);
mail.setUsername(mailUsername.isBlank() ? null : mailUsername);
mail.setPasswordConfigured(!mailPassword.isBlank());
resp.setMail(mail);
return resp;
}
private HealthResponse.RedisHealth buildRedisHealth() {
HealthResponse.RedisHealth r = new HealthResponse.RedisHealth();
r.setEnabled(appProperties.isRedisEnabled());
if (!appProperties.isRedisEnabled()) {
return r;
}
try {
redisTemplate.opsForValue().get("__ping__");
r.setOk(true);
} catch (Exception e) {
r.setOk(false);
r.setError(e.getMessage());
}
return r;
}
private DiagnosticsResponse.RedisInfo buildRedisDiagnostics() {
DiagnosticsResponse.RedisInfo r = new DiagnosticsResponse.RedisInfo();
r.setEnabled(appProperties.isRedisEnabled());
if (!appProperties.isRedisEnabled()) {
return r;
}
r.setAddr(redisHost + ":" + redisPort);
r.setLogicalDb(redisDatabase);
r.setKeyPrefix(appProperties.getRedisKeyPrefix());
r.setSiteCacheTtlSec((int) appProperties.getRedisSiteTtl());
r.setPasswordConfigured(!redisPassword.isBlank());
return r;
}
private HealthResponse.SixtyApiHealth probeSixtyApi() {
SiteSourceResponse src = siteConfigService.get60sSource();
String base = siteConfigService.get60sBaseUrl();
HealthResponse.SixtyApiHealth sixty = new HealthResponse.SixtyApiHealth();
sixty.setSourceId(src.getSourceId());
sixty.setBaseUrl(src.getBaseUrl());
sixty.setLabel(src.getLabel());
String trimmed = base == null ? "" : base.strip();
if (trimmed.isEmpty()) {
sixty.setOk(false);
sixty.setHttpStatus(0);
sixty.setLatencyMs(0);
sixty.setError("empty_base");
return sixty;
}
String probeUrl =
(trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed) + "/v2/ip";
sixty.setProbeUrl(probeUrl);
long t0 = System.currentTimeMillis();
try {
ResponseEntity<String> re =
restTemplate.exchange(probeUrl, HttpMethod.GET, null, String.class);
sixty.setLatencyMs(System.currentTimeMillis() - t0);
int code = re.getStatusCode().value();
sixty.setHttpStatus(code);
boolean ok = code >= 200 && code < 500;
sixty.setOk(ok);
if (!ok) {
sixty.setError("http_" + code);
}
} catch (RestClientException e) {
sixty.setLatencyMs(System.currentTimeMillis() - t0);
sixty.setHttpStatus(0);
sixty.setOk(false);
sixty.setError(e.getMessage() != null ? e.getMessage() : "request_failed");
}
return sixty;
}
private boolean pingMysql() {
try (Connection c = dataSource.getConnection()) {
return c.isValid(5);
} catch (Exception e) {
log.debug("MySQL ping failed: {}", e.getMessage());
return false;
}
}
private JdbcMeta resolveJdbcMeta() {
if (!(dataSource instanceof HikariDataSource hds)) {
return new JdbcMeta("", "", "", "", false);
}
String url = hds.getJdbcUrl();
String host = "";
String port = "";
String db = "";
Matcher m = JDBC_MYSQL.matcher(url == null ? "" : url);
if (m.find()) {
String hostPart = m.group(1);
int colon = hostPart.lastIndexOf(':');
if (colon > 0) {
host = hostPart.substring(0, colon);
port = hostPart.substring(colon + 1);
} else {
host = hostPart;
port = "3306";
}
if (m.group(3) != null && !m.group(3).isBlank()) {
db = m.group(3);
}
}
String user = hds.getUsername() != null ? hds.getUsername() : "";
String pwd = hds.getPassword();
boolean pwdConfigured = pwd != null && !pwd.isEmpty();
return new JdbcMeta(host, port, db, user, pwdConfigured);
}
private record JdbcMeta(
String host,
String port,
String database,
String user,
boolean passwordConfigured
) {}
}

View File

@@ -0,0 +1,176 @@
package com.smyhub.infogenie.infogeniebackendjava.service;
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sDisabled;
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sUpstream;
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIModelDisabled;
import com.smyhub.infogenie.infogeniebackendjava.repository.Site60sDisabledRepository;
import com.smyhub.infogenie.infogeniebackendjava.repository.Site60sUpstreamRepository;
import com.smyhub.infogenie.infogeniebackendjava.repository.SiteAIModelDisabledRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Manages all public site-configuration reads and admin writes.
* Implements a Redis cache-aside pattern identical to the Go backend:
* reads check Redis first, fall back to MySQL, write back with TTL.
* Admin writes update MySQL then evict the corresponding Redis key.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SiteConfigService {
private static final String KEY_60S_DISABLED = "site:60s-disabled";
private static final String KEY_60S_SOURCE = "site:60s-source";
private static final String KEY_AI_MODEL_DISABLED = "site:ai-model-disabled";
private static final Map<String, String> SOURCE_URLS = Map.of(
"self", "https://60s.api.shumengya.top",
"official", "https://60s.viki.moe"
);
private static final Map<String, String> SOURCE_LABELS = Map.of(
"self", "自建源",
"official", "官方源"
);
private final Site60sDisabledRepository site60sDisabledRepo;
private final SiteAIModelDisabledRepository siteAIModelDisabledRepo;
private final Site60sUpstreamRepository site60sUpstreamRepo;
private final RedisTemplate<String, Object> redisTemplate;
private final AppProperties appProperties;
public List<String> get60sDisabled() {
String cacheKey = cacheKey(KEY_60S_DISABLED);
if (appProperties.isRedisEnabled()) {
try {
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof List<?> list) {
return list.stream().map(Object::toString).toList();
}
} catch (Exception e) {
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
}
}
List<String> result = site60sDisabledRepo.findAll()
.stream().map(Site60sDisabled::getFeatureId).toList();
cacheWrite(cacheKey, result);
return result;
}
@Transactional
public int update60sDisabled(List<String> ids) {
site60sDisabledRepo.deleteAll();
List<Site60sDisabled> rows = ids.stream().map(Site60sDisabled::new).toList();
site60sDisabledRepo.saveAll(rows);
evict(cacheKey(KEY_60S_DISABLED));
return rows.size();
}
public List<String> getAiModelDisabled() {
String cacheKey = cacheKey(KEY_AI_MODEL_DISABLED);
if (appProperties.isRedisEnabled()) {
try {
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof List<?> list) {
return list.stream().map(Object::toString).toList();
}
} catch (Exception e) {
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
}
}
List<String> result = siteAIModelDisabledRepo.findAll()
.stream().map(SiteAIModelDisabled::getAppId).toList();
cacheWrite(cacheKey, result);
return result;
}
@Transactional
public int updateAiModelDisabled(List<String> ids) {
siteAIModelDisabledRepo.deleteAll();
List<SiteAIModelDisabled> rows = ids.stream().map(SiteAIModelDisabled::new).toList();
siteAIModelDisabledRepo.saveAll(rows);
evict(cacheKey(KEY_AI_MODEL_DISABLED));
return rows.size();
}
public SiteSourceResponse get60sSource() {
String cacheKey = cacheKey(KEY_60S_SOURCE);
if (appProperties.isRedisEnabled()) {
try {
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof SiteSourceResponse r) {
return r;
}
} catch (Exception e) {
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
}
}
Site60sUpstream row = site60sUpstreamRepo.findById(1L).orElseGet(() -> {
Site60sUpstream def = new Site60sUpstream();
def.setId(1L);
def.setSourceId("self");
return site60sUpstreamRepo.save(def);
});
SiteSourceResponse resp = buildSourceResponse(row.getSourceId());
cacheWrite(cacheKey, resp);
return resp;
}
@Transactional
public SiteSourceResponse update60sSource(String sourceId) {
if (!SOURCE_URLS.containsKey(sourceId)) {
throw new IllegalArgumentException("Invalid source_id: " + sourceId);
}
Site60sUpstream row = site60sUpstreamRepo.findById(1L).orElse(new Site60sUpstream());
row.setId(1L);
row.setSourceId(sourceId);
site60sUpstreamRepo.save(row);
evict(cacheKey(KEY_60S_SOURCE));
return buildSourceResponse(sourceId);
}
public String get60sBaseUrl() {
return SOURCE_URLS.getOrDefault(get60sSource().getSourceId(),
SOURCE_URLS.get("self"));
}
private SiteSourceResponse buildSourceResponse(String sourceId) {
SiteSourceResponse r = new SiteSourceResponse();
r.setSourceId(sourceId);
r.setBaseUrl(SOURCE_URLS.getOrDefault(sourceId, SOURCE_URLS.get("self")));
r.setLabel(SOURCE_LABELS.getOrDefault(sourceId, ""));
return r;
}
private String cacheKey(String suffix) {
return appProperties.getRedisKeyPrefix() + suffix;
}
private void cacheWrite(String key, Object value) {
if (!appProperties.isRedisEnabled()) return;
try {
redisTemplate.opsForValue().set(key, value,
Duration.ofSeconds(appProperties.getRedisSiteTtl()));
} catch (Exception e) {
log.warn("Redis write failed for {}: {}", key, e.getMessage());
}
}
private void evict(String key) {
if (!appProperties.isRedisEnabled()) return;
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.warn("Redis evict failed for {}: {}", key, e.getMessage());
}
}
}

View File

@@ -0,0 +1,48 @@
package com.smyhub.infogenie.infogeniebackendjava.util;
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.security.MessageDigest;
import java.util.Arrays;
/**
* Validates the X-Site-Admin-Token request header against the configured secret.
* Uses constant-time comparison to prevent timing attacks, mirroring Go's
* crypto/subtle.ConstantTimeCompare.
*/
@Component
@RequiredArgsConstructor
public class AdminTokenUtil {
public static final String ADMIN_TOKEN_HEADER = "X-Site-Admin-Token";
private final AppProperties appProperties;
/**
* Returns true only when the header value matches the configured site admin token
* and neither value is blank.
*/
public boolean isValid(HttpServletRequest request) {
String headerToken = request.getHeader(ADMIN_TOKEN_HEADER);
String configToken = appProperties.getSiteAdminToken();
if (headerToken == null || headerToken.isBlank() || configToken.isBlank()) {
return false;
}
return constantTimeEquals(headerToken.getBytes(), configToken.getBytes());
}
private boolean constantTimeEquals(byte[] a, byte[] b) {
try {
byte[] digestA = MessageDigest.getInstance("SHA-256").digest(a);
byte[] digestB = MessageDigest.getInstance("SHA-256").digest(b);
return Arrays.equals(digestA, digestB) && Arrays.equals(a, b);
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,49 @@
package com.smyhub.infogenie.infogeniebackendjava.util;
import org.springframework.stereotype.Component;
/**
* Helpers for processing AI model responses.
* Mirrors the Go extractOrRaw helper: attempts to parse a JSON string, falls back
* to returning the raw text unchanged.
*/
@Component
public class AiResponseUtil {
private static final int MAX_INPUT_LEN = 5000;
private static final int MAX_CHAT_MSG_COUNT = 20;
public int getMaxInputLen() {
return MAX_INPUT_LEN;
}
public int getMaxChatMsgCount() {
return MAX_CHAT_MSG_COUNT;
}
/**
* Truncates input to the configured maximum character limit.
*/
public String truncateInput(String input) {
if (input == null) return "";
return input.length() > MAX_INPUT_LEN ? input.substring(0, MAX_INPUT_LEN) : input;
}
/**
* Masks an API key, showing only the last 4 characters.
* Returns an empty string for blank keys.
*/
public String maskApiKey(String key) {
if (key == null || key.isBlank()) return "";
if (key.length() <= 4) return "****";
return "****" + key.substring(key.length() - 4);
}
/**
* Returns true when the provided key value should be treated as a placeholder
* rather than a real update (blank or contains asterisks).
*/
public boolean isKeyPlaceholder(String key) {
return key == null || key.isBlank() || key.contains("****");
}
}

View File

@@ -1,3 +1,62 @@
spring:
application:
name: infogenie-backend-java
datasource:
url: jdbc:mysql://${DB_HOST:10.1.1.100}:${DB_PORT:3306}/${DB_NAME:infogenie-test}?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: ${DB_USER:infogenie-test}
password: ${DB_PASSWORD:infogenie-test}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 25
minimum-idle: 5
max-lifetime: 300000
connection-timeout: 30000
# Allow startup without DB in edge cases; JPA will still fail fast on first use
initialization-fail-timeout: 30000
jpa:
hibernate:
ddl-auto: update
show-sql: false
open-in-view: false
properties:
hibernate:
format_sql: false
# Redis — connection details only matter when app.redis-enabled=true.
# Lettuce does NOT connect at startup unless a command is issued, so leaving
# these as non-existent host values is safe when Redis is disabled.
data:
redis:
host: ${REDIS_HOST:10.1.1.100}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:tyh@19900420}
database: ${REDIS_DB:10}
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
server:
port: ${APP_PORT:5002}
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
app:
auth-center-url: ${AUTH_CENTER_API_URL:https://auth.api.shumengya.top}
site-admin-token: ${INFOGENIE_SITE_ADMIN_TOKEN:shumengya520}
redis-enabled: ${REDIS_ENABLED:true}
redis-key-prefix: ${REDIS_KEY_PREFIX:infogenie:java:}
redis-site-ttl: ${REDIS_SITE_TTL:300}
logging:
level:
com.smyhub.infogenie: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: WARN

View File

@@ -0,0 +1,154 @@
# 万象口袋 — JavaSpring Boot后端文档
**技术栈**Java 17 · Spring Boot 3.5 · Spring Web · Spring Data JPA · Hibernate · MySQL · Spring Data RedisLettuce
**模块路径**Maven 工程 `infogenie-backend-java`(坐标 `com.smyhub.infogenie:infogenie-backend-java`
**入口**`com.smyhub.infogenie.infogeniebackendjava.InfogenieBackendJavaApplication` — 载入 `application.yaml`、连接 MySQL、按需使用 Redis、启动内嵌 Tomcat。
**与 Go 后端关系**:路由与 JSON 契约与 `infogenie-backend-go` 对齐,便于前端同一份 `REACT_APP_API_URL` 切换端口联调;生产环境当前以 Go 部署为主时,可将前端仍指向 Go 服务。
---
## 运行与配置
- 配置主文件:[`src/main/resources/application.yaml`](src/main/resources/application.yaml)(占位符 `${VAR:default}` 可被系统环境变量或 IDE Run Configuration 覆盖)。
- Spring Boot **不会自动读取** 仓库内 `.env.development`;该文件仅作文向 `application.yaml` 变量说明(也可自行在 IDE 中一条条导入为环境变量)。
- `**APP_PORT**`:默认 **5002**(与 Go 开发端口一致时需注意二选一占用;亦可改为 5003 等与 Go 错峰)。
- 数据库:通过 `DB_HOST``DB_PORT``DB_NAME``DB_USER``DB_PASSWORD` 注入 JDBC URLyaml 内已给开发用默认值,与 Go `.env.development` 常见配置一致)。
- 认证中心:`AUTH_CENTER_API_URL``app.auth-center-url`
- 站点管理:`INFOGENIE_SITE_ADMIN_TOKEN``app.site-admin-token`(请求头 `X-Site-Admin-Token`)。
### Redis可选
业务是否走缓存由 **`app.redis-enabled`**(环境变量 `REDIS_ENABLED`)决定;关闭时服务逻辑直连 MySQL不在启动阶段强制 Ping Redis。
| 变量 | 说明 |
| --- | --- |
| `REDIS_ENABLED` | `true` / `false`;映射 `app.redis-enabled` |
| `REDIS_HOST` / `REDIS_PORT` | `spring.data.redis.host` / `port` |
| `REDIS_PASSWORD` | 可选 |
| `REDIS_DB` | 逻辑库编号,默认与 yaml 中示例一致 |
| `REDIS_KEY_PREFIX` | `app.redis-key-prefix`,建议与 Go 前缀区分(如 `infogenie:java:` |
| `REDIS_SITE_TTL` | 站点类缓存 TTL`app.redis-site-ttl` |
站点只读接口对 `60s-disabled``60s-source``ai-model-disabled``feature-clicks:{section}` 等做 cache-aside管理端写入后删除对应 Key`SiteConfigService``FeatureClickService`)。
### 邮件(诊断展示)
管理页 diagnostics 中的 `mail` 块来自环境变量 `MAIL_HOST``MAIL_PORT``MAIL_USERNAME``MAIL_PASSWORD`(仅展示是否配置密码,不落明文)。
---
## 健康与诊断
### `GET /api/health`(公开)
响应形状与 Go 一致,供 `AdminPage.js``normalizeHealthPayload` 解析:
- **`status`**`running``degraded`MySQL 未连通或 60s 上游探测失败;**不**因 Redis 将整体标为 degraded与 Go 一致)。
- **`database`**`connected` / `disconnected`
- **`mysql`**`{ ok, status }`
- **`redis`**:未启用 `{ enabled: false }`;启用 `{ enabled: true, ok, error? }`
- **`backend_api`**`{ ok: true }`
- **`sixty_api`**`ok``source_id``base_url``label``probe_url``http_status``latency_ms``error`
**HTTP 状态**:始终 **200**(退化只体现在 JSON `status`),避免前端将健康拉取判为失败。
### `GET /api/admin/site/diagnostics`(需管理员)
请求头 `X-Site-Admin-Token``INFOGENIE_SITE_ADMIN_TOKEN` 一致。返回字段与 Go 对齐:`app``env``listen_addr``listen_port`)、`mysql``redis``sixty_upstream``auth_center.api_url``mail` 等(不含密钥明文)。
---
**根路径**`GET /` — 服务说明与主要 endpoint 列表(`version`**3.3.0-java**)。
---
## 数据库JPA
`spring.jpa.hibernate.ddl-auto: update` 启动时按实体同步表结构,与 Go `AutoMigrate` 使用同一组业务表:
| 实体 | 用途 |
| --- | --- |
| `AIConfig` | 多厂商 AI Key / Base / 模型 |
| `Site60sDisabled` | 60s 前台隐藏 `feature_id` |
| `SiteAIRuntime` | DeepSeek 兼容网关Base + Key + 默认模型),优先级高于部分 `AIConfig` |
| `Site60sUpstream` | 60s 上游(单例 `id=1` |
| `SiteAIModelDisabled` | AI 应用前台隐藏 `app_id` |
| `SiteFeatureCardClick` | 功能卡片点击(`section` + `item_id` 联合主键);增量 SQL 为 MySQL `INSERT … ON DUPLICATE KEY UPDATE`,对应 Repository 使用 `nativeQuery = true` |
---
## 路由与包结构概览
包根:`com.smyhub.infogenie.infogeniebackendjava`
| 包 | 说明 |
| --- | --- |
| `config` | `WebConfig`CORS`RedisConfig``RestTemplateConfig``AppProperties` |
| `controller` | `Health``Auth``User``SiteConfig``Admin``AIModel` |
| `service` | 站点配置、点击统计、AI 调用、AI 工具提示词、运行时配置、健康探测 |
| `repository` | Spring Data JPA |
| `entity` | JPA 实体 |
| `dto.request` / `dto.response` | 入参与出参;**管理端、`item_id` 等入参使用 snake_case**`@JsonProperty`与前端、Go 一致 |
| `filter` | `JwtAuthFilter`:将 `Authorization: Bearer` 转发认证中心 `POST …/api/auth/verify`,结果放入 request attribute |
| `util` | 管理员令牌校验、AI 辅助方法 |
### CORS
`WebConfig` 放宽常用 Method/Header`Authorization``X-Site-Admin-Token`)。
### 认证与用户
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/auth/check` | 可选 Bearer已登录则返回简要 user |
| GET | `/api/user/profile` | **需**有效 Bearer |
### 站点公开配置
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/site/60s-disabled` | 隐藏 60s id 列表 |
| GET | `/api/site/60s-source` | 当前 60s 节点 |
| GET | `/api/site/ai-model-disabled` | 隐藏 AI 应用 id |
| GET | `/api/site/feature-card-clicks?section=` | 点击统计 |
| POST | `/api/site/feature-card-clicks/increment` | body`section``item_id` |
### 站点管理(`X-Site-Admin-Token`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| PUT | `/api/admin/site/60s-disabled` | body`disabled` |
| PUT | `/api/admin/site/60s-source` | body`source_id` |
| PUT | `/api/admin/site/ai-model-disabled` | body`disabled` |
| GET/PUT | `/api/admin/site/ai-runtime` | GET 脱敏PUT body`api_base``api_key``default_model``default_provider` |
| GET | `/api/admin/site/diagnostics` | 配置快照 |
### AI`/api/aimodelapp`,需 Bearer
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/chat``/chat/stream` | 非流式 / SSE 透传 |
| POST | `/name-analysis` 等 | 垂直场景 |
| GET | `/models` | 白名单模型列表 |
上游解析与 Go 一致:`SiteAIRuntime` 在 base+key 齐全时优先;否则读 `AIConfig`deepseek/kimi
---
## 常用命令
```bash
cd infogenie-backend-java
./mvnw test
./mvnw spring-boot:run
```
---
## 与其他工程的关系
- 前端 SPA 通过 `REACT_APP_API_URL` 指向本服务或 Go 服务(路径相同,端口按实际进程调整)。
- 更完整的前端对接说明见 **`infogenie-frontend/前端文档.md`**Go 行为对照见 **`infogenie-backend-go/后端文档.md`**。

View File

@@ -0,0 +1,6 @@
- **项目名称**:万象口袋- 跨平台信息聚合网站
- **时间**2025.08 - 2026.01
- **技术栈**Java 17,Spring Boot,Spring Web,Spring Data JPA,Hibernate,MySQL,Redis,Lettuce,React,Tailwind,Maven
- **描述**:产品定位为一站式资讯与工具导航:首页与栏目以配置驱动,整合 60s 类 API 浏览、休闲小游戏、实用工具箱及多款 AI 应用(后端 OpenAI 格式兼容与 SSE 流式输出)。后端采用 Spring Boot 分层架构Controller / Service / Repository / DTOJPA `ddl-auto=update` 维护与产品一致的 MySQL 业务表;可选 Redis`app.redis-enabled`)对站点只读配置做 cache-aside独立逻辑库与 Key 前缀隔离。公开 `/api/health` 与管理员 `/api/admin/site/diagnostics` 输出结构化 JSON供管理后台监控中间件与连接摘要Bearer 由过滤器转发认证中心校验,管理写入依赖 `X-Site-Admin-Token`,接口入参 snake_case 与前端约定一致。前端基于 React 与 Tailwindaxios 与路由守卫;管理员后台支持 AI 上游、前台显隐与运行状态。Java 侧侧重开发与联调,可与前端通过 `REACT_APP_API_URL` 指向本服务端口对接。
- **项目地址**[https://infogenie.smyhub.com](https://infogenie.smyhub.com) **|** **开源地址**[https://github.com/shumengya/infogenie](https://github.com/shumengya/infogenie)