Add backend foundation and config-driven workbench

This commit is contained in:
2026-04-01 15:01:44 +08:00
parent 88b8f05f03
commit 94a1f0ba78
68 changed files with 10833 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
package app
import (
"context"
"net/http"
"cmr-backend/internal/httpapi"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/service"
"cmr-backend/internal/store/postgres"
)
type App struct {
router http.Handler
store *postgres.Store
}
func New(ctx context.Context, cfg Config) (*App, error) {
pool, err := postgres.Open(ctx, cfg.DatabaseURL)
if err != nil {
return nil, err
}
store := postgres.NewStore(pool)
jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL)
wechatMiniClient := wechatmini.NewClient(cfg.WechatMiniAppID, cfg.WechatMiniSecret, cfg.WechatMiniDevPrefix)
authService := service.NewAuthService(service.AuthSettings{
AppEnv: cfg.AppEnv,
RefreshTTL: cfg.RefreshTTL,
SMSCodeTTL: cfg.SMSCodeTTL,
SMSCodeCooldown: cfg.SMSCodeCooldown,
SMSProvider: cfg.SMSProvider,
DevSMSCode: cfg.DevSMSCode,
WechatMini: wechatMiniClient,
}, store, jwtManager)
entryService := service.NewEntryService(store)
entryHomeService := service.NewEntryHomeService(store)
eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(store)
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL)
homeService := service.NewHomeService(store)
profileService := service.NewProfileService(store)
resultService := service.NewResultService(store)
sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(store)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
return &App{
router: router,
store: store,
}, nil
}
func (a *App) Router() http.Handler {
return a.router
}
func (a *App) Close() {
if a.store != nil {
a.store.Close()
}
}

View File

@@ -0,0 +1,73 @@
package app
import (
"fmt"
"os"
"path/filepath"
"time"
)
type Config struct {
AppEnv string
HTTPAddr string
DatabaseURL string
JWTIssuer string
JWTAccessSecret string
JWTAccessTTL time.Duration
RefreshTTL time.Duration
SMSCodeTTL time.Duration
SMSCodeCooldown time.Duration
SMSProvider string
DevSMSCode string
WechatMiniAppID string
WechatMiniSecret string
WechatMiniDevPrefix string
LocalEventDir string
AssetBaseURL string
}
func LoadConfigFromEnv() (Config, error) {
cfg := Config{
AppEnv: getEnv("APP_ENV", "development"),
HTTPAddr: getEnv("HTTP_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTIssuer: getEnv("JWT_ISSUER", "cmr-backend"),
JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-in-production"),
JWTAccessTTL: getDurationEnv("JWT_ACCESS_TTL", 2*time.Hour),
RefreshTTL: getDurationEnv("AUTH_REFRESH_TTL", 30*24*time.Hour),
SMSCodeTTL: getDurationEnv("AUTH_SMS_CODE_TTL", 10*time.Minute),
SMSCodeCooldown: getDurationEnv("AUTH_SMS_COOLDOWN", 60*time.Second),
SMSProvider: getEnv("AUTH_SMS_PROVIDER", "console"),
DevSMSCode: os.Getenv("AUTH_DEV_SMS_CODE"),
WechatMiniAppID: getEnv("WECHAT_MINI_APP_ID", ""),
WechatMiniSecret: getEnv("WECHAT_MINI_APP_SECRET", ""),
WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"),
LocalEventDir: getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")),
AssetBaseURL: getEnv("ASSET_BASE_URL", "https://oss-mbh5.colormaprun.com/gotomars"),
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTAccessSecret == "" {
return Config{}, fmt.Errorf("JWT_ACCESS_SECRET is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
return parsed
}
}
return fallback
}

View File

@@ -0,0 +1,29 @@
package apperr
import "errors"
type Error struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string {
return e.Message
}
func New(status int, code, message string) *Error {
return &Error{
Status: status,
Code: code,
Message: message,
}
}
func From(err error) *Error {
var appErr *Error
if errors.As(err, &appErr) {
return appErr
}
return nil
}

View File

@@ -0,0 +1,129 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) {
var req service.SendSMSCodeInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.SendSMSCode(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) {
var req service.LoginSMSInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.LoginSMS(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) LoginWechatMini(w http.ResponseWriter, r *http.Request) {
var req service.LoginWechatMiniInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.LoginWechatMini(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) BindMobile(w http.ResponseWriter, r *http.Request) {
var req service.BindMobileInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
req.UserID = auth.UserID
result, err := h.authService.BindMobile(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var req service.RefreshTokenInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.Refresh(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
var req service.LogoutInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
auth := middleware.GetAuthContext(r.Context())
if auth != nil && req.UserID == "" {
req.UserID = auth.UserID
}
if err := h.authService.Logout(r.Context(), req); err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"loggedOut": true,
},
})
}

View File

@@ -0,0 +1,107 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ConfigHandler struct {
configService *service.ConfigService
}
func NewConfigHandler(configService *service.ConfigService) *ConfigHandler {
return &ConfigHandler{configService: configService}
}
func (h *ConfigHandler) ListLocalFiles(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.ListLocalEventFiles()
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) ImportLocal(w http.ResponseWriter, r *http.Request) {
var req service.ImportLocalEventConfigInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
req.EventPublicID = r.PathValue("eventPublicID")
result, err := h.configService.ImportLocalEventConfig(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) BuildPreview(w http.ResponseWriter, r *http.Request) {
var req service.BuildPreviewInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.configService.BuildPreview(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
var req service.PublishBuildInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.configService.PublishBuild(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) ListSources(w http.ResponseWriter, r *http.Request) {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.configService.ListEventConfigSources(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) GetSource(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.GetEventConfigSource(r.Context(), r.PathValue("sourceID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.GetEventConfigBuild(r.Context(), r.PathValue("buildID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EntryHandler struct {
entryService *service.EntryService
}
func NewEntryHandler(entryService *service.EntryService) *EntryHandler {
return &EntryHandler{entryService: entryService}
}
func (h *EntryHandler) Resolve(w http.ResponseWriter, r *http.Request) {
result, err := h.entryService.Resolve(r.Context(), service.ResolveEntryInput{
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,40 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EntryHomeHandler struct {
entryHomeService *service.EntryHomeService
}
func NewEntryHomeHandler(entryHomeService *service.EntryHomeService) *EntryHomeHandler {
return &EntryHomeHandler{entryHomeService: entryHomeService}
}
func (h *EntryHomeHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.entryHomeService.GetEntryHome(r.Context(), service.EntryHomeInput{
UserID: auth.UserID,
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,51 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EventHandler struct {
eventService *service.EventService
}
func NewEventHandler(eventService *service.EventService) *EventHandler {
return &EventHandler{eventService: eventService}
}
func (h *EventHandler) GetDetail(w http.ResponseWriter, r *http.Request) {
eventPublicID := r.PathValue("eventPublicID")
result, err := h.eventService.GetEventDetail(r.Context(), eventPublicID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *EventHandler) Launch(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
var req service.LaunchEventInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
req.EventPublicID = r.PathValue("eventPublicID")
req.UserID = auth.UserID
result, err := h.eventService.LaunchEvent(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,37 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EventPlayHandler struct {
eventPlayService *service.EventPlayService
}
func NewEventPlayHandler(eventPlayService *service.EventPlayService) *EventPlayHandler {
return &EventPlayHandler{eventPlayService: eventPlayService}
}
func (h *EventPlayHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.eventPlayService.GetEventPlay(r.Context(), service.EventPlayInput{
EventPublicID: r.PathValue("eventPublicID"),
UserID: auth.UserID,
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) Get(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"status": "ok",
},
})
}

View File

@@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type HomeHandler struct {
homeService *service.HomeService
}
func NewHomeHandler(homeService *service.HomeService) *HomeHandler {
return &HomeHandler{homeService: homeService}
}
func (h *HomeHandler) GetHome(w http.ResponseWriter, r *http.Request) {
result, err := h.homeService.GetHome(r.Context(), buildListCardsInput(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *HomeHandler) GetCards(w http.ResponseWriter, r *http.Request) {
result, err := h.homeService.ListCards(r.Context(), buildListCardsInput(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func buildListCardsInput(r *http.Request) service.ListCardsInput {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
return service.ListCardsInput{
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
Slot: r.URL.Query().Get("slot"),
Limit: limit,
}
}

View File

@@ -0,0 +1,34 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type MeHandler struct {
meService *service.MeService
}
func NewMeHandler(meService *service.MeService) *MeHandler {
return &MeHandler{meService: meService}
}
func (h *MeHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
user, err := h.meService.GetMe(r.Context(), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": user})
}

View File

@@ -0,0 +1,34 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ProfileHandler struct {
profileService *service.ProfileService
}
func NewProfileHandler(profileService *service.ProfileService) *ProfileHandler {
return &ProfileHandler{profileService: profileService}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.profileService.GetProfile(r.Context(), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ResultHandler struct {
resultService *service.ResultService
}
func NewResultHandler(resultService *service.ResultService) *ResultHandler {
return &ResultHandler{resultService: resultService}
}
func (h *ResultHandler) GetSessionResult(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.resultService.GetSessionResult(r.Context(), r.PathValue("sessionPublicID"), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ResultHandler) ListMine(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.resultService.ListMyResults(r.Context(), auth.UserID, limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,88 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type SessionHandler struct {
sessionService *service.SessionService
}
func NewSessionHandler(sessionService *service.SessionService) *SessionHandler {
return &SessionHandler{sessionService: sessionService}
}
func (h *SessionHandler) GetDetail(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.sessionService.GetSession(r.Context(), r.PathValue("sessionPublicID"), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.sessionService.ListMySessions(r.Context(), auth.UserID, limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) Start(w http.ResponseWriter, r *http.Request) {
var req service.SessionActionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
req.SessionPublicID = r.PathValue("sessionPublicID")
result, err := h.sessionService.StartSession(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) Finish(w http.ResponseWriter, r *http.Request) {
var req service.FinishSessionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
req.SessionPublicID = r.PathValue("sessionPublicID")
result, err := h.sessionService.FinishSession(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -0,0 +1,50 @@
package middleware
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/platform/jwtx"
)
type authContextKey string
const authKey authContextKey = "auth"
type AuthContext struct {
UserID string
UserPublicID string
}
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing bearer token"))
return
}
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
claims, err := jwtManager.ParseAccessToken(token)
if err != nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return
}
ctx := context.WithValue(r.Context(), authKey, &AuthContext{
UserID: claims.UserID,
UserPublicID: claims.UserPublicID,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetAuthContext(ctx context.Context) *AuthContext {
auth, _ := ctx.Value(authKey).(*AuthContext)
return auth
}

View File

@@ -0,0 +1,80 @@
package httpapi
import (
"net/http"
"cmr-backend/internal/httpapi/handlers"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/service"
)
func NewRouter(
appEnv string,
jwtManager *jwtx.Manager,
authService *service.AuthService,
entryService *service.EntryService,
entryHomeService *service.EntryHomeService,
eventService *service.EventService,
eventPlayService *service.EventPlayService,
configService *service.ConfigService,
homeService *service.HomeService,
profileService *service.ProfileService,
resultService *service.ResultService,
sessionService *service.SessionService,
devService *service.DevService,
meService *service.MeService,
) http.Handler {
mux := http.NewServeMux()
healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService)
entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
eventHandler := handlers.NewEventHandler(eventService)
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
configHandler := handlers.NewConfigHandler(configService)
homeHandler := handlers.NewHomeHandler(homeService)
profileHandler := handlers.NewProfileHandler(profileService)
resultHandler := handlers.NewResultHandler(resultService)
sessionHandler := handlers.NewSessionHandler(sessionService)
devHandler := handlers.NewDevHandler(devService)
meHandler := handlers.NewMeHandler(meService)
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
mux.HandleFunc("GET /healthz", healthHandler.Get)
mux.HandleFunc("GET /home", homeHandler.GetHome)
mux.HandleFunc("GET /cards", homeHandler.GetCards)
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal)
mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview)
mux.HandleFunc("POST /dev/config-builds/publish", configHandler.PublishBuild)
}
mux.Handle("GET /me/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get)))
mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get)))
mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail)
mux.Handle("GET /events/{eventPublicID}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get)))
mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources)))
mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch)))
mux.Handle("GET /config-sources/{sourceID}", authMiddleware(http.HandlerFunc(configHandler.GetSource)))
mux.Handle("GET /config-builds/{buildID}", authMiddleware(http.HandlerFunc(configHandler.GetBuild)))
mux.Handle("GET /sessions/{sessionPublicID}", authMiddleware(http.HandlerFunc(sessionHandler.GetDetail)))
mux.Handle("GET /sessions/{sessionPublicID}/result", authMiddleware(http.HandlerFunc(resultHandler.GetSessionResult)))
mux.HandleFunc("POST /sessions/{sessionPublicID}/start", sessionHandler.Start)
mux.HandleFunc("POST /sessions/{sessionPublicID}/finish", sessionHandler.Finish)
mux.HandleFunc("POST /auth/sms/send", authHandler.SendSMSCode)
mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS)
mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini)
mux.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile)))
mux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
mux.HandleFunc("POST /auth/logout", authHandler.Logout)
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get)))
mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine)))
mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.ListMine)))
return mux
}

View File

@@ -0,0 +1,39 @@
package httpx
import (
"encoding/json"
"net/http"
"cmr-backend/internal/apperr"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, err error) {
if appErr := apperr.From(err); appErr != nil {
WriteJSON(w, appErr.Status, map[string]any{
"error": map[string]any{
"code": appErr.Code,
"message": appErr.Message,
},
})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]any{
"error": map[string]any{
"code": "internal_error",
"message": "internal server error",
},
})
}
func DecodeJSON(r *http.Request, dst any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(dst)
}

View File

@@ -0,0 +1,67 @@
package jwtx
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Manager struct {
issuer string
secret []byte
ttl time.Duration
}
type AccessClaims struct {
UserID string `json:"uid"`
UserPublicID string `json:"upub"`
jwt.RegisteredClaims
}
func NewManager(issuer, secret string, ttl time.Duration) *Manager {
return &Manager{
issuer: issuer,
secret: []byte(secret),
ttl: ttl,
}
}
func (m *Manager) IssueAccessToken(userID, userPublicID string) (string, time.Time, error) {
expiresAt := time.Now().UTC().Add(m.ttl)
claims := AccessClaims{
UserID: userID,
UserPublicID: userPublicID,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: userID,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.secret)
if err != nil {
return "", time.Time{}, err
}
return signed, expiresAt, nil
}
func (m *Manager) ParseAccessToken(tokenString string) (*AccessClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessClaims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*AccessClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}

View File

@@ -0,0 +1,47 @@
package security
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func GenerateToken(byteLength int) (string, error) {
raw := make([]byte, byteLength)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func GenerateNumericCode(length int) (string, error) {
if length <= 0 {
length = 6
}
const digits = "0123456789"
raw := make([]byte, length)
if _, err := rand.Read(raw); err != nil {
return "", err
}
code := make([]byte, length)
for i := range raw {
code[i] = digits[int(raw[i])%len(digits)]
}
return string(code), nil
}
func HashText(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func GeneratePublicID(prefix string) (string, error) {
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + "_" + hex.EncodeToString(raw), nil
}

View File

@@ -0,0 +1,120 @@
package wechatmini
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
appID string
appSecret string
devPrefix string
httpClient *http.Client
}
type Session struct {
AppID string
OpenID string
UnionID string
SessionKey string
}
type code2SessionResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func NewClient(appID, appSecret, devPrefix string) *Client {
return &Client{
appID: appID,
appSecret: appSecret,
devPrefix: devPrefix,
httpClient: &http.Client{Timeout: 8 * time.Second},
}
}
func (c *Client) ExchangeCode(ctx context.Context, code string) (*Session, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, fmt.Errorf("wechat code is required")
}
if c.devPrefix != "" && strings.HasPrefix(code, c.devPrefix) {
suffix := strings.TrimPrefix(code, c.devPrefix)
if suffix == "" {
suffix = "default"
}
return &Session{
AppID: fallbackString(c.appID, "dev-mini-app"),
OpenID: "dev_openid_" + normalizeDevID(suffix),
UnionID: "",
}, nil
}
if c.appID == "" || c.appSecret == "" {
return nil, fmt.Errorf("wechat mini app credentials are not configured")
}
values := url.Values{}
values.Set("appid", c.appID)
values.Set("secret", c.appSecret)
values.Set("js_code", code)
values.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+values.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var parsed code2SessionResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if parsed.ErrCode != 0 {
return nil, fmt.Errorf("wechat code2session failed: %d %s", parsed.ErrCode, parsed.ErrMsg)
}
if parsed.OpenID == "" {
return nil, fmt.Errorf("wechat code2session returned empty openid")
}
return &Session{
AppID: c.appID,
OpenID: parsed.OpenID,
UnionID: parsed.UnionID,
SessionKey: parsed.SessionKey,
}, nil
}
func normalizeDevID(value string) string {
sum := sha1.Sum([]byte(value))
return hex.EncodeToString(sum[:])[:16]
}
func fallbackString(value, fallback string) string {
if value != "" {
return value
}
return fallback
}

View File

@@ -0,0 +1,595 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/store/postgres"
)
type AuthSettings struct {
AppEnv string
RefreshTTL time.Duration
SMSCodeTTL time.Duration
SMSCodeCooldown time.Duration
SMSProvider string
DevSMSCode string
WechatMini *wechatmini.Client
}
type AuthService struct {
cfg AuthSettings
store *postgres.Store
jwtManager *jwtx.Manager
}
type SendSMSCodeInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
Scene string `json:"scene"`
}
type SendSMSCodeResult struct {
TTLSeconds int64 `json:"ttlSeconds"`
CooldownSeconds int64 `json:"cooldownSeconds"`
DevCode *string `json:"devCode,omitempty"`
}
type LoginSMSInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LoginWechatMiniInput struct {
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type BindMobileInput struct {
UserID string `json:"-"`
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type RefreshTokenInput struct {
RefreshToken string `json:"refreshToken"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LogoutInput struct {
RefreshToken string `json:"refreshToken"`
UserID string `json:"-"`
}
type AuthUser struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
type AuthTokens struct {
AccessToken string `json:"accessToken"`
AccessTokenExpiresAt string `json:"accessTokenExpiresAt"`
RefreshToken string `json:"refreshToken"`
RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"`
}
type AuthResult struct {
User AuthUser `json:"user"`
Tokens AuthTokens `json:"tokens"`
NewUser bool `json:"newUser"`
}
func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService {
return &AuthService{
cfg: cfg,
store: store,
jwtManager: jwtManager,
}
}
func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Scene = normalizeScene(input.Scene)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
}
latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, input.ClientType, input.Scene)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if latest != nil && latest.CooldownUntil.After(now) {
return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
}
code := s.cfg.DevSMSCode
if code == "" {
code, err = security.GenerateNumericCode(6)
if err != nil {
return nil, err
}
}
expiresAt := now.Add(s.cfg.SMSCodeTTL)
cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
Scene: input.Scene,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
ClientType: input.ClientType,
DeviceKey: input.DeviceKey,
CodeHash: security.HashText(code),
ProviderName: s.cfg.SMSProvider,
ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider},
ExpiresAt: expiresAt,
CooldownUntil: cooldownUntil,
}); err != nil {
return nil, err
}
result := &SendSMSCodeResult{
TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
}
if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
result.DevCode = &code
}
return result, nil
}
func (s *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "login")
if err != nil {
return nil, err
}
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
if err != nil {
return nil, err
}
if !consumed {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
}
user, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
newUser := false
if user == nil {
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: user.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newUser = true
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) {
input.RefreshToken = strings.TrimSpace(input.RefreshToken)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.RefreshToken == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.GetRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
if err != nil {
return nil, err
}
if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
}
if input.ClientType != "" && input.ClientType != record.ClientType {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch")
}
if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
}
user, err := s.store.GetUserByID(ctx, tx, record.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
}
result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false)
if err != nil {
return nil, err
}
if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) {
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.ClientType != "wechat" {
return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat")
}
if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required")
}
if s.cfg.WechatMini == nil {
return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured")
}
session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code)
if err != nil {
return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error())
}
openIDSubject := session.AppID + ":" + session.OpenID
unionIDSubject := strings.TrimSpace(session.UnionID)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject)
if err != nil {
return nil, err
}
if user == nil && unionIDSubject != "" {
user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject)
if err != nil {
return nil, err
}
}
newUser := false
if user == nil {
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
newUser = true
}
profileJSON, err := json.Marshal(map[string]any{
"appId": session.AppID,
})
if err != nil {
return nil, err
}
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_mini_openid",
Provider: "wechat_mini",
ProviderSubj: openIDSubject,
ProfileJSON: string(profileJSON),
}); err != nil {
return nil, err
}
if unionIDSubject != "" {
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_unionid",
Provider: "wechat_unionid",
ProviderSubj: unionIDSubject,
ProfileJSON: "{}",
}); err != nil {
return nil, err
}
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile")
if err != nil {
return nil, err
}
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
if err != nil {
return nil, err
}
if !consumed {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
}
currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID)
if err != nil {
return nil, err
}
if currentUser == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found")
}
mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
finalUser := currentUser
newlyBound := false
if mobileUser == nil {
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: currentUser.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newlyBound = true
} else if mobileUser.ID != currentUser.ID {
if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil {
return nil, err
}
if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
finalUser = mobileUser
}
if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error {
if strings.TrimSpace(input.RefreshToken) == "" {
return nil
}
return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
}
func (s *AuthService) issueAuthResult(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, error) {
result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser)
return result, err
}
func (s *AuthService) issueAuthResultWithRefreshID(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, string, error) {
accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID)
if err != nil {
return nil, "", err
}
refreshToken, err := security.GenerateToken(32)
if err != nil {
return nil, "", err
}
refreshTokenHash := security.HashText(refreshToken)
refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
refreshID, err := s.store.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{
UserID: user.ID,
ClientType: clientType,
DeviceKey: deviceKey,
TokenHash: refreshTokenHash,
ExpiresAt: refreshExpiresAt,
})
if err != nil {
return nil, "", err
}
return &AuthResult{
User: AuthUser{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
},
Tokens: AuthTokens{
AccessToken: accessToken,
AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
},
NewUser: newUser,
}, refreshID, nil
}
func validateClientType(clientType string) error {
switch clientType {
case "app", "wechat":
return nil
default:
return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat")
}
}
func normalizeCountryCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "86"
}
return strings.TrimPrefix(value, "+")
}
func normalizeMobile(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "-", "")
return value
}
func normalizeScene(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "login"
}
return value
}
func nullableStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -0,0 +1,678 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type ConfigService struct {
store *postgres.Store
localEventDir string
assetBaseURL string
}
type ConfigPipelineSummary struct {
SourceTable string `json:"sourceTable"`
BuildTable string `json:"buildTable"`
ReleaseAssetsTable string `json:"releaseAssetsTable"`
}
type LocalEventFile struct {
FileName string `json:"fileName"`
FullPath string `json:"fullPath"`
}
type EventConfigSourceView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
SourceVersionNo int `json:"sourceVersionNo"`
SourceKind string `json:"sourceKind"`
SchemaID string `json:"schemaId"`
SchemaVersion string `json:"schemaVersion"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
Source map[string]any `json:"source"`
}
type EventConfigBuildView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
SourceID string `json:"sourceId"`
BuildNo int `json:"buildNo"`
BuildStatus string `json:"buildStatus"`
BuildLog *string `json:"buildLog,omitempty"`
Manifest map[string]any `json:"manifest"`
AssetIndex []map[string]any `json:"assetIndex"`
}
type PublishedReleaseView struct {
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
}
type ImportLocalEventConfigInput struct {
EventPublicID string
FileName string `json:"fileName"`
Notes *string `json:"notes,omitempty"`
}
type BuildPreviewInput struct {
SourceID string `json:"sourceId"`
}
type PublishBuildInput struct {
BuildID string `json:"buildId"`
}
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService {
return &ConfigService{
store: store,
localEventDir: localEventDir,
assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
}
}
func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
return ConfigPipelineSummary{
SourceTable: "event_config_sources",
BuildTable: "event_config_builds",
ReleaseAssetsTable: "event_release_assets",
}
}
func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
dir, err := filepath.Abs(s.localEventDir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
}
files := make([]LocalEventFile, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
continue
}
files = append(files, LocalEventFile{
FileName: entry.Name(),
FullPath: filepath.Join(dir, entry.Name()),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].FileName < files[j].FileName
})
return files, nil
}
func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
event, err := s.requireEvent(ctx, eventPublicID)
if err != nil {
return nil, err
}
items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
results := make([]EventConfigSourceView, 0, len(items))
for i := range items {
view, err := buildEventConfigSourceView(&items[i], event.PublicID)
if err != nil {
return nil, err
}
results = append(results, *view)
}
return results, nil
}
func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
}
return buildEventConfigSourceView(record, "")
}
func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
}
return buildEventConfigBuildView(record)
}
func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
event, err := s.requireEvent(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
fileName := strings.TrimSpace(filepath.Base(input.FileName))
if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
}
dir, err := filepath.Abs(s.localEventDir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
}
path := filepath.Join(dir, fileName)
raw, err := os.ReadFile(path)
if err != nil {
return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
}
source := map[string]any{}
if err := json.Unmarshal(raw, &source); err != nil {
return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
}
if err := validateSourceConfig(source); err != nil {
return nil, err
}
nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
if err != nil {
return nil, err
}
note := input.Notes
if note == nil || strings.TrimSpace(*note) == "" {
defaultNote := "imported from local event file: " + fileName
note = &defaultNote
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
EventID: event.ID,
SourceVersionNo: nextVersion,
SourceKind: "event_bundle",
SchemaID: "event-source",
SchemaVersion: resolveSchemaVersion(source),
Status: "active",
Source: source,
Notes: note,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildEventConfigSourceView(record, event.PublicID)
}
func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
if err != nil {
return nil, err
}
if sourceRecord == nil {
return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
}
source, err := decodeJSONObject(sourceRecord.SourceJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
}
if err := validateSourceConfig(source); err != nil {
return nil, err
}
buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
if err != nil {
return nil, err
}
previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
manifest := s.buildPreviewManifest(source, previewReleaseID)
assetIndex := s.buildAssetIndex(manifest)
buildLog := "preview build generated from source " + sourceRecord.ID
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
EventID: sourceRecord.EventID,
SourceID: sourceRecord.ID,
BuildNo: buildNo,
BuildStatus: "success",
BuildLog: &buildLog,
Manifest: manifest,
AssetIndex: assetIndex,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildEventConfigBuildView(record)
}
func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
if err != nil {
return nil, err
}
if buildRecord == nil {
return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
}
if buildRecord.BuildStatus != "success" {
return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
}
event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
}
assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
}
releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
if err != nil {
return nil, err
}
releasePublicID, err := security.GeneratePublicID("rel")
if err != nil {
return nil, err
}
configLabel := deriveConfigLabel(event, manifest, releaseNo)
manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
checksum := security.HashText(buildRecord.ManifestJSON)
routeCode := deriveRouteCode(manifest)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
PublicID: releasePublicID,
EventID: event.ID,
ReleaseNo: releaseNo,
ConfigLabel: configLabel,
ManifestURL: manifestURL,
ManifestChecksum: &checksum,
RouteCode: routeCode,
BuildID: &buildRecord.ID,
Status: "published",
PayloadJSON: buildRecord.ManifestJSON,
})
if err != nil {
return nil, err
}
if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, &checksum, assetIndex)); err != nil {
return nil, err
}
if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &PublishedReleaseView{
EventID: event.PublicID,
Release: ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: LaunchSourceEventCurrentRelease,
EventID: event.PublicID,
ReleaseID: releaseRecord.PublicID,
ConfigLabel: releaseRecord.ConfigLabel,
ManifestURL: releaseRecord.ManifestURL,
ManifestChecksumSha256: releaseRecord.ManifestChecksum,
RouteCode: releaseRecord.RouteCode,
},
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
}, nil
}
func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
return event, nil
}
func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
source, err := decodeJSONObject(record.SourceJSON)
if err != nil {
return nil, err
}
view := &EventConfigSourceView{
ID: record.ID,
EventID: eventPublicID,
SourceVersionNo: record.SourceVersionNo,
SourceKind: record.SourceKind,
SchemaID: record.SchemaID,
SchemaVersion: record.SchemaVersion,
Status: record.Status,
Notes: record.Notes,
Source: source,
}
return view, nil
}
func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
manifest, err := decodeJSONObject(record.ManifestJSON)
if err != nil {
return nil, err
}
assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
if err != nil {
return nil, err
}
return &EventConfigBuildView{
ID: record.ID,
EventID: record.EventID,
SourceID: record.SourceID,
BuildNo: record.BuildNo,
BuildStatus: record.BuildStatus,
BuildLog: record.BuildLog,
Manifest: manifest,
AssetIndex: assetIndex,
}, nil
}
func validateSourceConfig(source map[string]any) error {
requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
value, ok := parent[key]
if !ok {
return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
}
asMap, ok := value.(map[string]any)
if !ok {
return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
}
return asMap, nil
}
requiredString := func(parent map[string]any, key string) error {
value, ok := parent[key]
if !ok {
return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
}
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
}
return nil
}
if err := requiredString(source, "schemaVersion"); err != nil {
return err
}
app, err := requiredMap(source, "app")
if err != nil {
return err
}
if err := requiredString(app, "id"); err != nil {
return err
}
if err := requiredString(app, "title"); err != nil {
return err
}
m, err := requiredMap(source, "map")
if err != nil {
return err
}
if err := requiredString(m, "tiles"); err != nil {
return err
}
if err := requiredString(m, "mapmeta"); err != nil {
return err
}
playfield, err := requiredMap(source, "playfield")
if err != nil {
return err
}
if err := requiredString(playfield, "kind"); err != nil {
return err
}
playfieldSource, err := requiredMap(playfield, "source")
if err != nil {
return err
}
if err := requiredString(playfieldSource, "type"); err != nil {
return err
}
if err := requiredString(playfieldSource, "url"); err != nil {
return err
}
game, err := requiredMap(source, "game")
if err != nil {
return err
}
if err := requiredString(game, "mode"); err != nil {
return err
}
return nil
}
func resolveSchemaVersion(source map[string]any) string {
if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
return value
}
return "1"
}
func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
manifest := cloneJSONObject(source)
manifest["releaseId"] = previewReleaseID
manifest["preview"] = true
manifest["assetBaseUrl"] = s.assetBaseURL
if version, ok := manifest["version"]; !ok || version == "" {
manifest["version"] = "preview"
}
if m, ok := manifest["map"].(map[string]any); ok {
if tiles, ok := m["tiles"].(string); ok {
m["tiles"] = s.normalizeAssetURL(tiles)
}
if meta, ok := m["mapmeta"].(string); ok {
m["mapmeta"] = s.normalizeAssetURL(meta)
}
}
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if src, ok := playfield["source"].(map[string]any); ok {
if url, ok := src["url"].(string); ok {
src["url"] = s.normalizeAssetURL(url)
}
}
}
if assets, ok := manifest["assets"].(map[string]any); ok {
for key, value := range assets {
if text, ok := value.(string); ok {
assets[key] = s.normalizeAssetURL(text)
}
}
}
return manifest
}
func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
var assets []map[string]any
if m, ok := manifest["map"].(map[string]any); ok {
if tiles, ok := m["tiles"].(string); ok {
assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
}
if meta, ok := m["mapmeta"].(string); ok {
assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
}
}
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if src, ok := playfield["source"].(map[string]any); ok {
if url, ok := src["url"].(string); ok {
assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
}
}
}
if rawAssets, ok := manifest["assets"].(map[string]any); ok {
keys := make([]string, 0, len(rawAssets))
for key := range rawAssets {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if url, ok := rawAssets[key].(string); ok {
assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
}
}
}
return assets
}
func (s *ConfigService) normalizeAssetURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value
}
trimmed := strings.TrimPrefix(value, "../")
trimmed = strings.TrimPrefix(trimmed, "./")
trimmed = strings.TrimLeft(trimmed, "/")
return s.assetBaseURL + "/" + trimmed
}
func cloneJSONObject(source map[string]any) map[string]any {
raw, _ := json.Marshal(source)
cloned := map[string]any{}
_ = json.Unmarshal(raw, &cloned)
return cloned
}
func decodeJSONObject(raw string) (map[string]any, error) {
result := map[string]any{}
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, err
}
return result, nil
}
func decodeJSONArray(raw string) ([]map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return []map[string]any{}, nil
}
var result []map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, err
}
return result, nil
}
func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
if app, ok := manifest["app"].(map[string]any); ok {
if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
}
}
if event != nil && strings.TrimSpace(event.DisplayName) != "" {
return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
}
return fmt.Sprintf("Release %d", releaseNo)
}
func deriveRouteCode(manifest map[string]any) *string {
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
route := strings.TrimSpace(value)
return &route
}
}
return nil
}
func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
assets := []postgres.UpsertEventReleaseAssetParams{
{
EventReleaseID: eventReleaseID,
AssetType: "manifest",
AssetKey: "manifest",
AssetURL: manifestURL,
Checksum: checksum,
Meta: map[string]any{"source": "published-build"},
},
}
for _, asset := range assetIndex {
assetType, _ := asset["assetType"].(string)
assetKey, _ := asset["assetKey"].(string)
assetURL, _ := asset["assetUrl"].(string)
if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
continue
}
mappedType := assetType
if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
mappedType = "other"
}
assets = append(assets, postgres.UpsertEventReleaseAssetParams{
EventReleaseID: eventReleaseID,
AssetType: mappedType,
AssetKey: assetKey,
AssetURL: assetURL,
Meta: asset,
})
}
return assets
}

View File

@@ -0,0 +1,32 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type DevService struct {
appEnv string
store *postgres.Store
}
func NewDevService(appEnv string, store *postgres.Store) *DevService {
return &DevService{
appEnv: appEnv,
store: store,
}
}
func (s *DevService) Enabled() bool {
return s.appEnv != "production"
}
func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrapSummary, error) {
if !s.Enabled() {
return nil, apperr.New(http.StatusNotFound, "not_found", "dev bootstrap is disabled")
}
return s.store.EnsureDemoData(ctx)
}

View File

@@ -0,0 +1,164 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EntryHomeService struct {
store *postgres.Store
}
type EntryHomeInput struct {
UserID string
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
type EntryHomeResult struct {
User struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
} `json:"user"`
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
Cards []CardResult `json:"cards"`
OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"`
RecentSession *EntrySessionSummary `json:"recentSession,omitempty"`
}
type EntrySessionSummary struct {
ID string `json:"id"`
Status string `json:"status"`
EventID string `json:"eventId"`
EventName string `json:"eventName"`
ReleaseID *string `json:"releaseId,omitempty"`
ConfigLabel *string `json:"configLabel,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
LaunchedAt string `json:"launchedAt"`
StartedAt *string `json:"startedAt,omitempty"`
EndedAt *string `json:"endedAt,omitempty"`
}
func NewEntryHomeService(store *postgres.Store) *EntryHomeService {
return &EntryHomeService{store: store}
}
func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInput) (*EntryHomeResult, error) {
input.UserID = strings.TrimSpace(input.UserID)
if input.UserID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
user, err := s.store.GetUserByID(ctx, s.store.Pool(), input.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: strings.TrimSpace(input.ChannelCode),
ChannelType: strings.TrimSpace(input.ChannelType),
PlatformAppID: strings.TrimSpace(input.PlatformAppID),
TenantCode: strings.TrimSpace(input.TenantCode),
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, "home_primary", nowUTC(), 20)
if err != nil {
return nil, err
}
sessions, err := s.store.ListSessionsByUserID(ctx, user.ID, 10)
if err != nil {
return nil, err
}
result := &EntryHomeResult{
Cards: mapCards(cards),
}
result.User.ID = user.ID
result.User.PublicID = user.PublicID
result.User.Status = user.Status
result.User.Nickname = user.Nickname
result.User.AvatarURL = user.AvatarURL
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])
result.RecentSession = &recent
}
for i := range sessions {
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
ongoing := buildEntrySessionSummary(&sessions[i])
result.OngoingSession = &ongoing
break
}
}
return result, nil
}
func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary {
summary := EntrySessionSummary{
ID: session.SessionPublicID,
Status: session.Status,
RouteCode: session.RouteCode,
LaunchedAt: session.LaunchedAt.Format(timeRFC3339),
}
if session.EventPublicID != nil {
summary.EventID = *session.EventPublicID
}
if session.EventDisplayName != nil {
summary.EventName = *session.EventDisplayName
}
summary.ReleaseID = session.ReleasePublicID
summary.ConfigLabel = session.ConfigLabel
if session.StartedAt != nil {
value := session.StartedAt.Format(timeRFC3339)
summary.StartedAt = &value
}
if session.EndedAt != nil {
value := session.EndedAt.Format(timeRFC3339)
summary.EndedAt = &value
}
return summary
}

View File

@@ -0,0 +1,79 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EntryService struct {
store *postgres.Store
}
type ResolveEntryInput struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
type ResolveEntryResult struct {
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
}
func NewEntryService(store *postgres.Store) *EntryService {
return &EntryService{store: store}
}
func (s *EntryService) Resolve(ctx context.Context, input ResolveEntryInput) (*ResolveEntryResult, error) {
input.ChannelCode = strings.TrimSpace(input.ChannelCode)
input.ChannelType = strings.TrimSpace(input.ChannelType)
input.PlatformAppID = strings.TrimSpace(input.PlatformAppID)
input.TenantCode = strings.TrimSpace(input.TenantCode)
if input.ChannelCode == "" && input.PlatformAppID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "channelCode or platformAppId is required")
}
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: input.ChannelCode,
ChannelType: input.ChannelType,
PlatformAppID: input.PlatformAppID,
TenantCode: input.TenantCode,
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
result := &ResolveEntryResult{}
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
return result, nil
}

View File

@@ -0,0 +1,131 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EventPlayService struct {
store *postgres.Store
}
type EventPlayInput struct {
EventPublicID string
UserID string
}
type EventPlayResult struct {
Event struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
} `json:"event"`
Release *struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Play struct {
CanLaunch bool `json:"canLaunch"`
PrimaryAction string `json:"primaryAction"`
Reason string `json:"reason"`
LaunchSource string `json:"launchSource,omitempty"`
OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"`
RecentSession *EntrySessionSummary `json:"recentSession,omitempty"`
} `json:"play"`
}
func NewEventPlayService(store *postgres.Store) *EventPlayService {
return &EventPlayService{store: store}
}
func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInput) (*EventPlayResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
input.UserID = strings.TrimSpace(input.UserID)
if input.EventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
if input.UserID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
sessions, err := s.store.ListSessionsByUserAndEvent(ctx, input.UserID, event.ID, 10)
if err != nil {
return nil, err
}
result := &EventPlayResult{}
result.Event.ID = event.PublicID
result.Event.Slug = event.Slug
result.Event.DisplayName = event.DisplayName
result.Event.Summary = event.Summary
result.Event.Status = event.Status
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
result.Release = &struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}{
ID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])
result.Play.RecentSession = &recent
}
for i := range sessions {
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
ongoing := buildEntrySessionSummary(&sessions[i])
result.Play.OngoingSession = &ongoing
break
}
}
canLaunch := event.Status == "active" && event.CurrentReleaseID != nil && event.ManifestURL != nil
result.Play.CanLaunch = canLaunch
if canLaunch {
result.Play.LaunchSource = LaunchSourceEventCurrentRelease
}
switch {
case result.Play.OngoingSession != nil:
result.Play.PrimaryAction = "continue"
result.Play.Reason = "user has an ongoing session for this event"
case canLaunch:
result.Play.PrimaryAction = "start"
result.Play.Reason = "event is active and launchable"
case result.Play.RecentSession != nil:
result.Play.PrimaryAction = "review_last_result"
result.Play.Reason = "event is not launchable, but user has previous session history"
default:
result.Play.PrimaryAction = "unavailable"
result.Play.Reason = "event is not launchable"
}
return result, nil
}

View File

@@ -0,0 +1,195 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type EventService struct {
store *postgres.Store
}
type EventDetailResult struct {
Event struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
} `json:"event"`
Release *struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
}
type LaunchEventInput struct {
EventPublicID string
UserID string
ReleaseID string `json:"releaseId,omitempty"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LaunchEventResult struct {
Event struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
} `json:"event"`
Launch struct {
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Config struct {
ConfigURL string `json:"configUrl"`
ConfigLabel string `json:"configLabel"`
ConfigChecksumSha256 *string `json:"configChecksumSha256,omitempty"`
ReleaseID string `json:"releaseId"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"config"`
Business struct {
Source string `json:"source"`
EventID string `json:"eventId"`
SessionID string `json:"sessionId"`
SessionToken string `json:"sessionToken"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"business"`
} `json:"launch"`
}
func NewEventService(store *postgres.Store) *EventService {
return &EventService{store: store}
}
func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
result := &EventDetailResult{}
result.Event.ID = event.PublicID
result.Event.Slug = event.Slug
result.Event.DisplayName = event.DisplayName
result.Event.Summary = event.Summary
result.Event.Status = event.Status
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
result.Release = &struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}{
ID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
return result, nil
}
func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*LaunchEventResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.EventPublicID == "" || input.UserID == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id, user and deviceKey are required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
if event.Status != "active" {
return nil, apperr.New(http.StatusConflict, "event_not_launchable", "event is not active")
}
if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil, apperr.New(http.StatusConflict, "event_release_missing", "event does not have a published release")
}
if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
sessionPublicID, err := security.GeneratePublicID("sess")
if err != nil {
return nil, err
}
sessionToken, err := security.GenerateToken(32)
if err != nil {
return nil, err
}
sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
SessionPublicID: sessionPublicID,
UserID: input.UserID,
EventID: event.ID,
EventReleaseID: *event.CurrentReleaseID,
DeviceKey: input.DeviceKey,
ClientType: input.ClientType,
RouteCode: event.RouteCode,
SessionTokenHash: security.HashText(sessionToken),
SessionTokenExpiresAt: sessionTokenExpiresAt,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := &LaunchEventResult{}
result.Event.ID = event.PublicID
result.Event.DisplayName = event.DisplayName
result.Launch.Source = LaunchSourceEventCurrentRelease
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
result.Launch.Config.RouteCode = event.RouteCode
result.Launch.Business.Source = "direct-event"
result.Launch.Business.EventID = event.PublicID
result.Launch.Business.SessionID = session.SessionPublicID
result.Launch.Business.SessionToken = sessionToken
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Launch.Business.RouteCode = event.RouteCode
return result, nil
}

View File

@@ -0,0 +1,159 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type HomeService struct {
store *postgres.Store
}
type ListCardsInput struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
Slot string
Limit int
}
type CardResult struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Subtitle *string `json:"subtitle,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
DisplaySlot string `json:"displaySlot"`
DisplayPriority int `json:"displayPriority"`
Event *struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
} `json:"event,omitempty"`
HTMLURL *string `json:"htmlUrl,omitempty"`
}
type HomeResult struct {
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
Cards []CardResult `json:"cards"`
}
func NewHomeService(store *postgres.Store) *HomeService {
return &HomeService{store: store}
}
func (s *HomeService) ListCards(ctx context.Context, input ListCardsInput) ([]CardResult, error) {
entry, err := s.resolveEntry(ctx, input)
if err != nil {
return nil, err
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
if err != nil {
return nil, err
}
return mapCards(cards), nil
}
func (s *HomeService) GetHome(ctx context.Context, input ListCardsInput) (*HomeResult, error) {
entry, err := s.resolveEntry(ctx, input)
if err != nil {
return nil, err
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
if err != nil {
return nil, err
}
result := &HomeResult{
Cards: mapCards(cards),
}
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
return result, nil
}
func (s *HomeService) resolveEntry(ctx context.Context, input ListCardsInput) (*postgres.EntryChannel, error) {
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: strings.TrimSpace(input.ChannelCode),
ChannelType: strings.TrimSpace(input.ChannelType),
PlatformAppID: strings.TrimSpace(input.PlatformAppID),
TenantCode: strings.TrimSpace(input.TenantCode),
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
return entry, nil
}
func normalizeSlot(slot string) string {
slot = strings.TrimSpace(slot)
if slot == "" {
return "home_primary"
}
return slot
}
func mapCards(cards []postgres.Card) []CardResult {
results := make([]CardResult, 0, len(cards))
for _, card := range cards {
item := CardResult{
ID: card.PublicID,
Type: card.CardType,
Title: card.Title,
Subtitle: card.Subtitle,
CoverURL: card.CoverURL,
DisplaySlot: card.DisplaySlot,
DisplayPriority: card.DisplayPriority,
HTMLURL: card.HTMLURL,
}
if card.EventPublicID != nil || card.EventDisplayName != nil {
item.Event = &struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
}{
Summary: card.EventSummary,
}
if card.EventPublicID != nil {
item.Event.ID = *card.EventPublicID
}
if card.EventDisplayName != nil {
item.Event.DisplayName = *card.EventDisplayName
}
}
results = append(results, item)
}
return results
}

View File

@@ -0,0 +1,43 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type MeService struct {
store *postgres.Store
}
type MeResult struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
func NewMeService(store *postgres.Store) *MeService {
return &MeService{store: store}
}
func (s *MeService) GetMe(ctx context.Context, userID string) (*MeResult, error) {
user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
return &MeResult{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
}, nil
}

View File

@@ -0,0 +1,119 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type ProfileService struct {
store *postgres.Store
}
type ProfileResult struct {
User struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
} `json:"user"`
Bindings struct {
HasMobile bool `json:"hasMobile"`
HasWechatMini bool `json:"hasWechatMini"`
HasWechatUnion bool `json:"hasWechatUnion"`
Items []ProfileBindingItem `json:"items"`
} `json:"bindings"`
RecentSessions []EntrySessionSummary `json:"recentSessions"`
}
type ProfileBindingItem struct {
IdentityType string `json:"identityType"`
Provider string `json:"provider"`
Status string `json:"status"`
CountryCode *string `json:"countryCode,omitempty"`
Mobile *string `json:"mobile,omitempty"`
MaskedLabel string `json:"maskedLabel"`
}
func NewProfileService(store *postgres.Store) *ProfileService {
return &ProfileService{store: store}
}
func (s *ProfileService) GetProfile(ctx context.Context, userID string) (*ProfileResult, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
identities, err := s.store.ListIdentitiesByUserID(ctx, userID)
if err != nil {
return nil, err
}
sessions, err := s.store.ListSessionsByUserID(ctx, userID, 5)
if err != nil {
return nil, err
}
result := &ProfileResult{}
result.User.ID = user.ID
result.User.PublicID = user.PublicID
result.User.Status = user.Status
result.User.Nickname = user.Nickname
result.User.AvatarURL = user.AvatarURL
for _, identity := range identities {
item := ProfileBindingItem{
IdentityType: identity.IdentityType,
Provider: identity.Provider,
Status: identity.Status,
CountryCode: identity.CountryCode,
Mobile: identity.Mobile,
MaskedLabel: maskIdentity(identity),
}
result.Bindings.Items = append(result.Bindings.Items, item)
switch identity.Provider {
case "mobile":
result.Bindings.HasMobile = true
case "wechat_mini":
result.Bindings.HasWechatMini = true
case "wechat_unionid":
result.Bindings.HasWechatUnion = true
}
}
for i := range sessions {
result.RecentSessions = append(result.RecentSessions, buildEntrySessionSummary(&sessions[i]))
}
return result, nil
}
func maskIdentity(identity postgres.LoginIdentity) string {
if identity.Provider == "mobile" && identity.Mobile != nil {
value := *identity.Mobile
if len(value) >= 7 {
return value[:3] + "****" + value[len(value)-4:]
}
return value
}
if identity.Provider == "wechat_mini" {
return "WeChat Mini bound"
}
if identity.Provider == "wechat_unionid" {
return "WeChat Union bound"
}
return identity.Provider
}

View File

@@ -0,0 +1,56 @@
package service
import "cmr-backend/internal/store/postgres"
const (
LaunchSourceEventCurrentRelease = "event_current_release"
LaunchModeManifestRelease = "manifest_release"
)
type ResolvedReleaseView struct {
LaunchMode string `json:"launchMode"`
Source string `json:"source"`
EventID string `json:"eventId"`
ReleaseID string `json:"releaseId"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}
func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView {
if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil
}
return &ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: source,
EventID: event.PublicID,
ReleaseID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView {
if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil {
return nil
}
view := &ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: source,
ReleaseID: *session.ReleasePublicID,
ConfigLabel: *session.ConfigLabel,
ManifestURL: *session.ManifestURL,
ManifestChecksumSha256: session.ManifestChecksum,
RouteCode: session.RouteCode,
}
if session.EventPublicID != nil {
view.EventID = *session.EventPublicID
}
return view
}

View File

@@ -0,0 +1,94 @@
package service
import (
"context"
"encoding/json"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type ResultService struct {
store *postgres.Store
}
type SessionResultView struct {
Session EntrySessionSummary `json:"session"`
Result ResultSummaryPayload `json:"result"`
}
type ResultSummaryPayload struct {
Status string `json:"status"`
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
FinalScore *int `json:"finalScore,omitempty"`
CompletedControls *int `json:"completedControls,omitempty"`
TotalControls *int `json:"totalControls,omitempty"`
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
Summary map[string]any `json:"summary,omitempty"`
}
func NewResultService(store *postgres.Store) *ResultService {
return &ResultService{store: store}
}
func (s *ResultService) GetSessionResult(ctx context.Context, sessionPublicID, userID string) (*SessionResultView, error) {
record, err := s.store.GetSessionResultByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if userID != "" && record.UserID != userID {
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
}
return buildSessionResultView(record), nil
}
func (s *ResultService) ListMyResults(ctx context.Context, userID string, limit int) ([]SessionResultView, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
records, err := s.store.ListSessionResultsByUserID(ctx, userID, limit)
if err != nil {
return nil, err
}
results := make([]SessionResultView, 0, len(records))
for i := range records {
results = append(results, *buildSessionResultView(&records[i]))
}
return results, nil
}
func buildSessionResultView(record *postgres.SessionResultRecord) *SessionResultView {
view := &SessionResultView{
Session: buildEntrySessionSummary(&record.Session),
Result: ResultSummaryPayload{
Status: record.Status,
},
}
if record.Result != nil {
view.Result.Status = record.Result.ResultStatus
view.Result.FinalDurationSec = record.Result.FinalDurationSec
view.Result.FinalScore = record.Result.FinalScore
view.Result.CompletedControls = record.Result.CompletedControls
view.Result.TotalControls = record.Result.TotalControls
view.Result.DistanceMeters = record.Result.DistanceMeters
view.Result.AverageSpeedKmh = record.Result.AverageSpeedKmh
view.Result.MaxHeartRateBpm = record.Result.MaxHeartRateBpm
if record.Result.SummaryJSON != "" {
summary := map[string]any{}
if err := json.Unmarshal([]byte(record.Result.SummaryJSON), &summary); err == nil && len(summary) > 0 {
view.Result.Summary = summary
}
}
}
return view
}

View File

@@ -0,0 +1,324 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type SessionService struct {
store *postgres.Store
}
type SessionResult struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
RouteCode *string `json:"routeCode,omitempty"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
LaunchedAt string `json:"launchedAt"`
StartedAt *string `json:"startedAt,omitempty"`
EndedAt *string `json:"endedAt,omitempty"`
} `json:"session"`
Event struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
} `json:"event"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
}
type SessionActionInput struct {
SessionPublicID string
SessionToken string `json:"sessionToken"`
}
type FinishSessionInput struct {
SessionPublicID string
SessionToken string `json:"sessionToken"`
Status string `json:"status"`
Summary *SessionSummaryInput `json:"summary,omitempty"`
}
type SessionSummaryInput struct {
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
FinalScore *int `json:"finalScore,omitempty"`
CompletedControls *int `json:"completedControls,omitempty"`
TotalControls *int `json:"totalControls,omitempty"`
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
}
func NewSessionService(store *postgres.Store) *SessionService {
return &SessionService{store: store}
}
func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
if sessionPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required")
}
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if userID != "" && session.UserID != userID {
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
}
return buildSessionResult(session), nil
}
func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit)
if err != nil {
return nil, err
}
results := make([]SessionResult, 0, len(sessions))
for i := range sessions {
results = append(results, *buildSessionResult(&sessions[i]))
}
return results, nil
}
func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
return nil, err
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
input.Status = normalizeFinishStatus(input.Status)
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
return buildSessionResult(session), nil
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(locked), nil
}
if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{
SessionID: locked.ID,
Status: input.Status,
}); err != nil {
return nil, err
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{
SessionID: updated.ID,
ResultStatus: input.Status,
Summary: buildSummaryMap(input.Summary),
FinalDurationSec: resolveDurationSeconds(updated, input.Summary),
FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }),
CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }),
TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }),
DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }),
AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }),
MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }),
}); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
sessionToken = strings.TrimSpace(sessionToken)
if sessionPublicID == "" || sessionToken == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required")
}
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(session, sessionToken); err != nil {
return nil, err
}
return session, nil
}
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
}
if session.SessionTokenHash != security.HashText(sessionToken) {
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
}
return nil
}
func buildSessionResult(session *postgres.Session) *SessionResult {
result := &SessionResult{}
result.Session.ID = session.SessionPublicID
result.Session.Status = session.Status
result.Session.ClientType = session.ClientType
result.Session.DeviceKey = session.DeviceKey
result.Session.RouteCode = session.RouteCode
result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
if session.StartedAt != nil {
value := session.StartedAt.Format(time.RFC3339)
result.Session.StartedAt = &value
}
if session.EndedAt != nil {
value := session.EndedAt.Format(time.RFC3339)
result.Session.EndedAt = &value
}
if session.EventPublicID != nil {
result.Event.ID = *session.EventPublicID
}
if session.EventDisplayName != nil {
result.Event.DisplayName = *session.EventDisplayName
}
result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease)
return result
}
func normalizeFinishStatus(value string) string {
switch strings.TrimSpace(value) {
case "failed":
return "failed"
case "cancelled":
return "cancelled"
default:
return "finished"
}
}
func buildSummaryMap(summary *SessionSummaryInput) map[string]any {
if summary == nil {
return map[string]any{}
}
raw, err := json.Marshal(summary)
if err != nil {
return map[string]any{}
}
result := map[string]any{}
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]any{}
}
return result
}
func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int {
if summary != nil && summary.FinalDurationSec != nil {
return summary.FinalDurationSec
}
if session.StartedAt != nil {
endAt := time.Now().UTC()
if session.EndedAt != nil {
endAt = *session.EndedAt
}
seconds := int(endAt.Sub(*session.StartedAt).Seconds())
if seconds < 0 {
seconds = 0
}
return &seconds
}
return nil
}
func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int {
if summary == nil {
return nil
}
return getter(summary)
}
func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 {
if summary == nil {
return nil
}
return getter(summary)
}

View File

@@ -0,0 +1,9 @@
package service
import "time"
const timeRFC3339 = time.RFC3339
func nowUTC() time.Time {
return time.Now().UTC()
}

View File

@@ -0,0 +1,310 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"cmr-backend/internal/apperr"
"github.com/jackc/pgx/v5"
)
type SMSCodeMeta struct {
ID string
CodeHash string
ExpiresAt time.Time
CooldownUntil time.Time
}
type CreateSMSCodeParams struct {
Scene string
CountryCode string
Mobile string
ClientType string
DeviceKey string
CodeHash string
ProviderName string
ProviderDebug map[string]any
ExpiresAt time.Time
CooldownUntil time.Time
}
type CreateMobileIdentityParams struct {
UserID string
IdentityType string
Provider string
ProviderSubj string
CountryCode string
Mobile string
}
type CreateIdentityParams struct {
UserID string
IdentityType string
Provider string
ProviderSubj string
CountryCode *string
Mobile *string
ProfileJSON string
}
type CreateRefreshTokenParams struct {
UserID string
ClientType string
DeviceKey string
TokenHash string
ExpiresAt time.Time
}
type RefreshTokenRecord struct {
ID string
UserID string
ClientType string
DeviceKey *string
ExpiresAt time.Time
IsRevoked bool
}
func (s *Store) GetLatestSMSCodeMeta(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, code_hash, expires_at, cooldown_until
FROM auth_sms_codes
WHERE country_code = $1 AND mobile = $2 AND client_type = $3 AND scene = $4
ORDER BY created_at DESC
LIMIT 1
`, countryCode, mobile, clientType, scene)
var record SMSCodeMeta
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest sms code meta: %w", err)
}
return &record, nil
}
func (s *Store) CreateSMSCode(ctx context.Context, params CreateSMSCodeParams) error {
payload, err := json.Marshal(map[string]any{
"provider": params.ProviderName,
"debug": params.ProviderDebug,
})
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO auth_sms_codes (
scene, country_code, mobile, client_type, device_key, code_hash,
provider_payload_jsonb, expires_at, cooldown_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
`, params.Scene, params.CountryCode, params.Mobile, params.ClientType, params.DeviceKey, params.CodeHash, string(payload), params.ExpiresAt, params.CooldownUntil)
if err != nil {
return fmt.Errorf("insert sms code: %w", err)
}
return nil
}
func (s *Store) GetLatestValidSMSCode(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, code_hash, expires_at, cooldown_until
FROM auth_sms_codes
WHERE country_code = $1
AND mobile = $2
AND client_type = $3
AND scene = $4
AND consumed_at IS NULL
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`, countryCode, mobile, clientType, scene)
var record SMSCodeMeta
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest valid sms code: %w", err)
}
return &record, nil
}
func (s *Store) ConsumeSMSCode(ctx context.Context, tx Tx, id string) (bool, error) {
commandTag, err := tx.Exec(ctx, `
UPDATE auth_sms_codes
SET consumed_at = NOW()
WHERE id = $1 AND consumed_at IS NULL
`, id)
if err != nil {
return false, fmt.Errorf("consume sms code: %w", err)
}
return commandTag.RowsAffected() == 1, nil
}
func (s *Store) CreateMobileIdentity(ctx context.Context, tx Tx, params CreateMobileIdentityParams) error {
countryCode := params.CountryCode
mobile := params.Mobile
return s.CreateIdentity(ctx, tx, CreateIdentityParams{
UserID: params.UserID,
IdentityType: params.IdentityType,
Provider: params.Provider,
ProviderSubj: params.ProviderSubj,
CountryCode: &countryCode,
Mobile: &mobile,
ProfileJSON: "{}",
})
}
func (s *Store) CreateIdentity(ctx context.Context, tx Tx, params CreateIdentityParams) error {
_, err := tx.Exec(ctx, `
INSERT INTO login_identities (
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7::jsonb)
ON CONFLICT (provider, provider_subject) DO NOTHING
`, params.UserID, params.IdentityType, params.Provider, params.ProviderSubj, params.CountryCode, params.Mobile, zeroJSON(params.ProfileJSON))
if err != nil {
return fmt.Errorf("create identity: %w", err)
}
return nil
}
func (s *Store) FindUserByProviderSubject(ctx context.Context, tx Tx, provider, providerSubject string) (*User, error) {
row := tx.QueryRow(ctx, `
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
FROM users u
JOIN login_identities li ON li.user_id = u.id
WHERE li.provider = $1
AND li.provider_subject = $2
AND li.status = 'active'
LIMIT 1
`, provider, providerSubject)
return scanUser(row)
}
func (s *Store) CreateRefreshToken(ctx context.Context, tx Tx, params CreateRefreshTokenParams) (string, error) {
row := tx.QueryRow(ctx, `
INSERT INTO auth_refresh_tokens (user_id, client_type, device_key, token_hash, expires_at)
VALUES ($1, $2, NULLIF($3, ''), $4, $5)
RETURNING id
`, params.UserID, params.ClientType, params.DeviceKey, params.TokenHash, params.ExpiresAt)
var id string
if err := row.Scan(&id); err != nil {
return "", fmt.Errorf("create refresh token: %w", err)
}
return id, nil
}
func (s *Store) GetRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*RefreshTokenRecord, error) {
row := tx.QueryRow(ctx, `
SELECT id, user_id, client_type, device_key, expires_at, revoked_at IS NOT NULL
FROM auth_refresh_tokens
WHERE token_hash = $1
FOR UPDATE
`, tokenHash)
var record RefreshTokenRecord
err := row.Scan(&record.ID, &record.UserID, &record.ClientType, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query refresh token for update: %w", err)
}
return &record, nil
}
func (s *Store) RotateRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
_, err := tx.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = NOW(), replaced_by_token_id = $2
WHERE id = $1
`, oldTokenID, newTokenID)
if err != nil {
return fmt.Errorf("rotate refresh token: %w", err)
}
return nil
}
func (s *Store) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
commandTag, err := s.pool.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE token_hash = $1
`, tokenHash)
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
if commandTag.RowsAffected() == 0 {
return apperr.New(http.StatusNotFound, "refresh_token_not_found", "refresh token not found")
}
return nil
}
func (s *Store) RevokeRefreshTokensByUserID(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE user_id = $1
`, userID)
if err != nil {
return fmt.Errorf("revoke refresh tokens by user id: %w", err)
}
return nil
}
func (s *Store) TransferNonMobileIdentities(ctx context.Context, tx Tx, sourceUserID, targetUserID string) error {
if sourceUserID == targetUserID {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO login_identities (
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb, created_at, updated_at
)
SELECT
$2,
li.identity_type,
li.provider,
li.provider_subject,
li.country_code,
li.mobile,
li.status,
li.profile_jsonb,
li.created_at,
li.updated_at
FROM login_identities li
WHERE li.user_id = $1
AND li.provider <> 'mobile'
ON CONFLICT (provider, provider_subject) DO NOTHING
`, sourceUserID, targetUserID)
if err != nil {
return fmt.Errorf("copy non-mobile identities: %w", err)
}
_, err = tx.Exec(ctx, `
DELETE FROM login_identities
WHERE user_id = $1
AND provider <> 'mobile'
`, sourceUserID)
if err != nil {
return fmt.Errorf("delete source non-mobile identities: %w", err)
}
return nil
}
func zeroJSON(value string) string {
if value == "" {
return "{}"
}
return value
}

View File

@@ -0,0 +1,93 @@
package postgres
import (
"context"
"fmt"
"time"
)
type Card struct {
ID string
PublicID string
CardType string
Title string
Subtitle *string
CoverURL *string
DisplaySlot string
DisplayPriority int
EntryChannelID *string
EventPublicID *string
EventDisplayName *string
EventSummary *string
HTMLURL *string
}
func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryChannelID *string, slot string, now time.Time, limit int) ([]Card, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if slot == "" {
slot = "home_primary"
}
rows, err := s.pool.Query(ctx, `
SELECT
c.id,
c.card_public_id,
c.card_type,
c.title,
c.subtitle,
c.cover_url,
c.display_slot,
c.display_priority,
c.entry_channel_id,
e.event_public_id,
e.display_name,
e.summary,
c.html_url
FROM cards c
LEFT JOIN events e ON e.id = c.event_id
WHERE c.tenant_id = $1
AND ($2::uuid IS NULL OR c.entry_channel_id = $2 OR c.entry_channel_id IS NULL)
AND c.display_slot = $3
AND c.status = 'active'
AND (c.starts_at IS NULL OR c.starts_at <= $4)
AND (c.ends_at IS NULL OR c.ends_at >= $4)
ORDER BY
CASE WHEN $2::uuid IS NOT NULL AND c.entry_channel_id = $2 THEN 0 ELSE 1 END,
c.display_priority DESC,
c.created_at ASC
LIMIT $5
`, tenantID, entryChannelID, slot, now, limit)
if err != nil {
return nil, fmt.Errorf("list cards for entry: %w", err)
}
defer rows.Close()
var cards []Card
for rows.Next() {
var card Card
if err := rows.Scan(
&card.ID,
&card.PublicID,
&card.CardType,
&card.Title,
&card.Subtitle,
&card.CoverURL,
&card.DisplaySlot,
&card.DisplayPriority,
&card.EntryChannelID,
&card.EventPublicID,
&card.EventDisplayName,
&card.EventSummary,
&card.HTMLURL,
); err != nil {
return nil, fmt.Errorf("scan card: %w", err)
}
cards = append(cards, card)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate cards: %w", err)
}
return cards, nil
}

View File

@@ -0,0 +1,323 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EventConfigSource struct {
ID string
EventID string
SourceVersionNo int
SourceKind string
SchemaID string
SchemaVersion string
Status string
SourceJSON string
Notes *string
}
type EventConfigBuild struct {
ID string
EventID string
SourceID string
BuildNo int
BuildStatus string
BuildLog *string
ManifestJSON string
AssetIndexJSON string
}
type EventReleaseAsset struct {
ID string
EventReleaseID string
AssetType string
AssetKey string
AssetPath *string
AssetURL string
Checksum *string
SizeBytes *int64
MetaJSON string
}
type UpsertEventConfigSourceParams struct {
EventID string
SourceVersionNo int
SourceKind string
SchemaID string
SchemaVersion string
Status string
Source map[string]any
Notes *string
}
type UpsertEventConfigBuildParams struct {
EventID string
SourceID string
BuildNo int
BuildStatus string
BuildLog *string
Manifest map[string]any
AssetIndex []map[string]any
}
type UpsertEventReleaseAssetParams struct {
EventReleaseID string
AssetType string
AssetKey string
AssetPath *string
AssetURL string
Checksum *string
SizeBytes *int64
Meta map[string]any
}
func (s *Store) UpsertEventConfigSource(ctx context.Context, tx Tx, params UpsertEventConfigSourceParams) (*EventConfigSource, error) {
sourceJSON, err := json.Marshal(params.Source)
if err != nil {
return nil, fmt.Errorf("marshal event config source: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO event_config_sources (
event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
ON CONFLICT (event_id, source_version_no) DO UPDATE SET
source_kind = EXCLUDED.source_kind,
schema_id = EXCLUDED.schema_id,
schema_version = EXCLUDED.schema_version,
status = EXCLUDED.status,
source_jsonb = EXCLUDED.source_jsonb,
notes = EXCLUDED.notes
RETURNING id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
`, params.EventID, params.SourceVersionNo, params.SourceKind, params.SchemaID, params.SchemaVersion, params.Status, string(sourceJSON), params.Notes)
var item EventConfigSource
if err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
); err != nil {
return nil, fmt.Errorf("upsert event config source: %w", err)
}
return &item, nil
}
func (s *Store) UpsertEventConfigBuild(ctx context.Context, tx Tx, params UpsertEventConfigBuildParams) (*EventConfigBuild, error) {
manifestJSON, err := json.Marshal(params.Manifest)
if err != nil {
return nil, fmt.Errorf("marshal event config manifest: %w", err)
}
assetIndexJSON, err := json.Marshal(params.AssetIndex)
if err != nil {
return nil, fmt.Errorf("marshal event config asset index: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO event_config_builds (
event_id, source_id, build_no, build_status, build_log, manifest_jsonb, asset_index_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
ON CONFLICT (event_id, build_no) DO UPDATE SET
source_id = EXCLUDED.source_id,
build_status = EXCLUDED.build_status,
build_log = EXCLUDED.build_log,
manifest_jsonb = EXCLUDED.manifest_jsonb,
asset_index_jsonb = EXCLUDED.asset_index_jsonb
RETURNING id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
`, params.EventID, params.SourceID, params.BuildNo, params.BuildStatus, params.BuildLog, string(manifestJSON), string(assetIndexJSON))
var item EventConfigBuild
if err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
); err != nil {
return nil, fmt.Errorf("upsert event config build: %w", err)
}
return &item, nil
}
func (s *Store) AttachBuildToRelease(ctx context.Context, tx Tx, releaseID, buildID string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET build_id = $2
WHERE id = $1
`, releaseID, buildID); err != nil {
return fmt.Errorf("attach build to release: %w", err)
}
return nil
}
func (s *Store) ReplaceEventReleaseAssets(ctx context.Context, tx Tx, eventReleaseID string, assets []UpsertEventReleaseAssetParams) error {
if _, err := tx.Exec(ctx, `DELETE FROM event_release_assets WHERE event_release_id = $1`, eventReleaseID); err != nil {
return fmt.Errorf("clear event release assets: %w", err)
}
for _, asset := range assets {
metaJSON, err := json.Marshal(asset.Meta)
if err != nil {
return fmt.Errorf("marshal event release asset meta: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO event_release_assets (
event_release_id, asset_type, asset_key, asset_path, asset_url, checksum, size_bytes, meta_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`, eventReleaseID, asset.AssetType, asset.AssetKey, asset.AssetPath, asset.AssetURL, asset.Checksum, asset.SizeBytes, string(metaJSON)); err != nil {
return fmt.Errorf("insert event release asset: %w", err)
}
}
return nil
}
func (s *Store) NextEventConfigSourceVersion(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(source_version_no), 0) + 1
FROM event_config_sources
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event config source version: %w", err)
}
return next, nil
}
func (s *Store) NextEventConfigBuildNo(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(build_no), 0) + 1
FROM event_config_builds
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event config build no: %w", err)
}
return next, nil
}
func (s *Store) ListEventConfigSourcesByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigSource, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
FROM event_config_sources
WHERE event_id = $1
ORDER BY source_version_no DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list event config sources: %w", err)
}
defer rows.Close()
var items []EventConfigSource
for rows.Next() {
item, err := scanEventConfigSourceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event config sources: %w", err)
}
return items, nil
}
func (s *Store) GetEventConfigSourceByID(ctx context.Context, sourceID string) (*EventConfigSource, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
FROM event_config_sources
WHERE id = $1
LIMIT 1
`, sourceID)
return scanEventConfigSource(row)
}
func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*EventConfigBuild, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
FROM event_config_builds
WHERE id = $1
LIMIT 1
`, buildID)
return scanEventConfigBuild(row)
}
func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) {
var item EventConfigSource
err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event config source: %w", err)
}
return &item, nil
}
func scanEventConfigSourceFromRows(rows pgx.Rows) (*EventConfigSource, error) {
var item EventConfigSource
if err := rows.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
); err != nil {
return nil, fmt.Errorf("scan event config source row: %w", err)
}
return &item, nil
}
func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) {
var item EventConfigBuild
err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event config build: %w", err)
}
return &item, nil
}

View File

@@ -0,0 +1,46 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
type Tx = pgx.Tx
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pool, nil
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Pool() *pgxpool.Pool {
return s.pool
}
func (s *Store) Close() {
if s.pool != nil {
s.pool.Close()
}
}
func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) {
return s.pool.Begin(ctx)
}

View File

@@ -0,0 +1,324 @@
package postgres
import (
"context"
"fmt"
)
type DemoBootstrapSummary struct {
TenantCode string `json:"tenantCode"`
ChannelCode string `json:"channelCode"`
EventID string `json:"eventId"`
ReleaseID string `json:"releaseId"`
SourceID string `json:"sourceId"`
BuildID string `json:"buildId"`
CardID string `json:"cardId"`
}
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
tx, err := s.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var tenantID string
if err := tx.QueryRow(ctx, `
INSERT INTO tenants (tenant_code, name, status)
VALUES ('tenant_demo', 'Demo Tenant', 'active')
ON CONFLICT (tenant_code) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status
RETURNING id
`).Scan(&tenantID); err != nil {
return nil, fmt.Errorf("ensure demo tenant: %w", err)
}
var channelID string
if err := tx.QueryRow(ctx, `
INSERT INTO entry_channels (
tenant_id, channel_code, channel_type, platform_app_id, display_name, status, is_default
)
VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', 'Demo Mini Channel', 'active', true)
ON CONFLICT (tenant_id, channel_code) DO UPDATE SET
channel_type = EXCLUDED.channel_type,
platform_app_id = EXCLUDED.platform_app_id,
display_name = EXCLUDED.display_name,
status = EXCLUDED.status,
is_default = EXCLUDED.is_default
RETURNING id
`, tenantID).Scan(&channelID); err != nil {
return nil, fmt.Errorf("ensure demo entry channel: %w", err)
}
var eventID string
if err := tx.QueryRow(ctx, `
INSERT INTO events (
tenant_id, event_public_id, slug, display_name, summary, status
)
VALUES ($1, 'evt_demo_001', 'demo-city-run', 'Demo City Run', 'Launch flow demo event', 'active')
ON CONFLICT (event_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
slug = EXCLUDED.slug,
display_name = EXCLUDED.display_name,
summary = EXCLUDED.summary,
status = EXCLUDED.status
RETURNING id
`, tenantID).Scan(&eventID); err != nil {
return nil, fmt.Errorf("ensure demo event: %w", err)
}
var releaseRow struct {
ID string
PublicID string
}
if err := tx.QueryRow(ctx, `
INSERT INTO event_releases (
release_public_id,
event_id,
release_no,
config_label,
manifest_url,
manifest_checksum_sha256,
route_code,
status
)
VALUES (
'rel_demo_001',
$1,
1,
'Demo Config v1',
'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json',
'demo-checksum-001',
'route-demo-001',
'published'
)
ON CONFLICT (release_public_id) DO UPDATE SET
event_id = EXCLUDED.event_id,
config_label = EXCLUDED.config_label,
manifest_url = EXCLUDED.manifest_url,
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
route_code = EXCLUDED.route_code,
status = EXCLUDED.status
RETURNING id, release_public_id
`, eventID).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil {
return nil, fmt.Errorf("ensure demo release: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE events
SET current_release_id = $2
WHERE id = $1
`, eventID, releaseRow.ID); err != nil {
return nil, fmt.Errorf("attach demo release: %w", err)
}
sourceNotes := "demo source config imported from local event sample"
source, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
EventID: eventID,
SourceVersionNo: 1,
SourceKind: "event_bundle",
SchemaID: "event-source",
SchemaVersion: "1",
Status: "active",
Notes: &sourceNotes,
Source: map[string]any{
"app": map[string]any{
"id": "sample-classic-001",
"title": "顺序赛示例",
},
"branding": map[string]any{
"tenantCode": "tenant_demo",
"entryChannel": "mini-demo",
},
"map": map[string]any{
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
},
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
"url": "../kml/lxcb-001/10/c01.kml",
},
},
"game": map[string]any{
"mode": "classic-sequential",
},
"content": map[string]any{
"h5Template": "content-h5-test-template.html",
},
},
})
if err != nil {
return nil, fmt.Errorf("ensure demo event config source: %w", err)
}
buildLog := "demo build generated from sample classic-sequential.json"
build, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
EventID: eventID,
SourceID: source.ID,
BuildNo: 1,
BuildStatus: "success",
BuildLog: &buildLog,
Manifest: map[string]any{
"schemaVersion": "1",
"releaseId": "rel_demo_001",
"version": "2026.04.01",
"app": map[string]any{
"id": "sample-classic-001",
"title": "顺序赛示例",
},
"map": map[string]any{
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
},
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
"url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
},
},
"game": map[string]any{
"mode": "classic-sequential",
},
"assets": map[string]any{
"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
AssetIndex: []map[string]any{
{
"assetType": "manifest",
"assetKey": "manifest",
},
{
"assetType": "mapmeta",
"assetKey": "mapmeta",
},
{
"assetType": "playfield",
"assetKey": "playfield-kml",
},
{
"assetType": "content_html",
"assetKey": "content-html",
},
},
})
if err != nil {
return nil, fmt.Errorf("ensure demo event config build: %w", err)
}
if err := s.AttachBuildToRelease(ctx, tx, releaseRow.ID, build.ID); err != nil {
return nil, fmt.Errorf("attach demo build to release: %w", err)
}
tilesPath := "map/lxcb-001/tiles/"
mapmetaPath := "map/lxcb-001/tiles/meta.json"
playfieldPath := "kml/lxcb-001/10/c01.kml"
contentPath := "event/content-h5-test-template.html"
manifestChecksum := "demo-checksum-001"
if err := s.ReplaceEventReleaseAssets(ctx, tx, releaseRow.ID, []UpsertEventReleaseAssetParams{
{
EventReleaseID: releaseRow.ID,
AssetType: "manifest",
AssetKey: "manifest",
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json",
Checksum: &manifestChecksum,
Meta: map[string]any{"source": "release-manifest"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "tiles",
AssetKey: "tiles-root",
AssetPath: &tilesPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
Meta: map[string]any{"kind": "directory"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "mapmeta",
AssetKey: "mapmeta",
AssetPath: &mapmetaPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
Meta: map[string]any{"format": "json"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "playfield",
AssetKey: "course-kml",
AssetPath: &playfieldPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
Meta: map[string]any{"format": "kml"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "content_html",
AssetKey: "content-html",
AssetPath: &contentPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
Meta: map[string]any{"kind": "content-page"},
},
}); err != nil {
return nil, fmt.Errorf("ensure demo event release assets: %w", err)
}
var cardPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO cards (
card_public_id,
tenant_id,
entry_channel_id,
card_type,
title,
subtitle,
cover_url,
event_id,
display_slot,
display_priority,
status
)
VALUES (
'card_demo_001',
$1,
$2,
'event',
'Demo City Run',
'今日推荐路线',
'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
$3,
'home_primary',
100,
'active'
)
ON CONFLICT (card_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
entry_channel_id = EXCLUDED.entry_channel_id,
card_type = EXCLUDED.card_type,
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
cover_url = EXCLUDED.cover_url,
event_id = EXCLUDED.event_id,
display_slot = EXCLUDED.display_slot,
display_priority = EXCLUDED.display_priority,
status = EXCLUDED.status
RETURNING card_public_id
`, tenantID, channelID, eventID).Scan(&cardPublicID); err != nil {
return nil, fmt.Errorf("ensure demo card: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &DemoBootstrapSummary{
TenantCode: "tenant_demo",
ChannelCode: "mini-demo",
EventID: "evt_demo_001",
ReleaseID: releaseRow.PublicID,
SourceID: source.ID,
BuildID: build.ID,
CardID: cardPublicID,
}, nil
}

View File

@@ -0,0 +1,74 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EntryChannel struct {
ID string
ChannelCode string
ChannelType string
PlatformAppID *string
DisplayName string
Status string
IsDefault bool
TenantID string
TenantCode string
TenantName string
}
type FindEntryChannelParams struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
func (s *Store) FindEntryChannel(ctx context.Context, params FindEntryChannelParams) (*EntryChannel, error) {
row := s.pool.QueryRow(ctx, `
SELECT
ec.id,
ec.channel_code,
ec.channel_type,
ec.platform_app_id,
ec.display_name,
ec.status,
ec.is_default,
t.id,
t.tenant_code,
t.name
FROM entry_channels ec
JOIN tenants t ON t.id = ec.tenant_id
WHERE ($1 = '' OR ec.channel_code = $1)
AND ($2 = '' OR ec.channel_type = $2)
AND ($3 = '' OR COALESCE(ec.platform_app_id, '') = $3)
AND ($4 = '' OR t.tenant_code = $4)
ORDER BY ec.is_default DESC, ec.created_at ASC
LIMIT 1
`, params.ChannelCode, params.ChannelType, params.PlatformAppID, params.TenantCode)
var entry EntryChannel
err := row.Scan(
&entry.ID,
&entry.ChannelCode,
&entry.ChannelType,
&entry.PlatformAppID,
&entry.DisplayName,
&entry.Status,
&entry.IsDefault,
&entry.TenantID,
&entry.TenantCode,
&entry.TenantName,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find entry channel: %w", err)
}
return &entry, nil
}

View File

@@ -0,0 +1,263 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Event struct {
ID string
PublicID string
Slug string
DisplayName string
Summary *string
Status string
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
RouteCode *string
}
type EventRelease struct {
ID string
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PublishedAt time.Time
}
type CreateGameSessionParams struct {
SessionPublicID string
UserID string
EventID string
EventReleaseID string
DeviceKey string
ClientType string
RouteCode *string
SessionTokenHash string
SessionTokenExpiresAt time.Time
}
type GameSession struct {
ID string
SessionPublicID string
UserID string
EventID string
EventReleaseID string
DeviceKey string
ClientType string
RouteCode *string
Status string
SessionTokenExpiresAt time.Time
}
func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*Event, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.slug,
e.display_name,
e.summary,
e.status,
e.current_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
WHERE e.event_public_id = $1
LIMIT 1
`, eventPublicID)
var event Event
err := row.Scan(
&event.ID,
&event.PublicID,
&event.Slug,
&event.DisplayName,
&event.Summary,
&event.Status,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
&event.ManifestURL,
&event.ManifestChecksum,
&event.RouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event by public id: %w", err)
}
return &event, nil
}
func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.slug,
e.display_name,
e.summary,
e.status,
e.current_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
WHERE e.id = $1
LIMIT 1
`, eventID)
var event Event
err := row.Scan(
&event.ID,
&event.PublicID,
&event.Slug,
&event.DisplayName,
&event.Summary,
&event.Status,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
&event.ManifestURL,
&event.ManifestChecksum,
&event.RouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event by id: %w", err)
}
return &event, nil
}
func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(release_no), 0) + 1
FROM event_releases
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event release no: %w", err)
}
return next, nil
}
type CreateEventReleaseParams struct {
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PayloadJSON string
}
func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEventReleaseParams) (*EventRelease, error) {
row := tx.QueryRow(ctx, `
INSERT INTO event_releases (
release_public_id,
event_id,
release_no,
config_label,
manifest_url,
manifest_checksum_sha256,
route_code,
build_id,
status,
payload_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON)
var item EventRelease
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.ReleaseNo,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.BuildID,
&item.Status,
&item.PublishedAt,
); err != nil {
return nil, fmt.Errorf("create event release: %w", err)
}
return &item, nil
}
func (s *Store) SetCurrentEventRelease(ctx context.Context, tx Tx, eventID, releaseID string) error {
if _, err := tx.Exec(ctx, `
UPDATE events
SET current_release_id = $2
WHERE id = $1
`, eventID, releaseID); err != nil {
return fmt.Errorf("set current event release: %w", err)
}
return nil
}
func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameSessionParams) (*GameSession, error) {
row := tx.QueryRow(ctx, `
INSERT INTO game_sessions (
session_public_id,
user_id,
event_id,
event_release_id,
device_key,
client_type,
route_code,
session_token_hash,
session_token_expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
var session GameSession
err := row.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.DeviceKey,
&session.ClientType,
&session.RouteCode,
&session.Status,
&session.SessionTokenExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("create game session: %w", err)
}
return &session, nil
}

View File

@@ -0,0 +1,50 @@
package postgres
import (
"context"
"fmt"
)
type LoginIdentity struct {
ID string
IdentityType string
Provider string
ProviderSubject string
CountryCode *string
Mobile *string
Status string
}
func (s *Store) ListIdentitiesByUserID(ctx context.Context, userID string) ([]LoginIdentity, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, identity_type, provider, provider_subject, country_code, mobile, status
FROM login_identities
WHERE user_id = $1
ORDER BY created_at ASC
`, userID)
if err != nil {
return nil, fmt.Errorf("list identities by user id: %w", err)
}
defer rows.Close()
var identities []LoginIdentity
for rows.Next() {
var identity LoginIdentity
if err := rows.Scan(
&identity.ID,
&identity.IdentityType,
&identity.Provider,
&identity.ProviderSubject,
&identity.CountryCode,
&identity.Mobile,
&identity.Status,
); err != nil {
return nil, fmt.Errorf("scan identity: %w", err)
}
identities = append(identities, identity)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate identities: %w", err)
}
return identities, nil
}

View File

@@ -0,0 +1,367 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5"
)
type SessionResult struct {
ID string
SessionID string
ResultStatus string
SummaryJSON string
FinalDurationSec *int
FinalScore *int
CompletedControls *int
TotalControls *int
DistanceMeters *float64
AverageSpeedKmh *float64
MaxHeartRateBpm *int
}
type UpsertSessionResultParams struct {
SessionID string
ResultStatus string
Summary map[string]any
FinalDurationSec *int
FinalScore *int
CompletedControls *int
TotalControls *int
DistanceMeters *float64
AverageSpeedKmh *float64
MaxHeartRateBpm *int
}
type SessionResultRecord struct {
Session
Result *SessionResult
}
func (s *Store) UpsertSessionResult(ctx context.Context, tx Tx, params UpsertSessionResultParams) (*SessionResult, error) {
summaryJSON, err := json.Marshal(params.Summary)
if err != nil {
return nil, fmt.Errorf("marshal session summary: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO session_results (
session_id,
result_status,
summary_jsonb,
final_duration_sec,
final_score,
completed_controls,
total_controls,
distance_meters,
average_speed_kmh,
max_heart_rate_bpm
)
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (session_id) DO UPDATE SET
result_status = EXCLUDED.result_status,
summary_jsonb = EXCLUDED.summary_jsonb,
final_duration_sec = EXCLUDED.final_duration_sec,
final_score = EXCLUDED.final_score,
completed_controls = EXCLUDED.completed_controls,
total_controls = EXCLUDED.total_controls,
distance_meters = EXCLUDED.distance_meters,
average_speed_kmh = EXCLUDED.average_speed_kmh,
max_heart_rate_bpm = EXCLUDED.max_heart_rate_bpm
RETURNING
id,
session_id,
result_status,
summary_jsonb::text,
final_duration_sec,
final_score,
completed_controls,
total_controls,
distance_meters::float8,
average_speed_kmh::float8,
max_heart_rate_bpm
`, params.SessionID, params.ResultStatus, string(summaryJSON), params.FinalDurationSec, params.FinalScore, params.CompletedControls, params.TotalControls, params.DistanceMeters, params.AverageSpeedKmh, params.MaxHeartRateBpm)
return scanSessionResult(row)
}
func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID string) (*SessionResultRecord, error) {
row := s.pool.QueryRow(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name,
sr.id,
sr.session_id,
sr.result_status,
sr.summary_jsonb::text,
sr.final_duration_sec,
sr.final_score,
sr.completed_controls,
sr.total_controls,
sr.distance_meters::float8,
sr.average_speed_kmh::float8,
sr.max_heart_rate_bpm
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
LEFT JOIN session_results sr ON sr.session_id = gs.id
WHERE gs.session_public_id = $1
LIMIT 1
`, sessionPublicID)
return scanSessionResultRecord(row)
}
func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, limit int) ([]SessionResultRecord, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name,
sr.id,
sr.session_id,
sr.result_status,
sr.summary_jsonb::text,
sr.final_duration_sec,
sr.final_score,
sr.completed_controls,
sr.total_controls,
sr.distance_meters::float8,
sr.average_speed_kmh::float8,
sr.max_heart_rate_bpm
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
LEFT JOIN session_results sr ON sr.session_id = gs.id
WHERE gs.user_id = $1
AND gs.status IN ('finished', 'failed', 'cancelled')
ORDER BY COALESCE(gs.ended_at, gs.updated_at, gs.created_at) DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list session results by user id: %w", err)
}
defer rows.Close()
var items []SessionResultRecord
for rows.Next() {
item, err := scanSessionResultRecordFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate session results by user id: %w", err)
}
return items, nil
}
func scanSessionResult(row pgx.Row) (*SessionResult, error) {
var result SessionResult
err := row.Scan(
&result.ID,
&result.SessionID,
&result.ResultStatus,
&result.SummaryJSON,
&result.FinalDurationSec,
&result.FinalScore,
&result.CompletedControls,
&result.TotalControls,
&result.DistanceMeters,
&result.AverageSpeedKmh,
&result.MaxHeartRateBpm,
)
if err != nil {
return nil, fmt.Errorf("scan session result: %w", err)
}
return &result, nil
}
func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) {
var record SessionResultRecord
var resultID *string
var resultSessionID *string
var resultStatus *string
var resultSummaryJSON *string
var finalDurationSec *int
var finalScore *int
var completedControls *int
var totalControls *int
var distanceMeters *float64
var averageSpeedKmh *float64
var maxHeartRateBpm *int
err := row.Scan(
&record.ID,
&record.SessionPublicID,
&record.UserID,
&record.EventID,
&record.EventReleaseID,
&record.ReleasePublicID,
&record.ConfigLabel,
&record.ManifestURL,
&record.ManifestChecksum,
&record.DeviceKey,
&record.ClientType,
&record.RouteCode,
&record.Status,
&record.SessionTokenHash,
&record.SessionTokenExpiresAt,
&record.LaunchedAt,
&record.StartedAt,
&record.EndedAt,
&record.EventPublicID,
&record.EventDisplayName,
&resultID,
&resultSessionID,
&resultStatus,
&resultSummaryJSON,
&finalDurationSec,
&finalScore,
&completedControls,
&totalControls,
&distanceMeters,
&averageSpeedKmh,
&maxHeartRateBpm,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("scan session result record: %w", err)
}
if resultID != nil {
record.Result = &SessionResult{
ID: *resultID,
SessionID: derefString(resultSessionID),
ResultStatus: derefString(resultStatus),
SummaryJSON: derefString(resultSummaryJSON),
FinalDurationSec: finalDurationSec,
FinalScore: finalScore,
CompletedControls: completedControls,
TotalControls: totalControls,
DistanceMeters: distanceMeters,
AverageSpeedKmh: averageSpeedKmh,
MaxHeartRateBpm: maxHeartRateBpm,
}
}
return &record, nil
}
func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error) {
var record SessionResultRecord
var resultID *string
var resultSessionID *string
var resultStatus *string
var resultSummaryJSON *string
var finalDurationSec *int
var finalScore *int
var completedControls *int
var totalControls *int
var distanceMeters *float64
var averageSpeedKmh *float64
var maxHeartRateBpm *int
err := rows.Scan(
&record.ID,
&record.SessionPublicID,
&record.UserID,
&record.EventID,
&record.EventReleaseID,
&record.ReleasePublicID,
&record.ConfigLabel,
&record.ManifestURL,
&record.ManifestChecksum,
&record.DeviceKey,
&record.ClientType,
&record.RouteCode,
&record.Status,
&record.SessionTokenHash,
&record.SessionTokenExpiresAt,
&record.LaunchedAt,
&record.StartedAt,
&record.EndedAt,
&record.EventPublicID,
&record.EventDisplayName,
&resultID,
&resultSessionID,
&resultStatus,
&resultSummaryJSON,
&finalDurationSec,
&finalScore,
&completedControls,
&totalControls,
&distanceMeters,
&averageSpeedKmh,
&maxHeartRateBpm,
)
if err != nil {
return nil, fmt.Errorf("scan session result row: %w", err)
}
if resultID != nil {
record.Result = &SessionResult{
ID: *resultID,
SessionID: derefString(resultSessionID),
ResultStatus: derefString(resultStatus),
SummaryJSON: derefString(resultSummaryJSON),
FinalDurationSec: finalDurationSec,
FinalScore: finalScore,
CompletedControls: completedControls,
TotalControls: totalControls,
DistanceMeters: distanceMeters,
AverageSpeedKmh: averageSpeedKmh,
MaxHeartRateBpm: maxHeartRateBpm,
}
}
return &record, nil
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -0,0 +1,299 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Session struct {
ID string
SessionPublicID string
UserID string
EventID string
EventReleaseID string
ReleasePublicID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
DeviceKey string
ClientType string
RouteCode *string
Status string
SessionTokenHash string
SessionTokenExpiresAt time.Time
LaunchedAt time.Time
StartedAt *time.Time
EndedAt *time.Time
EventPublicID *string
EventDisplayName *string
}
type FinishSessionParams struct {
SessionID string
Status string
}
func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string) (*Session, error) {
row := s.pool.QueryRow(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.session_public_id = $1
LIMIT 1
`, sessionPublicID)
return scanSession(row)
}
func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessionPublicID string) (*Session, error) {
row := tx.QueryRow(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.session_public_id = $1
FOR UPDATE
`, sessionPublicID)
return scanSession(row)
}
func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit int) ([]Session, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.user_id = $1
ORDER BY gs.created_at DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list sessions by user id: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
session, err := scanSessionFromRows(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate sessions by user id: %w", err)
}
return sessions, nil
}
func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID string, limit int) ([]Session, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.user_id = $1
AND gs.event_id = $2
ORDER BY gs.created_at DESC
LIMIT $3
`, userID, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list sessions by user and event: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
session, err := scanSessionFromRows(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate sessions by user and event: %w", err)
}
return sessions, nil
}
func (s *Store) StartSession(ctx context.Context, tx Tx, sessionID string) error {
_, err := tx.Exec(ctx, `
UPDATE game_sessions
SET status = CASE WHEN status = 'launched' THEN 'running' ELSE status END,
started_at = COALESCE(started_at, NOW())
WHERE id = $1
`, sessionID)
if err != nil {
return fmt.Errorf("start session: %w", err)
}
return nil
}
func (s *Store) FinishSession(ctx context.Context, tx Tx, params FinishSessionParams) error {
_, err := tx.Exec(ctx, `
UPDATE game_sessions
SET status = $2,
started_at = COALESCE(started_at, NOW()),
ended_at = COALESCE(ended_at, NOW())
WHERE id = $1
`, params.SessionID, params.Status)
if err != nil {
return fmt.Errorf("finish session: %w", err)
}
return nil
}
func scanSession(row pgx.Row) (*Session, error) {
var session Session
err := row.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.ReleasePublicID,
&session.ConfigLabel,
&session.ManifestURL,
&session.ManifestChecksum,
&session.DeviceKey,
&session.ClientType,
&session.RouteCode,
&session.Status,
&session.SessionTokenHash,
&session.SessionTokenExpiresAt,
&session.LaunchedAt,
&session.StartedAt,
&session.EndedAt,
&session.EventPublicID,
&session.EventDisplayName,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
return &session, nil
}
func scanSessionFromRows(rows pgx.Rows) (*Session, error) {
var session Session
err := rows.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.ReleasePublicID,
&session.ConfigLabel,
&session.ManifestURL,
&session.ManifestChecksum,
&session.DeviceKey,
&session.ClientType,
&session.RouteCode,
&session.Status,
&session.SessionTokenHash,
&session.SessionTokenExpiresAt,
&session.LaunchedAt,
&session.StartedAt,
&session.EndedAt,
&session.EventPublicID,
&session.EventDisplayName,
)
if err != nil {
return nil, fmt.Errorf("scan session row: %w", err)
}
return &session, nil
}

View File

@@ -0,0 +1,94 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type User struct {
ID string
PublicID string
Status string
Nickname *string
AvatarURL *string
}
type CreateUserParams struct {
PublicID string
Status string
}
type queryRower interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func (s *Store) FindUserByMobile(ctx context.Context, tx Tx, countryCode, mobile string) (*User, error) {
row := tx.QueryRow(ctx, `
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
FROM users u
JOIN login_identities li ON li.user_id = u.id
WHERE li.provider = 'mobile'
AND li.country_code = $1
AND li.mobile = $2
AND li.status = 'active'
LIMIT 1
`, countryCode, mobile)
return scanUser(row)
}
func (s *Store) CreateUser(ctx context.Context, tx Tx, params CreateUserParams) (*User, error) {
row := tx.QueryRow(ctx, `
INSERT INTO users (user_public_id, status)
VALUES ($1, $2)
RETURNING id, user_public_id, status, nickname, avatar_url
`, params.PublicID, params.Status)
return scanUser(row)
}
func (s *Store) TouchUserLogin(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE users
SET last_login_at = NOW()
WHERE id = $1
`, userID)
if err != nil {
return fmt.Errorf("touch user last login: %w", err)
}
return nil
}
func (s *Store) DeactivateUser(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE users
SET status = 'deleted', updated_at = NOW()
WHERE id = $1
`, userID)
if err != nil {
return fmt.Errorf("deactivate user: %w", err)
}
return nil
}
func (s *Store) GetUserByID(ctx context.Context, db queryRower, userID string) (*User, error) {
row := db.QueryRow(ctx, `
SELECT id, user_public_id, status, nickname, avatar_url
FROM users
WHERE id = $1
`, userID)
return scanUser(row)
}
func scanUser(row pgx.Row) (*User, error) {
var user User
err := row.Scan(&user.ID, &user.PublicID, &user.Status, &user.Nickname, &user.AvatarURL)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
return &user, nil
}