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 // Cached push auth token; the provider rate-limits the auth endpoint. push pushAuth } 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) a.startFeedSettlementWorker(workerContext) a.startAddressPurgeWorker(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.MethodGet, Path: "/api/v1/pet/adoptions", Handler: auth(a.adoptions)}, {Method: http.MethodPost, Path: "/api/v1/pet/adoptions", Handler: auth(a.createAdoption)}, {Method: http.MethodGet, Path: "/api/v1/pet/adoptions/:id", Handler: auth(a.adoptionDetail)}, {Method: http.MethodPost, Path: "/api/v1/pet/adoptions/:id/apply", Handler: auth(a.applyAdoption)}, {Method: http.MethodPost, Path: "/api/v1/pet/adoptions/:id/decide", Handler: auth(a.decideAdoption)}, {Method: http.MethodPost, Path: "/api/v1/pet/adoptions/:id/close", Handler: auth(a.closeAdoption)}, {Method: http.MethodGet, Path: "/api/v1/pet/followups", Handler: auth(a.myAdoptionFollowups)}, {Method: http.MethodPost, Path: "/api/v1/pet/followups/:id", Handler: auth(a.submitAdoptionFollowup)}, {Method: http.MethodGet, Path: "/api/v1/pet/mating", Handler: auth(a.matingListings)}, {Method: http.MethodPost, Path: "/api/v1/pet/mating", Handler: auth(a.createMatingListing)}, {Method: http.MethodPost, Path: "/api/v1/pet/mating/:id/close", Handler: auth(a.closeMatingListing)}, {Method: http.MethodGet, Path: "/api/v1/pet/addresses", Handler: auth(a.petAddresses)}, {Method: http.MethodPost, Path: "/api/v1/pet/addresses", Handler: auth(a.createPetAddress)}, {Method: http.MethodGet, Path: "/api/v1/pet/sitter", Handler: auth(a.sitterProfile)}, {Method: http.MethodPost, Path: "/api/v1/pet/sitter", Handler: auth(a.applySitter)}, {Method: http.MethodGet, Path: "/api/v1/pet/sitters/:id/reviews", Handler: auth(a.sitterReviews)}, {Method: http.MethodGet, Path: "/api/v1/pet/feed-price", Handler: auth(a.feedPriceSuggestion)}, {Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.feedTasks)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.createFeedTask)}, {Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks/:id", Handler: auth(a.feedTaskDetail)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/order", Handler: auth(a.createFeedTaskOrder)}, {Method: http.MethodPost, Path: "/api/v1/pet/sitter/deposit", Handler: auth(a.createDepositOrder)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/apply", Handler: auth(a.applyFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/assign", Handler: auth(a.assignFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/confirm", Handler: auth(a.confirmFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/cancel", Handler: auth(a.cancelFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/dispute", Handler: auth(a.raiseFeedDispute)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/review", Handler: auth(a.reviewFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-visits/:id/checkin", Handler: auth(a.checkinFeedVisit)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-visits/:id/finish", Handler: auth(a.finishFeedVisit)}, {Method: http.MethodGet, Path: "/api/v1/wallet", Handler: auth(a.walletSummary)}, {Method: http.MethodGet, Path: "/api/v1/wallet/transactions", Handler: auth(a.walletTransactions)}, {Method: http.MethodGet, Path: "/api/v1/wallet/payout-accounts", Handler: auth(a.payoutAccounts)}, {Method: http.MethodPost, Path: "/api/v1/wallet/payout-accounts", Handler: auth(a.addPayoutAccount)}, {Method: http.MethodGet, Path: "/api/v1/wallet/withdrawals", Handler: auth(a.myWithdrawals)}, {Method: http.MethodPost, Path: "/api/v1/wallet/withdrawals", Handler: auth(a.createWithdrawal)}, {Method: http.MethodGet, Path: "/api/v1/pets", Handler: auth(a.myPets)}, {Method: http.MethodPost, Path: "/api/v1/pets", Handler: auth(a.createPet)}, {Method: http.MethodGet, Path: "/api/v1/pets/:id", Handler: auth(a.petDetail)}, {Method: http.MethodPut, Path: "/api/v1/pets/:id", Handler: auth(a.updatePet)}, {Method: http.MethodDelete, Path: "/api/v1/pets/:id", Handler: auth(a.deletePet)}, {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.MethodPost, Path: "/admin/v1/risk/users/:id/whitelist", Handler: permit("risk:view", a.adminWhitelistRiskUser)}, {Method: http.MethodPost, Path: "/admin/v1/risk/events/:id/handle", Handler: permit("risk:view", a.adminHandleRiskEvent)}, {Method: http.MethodPost, Path: "/admin/v1/posts/batch", Handler: permit("content:manage", a.adminBatchPosts)}, {Method: http.MethodPost, Path: "/admin/v1/messages/batch", Handler: permit("messages:manage", a.adminBatchMessages)}, {Method: http.MethodPost, Path: "/admin/v1/reports/batch", Handler: permit("reports:handle", a.adminBatchReports)}, {Method: http.MethodPost, Path: "/admin/v1/pet/listings/batch", Handler: permit("users:manage", a.adminBatchPetListings)}, {Method: http.MethodGet, Path: "/admin/v1/moderation/keywords", Handler: permit("system:manage", a.adminKeywords)}, {Method: http.MethodPost, Path: "/admin/v1/moderation/keywords", Handler: permit("system:manage", a.adminCreateKeyword)}, {Method: http.MethodPut, Path: "/admin/v1/moderation/keywords/:id", Handler: permit("system:manage", a.adminUpdateKeyword)}, {Method: http.MethodDelete, Path: "/admin/v1/moderation/keywords/:id", Handler: permit("system:manage", a.adminDeleteKeyword)}, {Method: http.MethodPost, Path: "/admin/v1/moderation/keywords/test", Handler: permit("system:manage", a.adminTestKeyword)}, {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.adminRefundOrder)}, {Method: http.MethodGet, Path: "/admin/v1/orders/:id/refunds", Handler: permit("orders:view", a.adminOrderRefunds)}, {Method: http.MethodGet, Path: "/admin/v1/finance/overview", Handler: permit("orders:view", a.adminFinanceOverview)}, {Method: http.MethodGet, Path: "/admin/v1/notifications/broadcasts", Handler: permit("users:view", a.adminBroadcasts)}, {Method: http.MethodGet, Path: "/admin/v1/notifications/broadcasts/preview", Handler: permit("users:view", a.adminBroadcastPreview)}, {Method: http.MethodPost, Path: "/admin/v1/notifications/broadcasts", Handler: permit("users:manage", a.adminSendBroadcast)}, {Method: http.MethodPost, Path: "/admin/v1/notifications/broadcasts/:id/revoke", Handler: permit("users:manage", a.adminRevokeBroadcast)}, {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/pets", Handler: permit("users:view", a.adminPets)}, {Method: http.MethodGet, Path: "/admin/v1/pet/listings", Handler: permit("users:view", a.adminPetListings)}, {Method: http.MethodPost, Path: "/admin/v1/pet/listings/:id/moderate", Handler: permit("users:manage", a.adminModeratePetListing)}, {Method: http.MethodPost, Path: "/admin/v1/pets/:id/moderate", Handler: permit("users:manage", a.adminModeratePet)}, {Method: http.MethodPost, Path: "/admin/v1/pets", Handler: permit("users:manage", a.adminCreatePet)}, {Method: http.MethodPut, Path: "/admin/v1/pets/:id", Handler: permit("users:manage", a.adminUpdatePet)}, {Method: http.MethodDelete, Path: "/admin/v1/pets/:id", Handler: permit("users:manage", a.adminDeletePet)}, {Method: http.MethodGet, Path: "/admin/v1/pet/owners", Handler: permit("users:view", a.adminPetOwners)}, {Method: http.MethodPut, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminUpdatePetListing)}, {Method: http.MethodDelete, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminDeletePetListing)}, {Method: http.MethodPut, Path: "/admin/v1/pet/feed-tasks/:id", Handler: permit("users:manage", a.adminUpdateFeedTask)}, {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/refund", Handler: permit("users:manage", a.adminRefundFeedTask)}, {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks/:id/refunds", Handler: permit("users:view", a.adminFeedRefunds)}, {Method: http.MethodGet, Path: "/admin/v1/pet/overview", Handler: permit("users:view", a.adminPetOverview)}, {Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)}, {Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)}, {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)}, {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("users:manage", a.adminEscrowFeedTask)}, {Method: http.MethodGet, Path: "/admin/v1/pet/disputes", Handler: permit("users:view", a.adminFeedDisputes)}, {Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("users:manage", a.adminHandleFeedDispute)}, {Method: http.MethodGet, Path: "/admin/v1/wallet/withdrawals", Handler: permit("users:view", a.adminWithdrawals)}, {Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/:id/handle", Handler: permit("users:manage", a.adminHandleWithdrawal)}, {Method: http.MethodGet, Path: "/admin/v1/wallet/audit", Handler: permit("users:view", a.adminWalletAudit)}, {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 }