0.29.1原版

This commit is contained in:
anian
2026-07-02 19:14:14 +08:00
commit d94008f0fb
947 changed files with 174905 additions and 0 deletions
@@ -0,0 +1,328 @@
## Task List
**Task Index**
> T1: Add `user_identity` migrations + LATEST.sql updates for all three backends [M] — T2: Add `store.UserIdentity` model and `Store` methods + driver interface [M] — T3: Implement SQLite driver for `user_identity` [M] — T4: Implement Postgres driver for `user_identity` [M] — T5: Implement MySQL driver for `user_identity` [M] — T6: Add store-layer tests for `user_identity` [M] — T7: Add SSO username derivation helper [M] — T8: Route SSO sign-in through `user_identity` linkage [L]
### T1: Add `user_identity` migrations + LATEST.sql updates [M]
**Objective**: Create the `user_identity` persistence structure across SQLite, Postgres, and MySQL, and reflect it in `LATEST.sql` for fresh installs (G1, G2, G3, G4, G5; design §1, §5).
**Size**: M (3 new migration files, 3 LATEST.sql edits; straightforward DDL).
**Files**:
- Create: `store/migration/sqlite/0.28/00__user_identity.sql`
- Create: `store/migration/postgres/0.28/00__user_identity.sql`
- Create: `store/migration/mysql/0.28/00__user_identity.sql`
- Modify: `store/migration/sqlite/LATEST.sql`
- Modify: `store/migration/postgres/LATEST.sql`
- Modify: `store/migration/mysql/LATEST.sql`
**Implementation**:
1. `store/migration/sqlite/0.28/00__user_identity.sql`:
```sql
CREATE TABLE user_identity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL,
extern_uid TEXT NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE (provider, extern_uid)
);
CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);
```
2. `store/migration/postgres/0.28/00__user_identity.sql`: same logical schema with Postgres types — `id SERIAL PRIMARY KEY`, `user_id INTEGER NOT NULL`, `provider TEXT NOT NULL`, `extern_uid TEXT NOT NULL`, `created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `updated_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`. Include a 2-line header comment describing the table purpose (pattern-match `04__memo_share.sql`).
3. `store/migration/mysql/0.28/00__user_identity.sql`: same logical schema with MySQL syntax — backticked identifiers, `INT NOT NULL AUTO_INCREMENT PRIMARY KEY`, `VARCHAR(256)` for `provider`, `VARCHAR(256)` for `extern_uid` (so unique key fits within index limits), `BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP())` for timestamps, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`.
4. Append a `-- user_identity` section to each `LATEST.sql` mirroring the corresponding migration file (schema only, same indentation style used by neighboring tables in that file).
**Boundaries**: Must NOT alter the `user` or `idp` tables; must NOT add FK from `user_identity.provider` to `idp.uid`; must NOT add columns beyond `id`, `user_id`, `provider`, `extern_uid`, `created_ts`, `updated_ts`.
**Dependencies**: None.
**Expected Outcome**: New migration files exist; `LATEST.sql` for each backend contains a `user_identity` table block and its `user_id` index.
**Validation**:
- `rg -n "CREATE TABLE user_identity" store/migration` — expects one hit per backend in both the 0.28 migration and `LATEST.sql` (6 hits total).
- `rg -n "UNIQUE ?\\(provider, extern_uid\\)" store/migration` — expects 6 hits total.
- `go build ./...` — expects PASS (no code changes affect the build; confirms no stray syntax issues).
---
### T2: Add `store.UserIdentity` model, `Store` methods, and driver interface [M]
**Objective**: Provide a Go-level abstraction for the `user_identity` record with create/read operations wired through `store.Driver` (design §2, G3, G5).
**Size**: M (one new store file, one interface edit; simple CRUD-shaped code).
**Files**:
- Create: `store/user_identity.go`
- Modify: `store/driver.go`
**Implementation**:
1. `store/user_identity.go`:
- Types:
```go
type UserIdentity struct {
ID int32
UserID int32
Provider string
ExternUID string
CreatedTs int64
UpdatedTs int64
}
type FindUserIdentity struct {
ID *int32
UserID *int32
Provider *string
ExternUID *string
}
```
- Store methods (thin passthroughs to driver):
```go
func (s *Store) CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
func (s *Store) ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
func (s *Store) GetUserIdentity(ctx context.Context, find *FindUserIdentity) (*UserIdentity, error) // returns (nil, nil) on no match
```
- No update/delete methods in this issue (design §2: create/read only).
2. `store/driver.go`: extend the `Driver` interface with:
```go
// UserIdentity model related methods.
CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
```
`GetUserIdentity` in `store` can be implemented locally by calling `ListUserIdentities` with `Limit`-free semantics and returning the first row, matching the `GetMemoShare`/`GetIdentityProvider` pattern (no new driver method required for "get").
**Boundaries**: Must NOT add fields to `store.User` or `store.UpdateUser`; must NOT add update/delete methods.
**Dependencies**: None (T3T5 will satisfy the new interface methods).
**Expected Outcome**: `store.UserIdentity`, `FindUserIdentity`, and three `Store` methods exist; `Driver` interface declares the two new methods.
**Validation**:
- `go build ./store/...` — expects FAIL until T3T5 implement the interface on each driver. Record as expected; final pass comes at end of T5.
- `rg -n "CreateUserIdentity|ListUserIdentities" store/driver.go store/user_identity.go` — expects method declarations in both files.
---
### T3: Implement SQLite driver for `user_identity` [M]
**Objective**: Implement `CreateUserIdentity` and `ListUserIdentities` for SQLite so the interface declared in T2 is satisfied (design §2).
**Size**: M (one new driver file; mirrors existing `memo_share.go` patterns).
**Files**:
- Create: `store/db/sqlite/user_identity.go`
**Implementation**:
1. `CreateUserIdentity`:
- Insert columns `user_id`, `provider`, `extern_uid` using `?` placeholders.
- Use `RETURNING id, created_ts, updated_ts` to populate generated fields, same pattern as `store/db/sqlite/memo_share.go:24`.
- Return the passed-in `create` struct with generated fields populated, or the error from `QueryRowContext(...).Scan(...)` (unique-constraint violation surfaces to caller unchanged).
2. `ListUserIdentities`:
- `where := []string{"1 = 1"}`; append clauses for `find.ID`, `find.UserID`, `find.Provider`, `find.ExternUID` when non-nil.
- `SELECT id, user_id, provider, extern_uid, created_ts, updated_ts FROM user_identity WHERE ... ORDER BY id ASC`.
- Scan rows into `[]*store.UserIdentity`; return `[]*store.UserIdentity{}` on no rows (not nil).
**Boundaries**: Must NOT introduce transaction helpers, upsert semantics, or extra scan columns.
**Dependencies**: T2.
**Expected Outcome**: SQLite driver compiles and returns populated rows.
**Validation**:
- `go build ./store/db/sqlite/...` — expects PASS.
---
### T4: Implement Postgres driver for `user_identity` [M]
**Objective**: Mirror T3 for Postgres using `$N` placeholders and `SERIAL` semantics (design §2).
**Size**: M (one new driver file; mirrors `store/db/postgres/memo_share.go`).
**Files**:
- Create: `store/db/postgres/user_identity.go`
**Implementation**:
- Same shape as T3, but:
- Use `placeholder(n)` / `placeholders(n)` helpers from `store/db/postgres/common.go`.
- Insert stmt `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (...) RETURNING id, created_ts, updated_ts`.
- List query identical SQL shape to SQLite (no backticks in Postgres; match `memo_share.go` style).
**Boundaries**: Same as T3.
**Dependencies**: T2.
**Expected Outcome**: Postgres driver compiles.
**Validation**:
- `go build ./store/db/postgres/...` — expects PASS.
---
### T5: Implement MySQL driver for `user_identity` [M]
**Objective**: Mirror T3/T4 for MySQL, using `LastInsertId()` + re-read pattern (MySQL's driver does not support `RETURNING`; design §2).
**Size**: M (one new driver file; mirrors `store/db/mysql/memo_share.go`).
**Files**:
- Create: `store/db/mysql/user_identity.go`
**Implementation**:
- `CreateUserIdentity`:
- `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (?, ?, ?)` via `ExecContext`.
- Get `LastInsertId()`, re-fetch via `GetUserIdentity(... ID: &id)` helper (internal unexported `listUserIdentitiesByID` or reuse `ListUserIdentities` with `FindUserIdentity{ID: &id}` + take first result).
- Mirror `memo_share.go` error-handling style (return `errors.Errorf("failed to create user identity")` when re-fetch returns nil, like memo_share does).
- `ListUserIdentities`:
- Same shape as T3, using backticked column names (`` `user_id` ``, `` `provider` ``, `` `extern_uid` ``) and `?` placeholders, matching the MySQL idiom used in `memo_share.go`.
**Boundaries**: Same as T3.
**Dependencies**: T2.
**Expected Outcome**: MySQL driver compiles; full repo builds.
**Validation**:
- `go build ./...` — expects PASS (entire repo compiles with all drivers satisfying the `Driver` interface introduced in T2).
---
### T6: Add store-layer tests for `user_identity` [M]
**Objective**: Exercise create + read paths plus the `(provider, extern_uid)` uniqueness guard across the active driver (G2).
**Size**: M (one new test file; patterns match existing store tests).
**Files**:
- Create: `store/test/user_identity_test.go`
**Implementation**:
1. `TestUserIdentityCreateAndGet`:
- Create host user via `createTestingHostUser`.
- `CreateUserIdentity` with `UserID=user.ID`, `Provider="idp-uid-1"`, `ExternUID="jane@example.com"`.
- `GetUserIdentity` by `(Provider, ExternUID)` — assert match on `UserID`, `Provider`, `ExternUID`, non-zero `ID`, non-zero `CreatedTs`.
2. `TestUserIdentityListByUserID`:
- Create two identities under the same `UserID` with two different `Provider` values.
- `ListUserIdentities` by `UserID` — assert length 2.
3. `TestUserIdentityUniqueConflict`:
- Insert one row with `(Provider="idp-A", ExternUID="sub-1")`.
- Insert a second row with identical `(Provider, ExternUID)` for a different `UserID`.
- Assert the second `CreateUserIdentity` returns a non-nil error (detection via `err != nil`; do not assert message since error strings differ per backend).
4. `TestUserIdentitySameExternUIDDifferentProviders`:
- Insert `(Provider="idp-A", ExternUID="sub-1")` and `(Provider="idp-B", ExternUID="sub-1")` under the same or different users.
- Assert both inserts succeed (G2: uniqueness is scoped to the pair, not `extern_uid` alone).
**Boundaries**: Must NOT test SSO sign-in or auth service behavior; must NOT test migration contents beyond what `NewTestingStore` already executes.
**Dependencies**: T1T5.
**Expected Outcome**: All four tests pass against SQLite.
**Validation**:
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects all 4 tests PASS.
---
### T7: Add SSO username derivation helper [M]
**Objective**: Produce a valid `User.Username` for new SSO-created users from profile fields, independent of `extern_uid` (design §4).
**Size**: M (one new file with helper + small unit test; self-contained logic).
**Files**:
- Create: `server/router/api/v1/sso_username.go`
**Implementation**:
1. `deriveSSOUsername(ctx context.Context, stores *store.Store, userInfo *idp.IdentityProviderUserInfo) (string, error)`:
- Build ordered candidate list: `[userInfo.DisplayName, userInfo.Email, userInfo.Identifier]`, skipping empty values.
- For each candidate:
1. `base := normalizeToUsername(candidate)`
2. If `validateUsername(base) == nil`:
- If no existing user with `Username=base` (via `stores.GetUser(&FindUser{Username: &base})`), return `base`.
- Else: try up to N=8 suffix retries `base + "-" + randomSuffix(6)`, where the trimmed base ensures total length ≤ 36. If a candidate passes `validateUsername` and is unique, return it.
3. If all candidates are exhausted: fall back to a purely random username `"user-" + randomSuffix(10)` validated via `validateUsername`; retry up to 5 times before returning an error.
4. `normalizeToUsername(s string) string`:
- ASCII-fold / lowercase.
- Replace every character not in `[a-zA-Z0-9]` with `-`.
- Collapse consecutive `-` into one `-`.
- Trim leading/trailing `-`.
- Truncate to 36 chars, then re-trim trailing `-` so the string still ends in alphanumeric.
- Return `""` if the result is empty or fully numeric (so the caller falls through to the next candidate).
5. Use `internal/util.RandomString` for the random suffix (already imported by `auth_service.go`).
**Boundaries**: Must NOT modify `validateUsername` or `base.UIDMatcher`; must NOT write to `user_identity` or `user` directly; must NOT call `CreateUser`.
**Dependencies**: None.
**Expected Outcome**: New file `server/router/api/v1/sso_username.go` containing the exported-for-package helper `deriveSSOUsername` and internal `normalizeToUsername`.
**Validation**:
- `go build ./server/router/api/v1/...` — expects PASS.
- `go vet ./server/router/api/v1/...` — expects PASS.
---
### T8: Route SSO sign-in through `user_identity` linkage [L]
**Objective**: Replace the `FindUser{Username: &userInfo.Identifier}` lookup and `Username: userInfo.Identifier` user creation with `user_identity`-backed lookup and derived-username user creation, satisfying G1 and G2 end-to-end (design §3).
**Size**: L (non-trivial branching logic: lookup, miss path, registration gate, race recovery).
**Files**:
- Modify: `server/router/api/v1/auth_service.go`
**Implementation** (in `SignIn`, SSO branch, replacing current lines ~124173):
1. After `identifier_filter` check succeeds (existing `lines 124-133` unchanged), resolve the linkage:
```go
provider := identityProvider.Uid
externUID := userInfo.Identifier
existingIdentity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
Provider: &provider,
ExternUID: &externUID,
})
// error handling → codes.Internal
```
2. **Hit path**: if `existingIdentity != nil`, load `s.Store.GetUser(ctx, &store.FindUser{ID: &existingIdentity.UserID})`; set `existingUser`; skip creation.
3. **Miss path**: gate on `instanceGeneralSetting.DisallowUserRegistration` (reuse existing flow at current lines 143149), then:
1. `username, err := deriveSSOUsername(ctx, s.Store, userInfo)` — from T7. `codes.Internal` on error.
2. Generate random password + bcrypt hash (unchanged from current lines 160168).
3. `user, err := s.Store.CreateUser(ctx, &store.User{Username: username, Role: store.RoleUser, Nickname: userInfo.DisplayName, Email: userInfo.Email, AvatarURL: userInfo.AvatarURL, PasswordHash: string(passwordHash)})`.
4. `_, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{UserID: user.ID, Provider: provider, ExternUID: externUID})`.
5. **Race recovery**: if `CreateUserIdentity` returns an error whose message matches one of the known unique-constraint markers (`strings.Contains(err.Error(), "UNIQUE constraint failed")`, `"duplicate key"`, `"Duplicate entry")` — reusing the same pattern as `server/router/api/v1/memo_service.go:103105`):
- `_ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})` (best-effort cleanup of the provisional local user).
- Re-read the winning `user_identity` via `s.Store.GetUserIdentity(ctx, &FindUserIdentity{Provider: &provider, ExternUID: &externUID})`; if still nil, return `codes.Internal` (should not happen under correct semantics).
- Load its user via `s.Store.GetUser(ctx, &FindUser{ID: &winner.UserID})`; set `existingUser`.
6. On any other `CreateUserIdentity` error: best-effort `DeleteUser` cleanup, then return `codes.Internal`.
7. On full success: set `existingUser = user`.
4. Leave the remainder of `SignIn` (row-status check, `doSignIn`, response construction) untouched.
**Boundaries**: Must NOT touch the password-credentials branch; must NOT modify `identifier_filter` logic; must NOT touch `doSignIn`, `SignOut`, or `RefreshToken`; must NOT add new fields to `SignInRequest`/`SignInResponse`.
**Dependencies**: T2, T3, T6 minimum for SQLite confidence; T7 for the derivation helper.
**Expected Outcome**:
- Sign-in with an IdP-issued identifier that fails `base.UIDMatcher` (e.g., `jane@example.com`) succeeds: a `user_identity` row is created, and the local `User.Username` is a derived valid username.
- Repeat sign-in for the same `(provider, extern_uid)` pair loads the same user by linkage, not by username.
- Two IdPs emitting the same `extern_uid` can each link to their own local users without colliding (G2).
**Validation**:
- `go build ./...` — expects PASS.
- `go vet ./...` — expects PASS.
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects PASS (T6 regression check; ensures no store-layer drift).
## Out-of-Scope Tasks
The following are explicitly deferred per `definition.md` / `design.md` and will NOT be attempted during this execution:
- UI or API surfaces for linking/unlinking external identities.
- Update or delete paths for `user_identity` rows.
- Backfill / migration of existing users whose current `Username` matches an IdP identifier.
- Non-OAUTH2 IdP types.
- Protobuf or API changes to `SignInRequest`/`SignInResponse`.
- Adding foreign keys between `user_identity.provider` and `idp.uid`.
- Running PostgreSQL or MySQL integration tests locally (validation commands only cover SQLite, which is the default `DRIVER` in `store/test/store.go`).