Files
license/internal/issuance/service.go
2026-07-31 16:59:55 +08:00

245 lines
6.6 KiB
Go

package issuance
import (
"context"
"fmt"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/gorm"
"git.apinb.com/ops/license/internal/subject"
"git.apinb.com/ops/license/sdk/licence"
)
const (
maximumOperatorRunes = 128
maximumPageSize = 100
)
type Service struct {
repository *Repository
subjectRepository *subject.Repository
signer *FileSigner
}
func NewService(repository *Repository, subjectRepository *subject.Repository, signer *FileSigner) (*Service, error) {
if repository == nil || repository.db == nil || subjectRepository == nil || signer == nil || signer.active == nil {
return nil, ErrInvalidInput
}
return &Service{
repository: repository,
subjectRepository: subjectRepository,
signer: signer,
}, nil
}
func (s *Service) Issue(ctx context.Context, subjectID uuid.UUID, input IssueInput, operator string) (Record, error) {
operator, err := s.validateWrite(ctx, subjectID, operator)
if err != nil {
return Record{}, err
}
var issued Record
err = s.repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
item, err := s.subjectRepository.GetForUpdate(ctx, tx, subjectID)
if err != nil {
return mapSubjectError(err)
}
now := time.Now().UTC()
claims, content, err := s.signer.Build(item, input, now)
if err != nil {
return err
}
record, err := buildRecord(item.ID, "issue", nil, claims, content, now, operator)
if err != nil {
return err
}
if err := s.repository.Insert(ctx, tx, record); err != nil {
return err
}
issued = record
return nil
})
if err != nil {
return Record{}, err
}
issued.FileContent = cloneBytes(issued.FileContent)
return issued, nil
}
func (s *Service) Renew(ctx context.Context, previousID uuid.UUID, input IssueInput, operator string) (Record, error) {
operator, err := s.validateWrite(ctx, previousID, operator)
if err != nil {
return Record{}, err
}
var renewed Record
err = s.repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
previous, err := s.repository.lock(ctx, tx, previousID)
if err != nil {
return err
}
item, err := s.subjectRepository.GetForUpdate(ctx, tx, previous.SubjectID)
if err != nil {
return mapSubjectError(err)
}
now := time.Now().UTC()
claims, content, err := s.signer.Build(item, input, now)
if err != nil {
return err
}
record, err := buildRecord(item.ID, "renewal", &previousID, claims, content, now, operator)
if err != nil {
return err
}
if err := s.repository.Insert(ctx, tx, record); err != nil {
return err
}
renewed = record
return nil
})
if err != nil {
return Record{}, err
}
renewed.FileContent = cloneBytes(renewed.FileContent)
return renewed, nil
}
func (s *Service) Get(ctx context.Context, id uuid.UUID) (Record, error) {
if !s.usable(ctx) || id == uuid.Nil {
return Record{}, ErrInvalidInput
}
return s.repository.Get(ctx, id)
}
func (s *Service) List(ctx context.Context, filter Filter, page, pageSize int) ([]RecordSummary, int64, error) {
if !s.usable(ctx) || !validPage(page, pageSize) {
return nil, 0, ErrInvalidInput
}
normalized, err := normalizeFilter(filter)
if err != nil {
return nil, 0, err
}
return s.repository.List(ctx, normalized, page, pageSize)
}
func (s *Service) ListBySubject(ctx context.Context, subjectID uuid.UUID, page, pageSize int) ([]RecordSummary, int64, error) {
if !s.usable(ctx) || subjectID == uuid.Nil || !validPage(page, pageSize) {
return nil, 0, ErrInvalidInput
}
return s.repository.ListBySubject(ctx, subjectID, page, pageSize)
}
func (s *Service) Download(ctx context.Context, id uuid.UUID) ([]byte, DownloadMetadata, error) {
if !s.usable(ctx) || id == uuid.Nil {
return nil, DownloadMetadata{}, ErrInvalidInput
}
return s.repository.Download(ctx, id)
}
func (s *Service) validateWrite(ctx context.Context, id uuid.UUID, operator string) (string, error) {
if !s.usable(ctx) || id == uuid.Nil {
return "", ErrInvalidInput
}
operator = strings.TrimSpace(operator)
if operator == "" || !utf8.ValidString(operator) || utf8.RuneCountInString(operator) > maximumOperatorRunes {
return "", ErrInvalidInput
}
for _, character := range operator {
if unicode.IsControl(character) {
return "", ErrInvalidInput
}
}
return operator, nil
}
func (s *Service) usable(ctx context.Context) bool {
return s != nil && s.repository != nil && s.repository.db != nil &&
s.subjectRepository != nil && s.signer != nil && s.signer.active != nil && ctx != nil
}
func validPage(page, pageSize int) bool {
return page >= 1 && pageSize >= 1 && pageSize <= maximumPageSize
}
func normalizeFilter(filter Filter) (Filter, error) {
filter.PlatformName = strings.TrimSpace(filter.PlatformName)
filter.Workspace = strings.TrimSpace(filter.Workspace)
var err error
filter.ValidFrom, err = normalizeDateFilter(filter.ValidFrom)
if err != nil {
return Filter{}, err
}
filter.ExpiresOn, err = normalizeDateFilter(filter.ExpiresOn)
if err != nil {
return Filter{}, err
}
filter.IssuedOn, err = normalizeDateFilter(filter.IssuedOn)
if err != nil {
return Filter{}, err
}
return filter, nil
}
func normalizeDateFilter(value *time.Time) (*time.Time, error) {
if value == nil {
return nil, nil
}
if value.IsZero() {
return nil, ErrInvalidInput
}
date, err := licence.NewDate(*value)
if err != nil {
return nil, ErrInvalidInput
}
normalized, err := date.Time()
if err != nil {
return nil, ErrInvalidInput
}
return &normalized, nil
}
func buildRecord(subjectID uuid.UUID, issuanceType string, previousID *uuid.UUID, claims licence.Claims, content []byte, issuedAt time.Time, operator string) (Record, error) {
id, err := uuid.Parse(claims.ID)
if err != nil {
return Record{}, fmt.Errorf("解析许可证 ID: %w", err)
}
signingKeyID, err := uuid.Parse(claims.SigningKeyID)
if err != nil {
return Record{}, fmt.Errorf("解析签发密钥 ID: %w", err)
}
validFrom, err := claims.ValidFrom.Time()
if err != nil {
return Record{}, fmt.Errorf("解析生效日期: %w", err)
}
expiresOn, err := claims.ExpiresOn.Time()
if err != nil {
return Record{}, fmt.Errorf("解析到期日期: %w", err)
}
return Record{
ID: id,
SubjectID: subjectID,
IssuanceType: issuanceType,
PreviousLicenceID: cloneUUID(previousID),
ValidFrom: validFrom,
ExpiresOn: expiresOn,
Quotas: claims.Quotas,
SigningKeyID: signingKeyID,
Payload: claims,
FileContent: cloneBytes(content),
IssuedAt: issuedAt,
OperatorName: operator,
}, nil
}
func mapSubjectError(err error) error {
if err == subject.ErrNotFound {
return ErrNotFound
}
return err
}