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:
jackyu66git
2026-08-02 16:24:57 +08:00
co-authored by Cursor
parent dd94e57277
commit 0f320e040b
49 changed files with 1907 additions and 191 deletions
+96
View File
@@ -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)
}