feat: P1 profile/portrait API and freeze local-dev environment
Add host-first environment contracts (Local vs CI vs Prod), deps-only compose, and the Profile → Portrait → deep-access mock payment slice with device identity and auto-migrate on API startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const UserIDKey ctxKey = "user_id"
|
||||
const DeviceKeyHeader = "X-Device-Key"
|
||||
|
||||
// DeviceAuth resolves or creates a Visitor→User via device key.
|
||||
func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.GetHeader(DeviceKeyHeader)
|
||||
if key == "" {
|
||||
key = newDeviceKey()
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
}
|
||||
userID, err := ensureUser(c.Request.Context(), pool, key)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(string(UserIDKey), userID.String())
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UserIDFromContext returns the authenticated user id.
|
||||
func UserIDFromContext(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, ok := c.Get(string(UserIDKey))
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(v.(string))
|
||||
return id, err == nil
|
||||
}
|
||||
|
||||
func ensureUser(ctx context.Context, pool *pgxpool.Pool, deviceKey string) (uuid.UUID, error) {
|
||||
var userID *uuid.UUID
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT user_id FROM device_identities
|
||||
WHERE device_key=$1 AND deleted_at IS NULL`, deviceKey,
|
||||
).Scan(&userID)
|
||||
if err == nil && userID != nil {
|
||||
return *userID, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var uid uuid.UUID
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO users DEFAULT VALUES RETURNING id`,
|
||||
).Scan(&uid); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (device_key) DO UPDATE SET user_id=EXCLUDED.user_id, updated_at=now()`,
|
||||
deviceKey, uid,
|
||||
); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
func newDeviceKey() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user