Update InfoGenie
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -725,7 +725,7 @@ const AdminPage = () => {
|
||||
{adminSection === 'ai-upstream' && (
|
||||
<InfoCard>
|
||||
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
|
||||
此处配置会写入数据库表 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_ai_runtime</code>,并<strong>优先于</strong>服务端 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">ai_config.json</code>。
|
||||
此处配置会写入数据库表 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_ai_runtime</code>
|
||||
密钥留空表示<strong>不修改</strong>已保存的 API Key。
|
||||
</p>
|
||||
{loadingAi ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
238
infogenie-backend-go/internal/cache/redis.go
vendored
238
infogenie-backend-go/internal/cache/redis.go
vendored
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 用法: go run . <host:port> [password]
|
||||
// 例: go run . 10.1.1.100:6379
|
||||
func main() {
|
||||
addr := "10.1.1.100:6379"
|
||||
pass := os.Getenv("REDIS_PASSWORD")
|
||||
if len(os.Args) >= 2 {
|
||||
addr = os.Args[1]
|
||||
}
|
||||
if len(os.Args) >= 3 {
|
||||
pass = os.Args[2]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r := redis.NewClient(&redis.Options{Addr: addr, Password: pass})
|
||||
defer r.Close()
|
||||
if err := r.Ping(ctx).Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "连接失败 %s: %v\n", addr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s, err := r.Info(ctx, "keyspace").Result()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "INFO keyspace: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
used := make(map[int]int64)
|
||||
for _, line := range strings.Split(s, "\r\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "db") {
|
||||
continue
|
||||
}
|
||||
colon := strings.IndexByte(line, ':')
|
||||
if colon <= 2 {
|
||||
continue
|
||||
}
|
||||
dbStr := line[2:colon]
|
||||
dbNum, err := strconv.Atoi(dbStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var keys int64
|
||||
for _, part := range strings.Split(line[colon+1:], ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "keys=") {
|
||||
keys, _ = strconv.ParseInt(strings.TrimPrefix(part, "keys="), 10, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
used[dbNum] = keys
|
||||
}
|
||||
|
||||
fmt.Printf("Redis %s — 逻辑库 key 统计(INFO keyspace)\n\n", addr)
|
||||
for i := 0; i < 16; i++ {
|
||||
k, ok := used[i]
|
||||
if !ok {
|
||||
fmt.Printf(" db%-2d : 无记录(通常为 0 个 key / 未写入过)\n", i)
|
||||
} else if k == 0 {
|
||||
fmt.Printf(" db%-2d : 0 keys\n", i)
|
||||
} else {
|
||||
fmt.Printf(" db%-2d : %d keys (已占用)\n", i, k)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("说明: Redis 默认 16 个逻辑库(0-15);「无记录」一般可当空闲选用。")
|
||||
fmt.Println("若 redis.conf 里 databases=N,实际库数量可能更大,可用 redis-cli CONFIG GET databases 查看。")
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 用法: go run . <host:port> [password]
|
||||
// 例: go run . 10.1.1.100:6379
|
||||
func main() {
|
||||
addr := "10.1.1.100:6379"
|
||||
pass := os.Getenv("REDIS_PASSWORD")
|
||||
if len(os.Args) >= 2 {
|
||||
addr = os.Args[1]
|
||||
}
|
||||
if len(os.Args) >= 3 {
|
||||
pass = os.Args[2]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r := redis.NewClient(&redis.Options{Addr: addr, Password: pass})
|
||||
defer r.Close()
|
||||
if err := r.Ping(ctx).Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "连接失败 %s: %v\n", addr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s, err := r.Info(ctx, "keyspace").Result()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "INFO keyspace: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
used := make(map[int]int64)
|
||||
for _, line := range strings.Split(s, "\r\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "db") {
|
||||
continue
|
||||
}
|
||||
colon := strings.IndexByte(line, ':')
|
||||
if colon <= 2 {
|
||||
continue
|
||||
}
|
||||
dbStr := line[2:colon]
|
||||
dbNum, err := strconv.Atoi(dbStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var keys int64
|
||||
for _, part := range strings.Split(line[colon+1:], ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "keys=") {
|
||||
keys, _ = strconv.ParseInt(strings.TrimPrefix(part, "keys="), 10, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
used[dbNum] = keys
|
||||
}
|
||||
|
||||
fmt.Printf("Redis %s — 逻辑库 key 统计(INFO keyspace)\n\n", addr)
|
||||
for i := 0; i < 16; i++ {
|
||||
k, ok := used[i]
|
||||
if !ok {
|
||||
fmt.Printf(" db%-2d : 无记录(通常为 0 个 key / 未写入过)\n", i)
|
||||
} else if k == 0 {
|
||||
fmt.Printf(" db%-2d : 0 keys\n", i)
|
||||
} else {
|
||||
fmt.Printf(" db%-2d : %d keys (已占用)\n", i, k)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("说明: Redis 默认 16 个逻辑库(0-15);「无记录」一般可当空闲选用。")
|
||||
fmt.Println("若 redis.conf 里 databases=N,实际库数量可能更大,可用 redis-cli CONFIG GET databases 查看。")
|
||||
}
|
||||
|
||||
@@ -6,52 +6,70 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.12</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
<relativePath/>
|
||||
</parent>
|
||||
<groupId>com.smyhub.infogenie</groupId>
|
||||
<artifactId>infogenie-backend-java</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>infogenie-backend-java</name>
|
||||
<description>infogenie-backend-java</description>
|
||||
<description>InfoGenie Spring Boot backend — Java/Spring port of the Go backend</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<licenses><license/></licenses>
|
||||
<developers><developer/></developers>
|
||||
<scm><connection/><developerConnection/><tag/><url/></scm>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Web MVC -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA / Hibernate -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Bean Validation (@Valid, @NotBlank …) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL driver -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Hot-reload in dev -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-docker-compose</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Configures a RedisTemplate with String keys and JSON values.
|
||||
* The template is always declared; actual usage is guarded by
|
||||
* AppProperties.redisEnabled checks in service code, so the application
|
||||
* starts normally even when Redis is not available and REDIS_ENABLED=false.
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper redisObjectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return mapper;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(
|
||||
RedisConnectionFactory connectionFactory,
|
||||
ObjectMapper redisObjectMapper) {
|
||||
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
GenericJackson2JsonRedisSerializer jsonSerializer =
|
||||
new GenericJackson2JsonRedisSerializer(redisObjectMapper);
|
||||
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
template.setValueSerializer(jsonSerializer);
|
||||
template.setHashValueSerializer(jsonSerializer);
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AIToolRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.ChatRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AIToolResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.ChatResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.ModelsResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.filter.AuthCenterUser;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.AIModelService;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.AIToolService;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.util.AiResponseUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI model endpoints — requires a valid Bearer token on every request.
|
||||
*
|
||||
* POST /api/aimodelapp/chat
|
||||
* POST /api/aimodelapp/chat/stream (SSE)
|
||||
* POST /api/aimodelapp/name-analysis
|
||||
* POST /api/aimodelapp/variable-naming
|
||||
* POST /api/aimodelapp/poetry
|
||||
* POST /api/aimodelapp/translation
|
||||
* POST /api/aimodelapp/classical_conversion
|
||||
* POST /api/aimodelapp/expression-maker
|
||||
* POST /api/aimodelapp/linux-command
|
||||
* POST /api/aimodelapp/markdown_formatting
|
||||
* POST /api/aimodelapp/kinship-calculator
|
||||
* GET /api/aimodelapp/models
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/aimodelapp")
|
||||
@RequiredArgsConstructor
|
||||
public class AIModelController {
|
||||
|
||||
private final AIModelService aiModelService;
|
||||
private final AIToolService aiToolService;
|
||||
private final AiResponseUtil aiResponseUtil;
|
||||
private final RestTemplate aiRestTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// ──────────────────────────── Auth guard ─────────────────────────────
|
||||
|
||||
private boolean isAuthenticated(HttpServletRequest req) {
|
||||
return req.getAttribute(AuthCenterUser.AUTH_USER_ATTR) != null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, String>> unauthenticated() {
|
||||
return ResponseEntity.status(401).body(Map.of("error", "未授权,请先登录"));
|
||||
}
|
||||
|
||||
// ──────────────────────────── Chat ───────────────────────────────────
|
||||
|
||||
@PostMapping("/chat")
|
||||
public ResponseEntity<?> chat(@RequestBody ChatRequest req,
|
||||
HttpServletRequest httpReq) throws Exception {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
|
||||
List<Map<String, String>> messages = validateAndConvertMessages(req);
|
||||
if (messages == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "消息列表无效或超出限制"));
|
||||
}
|
||||
|
||||
String provider = req.getProvider() != null ? req.getProvider() : AIModelService.PROVIDER_DEEPSEEK;
|
||||
String model = req.getModel();
|
||||
|
||||
String content = aiModelService.callAI(provider, model, messages);
|
||||
|
||||
ChatResponse resp = new ChatResponse();
|
||||
resp.setContent(content);
|
||||
resp.setProvider(provider);
|
||||
resp.setModel(model != null ? model : AIModelService.DEFAULT_MODEL_DEEPSEEK);
|
||||
resp.setTimestamp(System.currentTimeMillis());
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public void chatStream(@RequestBody ChatRequest req,
|
||||
HttpServletRequest httpReq,
|
||||
HttpServletResponse httpResp) throws Exception {
|
||||
if (!isAuthenticated(httpReq)) {
|
||||
httpResp.setStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Map<String, String>> messages = validateAndConvertMessages(req);
|
||||
if (messages == null) {
|
||||
httpResp.setStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
String provider = req.getProvider() != null ? req.getProvider() : AIModelService.PROVIDER_DEEPSEEK;
|
||||
AIModelService.StreamConfig streamCfg =
|
||||
aiModelService.buildStreamConfig(provider, req.getModel(), messages);
|
||||
|
||||
httpResp.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
httpResp.setCharacterEncoding("UTF-8");
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(streamCfg.apiKey());
|
||||
|
||||
String bodyJson = objectMapper.writeValueAsString(streamCfg.body());
|
||||
|
||||
RequestCallback requestCallback = clientHttpRequest -> {
|
||||
clientHttpRequest.getHeaders().addAll(headers);
|
||||
try (OutputStream os = clientHttpRequest.getBody()) {
|
||||
os.write(bodyJson.getBytes());
|
||||
}
|
||||
};
|
||||
|
||||
try (PrintWriter writer = httpResp.getWriter()) {
|
||||
aiRestTemplate.execute(streamCfg.url(), HttpMethod.POST, requestCallback,
|
||||
clientHttpResponse -> {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(clientHttpResponse.getBody()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
writer.write(line + "\n");
|
||||
writer.flush();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.warn("Stream proxy error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── Models ─────────────────────────────────
|
||||
|
||||
@GetMapping("/models")
|
||||
public ResponseEntity<?> getModels(HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
ModelsResponse resp = new ModelsResponse();
|
||||
resp.setModels(aiModelService.getAllowedModels());
|
||||
resp.setDefaultProvider(AIModelService.PROVIDER_DEEPSEEK);
|
||||
resp.setDefaultModel(AIModelService.DEFAULT_MODEL_DEEPSEEK);
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
// ──────────────────────────── AI Tools ───────────────────────────────
|
||||
|
||||
@PostMapping("/name-analysis")
|
||||
public ResponseEntity<?> nameAnalysis(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.nameAnalysis(
|
||||
aiResponseUtil.truncateInput(req.getInput()));
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("analysis", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/variable-naming")
|
||||
public ResponseEntity<?> variableNaming(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.variableNaming(
|
||||
aiResponseUtil.truncateInput(req.getInput()), req.getLanguage());
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("suggestions", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/poetry")
|
||||
public ResponseEntity<?> poetry(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.poetry(
|
||||
aiResponseUtil.truncateInput(req.getInput()), req.getStyle());
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("poem", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/translation")
|
||||
public ResponseEntity<?> translation(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.translation(
|
||||
aiResponseUtil.truncateInput(req.getInput()),
|
||||
req.getTargetLanguage(), req.getSourceLanguage());
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("translation", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/classical_conversion")
|
||||
public ResponseEntity<?> classicalConversion(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.classicalConversion(
|
||||
aiResponseUtil.truncateInput(req.getInput()));
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("result", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/expression-maker")
|
||||
public ResponseEntity<?> expressionMaker(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.expressionMaker(
|
||||
aiResponseUtil.truncateInput(req.getInput()), req.getStyle());
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("expressions", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/linux-command")
|
||||
public ResponseEntity<?> linuxCommand(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.linuxCommand(
|
||||
aiResponseUtil.truncateInput(req.getInput()));
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("command", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/markdown_formatting")
|
||||
public ResponseEntity<?> markdownFormatting(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.markdownFormatting(
|
||||
aiResponseUtil.truncateInput(req.getInput()));
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("markdown", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/kinship-calculator")
|
||||
public ResponseEntity<?> kinshipCalculator(@RequestBody AIToolRequest req,
|
||||
HttpServletRequest httpReq) {
|
||||
if (!isAuthenticated(httpReq)) return unauthenticated();
|
||||
try {
|
||||
String result = aiToolService.kinshipCalculator(
|
||||
aiResponseUtil.truncateInput(req.getInput()));
|
||||
return ResponseEntity.ok(new AIToolResponse()
|
||||
.addField("result", result)
|
||||
.addField("timestamp", System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
return toolError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── Helpers ────────────────────────────────
|
||||
|
||||
private List<Map<String, String>> validateAndConvertMessages(ChatRequest req) {
|
||||
if (req.getMessages() == null || req.getMessages().isEmpty()) return null;
|
||||
if (req.getMessages().size() > aiResponseUtil.getMaxChatMsgCount()) return null;
|
||||
return req.getMessages().stream()
|
||||
.map(m -> Map.of("role", m.getRole(), "content",
|
||||
aiResponseUtil.truncateInput(m.getContent())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, String>> toolError(Exception e) {
|
||||
log.error("AI tool error: {}", e.getMessage());
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.controller;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminAIRuntimeRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminDisabledUpdateRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminSourceUpdateRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AIRuntimeResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.DiagnosticsResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.AIRuntimeService;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.HealthService;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.SiteConfigService;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.util.AdminTokenUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Admin-only endpoints protected by X-Site-Admin-Token header.
|
||||
*
|
||||
* PUT /api/admin/site/60s-disabled
|
||||
* PUT /api/admin/site/60s-source
|
||||
* PUT /api/admin/site/ai-model-disabled
|
||||
* GET /api/admin/site/ai-runtime
|
||||
* PUT /api/admin/site/ai-runtime
|
||||
* GET /api/admin/site/diagnostics
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/site")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final SiteConfigService siteConfigService;
|
||||
private final AIRuntimeService aiRuntimeService;
|
||||
private final HealthService healthService;
|
||||
private final AdminTokenUtil adminTokenUtil;
|
||||
|
||||
@PutMapping("/60s-disabled")
|
||||
public ResponseEntity<?> update60sDisabled(@RequestBody AdminDisabledUpdateRequest req,
|
||||
HttpServletRequest request) {
|
||||
if (!adminTokenUtil.isValid(request)) return unauthorized();
|
||||
int count = siteConfigService.update60sDisabled(
|
||||
req.getDisabled() != null ? req.getDisabled() : java.util.List.of());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "count", count));
|
||||
}
|
||||
|
||||
@PutMapping("/60s-source")
|
||||
public ResponseEntity<?> update60sSource(@RequestBody AdminSourceUpdateRequest req,
|
||||
HttpServletRequest request) {
|
||||
if (!adminTokenUtil.isValid(request)) return unauthorized();
|
||||
try {
|
||||
SiteSourceResponse resp = siteConfigService.update60sSource(req.getSourceId());
|
||||
return ResponseEntity.ok(resp);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/ai-model-disabled")
|
||||
public ResponseEntity<?> updateAiModelDisabled(@RequestBody AdminDisabledUpdateRequest req,
|
||||
HttpServletRequest request) {
|
||||
if (!adminTokenUtil.isValid(request)) return unauthorized();
|
||||
int count = siteConfigService.updateAiModelDisabled(
|
||||
req.getDisabled() != null ? req.getDisabled() : java.util.List.of());
|
||||
return ResponseEntity.ok(Map.of("ok", true, "count", count));
|
||||
}
|
||||
|
||||
@GetMapping("/ai-runtime")
|
||||
public ResponseEntity<?> getAiRuntime(HttpServletRequest request) {
|
||||
if (!adminTokenUtil.isValid(request)) return unauthorized();
|
||||
AIRuntimeResponse resp = aiRuntimeService.getRuntimeResponse();
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
@PutMapping("/ai-runtime")
|
||||
public ResponseEntity<?> updateAiRuntime(@RequestBody AdminAIRuntimeRequest req,
|
||||
HttpServletRequest request) {
|
||||
if (!adminTokenUtil.isValid(request)) return unauthorized();
|
||||
AIRuntimeResponse resp = aiRuntimeService.updateRuntime(req);
|
||||
return ResponseEntity.ok(Map.of("ok", true, "runtime", resp));
|
||||
}
|
||||
|
||||
@GetMapping("/diagnostics")
|
||||
public ResponseEntity<?> getDiagnostics(HttpServletRequest request) {
|
||||
if (!adminTokenUtil.isValid(request)) return unauthorized();
|
||||
DiagnosticsResponse resp = healthService.buildDiagnostics();
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> unauthorized() {
|
||||
return ResponseEntity.status(401)
|
||||
.body(Map.of("error", "无效或缺失的管理员令牌"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.controller;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AuthCheckResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.filter.AuthCenterUser;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* GET /api/auth/check
|
||||
* Optional auth — returns logged_in:true with basic user info if a valid token is present.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<AuthCheckResponse> check(HttpServletRequest request) {
|
||||
AuthCheckResponse resp = new AuthCheckResponse();
|
||||
AuthCenterUser user =
|
||||
(AuthCenterUser) request.getAttribute(AuthCenterUser.AUTH_USER_ATTR);
|
||||
|
||||
if (user != null) {
|
||||
resp.setLoggedIn(true);
|
||||
AuthCheckResponse.UserInfo info = new AuthCheckResponse.UserInfo();
|
||||
info.setAccount(user.getAccount());
|
||||
info.setUsername(user.getUsername());
|
||||
info.setEmail(user.getEmail());
|
||||
resp.setUser(info);
|
||||
} else {
|
||||
resp.setLoggedIn(false);
|
||||
}
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.controller;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.HealthResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.HealthService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* GET / — service banner
|
||||
* GET /api/health — component health check
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class HealthController {
|
||||
|
||||
private final HealthService healthService;
|
||||
|
||||
@GetMapping("/")
|
||||
public ResponseEntity<Map<String, Object>> root() {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("service", "InfoGenie Backend");
|
||||
body.put("version", "3.3.0-java");
|
||||
body.put("status", "running");
|
||||
body.put("endpoints", new String[]{
|
||||
"GET /api/health",
|
||||
"GET /api/auth/check",
|
||||
"GET /api/user/profile",
|
||||
"GET /api/site/60s-disabled",
|
||||
"GET /api/site/60s-source",
|
||||
"GET /api/site/ai-model-disabled",
|
||||
"GET /api/site/feature-card-clicks",
|
||||
"POST /api/site/feature-card-clicks/increment",
|
||||
"GET /api/admin/site/diagnostics",
|
||||
"POST /api/aimodelapp/chat",
|
||||
"POST /api/aimodelapp/chat/stream",
|
||||
"GET /api/aimodelapp/models"
|
||||
});
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as Go: always HTTP 200; degradation is expressed only in JSON {@code status} field.
|
||||
* AdminPage treats non-2xx as a failed fetch and clears the monitoring payload.
|
||||
*/
|
||||
@GetMapping("/api/health")
|
||||
public ResponseEntity<HealthResponse> health() {
|
||||
return ResponseEntity.ok(healthService.buildHealthResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.controller;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.FeatureClickIncrementRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.FeatureClickResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteDisabledResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.FeatureClickService;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.service.SiteConfigService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Public site-configuration read endpoints and feature-card click tracking.
|
||||
*
|
||||
* GET /api/site/60s-disabled
|
||||
* GET /api/site/60s-source
|
||||
* GET /api/site/ai-model-disabled
|
||||
* GET /api/site/feature-card-clicks?section=...
|
||||
* POST /api/site/feature-card-clicks/increment
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/site")
|
||||
@RequiredArgsConstructor
|
||||
public class SiteConfigController {
|
||||
|
||||
private final SiteConfigService siteConfigService;
|
||||
private final FeatureClickService featureClickService;
|
||||
|
||||
@GetMapping("/60s-disabled")
|
||||
public ResponseEntity<SiteDisabledResponse> get60sDisabled() {
|
||||
return ResponseEntity.ok(
|
||||
new SiteDisabledResponse(siteConfigService.get60sDisabled()));
|
||||
}
|
||||
|
||||
@GetMapping("/60s-source")
|
||||
public ResponseEntity<SiteSourceResponse> get60sSource() {
|
||||
return ResponseEntity.ok(siteConfigService.get60sSource());
|
||||
}
|
||||
|
||||
@GetMapping("/ai-model-disabled")
|
||||
public ResponseEntity<SiteDisabledResponse> getAiModelDisabled() {
|
||||
return ResponseEntity.ok(
|
||||
new SiteDisabledResponse(siteConfigService.getAiModelDisabled()));
|
||||
}
|
||||
|
||||
@GetMapping("/feature-card-clicks")
|
||||
public ResponseEntity<?> getFeatureClicks(@RequestParam(required = false) String section) {
|
||||
if (section == null || section.isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "section parameter is required"));
|
||||
}
|
||||
return ResponseEntity.ok(featureClickService.getClicksForSection(section));
|
||||
}
|
||||
|
||||
@PostMapping("/feature-card-clicks/increment")
|
||||
public ResponseEntity<?> incrementClick(@RequestBody FeatureClickIncrementRequest req) {
|
||||
if (req.getSection() == null || req.getItemId() == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "section and item_id are required"));
|
||||
}
|
||||
FeatureClickResponse resp =
|
||||
featureClickService.increment(req.getSection(), req.getItemId());
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request body for PUT /api/admin/site/60s-disabled and PUT /api/admin/site/ai-model-disabled.
|
||||
* The list replaces the entire disabled set in the database.
|
||||
*/
|
||||
@Data
|
||||
public class AdminDisabledUpdateRequest {
|
||||
|
||||
private List<String> disabled;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.request;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request body for POST /api/aimodelapp/chat and /api/aimodelapp/chat/stream.
|
||||
*/
|
||||
@Data
|
||||
public class ChatRequest {
|
||||
|
||||
@NotEmpty(message = "messages must not be empty")
|
||||
@Valid
|
||||
private List<ChatMessage> messages;
|
||||
|
||||
/** Defaults to "deepseek" when absent. */
|
||||
private String provider;
|
||||
|
||||
/** Defaults to "deepseek-chat" when absent. */
|
||||
private String model;
|
||||
|
||||
@Data
|
||||
public static class ChatMessage {
|
||||
private String role;
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Generic response wrapper for all vertical AI-tool endpoints.
|
||||
* The "payload" map is serialised as flat top-level JSON fields via @JsonAnyGetter,
|
||||
* allowing each tool to return its own named fields alongside "success" and "timestamp".
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AIToolResponse {
|
||||
|
||||
private boolean success = true;
|
||||
private long timestamp;
|
||||
private String error;
|
||||
|
||||
private final Map<String, Object> payload = new LinkedHashMap<>();
|
||||
|
||||
public boolean isSuccess() { return success; }
|
||||
public void setSuccess(boolean success) { this.success = success; }
|
||||
|
||||
public long getTimestamp() { return timestamp; }
|
||||
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
|
||||
|
||||
public String getError() { return error; }
|
||||
public void setError(String error) { this.error = error; }
|
||||
|
||||
public AIToolResponse addField(String key, Object value) {
|
||||
payload.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> getExtraFields() {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Generic wrapper used for most API responses.
|
||||
* Mirrors Go handlers that return { "success": true, "data": {...} } or
|
||||
* { "success": false, "error": "..." }.
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private boolean success;
|
||||
private T data;
|
||||
private String error;
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.success = true;
|
||||
r.data = data;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> fail(String error) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.success = false;
|
||||
r.error = error;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Response for GET /api/site/feature-card-clicks.
|
||||
* { "section": "...", "counts": { "itemId": 42, ... } }
|
||||
*
|
||||
* Increment response also includes "item_id" and "count" fields.
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class FeatureClickResponse {
|
||||
|
||||
private String section;
|
||||
|
||||
/** Map of itemId → clickCount, used by the GET endpoint. */
|
||||
private Map<String, Long> counts;
|
||||
|
||||
/** Single incremented item_id, used by the POST increment endpoint. */
|
||||
@JsonProperty("item_id")
|
||||
private String itemId;
|
||||
|
||||
/** Updated click count after increment. */
|
||||
private Long count;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Response for GET /api/aimodelapp/models.
|
||||
* { "success": true, "models": { "deepseek": [...], "kimi": [...] },
|
||||
* "default_provider": "deepseek", "default_model": "deepseek-chat" }
|
||||
*/
|
||||
@Data
|
||||
public class ModelsResponse {
|
||||
|
||||
private boolean success = true;
|
||||
private Map<String, List<String>> models;
|
||||
|
||||
@JsonProperty("default_provider")
|
||||
private String defaultProvider;
|
||||
|
||||
@JsonProperty("default_model")
|
||||
private String defaultModel;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.dto.response;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Response for GET /api/site/60s-disabled and GET /api/site/ai-model-disabled.
|
||||
* { "disabled": ["item1", "item2", ...] }
|
||||
*/
|
||||
@Data
|
||||
public class SiteDisabledResponse {
|
||||
|
||||
private List<String> disabled;
|
||||
|
||||
public SiteDisabledResponse(List<String> disabled) {
|
||||
this.disabled = disabled;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Intercepts every request and, if a Bearer token is present in the Authorization header,
|
||||
* forwards it to the external auth-center for verification.
|
||||
*
|
||||
* On success the resulting AuthCenterUser is stored as a request attribute.
|
||||
* On failure the attribute is simply absent — controllers decide how to react
|
||||
* (401 for required-auth, ignored for optional-auth endpoints).
|
||||
*
|
||||
* Mirrors Go's OptionalJWTAuth / JWTAuth middleware pair.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
AuthCenterUser user = verifyToken(token);
|
||||
if (user != null) {
|
||||
request.setAttribute(AuthCenterUser.AUTH_USER_ATTR, user);
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private AuthCenterUser verifyToken(String token) {
|
||||
try {
|
||||
String url = appProperties.getAuthCenterUrl() + "/api/auth/verify";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Auth-Client", "infogenie");
|
||||
headers.set("X-Auth-Client-Name", "万象口袋");
|
||||
|
||||
Map<String, String> body = Map.of("token", token);
|
||||
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
ResponseEntity<Map> responseEntity =
|
||||
restTemplate.postForEntity(url, entity, Map.class);
|
||||
|
||||
if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) {
|
||||
Map<?, ?> responseBody = responseEntity.getBody();
|
||||
Object userObj = responseBody.get("user");
|
||||
if (userObj != null) {
|
||||
String userJson = objectMapper.writeValueAsString(userObj);
|
||||
return objectMapper.readValue(userJson, AuthCenterUser.class);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Auth-center token verification failed: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.repository;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.AIConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface AIConfigRepository extends JpaRepository<AIConfig, Long> {
|
||||
|
||||
Optional<AIConfig> findByProviderAndIsEnabledTrue(String provider);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.repository;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sDisabled;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface Site60sDisabledRepository extends JpaRepository<Site60sDisabled, String> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.repository;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sUpstream;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface Site60sUpstreamRepository extends JpaRepository<Site60sUpstream, Long> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.repository;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIModelDisabled;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface SiteAIModelDisabledRepository extends JpaRepository<SiteAIModelDisabled, String> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.repository;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIRuntime;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface SiteAIRuntimeRepository extends JpaRepository<SiteAIRuntime, Long> {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.repository;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteFeatureCardClick;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface SiteFeatureCardClickRepository
|
||||
extends JpaRepository<SiteFeatureCardClick, SiteFeatureCardClick.ClickId> {
|
||||
|
||||
List<SiteFeatureCardClick> findBySection(String section);
|
||||
|
||||
@Modifying
|
||||
@Query(value = """
|
||||
INSERT INTO site_feature_card_clicks (section, item_id, click_count, updated_at)
|
||||
VALUES (:section, :itemId, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()
|
||||
""", nativeQuery = true)
|
||||
void upsertIncrement(@Param("section") String section, @Param("itemId") String itemId);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.AIConfig;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIRuntime;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.repository.AIConfigRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Proxies chat and AI-tool requests to DeepSeek or Kimi endpoints.
|
||||
* Endpoint resolution mirrors the Go ai.go service:
|
||||
* - DeepSeek: use SiteAIRuntime if api_base+api_key are set, else ai_configs table.
|
||||
* - Kimi: always from ai_configs table (provider=kimi).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AIModelService {
|
||||
|
||||
public static final String PROVIDER_DEEPSEEK = "deepseek";
|
||||
public static final String PROVIDER_KIMI = "kimi";
|
||||
public static final String DEFAULT_MODEL_DEEPSEEK = "deepseek-chat";
|
||||
|
||||
private static final Set<String> ALLOWED_DEEPSEEK = Set.of(
|
||||
"deepseek-chat", "deepseek-reasoner");
|
||||
private static final Set<String> ALLOWED_KIMI = Set.of(
|
||||
"kimi-k2-0905-preview", "kimi-k2-0711-preview");
|
||||
|
||||
private final AIConfigRepository aiConfigRepo;
|
||||
private final AIRuntimeService aiRuntimeService;
|
||||
@Qualifier("aiRestTemplate")
|
||||
private final RestTemplate aiRestTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public Map<String, List<String>> getAllowedModels() {
|
||||
return Map.of(
|
||||
PROVIDER_DEEPSEEK, new ArrayList<>(ALLOWED_DEEPSEEK),
|
||||
PROVIDER_KIMI, new ArrayList<>(ALLOWED_KIMI)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the AI provider and returns the assistant message content.
|
||||
* Retries up to {@code maxRetries} times for DeepSeek; once for Kimi.
|
||||
*/
|
||||
public String callAI(String provider, String model,
|
||||
List<Map<String, String>> messages) throws Exception {
|
||||
String resolvedProvider = provider == null ? PROVIDER_DEEPSEEK : provider;
|
||||
String resolvedModel = resolveModel(resolvedProvider, model);
|
||||
|
||||
EndpointConfig ep = resolveEndpoint(resolvedProvider);
|
||||
int maxAttempts = PROVIDER_DEEPSEEK.equals(resolvedProvider) ? 3 : 1;
|
||||
|
||||
Exception lastEx = null;
|
||||
for (int i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
return doChat(ep, resolvedModel, messages);
|
||||
} catch (Exception e) {
|
||||
lastEx = e;
|
||||
log.warn("AI call attempt {}/{} failed: {}", i + 1, maxAttempts, e.getMessage());
|
||||
if (i < maxAttempts - 1) Thread.sleep(1000L * (i + 1));
|
||||
}
|
||||
}
|
||||
throw lastEx != null ? lastEx : new RuntimeException("AI call failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw SSE stream URL and headers needed for streaming chat.
|
||||
* The controller is responsible for proxying the response stream.
|
||||
*/
|
||||
public StreamConfig buildStreamConfig(String provider, String model,
|
||||
List<Map<String, String>> messages) {
|
||||
String resolvedProvider = provider == null ? PROVIDER_DEEPSEEK : provider;
|
||||
String resolvedModel = resolveModel(resolvedProvider, model);
|
||||
EndpointConfig ep = resolveEndpoint(resolvedProvider);
|
||||
|
||||
Map<String, Object> body = buildRequestBody(resolvedModel, messages, true);
|
||||
return new StreamConfig(ep.url(), ep.apiKey(), body);
|
||||
}
|
||||
|
||||
private String doChat(EndpointConfig ep, String model,
|
||||
List<Map<String, String>> messages) throws Exception {
|
||||
Map<String, Object> body = buildRequestBody(model, messages, false);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(ep.apiKey());
|
||||
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<String> response =
|
||||
aiRestTemplate.postForEntity(ep.url(), entity, String.class);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw new RuntimeException("AI API returned " + response.getStatusCode());
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response.getBody());
|
||||
return root.path("choices").path(0).path("message").path("content").asText();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildRequestBody(String model,
|
||||
List<Map<String, String>> messages,
|
||||
boolean stream) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("model", model);
|
||||
body.put("messages", messages);
|
||||
if (stream) body.put("stream", true);
|
||||
return body;
|
||||
}
|
||||
|
||||
private String resolveModel(String provider, String model) {
|
||||
if (model == null || model.isBlank()) {
|
||||
return DEFAULT_MODEL_DEEPSEEK;
|
||||
}
|
||||
Set<String> allowed = PROVIDER_KIMI.equals(provider) ? ALLOWED_KIMI : ALLOWED_DEEPSEEK;
|
||||
return allowed.contains(model) ? model : DEFAULT_MODEL_DEEPSEEK;
|
||||
}
|
||||
|
||||
private EndpointConfig resolveEndpoint(String provider) {
|
||||
if (PROVIDER_DEEPSEEK.equals(provider)) {
|
||||
SiteAIRuntime runtime = aiRuntimeService.getRuntime().orElse(null);
|
||||
if (runtime != null
|
||||
&& runtime.getApiBase() != null && !runtime.getApiBase().isBlank()
|
||||
&& runtime.getApiKey() != null && !runtime.getApiKey().isBlank()) {
|
||||
return new EndpointConfig(
|
||||
runtime.getApiBase().stripTrailing() + "/chat/completions",
|
||||
runtime.getApiKey());
|
||||
}
|
||||
}
|
||||
|
||||
String prov = PROVIDER_KIMI.equals(provider) ? PROVIDER_KIMI : PROVIDER_DEEPSEEK;
|
||||
AIConfig cfg = aiConfigRepo.findByProviderAndIsEnabledTrue(prov)
|
||||
.orElseThrow(() -> new RuntimeException(
|
||||
"No enabled AI config for provider: " + prov));
|
||||
|
||||
String base = cfg.getApiBase().stripTrailing();
|
||||
String url = PROVIDER_KIMI.equals(prov)
|
||||
? base + "/v1/chat/completions"
|
||||
: base + "/chat/completions";
|
||||
|
||||
return new EndpointConfig(url, cfg.getApiKey());
|
||||
}
|
||||
|
||||
public record EndpointConfig(String url, String apiKey) {}
|
||||
|
||||
public record StreamConfig(String url, String apiKey, Map<String, Object> body) {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.service;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.request.AdminAIRuntimeRequest;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.AIRuntimeResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIRuntime;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.repository.SiteAIRuntimeRepository;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.util.AiResponseUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads and updates the singleton SiteAIRuntime row (id=1).
|
||||
* Used by the AI service to resolve the active DeepSeek endpoint.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AIRuntimeService {
|
||||
|
||||
private final SiteAIRuntimeRepository runtimeRepo;
|
||||
private final AiResponseUtil aiResponseUtil;
|
||||
|
||||
public Optional<SiteAIRuntime> getRuntime() {
|
||||
return runtimeRepo.findById(1L);
|
||||
}
|
||||
|
||||
public AIRuntimeResponse getRuntimeResponse() {
|
||||
SiteAIRuntime row = runtimeRepo.findById(1L).orElse(new SiteAIRuntime());
|
||||
AIRuntimeResponse resp = new AIRuntimeResponse();
|
||||
resp.setApiBase(row.getApiBase());
|
||||
resp.setApiKeyHint(aiResponseUtil.maskApiKey(row.getApiKey()));
|
||||
resp.setDefaultModel(row.getDefaultModel());
|
||||
resp.setDefaultProvider(row.getDefaultProvider());
|
||||
if (row.getUpdatedAt() != null) {
|
||||
resp.setUpdatedAt(row.getUpdatedAt().toString());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AIRuntimeResponse updateRuntime(AdminAIRuntimeRequest req) {
|
||||
SiteAIRuntime row = runtimeRepo.findById(1L).orElse(new SiteAIRuntime());
|
||||
row.setId(1L);
|
||||
|
||||
if (req.getApiBase() != null) {
|
||||
row.setApiBase(req.getApiBase());
|
||||
}
|
||||
if (!aiResponseUtil.isKeyPlaceholder(req.getApiKey())) {
|
||||
row.setApiKey(req.getApiKey());
|
||||
}
|
||||
if (req.getDefaultModel() != null) {
|
||||
row.setDefaultModel(req.getDefaultModel());
|
||||
}
|
||||
if (req.getDefaultProvider() != null) {
|
||||
row.setDefaultProvider(req.getDefaultProvider());
|
||||
}
|
||||
|
||||
runtimeRepo.save(row);
|
||||
return getRuntimeResponse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Builds prompt strings and dispatches to the AIModelService for all vertical
|
||||
* AI-tool endpoints (name-analysis, variable-naming, poetry, etc.).
|
||||
* Mirrors the prompt-construction logic in the Go aimodel handler.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AIToolService {
|
||||
|
||||
private final AIModelService aiModelService;
|
||||
|
||||
public String nameAnalysis(String name) throws Exception {
|
||||
String prompt = """
|
||||
你是一个专业的姓名分析师,精通中国传统文化、五行八字和现代心理学。
|
||||
请对以下姓名进行详细分析,包括:字义解读、五行属性、性格特征、事业运势、感情运势。
|
||||
姓名:%s
|
||||
请用结构化的格式输出分析结果。
|
||||
""".formatted(name);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String variableNaming(String description, String language) throws Exception {
|
||||
String lang = (language == null || language.isBlank()) ? "通用" : language;
|
||||
String prompt = """
|
||||
你是一个专业的程序员,精通各种编程语言的命名规范。
|
||||
请为以下功能/变量描述生成合适的变量名/函数名,提供多种命名风格(camelCase、snake_case、PascalCase等)。
|
||||
编程语言:%s
|
||||
描述:%s
|
||||
请提供5-10个命名建议,并简要说明每个命名的含义。
|
||||
""".formatted(lang, description);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String poetry(String topic, String style) throws Exception {
|
||||
String styleHint = (style == null || style.isBlank()) ? "现代诗" : style;
|
||||
String prompt = """
|
||||
你是一位才华横溢的诗人,精通各种诗歌形式。
|
||||
请以"%s"为主题,创作一首%s。
|
||||
要求:意境优美,情感真挚,语言生动。
|
||||
""".formatted(topic, styleHint);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String translation(String text, String targetLanguage, String sourceLanguage) throws Exception {
|
||||
String target = (targetLanguage == null || targetLanguage.isBlank()) ? "英语" : targetLanguage;
|
||||
String source = (sourceLanguage == null || sourceLanguage.isBlank()) ? "自动检测" : sourceLanguage;
|
||||
String prompt = """
|
||||
你是一位专业翻译,精通多种语言。
|
||||
请将以下文本从%s翻译成%s,保持原文的语气、风格和文化内涵。
|
||||
原文:%s
|
||||
请提供翻译结果,如有必要可附上简短说明。
|
||||
""".formatted(source, target, text);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String classicalConversion(String text) throws Exception {
|
||||
String prompt = """
|
||||
你是一位古汉语专家,精通文言文与白话文的互译。
|
||||
请判断以下文本是文言文还是白话文,并进行相应转换:
|
||||
- 如果是白话文,请转换为文言文
|
||||
- 如果是文言文,请转换为现代白话文
|
||||
原文:%s
|
||||
请提供转换结果,并简要说明转换要点。
|
||||
""".formatted(text);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String expressionMaker(String input, String style) throws Exception {
|
||||
String tone = (style == null || style.isBlank()) ? "幽默风趣" : style;
|
||||
String prompt = """
|
||||
你是一个表达大师,擅长用各种风格表达同一个意思。
|
||||
请将以下内容用%s的风格重新表达,让表达更加生动有趣:
|
||||
原文:%s
|
||||
请提供3-5种不同的表达方式。
|
||||
""".formatted(tone, input);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String linuxCommand(String description) throws Exception {
|
||||
String prompt = """
|
||||
你是一位Linux系统专家,精通各种Shell命令和脚本。
|
||||
请根据以下描述生成对应的Linux命令:
|
||||
描述:%s
|
||||
请提供命令示例,并解释命令的各个参数含义。如果有多种实现方式,请列出常用的几种。
|
||||
""".formatted(description);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String markdownFormatting(String text) throws Exception {
|
||||
String prompt = """
|
||||
你是一位Markdown格式化专家。
|
||||
请将以下文本整理并格式化为规范的Markdown格式,合理使用标题、列表、代码块等元素:
|
||||
原文:%s
|
||||
请输出格式化后的Markdown文本。
|
||||
""".formatted(text);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
public String kinshipCalculator(String relationship) throws Exception {
|
||||
String prompt = """
|
||||
你是一位精通中国亲属称谓的专家。
|
||||
请解析以下亲属关系描述,给出正确的称谓:
|
||||
关系描述:%s
|
||||
请给出:1.正式称谓 2.口语称谓 3.简要说明关系链。
|
||||
""".formatted(relationship);
|
||||
return callDeepSeek(prompt);
|
||||
}
|
||||
|
||||
private String callDeepSeek(String userPrompt) throws Exception {
|
||||
List<Map<String, String>> messages = List.of(
|
||||
Map.of("role", "user", "content", userPrompt)
|
||||
);
|
||||
return aiModelService.callAI(AIModelService.PROVIDER_DEEPSEEK,
|
||||
AIModelService.DEFAULT_MODEL_DEEPSEEK, messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.service;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.FeatureClickResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteFeatureCardClick;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.repository.SiteFeatureCardClickRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Manages feature card click counts with Redis cache-aside (optional).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeatureClickService {
|
||||
|
||||
private static final String KEY_CLICKS_PREFIX = "site:feature-clicks:";
|
||||
|
||||
private final SiteFeatureCardClickRepository clickRepo;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public FeatureClickResponse getClicksForSection(String section) {
|
||||
String cacheKey = cacheKey(KEY_CLICKS_PREFIX + section);
|
||||
if (appProperties.isRedisEnabled()) {
|
||||
try {
|
||||
Object cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cached instanceof Map<?, ?> map) {
|
||||
FeatureClickResponse resp = new FeatureClickResponse();
|
||||
resp.setSection(section);
|
||||
resp.setCounts((Map<String, Long>) cached);
|
||||
return resp;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
List<SiteFeatureCardClick> rows = clickRepo.findBySection(section);
|
||||
Map<String, Long> counts = rows.stream()
|
||||
.collect(Collectors.toMap(
|
||||
SiteFeatureCardClick::getItemId,
|
||||
SiteFeatureCardClick::getClickCount));
|
||||
|
||||
cacheWrite(cacheKey, counts);
|
||||
|
||||
FeatureClickResponse resp = new FeatureClickResponse();
|
||||
resp.setSection(section);
|
||||
resp.setCounts(counts);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeatureClickResponse increment(String section, String itemId) {
|
||||
clickRepo.upsertIncrement(section, itemId);
|
||||
evict(cacheKey(KEY_CLICKS_PREFIX + section));
|
||||
|
||||
SiteFeatureCardClick.ClickId id = new SiteFeatureCardClick.ClickId();
|
||||
id.setSection(section);
|
||||
id.setItemId(itemId);
|
||||
long count = clickRepo.findById(id)
|
||||
.map(SiteFeatureCardClick::getClickCount)
|
||||
.orElse(0L);
|
||||
|
||||
FeatureClickResponse resp = new FeatureClickResponse();
|
||||
resp.setSection(section);
|
||||
resp.setItemId(itemId);
|
||||
resp.setCount(count);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private String cacheKey(String suffix) {
|
||||
return appProperties.getRedisKeyPrefix() + suffix;
|
||||
}
|
||||
|
||||
private void cacheWrite(String key, Object value) {
|
||||
if (!appProperties.isRedisEnabled()) return;
|
||||
try {
|
||||
redisTemplate.opsForValue().set(key, value,
|
||||
Duration.ofSeconds(appProperties.getRedisSiteTtl()));
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis write failed for {}: {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void evict(String key) {
|
||||
if (!appProperties.isRedisEnabled()) return;
|
||||
try {
|
||||
redisTemplate.delete(key);
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis evict failed for {}: {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.service;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.DiagnosticsResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.HealthResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Health + diagnostics payloads aligned with the Go backend and {@code AdminPage.js}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HealthService {
|
||||
|
||||
private static final Pattern JDBC_MYSQL =
|
||||
Pattern.compile("^jdbc:mysql://([^/?]+)(/([^?]+))?");
|
||||
private static final DateTimeFormatter RFC3339 = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final RestTemplate restTemplate;
|
||||
private final AppProperties appProperties;
|
||||
private final SiteConfigService siteConfigService;
|
||||
|
||||
@Value("${server.port:5003}")
|
||||
private String serverPort;
|
||||
|
||||
@Value("${spring.data.redis.host:localhost}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port:6379}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.data.redis.password:}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.database:0}")
|
||||
private int redisDatabase;
|
||||
|
||||
@Value("${APP_ENV:development}")
|
||||
private String appEnv;
|
||||
|
||||
@Value("${MAIL_HOST:}")
|
||||
private String mailHost;
|
||||
|
||||
@Value("${MAIL_PORT:465}")
|
||||
private int mailPort;
|
||||
|
||||
@Value("${MAIL_USERNAME:}")
|
||||
private String mailUsername;
|
||||
|
||||
@Value("${MAIL_PASSWORD:}")
|
||||
private String mailPassword;
|
||||
|
||||
public HealthResponse buildHealthResponse() {
|
||||
HealthResponse resp = new HealthResponse();
|
||||
resp.setTimestamp(ZonedDateTime.now(ZoneId.systemDefault()).format(RFC3339));
|
||||
|
||||
boolean mysqlOk = pingMysql();
|
||||
String dbStatus = mysqlOk ? "connected" : "disconnected";
|
||||
resp.setDatabase(dbStatus);
|
||||
|
||||
HealthResponse.MysqlHealth mysql = new HealthResponse.MysqlHealth();
|
||||
mysql.setOk(mysqlOk);
|
||||
mysql.setStatus(dbStatus);
|
||||
resp.setMysql(mysql);
|
||||
|
||||
resp.setRedis(buildRedisHealth());
|
||||
|
||||
HealthResponse.BackendApiHealth backend = new HealthResponse.BackendApiHealth();
|
||||
backend.setOk(true);
|
||||
resp.setBackendApi(backend);
|
||||
|
||||
resp.setSixtyApi(probeSixtyApi());
|
||||
|
||||
boolean sixtyOk = resp.getSixtyApi() != null && resp.getSixtyApi().isOk();
|
||||
resp.setStatus(mysqlOk && sixtyOk ? "running" : "degraded");
|
||||
return resp;
|
||||
}
|
||||
|
||||
public DiagnosticsResponse buildDiagnostics() {
|
||||
DiagnosticsResponse resp = new DiagnosticsResponse();
|
||||
|
||||
DiagnosticsResponse.AppInfo app = new DiagnosticsResponse.AppInfo();
|
||||
app.setEnv(appEnv);
|
||||
app.setListenAddr("0.0.0.0:" + serverPort);
|
||||
app.setListenPort(serverPort);
|
||||
resp.setApp(app);
|
||||
|
||||
JdbcMeta jdbc = resolveJdbcMeta();
|
||||
DiagnosticsResponse.MysqlInfo mysql = new DiagnosticsResponse.MysqlInfo();
|
||||
mysql.setHost(jdbc.host());
|
||||
mysql.setPort(jdbc.port());
|
||||
mysql.setDatabase(jdbc.database());
|
||||
mysql.setUser(jdbc.user());
|
||||
mysql.setPasswordConfigured(jdbc.passwordConfigured());
|
||||
resp.setMysql(mysql);
|
||||
|
||||
resp.setRedis(buildRedisDiagnostics());
|
||||
|
||||
SiteSourceResponse src = siteConfigService.get60sSource();
|
||||
DiagnosticsResponse.SixtyUpstreamInfo sixty = new DiagnosticsResponse.SixtyUpstreamInfo();
|
||||
sixty.setSourceId(src.getSourceId());
|
||||
sixty.setBaseUrl(src.getBaseUrl());
|
||||
sixty.setLabel(src.getLabel());
|
||||
resp.setSixtyUpstream(sixty);
|
||||
|
||||
DiagnosticsResponse.AuthCenterInfo ac = new DiagnosticsResponse.AuthCenterInfo();
|
||||
ac.setApiUrl(appProperties.getAuthCenterUrl());
|
||||
resp.setAuthCenter(ac);
|
||||
|
||||
DiagnosticsResponse.MailInfo mail = new DiagnosticsResponse.MailInfo();
|
||||
mail.setHost(mailHost.isBlank() ? null : mailHost);
|
||||
mail.setPort(mailPort);
|
||||
mail.setUsername(mailUsername.isBlank() ? null : mailUsername);
|
||||
mail.setPasswordConfigured(!mailPassword.isBlank());
|
||||
resp.setMail(mail);
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
private HealthResponse.RedisHealth buildRedisHealth() {
|
||||
HealthResponse.RedisHealth r = new HealthResponse.RedisHealth();
|
||||
r.setEnabled(appProperties.isRedisEnabled());
|
||||
if (!appProperties.isRedisEnabled()) {
|
||||
return r;
|
||||
}
|
||||
try {
|
||||
redisTemplate.opsForValue().get("__ping__");
|
||||
r.setOk(true);
|
||||
} catch (Exception e) {
|
||||
r.setOk(false);
|
||||
r.setError(e.getMessage());
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private DiagnosticsResponse.RedisInfo buildRedisDiagnostics() {
|
||||
DiagnosticsResponse.RedisInfo r = new DiagnosticsResponse.RedisInfo();
|
||||
r.setEnabled(appProperties.isRedisEnabled());
|
||||
if (!appProperties.isRedisEnabled()) {
|
||||
return r;
|
||||
}
|
||||
r.setAddr(redisHost + ":" + redisPort);
|
||||
r.setLogicalDb(redisDatabase);
|
||||
r.setKeyPrefix(appProperties.getRedisKeyPrefix());
|
||||
r.setSiteCacheTtlSec((int) appProperties.getRedisSiteTtl());
|
||||
r.setPasswordConfigured(!redisPassword.isBlank());
|
||||
return r;
|
||||
}
|
||||
|
||||
private HealthResponse.SixtyApiHealth probeSixtyApi() {
|
||||
SiteSourceResponse src = siteConfigService.get60sSource();
|
||||
String base = siteConfigService.get60sBaseUrl();
|
||||
HealthResponse.SixtyApiHealth sixty = new HealthResponse.SixtyApiHealth();
|
||||
sixty.setSourceId(src.getSourceId());
|
||||
sixty.setBaseUrl(src.getBaseUrl());
|
||||
sixty.setLabel(src.getLabel());
|
||||
|
||||
String trimmed = base == null ? "" : base.strip();
|
||||
if (trimmed.isEmpty()) {
|
||||
sixty.setOk(false);
|
||||
sixty.setHttpStatus(0);
|
||||
sixty.setLatencyMs(0);
|
||||
sixty.setError("empty_base");
|
||||
return sixty;
|
||||
}
|
||||
String probeUrl =
|
||||
(trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed) + "/v2/ip";
|
||||
sixty.setProbeUrl(probeUrl);
|
||||
|
||||
long t0 = System.currentTimeMillis();
|
||||
try {
|
||||
ResponseEntity<String> re =
|
||||
restTemplate.exchange(probeUrl, HttpMethod.GET, null, String.class);
|
||||
sixty.setLatencyMs(System.currentTimeMillis() - t0);
|
||||
int code = re.getStatusCode().value();
|
||||
sixty.setHttpStatus(code);
|
||||
boolean ok = code >= 200 && code < 500;
|
||||
sixty.setOk(ok);
|
||||
if (!ok) {
|
||||
sixty.setError("http_" + code);
|
||||
}
|
||||
} catch (RestClientException e) {
|
||||
sixty.setLatencyMs(System.currentTimeMillis() - t0);
|
||||
sixty.setHttpStatus(0);
|
||||
sixty.setOk(false);
|
||||
sixty.setError(e.getMessage() != null ? e.getMessage() : "request_failed");
|
||||
}
|
||||
return sixty;
|
||||
}
|
||||
|
||||
private boolean pingMysql() {
|
||||
try (Connection c = dataSource.getConnection()) {
|
||||
return c.isValid(5);
|
||||
} catch (Exception e) {
|
||||
log.debug("MySQL ping failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private JdbcMeta resolveJdbcMeta() {
|
||||
if (!(dataSource instanceof HikariDataSource hds)) {
|
||||
return new JdbcMeta("—", "—", "—", "—", false);
|
||||
}
|
||||
String url = hds.getJdbcUrl();
|
||||
String host = "—";
|
||||
String port = "—";
|
||||
String db = "—";
|
||||
Matcher m = JDBC_MYSQL.matcher(url == null ? "" : url);
|
||||
if (m.find()) {
|
||||
String hostPart = m.group(1);
|
||||
int colon = hostPart.lastIndexOf(':');
|
||||
if (colon > 0) {
|
||||
host = hostPart.substring(0, colon);
|
||||
port = hostPart.substring(colon + 1);
|
||||
} else {
|
||||
host = hostPart;
|
||||
port = "3306";
|
||||
}
|
||||
if (m.group(3) != null && !m.group(3).isBlank()) {
|
||||
db = m.group(3);
|
||||
}
|
||||
}
|
||||
String user = hds.getUsername() != null ? hds.getUsername() : "—";
|
||||
String pwd = hds.getPassword();
|
||||
boolean pwdConfigured = pwd != null && !pwd.isEmpty();
|
||||
return new JdbcMeta(host, port, db, user, pwdConfigured);
|
||||
}
|
||||
|
||||
private record JdbcMeta(
|
||||
String host,
|
||||
String port,
|
||||
String database,
|
||||
String user,
|
||||
boolean passwordConfigured
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.smyhub.infogenie.infogeniebackendjava.service;
|
||||
|
||||
import com.smyhub.infogenie.infogeniebackendjava.config.AppProperties;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.dto.response.SiteSourceResponse;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sDisabled;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.Site60sUpstream;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.entity.SiteAIModelDisabled;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.repository.Site60sDisabledRepository;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.repository.Site60sUpstreamRepository;
|
||||
import com.smyhub.infogenie.infogeniebackendjava.repository.SiteAIModelDisabledRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Manages all public site-configuration reads and admin writes.
|
||||
* Implements a Redis cache-aside pattern identical to the Go backend:
|
||||
* reads check Redis first, fall back to MySQL, write back with TTL.
|
||||
* Admin writes update MySQL then evict the corresponding Redis key.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SiteConfigService {
|
||||
|
||||
private static final String KEY_60S_DISABLED = "site:60s-disabled";
|
||||
private static final String KEY_60S_SOURCE = "site:60s-source";
|
||||
private static final String KEY_AI_MODEL_DISABLED = "site:ai-model-disabled";
|
||||
|
||||
private static final Map<String, String> SOURCE_URLS = Map.of(
|
||||
"self", "https://60s.api.shumengya.top",
|
||||
"official", "https://60s.viki.moe"
|
||||
);
|
||||
private static final Map<String, String> SOURCE_LABELS = Map.of(
|
||||
"self", "自建源",
|
||||
"official", "官方源"
|
||||
);
|
||||
|
||||
private final Site60sDisabledRepository site60sDisabledRepo;
|
||||
private final SiteAIModelDisabledRepository siteAIModelDisabledRepo;
|
||||
private final Site60sUpstreamRepository site60sUpstreamRepo;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
public List<String> get60sDisabled() {
|
||||
String cacheKey = cacheKey(KEY_60S_DISABLED);
|
||||
if (appProperties.isRedisEnabled()) {
|
||||
try {
|
||||
Object cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cached instanceof List<?> list) {
|
||||
return list.stream().map(Object::toString).toList();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
|
||||
}
|
||||
}
|
||||
List<String> result = site60sDisabledRepo.findAll()
|
||||
.stream().map(Site60sDisabled::getFeatureId).toList();
|
||||
cacheWrite(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int update60sDisabled(List<String> ids) {
|
||||
site60sDisabledRepo.deleteAll();
|
||||
List<Site60sDisabled> rows = ids.stream().map(Site60sDisabled::new).toList();
|
||||
site60sDisabledRepo.saveAll(rows);
|
||||
evict(cacheKey(KEY_60S_DISABLED));
|
||||
return rows.size();
|
||||
}
|
||||
|
||||
public List<String> getAiModelDisabled() {
|
||||
String cacheKey = cacheKey(KEY_AI_MODEL_DISABLED);
|
||||
if (appProperties.isRedisEnabled()) {
|
||||
try {
|
||||
Object cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cached instanceof List<?> list) {
|
||||
return list.stream().map(Object::toString).toList();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
|
||||
}
|
||||
}
|
||||
List<String> result = siteAIModelDisabledRepo.findAll()
|
||||
.stream().map(SiteAIModelDisabled::getAppId).toList();
|
||||
cacheWrite(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int updateAiModelDisabled(List<String> ids) {
|
||||
siteAIModelDisabledRepo.deleteAll();
|
||||
List<SiteAIModelDisabled> rows = ids.stream().map(SiteAIModelDisabled::new).toList();
|
||||
siteAIModelDisabledRepo.saveAll(rows);
|
||||
evict(cacheKey(KEY_AI_MODEL_DISABLED));
|
||||
return rows.size();
|
||||
}
|
||||
|
||||
public SiteSourceResponse get60sSource() {
|
||||
String cacheKey = cacheKey(KEY_60S_SOURCE);
|
||||
if (appProperties.isRedisEnabled()) {
|
||||
try {
|
||||
Object cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cached instanceof SiteSourceResponse r) {
|
||||
return r;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis read failed for {}: {}", cacheKey, e.getMessage());
|
||||
}
|
||||
}
|
||||
Site60sUpstream row = site60sUpstreamRepo.findById(1L).orElseGet(() -> {
|
||||
Site60sUpstream def = new Site60sUpstream();
|
||||
def.setId(1L);
|
||||
def.setSourceId("self");
|
||||
return site60sUpstreamRepo.save(def);
|
||||
});
|
||||
SiteSourceResponse resp = buildSourceResponse(row.getSourceId());
|
||||
cacheWrite(cacheKey, resp);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SiteSourceResponse update60sSource(String sourceId) {
|
||||
if (!SOURCE_URLS.containsKey(sourceId)) {
|
||||
throw new IllegalArgumentException("Invalid source_id: " + sourceId);
|
||||
}
|
||||
Site60sUpstream row = site60sUpstreamRepo.findById(1L).orElse(new Site60sUpstream());
|
||||
row.setId(1L);
|
||||
row.setSourceId(sourceId);
|
||||
site60sUpstreamRepo.save(row);
|
||||
evict(cacheKey(KEY_60S_SOURCE));
|
||||
return buildSourceResponse(sourceId);
|
||||
}
|
||||
|
||||
public String get60sBaseUrl() {
|
||||
return SOURCE_URLS.getOrDefault(get60sSource().getSourceId(),
|
||||
SOURCE_URLS.get("self"));
|
||||
}
|
||||
|
||||
private SiteSourceResponse buildSourceResponse(String sourceId) {
|
||||
SiteSourceResponse r = new SiteSourceResponse();
|
||||
r.setSourceId(sourceId);
|
||||
r.setBaseUrl(SOURCE_URLS.getOrDefault(sourceId, SOURCE_URLS.get("self")));
|
||||
r.setLabel(SOURCE_LABELS.getOrDefault(sourceId, ""));
|
||||
return r;
|
||||
}
|
||||
|
||||
private String cacheKey(String suffix) {
|
||||
return appProperties.getRedisKeyPrefix() + suffix;
|
||||
}
|
||||
|
||||
private void cacheWrite(String key, Object value) {
|
||||
if (!appProperties.isRedisEnabled()) return;
|
||||
try {
|
||||
redisTemplate.opsForValue().set(key, value,
|
||||
Duration.ofSeconds(appProperties.getRedisSiteTtl()));
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis write failed for {}: {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void evict(String key) {
|
||||
if (!appProperties.isRedisEnabled()) return;
|
||||
try {
|
||||
redisTemplate.delete(key);
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis evict failed for {}: {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("****");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`**。
|
||||
|
||||
6
万象口袋项目经历-java.md
Normal file
6
万象口袋项目经历-java.md
Normal file
@@ -0,0 +1,6 @@
|
||||
- **项目名称**:万象口袋- 跨平台信息聚合网站
|
||||
- **时间**:2025.08 - 2026.01
|
||||
- **技术栈**:Java 17,Spring Boot,Spring Web,Spring Data JPA,Hibernate,MySQL,Redis,Lettuce,React,Tailwind,Maven
|
||||
- **描述**:产品定位为一站式资讯与工具导航:首页与栏目以配置驱动,整合 60s 类 API 浏览、休闲小游戏、实用工具箱及多款 AI 应用(后端 OpenAI 格式兼容与 SSE 流式输出)。后端采用 Spring Boot 分层架构(Controller / Service / Repository / 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)
|
||||
|
||||
Reference in New Issue
Block a user