This commit is contained in:
zhx 2026-06-11 01:02:20 +08:00
parent ebd14c7f85
commit ecf03ae362
41 changed files with 1825 additions and 728 deletions

View File

@ -287,7 +287,6 @@ services:
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro

View File

@ -19,7 +19,6 @@ SQL_FILES=(
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/006_admin_database.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/008_remove_taiwan_country.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"

View File

@ -1,22 +1,19 @@
CREATE TABLE IF NOT EXISTS bd_leader_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_leader_profiles_region_status (app_code, region_id, status)
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表';
INSERT INTO bd_leader_profiles (
app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms
)
SELECT app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
SELECT app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms
FROM bd_profiles
WHERE role = 'bd_leader'
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
created_by_user_id = VALUES(created_by_user_id),
created_at_ms = VALUES(created_at_ms),

View File

@ -0,0 +1,104 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- BD/Agency/Host 的区域统一由 users 当前区域或用户国家映射区域派生。
-- 这些身份表里的 region_id 是旧快照,先删除依赖索引,再删除列,避免后续继续写入或误读。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_profiles' AND INDEX_NAME = 'idx_host_profiles_region_status') > 0,
'ALTER TABLE host_profiles DROP INDEX idx_host_profiles_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND INDEX_NAME = 'idx_bd_profiles_region_role') > 0,
'ALTER TABLE bd_profiles DROP INDEX idx_bd_profiles_region_role',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_leader_profiles' AND INDEX_NAME = 'idx_bd_leader_profiles_region_status') > 0,
'ALTER TABLE bd_leader_profiles DROP INDEX idx_bd_leader_profiles_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agencies' AND INDEX_NAME = 'idx_agencies_region_status') > 0,
'ALTER TABLE agencies DROP INDEX idx_agencies_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE host_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE bd_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_leader_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE bd_leader_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agencies' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agencies DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_memberships' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agency_memberships DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_applications' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agency_applications DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE role_invitations DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -231,31 +231,37 @@ CREATE TABLE pretty_display_id_generation_batches (
## 靓号池生成规则
靓号池批量生成首版支持 `aabbcc`
- 必须是 6 位数字。
- 第 1、2 位相同。
- 第 3、4 位相同。
- 第 5、6 位相同。
- 第 1 位不能是 `0`
- 生成后仍然复用数字短号格式校验,避免生成出 App 用户难以输入的池号码。
示例:
```text
112233
223344
667788
```
后续可以扩展:
靓号池批量生成支持数字规则、日期规则、寓意号和自定义规则。数字规则允许前导 0号码以字符串保存。
| rule_type | 说明 |
| --- | --- |
| `aa``aaa``aaaa``aaaaa``aaaaaa``aaaaaaa` | 2A 到 7A 连号 |
| `aabb` | 两组对子 |
| `aabbcc` | 三组重复数字 |
| `aaaaaa` | 6 位相同数字 |
| `abcabc` | 前三位重复一次 |
| `manual` | 后台手动导入号码列表 |
| `aabbccdd` | 四组对子 |
| `abab``ababab` | 交替号 |
| `abba``abcba``abccba` | 回文号 |
| `aaab``aaaab``aaaaab` | 拖一号 |
| `aaabbb``aaaabbbb` | 双组号 |
| `abc``abcd``abcde``abcdef` | 顺子号 |
| `cba``dcba``edcba``fedcba` | 倒顺号 |
| `abcabc` | 顺子重复号 |
| `date_yyyymmdd``date_yymmdd` | 日期号 |
| `love` | 520、521、1314 等寓意号 |
| `custom_values` | 使用 `rule_config_json.values` 固定候选 |
| `custom_pattern` | 使用 `rule_config_json.pattern` 自定义模式 |
`rule_config_json` 可选字段:
| 字段 | 说明 |
| --- | --- |
| `digits` | 限制可用数字,例如 `["6","8","9"]` |
| `exclude_digits` | 排除数字,例如 `["4"]` |
| `prefix``suffix` | 给生成候选加前缀或后缀 |
| `values` | `custom_values` 的候选数组 |
| `pattern` | `custom_pattern` 的模式,例如 `AAAABBBB` |
| `year_from``year_to` | 日期号年份范围 |
| `start_date``end_date` | 日期号精确范围,格式 `yyyy-mm-dd` |
后台直接发放靓号不受本节数字生成规则限制,按“现有短号表扩展”里的后台发放字符规则校验。
@ -618,7 +624,7 @@ message UserIdentity {
- 用户等级低于池区间时,申请返回 `PRETTY_ID_LEVEL_NOT_MATCH`
- 用户等级在池区间内时,申请成功并更新:
- `pretty_display_ids.status='assigned'`
- `pretty_display_user_id_leases.source='level_pool'`
- `pretty_display_user_id_leases.source='pool'`
- `user_display_user_ids.display_user_id_kind='pretty'`
- `users.current_display_user_id_kind='pretty'`
- 用户已有靓号时,再申请另一个靓号会覆盖旧靓号,旧靓号释放;旧池靓号回到 `available`,旧后台发放靓号进入 `released`

View File

@ -53,6 +53,33 @@ type CoinSellerSalaryRateTier struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
func userCountryRegionJoin(userAlias, countryRegionAlias, regionAlias string) string {
// 组织列表里的区域只用于后台展示、搜索和筛选,必须以用户当前国家归属的 active 区域为准。
// 身份表不再保存区域;这里始终按用户国家映射 active 区域,避免用户国家调整后列表继续显示旧值。
return fmt.Sprintf(`
LEFT JOIN region_countries %s ON %s.app_code = %s.app_code
AND %s.country_code = %s.country
AND %s.status = 'active'
LEFT JOIN regions %s ON %s.app_code = %s.app_code
AND %s.region_id = %s.region_id
AND %s.status = 'active'`,
countryRegionAlias, countryRegionAlias, userAlias,
countryRegionAlias, userAlias,
countryRegionAlias,
regionAlias, regionAlias, countryRegionAlias,
regionAlias, countryRegionAlias,
regionAlias,
)
}
func userCountryRegionIDSQL(regionAlias string) string {
return fmt.Sprintf("COALESCE(%s.region_id, 0)", regionAlias)
}
func userCountryRegionNameSQL(regionAlias string) string {
return fmt.Sprintf("COALESCE(%s.name, '')", regionAlias)
}
// ListBDProfiles 只读 user-service 的 BD/BD Leader 事实表,后台列表按角色选择物理表,避免把两种身份重新混在一起。
func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) {
if r == nil || r.db == nil {
@ -65,9 +92,9 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
whereSQL := `
FROM bd_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.role = ?`
args := []any{appCode, role}
if query.Status != "" {
@ -75,7 +102,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND bp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.ParentLeaderUserID > 0 {
@ -84,7 +111,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
}
@ -93,10 +120,10 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
COALESCE(parent_leader.avatar, ''),
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
@ -168,8 +195,8 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
whereSQL := `
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -177,12 +204,12 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND bp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
}
@ -191,10 +218,10 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
'', '', '',
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
@ -261,10 +288,10 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
(
@ -276,8 +303,8 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
)
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
`+userCountryRegionJoin("u", "user_country_region", "user_region")+`
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.user_id = ?
`, appCode, userID).Scan(
&item.UserID,
@ -448,12 +475,14 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.
}
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', %s, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
%s
WHERE bp.app_code = ? AND bp.user_id = ?
`, appCode, userID).Scan(
`, userCountryRegionIDSQL("user_region"), userCountryRegionJoin("u", "user_country_region", "user_region")), appCode, userID).Scan(
&item.UserID,
&item.Role,
&item.RegionID,
@ -501,8 +530,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
whereSQL := `
FROM agencies a
LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
` + userCountryRegionJoin("owner", "owner_country_region", "owner_region") + `
LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
LEFT JOIN regions r ON r.app_code = a.app_code AND r.region_id = a.region_id
WHERE a.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -513,7 +542,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
args = append(args, "deleted")
}
if query.RegionID > 0 {
whereSQL += " AND a.region_id = ?"
whereSQL += " AND owner_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.ParentBDUserID > 0 {
@ -522,7 +551,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR owner_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like, like, like)
}
@ -531,13 +560,13 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, a.region_id, a.parent_bd_user_id,
SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id,
a.name, a.status, a.join_enabled, a.max_hosts,
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
COALESCE(owner.avatar, ''),
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
COALESCE(parent_bd.avatar, ''), COALESCE(r.name, '')
COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`
%s
ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ?
@ -586,9 +615,9 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
whereSQL := `
FROM host_profiles hp
LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
LEFT JOIN users agency_owner ON agency_owner.app_code = a.app_code AND agency_owner.user_id = a.owner_user_id
LEFT JOIN regions r ON r.app_code = hp.app_code AND r.region_id = hp.region_id
WHERE hp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -596,7 +625,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND hp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.AgencyID > 0 {
@ -605,7 +634,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like)
}
@ -620,11 +649,11 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
queryArgs = args
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0),
SELECT hp.user_id, hp.status, `+userCountryRegionIDSQL("user_region")+`, COALESCE(hp.current_agency_id, 0),
COALESCE(hp.current_membership_id, 0), hp.source,
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''), COALESCE(a.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
COALESCE(a.owner_user_id, 0), COALESCE(agency_owner.current_display_user_id, ''),
COALESCE(agency_owner.username, ''), COALESCE(agency_owner.avatar, '')
%s

View File

@ -1,6 +1,9 @@
package hostorg
import "testing"
import (
"strings"
"testing"
)
// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受。
func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) {
@ -25,6 +28,32 @@ func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) {
}
}
// TestUserCountryRegionSQLUsesCountryMapping 锁定组织列表区域展示口径:从用户国家映射 active 区域,不从身份表区域快照取值。
func TestUserCountryRegionSQLUsesCountryMapping(t *testing.T) {
joinSQL := userCountryRegionJoin("owner", "owner_country_region", "owner_region")
for _, want := range []string{
"LEFT JOIN region_countries owner_country_region",
"owner_country_region.country_code = owner.country",
"owner_country_region.status = 'active'",
"LEFT JOIN regions owner_region",
"owner_region.region_id = owner_country_region.region_id",
"owner_region.status = 'active'",
} {
if !strings.Contains(joinSQL, want) {
t.Fatalf("country region join missing %q in %s", want, joinSQL)
}
}
if strings.Contains(joinSQL, "owner.region_id") {
t.Fatalf("country region join must not read identity or user region snapshots: %s", joinSQL)
}
if got := userCountryRegionIDSQL("owner_region"); got != "COALESCE(owner_region.region_id, 0)" {
t.Fatalf("region id projection mismatch: %s", got)
}
if got := userCountryRegionNameSQL("owner_region"); got != "COALESCE(owner_region.name, '')" {
t.Fatalf("region name projection mismatch: %s", got)
}
}
// TestSortCoinSellerListItemsComputedFields 验证 wallet 聚合字段排序只改变展示顺序,不改动币商身份数据。
func TestSortCoinSellerListItemsComputedFields(t *testing.T) {
items := []*CoinSellerListItem{

View File

@ -0,0 +1,227 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: "lalu",
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
Status: "active",
SortOrder: 10,
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 810,
AppCode: "lalu",
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Card",
PayType: "MADA",
MethodName: "MADA",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?provider_code=mifapay&status=active", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastThirdPartyChannels == nil ||
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "mifapay" ||
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items := response.Data["items"].([]any)
channel := items[0].(map[string]any)
methods := channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || len(methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" {
t.Fatalf("third party channels response mismatch: %+v", response)
}
}
func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-methods/810/status", bytes.NewBufferString(`{"enabled":false}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastSetMethodStatus == nil ||
wallet.lastSetMethodStatus.GetMethodId() != 810 ||
wallet.lastSetMethodStatus.GetEnabled() ||
wallet.lastSetMethodStatus.GetOperatorUserId() != 7 {
t.Fatalf("method status request mismatch: %+v", wallet.lastSetMethodStatus)
}
}
func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-rates/810", bytes.NewBufferString(`{"usdToCurrencyRate":" 3.75000000 "}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastUpdateRate == nil ||
wallet.lastUpdateRate.GetMethodId() != 810 ||
wallet.lastUpdateRate.GetUsdToCurrencyRate() != "3.75000000" ||
wallet.lastUpdateRate.GetOperatorUserId() != 7 {
t.Fatalf("payment rate request mismatch: %+v", wallet.lastUpdateRate)
}
}
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}`
request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateProduct == nil ||
wallet.lastCreateProduct.GetAmountMicro() != 10_000_000 ||
wallet.lastCreateProduct.GetCoinAmount() != 880000 ||
wallet.lastCreateProduct.GetAudienceType() != "coin_seller" ||
wallet.lastCreateProduct.GetPlatform() != "web" ||
len(wallet.lastCreateProduct.GetRegionIds()) != 1 ||
wallet.lastCreateProduct.GetRegionIds()[0] != 7100 ||
!wallet.lastCreateProduct.GetEnabled() ||
wallet.lastCreateProduct.GetOperatorUserId() != 7 {
t.Fatalf("create recharge product request mismatch: %+v", wallet.lastCreateProduct)
}
}
func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
// 后台 handler 只依赖 app_code、request_id 和操作者;测试在这里模拟鉴权中间件已完成后的上下文。
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "payment-handler-test")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextUsername, "tester")
c.Next()
})
router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels)
router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus)
router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate)
router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct)
return router
}
type adminPaymentTestResponse struct {
Code int `json:"code"`
Data map[string]any `json:"data"`
}
type mockPaymentWallet struct {
walletclient.Client
lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest
lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest
lastCreateProduct *walletv1.CreateRechargeProductRequest
}
func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
m.lastThirdPartyChannels = req
if m.thirdPartyChannelsResp != nil {
return m.thirdPartyChannelsResp, nil
}
return &walletv1.ListThirdPartyPaymentChannelsResponse{}, nil
}
func (m *mockPaymentWallet) SetThirdPartyPaymentMethodStatus(_ context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
m.lastSetMethodStatus = req
status := "disabled"
if req.GetEnabled() {
status = "active"
}
return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{
MethodId: req.GetMethodId(),
AppCode: req.GetAppCode(),
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
PayWay: "Card",
PayType: "MADA",
Status: status,
}}, nil
}
func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
m.lastUpdateRate = req
return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{
MethodId: req.GetMethodId(),
AppCode: req.GetAppCode(),
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
PayWay: "Card",
PayType: "MADA",
Status: "active",
UsdToCurrencyRate: req.GetUsdToCurrencyRate(),
}}, nil
}
func (m *mockPaymentWallet) CreateRechargeProduct(_ context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) {
m.lastCreateProduct = req
return &walletv1.RechargeProductResponse{Product: &walletv1.RechargeProduct{
AppCode: req.GetAppCode(),
ProductId: 9001,
ProductCode: "h5_seller_10",
ProductName: req.GetProductName(),
Description: req.GetDescription(),
AudienceType: req.GetAudienceType(),
Platform: req.GetPlatform(),
AmountMicro: req.GetAmountMicro(),
CoinAmount: req.GetCoinAmount(),
RegionIds: append([]int64(nil), req.GetRegionIds()...),
Enabled: req.GetEnabled(),
Status: "active",
}}, nil
}

View File

@ -354,7 +354,7 @@ func (s *Service) collectBDCandidates(ctx context.Context, appCode string, trigg
if !ok || agency.ParentBDUserID <= 0 || income <= 0 {
continue
}
// 区域来自 Agency 配置,不从主播记录推导,保证 BD 政策和组织管理后台保持同一口径
// 区域来自 Agency 拥有者 users 当前区域,不从主播记录或 Agency 历史快照推导
if len(regions) > 0 {
if _, ok := regions[agency.RegionID]; !ok {
continue
@ -399,7 +399,7 @@ func (s *Service) collectAdminCandidates(ctx context.Context, appCode string, tr
if !ok {
continue
}
// Admin 的适用区域以 bd_leader profile 为准,避免一个 BD 跨区域时把收入错配到错误政策
// Admin 的适用区域以 BD Leader 用户当前区域为准,不再读取 bd_leader_profiles 的历史 region_id
if len(regions) > 0 {
if _, ok := regions[leader.RegionID]; !ok {
continue
@ -614,7 +614,7 @@ type agencyInfo struct {
RegionID int64
}
// activeAgenciesByOwner 只使用 active agency避免历史或停用 Agency 继续给 BD 产生工资。
// activeAgenciesByOwner 只使用 active agency并从 owner users 行读取当前区域,避免历史快照继续影响 BD 工资。
func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, ownerIDs []int64) (map[int64]agencyInfo, error) {
ownerIDs = uniquePositiveUserIDs(ownerIDs)
if len(ownerIDs) == 0 {
@ -627,9 +627,11 @@ func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, own
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT owner_user_id, parent_bd_user_id, region_id
FROM agencies
WHERE app_code = ? AND status = 'active' AND owner_user_id IN (`+placeholders+`)`, args...)
SELECT a.owner_user_id, a.parent_bd_user_id, COALESCE(u.region_id, 0)
FROM agencies a
INNER JOIN users u
ON u.app_code = a.app_code AND u.user_id = a.owner_user_id
WHERE a.app_code = ? AND a.status = 'active' AND a.owner_user_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
@ -653,7 +655,7 @@ type bdProfile struct {
ParentLeaderUserID int64
}
// bdProfilesByUser 按角色取 active profile普通 BD 和 BD Leader 已拆成两张物理表,调用方必须显式传 role。
// bdProfilesByUser 按角色取 active profileRegionID 始终来自用户当前区域,调用方必须显式传 role。
func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) {
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
@ -666,14 +668,18 @@ func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs
args = append(args, id)
}
query := `
SELECT user_id, role, region_id, COALESCE(parent_leader_user_id, 0)
FROM bd_profiles
WHERE app_code = ? AND role = 'bd' AND status = 'active' AND user_id IN (` + placeholders + `)`
SELECT bp.user_id, bp.role, COALESCE(u.region_id, 0), COALESCE(bp.parent_leader_user_id, 0)
FROM bd_profiles bp
INNER JOIN users u
ON u.app_code = bp.app_code AND u.user_id = bp.user_id
WHERE bp.app_code = ? AND bp.role = 'bd' AND bp.status = 'active' AND bp.user_id IN (` + placeholders + `)`
if role == "bd_leader" {
query = `
SELECT user_id, 'bd_leader', region_id, 0
FROM bd_leader_profiles
WHERE app_code = ? AND status = 'active' AND user_id IN (` + placeholders + `)`
SELECT bl.user_id, 'bd_leader', COALESCE(u.region_id, 0), 0
FROM bd_leader_profiles bl
INNER JOIN users u
ON u.app_code = bl.app_code AND u.user_id = bl.user_id
WHERE bl.app_code = ? AND bl.status = 'active' AND bl.user_id IN (` + placeholders + `)`
}
rows, err := s.userDB.QueryContext(ctx, `
`+query, args...)

View File

@ -148,18 +148,18 @@ func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB
`INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`,
`INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`,
`INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar) VALUES
('lalu', 3001, 'AG3001', 'Agency One', ''),
('lalu', 3002, 'AG3002', 'Agency Two', ''),
('lalu', 4001, 'BD4001', 'BD One', ''),
('lalu', 5001, 'AD5001', 'Admin One', '')`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES
('lalu', 3001, 4001, 686, 'active'),
('lalu', 3002, 4001, 686, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 686, 5001, 'active')`,
`INSERT INTO bd_leader_profiles (app_code, user_id, region_id, status) VALUES
('lalu', 5001, 686, 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar, region_id) VALUES
('lalu', 3001, 'AG3001', 'Agency One', '', 686),
('lalu', 3002, 'AG3002', 'Agency Two', '', 686),
('lalu', 4001, 'BD4001', 'BD One', '', 686),
('lalu', 5001, 'AD5001', 'Admin One', '', 686)`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, status) VALUES
('lalu', 3001, 4001, 'active'),
('lalu', 3002, 4001, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 5001, 'active')`,
`INSERT INTO bd_leader_profiles (app_code, user_id, status) VALUES
('lalu', 5001, 'active')`,
)
execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records
(app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id,
@ -217,6 +217,7 @@ func teamSalaryUserDDL() []string {
current_display_user_id VARCHAR(64) NOT NULL DEFAULT '',
username VARCHAR(128) NULL,
avatar VARCHAR(512) NULL,
region_id BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (app_code, user_id)
)`,
`CREATE TABLE regions (
@ -243,7 +244,6 @@ func teamSalaryUserDDL() []string {
app_code VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
parent_bd_user_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, owner_user_id)
)`,
@ -251,7 +251,6 @@ func teamSalaryUserDDL() []string {
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(24) NOT NULL,
region_id BIGINT NOT NULL,
parent_leader_user_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id, role)
@ -259,7 +258,6 @@ func teamSalaryUserDDL() []string {
`CREATE TABLE bd_leader_profiles (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id)
)`,

View File

@ -6446,6 +6446,283 @@ func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T)
}
}
func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) {
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
Products: []*walletv1.RechargeProduct{{
ProductId: 91001,
ProductName: "Seller 10 USD",
AmountMicro: 10_000_000,
CoinAmount: 880000,
Platform: "web",
AudienceType: "coin_seller",
Enabled: true,
}},
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 810,
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Card",
PayType: "MADA",
MethodName: "MADA",
Status: "active",
UsdToCurrencyRate: "1.00000000",
SortOrder: 810,
}},
UsdtTrc20Enabled: true,
UsdtTrc20Address: "TPlatformReceiveAddress",
}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
UserId: 42,
DisplayUserId: "163042",
Username: "seller42",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
Country: "SA",
CountryDisplayName: "Saudi Arabia",
RegionId: 7100,
RegionCode: "mena-sa",
RegionName: "Saudi Arabia",
}}}
identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"should-ignore": 900001}}
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsCoinSeller: true, CoinSellerStatus: "active"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
// H5 带 token 时 display_user_id 只能作为无效噪声gateway 必须用 token 用户重新查国家和币商角色。
request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=should-ignore", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-h5-seller-options")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if identityClient.lastResolve != nil {
t.Fatalf("token h5 options must not resolve display id: %+v", identityClient.lastResolve)
}
if walletClient.lastH5Options == nil ||
walletClient.lastH5Options.GetTargetUserId() != 42 ||
walletClient.lastH5Options.GetTargetRegionId() != 7100 ||
walletClient.lastH5Options.GetTargetCountryCode() != "SA" ||
walletClient.lastH5Options.GetAudienceType() != "coin_seller" {
t.Fatalf("h5 options wallet request mismatch: %+v", walletClient.lastH5Options)
}
if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 {
t.Fatalf("h5 options role summary request mismatch: %+v", hostClient.lastRoleSummary)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("h5 options response mismatch: %+v", response)
}
account := data["account"].(map[string]any)
methods := data["payment_methods"].([]any)
if account["audience_type"] != "coin_seller" || account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["pay_type"] != "MADA" {
t.Fatalf("h5 options response data mismatch: %+v", data)
}
}
func TestH5RechargeOptionsResolvesDisplayUserCountryWithoutToken(t *testing.T) {
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 1310,
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "PH",
CountryName: "Philippines",
CurrencyCode: "PHP",
PayWay: "Ewallet",
PayType: "Gcash",
MethodName: "GCash",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}}
identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: {
UserId: 66,
DisplayUserId: "163066",
Username: "normal66",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
Country: "PH",
CountryDisplayName: "Philippines",
RegionId: 7200,
}}}
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=163066", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163066" {
t.Fatalf("display user id was not resolved: %+v", identityClient.lastResolve)
}
if walletClient.lastH5Options == nil ||
walletClient.lastH5Options.GetTargetUserId() != 66 ||
walletClient.lastH5Options.GetTargetRegionId() != 7200 ||
walletClient.lastH5Options.GetTargetCountryCode() != "PH" ||
walletClient.lastH5Options.GetAudienceType() != "normal" {
t.Fatalf("display h5 options wallet request mismatch: %+v", walletClient.lastH5Options)
}
}
func TestH5RechargeOptionsFallsBackToNumericUserIDWhenDisplayIDIsHeld(t *testing.T) {
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 1810,
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Card",
PayType: "MADA",
MethodName: "MADA",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}}
identityClient := &fakeUserIdentityClient{resolveErr: xerr.New(xerr.DisplayUserIDNotFound, "not found")}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{123456: {
UserId: 123456,
DisplayUserId: "1111cc1111",
Username: "test_123456",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
Country: "SA",
CountryDisplayName: "Saudi Arabia",
RegionId: 686,
}}}
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 123456}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
// 123456 在用户换靓号后可能不再是 active display_user_id但 H5 的无 token 入口文案是输入用户 ID
// display 解析 NOT_FOUND 时,纯数字输入必须回退到真实 user_id才能按该用户国家返回沙特支付方式。
request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=123456", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "123456" {
t.Fatalf("display user id was not tried first: %+v", identityClient.lastResolve)
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 123456 {
t.Fatalf("numeric user id fallback did not load profile: %+v", profileClient.lastGet)
}
if walletClient.lastH5Options == nil ||
walletClient.lastH5Options.GetTargetUserId() != 123456 ||
walletClient.lastH5Options.GetTargetRegionId() != 686 ||
walletClient.lastH5Options.GetTargetCountryCode() != "SA" ||
walletClient.lastH5Options.GetAudienceType() != "normal" {
t.Fatalf("numeric h5 options wallet request mismatch: %+v", walletClient.lastH5Options)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("h5 options response mismatch: %+v", response)
}
account := data["account"].(map[string]any)
methods := data["payment_methods"].([]any)
if account["display_user_id"] != "1111cc1111" || account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["pay_type"] != "MADA" {
t.Fatalf("numeric h5 options response data mismatch: %+v", data)
}
}
func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
walletClient := &fakeWalletClient{h5CreateOrderResp: &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{
OrderId: "h5-order-eg",
TargetUserId: 55,
TargetRegionId: 7300,
TargetCountryCode: "EG",
AudienceType: "normal",
ProductId: 93001,
ProductName: "Normal 1.5 USD",
CoinAmount: 120000,
UsdMinorAmount: 150,
ProviderCode: "mifapay",
PaymentMethodId: 1210,
CountryCode: "EG",
CurrencyCode: "EGP",
ProviderAmountMinor: 150,
PayWay: "Ewallet",
PayType: "FawryPay",
PayUrl: "https://pay.example/h5-order-eg",
Status: "pending",
}}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{55: {
UserId: 55,
DisplayUserId: "163055",
Username: "normal55",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
Country: "EG",
CountryDisplayName: "Egypt",
RegionId: 7300,
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(&fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 55}})
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"commandId":"cmd-h5-create-eg","productId":93001,"paymentMethodId":1210,"returnUrl":"https://h5.example/return"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/recharge/h5/orders", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 55))
request.Header.Set("X-Forwarded-For", "203.0.113.9")
request.Header.Set("Accept-Language", "ar-EG")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.lastH5CreateOrder == nil ||
walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" ||
walletClient.lastH5CreateOrder.GetTargetUserId() != 55 ||
walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 ||
walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" ||
walletClient.lastH5CreateOrder.GetAudienceType() != "normal" ||
walletClient.lastH5CreateOrder.GetProductId() != 93001 ||
walletClient.lastH5CreateOrder.GetProviderCode() != "mifapay" ||
walletClient.lastH5CreateOrder.GetPaymentMethodId() != 1210 ||
walletClient.lastH5CreateOrder.GetReturnUrl() != "https://h5.example/return" ||
walletClient.lastH5CreateOrder.GetLanguage() != "ar-EG" {
t.Fatalf("h5 create order wallet request mismatch: %+v", walletClient.lastH5CreateOrder)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("h5 create order response mismatch: %+v", response)
}
order := data["order"].(map[string]any)
if order["order_id"] != "h5-order-eg" || order["country_code"] != "EG" || order["pay_type"] != "FawryPay" || order["pay_url"] != "https://pay.example/h5-order-eg" {
t.Fatalf("h5 create order response data mismatch: %+v", data)
}
}
func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller",

View File

@ -0,0 +1,56 @@
package useridinput
import (
"context"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/xerr"
)
type DisplayResolver interface {
ResolveDisplayUserID(ctx context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error)
}
// Resolve 把 H5、后台、运营入口里用户手填的“用户标识”统一转换成服务内部 user_id。
// 解析顺序必须先查 active display_user_id靓号、当前短号、数字短号都属于 display 体系,优先级高于纯数字 user_id。
// 只有 user-service 明确返回 DISPLAY_USER_ID_NOT_FOUND或者旧 fake 返回空 identity 时,纯数字输入才回退成 user_id。
// 非 NOT_FOUND 错误不能被兜底吞掉,否则 user-service 抖动时会把请求错误地路由到同名数字 user_id。
func Resolve(ctx context.Context, resolver DisplayResolver, meta *userv1.RequestMeta, raw string) (int64, error) {
input := strings.TrimSpace(raw)
if input == "" {
return 0, xerr.New(xerr.InvalidArgument, "user identifier is required")
}
if resolver == nil {
return 0, xerr.New(xerr.Unavailable, "user identity resolver is not configured")
}
resp, err := resolver.ResolveDisplayUserID(ctx, &userv1.ResolveDisplayUserIDRequest{
Meta: meta,
DisplayUserId: input,
})
if err == nil {
if userID := resp.GetIdentity().GetUserId(); userID > 0 {
return userID, nil
}
if userID, ok := numericUserID(input); ok {
return userID, nil
}
return 0, xerr.New(xerr.DisplayUserIDNotFound, "not found")
}
if !xerr.IsCode(err, xerr.DisplayUserIDNotFound) {
return 0, err
}
if userID, ok := numericUserID(input); ok {
return userID, nil
}
return 0, err
}
func numericUserID(value string) (int64, bool) {
userID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || userID <= 0 {
return 0, false
}
return userID, true
}

View File

@ -0,0 +1,74 @@
package useridinput
import (
"context"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/xerr"
)
type fakeDisplayResolver struct {
userID int64
err error
last *userv1.ResolveDisplayUserIDRequest
}
func (f *fakeDisplayResolver) ResolveDisplayUserID(_ context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) {
f.last = req
if f.err != nil {
return nil, f.err
}
return &userv1.ResolveDisplayUserIDResponse{Identity: &userv1.UserIdentity{
UserId: f.userID,
DisplayUserId: req.GetDisplayUserId(),
Status: "active",
AppCode: req.GetMeta().GetAppCode(),
}}, nil
}
func TestResolvePrefersActiveDisplayIDBeforeNumericUserID(t *testing.T) {
resolver := &fakeDisplayResolver{userID: 900001}
userID, err := Resolve(context.Background(), resolver, &userv1.RequestMeta{AppCode: "lalu"}, "123456")
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
if userID != 900001 {
t.Fatalf("active display id should win over numeric fallback, got %d", userID)
}
if resolver.last == nil || resolver.last.GetDisplayUserId() != "123456" {
t.Fatalf("display resolver was not called first: %+v", resolver.last)
}
}
func TestResolveFallsBackToNumericUserIDWhenDisplayIDNotFound(t *testing.T) {
resolver := &fakeDisplayResolver{err: xerr.New(xerr.DisplayUserIDNotFound, "not found")}
userID, err := Resolve(context.Background(), resolver, &userv1.RequestMeta{AppCode: "lalu"}, "123456")
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
if userID != 123456 {
t.Fatalf("numeric user id fallback mismatch: got %d", userID)
}
}
func TestResolveKeepsPrettyDisplayIDOnDisplayResolver(t *testing.T) {
resolver := &fakeDisplayResolver{userID: 123456}
userID, err := Resolve(context.Background(), resolver, &userv1.RequestMeta{AppCode: "lalu"}, "1111cc1111")
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
if userID != 123456 {
t.Fatalf("pretty display id resolve mismatch: got %d", userID)
}
if resolver.last == nil || resolver.last.GetDisplayUserId() != "1111cc1111" {
t.Fatalf("pretty display id was not passed through: %+v", resolver.last)
}
}
func TestResolveDoesNotHideNonNotFoundErrors(t *testing.T) {
resolver := &fakeDisplayResolver{err: xerr.New(xerr.Unavailable, "user-service unavailable")}
if _, err := Resolve(context.Background(), resolver, &userv1.RequestMeta{AppCode: "lalu"}, "123456"); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("non-not-found error should pass through, got %v", err)
}
}

View File

@ -11,6 +11,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"hyapp/services/gateway-service/internal/transport/http/useridinput"
)
const (
@ -304,20 +305,14 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h
userID := auth.UserIDFromContext(request.Context())
resolvedByToken := userID > 0
if !resolvedByToken {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return h5RechargeTargetContext{}, false
}
resp, err := h.userIdentityClient.ResolveDisplayUserID(request.Context(), &userv1.ResolveDisplayUserIDRequest{
Meta: httpkit.UserMeta(request, ""),
DisplayUserId: displayUserID,
})
// 无 token 入口的输入是“用户标识”,可能是 active 靓号、当前短号、默认数字 ID 或真实 user_id。
// 所有传给 wallet-service 的目标都必须先在 gateway 统一收敛成内部 user_id避免各业务入口自行解析导致靓号报错。
resolvedUserID, err := useridinput.Resolve(request.Context(), h.userIdentityClient, httpkit.UserMeta(request, ""), displayUserID)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return h5RechargeTargetContext{}, false
}
userID = resp.GetIdentity().GetUserId()
userID = resolvedUserID
}
if userID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")

View File

@ -725,14 +725,12 @@ CREATE TABLE IF NOT EXISTS host_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
region_id BIGINT NOT NULL COMMENT '区域 ID',
current_agency_id BIGINT NULL COMMENT '当前公会 ID',
current_membership_id BIGINT NULL COMMENT '当前成员关系 ID',
source VARCHAR(64) NOT NULL COMMENT '来源',
first_became_host_at_ms BIGINT NOT NULL COMMENT '首次成为主播时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_host_profiles_region_status (app_code, region_id, status),
KEY idx_host_profiles_agency (app_code, current_agency_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播资料表';
@ -740,25 +738,21 @@ CREATE TABLE IF NOT EXISTS bd_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
role VARCHAR(32) NOT NULL COMMENT '角色',
region_id BIGINT NOT NULL COMMENT '区域 ID',
parent_leader_user_id BIGINT NULL COMMENT '父级负责人用户 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_profiles_region_role (app_code, region_id, role, status),
KEY idx_bd_profiles_parent_leader (app_code, parent_leader_user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD资料表';
CREATE TABLE IF NOT EXISTS bd_leader_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_leader_profiles_region_status (app_code, region_id, status)
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表';
CREATE TABLE IF NOT EXISTS coin_seller_profiles (
@ -778,7 +772,6 @@ CREATE TABLE IF NOT EXISTS agencies (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
agency_id BIGINT NOT NULL PRIMARY KEY COMMENT '公会 ID',
owner_user_id BIGINT NOT NULL COMMENT '房主用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
parent_bd_user_id BIGINT NOT NULL COMMENT '父级BD用户 ID',
name VARCHAR(128) NOT NULL COMMENT '名称',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
@ -791,7 +784,6 @@ CREATE TABLE IF NOT EXISTS agencies (
CASE WHEN status = 'active' THEN owner_user_id ELSE NULL END
) STORED COMMENT '启用房主用户 ID',
UNIQUE KEY uk_agencies_active_owner (app_code, active_owner_user_id),
KEY idx_agencies_region_status (app_code, region_id, status, join_enabled),
KEY idx_agencies_parent_bd (app_code, parent_bd_user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公会表';
@ -800,7 +792,6 @@ CREATE TABLE IF NOT EXISTS agency_memberships (
membership_id BIGINT NOT NULL PRIMARY KEY COMMENT '成员关系 ID',
agency_id BIGINT NOT NULL COMMENT '公会 ID',
host_user_id BIGINT NOT NULL COMMENT '主播用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
membership_type VARCHAR(32) NOT NULL COMMENT '成员关系类型',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
joined_at_ms BIGINT NOT NULL COMMENT '加入时间UTC epoch ms',
@ -824,7 +815,6 @@ CREATE TABLE IF NOT EXISTS agency_applications (
command_id VARCHAR(96) NOT NULL COMMENT '业务命令幂等 ID',
applicant_user_id BIGINT NOT NULL COMMENT '申请人用户 ID',
agency_id BIGINT NOT NULL COMMENT '公会 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
reviewed_by_user_id BIGINT NULL COMMENT '审核用户 ID',
review_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '审核原因',
@ -849,7 +839,6 @@ CREATE TABLE IF NOT EXISTS role_invitations (
inviter_user_id BIGINT NOT NULL COMMENT '邀请人用户 ID',
inviter_bd_user_id BIGINT NOT NULL COMMENT '邀请人BD用户 ID',
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '公会名称',
parent_bd_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '父级BD用户 ID',
parent_leader_user_id BIGINT NULL COMMENT '父级负责人用户 ID',
@ -894,160 +883,7 @@ CREATE TABLE IF NOT EXISTS host_outbox (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播发件箱表';
-- 国家种子由 services/user-service/deploy/data/countries.json 生成,避免 SQL 与数据源分叉。
-- 区域种子使用产品定义的业务分区,不按原始地理来源自动推断。
INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, country_display_name, phone_country_code, flag, enabled, sort_order, created_at_ms, updated_at_ms) VALUES
('United Arab Emirates', 'AE', 'ARE', '784', '阿拉伯联合酋长国', '+971', '🇦🇪', TRUE, 20, 0, 0),
('Afghanistan', 'AF', 'AFG', '004', '阿富汗', '+93', '🇦🇫', TRUE, 30, 0, 0),
('Albania', 'AL', 'ALB', '008', '阿尔巴尼亚', '+355', '🇦🇱', TRUE, 60, 0, 0),
('Armenia', 'AM', 'ARM', '051', '亚美尼亚', '+374', '🇦🇲', TRUE, 70, 0, 0),
('Angola', 'AO', 'AGO', '024', '安哥拉', '+244', '🇦🇴', TRUE, 80, 0, 0),
('Argentina', 'AR', 'ARG', '032', '阿根廷', '+54', '🇦🇷', TRUE, 100, 0, 0),
('Austria', 'AT', 'AUT', '040', '奥地利', '+43', '🇦🇹', TRUE, 120, 0, 0),
('Australia', 'AU', 'AUS', '036', '澳大利亚', '+61', '🇦🇺', TRUE, 130, 0, 0),
('Bangladesh', 'BD', 'BGD', '050', '孟加拉国', '+880', '🇧🇩', TRUE, 190, 0, 0),
('Belgium', 'BE', 'BEL', '056', '比利时', '+32', '🇧🇪', TRUE, 200, 0, 0),
('Bulgaria', 'BG', 'BGR', '100', '保加利亚', '+359', '🇧🇬', TRUE, 220, 0, 0),
('Bahrain', 'BH', 'BHR', '048', '巴林', '+973', '🇧🇭', TRUE, 230, 0, 0),
('Benin', 'BJ', 'BEN', '204', '贝宁', '+229', '🇧🇯', TRUE, 250, 0, 0),
('Bolivia', 'BO', 'BOL', '068', '玻利维亚', '+591', '🇧🇴', TRUE, 290, 0, 0),
('Brazil', 'BR', 'BRA', '076', '巴西', '+55', '🇧🇷', TRUE, 310, 0, 0),
('Botswana', 'BW', 'BWA', '072', '博茨瓦纳', '+267', '🇧🇼', TRUE, 350, 0, 0),
('Belarus', 'BY', 'BLR', '112', '白俄罗斯', '+375', '🇧🇾', TRUE, 360, 0, 0),
('Canada', 'CA', 'CAN', '124', '加拿大', '+1', '🇨🇦', TRUE, 380, 0, 0),
('DR Congo', 'CD', 'COD', '180', '民主刚果', '+243', '🇨🇩', TRUE, 400, 0, 0),
('Central African Republic', 'CF', 'CAF', '140', '中非共和国', '+236', '🇨🇫', TRUE, 410, 0, 0),
('Congo', 'CG', 'COG', '178', '刚果', '+242', '🇨🇬', TRUE, 420, 0, 0),
('Switzerland', 'CH', 'CHE', '756', '瑞士', '+41', '🇨🇭', TRUE, 430, 0, 0),
('Ivory Coast', 'CI', 'CIV', '384', '科特迪瓦', '+225', '🇨🇮', TRUE, 440, 0, 0),
('Chile', 'CL', 'CHL', '152', '智利', '+56', '🇨🇱', TRUE, 460, 0, 0),
('Cameroon', 'CM', 'CMR', '120', '喀麦隆', '+237', '🇨🇲', TRUE, 470, 0, 0),
('Colombia', 'CO', 'COL', '170', '哥伦比亚', '+57', '🇨🇴', TRUE, 490, 0, 0),
('Costa Rica', 'CR', 'CRI', '188', '哥斯达黎加', '+506', '🇨🇷', TRUE, 500, 0, 0),
('Cuba', 'CU', 'CUB', '192', '古巴', '+53', '🇨🇺', TRUE, 510, 0, 0),
('Cyprus', 'CY', 'CYP', '196', '塞浦路斯', '+357', '🇨🇾', TRUE, 550, 0, 0),
('Czechia', 'CZ', 'CZE', '203', '捷克', '+420', '🇨🇿', TRUE, 560, 0, 0),
('Germany', 'DE', 'DEU', '276', '德国', '+49', '🇩🇪', TRUE, 570, 0, 0),
('Djibouti', 'DJ', 'DJI', '262', '吉布提', '+253', '🇩🇯', TRUE, 580, 0, 0),
('Denmark', 'DK', 'DNK', '208', '丹麦', '+45', '🇩🇰', TRUE, 590, 0, 0),
('Dominican Republic', 'DO', 'DOM', '214', '多明尼加', '+1', '🇩🇴', TRUE, 610, 0, 0),
('Algeria', 'DZ', 'DZA', '012', '阿尔及利亚', '+213', '🇩🇿', TRUE, 620, 0, 0),
('Ecuador', 'EC', 'ECU', '218', '厄瓜多尔', '+593', '🇪🇨', TRUE, 630, 0, 0),
('Estonia', 'EE', 'EST', '233', '爱沙尼亚', '+372', '🇪🇪', TRUE, 640, 0, 0),
('Egypt', 'EG', 'EGY', '818', '埃及', '+20', '🇪🇬', TRUE, 650, 0, 0),
('Eritrea', 'ER', 'ERI', '232', '厄立特里亚', '+291', '🇪🇷', TRUE, 670, 0, 0),
('Spain', 'ES', 'ESP', '724', '西班牙', '+34', '🇪🇸', TRUE, 680, 0, 0),
('Ethiopia', 'ET', 'ETH', '231', '埃塞俄比亚', '+251', '🇪🇹', TRUE, 690, 0, 0),
('Finland', 'FI', 'FIN', '246', '芬兰', '+358', '🇫🇮', TRUE, 700, 0, 0),
('France', 'FR', 'FRA', '250', '法国', '+33', '🇫🇷', TRUE, 750, 0, 0),
('Gabon', 'GA', 'GAB', '266', '加蓬', '+241', '🇬🇦', TRUE, 760, 0, 0),
('United Kingdom', 'GB', 'GBR', '826', '英国', '+44', '🇬🇧', TRUE, 770, 0, 0),
('Georgia', 'GE', 'GEO', '268', '格鲁吉亚', '+995', '🇬🇪', TRUE, 790, 0, 0),
('Ghana', 'GH', 'GHA', '288', '加纳', '+233', '🇬🇭', TRUE, 820, 0, 0),
('Gambia', 'GM', 'GMB', '270', '冈比亚', '+220', '🇬🇲', TRUE, 850, 0, 0),
('Guinea', 'GN', 'GIN', '324', '几内亚', '+224', '🇬🇳', TRUE, 860, 0, 0),
('Equatorial Guinea', 'GQ', 'GNQ', '226', '赤道几内亚', '+240', '🇬🇶', TRUE, 880, 0, 0),
('Greece', 'GR', 'GRC', '300', '希腊', '+30', '🇬🇷', TRUE, 890, 0, 0),
('Guatemala', 'GT', 'GTM', '320', '危地马拉', '+502', '🇬🇹', TRUE, 910, 0, 0),
('Honduras', 'HN', 'HND', '340', '洪都拉斯', '+504', '🇭🇳', TRUE, 970, 0, 0),
('Croatia', 'HR', 'HRV', '191', '克罗地亚', '+385', '🇭🇷', TRUE, 980, 0, 0),
('Haiti', 'HT', 'HTI', '332', '海地', '+509', '🇭🇹', TRUE, 990, 0, 0),
('Hungary', 'HU', 'HUN', '348', '匈牙利', '+36', '🇭🇺', TRUE, 1000, 0, 0),
('Indonesia', 'ID', 'IDN', '360', '印度尼西亚', '+62', '🇮🇩', TRUE, 1010, 0, 0),
('Ireland', 'IE', 'IRL', '372', '爱尔兰', '+353', '🇮🇪', TRUE, 1020, 0, 0),
('Israel', 'IL', 'ISR', '376', '以色列', '+972', '🇮🇱', TRUE, 1030, 0, 0),
('India', 'IN', 'IND', '356', '印度', '+91', '🇮🇳', TRUE, 1050, 0, 0),
('Iraq', 'IQ', 'IRQ', '368', '伊拉克', '+964', '🇮🇶', TRUE, 1070, 0, 0),
('Iran', 'IR', 'IRN', '364', '伊朗', '+98', '🇮🇷', TRUE, 1080, 0, 0),
('Italy', 'IT', 'ITA', '380', '意大利', '+39', '🇮🇹', TRUE, 1100, 0, 0),
('Jamaica', 'JM', 'JAM', '388', '牙买加', '+1', '🇯🇲', TRUE, 1120, 0, 0),
('Jordan', 'JO', 'JOR', '400', '约旦', '+962', '🇯🇴', TRUE, 1130, 0, 0),
('Japan', 'JP', 'JPN', '392', '日本', '+81', '🇯🇵', TRUE, 1140, 0, 0),
('Kenya', 'KE', 'KEN', '404', '肯尼亚', '+254', '🇰🇪', TRUE, 1150, 0, 0),
('Kyrgyzstan', 'KG', 'KGZ', '417', '吉尔吉斯斯坦', '+996', '🇰🇬', TRUE, 1160, 0, 0),
('Cambodia', 'KH', 'KHM', '116', '柬埔寨', '+855', '🇰🇭', TRUE, 1170, 0, 0),
('North Korea', 'KP', 'PRK', '408', '朝鲜', '+850', '🇰🇵', TRUE, 1210, 0, 0),
('South Korea', 'KR', 'KOR', '410', '韩国', '+82', '🇰🇷', TRUE, 1220, 0, 0),
('Kuwait', 'KW', 'KWT', '414', '科威特', '+965', '🇰🇼', TRUE, 1230, 0, 0),
('Kazakhstan', 'KZ', 'KAZ', '398', '哈萨克斯坦', '+7', '🇰🇿', TRUE, 1250, 0, 0),
('Laos', 'LA', 'LAO', '418', '老挝', '+856', '🇱🇦', TRUE, 1260, 0, 0),
('Lebanon', 'LB', 'LBN', '422', '黎巴嫩', '+961', '🇱🇧', TRUE, 1270, 0, 0),
('Sri Lanka', 'LK', 'LKA', '144', '斯里兰卡', '+94', '🇱🇰', TRUE, 1300, 0, 0),
('Liberia', 'LR', 'LBR', '430', '利比里亚', '+231', '🇱🇷', TRUE, 1310, 0, 0),
('Libya', 'LY', 'LBY', '434', '利比亚', '+218', '🇱🇾', TRUE, 1360, 0, 0),
('Morocco', 'MA', 'MAR', '504', '摩洛哥', '+212', '🇲🇦', TRUE, 1370, 0, 0),
('Madagascar', 'MG', 'MDG', '450', '马达加斯加', '+261', '🇲🇬', TRUE, 1420, 0, 0),
('Mali', 'ML', 'MLI', '466', '马里', '+223', '🇲🇱', TRUE, 1450, 0, 0),
('Myanmar', 'MM', 'MMR', '104', '缅甸', '+95', '🇲🇲', TRUE, 1460, 0, 0),
('Mongolia', 'MN', 'MNG', '496', '蒙古', '+976', '🇲🇳', TRUE, 1470, 0, 0),
('Mauritius', 'MU', 'MUS', '480', '毛里求斯', '+230', '🇲🇺', TRUE, 1540, 0, 0),
('Maldives', 'MV', 'MDV', '462', '马尔代夫', '+960', '🇲🇻', TRUE, 1550, 0, 0),
('Mexico', 'MX', 'MEX', '484', '墨西哥', '+52', '🇲🇽', TRUE, 1570, 0, 0),
('Malaysia', 'MY', 'MYS', '458', '马来西亚', '+60', '🇲🇾', TRUE, 1580, 0, 0),
('Mozambique', 'MZ', 'MOZ', '508', '莫桑比克', '+258', '🇲🇿', TRUE, 1590, 0, 0),
('Namibia', 'NA', 'NAM', '516', '纳米比亚', '+264', '🇳🇦', TRUE, 1600, 0, 0),
('Niger', 'NE', 'NER', '562', '尼日尔', '+227', '🇳🇪', TRUE, 1620, 0, 0),
('Nigeria', 'NG', 'NGA', '566', '尼日利亚', '+234', '🇳🇬', TRUE, 1640, 0, 0),
('Nicaragua', 'NI', 'NIC', '558', '尼加拉瓜', '+505', '🇳🇮', TRUE, 1650, 0, 0),
('Netherlands', 'NL', 'NLD', '528', '荷兰', '+31', '🇳🇱', TRUE, 1660, 0, 0),
('Norway', 'NO', 'NOR', '578', '挪威', '+47', '🇳🇴', TRUE, 1670, 0, 0),
('Nepal', 'NP', 'NPL', '524', '尼泊尔', '+977', '🇳🇵', TRUE, 1680, 0, 0),
('New Zealand', 'NZ', 'NZL', '554', '新西兰', '+64', '🇳🇿', TRUE, 1710, 0, 0),
('Oman', 'OM', 'OMN', '512', '阿曼', '+968', '🇴🇲', TRUE, 1720, 0, 0),
('Panama', 'PA', 'PAN', '591', '巴拿马', '+507', '🇵🇦', TRUE, 1730, 0, 0),
('Peru', 'PE', 'PER', '604', '秘鲁', '+51', '🇵🇪', TRUE, 1740, 0, 0),
('Philippines', 'PH', 'PHL', '608', '菲律宾', '+63', '🇵🇭', TRUE, 1770, 0, 0),
('Pakistan', 'PK', 'PAK', '586', '巴基斯坦', '+92', '🇵🇰', TRUE, 1780, 0, 0),
('Poland', 'PL', 'POL', '616', '波兰', '+48', '🇵🇱', TRUE, 1790, 0, 0),
('Puerto Rico', 'PR', 'PRI', '630', '波多黎各', '+1', '🇵🇷', TRUE, 1820, 0, 0),
('Palestine', 'PS', 'PSE', '275', '巴勒斯坦', '+970', '🇵🇸', TRUE, 1830, 0, 0),
('Portugal', 'PT', 'PRT', '620', '葡萄牙', '+351', '🇵🇹', TRUE, 1840, 0, 0),
('Paraguay', 'PY', 'PRY', '600', '巴拉圭', '+595', '🇵🇾', TRUE, 1860, 0, 0),
('Qatar', 'QA', 'QAT', '634', '卡塔尔', '+974', '🇶🇦', TRUE, 1870, 0, 0),
('Serbia', 'RS', 'SRB', '688', '塞尔维亚', '+381', '🇷🇸', TRUE, 1900, 0, 0),
('Russia', 'RU', 'RUS', '643', '俄罗斯', '+7', '🇷🇺', TRUE, 1910, 0, 0),
('Rwanda', 'RW', 'RWA', '646', '卢旺达', '+250', '🇷🇼', TRUE, 1920, 0, 0),
('Saudi Arabia', 'SA', 'SAU', '682', '沙特阿拉伯', '+966', '🇸🇦', TRUE, 1930, 0, 0),
('Sudan', 'SD', 'SDN', '729', '苏丹', '+249', '🇸🇩', TRUE, 1960, 0, 0),
('Sweden', 'SE', 'SWE', '752', '瑞典', '+46', '🇸🇪', TRUE, 1970, 0, 0),
('Singapore', 'SG', 'SGP', '702', '新加坡', '+65', '🇸🇬', TRUE, 1980, 0, 0),
('Slovakia', 'SK', 'SVK', '703', '斯洛伐克', '+421', '🇸🇰', TRUE, 2020, 0, 0),
('Sierra Leone', 'SL', 'SLE', '694', '塞拉利昂', '+232', '🇸🇱', TRUE, 2030, 0, 0),
('Senegal', 'SN', 'SEN', '686', '塞内加尔', '+221', '🇸🇳', TRUE, 2050, 0, 0),
('Somalia', 'SO', 'SOM', '706', '索马里', '+252', '🇸🇴', TRUE, 2060, 0, 0),
('South Sudan', 'SS', 'SSD', '728', '南苏丹', '+211', '🇸🇸', TRUE, 2080, 0, 0),
('El Salvador', 'SV', 'SLV', '222', '萨尔瓦多', '+503', '🇸🇻', TRUE, 2100, 0, 0),
('Syria', 'SY', 'SYR', '760', '叙利亚', '+963', '🇸🇾', TRUE, 2120, 0, 0),
('Eswatini', 'SZ', 'SWZ', '748', '斯威士兰', '+268', '🇸🇿', TRUE, 2130, 0, 0),
('Chad', 'TD', 'TCD', '148', '乍得', '+235', '🇹🇩', TRUE, 2150, 0, 0),
('Thailand', 'TH', 'THA', '764', '泰国', '+66', '🇹🇭', TRUE, 2180, 0, 0),
('Tajikistan', 'TJ', 'TJK', '762', '塔吉克斯坦', '+992', '🇹🇯', TRUE, 2190, 0, 0),
('Turkmenistan', 'TM', 'TKM', '795', '土库曼斯坦', '+993', '🇹🇲', TRUE, 2220, 0, 0),
('Tunisia', 'TN', 'TUN', '788', '突尼斯', '+216', '🇹🇳', TRUE, 2230, 0, 0),
('Türkiye', 'TR', 'TUR', '792', '土耳其', '+90', '🇹🇷', TRUE, 2250, 0, 0),
('Tanzania', 'TZ', 'TZA', '834', '坦桑尼亚', '+255', '🇹🇿', TRUE, 2290, 0, 0),
('Ukraine', 'UA', 'UKR', '804', '乌克兰', '+380', '🇺🇦', TRUE, 2300, 0, 0),
('Uganda', 'UG', 'UGA', '800', '乌干达', '+256', '🇺🇬', TRUE, 2310, 0, 0),
('United States', 'US', 'USA', '840', '美国', '+1', '🇺🇸', TRUE, 2330, 0, 0),
('Uruguay', 'UY', 'URY', '858', '乌拉圭', '+598', '🇺🇾', TRUE, 2340, 0, 0),
('Uzbekistan', 'UZ', 'UZB', '860', '乌兹别克斯坦', '+998', '🇺🇿', TRUE, 2350, 0, 0),
('Venezuela', 'VE', 'VEN', '862', '委内瑞拉', '+58', '🇻🇪', TRUE, 2380, 0, 0),
('Vietnam', 'VN', 'VNM', '704', '越南', '+84', '🇻🇳', TRUE, 2410, 0, 0),
('Kosovo', 'XK', 'UNK', NULL, '科索沃', '+383', '🇽🇰', TRUE, 2450, 0, 0),
('Yemen', 'YE', 'YEM', '887', '也门', '+967', '🇾🇪', TRUE, 2460, 0, 0),
('Zambia', 'ZM', 'ZMB', '894', '赞比亚', '+260', '🇿🇲', TRUE, 2490, 0, 0),
('Zimbabwe', 'ZW', 'ZWE', '716', '津巴布韦', '+263', '🇿🇼', TRUE, 2500, 0, 0)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
iso_alpha3 = VALUES(iso_alpha3),
iso_numeric = VALUES(iso_numeric),
country_display_name = VALUES(country_display_name),
phone_country_code = VALUES(phone_country_code),
flag = VALUES(flag),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES
('MIDDLE_EAST', '中东区', 'active', 10, 0, 0),
('INDIA', '印度区', 'active', 20, 0, 0),
@ -1058,310 +894,11 @@ ON DUPLICATE KEY UPDATE
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO region_countries (app_code, region_id, country_code, status, created_at_ms, updated_at_ms)
SELECT 'lalu', r.region_id, seed.country_code, 'active', 0, 0
FROM (
SELECT 'AE' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'AF' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'AL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BD' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'BE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BH' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'BJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CF' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'EC' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'EE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'EG' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'ER' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ES' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ET' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'FI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'FR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GB' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GQ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'HN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'HR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'HT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'HU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ID' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'IE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'IL' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'IN' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'IQ' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'IR' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'IT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'JM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'JO' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'JP' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KP' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KW' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'KZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LB' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'LK' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'LR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ML' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MV' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'MX' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NP' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'NZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'OM' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'PA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PK' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'PL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PS' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'PT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'QA' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'RS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'RU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'RW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SA' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'SD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SV' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SY' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'SZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TR' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'TZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'UA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'UG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'US' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'UY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'UZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'VE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'VN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'XK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'YE' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'ZM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ZW' AS country_code, 'EURASIA_AMERICA' AS region_code
) AS seed
INNER JOIN regions r ON r.app_code = 'lalu' AND r.region_code = seed.region_code
INNER JOIN countries c ON c.app_code = 'lalu' AND c.country_code = seed.country_code
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
-- 区域管理只暴露产品定义的业务分区GLOBAL 仅作为内部兜底,不作为后台可管理区域。
DELETE FROM regions
WHERE app_code = 'lalu'
AND region_code NOT IN ('MIDDLE_EAST', 'INDIA', 'EURASIA_AMERICA');
-- 产品明确排除的国家直接硬删除,保证重复导入 initdb 时新旧本地库都能对齐当前国家主数据。
DELETE FROM region_countries
WHERE app_code = 'lalu'
AND country_code IN ('AD', 'AG', 'AI', 'AQ', 'AS', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BF', 'BI', 'BL', 'BM', 'BN', 'BQ', 'BS', 'BT', 'BV', 'BZ', 'CC', 'CK', 'CN', 'CV', 'CW', 'CX', 'DM', 'EH', 'FJ', 'FK', 'FM', 'FO', 'GD', 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GU', 'GW', 'GY', 'HK', 'HM', 'IM', 'IO', 'IS', 'JE', 'KI', 'KM', 'KN', 'KY', 'LC', 'LI', 'LS', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MF', 'MH', 'MK', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MW', 'NC', 'NF', 'NR', 'NU', 'PF', 'PG', 'PM', 'PN', 'PW', 'RE', 'RO', 'SB', 'SC', 'SH', 'SI', 'SJ', 'SM', 'SR', 'ST', 'SX', 'TC', 'TF', 'TG', 'TK', 'TL', 'TO', 'TT', 'TV', 'TW', 'UM', 'VA', 'VC', 'VG', 'VI', 'VU', 'WF', 'WS', 'YT', 'ZA');
DELETE FROM countries
WHERE app_code = 'lalu'
AND country_code IN ('AD', 'AG', 'AI', 'AQ', 'AS', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BF', 'BI', 'BL', 'BM', 'BN', 'BQ', 'BS', 'BT', 'BV', 'BZ', 'CC', 'CK', 'CN', 'CV', 'CW', 'CX', 'DM', 'EH', 'FJ', 'FK', 'FM', 'FO', 'GD', 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GU', 'GW', 'GY', 'HK', 'HM', 'IM', 'IO', 'IS', 'JE', 'KI', 'KM', 'KN', 'KY', 'LC', 'LI', 'LS', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MF', 'MH', 'MK', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MW', 'NC', 'NF', 'NR', 'NU', 'PF', 'PG', 'PM', 'PN', 'PW', 'RE', 'RO', 'SB', 'SC', 'SH', 'SI', 'SJ', 'SM', 'SR', 'ST', 'SX', 'TC', 'TF', 'TG', 'TK', 'TL', 'TO', 'TT', 'TV', 'TW', 'UM', 'VA', 'VC', 'VG', 'VI', 'VU', 'WF', 'WS', 'YT', 'ZA');
CREATE TABLE IF NOT EXISTS user_display_user_ids (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
@ -1713,25 +1250,6 @@ ON DUPLICATE KEY UPDATE
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO user_display_user_ids (
app_code,
display_user_id,
user_id,
display_user_id_kind,
status,
assigned_at_ms,
activated_at_ms,
created_at_ms,
updated_at_ms
)
VALUES ('lalu', '123456', 123456, 'default', 'active', 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE
display_user_id = VALUES(display_user_id),
display_user_id_kind = VALUES(display_user_id_kind),
status = VALUES(status),
activated_at_ms = VALUES(activated_at_ms),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO password_accounts (
app_code,
user_id,
@ -1793,25 +1311,6 @@ ON DUPLICATE KEY UPDATE
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO user_display_user_ids (
app_code,
display_user_id,
user_id,
display_user_id_kind,
status,
assigned_at_ms,
activated_at_ms,
created_at_ms,
updated_at_ms
)
VALUES ('lalu', '12345678', 12345678, 'default', 'active', 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE
display_user_id = VALUES(display_user_id),
display_user_id_kind = VALUES(display_user_id_kind),
status = VALUES(status),
activated_at_ms = VALUES(activated_at_ms),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO password_accounts (
app_code,
user_id,

View File

@ -825,6 +825,16 @@ func ValidDisplayUserID(value string) bool {
return displayUserIDPattern.MatchString(value)
}
// ValidResolvableDisplayUserID 校验客户端或后台传入的“可解析展示号”。
func ValidResolvableDisplayUserID(value string) bool {
if ValidDisplayUserID(value) {
// 默认短号仍然走原有纯数字规则,保证注册、换号和历史默认号解析行为不变。
return true
}
// 解析和登录入口必须允许后台发放的靓号;写入默认短号仍然只能调用 ValidDisplayUserID。
return ValidAdminPrettyDisplayUserID(value)
}
// ValidAdminPrettyDisplayUserID 校验后台直接发放的靓号内容。
func ValidAdminPrettyDisplayUserID(value string) bool {
// 后台发放支持阿拉伯语、英文和数字禁止空白、控制字符、emoji 与标点,避免登录和展示链路出现不可输入字符。

View File

@ -20,6 +20,24 @@ func TestValidDisplayUserIDAllowsFiveDigitSeedAccounts(t *testing.T) {
}
}
func TestValidResolvableDisplayUserIDAllowsDefaultAndPrettyIDs(t *testing.T) {
t.Parallel()
valid := []string{"10001", "123456", "1111cc1111", "VIP2026", "ملك123", "Cafe\u0301123"}
for _, value := range valid {
if !ValidResolvableDisplayUserID(value) {
t.Fatalf("resolvable display_user_id %q should be valid", value)
}
}
invalid := []string{"", " ", "VIP-2026", "VIP 2026", "VIP😀", "\nVIP", "VIP!"}
for _, value := range invalid {
if ValidResolvableDisplayUserID(value) {
t.Fatalf("resolvable display_user_id %q should be invalid", value)
}
}
}
func TestValidAdminPrettyDisplayUserID(t *testing.T) {
t.Parallel()

View File

@ -24,8 +24,8 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
// 没有认证 repository 时不能读取密码身份或创建 session。
return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
if !userdomain.ValidDisplayUserID(displayUserID) {
// 非法短号和不存在短号统一映射 AUTH_FAILED避免账号枚举。
if !userdomain.ValidResolvableDisplayUserID(displayUserID) {
// 密码登录接受当前 active 默认短号或靓号;非法字符和不存在账号统一映射 AUTH_FAILED避免账号枚举。
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, authFailed()
}

View File

@ -920,3 +920,50 @@ func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
t.Fatalf("expired pretty display_user_id must not login, got %v", err)
}
}
func TestPasswordLoginAllowsAlphanumericPrettyDisplayID(t *testing.T) {
// 后台发放靓号可以包含字母;密码登录必须复用同一可解析展示号规则,不能只允许纯数字靓号。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty-alpha", thirdPartyRegistration("ios"), authservice.Meta{})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
t.Fatalf("SetPassword failed: %v", err)
}
if _, _, err := userSvc.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
TargetUserID: token.UserID,
DisplayUserID: "1111cc1111",
OperatorAdminID: 1,
RequestID: "req-admin-pretty-alpha",
}); err != nil {
t.Fatalf("AdminGrantPrettyDisplayID failed: %v", err)
}
if _, err := repository.RawDB().ExecContext(ctx, `
UPDATE users
SET current_display_user_id = default_display_user_id,
current_display_user_id_kind = ?,
current_display_user_id_expires_at_ms = NULL
WHERE app_code = ? AND user_id = ?
`, string(userdomain.DisplayUserIDKindDefault), "lalu", token.UserID); err != nil {
t.Fatalf("make users display snapshot stale failed: %v", err)
}
if _, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
// 默认短号被置为 held 后不能再作为登录入口,避免两个展示号同时登录同一账号。
t.Fatalf("default display_user_id must not login while admin pretty is active, got %v", err)
}
prettyToken, err := authSvc.LoginPassword(ctx, "1111cc1111", "secret-pass", "ios", authservice.Meta{})
if err != nil {
t.Fatalf("alphanumeric pretty display_user_id login failed: %v", err)
}
if prettyToken.UserID != token.UserID || prettyToken.DisplayUserID != "1111cc1111" || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
t.Fatalf("unexpected alphanumeric pretty login token: %+v", prettyToken)
}
}

View File

@ -55,6 +55,32 @@ func displayID(userID int64) string {
return fmt.Sprintf("%06d", 900000+userID%100000)
}
func assertNoHostOrgRegionSnapshotColumns(t *testing.T, repository *mysqltest.Repository) {
t.Helper()
var count int64
err := repository.RawDB().QueryRow(`
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND COLUMN_NAME = 'region_id'
AND TABLE_NAME IN (
'host_profiles',
'bd_profiles',
'bd_leader_profiles',
'agencies',
'agency_memberships',
'agency_applications',
'role_invitations'
)
`).Scan(&count)
if err != nil {
t.Fatalf("check host org region snapshot columns failed: %v", err)
}
if count != 0 {
t.Fatalf("host org identity tables must not keep region_id snapshot columns: count=%d", count)
}
}
func TestCheckBusinessCapabilityRequiresActiveAgencyOwner(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -336,6 +362,7 @@ func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
assertNoHostOrgRegionSnapshotColumns(t, repository)
repository.PutRegion(userdomain.Region{RegionID: 20, RegionCode: "R20", Name: "Region 20"})
seedActiveUser(t, repository, 801, 20)
seedActiveUser(t, repository, 802, 20)
@ -370,7 +397,7 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
if err != nil {
t.Fatalf("retry CreateBDLeader failed: %v", err)
}
if retriedLeader.UserID != leader.UserID {
if retriedLeader.UserID != leader.UserID || retriedLeader.RegionID != 20 {
t.Fatalf("leader retry must replay existing result: %+v", retriedLeader)
}
@ -407,6 +434,10 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
if bd.Role != hostdomain.BDRoleBD || bd.ParentLeaderUserID != 801 || bd.RegionID != 20 {
t.Fatalf("bd mismatch: %+v", bd)
}
bdAfterSnapshotDrift, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD)
if err != nil || bdAfterSnapshotDrift.RegionID != 20 {
t.Fatalf("bd profile must read region from users table: bd=%+v err=%v", bdAfterSnapshotDrift, err)
}
independentBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{
CommandID: "admin-create-bd-804-independent",
@ -508,10 +539,11 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
seedActiveUserInCountry(t, repository, 901, 30, "CN", "https://cdn.example/owner-901.png")
seedActiveUser(t, repository, 902, 30)
seedActiveUserInCountry(t, repository, 903, 30, "US", "")
seedActiveUser(t, repository, 900, 30)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 900,
Role: hostdomain.BDRoleLeader,
RegionID: 30,
RegionID: 999,
Status: hostdomain.BDStatusActive,
})
svc := newHostService(repository, 5000, 6000)
@ -557,6 +589,9 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID || agencies[0].OwnerAvatar != "https://cdn.example/owner-901.png" || agencies[0].ActiveHostCount != 1 {
t.Fatalf("created agency list should be scoped by country with display fields: %+v", agencies)
}
if agencies[0].RegionID != 30 {
t.Fatalf("agency search must read region from owner users table: %+v", agencies[0])
}
agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
if err != nil {
t.Fatalf("SearchAgencies failed: %v", err)
@ -676,10 +711,11 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 911, 40)
seedActiveUser(t, repository, 910, 40)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 910,
Role: hostdomain.BDRoleBD,
RegionID: 40,
RegionID: 999,
Status: hostdomain.BDStatusActive,
})
repository.PutHostProfile(hostdomain.HostProfile{

View File

@ -38,8 +38,8 @@ func (s *Service) GetUserIdentity(ctx context.Context, userID int64) (userdomain
// ResolveDisplayUserID 把短号解析成系统 user_id。
func (s *Service) ResolveDisplayUserID(ctx context.Context, displayUserID string) (userdomain.Identity, error) {
if !userdomain.ValidDisplayUserID(displayUserID) {
// 格式错误和未找到使用不同 reason便于客户端区分输入问题和占用状态
if !userdomain.ValidResolvableDisplayUserID(displayUserID) {
// 解析入口接受默认短号和后台发放靓号;非法字符仍然提前拒绝,避免把不可输入内容传进懒过期和查询链路
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDInvalid, "display_user_id format is invalid")
}
if s.identityRepository == nil {

View File

@ -70,21 +70,68 @@ func (r *Repository) FindPasswordByDisplayUserID(ctx context.Context, displayUse
}
var account authdomain.PasswordAccount
var displayUserIDKind string
var displayUserIDExpiresAtMs int64
err := r.db.QueryRowContext(ctx, `
SELECT pa.app_code, pa.user_id, u.current_display_user_id, pa.password_hash, pa.hash_alg, pa.created_at_ms, pa.updated_at_ms
FROM users u
INNER JOIN password_accounts pa ON pa.app_code = u.app_code AND pa.user_id = u.user_id
WHERE u.app_code = ? AND u.current_display_user_id = ?
`, appcode.FromContext(ctx), displayUserID).Scan(&account.AppCode, &account.UserID, &account.DisplayUserID, &account.PasswordHash, &account.HashAlg, &account.CreatedAtMs, &account.UpdatedAtMs)
SELECT
pa.app_code,
pa.user_id,
du.display_user_id,
du.display_user_id_kind,
COALESCE(du.expires_at_ms, 0),
pa.password_hash,
pa.hash_alg,
pa.created_at_ms,
pa.updated_at_ms
FROM user_display_user_ids du
INNER JOIN password_accounts pa
ON pa.app_code = du.app_code
AND pa.user_id = du.user_id
WHERE du.app_code = ?
AND du.display_user_id = ?
AND du.status = ?
AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?)
LIMIT 1
`, appcode.FromContext(ctx), displayUserID, string(userdomain.DisplayUserIDStatusActive), nowMs).Scan(&account.AppCode, &account.UserID, &account.DisplayUserID, &displayUserIDKind, &displayUserIDExpiresAtMs, &account.PasswordHash, &account.HashAlg, &account.CreatedAtMs, &account.UpdatedAtMs)
if err == sql.ErrNoRows {
// 短号不存在或未设置密码都返回 not found由 service 统一映射 AUTH_FAILED。
// 短号不存在、处于 held/released、已经过期或未设置密码都返回 not found由 service 统一映射 AUTH_FAILED。
return authdomain.PasswordAccount{}, xerr.New(xerr.NotFound, "password account not found")
}
if err != nil {
return authdomain.PasswordAccount{}, err
}
if err := r.repairPasswordLoginIdentity(ctx, account.AppCode, account.UserID, account.DisplayUserID, displayUserIDKind, displayUserIDExpiresAtMs, nowMs); err != nil {
return authdomain.PasswordAccount{}, err
}
return account, nil
}
func (r *Repository) repairPasswordLoginIdentity(ctx context.Context, appCode string, userID int64, displayUserID string, displayUserIDKind string, expiresAtMs int64, nowMs int64) error {
// 密码登录以 user_display_user_ids 的 active 行为准;同步 users 快照后,后续 freshUser 和 token 会携带同一个当前展示号。
_, err := r.db.ExecContext(ctx, `
UPDATE users
SET current_display_user_id = ?,
current_display_user_id_kind = ?,
current_display_user_id_expires_at_ms = ?,
updated_at_ms = GREATEST(updated_at_ms, ?)
WHERE app_code = ?
AND user_id = ?
AND (
current_display_user_id <> ?
OR current_display_user_id_kind <> ?
OR COALESCE(current_display_user_id_expires_at_ms, 0) <> ?
)
`, displayUserID, displayUserIDKind, nullableExpiresAt(expiresAtMs), nowMs,
appcode.Normalize(appCode), userID, displayUserID, displayUserIDKind, expiresAtMs)
return err
}
func nullableExpiresAt(value int64) any {
if value <= 0 {
return nil
}
return value
}
// CreateSession 创建新的 refresh session。

View File

@ -86,7 +86,8 @@ func (r *Repository) CreateBD(ctx context.Context, command hostservice.CreateBDC
return hostdomain.BDProfile{}, err
}
if command.ParentLeaderUserID > 0 {
// 独立 BD 不需要父级行锁;只有传入 Leader 时才锁定独立 Leader 表并校验启用状态和区域归属。
// 独立 BD 不需要父级行锁;只有传入 Leader 时才锁定独立 Leader 表并校验启用状态。
// Leader 的 RegionID 来自其 users 当前区域投影,不再相信 bd_leader_profiles 的历史快照。
leader, err := queryBDLeaderProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentLeaderUserID)
if err != nil {
return hostdomain.BDProfile{}, err
@ -298,6 +299,7 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
}
if command.ParentBDUserID > 0 {
// 独立 Agency 不需要父级行锁;传入父级时可挂普通 BD 或 Leader 自身,但必须是有效身份且同区域。
// 父级 RegionID 来自其 users 当前区域投影,避免角色表旧快照影响新 Agency 归属。
parentBD, err := queryActiveAgencyInviterProfile(ctx, tx, command.ParentBDUserID)
if err != nil {
return hostdomain.CreateAgencyResult{}, err

View File

@ -37,8 +37,8 @@ func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.Appl
return hostdomain.AgencyApplication{}, xerr.New(xerr.InvalidArgument, "user region is required")
}
// Agency 行必须锁住:加入开关、状态、区域是申请可创建性的条件,
// 如果后台同时禁用加入或迁移区域,这里必须和申请创建串行。
// Agency 行必须锁住:加入开关、状态、拥有者当前区域是申请可创建性的条件,
// 如果后台同时禁用加入或用户迁移区域,这里必须和申请创建串行。
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID)
if err != nil {
return hostdomain.AgencyApplication{}, err
@ -129,13 +129,13 @@ func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostse
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "agency is not active")
}
// 申请创建后用户区域可能被后台调整;通过前重新锁 users 行校验
// 确保最终成员关系的区域仍然和申请、Agency 三方一致
// 申请创建后申请人或 Agency 拥有者区域都可能被后台调整;通过前重新锁申请人 users 行
// 并用 Agency 当前拥有者区域校验,申请表不再保存区域快照
regionID, err := r.userRegion(ctx, tx, application.ApplicantUserID, "FOR UPDATE")
if err != nil {
return hostdomain.ReviewAgencyApplicationResult{}, err
}
if regionID != application.RegionID || regionID != agency.RegionID {
if regionID != agency.RegionID {
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.PermissionDenied, "application region no longer matches")
}
@ -162,7 +162,7 @@ func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostse
}
// host_profile 是用户成为主播的长期身份事实;成员关系是当前 Agency 归属事实。
// 两者和申请审核结果必须原子提交,否则列表页和身份页会看到不一致状态。
hostProfile, created, err := ensureHostProfileForMembership(ctx, tx, application.ApplicantUserID, application.RegionID, command.MembershipID, application.AgencyID, hostdomain.HostSourceApplication, command.NowMs)
hostProfile, created, err := ensureHostProfileForMembership(ctx, tx, application.ApplicantUserID, regionID, command.MembershipID, application.AgencyID, hostdomain.HostSourceApplication, command.NowMs)
if err != nil {
return hostdomain.ReviewAgencyApplicationResult{}, err
}
@ -170,7 +170,7 @@ func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostse
MembershipID: command.MembershipID,
AgencyID: application.AgencyID,
HostUserID: application.ApplicantUserID,
RegionID: application.RegionID,
RegionID: regionID,
MembershipType: hostdomain.MembershipTypeMember,
Status: hostdomain.MembershipStatusActive,
JoinedAtMs: command.NowMs,

View File

@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
mysqldriver "github.com/go-sql-driver/mysql"
@ -27,10 +28,17 @@ func New(db *sql.DB) *Repository {
}
const (
coinSellerProfileColumns = `
user_id, status, merchant_asset_type,
created_by_user_id, created_at_ms, updated_at_ms`
)
var (
// 列清单集中维护,保证查询函数和结果回放使用同一组投影字段。
// 可空外键在存储边界统一转成 0让领域结构体保持普通值类型。
hostProfileColumns = `
user_id, status, region_id, COALESCE(current_agency_id, 0),
// 角色表不再保存区域;领域读取统一从对应 users 行投影当前区域。
hostProfileColumns = fmt.Sprintf(`
user_id, status, %s, COALESCE(current_agency_id, 0),
COALESCE(current_membership_id, 0),
COALESCE((
SELECT owner_user_id
@ -40,32 +48,29 @@ const (
AND agencies.status = 'active'
LIMIT 1
), 0),
source, first_became_host_at_ms, created_at_ms, updated_at_ms`
agencyColumns = `
agency_id, owner_user_id, region_id, parent_bd_user_id, name, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms`
bdProfileColumns = `
user_id, role, region_id, COALESCE(parent_leader_user_id, 0), status,
created_by_user_id, created_at_ms, updated_at_ms`
bdLeaderProfileColumns = `
user_id, 'bd_leader', region_id, 0, status,
created_by_user_id, created_at_ms, updated_at_ms`
coinSellerProfileColumns = `
user_id, status, merchant_asset_type,
created_by_user_id, created_at_ms, updated_at_ms`
agencyMembershipColumns = `
membership_id, agency_id, host_user_id, region_id, membership_type, status,
source, first_became_host_at_ms, created_at_ms, updated_at_ms`, userRegionProjection("host_profiles", "user_id"))
agencyColumns = fmt.Sprintf(`
agency_id, owner_user_id, %s, parent_bd_user_id, name, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("agencies", "owner_user_id"))
bdProfileColumns = fmt.Sprintf(`
user_id, role, %s, COALESCE(parent_leader_user_id, 0), status,
created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_profiles", "user_id"))
bdLeaderProfileColumns = fmt.Sprintf(`
user_id, 'bd_leader', %s, 0, status,
created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_leader_profiles", "user_id"))
agencyMembershipColumns = fmt.Sprintf(`
membership_id, agency_id, host_user_id, %s, membership_type, status,
joined_at_ms, COALESCE(ended_at_ms, 0), COALESCE(ended_by_user_id, 0),
ended_reason, created_at_ms, updated_at_ms`
agencyApplicationColumns = `
application_id, command_id, applicant_user_id, agency_id, region_id, status,
ended_reason, created_at_ms, updated_at_ms`, userRegionProjection("agency_memberships", "host_user_id"))
agencyApplicationColumns = fmt.Sprintf(`
application_id, command_id, applicant_user_id, agency_id, %s, status,
COALESCE(reviewed_by_user_id, 0), review_reason, COALESCE(reviewed_at_ms, 0),
created_at_ms, updated_at_ms`
roleInvitationColumns = `
created_at_ms, updated_at_ms`, userRegionProjection("agency_applications", "applicant_user_id"))
roleInvitationColumns = fmt.Sprintf(`
invitation_id, command_id, invitation_type, status, inviter_user_id,
inviter_bd_user_id, target_user_id, region_id, agency_name, parent_bd_user_id,
inviter_bd_user_id, target_user_id, %s, agency_name, parent_bd_user_id,
COALESCE(parent_leader_user_id, 0), COALESCE(processed_by_user_id, 0),
process_reason, COALESCE(processed_at_ms, 0), created_at_ms, updated_at_ms`
process_reason, COALESCE(processed_at_ms, 0), created_at_ms, updated_at_ms`, userRegionProjection("role_invitations", "target_user_id"))
)
type sqlQueryer interface {
@ -86,7 +91,32 @@ func nullableRegionID(value int64) any {
return value
}
func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
func userRegionProjection(tableName string, userIDColumn string) string {
return fmt.Sprintf(`COALESCE((
SELECT u.region_id
FROM users u
WHERE u.app_code = %s.app_code
AND u.user_id = %s.%s
LIMIT 1
), 0)`, tableName, tableName, userIDColumn)
}
func userRegionValue(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
// 只读取 users 当前区域,不校验用户状态;用于历史邀请的发起人区域对齐,
// 避免旧库残留的邀请区域快照继续参与判断,又不额外改变“邀请已发出后可处理”的状态语义。
query := "SELECT COALESCE(region_id, 0) FROM users WHERE app_code = ? AND user_id = ?"
if strings.TrimSpace(lockClause) != "" {
query += " " + lockClause
}
var regionID int64
err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&regionID)
if err == sql.ErrNoRows {
return 0, xerr.New(xerr.NotFound, "user not found")
}
return regionID, err
}
func currentUserRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
// Host 领域不拥有 users 表,但所有角色流转都依赖用户当前有效区域。
// 调用方需要串行化用户身份变化时传入 FOR UPDATE 来锁住 users 行。
query := "SELECT COALESCE(region_id, 0), status FROM users WHERE app_code = ? AND user_id = ?"
@ -108,6 +138,10 @@ func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64,
return regionID, nil
}
func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
return currentUserRegion(ctx, q, userID, lockClause)
}
func (r *Repository) userCountry(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (string, error) {
// App 侧 Agency 推荐按用户当前国家收敛;国家来自用户资料事实,不能由客户端传入。
query := "SELECT COALESCE(country, ''), status FROM users WHERE app_code = ? AND user_id = ?"

View File

@ -69,7 +69,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
InviterUserID: command.InviterUserID,
InviterBDUserID: inviter.UserID,
TargetUserID: command.TargetUserID,
RegionID: inviter.RegionID,
RegionID: targetRegionID,
AgencyName: command.AgencyName,
ParentBDUserID: inviter.UserID,
ParentLeaderUserID: parentLeaderUserID,
@ -175,7 +175,7 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
InviterUserID: command.InviterUserID,
InviterBDUserID: inviter.UserID,
TargetUserID: command.TargetUserID,
RegionID: inviter.RegionID,
RegionID: targetRegionID,
ParentLeaderUserID: inviter.UserID,
ProcessedByUserID: command.InviterUserID,
ProcessReason: "direct_invite",
@ -335,12 +335,17 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
}
func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
// 邀请可能放置一段时间后才被接受,所以必须用当前用户区域再校验一次。
regionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
// 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都用 users 当前区域重新对齐。
// 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return false, err
}
if regionID != invitation.RegionID {
inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
if err != nil {
return false, err
}
if targetRegionID != inviterRegionID {
return false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
}
// 目标用户在邀请创建后可能已经加入其他 Agency接受前必须再次阻断有效归属。
@ -361,7 +366,7 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
agency := hostdomain.Agency{
AgencyID: command.AgencyID,
OwnerUserID: invitation.TargetUserID,
RegionID: invitation.RegionID,
RegionID: targetRegionID,
ParentBDUserID: invitation.ParentBDUserID,
Name: invitation.AgencyName,
Status: hostdomain.AgencyStatusActive,
@ -375,7 +380,7 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
if err := insertAgency(ctx, tx, agency); err != nil {
return false, mapHostDuplicateError(err)
}
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, invitation.RegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
if err != nil {
return false, err
}
@ -383,7 +388,7 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
MembershipID: command.MembershipID,
AgencyID: agency.AgencyID,
HostUserID: invitation.TargetUserID,
RegionID: invitation.RegionID,
RegionID: targetRegionID,
MembershipType: hostdomain.MembershipTypeOwner,
Status: hostdomain.MembershipStatusActive,
JoinedAtMs: command.NowMs,
@ -400,12 +405,16 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
}
func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) error {
// BD 邀请同样以当前用户区域为准;跨区调整后不能接受旧区域邀请
regionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
// BD 邀请同样以目标用户和发起 Leader 的 users 当前区域为准;旧邀请快照不能继续授权跨区接受
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return err
}
if regionID != invitation.RegionID {
leaderRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
if err != nil {
return err
}
if targetRegionID != leaderRegionID {
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
}
// BD 身份是独立角色事实,不依赖 host_profile任何已有普通 BD 行都不能重复插入。
@ -417,7 +426,7 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
bd := hostdomain.BDProfile{
UserID: invitation.TargetUserID,
Role: hostdomain.BDRoleBD,
RegionID: invitation.RegionID,
RegionID: targetRegionID,
ParentLeaderUserID: invitation.ParentLeaderUserID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: invitation.InviterUserID,

View File

@ -28,7 +28,7 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
SELECT
a.agency_id,
a.owner_user_id,
a.region_id,
COALESCE(owner.region_id, 0),
a.parent_bd_user_id,
a.name,
a.status,
@ -62,7 +62,7 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
GROUP BY
a.agency_id,
a.owner_user_id,
a.region_id,
owner.region_id,
a.parent_bd_user_id,
a.name,
a.status,

View File

@ -99,10 +99,10 @@ func insertHostProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.HostP
// 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。
_, err := tx.ExecContext(ctx, `
INSERT INTO host_profiles (
app_code, user_id, status, region_id, current_agency_id, current_membership_id, source,
app_code, user_id, status, current_agency_id, current_membership_id, source,
first_became_host_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.RegionID, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.Source, profile.FirstBecameHostAtMs, profile.CreatedAtMs, profile.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.Source, profile.FirstBecameHostAtMs, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
@ -111,9 +111,9 @@ func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdo
// 状态、来源、首次成为主播时间属于身份历史,不在这里重写。
_, err := tx.ExecContext(ctx, `
UPDATE host_profiles
SET region_id = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
SET current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, profile.RegionID, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
`, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
@ -130,10 +130,10 @@ func insertAgency(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) err
// 第一阶段只有接受 Agency 邀请时会创建 Agency。
_, err := tx.ExecContext(ctx, `
INSERT INTO agencies (
app_code, agency_id, owner_user_id, region_id, parent_bd_user_id, name, status,
app_code, agency_id, owner_user_id, parent_bd_user_id, name, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), agency.AgencyID, agency.OwnerUserID, agency.RegionID, agency.ParentBDUserID, agency.Name, agency.Status, agency.JoinEnabled, agency.MaxHosts, agency.CreatedByUserID, agency.CreatedAtMs, agency.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), agency.AgencyID, agency.OwnerUserID, agency.ParentBDUserID, agency.Name, agency.Status, agency.JoinEnabled, agency.MaxHosts, agency.CreatedByUserID, agency.CreatedAtMs, agency.UpdatedAtMs)
return err
}
@ -151,10 +151,10 @@ func insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfi
// 普通 BD 身份行和 host_profile 相互独立BD 用户不会自动成为 Host也不会隐式拥有 BD Leader 权限。
_, err := tx.ExecContext(ctx, `
INSERT INTO bd_profiles (
app_code, user_id, role, region_id, parent_leader_user_id, status,
app_code, user_id, role, parent_leader_user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Role, profile.RegionID, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Role, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
@ -162,10 +162,10 @@ func insertBDLeaderProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.B
// BD Leader 使用独立表承载负责人身份,避免和普通 BD 工资、状态、团队归属混成同一条事实。
_, err := tx.ExecContext(ctx, `
INSERT INTO bd_leader_profiles (
app_code, user_id, region_id, status,
app_code, user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.RegionID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
@ -214,10 +214,10 @@ func insertAgencyMembership(ctx context.Context, tx *sql.Tx, membership hostdoma
// 成员关系是 Agency 关系历史行;有效且当前的关系也会冗余到身份表。
_, err := tx.ExecContext(ctx, `
INSERT INTO agency_memberships (
app_code, membership_id, agency_id, host_user_id, region_id, membership_type, status,
app_code, membership_id, agency_id, host_user_id, membership_type, status,
joined_at_ms, ended_at_ms, ended_by_user_id, ended_reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), membership.MembershipID, membership.AgencyID, membership.HostUserID, membership.RegionID, membership.MembershipType, membership.Status, membership.JoinedAtMs, nullableRegionID(membership.EndedAtMs), nullableRegionID(membership.EndedByUserID), membership.EndedReason, membership.CreatedAtMs, membership.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), membership.MembershipID, membership.AgencyID, membership.HostUserID, membership.MembershipType, membership.Status, membership.JoinedAtMs, nullableRegionID(membership.EndedAtMs), nullableRegionID(membership.EndedByUserID), membership.EndedReason, membership.CreatedAtMs, membership.UpdatedAtMs)
return err
}
@ -232,13 +232,13 @@ func endAgencyMembership(ctx context.Context, tx *sql.Tx, membership hostdomain.
}
func insertAgencyApplication(ctx context.Context, tx *sql.Tx, application hostdomain.AgencyApplication) error {
// 申请记录保留申请人、Agency 和区域快照,审核时基于这份快照再校验当前事实
// 申请记录只保留申请人和 Agency区域每次读取时都从申请人 users 行投影
_, err := tx.ExecContext(ctx, `
INSERT INTO agency_applications (
app_code, application_id, command_id, applicant_user_id, agency_id, region_id, status,
app_code, application_id, command_id, applicant_user_id, agency_id, status,
reviewed_by_user_id, review_reason, reviewed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), application.ApplicationID, application.CommandID, application.ApplicantUserID, application.AgencyID, application.RegionID, application.Status, nullableRegionID(application.ReviewedByUserID), application.ReviewReason, nullableRegionID(application.ReviewedAtMs), application.CreatedAtMs, application.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), application.ApplicationID, application.CommandID, application.ApplicantUserID, application.AgencyID, application.Status, nullableRegionID(application.ReviewedByUserID), application.ReviewReason, nullableRegionID(application.ReviewedAtMs), application.CreatedAtMs, application.UpdatedAtMs)
return err
}
@ -257,11 +257,11 @@ func insertRoleInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain
_, err := tx.ExecContext(ctx, `
INSERT INTO role_invitations (
app_code, invitation_id, command_id, invitation_type, status, inviter_user_id,
inviter_bd_user_id, target_user_id, region_id, agency_name, parent_bd_user_id,
inviter_bd_user_id, target_user_id, agency_name, parent_bd_user_id,
parent_leader_user_id, processed_by_user_id, process_reason, processed_at_ms,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), invitation.InvitationID, invitation.CommandID, invitation.InvitationType, invitation.Status, invitation.InviterUserID, invitation.InviterBDUserID, invitation.TargetUserID, invitation.RegionID, invitation.AgencyName, invitation.ParentBDUserID, nullableRegionID(invitation.ParentLeaderUserID), nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.CreatedAtMs, invitation.UpdatedAtMs)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), invitation.InvitationID, invitation.CommandID, invitation.InvitationType, invitation.Status, invitation.InviterUserID, invitation.InviterBDUserID, invitation.TargetUserID, invitation.AgencyName, invitation.ParentBDUserID, nullableRegionID(invitation.ParentLeaderUserID), nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.CreatedAtMs, invitation.UpdatedAtMs)
return err
}

View File

@ -12,16 +12,19 @@ import (
)
func (r *Repository) GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error) {
// 直接读取 users 当前快照;是否懒过期由 service 层统一判断。
identity, err := r.queryActiveIdentity(ctx, "du.user_id = ?", userID)
if err == nil {
return identity, nil
}
if err != sql.ErrNoRows {
return userdomain.Identity{}, err
}
// 旧数据如果缺少 user_display_user_ids active 行,仍回退 users 快照,避免身份表补数据前直接阻断用户。
user, err := userstorage.New(r.db).GetUser(ctx, userID)
if err != nil {
return userdomain.Identity{}, err
}
if user.CurrentDisplayUserID == "" {
// 理论上用户创建事务会保证短号存在,该分支保护脏数据。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found")
}
return user.EffectiveIdentity(), nil
}
@ -34,16 +37,112 @@ func (r *Repository) ResolveDisplayUserID(ctx context.Context, displayUserID str
return userdomain.Identity{}, err
}
user, err := userstorage.QueryUser(ctx, r.db, "WHERE current_display_user_id = ?", displayUserID)
identity, err := r.queryActiveIdentity(ctx, "du.display_user_id = ? AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?)", displayUserID, nowMs)
if err == sql.ErrNoRows {
// held 默认号和过期靓号都不会匹配 current_display_user_id
// held 默认号、释放号和过期靓号都不能作为当前展示号解析
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found")
}
if err != nil {
return userdomain.Identity{}, err
}
if err := r.repairUserCurrentIdentity(ctx, identity, nowMs); err != nil {
return userdomain.Identity{}, err
}
return user.EffectiveIdentity(), nil
return identity, nil
}
func (r *Repository) queryActiveIdentity(ctx context.Context, clause string, args ...any) (userdomain.Identity, error) {
// user_display_user_ids 的 active 行是展示号归属事实users.current_display_user_id 只是高频读取快照。
// 解析入口优先读取事实表,可以容忍历史脚本或异常中断导致 users 快照暂时没有同步到最新靓号。
appCode := appcode.FromContext(ctx)
queryArgs := []any{
string(userdomain.DisplayUserIDKindPretty),
appCode,
string(userdomain.DisplayUserIDStatusActive),
}
queryArgs = append(queryArgs, args...)
row := r.db.QueryRowContext(ctx, `
SELECT
du.app_code,
du.user_id,
du.display_user_id,
u.default_display_user_id,
du.display_user_id_kind,
COALESCE(du.expires_at_ms, 0),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases pdl
LEFT JOIN pretty_display_ids pdi
ON pdi.app_code = pdl.app_code
AND pdi.assigned_lease_id = pdl.lease_id
AND pdi.assigned_user_id = pdl.user_id
WHERE pdl.app_code = du.app_code
AND pdl.user_id = du.user_id
AND pdl.display_user_id = du.display_user_id
AND pdl.status = 'active'
ORDER BY pdl.starts_at_ms DESC
LIMIT 1
), ''),
CASE WHEN du.display_user_id_kind = ? THEN du.display_user_id ELSE '' END,
du.status
FROM user_display_user_ids du
INNER JOIN users u
ON u.app_code = du.app_code
AND u.user_id = du.user_id
WHERE du.app_code = ?
AND du.status = ?
AND `+clause+`
LIMIT 1
`, queryArgs...)
var identity userdomain.Identity
var kind string
var status string
if err := row.Scan(
&identity.AppCode,
&identity.UserID,
&identity.DisplayUserID,
&identity.DefaultDisplayUserID,
&kind,
&identity.DisplayUserIDExpiresAtMs,
&identity.PrettyID,
&identity.PrettyDisplayUserID,
&status,
); err != nil {
return userdomain.Identity{}, err
}
identity.DisplayUserIDKind = userdomain.DisplayUserIDKind(kind)
identity.Status = userdomain.DisplayUserIDStatus(status)
return identity, nil
}
func (r *Repository) repairUserCurrentIdentity(ctx context.Context, identity userdomain.Identity, nowMs int64) error {
// users.current_display_user_id 是读取快照,真正归属以 user_display_user_ids active 行为准。
// 读路径发现快照滞后时做幂等同步,保证后续 GetUser、token 和 H5 账号确认都返回同一个当前展示号。
_, err := r.db.ExecContext(ctx, `
UPDATE users
SET current_display_user_id = ?,
current_display_user_id_kind = ?,
current_display_user_id_expires_at_ms = ?,
updated_at_ms = GREATEST(updated_at_ms, ?)
WHERE app_code = ?
AND user_id = ?
AND (
current_display_user_id <> ?
OR current_display_user_id_kind <> ?
OR COALESCE(current_display_user_id_expires_at_ms, 0) <> ?
)
`, identity.DisplayUserID, string(identity.DisplayUserIDKind), nullableExpiresAt(identity.DisplayUserIDExpiresAtMs), nowMs,
appcode.Normalize(identity.AppCode), identity.UserID, identity.DisplayUserID, string(identity.DisplayUserIDKind), identity.DisplayUserIDExpiresAtMs)
return err
}
func nullableExpiresAt(value int64) any {
if value <= 0 {
return nil
}
return value
}
// ChangeDisplayUserID 修改默认短号;有 active 靓号时拒绝,避免过期恢复目标不清晰。

View File

@ -663,17 +663,16 @@ func (r *Repository) PutHostProfile(profile hostdomain.HostProfile) {
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO host_profiles (
user_id, status, region_id, current_agency_id, current_membership_id, source,
user_id, status, current_agency_id, current_membership_id, source,
first_became_host_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
region_id = VALUES(region_id),
current_agency_id = VALUES(current_agency_id),
current_membership_id = VALUES(current_membership_id),
source = VALUES(source),
updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Status, profile.RegionID, nullableInt64(profile.CurrentAgencyID), nullableInt64(profile.CurrentMembershipID), profile.Source, profile.FirstBecameHostAtMs, profile.CreatedAtMs, profile.UpdatedAtMs)
`, profile.UserID, profile.Status, nullableInt64(profile.CurrentAgencyID), nullableInt64(profile.CurrentMembershipID), profile.Source, profile.FirstBecameHostAtMs, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed host profile %d failed: %v", profile.UserID, err)
}
@ -701,13 +700,12 @@ func (r *Repository) PutBDProfile(profile hostdomain.BDProfile) {
if profile.Role == hostdomain.BDRoleLeader {
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO bd_leader_profiles (
user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
user_id, status, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.RegionID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
`, profile.UserID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed bd leader profile %d failed: %v", profile.UserID, err)
}
@ -716,16 +714,15 @@ func (r *Repository) PutBDProfile(profile hostdomain.BDProfile) {
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO bd_profiles (
user_id, role, region_id, parent_leader_user_id, status,
user_id, role, parent_leader_user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
role = VALUES(role),
region_id = VALUES(region_id),
parent_leader_user_id = VALUES(parent_leader_user_id),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Role, profile.RegionID, nullableInt64(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
`, profile.UserID, profile.Role, nullableInt64(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed bd profile %d failed: %v", profile.UserID, err)
}
@ -752,19 +749,18 @@ func (r *Repository) PutAgency(agency hostdomain.Agency) {
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO agencies (
agency_id, owner_user_id, region_id, parent_bd_user_id, name, status,
agency_id, owner_user_id, parent_bd_user_id, name, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
owner_user_id = VALUES(owner_user_id),
region_id = VALUES(region_id),
parent_bd_user_id = VALUES(parent_bd_user_id),
name = VALUES(name),
status = VALUES(status),
join_enabled = VALUES(join_enabled),
max_hosts = VALUES(max_hosts),
updated_at_ms = VALUES(updated_at_ms)
`, agency.AgencyID, agency.OwnerUserID, agency.RegionID, agency.ParentBDUserID, agency.Name, agency.Status, agency.JoinEnabled, agency.MaxHosts, agency.CreatedByUserID, agency.CreatedAtMs, agency.UpdatedAtMs)
`, agency.AgencyID, agency.OwnerUserID, agency.ParentBDUserID, agency.Name, agency.Status, agency.JoinEnabled, agency.MaxHosts, agency.CreatedByUserID, agency.CreatedAtMs, agency.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed agency %d failed: %v", agency.AgencyID, err)
}

View File

@ -298,9 +298,56 @@ func TestResolveDisplayUserIDErrorCarriesReason(t *testing.T) {
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(mysqltest.NewRepository(t)))
_, err := server.ResolveDisplayUserID(context.Background(), &userv1.ResolveDisplayUserIDRequest{
DisplayUserId: "bad",
DisplayUserId: "bad!",
})
if got := xerr.ReasonFromGRPC(err); got != xerr.DisplayUserIDInvalid {
t.Fatalf("reason mismatch: got %s", got)
}
}
func TestResolveDisplayUserIDAllowsPrettyDisplayID(t *testing.T) {
// 真实解析链路必须允许当前 active 靓号作为入参,后台和 H5 不应该要求调用方先知道内部 user_id。
repository := mysqltest.NewRepository(t)
repository.PutUser(userdomain.User{
AppCode: "lalu",
UserID: 123456,
DefaultDisplayUserID: "123456",
CurrentDisplayUserID: "123456",
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.StatusActive,
CreatedAtMs: 1000,
UpdatedAtMs: 1000,
})
if _, err := repository.RawDB().ExecContext(context.Background(), `
UPDATE user_display_user_ids
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND display_user_id = ?
`, string(userdomain.DisplayUserIDStatusHeld), int64(1001), "lalu", int64(123456), "123456"); err != nil {
t.Fatalf("mark default display_user_id held failed: %v", err)
}
if _, err := repository.RawDB().ExecContext(context.Background(), `
INSERT INTO user_display_user_ids (app_code, display_user_id, user_id, display_user_id_kind, status, assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "lalu", "1111cc1111", int64(123456), string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive), int64(1001), int64(1001), int64(1001), int64(1001)); err != nil {
t.Fatalf("seed active pretty display_user_id failed: %v", err)
}
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository))
resp, err := server.ResolveDisplayUserID(context.Background(), &userv1.ResolveDisplayUserIDRequest{
Meta: &userv1.RequestMeta{AppCode: "lalu", RequestId: "req-pretty-resolve"},
DisplayUserId: "1111cc1111",
})
if err != nil {
t.Fatalf("ResolveDisplayUserID should accept pretty display_user_id: %v", err)
}
if resp.GetIdentity().GetUserId() != 123456 || resp.GetIdentity().GetDisplayUserId() != "1111cc1111" || resp.GetIdentity().GetDisplayUserIdKind() != string(userdomain.DisplayUserIDKindPretty) {
t.Fatalf("pretty display_user_id identity mismatch: %+v", resp.GetIdentity())
}
user, err := repository.GetUser(context.Background(), 123456)
if err != nil {
t.Fatalf("GetUser after pretty resolve failed: %v", err)
}
if user.CurrentDisplayUserID != "1111cc1111" || user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
t.Fatalf("users current display snapshot was not repaired: %+v", user)
}
}

View File

@ -52,12 +52,13 @@ google_play:
http_timeout: "10s"
consume_enabled: true
mifapay:
# MiFaPay 是 H5 统一三方支付入口;容器默认打开功能但不写真实商户凭证,部署时从密钥环境注入
# MiFaPay 是 H5 统一三方支付入口;商家公钥只用于记录商户侧配置,真实下单签名必须继续使用商户私钥
enabled: true
mer_account: ""
mer_no: ""
mer_account: "6000055d34f445418124ab7c0beb81a6"
mer_no: "10001910"
merchant_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVDVrlVS+HkfLx+56v1Fyfqj5Gd+DRkPIK4EOu+SMi+wtP4dwPBeXkrL1w+nSC0UFQ2Py690qdRu1FCSTNMWC8kfzddC4xlckJKXa9NoDyeJIN/Z5Za9Fg2znfqTee9BE8NnJk4EtmRwKJQaP8sCrYsIDv/bagywM9t25lKn2bmUOZKVVUtn/Xf5OBYDE6AVHB83J+HxX5VPLie+dbRdsRR+hoZs/vzLTNlZsTcrAnWUf7cqd9XNr39gXnXgdw61kzAC9RtSLbRPAo474F03xmLZKsL70vmBgex9/paljPxNnFVq1CPxmam5yzY0NtS6iXUlU7ttqAzBolSwPWla7QIDAQAB"
private_key: ""
platform_public_key: ""
platform_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Kt1qR5jwf4xUVHqo2pLZW2Xb43P9c8BjMHeq4ih+u+8Qr5YtJa2EPv/sNoAQktjaUhsg9tJvtn1Pc46xIo3XeZiicdn91eNSdC6mZ8NxT6IJyyEr5x15o6hJORkiDbYg8k3rf40bZl8B6Oz6pEGPIYrmO8DVWN0Ak7qmWhHF5ePKusfLjy4TWEt2JmQGHAz+lfkDGaQzwtrC2kt4adYwcdpY78B4fH8Ssofr2fLuMQYl2QYWIV0lplAQicuTRhgWj4tora/MoK/LbKKFCthrElGvVJtial1QCfhOOUVgdjzGKdmuer8qiuseDraVIKYkAjuY9fGBWF+2N7mNIWOKwIDAQAB"
api_base_url: "https://platformtest.xqdmipay.com"
http_timeout: "10s"
tron_grid:

View File

@ -52,12 +52,13 @@ google_play:
http_timeout: "10s"
consume_enabled: true
mifapay:
# MiFaPay 是 H5 统一三方支付入口;以下值是占位符,线上必须通过密钥系统或环境配置提供真实值
# MiFaPay 是 H5 统一三方支付入口;商户私钥必须通过密钥系统或环境配置提供,不能把商家公钥写进 private_key
enabled: true
mer_account: "MIFAPAY_MER_ACCOUNT"
mer_no: "MIFAPAY_MER_NO"
mer_account: "6000055d34f445418124ab7c0beb81a6"
mer_no: "10001910"
merchant_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVDVrlVS+HkfLx+56v1Fyfqj5Gd+DRkPIK4EOu+SMi+wtP4dwPBeXkrL1w+nSC0UFQ2Py690qdRu1FCSTNMWC8kfzddC4xlckJKXa9NoDyeJIN/Z5Za9Fg2znfqTee9BE8NnJk4EtmRwKJQaP8sCrYsIDv/bagywM9t25lKn2bmUOZKVVUtn/Xf5OBYDE6AVHB83J+HxX5VPLie+dbRdsRR+hoZs/vzLTNlZsTcrAnWUf7cqd9XNr39gXnXgdw61kzAC9RtSLbRPAo474F03xmLZKsL70vmBgex9/paljPxNnFVq1CPxmam5yzY0NtS6iXUlU7ttqAzBolSwPWla7QIDAQAB"
private_key: "MIFAPAY_RSA_PRIVATE_KEY_PKCS8"
platform_public_key: "MIFAPAY_PLATFORM_PUBLIC_KEY_X509"
platform_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Kt1qR5jwf4xUVHqo2pLZW2Xb43P9c8BjMHeq4ih+u+8Qr5YtJa2EPv/sNoAQktjaUhsg9tJvtn1Pc46xIo3XeZiicdn91eNSdC6mZ8NxT6IJyyEr5x15o6hJORkiDbYg8k3rf40bZl8B6Oz6pEGPIYrmO8DVWN0Ak7qmWhHF5ePKusfLjy4TWEt2JmQGHAz+lfkDGaQzwtrC2kt4adYwcdpY78B4fH8Ssofr2fLuMQYl2QYWIV0lplAQicuTRhgWj4tora/MoK/LbKKFCthrElGvVJtial1QCfhOOUVgdjzGKdmuer8qiuseDraVIKYkAjuY9fGBWF+2N7mNIWOKwIDAQAB"
api_base_url: "https://platform.xqdmipay.com"
http_timeout: "10s"
tron_grid:

View File

@ -52,12 +52,13 @@ google_play:
http_timeout: "10s"
consume_enabled: true
mifapay:
# MiFaPay 是 H5 统一三方支付入口;本地默认打开功能但不配置真实商户凭证,缺凭证时 H5 不展示三方支付下单入口
# MiFaPay 是 H5 统一三方支付入口;商家公钥只用于记录商户侧配置,真实下单签名必须继续使用商户私钥
enabled: true
mer_account: ""
mer_no: ""
mer_account: "6000055d34f445418124ab7c0beb81a6"
mer_no: "10001910"
merchant_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVDVrlVS+HkfLx+56v1Fyfqj5Gd+DRkPIK4EOu+SMi+wtP4dwPBeXkrL1w+nSC0UFQ2Py690qdRu1FCSTNMWC8kfzddC4xlckJKXa9NoDyeJIN/Z5Za9Fg2znfqTee9BE8NnJk4EtmRwKJQaP8sCrYsIDv/bagywM9t25lKn2bmUOZKVVUtn/Xf5OBYDE6AVHB83J+HxX5VPLie+dbRdsRR+hoZs/vzLTNlZsTcrAnWUf7cqd9XNr39gXnXgdw61kzAC9RtSLbRPAo474F03xmLZKsL70vmBgex9/paljPxNnFVq1CPxmam5yzY0NtS6iXUlU7ttqAzBolSwPWla7QIDAQAB"
private_key: ""
platform_public_key: ""
platform_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Kt1qR5jwf4xUVHqo2pLZW2Xb43P9c8BjMHeq4ih+u+8Qr5YtJa2EPv/sNoAQktjaUhsg9tJvtn1Pc46xIo3XeZiicdn91eNSdC6mZ8NxT6IJyyEr5x15o6hJORkiDbYg8k3rf40bZl8B6Oz6pEGPIYrmO8DVWN0Ak7qmWhHF5ePKusfLjy4TWEt2JmQGHAz+lfkDGaQzwtrC2kt4adYwcdpY78B4fH8Ssofr2fLuMQYl2QYWIV0lplAQicuTRhgWj4tora/MoK/LbKKFCthrElGvVJtial1QCfhOOUVgdjzGKdmuer8qiuseDraVIKYkAjuY9fGBWF+2N7mNIWOKwIDAQAB"
api_base_url: "https://platformtest.xqdmipay.com"
http_timeout: "10s"
tron_grid:
@ -68,7 +69,7 @@ tron_grid:
http_timeout: "10s"
external_recharge:
usdt_trc20_enabled: true
usdt_trc20_address: ""
usdt_trc20_address: "cccccccccccc"
mifapay_notify_url: ""
mifapay_return_url: ""
outbox_worker:

View File

@ -115,6 +115,7 @@ type MifaPayConfig struct {
Enabled bool `yaml:"enabled"`
MerAccount string `yaml:"mer_account"`
MerNo string `yaml:"mer_no"`
MerchantPublicKey string `yaml:"merchant_public_key"`
PrivateKey string `yaml:"private_key"`
PlatformPublicKey string `yaml:"platform_public_key"`
APIBaseURL string `yaml:"api_base_url"`

View File

@ -1,6 +1,12 @@
package config
import "testing"
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"strings"
"testing"
)
func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
cfg, err := Load("../../configs/config.yaml")
@ -40,3 +46,37 @@ func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T)
t.Fatalf("realtime outbox event types mismatch: %+v", cfg.RealtimeOutboxWorker.EventTypes)
}
}
func TestLoadMifaPayMerchantConfig(t *testing.T) {
for _, path := range []string{
"../../configs/config.yaml",
"../../configs/config.docker.yaml",
"../../configs/config.tencent.example.yaml",
} {
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load %s failed: %v", path, err)
}
if cfg.MifaPay.MerNo != "10001910" || cfg.MifaPay.MerAccount != "6000055d34f445418124ab7c0beb81a6" {
t.Fatalf("mifapay merchant identity mismatch in %s: %+v", path, cfg.MifaPay)
}
assertRSAPublicKey(t, path+" merchant_public_key", cfg.MifaPay.MerchantPublicKey)
assertRSAPublicKey(t, path+" platform_public_key", cfg.MifaPay.PlatformPublicKey)
}
}
func assertRSAPublicKey(t *testing.T, name string, value string) {
t.Helper()
clean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(strings.TrimSpace(value))
raw, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
t.Fatalf("%s should be base64 DER public key: %v", name, err)
}
key, err := x509.ParsePKIXPublicKey(raw)
if err != nil {
t.Fatalf("%s should parse as PKIX public key: %v", name, err)
}
if _, ok := key.(*rsa.PublicKey); !ok {
t.Fatalf("%s should be RSA public key, got %T", name, key)
}
}

View File

@ -1947,6 +1947,187 @@ func TestTransferSalaryToCoinSellerRequiresConfiguredRate(t *testing.T) {
}
}
// TestH5RechargeOptionsFilterMethodsByUserCountry 验证 H5 支付方式严格按目标用户国家返回,同时按普通用户/币商区分商品档位。
func TestH5RechargeOptionsFilterMethodsByUserCountry(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
svc.SetMifaPayClient(&fakeMifaPayClient{})
normalProduct := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7100, 1_500_000, 120000)
sellerProduct := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7100, 10_000_000, 880000)
normalOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
AppCode: appcode.Default,
TargetUserID: 41001,
TargetRegionID: 7100,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceNormal,
})
if err != nil {
t.Fatalf("ListH5RechargeOptions normal failed: %v", err)
}
if len(normalOptions.Products) != 1 || normalOptions.Products[0].ProductID != normalProduct.ProductID {
t.Fatalf("normal h5 products must only include normal product: %+v", normalOptions.Products)
}
if !paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Card", "MADA") ||
!paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Ewallet", "ApplePay") ||
!paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Ewallet", "STCPay") ||
paymentMethodsContain(normalOptions.PaymentMethods, "ID", "BankTransfer", "BCA") {
t.Fatalf("SA options must only expose SA MiFaPay methods: %+v", normalOptions.PaymentMethods)
}
for _, method := range normalOptions.PaymentMethods {
if method.CountryCode != "SA" && method.CountryCode != "GLOBAL" {
t.Fatalf("payment method country leaked for SA user: %+v", method)
}
}
sellerOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
AppCode: appcode.Default,
TargetUserID: 41002,
TargetRegionID: 7100,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceCoinSeller,
})
if err != nil {
t.Fatalf("ListH5RechargeOptions coin seller failed: %v", err)
}
if len(sellerOptions.Products) != 1 || sellerOptions.Products[0].ProductID != sellerProduct.ProductID {
t.Fatalf("coin seller h5 products must only include seller product: %+v", sellerOptions.Products)
}
if !paymentMethodsContain(sellerOptions.PaymentMethods, "SA", "Card", "MADA") || paymentMethodsContain(sellerOptions.PaymentMethods, "ID", "BankTransfer", "BCA") {
t.Fatalf("coin seller SA options must use the same country-filtered MiFaPay methods: %+v", sellerOptions.PaymentMethods)
}
}
// TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry 锁定下单阶段再次校验国家,防止前端伪造 method_id 绕过 options 过滤。
func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
svc.SetMifaPayClient(&fakeMifaPayClient{})
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7200, 1_500_000, 120000)
idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA")
_, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
AppCode: appcode.Default,
CommandID: "cmd-h5-cross-country-method",
TargetUserID: 42001,
TargetRegionID: 7200,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceNormal,
ProductID: product.ProductID,
ProviderCode: ledger.PaymentProviderMifaPay,
PaymentMethodID: idBankMethodID,
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("expected CONFLICT for cross-country MiFaPay method, got %v", err)
}
}
// TestH5USDTRechargeCreditsNormalUserCoinWallet 验证 H5 普通用户 USDT 充值只进入普通 COIN 钱包,并写普通充值记录。
func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
tron := &fakeTronUSDTClient{}
svc.SetTronUSDTClient(tron)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Address: "TPlatformReceiveAddress",
})
product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7300, 1_500_000, 120000)
order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
AppCode: appcode.Default,
CommandID: "cmd-h5-normal-usdt",
TargetUserID: 43001,
TargetRegionID: 7300,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceNormal,
ProductID: product.ProductID,
ProviderCode: ledger.PaymentProviderUSDTTRC20,
})
if err != nil {
t.Fatalf("CreateH5RechargeOrder normal usdt failed: %v", err)
}
if order.Status != ledger.ExternalRechargeStatusPending || order.ReceiveAddress != "TPlatformReceiveAddress" || order.USDMinorAmount != 150 {
t.Fatalf("normal usdt order snapshot mismatch: %+v", order)
}
credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{
AppCode: appcode.Default,
OrderID: order.OrderID,
TargetUserID: 43001,
TxHash: "tx-normal-usdt",
})
if err != nil {
t.Fatalf("SubmitH5RechargeTx normal failed: %v", err)
}
if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
t.Fatalf("normal usdt credited order mismatch: %+v", credited)
}
if tron.lastTxHash != "tx-normal-usdt" || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 150 {
t.Fatalf("tron verification input mismatch: %+v", tron)
}
assertBalance(t, svc, 43001, ledger.AssetCoin, 120000)
assertBalance(t, svc, 43001, ledger.AssetCoinSellerCoin, 0)
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
t.Fatalf("normal h5 recharge should write one user recharge record, got %d", got)
}
if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ?", int64(43001)); got != 1 {
t.Fatalf("normal h5 recharge should update user recharge stats, got %d", got)
}
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ?", credited.TransactionID); got != 0 {
t.Fatalf("normal h5 recharge must not write seller stock record, got %d", got)
}
}
// TestH5USDTRechargeCreditsCoinSellerWalletOnly 验证 H5 币商充值只进入币商专用钱包,并写币商库存记录而不是普通用户充值记录。
func TestH5USDTRechargeCreditsCoinSellerWalletOnly(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
svc.SetTronUSDTClient(&fakeTronUSDTClient{})
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Address: "TPlatformReceiveAddress",
})
product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7400, 10_000_000, 880000)
order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
AppCode: appcode.Default,
CommandID: "cmd-h5-seller-usdt",
TargetUserID: 44001,
TargetRegionID: 7400,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceCoinSeller,
ProductID: product.ProductID,
ProviderCode: ledger.PaymentProviderUSDTTRC20,
})
if err != nil {
t.Fatalf("CreateH5RechargeOrder seller usdt failed: %v", err)
}
credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{
AppCode: appcode.Default,
OrderID: order.OrderID,
TargetUserID: 44001,
TxHash: "tx-seller-usdt",
})
if err != nil {
t.Fatalf("SubmitH5RechargeTx seller failed: %v", err)
}
assertBalance(t, svc, 44001, ledger.AssetCoinSellerCoin, 880000)
assertBalance(t, svc, 44001, ledger.AssetCoin, 0)
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 0 {
t.Fatalf("seller h5 recharge must not write user recharge record, got %d", got)
}
if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ?", int64(44001)); got != 0 {
t.Fatalf("seller h5 recharge must not update user recharge stats, got %d", got)
}
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_currency_code = ? AND paid_amount_micro = ?", credited.TransactionID, int64(880000), ledger.PaidCurrencyUSDT, int64(10_000_000)); got != 1 {
t.Fatalf("seller h5 recharge should write one seller stock record, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", credited.TransactionID, "WalletCoinSellerStockPurchased"); got != 1 {
t.Fatalf("seller h5 recharge should emit seller stock outbox event, got %d", got)
}
}
// TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。
func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -3877,6 +4058,86 @@ func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) {
}
}
// TestListAdminRechargeProductsWithoutAudienceOrPlatformFilterIncludesCoinSellerWeb 锁定后台“全部用户类型/全部平台”的语义。
// App/H5 购买入口可以把空 audience_type 解释成普通用户,但后台配置列表的空值必须表示不筛选,
// 否则运营选择“全部”时只看到 normal/android刚配置的 coin_seller/web 档位会被后端静默过滤。
func TestListAdminRechargeProductsWithoutAudienceOrPlatformFilterIncludesCoinSellerWeb(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
normalProduct, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
AmountMicro: 1000000,
CoinAmount: 1000000,
ProductName: "Normal Android",
Description: "normal android tier",
Platform: ledger.RechargeProductPlatformAndroid,
AudienceType: ledger.RechargeAudienceNormal,
RegionIDs: []int64{1001},
Enabled: true,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("CreateRechargeProduct normal failed: %v", err)
}
coinSellerProduct, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
AmountMicro: 5000000,
CoinAmount: 5000000,
ProductName: "Coin Seller Web",
Description: "coin seller web tier",
Platform: ledger.RechargeProductPlatformWeb,
AudienceType: ledger.RechargeAudienceCoinSeller,
RegionIDs: []int64{1001},
Enabled: true,
OperatorUserID: 90002,
})
if err != nil {
t.Fatalf("CreateRechargeProduct coin seller failed: %v", err)
}
products, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
AppCode: "lalu",
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListAdminRechargeProducts all filters failed: %v", err)
}
if total != 2 || len(products) != 2 {
t.Fatalf("admin all filters must include normal and coin seller products: total=%d products=%+v", total, products)
}
assertRechargeProductListed(t, products, normalProduct.ProductID, ledger.RechargeAudienceNormal, ledger.RechargeProductPlatformAndroid)
assertRechargeProductListed(t, products, coinSellerProduct.ProductID, ledger.RechargeAudienceCoinSeller, ledger.RechargeProductPlatformWeb)
coinSellerOnly, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
AppCode: "lalu",
AudienceType: ledger.RechargeAudienceCoinSeller,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListAdminRechargeProducts coin seller failed: %v", err)
}
if total != 1 || len(coinSellerOnly) != 1 || coinSellerOnly[0].ProductID != coinSellerProduct.ProductID {
t.Fatalf("coin seller filter must only include coin seller product: total=%d products=%+v", total, coinSellerOnly)
}
}
func assertRechargeProductListed(t *testing.T, products []ledger.RechargeProduct, productID int64, audienceType string, platform string) {
t.Helper()
for _, product := range products {
if product.ProductID != productID {
continue
}
if product.AudienceType != audienceType || product.Platform != platform {
t.Fatalf("product %d metadata mismatch: want audience=%s platform=%s got=%+v", productID, audienceType, platform, product)
}
return
}
t.Fatalf("product %d not found in admin list: %+v", productID, products)
}
// TestConfirmGooglePaymentUsesProductNameAsGoogleProductID 锁定后台 product_name 作为 Google Play 商品 ID。
func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -4066,6 +4327,15 @@ func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, asset
}
}
func paymentMethodsContain(methods []ledger.ThirdPartyPaymentMethod, countryCode string, payWay string, payType string) bool {
for _, method := range methods {
if method.CountryCode == countryCode && method.PayWay == payWay && method.PayType == payType {
return true
}
}
return false
}
func giftWallItemByID(items []ledger.GiftWallItem, giftID string) (ledger.GiftWallItem, bool) {
for _, item := range items {
if item.GiftID == giftID {
@ -4132,3 +4402,49 @@ func (f *fakeGooglePlayClient) ConsumeProduct(_ context.Context, _ string, produ
f.consumed = append(f.consumed, productID)
return nil
}
type fakeMifaPayClient struct {
createReq walletservice.MifaPayCreateOrderRequest
createResp walletservice.MifaPayCreateOrderResponse
notifyData walletservice.MifaPayNotifyData
err error
}
func (f *fakeMifaPayClient) CreateOrder(_ context.Context, req walletservice.MifaPayCreateOrderRequest) (walletservice.MifaPayCreateOrderResponse, error) {
f.createReq = req
if f.err != nil {
return walletservice.MifaPayCreateOrderResponse{}, f.err
}
if f.createResp.PayURL == "" {
return walletservice.MifaPayCreateOrderResponse{OrderID: req.OrderID, ProviderOrderID: "mb_" + req.OrderID, PayURL: "https://pay.example/" + req.OrderID, RawJSON: `{"ok":true}`}, nil
}
return f.createResp, nil
}
func (f *fakeMifaPayClient) ParseNotification(ledger.MifaPayNotification) (walletservice.MifaPayNotifyData, error) {
if f.err != nil {
return walletservice.MifaPayNotifyData{}, f.err
}
return f.notifyData, nil
}
type fakeTronUSDTClient struct {
lastTxHash string
lastToAddress string
lastAmountMinor int64
rawJSON string
err error
}
func (f *fakeTronUSDTClient) VerifyUSDTTransfer(_ context.Context, txHash string, toAddress string, amountMinor int64) (string, error) {
f.lastTxHash = txHash
f.lastToAddress = toAddress
f.lastAmountMinor = amountMinor
if f.err != nil {
return f.rawJSON, f.err
}
if f.rawJSON != "" {
return f.rawJSON, nil
}
return `{"confirmed":true}`, nil
}

View File

@ -376,7 +376,11 @@ func replaceRechargeProductRegions(ctx context.Context, tx *sql.Tx, appCode stri
func normalizeRechargeProductQuery(query ledger.ListRechargeProductsQuery) ledger.ListRechargeProductsQuery {
query.Status = ledger.NormalizeRechargeProductStatus(query.Status)
query.Platform = ledger.NormalizeRechargeProductPlatform(query.Platform)
query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType)
// 后台列表的空 audience_type 表示“全部用户类型”,不能复用前台充值入口的默认 normal 语义;
// 否则 admin-platform 选择“全部用户类型/全部平台”时会被静默收窄成普通用户,币商 Web 档位不会出现。
if strings.TrimSpace(query.AudienceType) != "" {
query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType)
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
return query

View File

@ -3002,6 +3002,9 @@ type salaryTransferToCoinSellerMetadata struct {
type coinSellerStockMetadata struct {
AppCode string `json:"app_code"`
PaymentOrderID string `json:"payment_order_id,omitempty"`
ProviderCode string `json:"provider,omitempty"`
PaymentMethodID int64 `json:"payment_method_id,omitempty"`
SellerUserID int64 `json:"seller_user_id"`
SellerCountryID int64 `json:"seller_country_id"`
SellerRegionID int64 `json:"seller_region_id"`

View File

@ -442,6 +442,13 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
RechargePolicyUSDMinorAmount: order.USDMinorAmount,
RechargeType: rechargeType,
}
if !isCoinSellerRecharge {
rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, order.AppCode, order.TargetUserID, transactionID, order.CoinAmount, order.USDMinorAmount, nowMS)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
metadata.RechargeSequence = rechargeSequence
}
if err := r.insertTransaction(ctx, tx, transactionID, order.CommandID, bizType, externalRechargeRequestHash(order, assetType), order.OrderID, metadata, nowMS); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
@ -459,8 +466,44 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
}); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMS); err != nil {
return ledger.ExternalRechargeOrder{}, err
// 普通用户 H5 充值进入普通 COIN 钱包,并写普通充值记录/累计统计供首充、累充、VIP 等用户侧口径消费。
// 币商 H5 充值只进入 COIN_SELLER_COIN 库存,并写币商库存记录;不能写 wallet_recharge_records避免被误算成普通用户充值。
outboxEvents := []walletOutboxEvent{
balanceChangedEvent(transactionID, order.CommandID, order.TargetUserID, assetType, order.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS),
}
if isCoinSellerRecharge {
paidAmountMicro, err := checkedMul(order.ProviderAmountMinor, 10_000)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
stockMetadata := coinSellerStockMetadata{
AppCode: order.AppCode,
PaymentOrderID: order.OrderID,
ProviderCode: order.ProviderCode,
PaymentMethodID: order.PaymentMethodID,
SellerUserID: order.TargetUserID,
SellerRegionID: order.TargetRegionID,
StockType: ledger.StockTypeUSDTPurchase,
CoinAmount: order.CoinAmount,
PaidCurrencyCode: order.CurrencyCode,
PaidAmountMicro: paidAmountMicro,
PaymentRef: externalRechargePaymentRef(order),
EvidenceRef: order.OrderID,
Reason: "h5 external recharge",
AssetType: ledger.AssetCoinSellerCoin,
CountsAsSellerRecharge: true,
BalanceAfter: balanceAfter,
CreatedAtMS: nowMS,
}
if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, order.CommandID, stockMetadata, nowMS); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
outboxEvents = append(outboxEvents, coinSellerStockCreditedEvent(transactionID, order.CommandID, stockMetadata, nowMS))
} else {
if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMS); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
outboxEvents = append(outboxEvents, rechargeRecordedEvent(transactionID, order.CommandID, order.TargetUserID, order.CoinAmount, metadata, nowMS))
}
if _, err := tx.ExecContext(ctx, `
UPDATE external_recharge_orders
@ -471,10 +514,7 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, order.CommandID, order.TargetUserID, assetType, order.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS),
rechargeRecordedEvent(transactionID, order.CommandID, order.TargetUserID, order.CoinAmount, metadata, nowMS),
}); err != nil {
if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := tx.Commit(); err != nil {
@ -582,6 +622,15 @@ func scanExternalRechargeOrder(scanner scanTarget) (ledger.ExternalRechargeOrder
return order, nil
}
func externalRechargePaymentRef(order ledger.ExternalRechargeOrder) string {
for _, value := range []string{order.ProviderOrderID, order.TxHash, order.OrderID} {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func externalRechargeOrderFromCommand(command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct, method *ledger.ThirdPartyPaymentMethod, providerAmountMinor int64, receiveAddress string, nowMS int64) ledger.ExternalRechargeOrder {
order := ledger.ExternalRechargeOrder{
OrderID: "h5r_" + stableHash(command.AppCode+"|"+command.CommandID),

View File

@ -351,6 +351,46 @@ func (r *Repository) SetCoinSellerSalaryExchangeRateTier(appCode string, regionI
}
}
// CreateH5RechargeProduct 通过生产仓储写入 H5 档位,测试只提供角色、区域、美元微单位和金币数。
func (r *Repository) CreateH5RechargeProduct(audienceType string, regionID int64, amountMicro int64, coinAmount int64) ledger.RechargeProduct {
r.t.Helper()
product, err := r.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
AudienceType: audienceType,
AmountMicro: amountMicro,
CoinAmount: coinAmount,
ProductName: fmt.Sprintf("H5 %s %d", audienceType, coinAmount),
Platform: ledger.RechargeProductPlatformWeb,
RegionIDs: []int64{regionID},
Enabled: true,
OperatorUserID: 90001,
})
if err != nil {
r.t.Fatalf("create h5 recharge product failed: %v", err)
}
return product
}
// ThirdPartyPaymentMethodID 返回 initdb/迁移种子里的真实 MiFaPay method_id用于 H5 下单和跨国家拒绝测试。
func (r *Repository) ThirdPartyPaymentMethodID(countryCode string, payWay string, payType string) int64 {
r.t.Helper()
var methodID int64
err := r.schema.DB.QueryRowContext(context.Background(), `
SELECT method_id
FROM third_party_payment_methods
WHERE app_code = 'lalu' AND provider_code = 'mifapay'
AND country_code = ? AND pay_way = ? AND pay_type = ?
LIMIT 1`,
countryCode, payWay, payType,
).Scan(&methodID)
if err != nil {
r.t.Fatalf("query third party payment method %s/%s/%s failed: %v", countryCode, payWay, payType, err)
}
return methodID
}
// SetGiftPrice overrides or inserts a gift settlement price for server-side billing tests.
func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice int64, giftPointAmount int64, heatValue int64) {
r.t.Helper()
@ -566,6 +606,7 @@ func initDBPath(t testing.TB) string {
func validateTableName(table string) string {
switch table {
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
"wallet_user_recharge_stats", "external_recharge_orders", "third_party_payment_methods",
"host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies",
"host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records",
"coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules",