This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
+434
View File
@@ -0,0 +1,434 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/zeromicro/go-zero/rest"
)
type Config struct {
Host string
Port int
DSN string
JWTSecret string
ConfigEncryptionKey string
MediaDir string
Environment string
AllowedOrigins []string
SeedDemo bool
BootstrapAdminUsername string
BootstrapAdminPassword string
BootstrapAdminRealName string
}
type App struct {
config Config
db *sql.DB
hub *Hub
}
type apiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
RequestID string `json:"request_id,omitempty"`
}
type pageResult struct {
Items any `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func LoadConfig() Config {
port, _ := strconv.Atoi(env("IM_PORT", "8888"))
return Config{
Host: env("IM_HOST", "0.0.0.0"),
Port: port,
DSN: env("IM_DB_DSN", "root:root@tcp(127.0.0.1:3306)/im?charset=utf8mb4&parseTime=True&loc=Local"),
JWTSecret: env("IM_JWT_SECRET", "local-development-secret-change-me"),
ConfigEncryptionKey: env("IM_CONFIG_ENCRYPTION_KEY", ""),
MediaDir: env("IM_MEDIA_DIR", "./uploads"),
Environment: strings.ToLower(env("IM_ENV", "development")),
AllowedOrigins: csvEnv("IM_ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5174,http://localhost:5180,http://localhost:5555,http://localhost:5556,http://localhost:5560,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:5180,http://127.0.0.1:5555,http://127.0.0.1:5556,http://127.0.0.1:5560"),
SeedDemo: boolEnv("IM_SEED_DEMO", false),
BootstrapAdminUsername: strings.TrimSpace(os.Getenv("IM_BOOTSTRAP_ADMIN_USERNAME")),
BootstrapAdminPassword: os.Getenv("IM_BOOTSTRAP_ADMIN_PASSWORD"),
BootstrapAdminRealName: env("IM_BOOTSTRAP_ADMIN_REAL_NAME", "平台管理员"),
}
}
func New(config Config) (*App, error) {
if err := validateConfig(config); err != nil {
return nil, err
}
db, err := sql.Open("mysql", config.DSN)
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
db.SetMaxOpenConns(30)
db.SetMaxIdleConns(10)
// Keep pooled connections below the local MySQL wait_timeout (120s).
db.SetConnMaxIdleTime(30 * time.Second)
db.SetConnMaxLifetime(90 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("connect mysql (run scripts/migrate.ps1 first): %w", err)
}
return &App{config: config, db: db, hub: NewHub()}, nil
}
func (a *App) Close() { _ = a.db.Close() }
func (a *App) Run() {
server := rest.MustNewServer(rest.RestConf{
Host: a.config.Host,
Port: a.config.Port,
MaxBytes: 16 << 20,
MaxConns: 5_000,
Timeout: 35_000,
}, rest.WithCors(a.config.AllowedOrigins...))
defer server.Stop()
server.Use(a.requestMetadata)
server.AddRoutes(a.routes())
log.Printf("星遇 API listening on http://127.0.0.1:%d", a.config.Port)
server.Start()
}
type responseRecorder struct {
http.ResponseWriter
status int
}
func (r *responseRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
var safeRequestID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
func (a *App) requestMetadata(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
requestID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if !safeRequestID.MatchString(requestID) {
requestID = randomToken()[:32]
}
w.Header().Set("X-Request-ID", requestID)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin/") {
w.Header().Set("Cache-Control", "no-store")
}
if r.URL.Path == "/ws" {
next(w, r)
log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, http.StatusSwitchingProtocols, time.Since(started).Milliseconds(), clientIP(r))
return
}
recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
next(recorder, r)
log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, recorder.status, time.Since(started).Milliseconds(), clientIP(r))
}
}
func (a *App) routes() []rest.Route {
routes := []rest.Route{
{Method: http.MethodGet, Path: "/healthz", Handler: a.health},
{Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: a.sendSMS},
{Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: a.register},
{Method: http.MethodPost, Path: "/api/v1/auth/login/password", Handler: a.loginPassword},
{Method: http.MethodPost, Path: "/api/v1/auth/login/sms", Handler: a.loginSMS},
{Method: http.MethodGet, Path: "/api/v1/auth/oauth/providers", Handler: a.userOAuthProviders},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/start", Handler: a.userOAuthStart},
{Method: http.MethodGet, Path: "/api/v1/auth/oauth/callback", Handler: a.oauthCallback},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/exchange", Handler: a.userOAuthExchange},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/link", Handler: a.userOAuthLink},
{Method: http.MethodPost, Path: "/api/v1/auth/password/reset", Handler: a.resetPassword},
{Method: http.MethodPost, Path: "/api/v1/auth/token/refresh", Handler: a.refreshToken},
{Method: http.MethodGet, Path: "/api/v1/membership/plans", Handler: a.membershipPlans},
{Method: http.MethodGet, Path: "/api/v1/payment/channels", Handler: a.paymentChannels},
{Method: http.MethodPost, Path: "/api/v1/payment/notify", Handler: a.paymentNotify},
{Method: http.MethodGet, Path: "/api/v1/app/config", Handler: a.appConfig},
{Method: http.MethodGet, Path: "/uploads/:name", Handler: a.serveMedia},
{Method: http.MethodHead, Path: "/uploads/:name", Handler: a.serveMedia},
{Method: http.MethodPost, Path: "/admin/v1/auth/login", Handler: a.adminLogin},
{Method: http.MethodPost, Path: "/admin/v1/auth/refresh", Handler: a.adminRefresh},
{Method: http.MethodGet, Path: "/admin/v1/auth/oauth/providers", Handler: a.adminOAuthProviders},
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/start", Handler: a.adminOAuthStart},
{Method: http.MethodGet, Path: "/admin/v1/auth/oauth/callback", Handler: a.oauthCallback},
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/exchange", Handler: a.adminOAuthExchange},
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/link", Handler: a.adminOAuthLink},
}
routes = append(routes, a.userRoutes()...)
routes = append(routes, a.adminRoutes()...)
return routes
}
func (a *App) userRoutes() []rest.Route {
auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("user", next) }
return []rest.Route{
{Method: http.MethodPost, Path: "/api/v1/auth/logout", Handler: auth(a.logout)},
{Method: http.MethodGet, Path: "/api/v1/me", Handler: auth(a.me)},
{Method: http.MethodGet, Path: "/api/v1/me/profile", Handler: auth(a.me)},
{Method: http.MethodPatch, Path: "/api/v1/me/profile", Handler: auth(a.updateProfile)},
{Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)},
{Method: http.MethodGet, Path: "/api/v1/users/:id", Handler: auth(a.userProfile)},
{Method: http.MethodGet, Path: "/api/v1/me/following", Handler: auth(a.followingList)},
{Method: http.MethodGet, Path: "/api/v1/me/followers", Handler: auth(a.followerList)},
{Method: http.MethodGet, Path: "/api/v1/me/visitors", Handler: auth(a.visitorList)},
{Method: http.MethodGet, Path: "/api/v1/me/privacy", Handler: auth(a.getPrivacy)},
{Method: http.MethodPut, Path: "/api/v1/me/privacy", Handler: auth(a.updatePrivacy)},
{Method: http.MethodGet, Path: "/api/v1/discover/recommendations", Handler: auth(a.discover)},
{Method: http.MethodGet, Path: "/api/v1/nearby/users", Handler: auth(a.nearby)},
{Method: http.MethodPut, Path: "/api/v1/location", Handler: auth(a.updateLocation)},
{Method: http.MethodPost, Path: "/api/v1/users/:id/follow", Handler: auth(a.follow)},
{Method: http.MethodDelete, Path: "/api/v1/users/:id/follow", Handler: auth(a.unfollow)},
{Method: http.MethodPost, Path: "/api/v1/users/:id/like", Handler: auth(a.likeUser)},
{Method: http.MethodDelete, Path: "/api/v1/users/:id/like", Handler: auth(a.unlikeUser)},
{Method: http.MethodPost, Path: "/api/v1/users/:id/block", Handler: auth(a.blockUser)},
{Method: http.MethodDelete, Path: "/api/v1/users/:id/block", Handler: auth(a.unblockUser)},
{Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)},
{Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)},
{Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)},
{Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)},
{Method: http.MethodPost, Path: "/api/v1/media/upload", Handler: auth(a.uploadMedia)},
{Method: http.MethodPost, Path: "/api/v1/posts/:id/like", Handler: auth(a.likePost)},
{Method: http.MethodDelete, Path: "/api/v1/posts/:id/like", Handler: auth(a.unlikePost)},
{Method: http.MethodGet, Path: "/api/v1/posts/:id/comments", Handler: auth(a.comments)},
{Method: http.MethodPost, Path: "/api/v1/posts/:id/comments", Handler: auth(a.createComment)},
{Method: http.MethodPost, Path: "/api/v1/im/conversations/direct", Handler: auth(a.directConversation)},
{Method: http.MethodGet, Path: "/api/v1/im/conversations", Handler: auth(a.conversations)},
{Method: http.MethodGet, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.messages)},
{Method: http.MethodPost, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.sendMessageHTTP)},
{Method: http.MethodPatch, Path: "/api/v1/im/conversations/:id/settings", Handler: auth(a.conversationSettings)},
{Method: http.MethodGet, Path: "/api/v1/membership/status", Handler: auth(a.membershipStatus)},
{Method: http.MethodPost, Path: "/api/v1/orders", Handler: auth(a.createOrder)},
{Method: http.MethodPost, Path: "/api/v1/orders/:id/pay", Handler: auth(a.payOrder)},
{Method: http.MethodGet, Path: "/api/v1/orders/:id", Handler: auth(a.orderStatus)},
{Method: http.MethodGet, Path: "/api/v1/me/orders", Handler: auth(a.myOrders)},
{Method: http.MethodGet, Path: "/api/v1/notifications", Handler: auth(a.notifications)},
{Method: http.MethodPost, Path: "/api/v1/notifications/read-all", Handler: auth(a.readAllNotifications)},
{Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)},
{Method: http.MethodGet, Path: "/ws", Handler: a.websocket},
}
}
func (a *App) adminRoutes() []rest.Route {
auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("admin", next) }
return []rest.Route{
{Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: auth(a.adminLogout)},
{Method: http.MethodPut, Path: "/admin/v1/me/password", Handler: auth(a.adminChangePassword)},
{Method: http.MethodGet, Path: "/admin/v1/auth/codes", Handler: auth(a.adminCodes)},
{Method: http.MethodGet, Path: "/admin/v1/user/info", Handler: auth(a.adminInfo)},
{Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: auth(a.dashboard)},
{Method: http.MethodGet, Path: "/admin/v1/users", Handler: auth(a.adminUsers)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: auth(a.adminUserDetail)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: auth(a.adminUpdateUserProfile)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: auth(a.adminUpdateVerification)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: auth(a.adminUpdateMembership)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: auth(a.adminResetUserPassword)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: auth(a.adminForceLogout)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: auth(a.adminRevokeSanction)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: auth(a.adminUserStatus)},
{Method: http.MethodGet, Path: "/admin/v1/posts", Handler: auth(a.adminPosts)},
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: auth(a.adminPostDetail)},
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: auth(a.adminDeletePost)},
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: auth(a.adminReports)},
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: auth(a.adminHandleReport)},
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: auth(a.adminRiskUsers)},
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: auth(a.adminRiskEvents)},
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: auth(a.adminPlans)},
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: auth(a.adminCreatePlan)},
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
{Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
{Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminDeletePlan)},
{Method: http.MethodGet, Path: "/admin/v1/orders", Handler: auth(a.adminOrders)},
{Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: auth(a.adminUpdateOrder)},
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: auth(a.adminDeleteOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: auth(a.adminMarkOrderPaid)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: auth(a.adminCloseOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: auth(a.adminRefund)},
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: auth(a.adminMessages)},
{Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: auth(a.adminConfigs)},
{Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
{Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
{Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminIntegration)},
{Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminUpdateIntegration)},
{Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: auth(a.adminTestIntegration)},
{Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: auth(a.adminAuditLogs)},
}
}
func (a *App) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
if err := a.db.PingContext(ctx); err != nil {
fail(w, http.StatusServiceUnavailable, 50001, "database unavailable")
return
}
reply(w, map[string]any{"status": "ok", "time": time.Now()})
}
func reply(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(apiResponse{Code: 0, Message: "OK", Data: data})
}
func fail(w http.ResponseWriter, status, code int, message string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(apiResponse{Code: code, Message: message, Data: nil})
}
func decode(r *http.Request, out any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 2<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
return nil
}
func pagination(r *http.Request) (int, int, int) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
size, _ := strconv.Atoi(r.URL.Query().Get("size"))
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size, (page - 1) * size
}
func pathID(r *http.Request) (int64, error) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
if id, err := strconv.ParseInt(parts[i], 10, 64); err == nil {
return id, nil
}
}
return 0, errors.New("invalid id")
}
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func boolEnv(key string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if value == "" {
return fallback
}
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func csvEnv(key, fallback string) []string {
value := env(key, fallback)
items := make([]string, 0)
seen := map[string]bool{}
for _, item := range strings.Split(value, ",") {
item = strings.TrimRight(strings.TrimSpace(item), "/")
if item != "" && !seen[item] {
seen[item] = true
items = append(items, item)
}
}
return items
}
func validateConfig(config Config) error {
if config.Port < 1 || config.Port > 65535 {
return fmt.Errorf("IM_PORT 无效")
}
if config.Environment != "production" {
return nil
}
if config.SeedDemo {
return fmt.Errorf("生产环境禁止启用 IM_SEED_DEMO")
}
if len(config.JWTSecret) < 32 || config.JWTSecret == "local-development-secret-change-me" {
return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_JWT_SECRET")
}
if len(config.ConfigEncryptionKey) < 32 {
return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_CONFIG_ENCRYPTION_KEY")
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.DSN)), "root:") {
return fmt.Errorf("生产环境禁止使用 root 数据库账号")
}
if len(config.AllowedOrigins) == 0 {
return fmt.Errorf("生产环境必须配置 IM_ALLOWED_ORIGINS")
}
for _, origin := range config.AllowedOrigins {
if origin == "*" {
return fmt.Errorf("生产环境禁止使用通配 CORS 来源")
}
if !strings.HasPrefix(origin, "https://") {
return fmt.Errorf("生产环境来源必须使用 HTTPS: %s", origin)
}
}
if config.BootstrapAdminPassword != "" && !strongAdminPassword(config.BootstrapAdminPassword) {
return fmt.Errorf("IM_BOOTSTRAP_ADMIN_PASSWORD 至少 12 位,且必须包含大小写字母、数字和特殊字符")
}
return nil
}
func strongAdminPassword(value string) bool {
if len(value) < 12 {
return false
}
var lower, upper, digit, special bool
for _, char := range value {
switch {
case char >= 'a' && char <= 'z':
lower = true
case char >= 'A' && char <= 'Z':
upper = true
case char >= '0' && char <= '9':
digit = true
default:
special = true
}
}
return lower && upper && digit && special
}
func (a *App) originAllowed(origin string) bool {
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
if origin == "" {
return true
}
for _, allowed := range a.config.AllowedOrigins {
if origin == allowed {
return true
}
}
return false
}