feat: opaque session、安装/发帖 SSR 与最小 Admin 后台

浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:44:16 +08:00
parent 3f50316ad0
commit fde5f628ec
80 changed files with 4148 additions and 1849 deletions

View File

@@ -5,33 +5,60 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite" // 纯 Go支持 CGO_ENABLED=0 交叉编译
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
// InitDB 初始化 SQLite 并自动迁移
func InitDB(dbPath string) error {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建数据库目录失败: %w", err)
// DatabaseConfig 与 config.DatabaseConfig 对齐的精简结构(避免 models→config 循环依赖)
type DatabaseConfig struct {
Type string
DSN string
SQLitePath string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetimeSec int
}
// InitDB 按方言初始化数据库并自动迁移
func InitDB(cfg DatabaseConfig) error {
typ := strings.ToLower(strings.TrimSpace(cfg.Type))
if typ == "" {
typ = "sqlite"
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
dialector, err := openDialector(typ, cfg)
if err != nil {
return err
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return fmt.Errorf("连接 SQLite 失败: %w", err)
return fmt.Errorf("连接数据库失败 (%s): %w — 请检查 JIANG13_DB_TYPE / JIANG13_DB_DSN", typ, err)
}
sqlDB, err := db.DB()
if err != nil {
return err
}
sqlDB.SetMaxOpenConns(1)
if cfg.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
}
if cfg.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
}
if cfg.ConnMaxLifetimeSec > 0 {
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second)
}
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{},
@@ -43,11 +70,11 @@ func InitDB(dbPath string) error {
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
&BadgeDef{}, &UserBadge{},
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
&Session{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
// 存量数据默认视为已公开,避免升级后内容全部进入待审
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Comment{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error
@@ -55,11 +82,50 @@ func InitDB(dbPath string) error {
DB = db
seedDefaultBadges(db)
backfillUserExp(db)
log.Println("[model] SQLite 数据库初始化完成:", dbPath)
log.Printf("[models] 数据库初始化完成 type=%s", typ)
return nil
}
// PingDB 检测数据库连接是否可用(供健康检查使用)
func openDialector(typ string, cfg DatabaseConfig) (gorm.Dialector, error) {
switch typ {
case "sqlite", "sqlite3":
path := cfg.SQLitePath
if path == "" {
path = cfg.DSN
}
if path == "" {
return nil, fmt.Errorf("sqlite 需要文件路径")
}
if path != ":memory:" {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
}
return sqlite.Open(path), nil
case "postgres", "postgresql", "pg":
if cfg.DSN == "" {
return nil, fmt.Errorf("postgres 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
}
return postgres.Open(cfg.DSN), nil
case "mysql", "mariadb":
if cfg.DSN == "" {
return nil, fmt.Errorf("mysql 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
}
return mysql.Open(cfg.DSN), nil
default:
return nil, fmt.Errorf("不支持的数据库类型 %q", typ)
}
}
// DialectorName 当前驱动名sqlite/postgres/mysql
func DialectorName() string {
if DB == nil {
return ""
}
return DB.Dialector.Name()
}
// PingDB 检测数据库连接是否可用
func PingDB() error {
if DB == nil {
return fmt.Errorf("数据库未初始化")
@@ -75,12 +141,12 @@ func PingDB() error {
func seedDefaultBadges(db *gorm.DB) {
defs := []BadgeDef{
{Code: "tenure_30", Name: "初来乍到", Description: "注册满 30 天", Icon: "calendar", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 30, SortOrder: 10, Enabled: true},
{Code: "tenure_365", Name: "资深居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
{Code: "tenure_365", Name: "常驻居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
{Code: "likes_10", Name: "小有人气", Description: "帖子获赞累计 10", Icon: "heart", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 10, SortOrder: 30, Enabled: true},
{Code: "likes_100", Name: "人气作者", Description: "帖子获赞累计 100", Icon: "heart-handshake", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 100, SortOrder: 40, Enabled: true},
{Code: "likes_1000", Name: "人气巨星", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
{Code: "income_100", Name: "小有进账", Description: "创作分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
{Code: "income_1000", Name: "创达人", Description: "创作分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
{Code: "likes_1000", Name: "超级人气", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
{Code: "income_100", Name: "小有进账", Description: "创作分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
{Code: "income_1000", Name: "创达人", Description: "创作分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
}
for _, d := range defs {
var n int64
@@ -91,7 +157,7 @@ func seedDefaultBadges(db *gorm.DB) {
}
}
// backfillUserExp 对 Exp 仍为 0 的用户按存量公开内容粗算经验(仅补一次量级
// backfillUserExp 对 Exp 仍为 0 的用户按发帖/评论/获赞粗算经验(仅补一次语义
func backfillUserExp(db *gorm.DB) {
var users []User
if err := db.Select("id", "exp").Where("exp = 0").Find(&users).Error; err != nil {

14
models/session.go Normal file
View File

@@ -0,0 +1,14 @@
package models
import "time"
// Session 浏览器 opaque 会话Cookie 只存 Id
type Session struct {
ID string `gorm:"primaryKey;size:64" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
IP string `gorm:"size:45" json:"ip,omitempty"`
UserAgent string `gorm:"size:256" json:"user_agent,omitempty"`
}