RC-10-A
All checks were successful
release-tag / release-image (push) Successful in 4m43s

This commit is contained in:
2026-08-11 22:18:08 +02:00
parent 74d6b2f7d2
commit a88e75a8c4
4 changed files with 107 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
> **V4.2.6.1 Build Fix:** Das erste V4.2.6-Release-Archiv enthielt versehentlich nicht `internal/data/`. Dadurch konnte neuer Server-Code mit einem alten Data-Package kombiniert werden und der Server-Build scheiterte mit fehlenden `HostedCreditEvent`/Outbox-Methoden. Dieses Archiv enthält den vollständigen Data-Layer. Details: `V4.2.6.1_BUILD_FIX.md`.
> **V4.2.6 Customer Engagement + Admin Controls:** Der Hosted Customer Service kann neuen Konten ein konfigurierbares Startguthaben geben und Hosted Workern für echte persönliche Best-Score-Verbesserungen konfigurierbare Bonus-Credits gutschreiben. Das Customer Portal blendet PayPal vollständig aus, wenn PayPal deaktiviert ist, und fasst den Credit-Verlauf kompakt nach Tag/Buchungsart zusammen. Im privaten Customer-Admin gibt es Benutzer sperren/freigeben, Worker-Stop, Login-/Registrierungs-Kill-Switches, Registrierungs-Proof-of-Work und optionale einmalige Invite-Codes. Positive-Tip-Rewards werden über eine persistente Game-Outbox idempotent an den Customer Service zugestellt. Details: `V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md`.
> **V4.2 Pipeline Dockerfiles:** Server, Customer Service und Worker besitzen jetzt jeweils ein eigenes Dockerfile (`Dockerfile.server`, `Dockerfile.customer-service`, `Dockerfile.worker`). Das bestehende `Dockerfile` baut weiterhin den Server, damit vorhandene Single-Image-Pipelines kompatibel bleiben. `CS_WORKER_IMAGE` zeigt weiterhin direkt auf das veröffentlichte Worker-Image.

19
V4.2.6.1_BUILD_FIX.md Normal file
View File

@@ -0,0 +1,19 @@
# V4.2.6.1 build/package fix
V4.2.6 introduced the hosted positive-tip credit outbox in `internal/server/customer_credit.go`.
The corresponding store implementation and schema changes are in:
- `internal/data/store.go`
- `internal/data/schema.sql`
The first V4.2.6 release archive accidentally omitted the complete `internal/data/` directory while being packaged. When that archive was copied over an older checkout, the new server code was combined with the older data package. The compiler then reported missing symbols such as:
- `data.HostedCreditEvent`
- `Store.PendingHostedCreditEvents`
- `Store.MarkHostedCreditEventAttempt`
- `Store.MarkHostedCreditEventDelivered`
- `Store.RecordHostedPositiveCreditEvent`
V4.2.6.1 contains the complete `internal/data/` directory and therefore keeps the server and data package at the same version.
No game, billing, worker, or portal behavior was intentionally changed by this patch.

View File

@@ -173,3 +173,20 @@ CREATE TABLE IF NOT EXISTS customer_link_tokens (
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS customer_link_tokens_exp_idx ON customer_link_tokens(expires_at);
-- Durable outbox for hosted-worker positive-tip rewards. A "positive tip" is
-- a signed guess that improves that worker's personal best score. Delivery to
-- Customer Service is retried and the event id is idempotent there.
CREATE TABLE IF NOT EXISTS hosted_credit_events (
event_id TEXT PRIMARY KEY,
worker_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
reward_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
score REAL NOT NULL,
created_at INTEGER NOT NULL,
delivered_at INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS hosted_credit_events_pending_idx ON hosted_credit_events(delivered_at,created_at);

View File

@@ -1784,3 +1784,72 @@ func (s *Store) ConsumeCustomerLinkToken(ctx context.Context, tokenHash string)
}
return clientID, nil
}
// HostedCreditEvent is a durable outbox item for customer-service engagement
// credits. Keeping it in the game database means a temporary Customer Service
// outage does not silently lose a positive-tip reward.
type HostedCreditEvent struct {
EventID string `json:"event_id"`
WorkerClientID string `json:"worker_client_id"`
RewardClientID string `json:"reward_client_id"`
TaskID string `json:"task_id"`
Seq int64 `json:"seq"`
Score float64 `json:"score"`
CreatedAt time.Time `json:"created_at"`
Attempts int `json:"attempts"`
}
func (s *Store) RecordHostedPositiveCreditEvent(ctx context.Context, taskID, workerClientID, rewardClientID string, seq int64, score float64) (string, bool, error) {
workerClientID = strings.TrimSpace(workerClientID)
rewardClientID = strings.TrimSpace(rewardClientID)
if workerClientID == "" || rewardClientID == "" || rewardClientID == workerClientID {
return "", false, nil
}
// The identity delegation itself is the server-side proof that this is a
// hosted/delegated worker rather than an ordinary browser/CLI identity.
var owner string
if err := s.DB.QueryRowContext(ctx, `SELECT owner_client_id FROM identity_delegations WHERE worker_client_id=?`, workerClientID).Scan(&owner); err != nil || owner != rewardClientID {
return "", false, nil
}
eventID := fmt.Sprintf("%s:%s:%d", taskID, workerClientID, seq)
res, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO hosted_credit_events(event_id,worker_client_id,reward_client_id,task_id,seq,score,created_at) VALUES(?,?,?,?,?,?,?)`, eventID, workerClientID, rewardClientID, taskID, seq, score, time.Now().UTC().UnixMilli())
if err != nil {
return "", false, err
}
n, err := res.RowsAffected()
return eventID, n == 1, err
}
func (s *Store) PendingHostedCreditEvents(ctx context.Context, limit int) ([]HostedCreditEvent, error) {
if limit < 1 || limit > 200 {
limit = 50
}
rows, err := s.DB.QueryContext(ctx, `SELECT event_id,worker_client_id,reward_client_id,task_id,seq,score,created_at,attempts FROM hosted_credit_events WHERE delivered_at IS NULL ORDER BY created_at LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HostedCreditEvent
for rows.Next() {
var x HostedCreditEvent
var created int64
if err := rows.Scan(&x.EventID, &x.WorkerClientID, &x.RewardClientID, &x.TaskID, &x.Seq, &x.Score, &created, &x.Attempts); err != nil {
return nil, err
}
x.CreatedAt = time.UnixMilli(created).UTC()
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) MarkHostedCreditEventDelivered(ctx context.Context, eventID string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE hosted_credit_events SET delivered_at=?,last_error='' WHERE event_id=?`, time.Now().UTC().UnixMilli(), eventID)
return err
}
func (s *Store) MarkHostedCreditEventAttempt(ctx context.Context, eventID, lastErr string) error {
if len(lastErr) > 1000 {
lastErr = lastErr[:1000]
}
_, err := s.DB.ExecContext(ctx, `UPDATE hosted_credit_events SET attempts=attempts+1,last_error=? WHERE event_id=?`, lastErr, eventID)
return err
}