- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。 - 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改 (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket 两条发送路径都覆盖;文本消息永不受限。 - 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续 要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于 凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。 - 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截, 把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复, 只对 AI 生成的消息跳过入队。 - ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL 静默转成 0 而使配置失效。 - 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。 新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、 默认模型解析、资料距离。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
493 lines
26 KiB
Go
493 lines
26 KiB
Go
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() {
|
|
workerContext, stopWorkers := context.WithCancel(context.Background())
|
|
defer stopWorkers()
|
|
go func() {
|
|
a.processDueAccountClosures(workerContext)
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-workerContext.Done():
|
|
return
|
|
case <-ticker.C:
|
|
a.processDueAccountClosures(workerContext)
|
|
}
|
|
}
|
|
}()
|
|
go a.runAIReplyWorker(workerContext)
|
|
go a.runAIMaintenance(workerContext)
|
|
go a.runChatMediaCleanupWorker(workerContext)
|
|
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.MethodPost, Path: "/api/v1/auth/oauth/native", Handler: a.userOAuthNative},
|
|
{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.MethodPost, Path: "/admin/v1/auth/logout", Handler: a.adminLogout},
|
|
{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.MethodPut, Path: "/api/v1/me/password", Handler: auth(a.changeUserPassword)},
|
|
{Method: http.MethodPut, Path: "/api/v1/me/phone", Handler: auth(a.changeUserPhone)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/devices", Handler: auth(a.myDevices)},
|
|
{Method: http.MethodDelete, Path: "/api/v1/me/devices/:id", Handler: auth(a.revokeDevice)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/blocks", Handler: auth(a.blockedUsers)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/notification-settings", Handler: auth(a.notificationSettings)},
|
|
{Method: http.MethodPut, Path: "/api/v1/me/notification-settings", Handler: auth(a.updateNotificationSettings)},
|
|
{Method: http.MethodPost, Path: "/api/v1/me/push-tokens", Handler: auth(a.registerPushToken)},
|
|
{Method: http.MethodDelete, Path: "/api/v1/me/push-tokens", Handler: auth(a.deletePushToken)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)},
|
|
{Method: http.MethodPost, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)},
|
|
{Method: http.MethodPost, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)},
|
|
{Method: http.MethodDelete, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/data-export", Handler: auth(a.exportMyData)},
|
|
{Method: http.MethodPost, Path: "/api/v1/me/consents", Handler: auth(a.recordConsent)},
|
|
{Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)},
|
|
{Method: http.MethodGet, Path: "/api/v1/tags", Handler: auth(a.availableTags)},
|
|
{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/me/verification", Handler: auth(a.myVerification)},
|
|
{Method: http.MethodPost, Path: "/api/v1/me/verification", Handler: auth(a.submitVerification)},
|
|
{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.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)},
|
|
{Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)},
|
|
{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.MethodDelete, Path: "/api/v1/comments/:id", Handler: auth(a.deleteOwnComment)},
|
|
{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.MethodPost, Path: "/api/v1/im/messages/:id/recall", Handler: auth(a.recallMessage)},
|
|
{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.MethodPost, Path: "/api/v1/orders/:id/close", Handler: auth(a.closeOwnOrder)},
|
|
{Method: http.MethodPost, Path: "/api/v1/orders/:id/refund", Handler: auth(a.requestOrderRefund)},
|
|
{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/notifications/:id/read", Handler: auth(a.readNotification)},
|
|
{Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)},
|
|
{Method: http.MethodGet, Path: "/api/v1/me/reports", Handler: auth(a.myReports)},
|
|
{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) }
|
|
permit := func(permission string, next http.HandlerFunc) http.HandlerFunc {
|
|
return a.requireAdminPermission(permission, next)
|
|
}
|
|
return []rest.Route{
|
|
{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: permit("dashboard:view", a.dashboard)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/users", Handler: permit("users:view", a.adminUsers)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users", Handler: permit("users:create", a.adminCreateUser)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: permit("users:view", a.adminUserDetail)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: permit("users:manage", a.adminUpdateUserProfile)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: permit("verification:manage", a.adminUpdateVerification)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: permit("users:manage", a.adminUpdateMembership)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: permit("users:security", a.adminResetUserPassword)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: permit("users:security", a.adminForceLogout)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: permit("violations:manage", a.adminRevokeSanction)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: permit("violations:manage", a.adminUserStatus)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: permit("violations:manage", a.adminUserStatus)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: permit("violations:manage", a.adminUserStatus)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: permit("violations:manage", a.adminUserStatus)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/posts", Handler: permit("content:view", a.adminPosts)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminPlans)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminCreatePlan)},
|
|
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminDeletePlan)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/orders", Handler: permit("orders:view", a.adminOrders)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminUpdateOrder)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/chat-media", Handler: permit("messages:view", a.adminChatMedia)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/chat-media/retention", Handler: permit("messages:manage", a.adminUpdateChatMediaRetention)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/chat-media/cleanup", Handler: permit("messages:manage", a.adminCleanupDueChatMedia)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/chat-media/:id", Handler: permit("messages:manage", a.adminDeleteChatMedia)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/client-feedback", Handler: permit("users:view", a.adminFeedback)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/client-feedback/:id", Handler: permit("users:manage", a.adminFeedback)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/account-closures", Handler: permit("users:view", a.adminAccountClosures)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/account-closures/:id", Handler: permit("users:manage", a.adminAccountClosures)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: permit("system:manage", a.adminConfigs)},
|
|
{Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminIntegration)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminUpdateIntegration)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: permit("system:manage", a.adminTestIntegration)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/ai/models", Handler: permit("system:manage", a.adminAIModels)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/ai/models", Handler: permit("system:manage", a.adminCreateAIModel)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/ai/models/:id", Handler: permit("system:manage", a.adminUpdateAIModel)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/ai/models/:id", Handler: permit("system:manage", a.adminDeleteAIModel)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/ai/models/:id/test", Handler: permit("system:manage", a.adminTestAIModel)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/ai/agents", Handler: permit("system:manage", a.adminAIAgents)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/ai/agents", Handler: permit("system:manage", a.adminUpdateAIAgents)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/ai/reply-jobs", Handler: permit("system:manage", a.adminAIReplyJobs)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/ai/logs", Handler: permit("system:manage", a.adminAILogs)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/ai/usage", Handler: permit("system:manage", a.adminAIUsage)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: permit("system:manage", a.adminAuditLogs)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminAccounts)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminCreateAccount)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/admin-users/:id", Handler: permit("system:manage", a.adminUpdateAccount)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminRoles)},
|
|
{Method: http.MethodPost, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminCreateRole)},
|
|
{Method: http.MethodPut, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminUpdateRole)},
|
|
{Method: http.MethodDelete, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminDeleteRole)},
|
|
{Method: http.MethodGet, Path: "/admin/v1/admin-permissions", Handler: permit("system:manage", a.adminPermissions)},
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|