diff --git a/InfoGenie-frontend/.env.example b/InfoGenie-frontend/.env.example index d337d768..044be9a9 100644 --- a/InfoGenie-frontend/.env.example +++ b/InfoGenie-frontend/.env.example @@ -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= diff --git a/InfoGenie-frontend/src/pages/AdminPage.js b/InfoGenie-frontend/src/pages/AdminPage.js index d1701ffc..8f5b105f 100644 --- a/InfoGenie-frontend/src/pages/AdminPage.js +++ b/InfoGenie-frontend/src/pages/AdminPage.js @@ -725,7 +725,7 @@ const AdminPage = () => { {adminSection === 'ai-upstream' && (

- 此处配置会写入数据库表 site_ai_runtime,并优先于服务端 ai_config.json。 + 此处配置会写入数据库表 site_ai_runtime 密钥留空表示不修改已保存的 API Key。

{loadingAi ? ( diff --git a/infogenie-backend-go/.env.example b/infogenie-backend-go/.env.example index d24f1c2e..6ee2888c 100644 --- a/infogenie-backend-go/.env.example +++ b/infogenie-backend-go/.env.example @@ -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 diff --git a/infogenie-backend-go/Dockerfile b/infogenie-backend-go/Dockerfile index dc10c123..d7155ad8 100644 --- a/infogenie-backend-go/Dockerfile +++ b/infogenie-backend-go/Dockerfile @@ -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 diff --git a/infogenie-backend-go/internal/cache/redis.go b/infogenie-backend-go/internal/cache/redis.go index 63653e7c..51b1bd8e 100644 --- a/infogenie-backend-go/internal/cache/redis.go +++ b/infogenie-backend-go/internal/cache/redis.go @@ -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;未启用或未命中返回 false(err 仅表示 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;未启用或未命中返回 false(err 仅表示 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() +} diff --git a/infogenie-backend-go/scripts/redis_keyspace/main.go b/infogenie-backend-go/scripts/redis_keyspace/main.go index 4039f243..f1a8eccd 100644 --- a/infogenie-backend-go/scripts/redis_keyspace/main.go +++ b/infogenie-backend-go/scripts/redis_keyspace/main.go @@ -1,84 +1,84 @@ -package main - -import ( - "context" - "fmt" - "os" - "strconv" - "strings" - "time" - - "github.com/redis/go-redis/v9" -) - -// 用法: go run . [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 . [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 查看。") +} diff --git a/infogenie-backend-java/pom.xml b/infogenie-backend-java/pom.xml index 8dba4c8f..91edddd9 100644 --- a/infogenie-backend-java/pom.xml +++ b/infogenie-backend-java/pom.xml @@ -6,52 +6,70 @@ org.springframework.boot spring-boot-starter-parent 3.5.12 - + com.smyhub.infogenie infogenie-backend-java 0.0.1-SNAPSHOT infogenie-backend-java - infogenie-backend-java + InfoGenie Spring Boot backend — Java/Spring port of the Go backend - - - - - - - - - - - - + + + + 17 + + org.springframework.boot - spring-boot-starter + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot spring-boot-devtools runtime true - - org.springframework.boot - spring-boot-docker-compose - runtime - true - + + org.projectlombok lombok true + + org.springframework.boot spring-boot-starter-test diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/InfogenieBackendJavaApplication.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/InfogenieBackendJavaApplication.java index 4e065c57..0daf1a8a 100644 --- a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/InfogenieBackendJavaApplication.java +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/InfogenieBackendJavaApplication.java @@ -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) { diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/AppProperties.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/AppProperties.java new file mode 100644 index 00000000..d91811aa --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/AppProperties.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/RedisConfig.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/RedisConfig.java new file mode 100644 index 00000000..e2e6e077 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/RedisConfig.java @@ -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 redisTemplate( + RedisConnectionFactory connectionFactory, + ObjectMapper redisObjectMapper) { + + RedisTemplate 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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/RestTemplateConfig.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/RestTemplateConfig.java new file mode 100644 index 00000000..f94b2ff6 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/RestTemplateConfig.java @@ -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(); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/WebConfig.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/WebConfig.java new file mode 100644 index 00000000..b9ea91ec --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/config/WebConfig.java @@ -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); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AIModelController.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AIModelController.java new file mode 100644 index 00000000..618e6c1c --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AIModelController.java @@ -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> 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> 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> 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> 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> toolError(Exception e) { + log.error("AI tool error: {}", e.getMessage()); + return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AdminController.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AdminController.java new file mode 100644 index 00000000..813767fb --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AdminController.java @@ -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> unauthorized() { + return ResponseEntity.status(401) + .body(Map.of("error", "无效或缺失的管理员令牌")); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AuthController.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AuthController.java new file mode 100644 index 00000000..0c36d5e8 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/AuthController.java @@ -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 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); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/HealthController.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/HealthController.java new file mode 100644 index 00000000..c30966ee --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/HealthController.java @@ -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> root() { + Map 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 health() { + return ResponseEntity.ok(healthService.buildHealthResponse()); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/SiteConfigController.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/SiteConfigController.java new file mode 100644 index 00000000..15397a4a --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/SiteConfigController.java @@ -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 get60sDisabled() { + return ResponseEntity.ok( + new SiteDisabledResponse(siteConfigService.get60sDisabled())); + } + + @GetMapping("/60s-source") + public ResponseEntity get60sSource() { + return ResponseEntity.ok(siteConfigService.get60sSource()); + } + + @GetMapping("/ai-model-disabled") + public ResponseEntity 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); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/UserController.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/UserController.java new file mode 100644 index 00000000..292b028f --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/controller/UserController.java @@ -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)); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AIToolRequest.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AIToolRequest.java new file mode 100644 index 00000000..7c7a2993 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AIToolRequest.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminAIRuntimeRequest.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminAIRuntimeRequest.java new file mode 100644 index 00000000..acb8968c --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminAIRuntimeRequest.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminDisabledUpdateRequest.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminDisabledUpdateRequest.java new file mode 100644 index 00000000..321e6ae4 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminDisabledUpdateRequest.java @@ -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 disabled; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminSourceUpdateRequest.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminSourceUpdateRequest.java new file mode 100644 index 00000000..b37debf0 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/AdminSourceUpdateRequest.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/ChatRequest.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/ChatRequest.java new file mode 100644 index 00000000..357959f3 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/ChatRequest.java @@ -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 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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/FeatureClickIncrementRequest.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/FeatureClickIncrementRequest.java new file mode 100644 index 00000000..54b0856b --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/request/FeatureClickIncrementRequest.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AIRuntimeResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AIRuntimeResponse.java new file mode 100644 index 00000000..d9377868 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AIRuntimeResponse.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AIToolResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AIToolResponse.java new file mode 100644 index 00000000..c5c3e318 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AIToolResponse.java @@ -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 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 getExtraFields() { + return payload; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ApiResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ApiResponse.java new file mode 100644 index 00000000..47791c10 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ApiResponse.java @@ -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 { + + private boolean success; + private T data; + private String error; + + public static ApiResponse ok(T data) { + ApiResponse r = new ApiResponse<>(); + r.success = true; + r.data = data; + return r; + } + + public static ApiResponse fail(String error) { + ApiResponse r = new ApiResponse<>(); + r.success = false; + r.error = error; + return r; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AuthCheckResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AuthCheckResponse.java new file mode 100644 index 00000000..1cc29518 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/AuthCheckResponse.java @@ -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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ChatResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ChatResponse.java new file mode 100644 index 00000000..df3442ab --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ChatResponse.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/DiagnosticsResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/DiagnosticsResponse.java new file mode 100644 index 00000000..bcb672e8 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/DiagnosticsResponse.java @@ -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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/FeatureClickResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/FeatureClickResponse.java new file mode 100644 index 00000000..c2d55b50 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/FeatureClickResponse.java @@ -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 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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/HealthResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/HealthResponse.java new file mode 100644 index 00000000..c11f61e8 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/HealthResponse.java @@ -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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ModelsResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ModelsResponse.java new file mode 100644 index 00000000..c8a58095 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/ModelsResponse.java @@ -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> models; + + @JsonProperty("default_provider") + private String defaultProvider; + + @JsonProperty("default_model") + private String defaultModel; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/SiteDisabledResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/SiteDisabledResponse.java new file mode 100644 index 00000000..47a92aa3 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/SiteDisabledResponse.java @@ -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 disabled; + + public SiteDisabledResponse(List disabled) { + this.disabled = disabled; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/SiteSourceResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/SiteSourceResponse.java new file mode 100644 index 00000000..c97ed882 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/SiteSourceResponse.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/UserProfileResponse.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/UserProfileResponse.java new file mode 100644 index 00000000..634547c0 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/dto/response/UserProfileResponse.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/AIConfig.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/AIConfig.java new file mode 100644 index 00000000..45d57c0a --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/AIConfig.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/Site60sDisabled.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/Site60sDisabled.java new file mode 100644 index 00000000..40bc4b54 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/Site60sDisabled.java @@ -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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/Site60sUpstream.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/Site60sUpstream.java new file mode 100644 index 00000000..70c4b802 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/Site60sUpstream.java @@ -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"; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteAIModelDisabled.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteAIModelDisabled.java new file mode 100644 index 00000000..d22e4714 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteAIModelDisabled.java @@ -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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteAIRuntime.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteAIRuntime.java new file mode 100644 index 00000000..68d3b44e --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteAIRuntime.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteFeatureCardClick.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteFeatureCardClick.java new file mode 100644 index 00000000..e79daf41 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/entity/SiteFeatureCardClick.java @@ -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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/filter/AuthCenterUser.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/filter/AuthCenterUser.java new file mode 100644 index 00000000..e37c2428 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/filter/AuthCenterUser.java @@ -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; +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/filter/JwtAuthFilter.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/filter/JwtAuthFilter.java new file mode 100644 index 00000000..eec2c206 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/filter/JwtAuthFilter.java @@ -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 body = Map.of("token", token); + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity 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; + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/AIConfigRepository.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/AIConfigRepository.java new file mode 100644 index 00000000..a22f2ce5 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/AIConfigRepository.java @@ -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 { + + Optional findByProviderAndIsEnabledTrue(String provider); +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/Site60sDisabledRepository.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/Site60sDisabledRepository.java new file mode 100644 index 00000000..115c49c4 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/Site60sDisabledRepository.java @@ -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 { +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/Site60sUpstreamRepository.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/Site60sUpstreamRepository.java new file mode 100644 index 00000000..4b31a302 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/Site60sUpstreamRepository.java @@ -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 { +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteAIModelDisabledRepository.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteAIModelDisabledRepository.java new file mode 100644 index 00000000..7dcc44b1 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteAIModelDisabledRepository.java @@ -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 { +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteAIRuntimeRepository.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteAIRuntimeRepository.java new file mode 100644 index 00000000..428aca79 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteAIRuntimeRepository.java @@ -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 { +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteFeatureCardClickRepository.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteFeatureCardClickRepository.java new file mode 100644 index 00000000..365c8959 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/repository/SiteFeatureCardClickRepository.java @@ -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 { + + List 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); +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIModelService.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIModelService.java new file mode 100644 index 00000000..1c756ec6 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIModelService.java @@ -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 ALLOWED_DEEPSEEK = Set.of( + "deepseek-chat", "deepseek-reasoner"); + private static final Set 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> 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> 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> messages) { + String resolvedProvider = provider == null ? PROVIDER_DEEPSEEK : provider; + String resolvedModel = resolveModel(resolvedProvider, model); + EndpointConfig ep = resolveEndpoint(resolvedProvider); + + Map body = buildRequestBody(resolvedModel, messages, true); + return new StreamConfig(ep.url(), ep.apiKey(), body); + } + + private String doChat(EndpointConfig ep, String model, + List> messages) throws Exception { + Map body = buildRequestBody(model, messages, false); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(ep.apiKey()); + + HttpEntity> entity = new HttpEntity<>(body, headers); + ResponseEntity 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 buildRequestBody(String model, + List> messages, + boolean stream) { + Map 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 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 body) {} +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIRuntimeService.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIRuntimeService.java new file mode 100644 index 00000000..1ac5818e --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIRuntimeService.java @@ -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 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(); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIToolService.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIToolService.java new file mode 100644 index 00000000..154d1996 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/AIToolService.java @@ -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> messages = List.of( + Map.of("role", "user", "content", userPrompt) + ); + return aiModelService.callAI(AIModelService.PROVIDER_DEEPSEEK, + AIModelService.DEFAULT_MODEL_DEEPSEEK, messages); + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/FeatureClickService.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/FeatureClickService.java new file mode 100644 index 00000000..4f10fe9e --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/FeatureClickService.java @@ -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 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) cached); + return resp; + } + } catch (Exception e) { + log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage()); + } + } + + List rows = clickRepo.findBySection(section); + Map 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()); + } + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/HealthService.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/HealthService.java new file mode 100644 index 00000000..090cbba3 --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/HealthService.java @@ -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 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 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 + ) {} +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/SiteConfigService.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/SiteConfigService.java new file mode 100644 index 00000000..ec0aa38a --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/service/SiteConfigService.java @@ -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 SOURCE_URLS = Map.of( + "self", "https://60s.api.shumengya.top", + "official", "https://60s.viki.moe" + ); + private static final Map SOURCE_LABELS = Map.of( + "self", "自建源", + "official", "官方源" + ); + + private final Site60sDisabledRepository site60sDisabledRepo; + private final SiteAIModelDisabledRepository siteAIModelDisabledRepo; + private final Site60sUpstreamRepository site60sUpstreamRepo; + private final RedisTemplate redisTemplate; + private final AppProperties appProperties; + + public List 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 result = site60sDisabledRepo.findAll() + .stream().map(Site60sDisabled::getFeatureId).toList(); + cacheWrite(cacheKey, result); + return result; + } + + @Transactional + public int update60sDisabled(List ids) { + site60sDisabledRepo.deleteAll(); + List rows = ids.stream().map(Site60sDisabled::new).toList(); + site60sDisabledRepo.saveAll(rows); + evict(cacheKey(KEY_60S_DISABLED)); + return rows.size(); + } + + public List 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 result = siteAIModelDisabledRepo.findAll() + .stream().map(SiteAIModelDisabled::getAppId).toList(); + cacheWrite(cacheKey, result); + return result; + } + + @Transactional + public int updateAiModelDisabled(List ids) { + siteAIModelDisabledRepo.deleteAll(); + List 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()); + } + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/util/AdminTokenUtil.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/util/AdminTokenUtil.java new file mode 100644 index 00000000..77416e6a --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/util/AdminTokenUtil.java @@ -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; + } + } +} diff --git a/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/util/AiResponseUtil.java b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/util/AiResponseUtil.java new file mode 100644 index 00000000..1456156a --- /dev/null +++ b/infogenie-backend-java/src/main/java/com/smyhub/infogenie/infogeniebackendjava/util/AiResponseUtil.java @@ -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("****"); + } +} diff --git a/infogenie-backend-java/src/main/resources/application.yaml b/infogenie-backend-java/src/main/resources/application.yaml index 83170f7d..323421fb 100644 --- a/infogenie-backend-java/src/main/resources/application.yaml +++ b/infogenie-backend-java/src/main/resources/application.yaml @@ -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 diff --git a/infogenie-backend-java/后端文档.md b/infogenie-backend-java/后端文档.md index e69de29b..8389d5f0 100644 --- a/infogenie-backend-java/后端文档.md +++ b/infogenie-backend-java/后端文档.md @@ -0,0 +1,154 @@ +# 万象口袋 — Java(Spring Boot)后端文档 + +**技术栈**:Java 17 · Spring Boot 3.5 · Spring Web · Spring Data JPA · Hibernate · MySQL · Spring Data Redis(Lettuce) + +**模块路径**: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 URL(yaml 内已给开发用默认值,与 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`**。 diff --git a/万象口袋项目经历.md b/万象口袋项目经历-go.md similarity index 100% rename from 万象口袋项目经历.md rename to 万象口袋项目经历-go.md diff --git a/万象口袋项目经历-java.md b/万象口袋项目经历-java.md new file mode 100644 index 00000000..09b995f4 --- /dev/null +++ b/万象口袋项目经历-java.md @@ -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 / DTO),JPA `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 与 Tailwind,axios 与路由守卫;管理员后台支持 AI 上游、前台显隐与运行状态。Java 侧侧重开发与联调,可与前端通过 `REACT_APP_API_URL` 指向本服务端口对接。 +- **项目地址**:[https://infogenie.smyhub.com](https://infogenie.smyhub.com) **|** **开源地址**:[https://github.com/shumengya/infogenie](https://github.com/shumengya/infogenie) +