Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
5d58c7c6
Commit
5d58c7c6
authored
Apr 21, 2026
by
IanShaw027
Browse files
Add auth identity legacy backfill and email sync
parent
92041457
Changes
8
Show whitespace changes
Inline
Side-by-side
backend/internal/repository/auth_identity_legacy_migration_integration_test.go
0 → 100644
View file @
5d58c7c6
//go:build integration
package
repository
import
(
"context"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/require"
)
func
TestAuthIdentityLegacyExternalBackfillMigration
(
t
*
testing
.
T
)
{
tx
:=
testTx
(
t
)
ctx
:=
context
.
Background
()
migrationPath
:=
filepath
.
Join
(
".."
,
".."
,
"migrations"
,
"115_auth_identity_legacy_external_backfill.sql"
)
migrationSQL
,
err
:=
os
.
ReadFile
(
migrationPath
)
require
.
NoError
(
t
,
err
)
_
,
err
=
tx
.
ExecContext
(
ctx
,
`
CREATE TABLE IF NOT EXISTS user_external_identities (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
provider_union_id TEXT NULL,
provider_username TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
profile_url TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
TRUNCATE TABLE
auth_identity_channels,
auth_identities,
auth_identity_migration_reports,
user_external_identities,
users
RESTART IDENTITY;
`
)
require
.
NoError
(
t
,
err
)
var
linuxDoUserID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO users (email, password_hash, role, status, balance, concurrency)
VALUES ('legacy-linuxdo@example.com', 'hash', 'user', 'active', 0, 1)
RETURNING id`
)
.
Scan
(
&
linuxDoUserID
))
var
wechatUnionUserID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO users (email, password_hash, role, status, balance, concurrency)
VALUES ('legacy-wechat-union@example.com', 'hash', 'user', 'active', 0, 1)
RETURNING id`
)
.
Scan
(
&
wechatUnionUserID
))
var
wechatOpenIDOnlyUserID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO users (email, password_hash, role, status, balance, concurrency)
VALUES ('legacy-wechat-openid@example.com', 'hash', 'user', 'active', 0, 1)
RETURNING id`
)
.
Scan
(
&
wechatOpenIDOnlyUserID
))
var
syntheticAuthIdentityID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO auth_identities (user_id, provider_type, provider_key, provider_subject, metadata)
VALUES ($1, 'wechat', 'wechat-main', 'openid-synthetic', '{"backfill_source":"synthetic_email"}'::jsonb)
RETURNING id`
,
wechatOpenIDOnlyUserID
)
.
Scan
(
&
syntheticAuthIdentityID
))
var
linuxDoLegacyID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO user_external_identities (
user_id,
provider,
provider_user_id,
provider_union_id,
provider_username,
display_name,
metadata
) VALUES ($1, 'linuxdo', 'linuxdo-user-1', NULL, 'linux-user', 'Linux User', '{"source":"legacy"}')
RETURNING id
`
,
linuxDoUserID
)
.
Scan
(
&
linuxDoLegacyID
))
var
wechatUnionLegacyID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO user_external_identities (
user_id,
provider,
provider_user_id,
provider_union_id,
provider_username,
display_name,
metadata
) VALUES ($1, 'wechat', 'openid-union-1', 'union-1', 'wechat-union-user', 'WeChat Union User', '{"channel":"oa","appid":"wx-app-1"}')
RETURNING id
`
,
wechatUnionUserID
)
.
Scan
(
&
wechatUnionLegacyID
))
var
wechatOpenIDLegacyID
int64
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
INSERT INTO user_external_identities (
user_id,
provider,
provider_user_id,
provider_union_id,
provider_username,
display_name,
metadata
) VALUES ($1, 'wechat', 'openid-only-1', NULL, 'wechat-openid-user', 'WeChat OpenID User', '{"channel":"oa","appid":"wx-app-2"}')
RETURNING id
`
,
wechatOpenIDOnlyUserID
)
.
Scan
(
&
wechatOpenIDLegacyID
))
_
,
err
=
tx
.
ExecContext
(
ctx
,
string
(
migrationSQL
))
require
.
NoError
(
t
,
err
)
var
linuxDoCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identities
WHERE user_id = $1
AND provider_type = 'linuxdo'
AND provider_key = 'linuxdo'
AND provider_subject = 'linuxdo-user-1'
`
,
linuxDoUserID
)
.
Scan
(
&
linuxDoCount
))
require
.
Equal
(
t
,
1
,
linuxDoCount
)
var
wechatSubject
string
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT provider_subject
FROM auth_identities
WHERE user_id = $1
AND provider_type = 'wechat'
AND provider_key = 'wechat-main'
AND provider_subject = 'union-1'
`
,
wechatUnionUserID
)
.
Scan
(
&
wechatSubject
))
require
.
Equal
(
t
,
"union-1"
,
wechatSubject
)
var
wechatChannelCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identity_channels channel
JOIN auth_identities ai ON ai.id = channel.identity_id
WHERE ai.user_id = $1
AND channel.provider_type = 'wechat'
AND channel.provider_key = 'wechat-main'
AND channel.channel = 'oa'
AND channel.channel_app_id = 'wx-app-1'
AND channel.channel_subject = 'openid-union-1'
`
,
wechatUnionUserID
)
.
Scan
(
&
wechatChannelCount
))
require
.
Equal
(
t
,
1
,
wechatChannelCount
)
var
legacyOpenIDOnlyReportCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identity_migration_reports
WHERE report_type = 'wechat_openid_only_requires_remediation'
AND report_key = $1
`
,
"legacy_external_identity:"
+
strconv
.
FormatInt
(
wechatOpenIDLegacyID
,
10
))
.
Scan
(
&
legacyOpenIDOnlyReportCount
))
require
.
Equal
(
t
,
1
,
legacyOpenIDOnlyReportCount
)
var
syntheticReviewCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identity_migration_reports
WHERE report_type = 'wechat_openid_only_requires_remediation'
AND report_key = $1
`
,
"synthetic_auth_identity:"
+
strconv
.
FormatInt
(
syntheticAuthIdentityID
,
10
))
.
Scan
(
&
syntheticReviewCount
))
require
.
Equal
(
t
,
1
,
syntheticReviewCount
)
var
unionLegacyReportCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identity_migration_reports
WHERE report_type = 'wechat_openid_only_requires_remediation'
AND report_key = $1
`
,
"legacy_external_identity:"
+
strconv
.
FormatInt
(
wechatUnionLegacyID
,
10
))
.
Scan
(
&
unionLegacyReportCount
))
require
.
Zero
(
t
,
unionLegacyReportCount
)
require
.
NotZero
(
t
,
linuxDoLegacyID
)
}
func
TestAuthIdentityLegacyExternalBackfillMigration_IsSafeWhenLegacyTableMissing
(
t
*
testing
.
T
)
{
tx
:=
testTx
(
t
)
ctx
:=
context
.
Background
()
migrationPath
:=
filepath
.
Join
(
".."
,
".."
,
"migrations"
,
"115_auth_identity_legacy_external_backfill.sql"
)
migrationSQL
,
err
:=
os
.
ReadFile
(
migrationPath
)
require
.
NoError
(
t
,
err
)
var
beforeCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identity_migration_reports
`
)
.
Scan
(
&
beforeCount
))
_
,
err
=
tx
.
ExecContext
(
ctx
,
string
(
migrationSQL
))
require
.
NoError
(
t
,
err
)
var
afterCount
int
require
.
NoError
(
t
,
tx
.
QueryRowContext
(
ctx
,
`
SELECT COUNT(*)
FROM auth_identity_migration_reports
`
)
.
Scan
(
&
afterCount
))
require
.
Equal
(
t
,
beforeCount
,
afterCount
)
}
backend/internal/repository/user_repo.go
View file @
5d58c7c6
...
@@ -11,6 +11,7 @@ import (
...
@@ -11,6 +11,7 @@ import (
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
dbgroup
"github.com/Wei-Shaw/sub2api/ent/group"
dbgroup
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/predicate"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
...
@@ -76,6 +77,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
...
@@ -76,6 +77,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
if
err
:=
r
.
syncUserAllowedGroupsWithClient
(
ctx
,
txClient
,
created
.
ID
,
userIn
.
AllowedGroups
);
err
!=
nil
{
if
err
:=
r
.
syncUserAllowedGroupsWithClient
(
ctx
,
txClient
,
created
.
ID
,
userIn
.
AllowedGroups
);
err
!=
nil
{
return
err
return
err
}
}
if
err
:=
ensureEmailAuthIdentityWithClient
(
ctx
,
txClient
,
created
.
ID
,
created
.
Email
,
"user_repo_create"
);
err
!=
nil
{
return
err
}
if
tx
!=
nil
{
if
tx
!=
nil
{
if
err
:=
tx
.
Commit
();
err
!=
nil
{
if
err
:=
tx
.
Commit
();
err
!=
nil
{
...
@@ -150,6 +154,11 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
...
@@ -150,6 +154,11 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
// 已处于外部事务中(ErrTxStarted),复用当前 client 并由调用方负责提交/回滚。
// 已处于外部事务中(ErrTxStarted),复用当前 client 并由调用方负责提交/回滚。
txClient
=
r
.
client
txClient
=
r
.
client
}
}
existing
,
err
:=
clientFromContext
(
ctx
,
txClient
)
.
User
.
Get
(
ctx
,
userIn
.
ID
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
oldEmail
:=
existing
.
Email
updateOp
:=
txClient
.
User
.
UpdateOneID
(
userIn
.
ID
)
.
updateOp
:=
txClient
.
User
.
UpdateOneID
(
userIn
.
ID
)
.
SetEmail
(
userIn
.
Email
)
.
SetEmail
(
userIn
.
Email
)
.
...
@@ -185,6 +194,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
...
@@ -185,6 +194,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
if
err
:=
r
.
syncUserAllowedGroupsWithClient
(
ctx
,
txClient
,
updated
.
ID
,
userIn
.
AllowedGroups
);
err
!=
nil
{
if
err
:=
r
.
syncUserAllowedGroupsWithClient
(
ctx
,
txClient
,
updated
.
ID
,
userIn
.
AllowedGroups
);
err
!=
nil
{
return
err
return
err
}
}
if
err
:=
replaceEmailAuthIdentityWithClient
(
ctx
,
txClient
,
updated
.
ID
,
oldEmail
,
updated
.
Email
,
"user_repo_update"
);
err
!=
nil
{
return
err
}
if
tx
!=
nil
{
if
tx
!=
nil
{
if
err
:=
tx
.
Commit
();
err
!=
nil
{
if
err
:=
tx
.
Commit
();
err
!=
nil
{
...
@@ -196,6 +208,96 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
...
@@ -196,6 +208,96 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
return
nil
return
nil
}
}
func
(
r
*
userRepository
)
EnsureEmailAuthIdentity
(
ctx
context
.
Context
,
userID
int64
,
email
string
)
error
{
return
ensureEmailAuthIdentityWithClient
(
ctx
,
r
.
client
,
userID
,
email
,
"service_dual_write"
)
}
func
(
r
*
userRepository
)
ReplaceEmailAuthIdentity
(
ctx
context
.
Context
,
userID
int64
,
oldEmail
,
newEmail
string
)
error
{
return
replaceEmailAuthIdentityWithClient
(
ctx
,
r
.
client
,
userID
,
oldEmail
,
newEmail
,
"service_dual_write"
)
}
func
ensureEmailAuthIdentityWithClient
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
userID
int64
,
email
string
,
source
string
)
error
{
client
=
clientFromContext
(
ctx
,
client
)
if
client
==
nil
||
userID
<=
0
{
return
nil
}
subject
:=
normalizeEmailAuthIdentitySubject
(
email
)
if
subject
==
""
{
return
nil
}
if
err
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
userID
)
.
SetProviderType
(
"email"
)
.
SetProviderKey
(
"email"
)
.
SetProviderSubject
(
subject
)
.
SetVerifiedAt
(
time
.
Now
()
.
UTC
())
.
SetMetadata
(
map
[
string
]
any
{
"source"
:
source
})
.
OnConflictColumns
(
authidentity
.
FieldProviderType
,
authidentity
.
FieldProviderKey
,
authidentity
.
FieldProviderSubject
,
)
.
DoNothing
()
.
Exec
(
ctx
);
err
!=
nil
{
return
err
}
identity
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
"email"
),
authidentity
.
ProviderKeyEQ
(
"email"
),
authidentity
.
ProviderSubjectEQ
(
subject
),
)
.
Only
(
ctx
)
if
err
!=
nil
{
if
dbent
.
IsNotFound
(
err
)
{
return
nil
}
return
err
}
if
identity
.
UserID
!=
userID
{
return
ErrAuthIdentityOwnershipConflict
}
return
nil
}
func
replaceEmailAuthIdentityWithClient
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
userID
int64
,
oldEmail
,
newEmail
string
,
source
string
)
error
{
newSubject
:=
normalizeEmailAuthIdentitySubject
(
newEmail
)
if
err
:=
ensureEmailAuthIdentityWithClient
(
ctx
,
client
,
userID
,
newEmail
,
source
);
err
!=
nil
{
return
err
}
oldSubject
:=
normalizeEmailAuthIdentitySubject
(
oldEmail
)
if
oldSubject
==
""
||
oldSubject
==
newSubject
{
return
nil
}
_
,
err
:=
clientFromContext
(
ctx
,
client
)
.
AuthIdentity
.
Delete
()
.
Where
(
authidentity
.
UserIDEQ
(
userID
),
authidentity
.
ProviderTypeEQ
(
"email"
),
authidentity
.
ProviderKeyEQ
(
"email"
),
authidentity
.
ProviderSubjectEQ
(
oldSubject
),
)
.
Exec
(
ctx
)
return
err
}
func
normalizeEmailAuthIdentitySubject
(
email
string
)
string
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
if
normalized
==
""
{
return
""
}
if
strings
.
HasSuffix
(
normalized
,
service
.
LinuxDoConnectSyntheticEmailDomain
)
||
strings
.
HasSuffix
(
normalized
,
service
.
OIDCConnectSyntheticEmailDomain
)
||
strings
.
HasSuffix
(
normalized
,
service
.
WeChatConnectSyntheticEmailDomain
)
{
return
""
}
return
normalized
}
func
(
r
*
userRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
func
(
r
*
userRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
affected
,
err
:=
r
.
client
.
User
.
Delete
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
Exec
(
ctx
)
affected
,
err
:=
r
.
client
.
User
.
Delete
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
Exec
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
...
...
backend/internal/repository/user_repo_email_identity_integration_test.go
0 → 100644
View file @
5d58c7c6
//go:build integration
package
repository
import
(
"context"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func
(
s
*
UserRepoSuite
)
TestCreate_CreatesEmailAuthIdentityForNormalEmail
()
{
user
:=
&
service
.
User
{
Email
:
"repo-create@example.com"
,
PasswordHash
:
"test-password-hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
Concurrency
:
2
,
}
s
.
Require
()
.
NoError
(
s
.
repo
.
Create
(
s
.
ctx
,
user
))
identity
,
err
:=
s
.
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
user
.
ID
),
authidentity
.
ProviderTypeEQ
(
"email"
),
authidentity
.
ProviderKeyEQ
(
"email"
),
authidentity
.
ProviderSubjectEQ
(
"repo-create@example.com"
),
)
.
Only
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Equal
(
user
.
ID
,
identity
.
UserID
)
}
func
(
s
*
UserRepoSuite
)
TestCreate_SkipsEmailAuthIdentityForSyntheticLinuxDoEmail
()
{
user
:=
&
service
.
User
{
Email
:
"linuxdo-legacy-user@linuxdo-connect.invalid"
,
PasswordHash
:
"test-password-hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
Concurrency
:
2
,
}
s
.
Require
()
.
NoError
(
s
.
repo
.
Create
(
s
.
ctx
,
user
))
count
,
err
:=
s
.
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
user
.
ID
),
authidentity
.
ProviderTypeEQ
(
"email"
),
authidentity
.
ProviderKeyEQ
(
"email"
),
)
.
Count
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Zero
(
count
)
}
func
(
s
*
UserRepoSuite
)
TestUpdate_ReplacesEmailAuthIdentityWhenEmailChanges
()
{
user
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"before-update@example.com"
,
})
user
.
Email
=
"after-update@example.com"
s
.
Require
()
.
NoError
(
s
.
repo
.
Update
(
s
.
ctx
,
user
))
newIdentity
,
err
:=
s
.
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
user
.
ID
),
authidentity
.
ProviderTypeEQ
(
"email"
),
authidentity
.
ProviderKeyEQ
(
"email"
),
authidentity
.
ProviderSubjectEQ
(
"after-update@example.com"
),
)
.
Only
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Equal
(
user
.
ID
,
newIdentity
.
UserID
)
oldCount
,
err
:=
s
.
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
user
.
ID
),
authidentity
.
ProviderTypeEQ
(
"email"
),
authidentity
.
ProviderKeyEQ
(
"email"
),
authidentity
.
ProviderSubjectEQ
(
"before-update@example.com"
),
)
.
Count
(
context
.
Background
())
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Zero
(
oldCount
)
}
backend/internal/repository/user_repo_integration_test.go
View file @
5d58c7c6
...
@@ -26,6 +26,8 @@ func (s *UserRepoSuite) SetupTest() {
...
@@ -26,6 +26,8 @@ func (s *UserRepoSuite) SetupTest() {
s
.
repo
=
newUserRepositoryWithSQL
(
s
.
client
,
integrationDB
)
s
.
repo
=
newUserRepositoryWithSQL
(
s
.
client
,
integrationDB
)
// 清理测试数据,确保每个测试从干净状态开始
// 清理测试数据,确保每个测试从干净状态开始
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM auth_identity_channels"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM auth_identities"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM user_subscriptions"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM user_subscriptions"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM user_allowed_groups"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM user_allowed_groups"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM users"
)
_
,
_
=
integrationDB
.
ExecContext
(
s
.
ctx
,
"DELETE FROM users"
)
...
...
backend/internal/service/admin_service.go
View file @
5d58c7c6
...
@@ -630,6 +630,9 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
...
@@ -630,6 +630,9 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
if
err
:=
s
.
userRepo
.
Create
(
ctx
,
user
);
err
!=
nil
{
if
err
:=
s
.
userRepo
.
Create
(
ctx
,
user
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
if
err
:=
ensureEmailAuthIdentitySync
(
ctx
,
s
.
userRepo
,
user
.
ID
,
user
.
Email
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"sync email auth identity: %w"
,
err
)
}
s
.
assignDefaultSubscriptions
(
ctx
,
user
.
ID
)
s
.
assignDefaultSubscriptions
(
ctx
,
user
.
ID
)
return
user
,
nil
return
user
,
nil
}
}
...
@@ -665,6 +668,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
...
@@ -665,6 +668,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
oldConcurrency
:=
user
.
Concurrency
oldConcurrency
:=
user
.
Concurrency
oldStatus
:=
user
.
Status
oldStatus
:=
user
.
Status
oldRole
:=
user
.
Role
oldRole
:=
user
.
Role
oldEmail
:=
user
.
Email
if
input
.
Email
!=
""
{
if
input
.
Email
!=
""
{
user
.
Email
=
input
.
Email
user
.
Email
=
input
.
Email
...
@@ -697,6 +701,9 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
...
@@ -697,6 +701,9 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
if
err
:=
s
.
userRepo
.
Update
(
ctx
,
user
);
err
!=
nil
{
if
err
:=
s
.
userRepo
.
Update
(
ctx
,
user
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
if
err
:=
replaceEmailAuthIdentitySync
(
ctx
,
s
.
userRepo
,
user
.
ID
,
oldEmail
,
user
.
Email
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"sync email auth identity: %w"
,
err
)
}
// 同步用户专属分组倍率
// 同步用户专属分组倍率
if
input
.
GroupRates
!=
nil
&&
s
.
userGroupRateRepo
!=
nil
{
if
input
.
GroupRates
!=
nil
&&
s
.
userGroupRateRepo
!=
nil
{
...
...
backend/internal/service/admin_service_email_identity_sync_test.go
0 → 100644
View file @
5d58c7c6
//go:build unit
package
service
import
(
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type
ensureEmailCall
struct
{
userID
int64
email
string
}
type
replaceEmailCall
struct
{
userID
int64
oldEmail
string
newEmail
string
}
type
emailSyncUserRepoStub
struct
{
*
userRepoStub
ensureCalls
[]
ensureEmailCall
replaceCalls
[]
replaceEmailCall
}
func
(
s
*
emailSyncUserRepoStub
)
EnsureEmailAuthIdentity
(
_
context
.
Context
,
userID
int64
,
email
string
)
error
{
s
.
ensureCalls
=
append
(
s
.
ensureCalls
,
ensureEmailCall
{
userID
:
userID
,
email
:
email
})
return
nil
}
func
(
s
*
emailSyncUserRepoStub
)
ReplaceEmailAuthIdentity
(
_
context
.
Context
,
userID
int64
,
oldEmail
,
newEmail
string
)
error
{
s
.
replaceCalls
=
append
(
s
.
replaceCalls
,
replaceEmailCall
{
userID
:
userID
,
oldEmail
:
oldEmail
,
newEmail
:
newEmail
,
})
return
nil
}
func
(
s
*
emailSyncUserRepoStub
)
GetLatestUsedAtByUserIDs
(
context
.
Context
,
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
return
map
[
int64
]
*
time
.
Time
{},
nil
}
func
(
s
*
emailSyncUserRepoStub
)
GetLatestUsedAtByUserID
(
context
.
Context
,
int64
)
(
*
time
.
Time
,
error
)
{
return
nil
,
nil
}
func
TestAdminService_CreateUser_EnsuresEmailAuthIdentity
(
t
*
testing
.
T
)
{
repo
:=
&
emailSyncUserRepoStub
{
userRepoStub
:
&
userRepoStub
{
nextID
:
55
}}
svc
:=
&
adminServiceImpl
{
userRepo
:
repo
}
user
,
err
:=
svc
.
CreateUser
(
context
.
Background
(),
&
CreateUserInput
{
Email
:
"admin-created@example.com"
,
Password
:
"strong-pass"
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
user
)
require
.
Equal
(
t
,
[]
ensureEmailCall
{{
userID
:
55
,
email
:
"admin-created@example.com"
,
}},
repo
.
ensureCalls
)
require
.
Empty
(
t
,
repo
.
replaceCalls
)
}
func
TestAdminService_UpdateUser_ReplacesEmailAuthIdentity
(
t
*
testing
.
T
)
{
repo
:=
&
emailSyncUserRepoStub
{
userRepoStub
:
&
userRepoStub
{
user
:
&
User
{
ID
:
91
,
Email
:
"before@example.com"
,
Role
:
RoleUser
,
Status
:
StatusActive
,
Concurrency
:
3
,
},
},
}
svc
:=
&
adminServiceImpl
{
userRepo
:
repo
}
updated
,
err
:=
svc
.
UpdateUser
(
context
.
Background
(),
91
,
&
UpdateUserInput
{
Email
:
"after@example.com"
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
updated
)
require
.
Equal
(
t
,
"after@example.com"
,
updated
.
Email
)
require
.
Equal
(
t
,
[]
replaceEmailCall
{{
userID
:
91
,
oldEmail
:
"before@example.com"
,
newEmail
:
"after@example.com"
,
}},
repo
.
replaceCalls
)
require
.
Empty
(
t
,
repo
.
ensureCalls
)
}
backend/internal/service/user_service_email_identity_sync_test.go
0 → 100644
View file @
5d58c7c6
//go:build unit
package
service
import
(
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type
emailSyncMockUserRepo
struct
{
*
mockUserRepo
ensureCalls
[]
ensureEmailCall
replaceCalls
[]
replaceEmailCall
}
func
(
m
*
emailSyncMockUserRepo
)
EnsureEmailAuthIdentity
(
_
context
.
Context
,
userID
int64
,
email
string
)
error
{
m
.
ensureCalls
=
append
(
m
.
ensureCalls
,
ensureEmailCall
{
userID
:
userID
,
email
:
email
})
return
nil
}
func
(
m
*
emailSyncMockUserRepo
)
ReplaceEmailAuthIdentity
(
_
context
.
Context
,
userID
int64
,
oldEmail
,
newEmail
string
)
error
{
m
.
replaceCalls
=
append
(
m
.
replaceCalls
,
replaceEmailCall
{
userID
:
userID
,
oldEmail
:
oldEmail
,
newEmail
:
newEmail
,
})
return
nil
}
func
(
m
*
emailSyncMockUserRepo
)
GetLatestUsedAtByUserIDs
(
context
.
Context
,
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
return
map
[
int64
]
*
time
.
Time
{},
nil
}
func
(
m
*
emailSyncMockUserRepo
)
GetLatestUsedAtByUserID
(
context
.
Context
,
int64
)
(
*
time
.
Time
,
error
)
{
return
nil
,
nil
}
func
TestUpdateProfile_ReplacesEmailAuthIdentityWhenEmailChanges
(
t
*
testing
.
T
)
{
repo
:=
&
emailSyncMockUserRepo
{
mockUserRepo
:
&
mockUserRepo
{
getByIDUser
:
&
User
{
ID
:
19
,
Email
:
"profile-before@example.com"
,
Username
:
"tester"
,
Concurrency
:
2
,
},
},
}
svc
:=
NewUserService
(
repo
,
nil
,
nil
,
nil
)
newEmail
:=
"profile-after@example.com"
updated
,
err
:=
svc
.
UpdateProfile
(
context
.
Background
(),
19
,
UpdateProfileRequest
{
Email
:
&
newEmail
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
updated
)
require
.
Equal
(
t
,
newEmail
,
updated
.
Email
)
require
.
Equal
(
t
,
1
,
repo
.
updateCalls
)
require
.
Equal
(
t
,
[]
replaceEmailCall
{{
userID
:
19
,
oldEmail
:
"profile-before@example.com"
,
newEmail
:
"profile-after@example.com"
,
}},
repo
.
replaceCalls
)
require
.
Empty
(
t
,
repo
.
ensureCalls
)
}
backend/migrations/115_auth_identity_legacy_external_backfill.sql
0 → 100644
View file @
5d58c7c6
DO
$$
BEGIN
IF
to_regclass
(
'public.user_external_identities'
)
IS
NULL
THEN
RETURN
;
END
IF
;
EXECUTE
$
sql
$
INSERT
INTO
auth_identities
(
user_id
,
provider_type
,
provider_key
,
provider_subject
,
verified_at
,
metadata
)
SELECT
legacy
.
user_id
,
'linuxdo'
,
'linuxdo'
,
legacy
.
provider_user_id
,
COALESCE
(
legacy
.
updated_at
,
legacy
.
created_at
,
NOW
()),
legacy
.
metadata_json
||
jsonb_build_object
(
'legacy_identity_id'
,
legacy
.
id
,
'provider_user_id'
,
legacy
.
provider_user_id
,
'provider_username'
,
legacy
.
provider_username
,
'display_name'
,
legacy
.
display_name
,
'migration'
,
'115_auth_identity_legacy_external_backfill'
)
FROM
(
SELECT
uei
.
id
,
uei
.
user_id
,
BTRIM
(
uei
.
provider_user_id
)
AS
provider_user_id
,
BTRIM
(
uei
.
provider_username
)
AS
provider_username
,
BTRIM
(
uei
.
display_name
)
AS
display_name
,
COALESCE
(
NULLIF
(
BTRIM
(
COALESCE
(
uei
.
metadata
,
''
)),
''
)::
jsonb
,
'{}'
::
jsonb
)
AS
metadata_json
,
uei
.
created_at
,
uei
.
updated_at
FROM
user_external_identities
AS
uei
JOIN
users
AS
u
ON
u
.
id
=
uei
.
user_id
WHERE
u
.
deleted_at
IS
NULL
AND
LOWER
(
BTRIM
(
COALESCE
(
uei
.
provider
,
''
)))
=
'linuxdo'
AND
BTRIM
(
COALESCE
(
uei
.
provider_user_id
,
''
))
<>
''
)
AS
legacy
ON
CONFLICT
(
provider_type
,
provider_key
,
provider_subject
)
DO
NOTHING
;
$
sql
$
;
EXECUTE
$
sql
$
INSERT
INTO
auth_identities
(
user_id
,
provider_type
,
provider_key
,
provider_subject
,
verified_at
,
metadata
)
SELECT
legacy
.
user_id
,
'wechat'
,
'wechat-main'
,
legacy
.
provider_union_id
,
COALESCE
(
legacy
.
updated_at
,
legacy
.
created_at
,
NOW
()),
legacy
.
metadata_json
||
jsonb_build_object
(
'legacy_identity_id'
,
legacy
.
id
,
'openid'
,
legacy
.
provider_user_id
,
'unionid'
,
legacy
.
provider_union_id
,
'provider_user_id'
,
legacy
.
provider_user_id
,
'provider_union_id'
,
legacy
.
provider_union_id
,
'provider_username'
,
legacy
.
provider_username
,
'display_name'
,
legacy
.
display_name
,
'migration'
,
'115_auth_identity_legacy_external_backfill'
)
FROM
(
SELECT
uei
.
id
,
uei
.
user_id
,
BTRIM
(
uei
.
provider_user_id
)
AS
provider_user_id
,
BTRIM
(
uei
.
provider_union_id
)
AS
provider_union_id
,
BTRIM
(
uei
.
provider_username
)
AS
provider_username
,
BTRIM
(
uei
.
display_name
)
AS
display_name
,
COALESCE
(
NULLIF
(
BTRIM
(
COALESCE
(
uei
.
metadata
,
''
)),
''
)::
jsonb
,
'{}'
::
jsonb
)
AS
metadata_json
,
uei
.
created_at
,
uei
.
updated_at
FROM
user_external_identities
AS
uei
JOIN
users
AS
u
ON
u
.
id
=
uei
.
user_id
WHERE
u
.
deleted_at
IS
NULL
AND
LOWER
(
BTRIM
(
COALESCE
(
uei
.
provider
,
''
)))
=
'wechat'
AND
BTRIM
(
COALESCE
(
uei
.
provider_union_id
,
''
))
<>
''
)
AS
legacy
ON
CONFLICT
(
provider_type
,
provider_key
,
provider_subject
)
DO
NOTHING
;
$
sql
$
;
EXECUTE
$
sql
$
INSERT
INTO
auth_identity_channels
(
identity_id
,
provider_type
,
provider_key
,
channel
,
channel_app_id
,
channel_subject
,
metadata
)
SELECT
ai
.
id
,
'wechat'
,
'wechat-main'
,
legacy
.
channel
,
legacy
.
channel_app_id
,
legacy
.
provider_user_id
,
legacy
.
metadata_json
||
jsonb_build_object
(
'openid'
,
legacy
.
provider_user_id
,
'unionid'
,
legacy
.
provider_union_id
,
'migration'
,
'115_auth_identity_legacy_external_backfill'
)
FROM
(
SELECT
uei
.
user_id
,
BTRIM
(
uei
.
provider_user_id
)
AS
provider_user_id
,
BTRIM
(
uei
.
provider_union_id
)
AS
provider_union_id
,
BTRIM
(
COALESCE
(
meta
.
metadata_json
->>
'channel'
,
''
))
AS
channel
,
BTRIM
(
COALESCE
(
meta
.
metadata_json
->>
'channel_app_id'
,
meta
.
metadata_json
->>
'appid'
,
meta
.
metadata_json
->>
'app_id'
,
''
))
AS
channel_app_id
,
meta
.
metadata_json
FROM
user_external_identities
AS
uei
JOIN
users
AS
u
ON
u
.
id
=
uei
.
user_id
CROSS
JOIN
LATERAL
(
SELECT
COALESCE
(
NULLIF
(
BTRIM
(
COALESCE
(
uei
.
metadata
,
''
)),
''
)::
jsonb
,
'{}'
::
jsonb
)
AS
metadata_json
)
AS
meta
WHERE
u
.
deleted_at
IS
NULL
AND
LOWER
(
BTRIM
(
COALESCE
(
uei
.
provider
,
''
)))
=
'wechat'
AND
BTRIM
(
COALESCE
(
uei
.
provider_union_id
,
''
))
<>
''
)
AS
legacy
JOIN
auth_identities
AS
ai
ON
ai
.
user_id
=
legacy
.
user_id
AND
ai
.
provider_type
=
'wechat'
AND
ai
.
provider_key
=
'wechat-main'
AND
ai
.
provider_subject
=
legacy
.
provider_union_id
WHERE
legacy
.
channel
<>
''
AND
legacy
.
channel_app_id
<>
''
AND
legacy
.
provider_user_id
<>
''
ON
CONFLICT
DO
NOTHING
;
$
sql
$
;
EXECUTE
$
sql
$
INSERT
INTO
auth_identity_migration_reports
(
report_type
,
report_key
,
details
)
SELECT
'wechat_openid_only_requires_remediation'
,
'legacy_external_identity:'
||
legacy
.
id
::
text
,
legacy
.
metadata_json
||
jsonb_build_object
(
'legacy_identity_id'
,
legacy
.
id
,
'user_id'
,
legacy
.
user_id
,
'openid'
,
legacy
.
provider_user_id
,
'reason'
,
'legacy user_external_identities row only has openid and cannot be canonicalized offline'
,
'migration'
,
'115_auth_identity_legacy_external_backfill'
)
FROM
(
SELECT
uei
.
id
,
uei
.
user_id
,
BTRIM
(
uei
.
provider_user_id
)
AS
provider_user_id
,
COALESCE
(
NULLIF
(
BTRIM
(
COALESCE
(
uei
.
metadata
,
''
)),
''
)::
jsonb
,
'{}'
::
jsonb
)
AS
metadata_json
FROM
user_external_identities
AS
uei
JOIN
users
AS
u
ON
u
.
id
=
uei
.
user_id
WHERE
u
.
deleted_at
IS
NULL
AND
LOWER
(
BTRIM
(
COALESCE
(
uei
.
provider
,
''
)))
=
'wechat'
AND
BTRIM
(
COALESCE
(
uei
.
provider_user_id
,
''
))
<>
''
AND
BTRIM
(
COALESCE
(
uei
.
provider_union_id
,
''
))
=
''
)
AS
legacy
ON
CONFLICT
(
report_type
,
report_key
)
DO
NOTHING
;
$
sql
$
;
END
$$
;
INSERT
INTO
auth_identity_migration_reports
(
report_type
,
report_key
,
details
)
SELECT
'wechat_openid_only_requires_remediation'
,
'synthetic_auth_identity:'
||
ai
.
id
::
text
,
COALESCE
(
ai
.
metadata
,
'{}'
::
jsonb
)
||
jsonb_build_object
(
'auth_identity_id'
,
ai
.
id
,
'user_id'
,
ai
.
user_id
,
'provider_subject'
,
ai
.
provider_subject
,
'reason'
,
'synthetic wechat auth identity still lacks unionid metadata and needs remediation'
,
'migration'
,
'115_auth_identity_legacy_external_backfill'
)
FROM
auth_identities
AS
ai
WHERE
ai
.
provider_type
=
'wechat'
AND
COALESCE
(
ai
.
metadata
->>
'backfill_source'
,
''
)
=
'synthetic_email'
AND
BTRIM
(
COALESCE
(
ai
.
metadata
->>
'unionid'
,
''
))
=
''
ON
CONFLICT
(
report_type
,
report_key
)
DO
NOTHING
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment