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,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()
}