generated from VLADIMIR/template
up
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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, "не удалось напечатать чек; проверьте, вышел ли он, перед повторной печатью")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user