Expose gateway billing flows from the synced server baseline

The preserved local work adds PopiNewAPI billing checkout, gateway account binding, and matching web console/pricing surfaces on top of the current test-server baseline. During the merge, the start-end video helpers from the deployed baseline were kept alongside the actionGenerate prompt handling from the WIP.

Constraint: Current usable baseline is 7054436, already deployed on the test server.

Rejected: Commit the WIP before syncing the baseline | would have hidden conflicts with the deployed start-end-frame changes.

Confidence: medium

Scope-risk: broad

Directive: Do not deploy this commit to the test server without rechecking gateway credentials and billing checkout behavior in that environment.

Tested: go test ./...

Tested: make build

Tested: cd web && npm run build
This commit is contained in:
wtgoku
2026-05-21 16:53:07 +08:00
parent 7054436a99
commit baba209519
31 changed files with 6035 additions and 86 deletions
+68 -6
View File
@@ -56,6 +56,8 @@ func (r *sqliteRepository) migrate() error {
user_json TEXT NOT NULL,
token_enc BLOB NOT NULL,
token_masked TEXT NOT NULL,
gateway_user_id INTEGER,
gateway_access_token_enc BLOB,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);`,
@@ -154,6 +156,14 @@ func (r *sqliteRepository) migrate() error {
return fmt.Errorf("upgrade sqlite jobs schema: %w", err)
}
}
for _, stmt := range []string{
`ALTER TABLE sessions ADD COLUMN gateway_user_id INTEGER;`,
`ALTER TABLE sessions ADD COLUMN gateway_access_token_enc BLOB;`,
} {
if _, err := r.db.Exec(stmt); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return fmt.Errorf("upgrade sqlite sessions schema: %w", err)
}
}
return nil
}
@@ -194,17 +204,27 @@ func (r *sqliteRepository) CreateSession(upstreamKey string, u user, ttl time.Du
func (r *sqliteRepository) GetSession(token string) (session, bool, error) {
row := r.db.QueryRow(
`SELECT user_id, user_json, token_enc, created_at, expires_at
`SELECT user_id, user_json, token_enc, gateway_user_id, gateway_access_token_enc, created_at, expires_at
FROM sessions WHERE session_id = ?`,
token,
)
var (
current session
userJSON string
tokenEnc []byte
createdAt, expiresAt string
current session
userJSON string
tokenEnc []byte
gatewayUserID sql.NullInt64
gatewayAccessTokenEnc []byte
createdAt, expiresAt string
)
if err := row.Scan(&current.UserID, &userJSON, &tokenEnc, &createdAt, &expiresAt); errors.Is(err, sql.ErrNoRows) {
if err := row.Scan(
&current.UserID,
&userJSON,
&tokenEnc,
&gatewayUserID,
&gatewayAccessTokenEnc,
&createdAt,
&expiresAt,
); errors.Is(err, sql.ErrNoRows) {
return session{}, false, nil
} else if err != nil {
return session{}, false, fmt.Errorf("scan session: %w", err)
@@ -217,6 +237,16 @@ func (r *sqliteRepository) GetSession(token string) (session, bool, error) {
if err != nil {
return session{}, false, err
}
if gatewayUserID.Valid {
current.GatewayUserID = int(gatewayUserID.Int64)
}
if len(gatewayAccessTokenEnc) > 0 {
gatewayAccessToken, err := r.decryptSecret(gatewayAccessTokenEnc)
if err != nil {
return session{}, false, err
}
current.GatewayAccessToken = gatewayAccessToken
}
current.Token = token
current.UpstreamKey = upstreamKey
current.CreatedAt = parseRFC3339(createdAt)
@@ -248,9 +278,41 @@ func (r *sqliteRepository) RotateSession(oldToken string, ttl time.Duration) (se
if err != nil {
return session{}, false, err
}
if current.GatewayUserID > 0 && strings.TrimSpace(current.GatewayAccessToken) != "" {
if err := r.UpdateSessionGatewayBinding(next.Token, current.GatewayUserID, current.GatewayAccessToken); err != nil {
return session{}, false, err
}
next.GatewayUserID = current.GatewayUserID
next.GatewayAccessToken = current.GatewayAccessToken
}
return next, true, nil
}
func (r *sqliteRepository) UpdateSessionGatewayBinding(token string, gatewayUserID int, gatewayAccessToken string) error {
if strings.TrimSpace(token) == "" {
return errors.New("session token is required")
}
if gatewayUserID <= 0 {
return errors.New("gateway user id is required")
}
if strings.TrimSpace(gatewayAccessToken) == "" {
return errors.New("gateway access token is required")
}
enc, err := r.encryptSecret(gatewayAccessToken)
if err != nil {
return err
}
if _, err := r.db.Exec(
`UPDATE sessions SET gateway_user_id = ?, gateway_access_token_enc = ? WHERE session_id = ?`,
gatewayUserID,
enc,
token,
); err != nil {
return fmt.Errorf("update session gateway binding: %w", err)
}
return nil
}
func (r *sqliteRepository) CreateJob(record *job) (*job, int, error) {
if record == nil {
return nil, 0, errors.New("job record is required")