Files
kefu/im/backend/internal/app/app.go
T
Your NameandClaude Opus 5 97351777ec 管理端拆出宠物模块,并给它一个当班概览
值班的人打开控制台只想知道两件事:谁在等回复,钱还对不对得上。概览把
待裁定的申诉、待打款的提现、各类待审排在一屏,并把代管中、已结算、
平台服务费和账户余额一起摆出来;余额与流水对不上时先弹红条,因为那
种时候不该继续打款。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:40:49 +08:00

548 lines
32 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
// 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)
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-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/pay", Handler: auth(a.escrowFeedTask)},
{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.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/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.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.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
}