generated from VLADIMIR/template
fix
This commit is contained in:
@@ -81,7 +81,6 @@ const grpcMsgLimit = maxFileSize + (1 << 20)
|
|||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
// PostgreSQL
|
|
||||||
dbpool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
dbpool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to create connection pool: %v\n", err)
|
log.Fatalf("Unable to create connection pool: %v\n", err)
|
||||||
@@ -164,13 +163,11 @@ func main() {
|
|||||||
go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE"))
|
go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE"))
|
||||||
go reviewService.RunReviewReminderEmails(context.Background(), emailSender)
|
go reviewService.RunReviewReminderEmails(context.Background(), emailSender)
|
||||||
|
|
||||||
// Create a listener on TCP port
|
|
||||||
lis, err := net.Listen("tcp", ":8080")
|
lis, err := net.Listen("tcp", ":8080")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln("Failed to listen:", err)
|
log.Fatalln("Failed to listen:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a gRPC server object
|
|
||||||
s := grpc.NewServer(
|
s := grpc.NewServer(
|
||||||
grpc.MaxRecvMsgSize(grpcMsgLimit),
|
grpc.MaxRecvMsgSize(grpcMsgLimit),
|
||||||
grpc.UnaryInterceptor(
|
grpc.UnaryInterceptor(
|
||||||
@@ -205,7 +202,6 @@ func main() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Attach the Greeter service to the server
|
|
||||||
proto.RegisterEveningDetectiveServerServer(
|
proto.RegisterEveningDetectiveServerServer(
|
||||||
s,
|
s,
|
||||||
app.NewServer(
|
app.NewServer(
|
||||||
@@ -225,14 +221,11 @@ func main() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Serve gRPC server
|
|
||||||
log.Println("Serving gRPC on 0.0.0.0:8080")
|
log.Println("Serving gRPC on 0.0.0.0:8080")
|
||||||
go func() {
|
go func() {
|
||||||
log.Fatalln(s.Serve(lis))
|
log.Fatalln(s.Serve(lis))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Create a client connection to the gRPC server we just started
|
|
||||||
// This is where the gRPC-Gateway proxies the requests
|
|
||||||
conn, err := grpc.NewClient(
|
conn, err := grpc.NewClient(
|
||||||
"0.0.0.0:8080",
|
"0.0.0.0:8080",
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
@@ -288,7 +281,6 @@ func main() {
|
|||||||
runtime.WithMarshalerOption("application/octet-stream", rawBody),
|
runtime.WithMarshalerOption("application/octet-stream", rawBody),
|
||||||
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
|
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
|
||||||
)
|
)
|
||||||
// Register Greeter
|
|
||||||
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
|
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln("Failed to register gateway:", err)
|
log.Fatalln("Failed to register gateway:", err)
|
||||||
|
|||||||
@@ -266,6 +266,8 @@ go test ./...
|
|||||||
|
|
||||||
## 11. Именование и стиль
|
## 11. Именование и стиль
|
||||||
|
|
||||||
|
- Все комментарии в исходном Go-коде пиши на русском языке. Сгенерированные
|
||||||
|
файлы и комментарии внешних библиотек не редактируй.
|
||||||
- Имена короткие и предметные: `gameID`, `teamID`, `ctx`, `err` допустимы по
|
- Имена короткие и предметные: `gameID`, `teamID`, `ctx`, `err` допустимы по
|
||||||
общему Go-стилю.
|
общему Go-стилю.
|
||||||
- Аббревиатуры пишутся единообразно: `ID`, `URL`, `HTTP`, `JWT`, `SQL`.
|
- Аббревиатуры пишутся единообразно: `ID`, `URL`, `HTTP`, `JWT`, `SQL`.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// Package docs встраивает юридические документы в бинарник и раздаёт их
|
// Пакет docs встраивает юридические документы в бинарник и раздаёт их
|
||||||
// по постоянным URL (/terms, /privacy, /consent), на которые сервис
|
// по постоянным URL (/terms, /privacy, /consent), на которые сервис
|
||||||
// ссылается при фиксации акцептов соглашений (user_agreements).
|
// ссылается при фиксации акцептов соглашений (user_agreements).
|
||||||
package docs
|
package docs
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ func TestChangeUserPasswordAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed the real repository so unexpected calls fail rather than silently pass.
|
// Встраиваем настоящий репозиторий, чтобы неожиданные вызовы завершались
|
||||||
|
// ошибкой, а не проходили незаметно.
|
||||||
type passwordRepo struct {
|
type passwordRepo struct {
|
||||||
*users_repo.UsersRepo
|
*users_repo.UsersRepo
|
||||||
role string
|
role string
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"evening_detective_server/internal/repos/teams_repo"
|
"evening_detective_server/internal/repos/teams_repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeamRegistrationHandler принимает webhook Яндекс Форм отдельно от пользовательского JWT.
|
// TeamRegistrationHandler принимает вебхук Яндекс Форм отдельно от пользовательского JWT.
|
||||||
func TeamRegistrationHandler(token string, register func(context.Context, int, string, team_service.Registration) (int, error)) http.Handler {
|
func TeamRegistrationHandler(token string, register func(context.Context, int, string, team_service.Registration) (int, error)) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import "context"
|
|||||||
type Message struct {
|
type Message struct {
|
||||||
To string
|
To string
|
||||||
Subject string
|
Subject string
|
||||||
// Body — текстовая версия письма (text/plain).
|
|
||||||
Body string
|
Body string
|
||||||
// HTML — версия письма для почтовых клиентов (text/html).
|
|
||||||
// Если пусто, письмо уходит только в text/plain.
|
|
||||||
HTML string
|
HTML string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func ButtonHTML(link, label string) string {
|
|||||||
</td></tr></table>`
|
</td></tr></table>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapHTML is the shared light/dark email layout used by all service emails.
|
// WrapHTML возвращает общую светлую/тёмную HTML-обёртку для писем сервиса.
|
||||||
func WrapHTML(content, baseURL string) string {
|
func WrapHTML(content, baseURL string) string {
|
||||||
fontURL := ""
|
fontURL := ""
|
||||||
if baseURL != "" {
|
if baseURL != "" {
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ func mimeFor(f *File) string {
|
|||||||
return mimeFromData(f.Data, f.Name)
|
return mimeFromData(f.Data, f.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeFromData — Content-Type по содержимому (первые 512 байт) с fallback
|
// mimeFromData — Content-Type по содержимому (первые 512 байт) с запасным вариантом
|
||||||
// на расширение. text/* (например, HTML) трактуется как octet-stream и
|
// на расширение. text/* (например, HTML) трактуется как octet-stream и
|
||||||
// уходит в fallback: при публичном скачивании не должно отдаваться как
|
// уходит в запасной вариант: при публичном скачивании не должно отдаваться как
|
||||||
// html с доверенного домена.
|
// html с доверенного домена.
|
||||||
func mimeFromData(data []byte, filename string) string {
|
func mimeFromData(data []byte, filename string) string {
|
||||||
detected := http.DetectContentType(data)
|
detected := http.DetectContentType(data)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package network_utils
|
|||||||
|
|
||||||
import "net"
|
import "net"
|
||||||
|
|
||||||
// GetLocalIPv4Addresses returns non-loopback IPv4 addresses of the host.
|
// GetLocalIPv4Addresses возвращает IPv4-адреса хоста, кроме loopback-адресов.
|
||||||
func GetLocalIPv4Addresses() ([]string, error) {
|
func GetLocalIPv4Addresses() ([]string, error) {
|
||||||
addrs, err := net.InterfaceAddrs()
|
addrs, err := net.InterfaceAddrs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func ContextWithClaims(ctx context.Context, claims *JWTClaims) context.Context {
|
|||||||
return context.WithValue(ctx, claimsContextKey{}, claims)
|
return context.WithValue(ctx, claimsContextKey{}, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimsFromContext returns authenticated claims stored in ctx.
|
// ClaimsFromContext возвращает данные аутентификации, сохранённые в ctx.
|
||||||
func ClaimsFromContext(ctx context.Context) (*JWTClaims, bool) {
|
func ClaimsFromContext(ctx context.Context) (*JWTClaims, bool) {
|
||||||
claims, ok := ctx.Value(claimsContextKey{}).(*JWTClaims)
|
claims, ok := ctx.Value(claimsContextKey{}).(*JWTClaims)
|
||||||
return claims, ok && claims != nil
|
return claims, ok && claims != nil
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ type subject struct {
|
|||||||
roles []string
|
roles []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSubject создаёт субъект с заданными ролями (например, для тестов
|
|
||||||
// авторизации или in-memory проверок прав).
|
|
||||||
func NewSubject(roles ...string) IHasRole {
|
func NewSubject(roles ...string) IHasRole {
|
||||||
return &subject{roles: roles}
|
return &subject{roles: roles}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import (
|
|||||||
|
|
||||||
const testDomain = "http://storage.test/api/files/"
|
const testDomain = "http://storage.test/api/files/"
|
||||||
|
|
||||||
// testArchive — экземпляр модуля для тестов.
|
|
||||||
var testArchive = NewScenarioArchive()
|
var testArchive = NewScenarioArchive()
|
||||||
|
|
||||||
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
|
||||||
func buildScenario() *repos.Scenario {
|
func buildScenario() *repos.Scenario {
|
||||||
description := "Детективная история"
|
description := "Детективная история"
|
||||||
image := "cover.png"
|
image := "cover.png"
|
||||||
@@ -35,7 +33,6 @@ func buildScenario() *repos.Scenario {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// memoryFiles — замена хранилища для Pack: имя → содержимое.
|
|
||||||
type memoryFiles map[string][]byte
|
type memoryFiles map[string][]byte
|
||||||
|
|
||||||
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
|
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
|
||||||
@@ -183,7 +180,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPackLegacyFullURLImage(t *testing.T) {
|
func TestPackLegacyFullURLImage(t *testing.T) {
|
||||||
// Legacy-данные: полный URL хранилища срезается до базового имени файла.
|
// Устаревшие данные: полный URL хранилища срезается до базового имени файла.
|
||||||
description := "desc"
|
description := "desc"
|
||||||
scenario := &repos.Scenario{
|
scenario := &repos.Scenario{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
@@ -258,7 +255,6 @@ func TestPackImageNameCollision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
|
|
||||||
func writeZip(t *testing.T, entries map[string][]byte) []byte {
|
func writeZip(t *testing.T, entries map[string][]byte) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -278,7 +274,6 @@ func writeZip(t *testing.T, entries map[string][]byte) []byte {
|
|||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// validScenarioJSON — минимальный корректный scenario.json.
|
|
||||||
func validScenarioJSON() []byte {
|
func validScenarioJSON() []byte {
|
||||||
b, _ := json.Marshal(ScenarioJSON{
|
b, _ := json.Marshal(ScenarioJSON{
|
||||||
Version: Version,
|
Version: Version,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ type ScenarioJSON struct {
|
|||||||
Story *storytelling.Story `json:"story"`
|
Story *storytelling.Story `json:"story"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bundle — результат разбора входящего архива.
|
|
||||||
type Bundle struct {
|
type Bundle struct {
|
||||||
Scenario *ScenarioJSON
|
Scenario *ScenarioJSON
|
||||||
Files map[string][]byte
|
Files map[string][]byte
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ type IStory interface {
|
|||||||
type Story struct {
|
type Story struct {
|
||||||
Background string `json:"background,omitempty"`
|
Background string `json:"background,omitempty"`
|
||||||
|
|
||||||
// Введение - то, что игрок получает в начале игры
|
|
||||||
Introduction *Introduction `json:"introduction,omitempty"`
|
Introduction *Introduction `json:"introduction,omitempty"`
|
||||||
|
|
||||||
// Закрывашка доступна в редакторе сценария, игрокам не передаётся.
|
// Закрывашка доступна в редакторе сценария, игрокам не передаётся.
|
||||||
Conclusion *Conclusion `json:"conclusion,omitempty"`
|
Conclusion *Conclusion `json:"conclusion,omitempty"`
|
||||||
|
|
||||||
// Список точек
|
|
||||||
Places []*Place `json:"places"`
|
Places []*Place `json:"places"`
|
||||||
|
|
||||||
// Вопросы дела - список вопросов, на которые команда должна ответить
|
// Вопросы дела - список вопросов, на которые команда должна ответить
|
||||||
@@ -24,11 +22,8 @@ type Story struct {
|
|||||||
// Вопрос дела - вопрос, который автор сценария добавляет вместе с
|
// Вопрос дела - вопрос, который автор сценария добавляет вместе с
|
||||||
// правильным ответом; команда отвечает на вопросы в конце игры
|
// правильным ответом; команда отвечает на вопросы в конце игры
|
||||||
type Question struct {
|
type Question struct {
|
||||||
|
|
||||||
// Код - идентификатор вопроса, используется при отправке ответов
|
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
|
||||||
// Текст вопроса
|
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|
||||||
// Правильный ответ - виден только автору и организаторам,
|
// Правильный ответ - виден только автору и организаторам,
|
||||||
@@ -44,11 +39,8 @@ type Conclusion struct {
|
|||||||
|
|
||||||
// Введение - текст и аудио, которые игрок получает в начале игры.
|
// Введение - текст и аудио, которые игрок получает в начале игры.
|
||||||
type Introduction struct {
|
type Introduction struct {
|
||||||
|
|
||||||
// Текст введения
|
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|
||||||
// Аудио введения
|
|
||||||
Audio string `json:"audio,omitempty"`
|
Audio string `json:"audio,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,23 +49,17 @@ type Introduction struct {
|
|||||||
// Это может быть персонаж - пострадавший, продавец, полицейский...
|
// Это может быть персонаж - пострадавший, продавец, полицейский...
|
||||||
// Это может быть описание действия игрока - ответ на вопрос, реакция на выбитую дверь...
|
// Это может быть описание действия игрока - ответ на вопрос, реакция на выбитую дверь...
|
||||||
type Place struct {
|
type Place struct {
|
||||||
|
|
||||||
// Код - то что вбивает игрок
|
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
|
||||||
// Название места, имя персонажа, заданный игроком вопрос...
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
// Текст - получаемая текстовая информация
|
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|
||||||
// Картинка - получаемая визуальная информация
|
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
|
|
||||||
// Признак скрытой карточки - некоторые места изначально не доступны
|
// Признак скрытой карточки - некоторые места изначально не доступны
|
||||||
Hidden bool `json:"hidden"`
|
Hidden bool `json:"hidden"`
|
||||||
|
|
||||||
// Приложения - получаемые улики
|
|
||||||
Applications []*Application `json:"applications,omitempty"`
|
Applications []*Application `json:"applications,omitempty"`
|
||||||
|
|
||||||
// Двери к другим точкам - действия или диалоги, открывающие другие точки
|
// Двери к другим точкам - действия или диалоги, открывающие другие точки
|
||||||
@@ -86,11 +72,8 @@ type Place struct {
|
|||||||
// Приложение - улика
|
// Приложение - улика
|
||||||
// Это может быть оружие, отпечатки, фотография...
|
// Это может быть оружие, отпечатки, фотография...
|
||||||
type Application struct {
|
type Application struct {
|
||||||
|
|
||||||
// Название улики
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
// Картинка
|
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
|
|
||||||
// Тип файла улики: image | pdf | audio.
|
// Тип файла улики: image | pdf | audio.
|
||||||
@@ -99,11 +82,8 @@ type Application struct {
|
|||||||
|
|
||||||
// Дверь - действие или диалог
|
// Дверь - действие или диалог
|
||||||
type Door struct {
|
type Door struct {
|
||||||
|
|
||||||
// Код
|
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
|
||||||
// Название - заданный вопрос, проделанное действие...
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
// Некоторые двери изначально не доступны
|
// Некоторые двери изначально не доступны
|
||||||
@@ -114,7 +94,5 @@ type Door struct {
|
|||||||
|
|
||||||
// Аналог карт допуска в "Тайны Аркхема"
|
// Аналог карт допуска в "Тайны Аркхема"
|
||||||
type Key struct {
|
type Key struct {
|
||||||
|
|
||||||
// Название
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ func (s *story) GetStory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Собираем ключи
|
|
||||||
if currentPlace != nil {
|
if currentPlace != nil {
|
||||||
for _, key := range currentPlace.Keys {
|
for _, key := range currentPlace.Keys {
|
||||||
keys[key.Name] = struct{}{}
|
keys[key.Name] = struct{}{}
|
||||||
|
|||||||
@@ -48,18 +48,14 @@ var (
|
|||||||
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
||||||
func Transliterate(text string) string {
|
func Transliterate(text string) string {
|
||||||
|
|
||||||
// Приводим к нижнему регистру и транслитерируем
|
|
||||||
result := replacer.Replace(strings.ToLower(text))
|
result := replacer.Replace(strings.ToLower(text))
|
||||||
|
|
||||||
// Удаляем все символы, кроме букв латиницы, подчеркивания и цифр
|
|
||||||
reg := regexp.MustCompile(`[^a-z0-9_]`)
|
reg := regexp.MustCompile(`[^a-z0-9_]`)
|
||||||
result = reg.ReplaceAllString(result, "")
|
result = reg.ReplaceAllString(result, "")
|
||||||
|
|
||||||
// Удаляем множественные подчеркивания (опционально)
|
|
||||||
regMulti := regexp.MustCompile(`_+`)
|
regMulti := regexp.MustCompile(`_+`)
|
||||||
result = regMulti.ReplaceAllString(result, "_")
|
result = regMulti.ReplaceAllString(result, "_")
|
||||||
|
|
||||||
// Удаляем подчеркивания в начале и конце строки (опционально)
|
|
||||||
result = strings.Trim(result, "_")
|
result = strings.Trim(result, "_")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func Generate(cards []Card) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
x := margin + float64(i%3)*width
|
x := margin + float64(i%3)*width
|
||||||
y := margin + float64(i%12/3)*height
|
y := margin + float64(i%12/3)*height
|
||||||
// Bitmap включает свободное поле QR; векторные квадраты сохраняют
|
// Растровый режим включает свободное поле QR; векторные квадраты сохраняют
|
||||||
// чёткость при печати без растрового масштабирования.
|
// чёткость при печати без растрового масштабирования.
|
||||||
bitmap := qr.Bitmap()
|
bitmap := qr.Bitmap()
|
||||||
pixel := qrSize / float64(len(bitmap))
|
pixel := qrSize / float64(len(bitmap))
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ type Repo struct {
|
|||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGameMapRepo создаёт repository объектов карты.
|
|
||||||
func NewGameMapRepo(pool *pgxpool.Pool) *Repo {
|
func NewGameMapRepo(pool *pgxpool.Pool) *Repo {
|
||||||
return &Repo{pool: pool}
|
return &Repo{pool: pool}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// The condition is rechecked after a concurrent answer releases its row lock.
|
// Условие проверяется повторно после снятия блокировки строки конкурентным ответом.
|
||||||
_, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id
|
_, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id
|
||||||
WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g)
|
WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g)
|
||||||
UPDATE teams t SET participation_status=$2,
|
UPDATE teams t SET participation_status=$2,
|
||||||
@@ -99,7 +99,8 @@ func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer strin
|
|||||||
return p, ErrParticipationClosed
|
return p, ErrParticipationClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
// A five-minute lease survives restarts and keeps concurrent workers from sending the same job.
|
// Пятиминутная аренда переживает перезапуски и не позволяет рабочим процессам
|
||||||
|
// отправить одну задачу повторно.
|
||||||
func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) {
|
func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) {
|
||||||
p := &Participation{}
|
p := &Participation{}
|
||||||
err := r.pool.QueryRow(ctx, `WITH candidate AS (
|
err := r.pool.QueryRow(ctx, `WITH candidate AS (
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID in
|
|||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveTeamsByGameID excludes cancelled registrations from gameplay exports and results.
|
// GetActiveTeamsByGameID исключает отменённые регистрации из игровых выгрузок и результатов.
|
||||||
func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) {
|
func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) {
|
||||||
teams, err := r.GetTeamsByGameID(ctx, gameID)
|
teams, err := r.GetTeamsByGameID(ctx, gameID)
|
||||||
active := teams[:0]
|
active := teams[:0]
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (s *UsersRepo) AddUserWithAgreements(
|
|||||||
// создать пользователя с тем же username/email — нарушение
|
// создать пользователя с тем же username/email — нарушение
|
||||||
// уникальности. Возвращаем тот же понятный клиенту ответ.
|
// уникальности. Возвращаем тот же понятный клиенту ответ.
|
||||||
var pgErr *pgconn.PgError
|
var pgErr *pgconn.PgError
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // нарушение уникальности
|
||||||
return 0, errors.New("Username или email уже используется")
|
return 0, errors.New("Username или email уже используется")
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestAnswerAccessPostgres(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Rejected requests must not reach scenario or answer storage.
|
// Отклонённые запросы не должны доходить до хранилищ сценариев и ответов.
|
||||||
service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil)
|
service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil)
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
status string
|
status string
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"evening_detective_server/internal/repos/game_reviews_repo"
|
"evening_detective_server/internal/repos/game_reviews_repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Review is the application model returned by game review use cases.
|
|
||||||
type Review struct {
|
type Review struct {
|
||||||
ID int
|
ID int
|
||||||
GameID int
|
GameID int
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ var (
|
|||||||
ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен")
|
ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен")
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameReviewService handles game review use cases and reminder emails.
|
|
||||||
type GameReviewService struct {
|
type GameReviewService struct {
|
||||||
reviews *game_reviews_repo.Repo
|
reviews *game_reviews_repo.Repo
|
||||||
games *games_repo.GamesRepo
|
games *games_repo.GamesRepo
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
|
|||||||
}
|
}
|
||||||
config, err := pgxpool.ParseConfig(dsn)
|
config, err := pgxpool.ParseConfig(dsn)
|
||||||
check(err)
|
check(err)
|
||||||
config.MaxConns = 1 // Temporary tables stay on the same connection.
|
config.MaxConns = 1 // Временные таблицы должны оставаться в одном соединении.
|
||||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||||
check(err)
|
check(err)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package mcp_service собирает MCP-сервер (Model Context Protocol) для
|
// Пакет mcp_service собирает MCP-сервер (Model Context Protocol) для
|
||||||
// игры «Вечерний детектив»: инструменты MCP вызывают игровые сервисы
|
// игры «Вечерний детектив»: инструменты MCP вызывают игровые сервисы
|
||||||
// напрямую (через интерфейс GamePlayer), без HTTP-прослойки. MCP-сервер
|
// напрямую (через интерфейс GamePlayer), без HTTP-прослойки. MCP-сервер
|
||||||
// монтируется в основной процесс как HTTP-эндпоинт /mcp (streamable HTTP)
|
// монтируется в основной процесс как HTTP-эндпоинт /mcp (streamable HTTP)
|
||||||
@@ -49,13 +49,10 @@ type MCPService struct {
|
|||||||
answers AnswerSubmitter
|
answers AnswerSubmitter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMCPService создаёт сервис поверх игровых сервисов.
|
|
||||||
func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService {
|
func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService {
|
||||||
return &MCPService{game: game, answers: answers}
|
return &MCPService{game: game, answers: answers}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server собирает и возвращает MCP-сервер с зарегистрированными
|
|
||||||
// инструментами.
|
|
||||||
func (s *MCPService) Server() *server.MCPServer {
|
func (s *MCPService) Server() *server.MCPServer {
|
||||||
srv := server.NewMCPServer(
|
srv := server.NewMCPServer(
|
||||||
"evening-detective-mcp",
|
"evening-detective-mcp",
|
||||||
@@ -124,7 +121,6 @@ func (s *MCPService) Server() *server.MCPServer {
|
|||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectArgs — результат разбора ссылки на игру.
|
|
||||||
type connectArgs struct {
|
type connectArgs struct {
|
||||||
teamID int64
|
teamID int64
|
||||||
password string
|
password string
|
||||||
@@ -168,21 +164,17 @@ func parseConnectURL(rawURL string) (connectArgs, error) {
|
|||||||
return connectArgs{teamID: teamID, password: password}, nil
|
return connectArgs{teamID: teamID, password: password}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// gameResponse — компактное описание игры для ответа инструментов
|
|
||||||
// (game_model.Game JSON-тегов не имеет).
|
|
||||||
type gameResponse struct {
|
type gameResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// storyResponse — JSON-ответ инструментов: история команды + инфо об игре.
|
|
||||||
type storyResponse struct {
|
type storyResponse struct {
|
||||||
Story *storytelling.Story `json:"story"`
|
Story *storytelling.Story `json:"story"`
|
||||||
Game *gameResponse `json:"game"`
|
Game *gameResponse `json:"game"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// storyJSON сериализует историю команды и игру в JSON.
|
|
||||||
func storyJSON(story *storytelling.Story, game *game_model.Game) (string, error) {
|
func storyJSON(story *storytelling.Story, game *game_model.Game) (string, error) {
|
||||||
resp := storyResponse{
|
resp := storyResponse{
|
||||||
Story: story,
|
Story: story,
|
||||||
@@ -284,7 +276,6 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
|
|||||||
return mcp.NewToolResultText(jsonBody), nil
|
return mcp.NewToolResultText(jsonBody), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitAnswersArgs — разобранные аргументы инструмента submit_answers.
|
|
||||||
type submitAnswersArgs struct {
|
type submitAnswersArgs struct {
|
||||||
teamID int64
|
teamID int64
|
||||||
password string
|
password string
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func defaultGame() *game_model.Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mcpFixture поднимает in-process MCP-клиент над сервисом с моком
|
// mcpFixture поднимает встроенный MCP-клиент над сервисом с моком
|
||||||
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
||||||
type mcpFixture struct {
|
type mcpFixture struct {
|
||||||
client *client.Client
|
client *client.Client
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestParticipationPostgres(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state(1, "pending")
|
state(1, "pending")
|
||||||
state(2, "pending") // Existing teams are not changed by the migration.
|
state(2, "pending") // Существующие команды миграция не изменяет.
|
||||||
// Model registration output explicitly: direct SQL inserts do not auto-confirm teams.
|
// Model registration output explicitly: direct SQL inserts do not auto-confirm teams.
|
||||||
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1;
|
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1;
|
||||||
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
|
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
|
||||||
@@ -113,7 +113,7 @@ func TestParticipationPostgres(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state(5, "confirmed")
|
state(5, "confirmed")
|
||||||
// Independent workers must claim each due team just once.
|
// Независимые рабочие процессы должны захватывать каждую подходящую команду только один раз.
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
ids := make(chan int, 8)
|
ids := make(chan int, 8)
|
||||||
errs := make(chan error, 8)
|
errs := make(chan error, 8)
|
||||||
@@ -158,7 +158,7 @@ func TestParticipationPostgres(t *testing.T) {
|
|||||||
t.Fatalf("failed send not recorded: %+v %v", team, err)
|
t.Fatalf("failed send not recorded: %+v %v", team, err)
|
||||||
}
|
}
|
||||||
sender.err = nil
|
sender.err = nil
|
||||||
// Expired lease is recovered after a worker restart.
|
// Истёкшая аренда восстанавливается после перезапуска рабочего процесса.
|
||||||
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`)
|
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`)
|
||||||
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
|
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -209,7 +209,7 @@ func TestParticipationPostgres(t *testing.T) {
|
|||||||
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
|
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
|
||||||
t.Fatalf("same answer not idempotent: %v", err)
|
t.Fatalf("same answer not idempotent: %v", err)
|
||||||
}
|
}
|
||||||
// Cancellation excludes teams from shared active listings and password-based gameplay.
|
// Отмена исключает команды из общих активных списков и игры по паролю.
|
||||||
token3 := participationToken("secret", 3)
|
token3 := participationToken("secret", 3)
|
||||||
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
|
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, a
|
|||||||
return s.teamsRepo.AnswerParticipation(ctx, token, answer)
|
return s.teamsRepo.AnswerParticipation(ctx, token, answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable, purpose-separated tokens keep links valid when an ambiguous SMTP failure is retried.
|
// Стабильные токены для разных целей сохраняют работоспособность ссылок при
|
||||||
// Only the hash is stored in the database; the signing secret must stay stable across workers/restarts.
|
// повторе после неоднозначной ошибки SMTP.
|
||||||
|
// В базе хранится только хеш; секрет подписи должен быть постоянным между
|
||||||
|
// рабочими процессами и перезапусками.
|
||||||
func participationToken(secret string, id int) string {
|
func participationToken(secret string, id int) string {
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
fmt.Fprintf(mac, "team-participation:v1:%d", id)
|
fmt.Fprintf(mac, "team-participation:v1:%d", id)
|
||||||
@@ -102,7 +104,7 @@ func (s *ParticipationService) sendParticipationEmails(ctx context.Context, send
|
|||||||
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
|
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
// Persist even when sending consumed the job's deadline.
|
// Сохраняем результат даже если отправка исчерпала время задачи.
|
||||||
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
|
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
|
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
|
||||||
saveCancel()
|
saveCancel()
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ func TestChangeUserPassword(t *testing.T) {
|
|||||||
r.byID[u.ID] = u
|
r.byID[u.ID] = u
|
||||||
r.users[u.Email] = u
|
r.users[u.Email] = u
|
||||||
}
|
}
|
||||||
// Nil JWT, token repository and mail sender ensure this operation does not use them.
|
// Пустые JWT, репозиторий токенов и отправитель писем подтверждают,
|
||||||
|
// что операция их не использует.
|
||||||
s := &UsersService{usersRepo: r}
|
s := &UsersService{usersRepo: r}
|
||||||
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
|
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
|
||||||
if !errors.Is(err, tc.want) {
|
if !errors.Is(err, tc.want) {
|
||||||
@@ -89,7 +90,7 @@ func TestLoginAfterPasswordChangePreservesEmailCase(t *testing.T) {
|
|||||||
if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil {
|
if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// This error can only occur after email lookup and bcrypt verification succeed.
|
// Эта ошибка возможна только после успешного поиска email и проверки bcrypt.
|
||||||
_, _, err := svc.Login(ctx, " User@Example.COM ", "new password 123")
|
_, _, err := svc.Login(ctx, " User@Example.COM ", "new password 123")
|
||||||
if err == nil || err.Error() != "Не получилось получить доступ" {
|
if err == nil || err.Error() != "Не получилось получить доступ" {
|
||||||
t.Fatalf("lookup/password verification failed: %v", err)
|
t.Fatalf("lookup/password verification failed: %v", err)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestEmailThemeAndContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n.Type == html.ElementNode && n.Data == "a" {
|
if n.Type == html.ElementNode && n.Data == "a" {
|
||||||
protected = true // The button has its own paired background and text colors.
|
protected = true // У кнопки собственные согласованные цвета фона и текста.
|
||||||
}
|
}
|
||||||
if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" {
|
if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" {
|
||||||
if !protected {
|
if !protected {
|
||||||
|
|||||||
Reference in New Issue
Block a user