This commit is contained in:
2026-10-13 13:50:13 +07:00
parent e77cc34b4d
commit 57fee0cc06
26 changed files with 2202 additions and 181 deletions
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"context"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/proto"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func receiptClaims(ctx context.Context) error {
claims, err := claimsFromContext(ctx)
if err != nil {
return err
}
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
return status.Error(codes.PermissionDenied, "нет доступа к печати чеков команды")
}
return nil
}
func (s *server) PreviewTeamReceipt(ctx context.Context, req *proto.TeamReceiptReq) (*httpbody.HttpBody, error) {
if err := receiptClaims(ctx); err != nil {
return nil, err
}
data, err := s.teamService.PreviewTeamReceipt(ctx, int(req.GetGameID()), req.GetTeamID())
if err != nil {
return nil, teamReceiptError(err)
}
if err := grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store", "X-Content-Type-Options", "nosniff")); err != nil {
return nil, status.Error(codes.Internal, "не удалось установить заголовки чека")
}
return &httpbody.HttpBody{
ContentType: "image/png",
Data: data,
}, nil
}
func (s *server) PrintTeamReceipt(ctx context.Context, req *proto.TeamReceiptReq) (*proto.PrintTeamReceiptRsp, error) {
if err := receiptClaims(ctx); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, team_service.ReceiptTimeout)
defer cancel()
if err := s.teamService.PrintTeamReceipt(ctx, int(req.GetGameID()), req.GetTeamID()); err != nil {
return nil, teamReceiptError(err)
}
return &proto.PrintTeamReceiptRsp{}, nil
}
+45
View File
@@ -0,0 +1,45 @@
package app
import (
"context"
"errors"
"log"
"evening_detective_server/internal/services/team_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func teamReceiptError(err error) error {
switch {
case errors.Is(err, team_service.ErrInvalidReceiptRequest):
return status.Error(codes.InvalidArgument, team_service.ErrInvalidReceiptRequest.Error())
case errors.Is(err, team_service.ErrGameNotFound), errors.Is(err, team_service.ErrTeamNotFound):
return status.Error(codes.NotFound, "игра или команда не найдены")
case errors.Is(err, team_service.ErrReceiptVenueOnly):
return status.Error(codes.FailedPrecondition, team_service.ErrReceiptVenueOnly.Error())
case errors.Is(err, team_service.ErrReceiptTeamCancelled):
return status.Error(codes.FailedPrecondition, team_service.ErrReceiptTeamCancelled.Error())
case errors.Is(err, team_service.ErrInvalidReceipt):
return status.Error(codes.FailedPrecondition, team_service.ErrInvalidReceipt.Error())
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "печать прервана; проверьте, вышел ли чек, перед повторной печатью")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "принтер не ответил вовремя; проверьте, вышел ли чек, перед повторной печатью")
}
code := team_service.ReceiptPrinterErrorCode(err)
known := team_service.ReceiptPrinterError(code)
if errors.Is(err, known) {
grpcCode := codes.FailedPrecondition
if code == "busy" {
grpcCode = codes.ResourceExhausted
}
if code == "unavailable" || code == "protocol" || code == "incomplete" {
grpcCode = codes.Unavailable
}
return status.Error(grpcCode, known.Error())
}
log.Printf("team receipt: %v", err)
return status.Error(codes.Internal, "не удалось напечатать чек; проверьте, вышел ли он, перед повторной печатью")
}
+143
View File
@@ -0,0 +1,143 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type receiptAppRepos struct{ team_service.ITeamsRepository }
func (receiptAppRepos) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1}, nil
}
func (receiptAppRepos) GetTeamByID(context.Context, string) (*repos.Team, error) {
return &repos.Team{ID: "019a1234-5678-7abc-8def-000000000007", GameID: 1, Name: "Сыщики", Password: "test-qr"}, nil
}
type receiptAppPrinter struct{ calls int }
func (p *receiptAppPrinter) Print(context.Context, []byte) error { p.calls++; return nil }
func TestTeamReceiptAccess(t *testing.T) {
for _, tc := range []struct {
name string
ctx context.Context
venue bool
req *proto.TeamReceiptReq
want codes.Code
}{
{"anonymous", context.Background(), true, nil, codes.Unauthenticated},
{"user", ctxWithClaims("user"), true, nil, codes.PermissionDenied},
{"prod", ctxWithClaims("admin"), false, &proto.TeamReceiptReq{GameID: 1}, codes.FailedPrecondition},
{"empty", ctxWithClaims("organizer"), true, nil, codes.InvalidArgument},
{"bad team", ctxWithClaims("author"), true, &proto.TeamReceiptReq{GameID: 1, TeamID: "bad"}, codes.InvalidArgument},
} {
t.Run(tc.name, func(t *testing.T) {
s := &server{teamService: team_service.NewTeamService(nil, nil, nil, "", nil, tc.venue, nil)}
_, err := s.PreviewTeamReceipt(tc.ctx, tc.req)
if status.Code(err) != tc.want {
t.Fatalf("preview code=%v", status.Code(err))
}
_, err = s.PrintTeamReceipt(tc.ctx, tc.req)
if status.Code(err) != tc.want {
t.Fatalf("print code=%v", status.Code(err))
}
})
}
}
func TestTeamReceiptResponse(t *testing.T) {
for _, role := range []string{"admin", "organizer", "author"} {
t.Run(role, func(t *testing.T) {
repos := receiptAppRepos{}
printer := &receiptAppPrinter{}
s := &server{teamService: team_service.NewTeamService(repos, repos, nil, "https://game.test", nil, true, printer)}
stream := &mockServerTransportStream{}
ctx := grpc.NewContextWithServerTransportStream(ctxWithClaims(role), stream)
req := &proto.TeamReceiptReq{GameID: 1, TeamID: "019a1234-5678-7abc-8def-000000000007"}
body, err := s.PreviewTeamReceipt(ctx, req)
if err != nil {
t.Fatal(err)
}
if body.ContentType != "image/png" || !bytes.HasPrefix(body.Data, []byte("\x89PNG")) {
t.Fatal("not a PNG")
}
if values := stream.header.Get("cache-control"); len(values) != 1 || values[0] != "no-store" {
t.Fatal("preview may be cached")
}
if _, err := s.PrintTeamReceipt(ctx, req); err != nil {
t.Fatal(err)
}
if printer.calls != 1 {
t.Fatal("incorrect number of prints")
}
})
}
}
func TestTeamReceiptErrorMapping(t *testing.T) {
for _, tc := range []struct {
err error
want codes.Code
}{
{team_service.ErrInvalidReceiptRequest, codes.InvalidArgument},
{team_service.ErrGameNotFound, codes.NotFound},
{team_service.ErrTeamNotFound, codes.NotFound},
{team_service.ErrReceiptVenueOnly, codes.FailedPrecondition},
{team_service.ErrReceiptTeamCancelled, codes.FailedPrecondition},
{team_service.ErrInvalidReceipt, codes.FailedPrecondition},
{team_service.ErrPrinterNotConfigured, codes.FailedPrecondition},
{team_service.ErrPrinterBusy, codes.ResourceExhausted},
{team_service.ErrPrinterNoPaper, codes.FailedPrecondition},
{team_service.ErrPrinterOverheated, codes.FailedPrecondition},
{team_service.ErrPrinterLowBattery, codes.FailedPrecondition},
{team_service.ErrPrinterNotReady, codes.FailedPrecondition},
{team_service.ErrPrinterProtocol, codes.Unavailable},
{team_service.ErrPrinterIncomplete, codes.Unavailable},
{team_service.ErrPrinterUnavailable, codes.Unavailable},
{context.Canceled, codes.Canceled},
{context.DeadlineExceeded, codes.DeadlineExceeded},
{errors.New("private-device-address-and-token"), codes.Internal},
} {
t.Run(tc.err.Error(), func(t *testing.T) {
err := teamReceiptError(fmt.Errorf("wrapped: %w", tc.err))
if status.Code(err) != tc.want {
t.Fatalf("code=%v,want %v", status.Code(err), tc.want)
}
if tc.want == codes.Internal && status.Convert(err).Message() == tc.err.Error() {
t.Fatal("private error exposed")
}
})
}
}
func TestTeamReceiptDisconnectWarnsBeforeRetry(t *testing.T) {
for _, tc := range []struct {
name string
err error
}{
{"Bluetooth disconnect", io.EOF},
{"worker response lost", fmt.Errorf("worker: %w", team_service.ErrPrinterUnavailable)},
} {
t.Run(tc.name, func(t *testing.T) {
code := team_service.ReceiptPrinterErrorCode(tc.err)
err := teamReceiptError(team_service.ReceiptPrinterError(code))
if status.Code(err) != codes.Unavailable || !strings.Contains(status.Convert(err).Message(), "перед повторной печатью проверьте, вышел ли чек") {
t.Fatalf("missing retry warning: %v", err)
}
})
}
}
@@ -25,6 +25,11 @@ type Card struct {
//go:embed fonts/DejaVuSansCondensed.ttf
var font []byte
// FontData returns the Cyrillic print font for raster receipts.
func FontData() []byte {
return bytes.Clone(font)
}
// RegisterFont adds the existing Cyrillic print font to a PDF document.
func RegisterFont(pdf *fpdf.Fpdf) {
pdf.AddUTF8FontFromBytes("teams", "", font)
@@ -54,7 +54,7 @@ func (f *printFixture) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Te
func (f *printFixture) GenerateTeamQR(ctx context.Context, game *repos.Game, teams []*repos.Team) ([]byte, string, error) {
f.qrCalls++
return team_service.NewTeamService(nil, nil, nil, "https://game.test", nil).GenerateTeamQR(ctx, game, teams)
return team_service.NewTeamService(nil, nil, nil, "https://game.test", nil, false, nil).GenerateTeamQR(ctx, game, teams)
}
func (f *printFixture) Get(ctx context.Context, name string) (*file_storage.File, error) {
@@ -70,7 +70,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
textCleaner := cleaner.NewCleaner()
scenarioService := scenarios_service.NewScenarioService(scenarios, textCleaner, "https://files.test/", nil, nil)
game := game_service.NewGameService(games, teams, scenarios, scenarioService, "https://files.test/", actions, applications)
team := team_service.NewTeamService(games, teams, password_generator.NewGenerator(12), "https://app.test", nil)
team := team_service.NewTeamService(games, teams, password_generator.NewGenerator(12), "https://app.test", nil, false, nil)
play := gameplay_service.NewGameplayService(games, teams, scenarioService, storytelling.NewStory(textCleaner), actions, applications, textCleaner, "https://files.test/")
answers := answer_service.NewAnswerService(games, teams, scenarioService, team_answers_repo.NewTeamAnswersRepo(pool), actions)
participation := participation_service.NewParticipationService(teams, "https://app.test")
+326
View File
@@ -0,0 +1,326 @@
package team_service
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
)
type attConnection interface {
Write(context.Context, []byte) error
Read(context.Context) ([]byte, error)
Close() error
}
type mxw01Printer struct {
address string
mutex sync.Mutex
}
// NewMXW01Printer creates the Linux printer used by the venue hardware process.
func NewMXW01Printer(address string) IReceiptPrinter {
return &mxw01Printer{address: address}
}
func (p *mxw01Printer) Print(ctx context.Context, data []byte) error {
if err := validateReceiptRaster(data); err != nil {
return err
}
if p.address == "" {
return ErrPrinterNotConfigured
}
if !p.mutex.TryLock() {
return ErrPrinterBusy
}
defer p.mutex.Unlock()
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
conn, err := dialMXW01(connectCtx, p.address)
cancel()
if err != nil {
if errors.Is(err, ErrPrinterNotConfigured) {
return ErrPrinterNotConfigured
}
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("connect MXW01: %w: %w", ErrPrinterUnavailable, err)
}
defer conn.Close()
return printMXW01(ctx, conn, data)
}
func printMXW01(ctx context.Context, conn attConnection, data []byte) error {
if err := validateReceiptRaster(data); err != nil {
return err
}
response, err := attRequest(ctx, conn, []byte{0x02, 185, 0}, 0x03)
if err != nil {
return err
}
if len(response) != 3 || binary.LittleEndian.Uint16(response[1:]) < 51 {
return ErrPrinterProtocol
}
handles, err := discoverMXW01(ctx, conn)
if err != nil {
return err
}
if _, err := attRequest(ctx, conn, []byte{0x12, byte(handles.ccc), byte(handles.ccc >> 8), 1, 0}, 0x13); err != nil {
return err
}
send := func(command byte, payload []byte) error {
packet := append([]byte{0x52, byte(handles.control), byte(handles.control >> 8)}, mxw01Command(command, payload)...)
return conn.Write(ctx, packet)
}
if err := send(0xa2, []byte{0x5d}); err != nil {
return err
}
if err := receiptPause(ctx, 100*time.Millisecond); err != nil {
return err
}
if err := send(0xa1, []byte{0}); err != nil {
return err
}
status, err := mxw01Response(ctx, conn, handles.notify, 0xa1, 7*time.Second)
if err != nil {
return err
}
if err := checkMXW01Status(status); err != nil {
return err
}
rows := len(data) / receiptRowBytes
if err := send(0xa9, []byte{byte(rows), byte(rows >> 8), 0x30, 0}); err != nil {
return err
}
accepted, err := mxw01Response(ctx, conn, handles.notify, 0xa9, 7*time.Second)
if err != nil {
return err
}
if len(accepted) < 1 || accepted[0] != 0 {
return ErrPrinterNotReady
}
for offset := 0; offset < len(data); offset += receiptRowBytes {
packet := append([]byte{0x52, byte(handles.data), byte(handles.data >> 8)}, data[offset:offset+receiptRowBytes]...)
if err := conn.Write(ctx, packet); err != nil {
return err
}
if err := receiptPause(ctx, 15*time.Millisecond); err != nil {
return err
}
}
if err := send(0xad, []byte{0}); err != nil {
return err
}
completed, err := mxw01Response(ctx, conn, handles.notify, 0xaa, 15*time.Second+time.Duration(rows)*time.Second/15)
if err != nil {
return err
}
crc := mxw01CRC(data)
if len(completed) != 3 || completed[0] != 0 || completed[1] != crc || completed[2] != crc {
return ErrPrinterIncomplete
}
return nil
}
type mxw01Handles struct{ control, notify, data, ccc uint16 }
func discoverMXW01(ctx context.Context, conn attConnection) (mxw01Handles, error) {
var handles mxw01Handles
var notifyEnd uint16 = 0xffff
for start := uint16(1); start != 0; {
request := []byte{0x08, byte(start), byte(start >> 8), 0xff, 0xff, 0x03, 0x28}
response, err := attRequest(ctx, conn, request, 0x09)
if err != nil {
return handles, err
}
if len(response) == 5 && response[0] == 0x01 && response[1] == 0x08 && response[4] == 0x0a {
break
}
if len(response) < 2 || (response[1] != 7 && response[1] != 21) || (len(response)-2)%int(response[1]) != 0 || len(response) == 2 {
return handles, ErrPrinterProtocol
}
length := int(response[1])
for offset := 2; offset < len(response); offset += length {
entry := response[offset : offset+length]
declaration := binary.LittleEndian.Uint16(entry)
if declaration < start {
return handles, ErrPrinterProtocol
}
if handles.notify != 0 && declaration > handles.notify && notifyEnd == 0xffff {
notifyEnd = declaration - 1
}
handle := binary.LittleEndian.Uint16(entry[3:])
if handle <= declaration {
return handles, ErrPrinterProtocol
}
switch bluetoothUUID16(entry[5:]) {
case 0xae01:
if entry[2]&0x04 == 0 {
return handles, ErrPrinterProtocol
}
handles.control = handle
case 0xae02:
if entry[2]&0x10 == 0 {
return handles, ErrPrinterProtocol
}
handles.notify = handle
case 0xae03:
if entry[2]&0x04 == 0 {
return handles, ErrPrinterProtocol
}
handles.data = handle
}
start = declaration + 1
}
}
if handles.control == 0 || handles.notify == 0 || handles.data == 0 || handles.notify == 0xffff {
return handles, ErrPrinterProtocol
}
start := handles.notify + 1
response, err := attRequest(ctx, conn, []byte{0x04, byte(start), byte(start >> 8), byte(notifyEnd), byte(notifyEnd >> 8)}, 0x05)
if err != nil {
return handles, err
}
if len(response) < 2 || response[0] != 0x05 || response[1] != 1 || (len(response)-2)%4 != 0 {
return handles, ErrPrinterProtocol
}
for offset := 2; offset < len(response); offset += 4 {
if binary.LittleEndian.Uint16(response[offset+2:]) == 0x2902 {
handles.ccc = binary.LittleEndian.Uint16(response[offset:])
if handles.ccc < start || handles.ccc > notifyEnd {
return handles, ErrPrinterProtocol
}
return handles, nil
}
}
return handles, ErrPrinterProtocol
}
func bluetoothUUID16(data []byte) uint16 {
if len(data) == 2 {
return binary.LittleEndian.Uint16(data)
}
base := []byte{0xfb, 0x34, 0x9b, 0x5f, 0x80, 0, 0, 0x80, 0, 0x10, 0, 0}
if len(data) == 16 && bytes.Equal(data[:12], base) && data[14] == 0 && data[15] == 0 {
return binary.LittleEndian.Uint16(data[12:])
}
return 0
}
func attRequest(ctx context.Context, conn attConnection, request []byte, expected byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 7*time.Second)
defer cancel()
if err := conn.Write(ctx, request); err != nil {
return nil, err
}
for {
response, err := conn.Read(ctx)
if err != nil {
return nil, err
}
if len(response) == 0 {
return nil, ErrPrinterProtocol
}
if response[0] == 0x01 {
if expected == 0x09 && len(response) == 5 && response[1] == request[0] && response[4] == 0x0a {
return response, nil
}
return nil, ErrPrinterProtocol
}
if response[0] == expected {
return response, nil
}
if response[0] != 0x1b {
return nil, ErrPrinterProtocol
}
}
}
func mxw01Response(ctx context.Context, conn attConnection, handle uint16, command byte, timeout time.Duration) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
message, err := conn.Read(ctx)
if err != nil {
return nil, err
}
if len(message) < 3 || message[0] != 0x1b {
return nil, ErrPrinterProtocol
}
if binary.LittleEndian.Uint16(message[1:]) != handle {
continue
}
data := message[3:]
if len(data) < 6 || data[0] != 0x22 || data[1] != 0x21 {
return nil, ErrPrinterProtocol
}
length := int(binary.LittleEndian.Uint16(data[4:]))
if len(data) < length+6 {
return nil, ErrPrinterProtocol
}
// Real firmware adds one non-CRC tail byte; only the declared payload is parsed.
if data[2] == command {
return data[6 : 6+length], nil
}
}
}
func checkMXW01Status(data []byte) error {
if len(data) < 10 {
return ErrPrinterProtocol
}
if data[9] == 7 {
return ErrPrinterNoPaper
}
if data[6] != 0 {
switch data[7] {
case 1, 9:
return ErrPrinterNoPaper
case 4:
return ErrPrinterOverheated
case 8:
return ErrPrinterLowBattery
default:
return ErrPrinterNotReady
}
}
if data[0] != 0 {
return ErrPrinterBusy
}
return nil
}
func mxw01CRC(data []byte) byte {
var crc byte
for _, b := range data {
crc ^= b
for range 8 {
if crc&0x80 != 0 {
crc = (crc << 1) ^ 0x07
} else {
crc <<= 1
}
}
}
return crc
}
func mxw01Command(command byte, payload []byte) []byte {
data := []byte{0x22, 0x21, command, 0, byte(len(payload)), byte(len(payload) >> 8)}
data = append(data, payload...)
return append(data, mxw01CRC(payload), 0xff)
}
func receiptPause(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
@@ -0,0 +1,116 @@
package team_service
import (
"context"
"errors"
"io"
"net"
"golang.org/x/sys/unix"
)
type linuxATTConnection struct{ fd int }
func dialMXW01(ctx context.Context, address string) (attConnection, error) {
mac, err := net.ParseMAC(address)
if err != nil || len(mac) != 6 {
return nil, ErrPrinterNotConfigured
}
fd, err := unix.Socket(unix.AF_BLUETOOTH, unix.SOCK_SEQPACKET|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, unix.BTPROTO_L2CAP)
if err != nil {
return nil, err
}
conn := &linuxATTConnection{fd: fd}
success := false
defer func() {
if !success {
conn.Close()
}
}()
local := &unix.SockaddrL2{CID: 4, AddrType: 1}
if err := unix.Bind(fd, local); err != nil {
return nil, err
}
peer := &unix.SockaddrL2{CID: 4, AddrType: 1}
copy(peer.Addr[:], mac)
if err := unix.Connect(fd, peer); err != nil {
if !errors.Is(err, unix.EINPROGRESS) {
return nil, err
}
if err := conn.wait(ctx, unix.POLLOUT); err != nil {
return nil, err
}
code, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR)
if err != nil {
return nil, err
}
if code != 0 {
return nil, unix.Errno(code)
}
}
success = true
return conn, nil
}
func (c *linuxATTConnection) wait(ctx context.Context, events int16) error {
for {
if err := ctx.Err(); err != nil {
return err
}
fds := []unix.PollFd{{Fd: int32(c.fd), Events: events}}
_, err := unix.Poll(fds, 100)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
return err
}
if fds[0].Revents&events != 0 {
return nil
}
if fds[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
return io.EOF
}
}
}
func (c *linuxATTConnection) Write(ctx context.Context, data []byte) error {
for {
if err := c.wait(ctx, unix.POLLOUT); err != nil {
return err
}
n, err := unix.Write(c.fd, data)
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
return err
}
if n != len(data) {
return io.ErrShortWrite
}
return nil
}
}
func (c *linuxATTConnection) Read(ctx context.Context) ([]byte, error) {
data := make([]byte, 512)
for {
if err := c.wait(ctx, unix.POLLIN); err != nil {
return nil, err
}
n, err := unix.Read(c.fd, data)
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
return nil, err
}
if n == 0 {
return nil, io.EOF
}
return data[:n], nil
}
}
func (c *linuxATTConnection) Close() error { return unix.Close(c.fd) }
@@ -0,0 +1,9 @@
//go:build !linux
package team_service
import "context"
func dialMXW01(context.Context, string) (attConnection, error) {
return nil, ErrPrinterUnavailable
}
@@ -0,0 +1,195 @@
package team_service
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"testing"
)
type attStub struct {
responses [][]byte
writes [][]byte
readErr error
endErr error
}
func (s *attStub) Write(_ context.Context, data []byte) error {
s.writes = append(s.writes, bytes.Clone(data))
return nil
}
func (s *attStub) Read(ctx context.Context) ([]byte, error) {
if s.readErr != nil {
return nil, s.readErr
}
if len(s.responses) == 0 {
if s.endErr != nil {
return nil, s.endErr
}
<-ctx.Done()
return nil, ctx.Err()
}
data := s.responses[0]
s.responses = s.responses[1:]
return data, nil
}
func (*attStub) Close() error { return nil }
func attNotification(command byte, payload []byte) []byte {
return append([]byte{0x1b, 12, 0}, mxw01Command(command, payload)...)
}
func mxw01FixtureResponses(status []byte, completion []byte) [][]byte {
return [][]byte{
{3, 185, 0},
{9, 7, 9, 0, 4, 10, 0, 1, 0xae, 11, 0, 0x10, 12, 0, 2, 0xae, 14, 0, 4, 15, 0, 3, 0xae},
{1, 8, 15, 0, 10},
{5, 1, 13, 0, 2, 0x29},
{0x13},
attNotification(0xa1, status),
attNotification(0xa9, []byte{0}),
attNotification(0xaa, completion),
}
}
func TestMXW01PrintProtocol(t *testing.T) {
status, _ := hex.DecodeString("00000053140000000a01")
for _, tc := range []struct {
name string
status []byte
completion []byte
readErr, want error
}{
{"success", status, []byte{0, 0, 0}, nil, nil},
{"short status", []byte{0}, nil, nil, ErrPrinterProtocol},
{"no paper", []byte{0, 0, 0, 83, 20, 0, 0, 0, 10, 7}, nil, nil, ErrPrinterNoPaper},
{"checksum mismatch", status, []byte{0, 1, 1}, nil, ErrPrinterIncomplete},
{"disconnect", nil, nil, io.EOF, io.EOF},
{"timeout", nil, nil, context.DeadlineExceeded, context.DeadlineExceeded},
} {
t.Run(tc.name, func(t *testing.T) {
stub := &attStub{responses: mxw01FixtureResponses(tc.status, tc.completion), readErr: tc.readErr}
err := printMXW01(context.Background(), stub, make([]byte, 48))
if !errors.Is(err, tc.want) {
t.Fatalf("error=%v, want %v", err, tc.want)
}
var commands []byte
var rows int
for _, data := range stub.writes {
if data[0] != 0x52 {
continue
}
handle := binary.LittleEndian.Uint16(data[1:])
if handle == 10 {
commands = append(commands, data[5])
}
if handle == 15 {
rows++
if len(data) != 51 {
t.Fatal("invalid row size")
}
}
}
if tc.want == nil && (!bytes.Equal(commands, []byte{0xa2, 0xa1, 0xa9, 0xad}) || rows != 1) {
t.Fatalf("commands=%x,rows=%d", commands, rows)
}
if errors.Is(tc.want, ErrPrinterNoPaper) && rows != 0 {
t.Fatal("sent pixels without paper")
}
})
}
}
func TestMXW01DisconnectAfterSendingReceipt(t *testing.T) {
for _, tc := range []struct {
name string
err error
}{
{"lost completion", io.EOF},
} {
t.Run(tc.name, func(t *testing.T) {
status, _ := hex.DecodeString("00000053140000000a01")
responses := mxw01FixtureResponses(status, nil)
stub := &attStub{responses: responses[:len(responses)-1], endErr: tc.err}
err := printMXW01(context.Background(), stub, make([]byte, 48))
if !errors.Is(err, tc.err) {
t.Fatalf("error=%v, want %v", err, tc.err)
}
if !bytes.Equal(stub.writes[len(stub.writes)-1], append([]byte{0x52, 10, 0}, mxw01Command(0xad, []byte{0})...)) {
t.Fatal("disconnect did not happen after sending the receipt")
}
if ReceiptPrinterErrorCode(err) != "unavailable" {
t.Fatal("disconnect lost its IPC error code")
}
})
}
}
func TestMXW01Status(t *testing.T) {
for _, tc := range []struct {
name string
state, flag, code, paper byte
want error
}{
{"ready", 0, 0, 0, 1, nil},
{"busy", 1, 0, 0, 1, ErrPrinterBusy},
{"paper with no error flag", 0, 0, 0, 7, ErrPrinterNoPaper},
{"heat", 0, 1, 4, 1, ErrPrinterOverheated},
{"battery", 0, 1, 8, 1, ErrPrinterLowBattery},
{"paper error", 0, 1, 9, 1, ErrPrinterNoPaper},
{"unknown", 0, 1, 99, 1, ErrPrinterNotReady},
} {
t.Run(tc.name, func(t *testing.T) {
status := []byte{tc.state, 0, 0, 83, 20, 0, tc.flag, tc.code, 10, tc.paper}
if err := checkMXW01Status(status); !errors.Is(err, tc.want) {
t.Fatalf("error=%v", err)
}
})
}
}
func TestMXW01CRC(t *testing.T) {
for _, tc := range []struct {
data []byte
crc byte
}{
{[]byte("123456789"), 0xf4},
{[]byte{0}, 0},
} {
t.Run(hex.EncodeToString(tc.data), func(t *testing.T) {
if mxw01CRC(tc.data) != tc.crc {
t.Fatal("CRC mismatch")
}
})
}
}
func TestMXW01BusyAndInvalidRaster(t *testing.T) {
for _, tc := range []struct {
name string
address string
busy bool
data []byte
want error
}{
{"busy", "48:0F:57:50:96:0C", true, make([]byte, 48), ErrPrinterBusy},
{"unconfigured", "", false, make([]byte, 48), ErrPrinterNotConfigured},
{"empty", "", false, nil, ErrInvalidReceipt},
{"partial row", "", false, make([]byte, 49), ErrInvalidReceipt},
{"too tall", "", false, make([]byte, 48*1025), ErrInvalidReceipt},
} {
t.Run(tc.name, func(t *testing.T) {
p := &mxw01Printer{address: tc.address}
if tc.busy {
p.mutex.Lock()
defer p.mutex.Unlock()
}
if err := p.Print(context.Background(), tc.data); !errors.Is(err, tc.want) {
t.Fatalf("error=%v", err)
}
})
}
}
+213
View File
@@ -0,0 +1,213 @@
package team_service
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"strings"
"time"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/teams_repo"
"github.com/google/uuid"
"github.com/skip2/go-qrcode"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
const (
receiptWidth = 384
receiptRowBytes = receiptWidth / 8
receiptMaxHeight = 1024
// ReceiptTimeout fits within the venue proxy's 120-second read timeout.
ReceiptTimeout = 110 * time.Second
)
// IReceiptPrinter sends a monochrome 384-pixel-wide raster to the venue printer.
type IReceiptPrinter interface {
Print(context.Context, []byte) error
}
// PreviewTeamReceipt renders a team receipt without contacting the printer.
func (s *TeamService) PreviewTeamReceipt(ctx context.Context, gameID int, teamID string) ([]byte, error) {
img, err := s.teamReceipt(ctx, gameID, teamID)
if err != nil {
return nil, err
}
var data bytes.Buffer
if err := png.Encode(&data, img); err != nil {
return nil, fmt.Errorf("encode receipt: %w", err)
}
return data.Bytes(), nil
}
// PrintTeamReceipt loads current team data and prints one receipt without retrying.
func (s *TeamService) PrintTeamReceipt(ctx context.Context, gameID int, teamID string) error {
img, err := s.teamReceipt(ctx, gameID, teamID)
if err != nil {
return err
}
if s.receiptPrinter == nil {
return ErrPrinterNotConfigured
}
return s.receiptPrinter.Print(ctx, packReceipt(img))
}
func (s *TeamService) teamReceipt(ctx context.Context, gameID int, teamID string) (*image.Gray, error) {
if !s.venueMode {
return nil, ErrReceiptVenueOnly
}
if gameID <= 0 {
return nil, ErrInvalidReceiptRequest
}
if _, err := uuid.Parse(teamID); err != nil {
return nil, ErrInvalidReceiptRequest
}
if err := ctx.Err(); err != nil {
return nil, err
}
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
if errors.Is(err, games_repo.ErrGameNotFound) {
return nil, ErrGameNotFound
}
if err != nil {
return nil, err
}
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
if errors.Is(err, teams_repo.ErrTeamNotFound) {
return nil, ErrTeamNotFound
}
if err != nil {
return nil, err
}
if team.GameID != gameID {
return nil, ErrTeamNotFound
}
if team.ParticipationStatus == "cancelled" {
return nil, ErrReceiptTeamCancelled
}
cards, _, err := teamQRData(s.appBaseURL, game, []*repos.Team{team})
if err != nil {
return nil, fmt.Errorf("receipt card: %w: %w", ErrInvalidReceipt, err)
}
return renderReceipt(cards[0])
}
func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
name := strings.Join(strings.Fields(card.Name), " ")
if name == "" || len([]rune(name)) > 255 {
return nil, ErrInvalidReceipt
}
ttf, err := opentype.Parse(team_qr_pdf.FontData())
if err != nil {
return nil, fmt.Errorf("parse receipt font: %w", err)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 26, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open receipt font: %w", err)
}
defer face.Close()
for _, r := range name {
if _, ok := face.GlyphAdvance(r); !ok {
return nil, ErrInvalidReceipt
}
}
lines := wrapReceiptName(name, face)
qr, err := qrcode.New(card.URL, qrcode.Medium)
if err != nil {
return nil, ErrInvalidReceipt
}
bitmap := qr.Bitmap()
scale := (receiptWidth - 32) / len(bitmap)
if scale < 3 {
return nil, ErrInvalidReceipt
}
qrSize := len(bitmap) * scale
qrY := 16 + len(lines)*34 + 16
height := qrY + qrSize + 24
if height > receiptMaxHeight {
return nil, ErrInvalidReceipt
}
img := image.NewGray(image.Rect(0, 0, receiptWidth, height))
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
drawer := font.Drawer{Dst: img, Src: image.Black, Face: face}
for i, line := range lines {
width := drawer.MeasureString(line).Ceil()
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*34)
drawer.DrawString(line)
}
x := (receiptWidth - qrSize) / 2
for row, cells := range bitmap {
for col, black := range cells {
if black {
draw.Draw(img, image.Rect(x+col*scale, qrY+row*scale, x+(col+1)*scale, qrY+(row+1)*scale), image.Black, image.Point{}, draw.Src)
}
}
}
for i, pixel := range img.Pix {
if pixel < 128 {
img.Pix[i] = 0
} else {
img.Pix[i] = 255
}
}
return img, nil
}
func wrapReceiptName(name string, face font.Face) []string {
var lines []string
line := ""
for _, word := range strings.Fields(name) {
candidate := word
if line != "" {
candidate = line + " " + word
}
if font.MeasureString(face, candidate).Ceil() <= receiptWidth-32 {
line = candidate
continue
}
if line != "" {
lines = append(lines, line)
}
line = ""
for _, r := range word {
if line != "" && font.MeasureString(face, line+string(r)).Ceil() > receiptWidth-32 {
lines = append(lines, line)
line = ""
}
line += string(r)
}
}
if line != "" {
lines = append(lines, line)
}
return lines
}
func packReceipt(img *image.Gray) []byte {
data := make([]byte, img.Rect.Dy()*receiptRowBytes)
for y := range img.Rect.Dy() {
for x := range receiptWidth {
if img.GrayAt(x, y) == (color.Gray{Y: 0}) {
data[y*receiptRowBytes+x/8] |= 1 << (x % 8)
}
}
}
return data
}
func validateReceiptRaster(data []byte) error {
if len(data) == 0 || len(data)%receiptRowBytes != 0 || len(data) > receiptMaxHeight*receiptRowBytes {
return ErrInvalidReceipt
}
return nil
}
@@ -0,0 +1,44 @@
package team_service
import (
"bytes"
"context"
"fmt"
"net"
"net/http"
)
type unixReceiptPrinter struct{ client *http.Client }
// NewUnixReceiptPrinter connects the venue API to its private hardware process.
func NewUnixReceiptPrinter(socket string) IReceiptPrinter {
return &unixReceiptPrinter{client: &http.Client{
Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
}},
Timeout: ReceiptTimeout,
}}
}
func (p *unixReceiptPrinter) Print(ctx context.Context, data []byte) error {
if err := validateReceiptRaster(data); err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://receipt-printer/print", bytes.NewReader(data))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/octet-stream")
response, err := p.client.Do(request)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("receipt worker: %w: %w", ErrPrinterUnavailable, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
return ReceiptPrinterError(response.Header.Get("X-Printer-Error"))
}
return nil
}
@@ -0,0 +1,55 @@
package team_service
import (
"context"
"errors"
)
var (
ErrReceiptVenueOnly = errors.New("печать чеков доступна только на локальной площадке")
ErrInvalidReceiptRequest = errors.New("некорректные идентификаторы игры или команды")
ErrReceiptTeamCancelled = errors.New("участие команды отменено")
ErrInvalidReceipt = errors.New("не удалось подготовить чек: проверьте название команды и адрес игры")
ErrPrinterNotConfigured = errors.New("принтер MXW01 не настроен на площадке")
ErrPrinterUnavailable = errors.New("связь с MXW01 недоступна; проверьте питание и Bluetooth, а перед повторной печатью проверьте, вышел ли чек")
ErrPrinterBusy = errors.New("принтер занят; дождитесь завершения печати")
ErrPrinterNoPaper = errors.New("в принтере нет бумаги")
ErrPrinterOverheated = errors.New("принтер перегрелся; подождите, пока он остынет")
ErrPrinterLowBattery = errors.New("зарядите принтер")
ErrPrinterNotReady = errors.New("MXW01 не готов к печати")
ErrPrinterProtocol = errors.New("MXW01 вернул неожиданный ответ; проверьте, вышел ли чек, перед повторной печатью")
ErrPrinterIncomplete = errors.New("MXW01 не подтвердил целостность чека; проверьте бумажный чек")
)
var receiptPrinterErrors = map[string]error{
"deadline": context.DeadlineExceeded,
"cancelled": context.Canceled,
"unconfigured": ErrPrinterNotConfigured,
"unavailable": ErrPrinterUnavailable,
"busy": ErrPrinterBusy,
"paper": ErrPrinterNoPaper,
"heat": ErrPrinterOverheated,
"battery": ErrPrinterLowBattery,
"not_ready": ErrPrinterNotReady,
"protocol": ErrPrinterProtocol,
"incomplete": ErrPrinterIncomplete,
"invalid": ErrInvalidReceipt,
}
// ReceiptPrinterErrorCode maps expected device errors to private IPC codes.
func ReceiptPrinterErrorCode(err error) string {
for code, known := range receiptPrinterErrors {
if errors.Is(err, known) {
return code
}
}
return "unavailable"
}
// ReceiptPrinterError maps a private IPC code to a safe domain error.
func ReceiptPrinterError(code string) error {
if err, ok := receiptPrinterErrors[code]; ok {
return err
}
return ErrPrinterUnavailable
}
@@ -0,0 +1,151 @@
package team_service
import (
"bytes"
"context"
"errors"
"image"
"image/draw"
"image/png"
"strings"
"testing"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/teams_repo"
)
const receiptTestTeamID = "019a1234-5678-7abc-8def-000000000007"
type receiptReposStub struct {
ITeamsRepository
team *repos.Team
gameErr, teamErr error
}
func (s *receiptReposStub) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1, Name: "Проверка чеков"}, s.gameErr
}
func (s *receiptReposStub) GetTeamByID(context.Context, string) (*repos.Team, error) {
return s.team, s.teamErr
}
type receiptPrinterStub struct {
data []byte
err error
}
func (p *receiptPrinterStub) Print(_ context.Context, data []byte) error {
p.data = bytes.Clone(data)
return p.err
}
func TestTeamReceiptValidation(t *testing.T) {
for _, tc := range []struct {
name string
venue bool
gameID int
teamID, baseURL string
team *repos.Team
gameErr, teamErr, want error
}{
{name: "prod", want: ErrReceiptVenueOnly},
{name: "invalid game", venue: true, want: ErrInvalidReceiptRequest},
{name: "invalid team", venue: true, gameID: 1, teamID: "bad", want: ErrInvalidReceiptRequest},
{name: "missing game", venue: true, gameID: 1, teamID: receiptTestTeamID, gameErr: games_repo.ErrGameNotFound, want: ErrGameNotFound},
{name: "missing team", venue: true, gameID: 1, teamID: receiptTestTeamID, teamErr: teams_repo.ErrTeamNotFound, want: ErrTeamNotFound},
{name: "wrong game", venue: true, gameID: 1, teamID: receiptTestTeamID, team: &repos.Team{GameID: 2}, want: ErrTeamNotFound},
{name: "cancelled", venue: true, gameID: 1, teamID: receiptTestTeamID, team: &repos.Team{GameID: 1, ParticipationStatus: "cancelled"}, want: ErrReceiptTeamCancelled},
{name: "missing password", venue: true, gameID: 1, teamID: receiptTestTeamID, baseURL: "https://game.test", team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: "Команда"}, want: ErrInvalidReceipt},
{name: "missing base", venue: true, gameID: 1, teamID: receiptTestTeamID, team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: "Команда", Password: "qr"}, want: ErrInvalidReceipt},
} {
t.Run(tc.name, func(t *testing.T) {
repos := &receiptReposStub{team: tc.team, gameErr: tc.gameErr, teamErr: tc.teamErr}
printer := &receiptPrinterStub{}
s := NewTeamService(repos, repos, nil, tc.baseURL, nil, tc.venue, printer)
if _, err := s.PreviewTeamReceipt(context.Background(), tc.gameID, tc.teamID); !errors.Is(err, tc.want) {
t.Fatalf("preview: %v, want %v", err, tc.want)
}
if err := s.PrintTeamReceipt(context.Background(), tc.gameID, tc.teamID); !errors.Is(err, tc.want) {
t.Fatalf("print: %v, want %v", err, tc.want)
}
if len(printer.data) != 0 {
t.Fatal("invalid team printed")
}
})
}
}
func TestTeamReceiptPreviewMatchesPrintedRaster(t *testing.T) {
for _, name := range []string{"Вечерние сыщики", "Очень длинное название команды БезПробеловОченьДлинноеНазваниеКоманды"} {
t.Run(name, func(t *testing.T) {
repos := &receiptReposStub{team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
printer := &receiptPrinterStub{}
s := NewTeamService(repos, repos, nil, "https://game.test/app", nil, true, printer)
data, err := s.PreviewTeamReceipt(context.Background(), 1, receiptTestTeamID)
if err != nil {
t.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
if img.Bounds().Dx() != 384 || img.Bounds().Dy() > 1024 {
t.Fatal("invalid receipt size")
}
if err := s.PrintTeamReceipt(context.Background(), 1, receiptTestTeamID); err != nil {
t.Fatal(err)
}
gray := image.NewGray(img.Bounds())
draw.Draw(gray, gray.Bounds(), img, image.Point{}, draw.Src)
if !bytes.Equal(packReceipt(gray), printer.data) {
t.Fatal("preview and printed pixels differ")
}
})
}
}
func TestRenderReceiptRejectsInvalidText(t *testing.T) {
for _, name := range []string{"", " ", "Команда🕵️", strings.Repeat("Ш", 256)} {
t.Run(name, func(t *testing.T) {
_, err := renderReceipt(team_qr_pdf.Card{Name: name, URL: "https://game.test/team"})
if !errors.Is(err, ErrInvalidReceipt) {
t.Fatalf("error = %v", err)
}
})
}
}
func TestPackReceipt(t *testing.T) {
for _, tc := range []struct {
name string
pixels []image.Point
wantFirst, wantLast byte
}{
{"LSB first", []image.Point{{0, 0}, {7, 0}, {8, 0}, {383, 1}}, 0x81, 0x80},
} {
t.Run(tc.name, func(t *testing.T) {
img := image.NewGray(image.Rect(0, 0, 384, 2))
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
for _, p := range tc.pixels {
img.Set(p.X, p.Y, image.Black)
}
data := packReceipt(img)
if len(data) != 96 || data[0] != tc.wantFirst || data[1] != 1 || data[95] != tc.wantLast {
t.Fatal("incorrect packed pixels")
}
})
}
}
func TestReceiptPrinterErrorsRoundTrip(t *testing.T) {
for code, known := range receiptPrinterErrors {
t.Run(code, func(t *testing.T) {
if ReceiptPrinterErrorCode(known) != code || !errors.Is(ReceiptPrinterError(code), known) {
t.Fatal("error code lost")
}
})
}
}
+25 -4
View File
@@ -19,20 +19,39 @@ import (
// TeamService handles team operations.
type TeamService struct {
gamesRepo *games_repo.GamesRepo
teamsRepo *teams_repo.TeamsRepo
gamesRepo ITeamGamesRepository
teamsRepo ITeamsRepository
passwordGenerator password_generator.IPasswordGenerator
appBaseURL string
emailSender email_sender.IEmailSender
venueMode bool
receiptPrinter IReceiptPrinter
}
// ITeamGamesRepository reads games used by team operations.
type ITeamGamesRepository interface {
GetGameByID(context.Context, int) (*repos.Game, error)
}
// ITeamsRepository stores teams used by this service.
type ITeamsRepository interface {
GetTeamByID(context.Context, string) (*repos.Team, error)
GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error)
AddTeamWithParticipation(context.Context, int, int, string, string, string, int32, bool) (string, error)
UpdateTeam(context.Context, string, string) error
DeleteTeam(context.Context, string) error
RegisterTeamWithParticipation(context.Context, int, string, string, string, int32, string, bool) (string, error)
}
// NewTeamService creates a team service.
func NewTeamService(
gamesRepo *games_repo.GamesRepo,
teamsRepo *teams_repo.TeamsRepo,
gamesRepo ITeamGamesRepository,
teamsRepo ITeamsRepository,
passwordGenerator password_generator.IPasswordGenerator,
appBaseURL string,
emailSender email_sender.IEmailSender,
venueMode bool,
receiptPrinter IReceiptPrinter,
) *TeamService {
return &TeamService{
gamesRepo: gamesRepo,
@@ -40,6 +59,8 @@ func NewTeamService(
passwordGenerator: passwordGenerator,
appBaseURL: appBaseURL,
emailSender: emailSender,
venueMode: venueMode,
receiptPrinter: receiptPrinter,
}
}