Compare commits
16
Commits
715740fc30
...
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6c421ac3e | ||
|
|
74785f948b | ||
|
|
f2f4720340 | ||
|
|
dffd47294e | ||
|
|
950890a734 | ||
|
|
179a9bdd16 | ||
|
|
aa0e17f1f1 | ||
|
|
47bb4fe2ed | ||
|
|
355d59813b | ||
|
|
04a3a0f538 | ||
|
|
1c11c0fa82 | ||
|
|
5e0abedbcc | ||
|
|
21371061ed | ||
|
|
319768bd82 | ||
|
|
d8facff163 | ||
|
|
2e47dd3935 |
@@ -64,5 +64,61 @@ jobs:
|
|||||||
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||||
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
"docker login git.olns.shop -u admin --password-stdin"
|
"docker login git.olns.shop -u admin --password-stdin"
|
||||||
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||||
"cd /opt/olns && docker compose pull olons-server && docker compose up -d --no-deps --wait olons-server"
|
set -euo pipefail
|
||||||
|
cd /opt/olns
|
||||||
|
base_file=""
|
||||||
|
for candidate in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
base_file="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
test -n "$base_file"
|
||||||
|
|
||||||
|
im_container="$(docker compose -f "$base_file" ps -q im-server)"
|
||||||
|
test -n "$im_container"
|
||||||
|
internal_secret="$(docker inspect "$im_container" --format '"'"'{{range .Config.Env}}{{println .}}{{end}}'"'"' | sed -n '"'"'s/^INTERNAL_API_HOTGO_SECRET=//p'"'"' | head -n 1)"
|
||||||
|
test -n "$internal_secret"
|
||||||
|
|
||||||
|
olons_container="$(docker compose -f "$base_file" ps -q olons-server)"
|
||||||
|
if [ -n "$olons_container" ]; then
|
||||||
|
docker logs "$olons_container" --since 30m 2>&1 | grep "customer service session failed" | tail -n 20 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
{
|
||||||
|
printf "OLONS_IM_INTERNAL_SECRET=%s\n" "$internal_secret"
|
||||||
|
printf "OLONS_IM_BASE_URL=https://im-api.yuexuan.shop\n"
|
||||||
|
printf "OLONS_IM_HTTP_API_URL=https://im-api.yuexuan.shop/v1/im\n"
|
||||||
|
printf "OLONS_IM_WS_URL=wss://im-api.yuexuan.shop/v0/channels\n"
|
||||||
|
printf "OLONS_IM_FILE_URL=https://im-api.yuexuan.shop/v0/file\n"
|
||||||
|
printf "OLONS_IM_TENANT_CODE=default\n"
|
||||||
|
printf "OLONS_IM_API_KEY=AQAAAAABAAAnbYhUP5GAkexs5qlwQ8fR\n"
|
||||||
|
printf "OLONS_IM_INTERNAL_SERVICE=olons-server\n"
|
||||||
|
} > .env.olons-server
|
||||||
|
cat > compose.olons-server-secret.yaml <<"YAML"
|
||||||
|
services:
|
||||||
|
olons-server:
|
||||||
|
environment:
|
||||||
|
OLONS_IM_INTERNAL_SECRET: ${OLONS_IM_INTERNAL_SECRET}
|
||||||
|
OLONS_IM_BASE_URL: ${OLONS_IM_BASE_URL}
|
||||||
|
OLONS_IM_HTTP_API_URL: ${OLONS_IM_HTTP_API_URL}
|
||||||
|
OLONS_IM_WS_URL: ${OLONS_IM_WS_URL}
|
||||||
|
OLONS_IM_FILE_URL: ${OLONS_IM_FILE_URL}
|
||||||
|
OLONS_IM_TENANT_CODE: ${OLONS_IM_TENANT_CODE}
|
||||||
|
OLONS_IM_API_KEY: ${OLONS_IM_API_KEY}
|
||||||
|
OLONS_IM_INTERNAL_SERVICE: ${OLONS_IM_INTERNAL_SERVICE}
|
||||||
|
YAML
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. ./.env.olons-server
|
||||||
|
set +a
|
||||||
|
docker compose -f "$base_file" -f compose.olons-server-secret.yaml pull olons-server
|
||||||
|
docker compose -f "$base_file" -f compose.olons-server-secret.yaml up -d --no-deps --wait olons-server
|
||||||
|
docker compose -f "$base_file" -f compose.olons-server-secret.yaml exec -T olons-server sh -c '"'"'
|
||||||
|
test -n "$OLONS_IM_INTERNAL_SECRET"
|
||||||
|
test "$OLONS_IM_BASE_URL" = "https://im-api.yuexuan.shop"
|
||||||
|
test "$OLONS_IM_HTTP_API_URL" = "https://im-api.yuexuan.shop/v1/im"
|
||||||
|
'"'"'
|
||||||
|
'
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ X-Request-ID: <request id>
|
|||||||
X-Device-ID: <optional device id>
|
X-Device-ID: <optional device id>
|
||||||
```
|
```
|
||||||
|
|
||||||
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "olons_server:session:token:%s",
|
"session_key_pattern": "olons_server:session:token:%s",
|
||||||
"session_ttl_seconds": 1209600,
|
"session_ttl_seconds": 1209600,
|
||||||
"default_tenant_id": 0,
|
|
||||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||||
"allow_dev_header_auth": true,
|
"allow_dev_header_auth": true,
|
||||||
"required_api_key": false
|
"required_api_key": false
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "olons_server:session:token:%s",
|
"session_key_pattern": "olons_server:session:token:%s",
|
||||||
"session_ttl_seconds": 1209600,
|
"session_ttl_seconds": 1209600,
|
||||||
"default_tenant_id": 0,
|
|
||||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||||
"required_api_key": true
|
"required_api_key": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
|
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
|
||||||
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
|
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
|
||||||
|
|
||||||
`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。
|
服务采用单租户模式,客户端不得在 Header、Query 或 Body 中传入租户身份。登录仅按手机号查找账号;数据库中的 `tenant_id` 仅作为兼容字段保留。
|
||||||
|
|
||||||
### 3.3 登录会话
|
### 3.3 登录会话
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type profileRepository struct {
|
|||||||
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
func (r *profileRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -47,7 +46,7 @@ var (
|
|||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
||||||
AccountByMobile(context.Context, int64, string) (*store.Account, error)
|
AccountByMobile(context.Context, string) (*store.Account, error)
|
||||||
RecordLogin(context.Context, int64, int64) error
|
RecordLogin(context.Context, int64, int64) error
|
||||||
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
||||||
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
||||||
@@ -179,7 +178,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||||
TenantID: s.cfg.DefaultTenantID,
|
TenantID: 0,
|
||||||
MemberNo: memberNo,
|
MemberNo: memberNo,
|
||||||
Username: input.Username,
|
Username: input.Username,
|
||||||
Mobile: input.Phone,
|
Mobile: input.Phone,
|
||||||
@@ -237,7 +236,7 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error)
|
|||||||
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
||||||
return nil, ErrRateLimited
|
return nil, ErrRateLimited
|
||||||
}
|
}
|
||||||
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
|
account, err := s.repository.AccountByMobile(ctx, phone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||||
s.recordLoginFailure(ctx, rateKey)
|
s.recordLoginFailure(ctx, rateKey)
|
||||||
@@ -387,7 +386,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
|||||||
|
|
||||||
func (s *Service) loginRateKey(phone string) string {
|
func (s *Service) loginRateKey(phone string) string {
|
||||||
digest := sha256.Sum256([]byte(phone))
|
digest := sha256.Sum256([]byte(phone))
|
||||||
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
return "olons_server:auth:login-rate:" + hex.EncodeToString(digest[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type fakeRepository struct{}
|
|||||||
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||||
return nil, errors.New("should not be called")
|
return nil, errors.New("should not be called")
|
||||||
}
|
}
|
||||||
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
func (fakeRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
|
||||||
return nil, errors.New("should not be called")
|
return nil, errors.New("should not be called")
|
||||||
}
|
}
|
||||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||||
|
|||||||
@@ -46,17 +46,13 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
|||||||
return &Authenticator{cfg: cfg, redis: redisClient}
|
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) DefaultTenantID() int64 {
|
|
||||||
return a.cfg.DefaultTenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||||
if err := a.ValidateAPIKey(req); err != nil {
|
if err := a.ValidateAPIKey(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if a.cfg.AllowDevHeaderAuth {
|
if a.cfg.AllowDevHeaderAuth {
|
||||||
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
|
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
|
||||||
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID,
|
return &Principal{TenantID: 0, MemberID: memberID,
|
||||||
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
|
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-23
@@ -64,15 +64,15 @@ type RedisConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
APIKeys []string `json:"api_keys"`
|
APIKeys []string `json:"api_keys"`
|
||||||
SessionKeyPattern string `json:"session_key_pattern"`
|
SessionKeyPattern string `json:"session_key_pattern"`
|
||||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
DataEncryptionKey string `json:"data_encryption_key"`
|
||||||
DataEncryptionKey string `json:"data_encryption_key"`
|
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
FinanceMasterKey string `json:"finance_master_key"`
|
||||||
FinanceMasterKey string `json:"finance_master_key"`
|
FinanceLegacyMasterKeys []string `json:"finance_legacy_master_keys"`
|
||||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||||
RequiredAPIKey bool `json:"required_api_key"`
|
RequiredAPIKey bool `json:"required_api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -86,7 +86,11 @@ func Load() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
configuredFinanceMasterKey := strings.TrimSpace(cfg.Auth.FinanceMasterKey)
|
||||||
applyEnv(cfg)
|
applyEnv(cfg)
|
||||||
|
if configuredFinanceMasterKey != "" && configuredFinanceMasterKey != strings.TrimSpace(cfg.Auth.FinanceMasterKey) {
|
||||||
|
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, configuredFinanceMasterKey)
|
||||||
|
}
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -106,14 +110,13 @@ func defaultConfig() *Config {
|
|||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SessionKeyPattern: "olons_server:session:token:%s",
|
SessionKeyPattern: "olons_server:session:token:%s",
|
||||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||||
DefaultTenantID: 0,
|
|
||||||
RequiredAPIKey: true,
|
RequiredAPIKey: true,
|
||||||
},
|
},
|
||||||
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
|
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
|
||||||
IM: IMConfig{
|
IM: IMConfig{
|
||||||
BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im",
|
BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im",
|
||||||
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file",
|
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file",
|
||||||
TenantCode: "allrich", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
|
TenantCode: "default", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
|
||||||
InternalService: "olons-server", Timeout: 8 * time.Second,
|
InternalService: "olons-server", Timeout: 8 * time.Second,
|
||||||
},
|
},
|
||||||
ReadTimeout: 8 * time.Second,
|
ReadTimeout: 8 * time.Second,
|
||||||
@@ -133,10 +136,12 @@ func applyEnv(cfg *Config) {
|
|||||||
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||||
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||||
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
||||||
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
|
|
||||||
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
||||||
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
||||||
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
||||||
|
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_FINANCE_LEGACY_MASTER_KEYS")); keys != "" {
|
||||||
|
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, splitCSV(keys)...)
|
||||||
|
}
|
||||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
|
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
|
||||||
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
||||||
ApplyUploadEnv(&cfg.Upload)
|
ApplyUploadEnv(&cfg.Upload)
|
||||||
@@ -177,9 +182,6 @@ func (c *Config) validate() error {
|
|||||||
if c.Auth.SessionTTLSeconds <= 0 {
|
if c.Auth.SessionTTLSeconds <= 0 {
|
||||||
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
||||||
}
|
}
|
||||||
if c.Auth.DefaultTenantID < 0 {
|
|
||||||
return errors.New("default tenant id must not be negative")
|
|
||||||
}
|
|
||||||
if err := ValidateUpload(c.Upload); err != nil {
|
if err := ValidateUpload(c.Upload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -261,14 +263,6 @@ func setInt(target *int, key string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setInt64(target *int64, key string) {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
||||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
||||||
*target = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBool(target *bool, key string) {
|
func setBool(target *bool, key string) {
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||||
case "1", "true", "yes", "on":
|
case "1", "true", "yes", "on":
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
|
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if cfg.Auth.DefaultTenantID != 0 {
|
if cfg.IM.TenantCode != "default" {
|
||||||
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
|
t.Fatalf("unexpected default IM tenant: %q", cfg.IM.TenantCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +24,24 @@ func TestConfigRejectsOtherDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadPreservesConfiguredFinanceKeyAsLegacyWhenEnvironmentOverridesIt(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
data := []byte(`{"auth":{"finance_master_key":"legacy-finance-master-key-with-at-least-32-characters"}}`)
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("OLONS_SERVER_CONFIG", path)
|
||||||
|
t.Setenv("OLONS_SERVER_FINANCE_MASTER_KEY", "current-finance-master-key-with-at-least-32-characters")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.Auth.FinanceLegacyMasterKeys) != 1 ||
|
||||||
|
cfg.Auth.FinanceLegacyMasterKeys[0] != "legacy-finance-master-key-with-at-least-32-characters" {
|
||||||
|
t.Fatalf("configured finance key was not retained for historical data: %#v", cfg.Auth.FinanceLegacyMasterKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyUploadSystemConfig(t *testing.T) {
|
func TestApplyUploadSystemConfig(t *testing.T) {
|
||||||
cfg := UploadConfig{Driver: "local", Region: "us-east-1"}
|
cfg := UploadConfig{Driver: "local", Region: "us-east-1"}
|
||||||
ApplyUploadSystemConfig(&cfg, map[string]string{
|
ApplyUploadSystemConfig(&cfg, map[string]string{
|
||||||
|
|||||||
+48
-53
@@ -35,7 +35,6 @@ const (
|
|||||||
var (
|
var (
|
||||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||||
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
|
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
|
||||||
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
@@ -59,19 +58,19 @@ type Repository interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
aead cipher.AEAD
|
aead cipher.AEAD
|
||||||
lookupKey []byte
|
legacyAEADs []cipher.AEAD
|
||||||
ready bool
|
lookupKey []byte
|
||||||
now func() time.Time
|
ready bool
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentAccountInput struct {
|
type PaymentAccountInput struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
BankName string `json:"bank_name"`
|
BankName string `json:"bank_name"`
|
||||||
HolderName string `json:"holder_name"`
|
HolderName string `json:"holder_name"`
|
||||||
IdentityNo string `json:"identity_no"`
|
|
||||||
AccountNo string `json:"account_no"`
|
AccountNo string `json:"account_no"`
|
||||||
DigitalRMBCode string `json:"digital_rmb_code"`
|
DigitalRMBCode string `json:"digital_rmb_code"`
|
||||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||||
@@ -84,7 +83,6 @@ type PaymentAccount struct {
|
|||||||
BankCode string `json:"bank_code"`
|
BankCode string `json:"bank_code"`
|
||||||
BankName string `json:"bank_name"`
|
BankName string `json:"bank_name"`
|
||||||
HolderMask string `json:"holder_mask"`
|
HolderMask string `json:"holder_mask"`
|
||||||
IdentityNoMask string `json:"identity_no_mask"`
|
|
||||||
AccountNoMask string `json:"account_no_mask"`
|
AccountNoMask string `json:"account_no_mask"`
|
||||||
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
|
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
|
||||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||||
@@ -99,7 +97,6 @@ type PaymentAccountDetail struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
BankName string `json:"bank_name"`
|
BankName string `json:"bank_name"`
|
||||||
HolderName string `json:"holder_name"`
|
HolderName string `json:"holder_name"`
|
||||||
IdentityNo string `json:"identity_no"`
|
|
||||||
AccountNo string `json:"account_no"`
|
AccountNo string `json:"account_no"`
|
||||||
DigitalRMBCode string `json:"digital_rmb_code"`
|
DigitalRMBCode string `json:"digital_rmb_code"`
|
||||||
WechatQRURL string `json:"wechat_qr_url"`
|
WechatQRURL string `json:"wechat_qr_url"`
|
||||||
@@ -266,20 +263,39 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
|
|||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
block, err := aes.NewCipher(encryptionKey)
|
aead, err := newAEAD(encryptionKey)
|
||||||
if err != nil {
|
|
||||||
return service
|
|
||||||
}
|
|
||||||
aead, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
service.aead = aead
|
service.aead = aead
|
||||||
|
seenLegacyKeys := make(map[string]struct{}, len(cfg.FinanceLegacyMasterKeys))
|
||||||
|
for _, legacyMasterKey := range cfg.FinanceLegacyMasterKeys {
|
||||||
|
legacyMasterKey = strings.TrimSpace(legacyMasterKey)
|
||||||
|
if len([]byte(legacyMasterKey)) < 32 || legacyMasterKey == masterKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seenLegacyKeys[legacyMasterKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
legacyAEAD, legacyErr := newAEAD(deriveKey(legacyMasterKey, "data-encryption"))
|
||||||
|
if legacyErr == nil {
|
||||||
|
service.legacyAEADs = append(service.legacyAEADs, legacyAEAD)
|
||||||
|
seenLegacyKeys[legacyMasterKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
service.lookupKey = lookupKey
|
service.lookupKey = lookupKey
|
||||||
service.ready = repository != nil && redisClient != nil
|
service.ready = repository != nil && redisClient != nil
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newAEAD(key []byte) (cipher.AEAD, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(block)
|
||||||
|
}
|
||||||
|
|
||||||
func deriveKey(masterKey, purpose string) []byte {
|
func deriveKey(masterKey, purpose string) []byte {
|
||||||
mac := hmac.New(sha256.New, []byte(masterKey))
|
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||||
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
|
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
|
||||||
@@ -312,20 +328,13 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
|
|||||||
items := make([]PaymentAccountDetail, 0, len(rows))
|
items := make([]PaymentAccountDetail, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
holderName, accountNo := row.HolderMask, row.AccountNoMask
|
holderName, accountNo := row.HolderMask, row.AccountNoMask
|
||||||
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask
|
digitalRMBCode := row.DigitalRMBCodeMask
|
||||||
masked := true
|
masked := true
|
||||||
if s.ready {
|
if s.ready {
|
||||||
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
|
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
|
||||||
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
|
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
|
||||||
if holderErr == nil && accountErr == nil {
|
if holderErr == nil && accountErr == nil {
|
||||||
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
|
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
|
||||||
if row.IdentityNoCipher != "" {
|
|
||||||
if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil {
|
|
||||||
identityNo = value
|
|
||||||
} else {
|
|
||||||
masked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if row.DigitalRMBCodeCipher != "" {
|
if row.DigitalRMBCodeCipher != "" {
|
||||||
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
|
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
|
||||||
digitalRMBCode = value
|
digitalRMBCode = value
|
||||||
@@ -337,7 +346,7 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
|
|||||||
}
|
}
|
||||||
items = append(items, PaymentAccountDetail{
|
items = append(items, PaymentAccountDetail{
|
||||||
ID: row.ID, BankName: row.BankName, HolderName: holderName,
|
ID: row.ID, BankName: row.BankName, HolderName: holderName,
|
||||||
IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
|
AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
|
||||||
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
|
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
|
||||||
WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked,
|
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked,
|
||||||
@@ -386,10 +395,6 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.PaymentAccountCreation{}, err
|
return store.PaymentAccountCreation{}, err
|
||||||
}
|
}
|
||||||
identityNoCipher, err := s.encrypt(input.IdentityNo)
|
|
||||||
if err != nil {
|
|
||||||
return store.PaymentAccountCreation{}, err
|
|
||||||
}
|
|
||||||
accountCipher, err := s.encrypt(input.AccountNo)
|
accountCipher, err := s.encrypt(input.AccountNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.PaymentAccountCreation{}, err
|
return store.PaymentAccountCreation{}, err
|
||||||
@@ -406,9 +411,8 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
|
|||||||
return store.PaymentAccountCreation{
|
return store.PaymentAccountCreation{
|
||||||
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
|
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
|
||||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
|
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
|
||||||
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
|
AccountNoCipher: accountCipher,
|
||||||
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
|
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
|
||||||
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
|
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
|
||||||
DigitalRMBCodeMask: digitalRMBCodeMask,
|
DigitalRMBCodeMask: digitalRMBCodeMask,
|
||||||
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||||
@@ -454,13 +458,6 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, store.ErrPaymentAccountInvalid
|
return nil, store.ErrPaymentAccountInvalid
|
||||||
}
|
}
|
||||||
identityNo := ""
|
|
||||||
if account.IdentityNoCipher != "" {
|
|
||||||
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
|
||||||
if err != nil {
|
|
||||||
return nil, store.ErrPaymentAccountInvalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
digitalRMBCode := ""
|
digitalRMBCode := ""
|
||||||
if account.DigitalRMBCodeCipher != "" {
|
if account.DigitalRMBCodeCipher != "" {
|
||||||
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
|
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
|
||||||
@@ -471,7 +468,7 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
|||||||
snapshot, err := json.Marshal(map[string]any{
|
snapshot, err := json.Marshal(map[string]any{
|
||||||
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
||||||
"bank_name": account.BankName, "holder_name": holderName,
|
"bank_name": account.BankName, "holder_name": holderName,
|
||||||
"identity_no": identityNo, "account_no": accountNo,
|
"account_no": accountNo,
|
||||||
"digital_rmb_code": digitalRMBCode,
|
"digital_rmb_code": digitalRMBCode,
|
||||||
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
||||||
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
||||||
@@ -849,10 +846,9 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
|||||||
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||||
input.BankName = strings.TrimSpace(input.BankName)
|
input.BankName = strings.TrimSpace(input.BankName)
|
||||||
input.HolderName = strings.TrimSpace(input.HolderName)
|
input.HolderName = strings.TrimSpace(input.HolderName)
|
||||||
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
|
|
||||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
||||||
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
|
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
|
||||||
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
|
if !validHumanName(input.HolderName) ||
|
||||||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
|
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
|
||||||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
||||||
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
|
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
|
||||||
@@ -865,7 +861,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error {
|
|||||||
|
|
||||||
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
||||||
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
||||||
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
|
BankName: row.BankName, HolderMask: row.HolderMask,
|
||||||
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
|
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
|
||||||
WechatQRAttachmentID: row.WechatQRAttachmentID,
|
WechatQRAttachmentID: row.WechatQRAttachmentID,
|
||||||
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||||
@@ -1042,11 +1038,17 @@ func (s *Service) decrypt(value string) (string, error) {
|
|||||||
if err != nil || len(payload) <= s.aead.NonceSize() {
|
if err != nil || len(payload) <= s.aead.NonceSize() {
|
||||||
return "", errors.New("encrypted value is invalid")
|
return "", errors.New("encrypted value is invalid")
|
||||||
}
|
}
|
||||||
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...)
|
||||||
if err != nil {
|
for _, aead := range aeads {
|
||||||
return "", err
|
if len(payload) <= aead.NonceSize() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plain, decryptErr := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil)
|
||||||
|
if decryptErr == nil {
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return string(plain), nil
|
return "", errors.New("encrypted value cannot be decrypted")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) lookupHash(value string) string {
|
func (s *Service) lookupHash(value string) string {
|
||||||
@@ -1116,13 +1118,6 @@ func maskAccount(value string) string {
|
|||||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func maskIdentityNo(value string) string {
|
|
||||||
if len(value) <= 8 {
|
|
||||||
return strings.Repeat("*", len(value))
|
|
||||||
}
|
|
||||||
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func validHumanName(value string) bool {
|
func validHumanName(value string) bool {
|
||||||
count := utf8.RuneCountInString(value)
|
count := utf8.RuneCountInString(value)
|
||||||
if count < 2 || count > 64 {
|
if count < 2 || count > 64 {
|
||||||
|
|||||||
@@ -135,22 +135,21 @@ func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) {
|
|||||||
|
|
||||||
func TestNormalizePaymentAccount(t *testing.T) {
|
func TestNormalizePaymentAccount(t *testing.T) {
|
||||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||||
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
AccountNo: "6222 0000-0000 1234",
|
||||||
DigitalRMBCode: "1234 5678",
|
DigitalRMBCode: "1234 5678",
|
||||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||||
if err := normalizePaymentAccount(&input); err != nil {
|
if err := normalizePaymentAccount(&input); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
|
if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" ||
|
||||||
input.DigitalRMBCode != "12345678" ||
|
maskAccount(input.AccountNo) != "6222********1234" {
|
||||||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
|
|
||||||
t.Fatalf("unexpected normalized input: %#v", input)
|
t.Fatalf("unexpected normalized input: %#v", input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
|
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
|
||||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||||
IdentityNo: "11010119900101123X", AccountNo: "6222000000001234",
|
AccountNo: "6222000000001234",
|
||||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||||
if err := normalizePaymentAccount(&input); err != nil {
|
if err := normalizePaymentAccount(&input); err != nil {
|
||||||
t.Fatalf("empty digital RMB code should be optional: %v", err)
|
t.Fatalf("empty digital RMB code should be optional: %v", err)
|
||||||
@@ -167,11 +166,10 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
|
|||||||
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||||
})
|
})
|
||||||
holder, _ := service.encrypt("张三")
|
holder, _ := service.encrypt("张三")
|
||||||
identity, _ := service.encrypt("11010119900101123X")
|
|
||||||
account, _ := service.encrypt("6222000000001234")
|
account, _ := service.encrypt("6222000000001234")
|
||||||
digital, _ := service.encrypt("123456")
|
digital, _ := service.encrypt("123456")
|
||||||
repository.details = []store.PaymentAccount{{
|
repository.details = []store.PaymentAccount{{
|
||||||
ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity,
|
ID: 7, BankName: "中国工商银行", HolderCipher: holder,
|
||||||
AccountNoCipher: account, DigitalRMBCodeCipher: digital,
|
AccountNoCipher: account, DigitalRMBCodeCipher: digital,
|
||||||
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
|
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
|
||||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
|
||||||
@@ -180,7 +178,7 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" ||
|
if len(items) != 1 || items[0].HolderName != "张三" ||
|
||||||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
|
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
|
||||||
items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
|
items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
|
||||||
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
|
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
|
||||||
@@ -188,13 +186,38 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPaymentAccountDetailsDecryptsWithLegacyMasterKey(t *testing.T) {
|
||||||
|
repository := &fakeRepository{}
|
||||||
|
legacyMasterKey := "legacy-finance-master-key-with-at-least-32-characters"
|
||||||
|
legacyService := New(repository, &redis.Client{}, config.AuthConfig{FinanceMasterKey: legacyMasterKey})
|
||||||
|
holder, _ := legacyService.encrypt("张三")
|
||||||
|
account, _ := legacyService.encrypt("6222000000001234")
|
||||||
|
digital, _ := legacyService.encrypt("123456")
|
||||||
|
repository.details = []store.PaymentAccount{{
|
||||||
|
ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account,
|
||||||
|
DigitalRMBCodeCipher: digital, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
||||||
|
}}
|
||||||
|
service := New(repository, &redis.Client{}, config.AuthConfig{
|
||||||
|
FinanceMasterKey: "current-finance-master-key-with-at-least-32-characters",
|
||||||
|
FinanceLegacyMasterKeys: []string{legacyMasterKey},
|
||||||
|
})
|
||||||
|
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Masked || items[0].HolderName != "张三" ||
|
||||||
|
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" {
|
||||||
|
t.Fatalf("unexpected legacy account details: %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) {
|
func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) {
|
||||||
repository := &paymentAccountRepository{}
|
repository := &paymentAccountRepository{}
|
||||||
service := New(repository, &redis.Client{}, config.AuthConfig{
|
service := New(repository, &redis.Client{}, config.AuthConfig{
|
||||||
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||||
})
|
})
|
||||||
result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{
|
result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{
|
||||||
ID: 7, BankName: "中国工商银行", HolderName: "李四", IdentityNo: "11010119900101123X",
|
ID: 7, BankName: "中国工商银行", HolderName: "李四",
|
||||||
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,7 +249,7 @@ func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" ||
|
if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" ||
|
||||||
items[0].IdentityNo != "1101**********123X" || items[0].AccountNo != "6222********1234" ||
|
items[0].AccountNo != "6222********1234" ||
|
||||||
items[0].DigitalRMBCode != "12****78" {
|
items[0].DigitalRMBCode != "12****78" {
|
||||||
t.Fatalf("unexpected masked fallback: %#v", items)
|
t.Fatalf("unexpected masked fallback: %#v", items)
|
||||||
}
|
}
|
||||||
@@ -238,7 +261,7 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
|
|||||||
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||||
})
|
})
|
||||||
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
|
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
|
||||||
BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X",
|
BankName: "中国工商银行", HolderName: "张三",
|
||||||
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,6 +270,9 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
|
|||||||
if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" {
|
if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" {
|
||||||
t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created)
|
t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created)
|
||||||
}
|
}
|
||||||
|
if repository.created.IdentityNoCipher != "" || repository.created.IdentityNoHash != "" || repository.created.IdentityNoMask != "" {
|
||||||
|
t.Fatalf("identity number must no longer be collected: %#v", repository.created)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
|
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (a *API) content(w http.ResponseWriter, r *http.Request) {
|
|||||||
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tenantID := a.auth.DefaultTenantID()
|
tenantID := int64(0)
|
||||||
if _, public := publicContentKeys[key]; !public {
|
if _, public := publicContentKeys[key]; !public {
|
||||||
principal, ok := a.authenticateMember(w, r)
|
principal, ok := a.authenticateMember(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import (
|
|||||||
|
|
||||||
const maxResponseBody = 2 << 20
|
const maxResponseBody = 2 << 20
|
||||||
|
|
||||||
|
const defaultIMTenantCode = "default"
|
||||||
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Status int
|
Status int
|
||||||
Code int
|
Code int
|
||||||
@@ -181,9 +183,13 @@ func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, p
|
|||||||
}
|
}
|
||||||
loginID := "login-" + requestID()
|
loginID := "login-" + requestID()
|
||||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||||
|
loginType := 0
|
||||||
|
if strings.EqualFold(strings.TrimSpace(c.cfg.TenantCode), defaultIMTenantCode) {
|
||||||
|
loginType = 1
|
||||||
|
}
|
||||||
if err = conn.WriteJSON(map[string]any{"login": map[string]any{
|
if err = conn.WriteJSON(map[string]any{"login": map[string]any{
|
||||||
"id": loginID, "scheme": "basic", "secret": secret,
|
"id": loginID, "scheme": "basic", "secret": secret,
|
||||||
"login_type": 0, "tenant_code": c.cfg.TenantCode,
|
"login_type": loginType, "tenant_code": c.cfg.TenantCode,
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,39 @@ func TestLoginBasicUsesExistingWebSocketProtocol(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginBasicUsesPersonalTypeForDefaultTenant(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
var packet map[string]map[string]any
|
||||||
|
if err = conn.ReadJSON(&packet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": packet["hi"]["id"], "code": 201, "text": "created"}})
|
||||||
|
if err = conn.ReadJSON(&packet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
login := packet["login"]
|
||||||
|
if login["login_type"] != float64(1) || login["tenant_code"] != defaultIMTenantCode {
|
||||||
|
t.Fatalf("unexpected login: %#v", login)
|
||||||
|
}
|
||||||
|
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": login["id"], "code": 200, "text": "ok",
|
||||||
|
"params": map[string]any{"user": "usr_member", "token": "token", "expires": "2026-10-27T10:00:00Z"}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL,
|
||||||
|
WebSocketURL: "ws" + strings.TrimPrefix(server.URL, "http"), TenantCode: defaultIMTenantCode,
|
||||||
|
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
|
||||||
|
if _, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
|
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
|
|||||||
@@ -95,11 +95,11 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
|||||||
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) {
|
func (s *Store) AccountByMobile(ctx context.Context, mobile string) (*Account, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, errors.New("store is unavailable")
|
return nil, errors.New("store is unavailable")
|
||||||
}
|
}
|
||||||
return accountByMobile(ctx, s.db, tenantID, mobile)
|
return accountByMobile(ctx, s.db, mobile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
||||||
@@ -112,11 +112,11 @@ type queryer interface {
|
|||||||
GetContext(context.Context, any, string, ...any) error
|
GetContext(context.Context, any, string, ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) {
|
func accountByMobile(ctx context.Context, db queryer, mobile string) (*Account, error) {
|
||||||
var row Account
|
var row Account
|
||||||
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
|
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
|
||||||
password_hash, invite_code, member_status, risk_status AS member_category
|
password_hash, invite_code, member_status, risk_status AS member_category
|
||||||
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile)
|
FROM hg_app_member WHERE mobile=? AND deleted_at IS NULL LIMIT 1`, mobile)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountQueryRecorder struct {
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *accountQueryRecorder) GetContext(_ context.Context, dest any, query string, args ...any) error {
|
||||||
|
r.query = query
|
||||||
|
r.args = args
|
||||||
|
account := dest.(*Account)
|
||||||
|
*account = Account{MemberID: 9, Mobile: "13311111111", MemberStatus: memberStatusActive}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountByMobileDoesNotFilterTenant(t *testing.T) {
|
||||||
|
recorder := &accountQueryRecorder{}
|
||||||
|
account, err := accountByMobile(context.Background(), recorder, "13311111111")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if account.MemberID != 9 {
|
||||||
|
t.Fatalf("MemberID = %d, want 9", account.MemberID)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(recorder.query), "tenant_id=?") {
|
||||||
|
t.Fatalf("query must not filter tenant: %s", recorder.query)
|
||||||
|
}
|
||||||
|
if len(recorder.args) != 1 || recorder.args[0] != "13311111111" {
|
||||||
|
t.Fatalf("query args = %#v", recorder.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ class DeploymentContractTest(unittest.TestCase):
|
|||||||
def test_workflow_targets_only_olons_server(self):
|
def test_workflow_targets_only_olons_server(self):
|
||||||
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
|
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
|
||||||
|
|
||||||
self.assertIn("Deploy olons-server (k3s test cluster)", workflow)
|
self.assertIn("Deploy olons-server production", workflow)
|
||||||
self.assertIn("git.118im.com/im_team/olons-server:", workflow)
|
self.assertIn("git.olns.shop/pro/olons_server", workflow)
|
||||||
self.assertIn("deployment/olons-server -n olons-server-system", workflow)
|
self.assertIn("docker compose pull olons-server", workflow)
|
||||||
|
self.assertIn("docker compose up -d --no-deps --wait olons-server", workflow)
|
||||||
self.assertNotIn("im-workbench", workflow)
|
self.assertNotIn("im-workbench", workflow)
|
||||||
self.assertNotIn("workbench-system", workflow)
|
self.assertNotIn("workbench-system", workflow)
|
||||||
|
|
||||||
|
|||||||
@@ -524,19 +524,57 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.m-notice {
|
.m-notice {
|
||||||
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
border: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
color: #6c5a45;
|
color: #6c5a45;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.m-notice p {
|
.m-notice p {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.m-notice-tag {
|
||||||
|
flex: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8d5b17;
|
||||||
|
background: #fff2d8;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.m-notice-tag.is-activity {
|
||||||
|
color: #24704b;
|
||||||
|
background: #e9f7ef;
|
||||||
|
}
|
||||||
|
.m-notice-copy {
|
||||||
|
animation: m-notice-enter 0.32s ease-out;
|
||||||
|
}
|
||||||
|
.m-notice-arrow {
|
||||||
|
flex: none;
|
||||||
|
color: #b4a590;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
@keyframes m-notice-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.m-section {
|
.m-section {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 17px 16px;
|
padding: 17px 16px;
|
||||||
|
|||||||
@@ -52,15 +52,10 @@
|
|||||||
|
|
||||||
<AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts">
|
<AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts">
|
||||||
<form class="m-plain-form m-bank-form" @submit.prevent="saveBank">
|
<form class="m-plain-form m-bank-form" @submit.prevent="saveBank">
|
||||||
<p v-if="existingAccount?.masked" class="m-bound-account-hint">历史资料无法完整读取,请重新填写姓名、身份证号和银行卡号后保存</p>
|
<p v-if="existingAccount?.masked" class="m-bound-account-hint">历史资料无法完整读取,请重新填写姓名和银行卡号后保存</p>
|
||||||
<label
|
<label
|
||||||
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
||||||
/></label>
|
/></label>
|
||||||
<label
|
|
||||||
><span>身份证号</span
|
|
||||||
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
|
|
||||||
/></label>
|
|
||||||
<div class="m-bank-form__divider"></div>
|
|
||||||
<label
|
<label
|
||||||
><span>银行卡号</span
|
><span>银行卡号</span
|
||||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
|
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
|
||||||
@@ -266,7 +261,6 @@
|
|||||||
id: number;
|
id: number;
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
holder_name: string;
|
holder_name: string;
|
||||||
identity_no: string;
|
|
||||||
account_no: string;
|
account_no: string;
|
||||||
digital_rmb_code: string;
|
digital_rmb_code: string;
|
||||||
wechat_qr_url: string;
|
wechat_qr_url: string;
|
||||||
@@ -282,7 +276,6 @@
|
|||||||
const bank = reactive({
|
const bank = reactive({
|
||||||
bank_name: '',
|
bank_name: '',
|
||||||
holder_name: '',
|
holder_name: '',
|
||||||
identity_no: '',
|
|
||||||
account_no: '',
|
account_no: '',
|
||||||
digital_rmb_code: '',
|
digital_rmb_code: '',
|
||||||
wechat_qr_attachment_id: 0,
|
wechat_qr_attachment_id: 0,
|
||||||
@@ -344,7 +337,7 @@
|
|||||||
bankLoadError.value = '';
|
bankLoadError.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
existingAccount.value = null;
|
existingAccount.value = null;
|
||||||
Object.assign(bank, { bank_name: '', holder_name: '', identity_no: '', account_no: '', digital_rmb_code: '',
|
Object.assign(bank, { bank_name: '', holder_name: '', account_no: '', digital_rmb_code: '',
|
||||||
wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 });
|
wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 });
|
||||||
qr.wechat.url = '';
|
qr.wechat.url = '';
|
||||||
qr.alipay.url = '';
|
qr.alipay.url = '';
|
||||||
@@ -359,7 +352,6 @@
|
|||||||
bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id;
|
bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id;
|
||||||
if (!account.masked) {
|
if (!account.masked) {
|
||||||
bank.holder_name = account.holder_name;
|
bank.holder_name = account.holder_name;
|
||||||
bank.identity_no = account.identity_no;
|
|
||||||
bank.account_no = account.account_no;
|
bank.account_no = account.account_no;
|
||||||
bank.digital_rmb_code = account.digital_rmb_code;
|
bank.digital_rmb_code = account.digital_rmb_code;
|
||||||
}
|
}
|
||||||
@@ -371,14 +363,11 @@
|
|||||||
}
|
}
|
||||||
async function saveBank() {
|
async function saveBank() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
const identityNo = bank.identity_no.replace(/[ -]/g, '');
|
|
||||||
const accountNo = bank.account_no.replace(/[ -]/g, '');
|
const accountNo = bank.account_no.replace(/[ -]/g, '');
|
||||||
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
|
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
|
||||||
const validationError =
|
const validationError =
|
||||||
bank.holder_name.length < 2
|
bank.holder_name.length < 2
|
||||||
? '请输入至少 2 个字符的姓名'
|
? '请输入至少 2 个字符的姓名'
|
||||||
: !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
|
|
||||||
? '请输入正确的 15 位或 18 位身份证号'
|
|
||||||
: !/^\d{8,32}$/.test(accountNo)
|
: !/^\d{8,32}$/.test(accountNo)
|
||||||
? '请输入 8 至 32 位银行卡号'
|
? '请输入 8 至 32 位银行卡号'
|
||||||
: bank.bank_name.length < 2
|
: bank.bank_name.length < 2
|
||||||
|
|||||||
@@ -53,10 +53,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="home?.notices?.length" class="m-notice m-card">
|
<button
|
||||||
|
v-if="currentNotice"
|
||||||
|
class="m-notice m-card"
|
||||||
|
type="button"
|
||||||
|
aria-label="查看全部通知"
|
||||||
|
@click="router.push('/app/content/notices')"
|
||||||
|
>
|
||||||
<n-icon :component="MegaphoneOutline" />
|
<n-icon :component="MegaphoneOutline" />
|
||||||
<p>{{ home.notices[0].title || home.notices[0].content }}</p>
|
<span :class="['m-notice-tag', { 'is-activity': currentNotice.source_type === 'operational_simulation' }]">
|
||||||
</div>
|
{{ currentNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
|
||||||
|
</span>
|
||||||
|
<p :key="currentNotice.id" class="m-notice-copy">
|
||||||
|
{{ currentNotice.title || currentNotice.content || '查看最新通知' }}
|
||||||
|
</p>
|
||||||
|
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<section class="m-section m-card m-home-ranking">
|
<section class="m-section m-card m-home-ranking">
|
||||||
<div class="m-section-title"
|
<div class="m-section-title"
|
||||||
@@ -101,7 +113,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import AnnouncementPopup, {
|
import AnnouncementPopup, {
|
||||||
type AnnouncementNotice,
|
type AnnouncementNotice,
|
||||||
@@ -121,7 +133,7 @@
|
|||||||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||||||
features: Record<string, boolean>;
|
features: Record<string, boolean>;
|
||||||
banners: BannerData[];
|
banners: BannerData[];
|
||||||
notices: Array<{ title?: string; content?: string }>;
|
notices: NoticeData[];
|
||||||
task_summary: {
|
task_summary: {
|
||||||
today_count: number;
|
today_count: number;
|
||||||
today_commission_amount: number;
|
today_commission_amount: number;
|
||||||
@@ -135,6 +147,12 @@
|
|||||||
target_type: 'none' | 'internal_route' | 'external_url';
|
target_type: 'none' | 'internal_route' | 'external_url';
|
||||||
target_value?: string;
|
target_value?: string;
|
||||||
}
|
}
|
||||||
|
interface NoticeData {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
source_type?: string;
|
||||||
|
}
|
||||||
interface LeaderboardEntry {
|
interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -149,12 +167,15 @@
|
|||||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||||
const popupNotice = ref<AnnouncementNotice | null>(null);
|
const popupNotice = ref<AnnouncementNotice | null>(null);
|
||||||
const activeBannerIndex = ref(0);
|
const activeBannerIndex = ref(0);
|
||||||
|
const activeNoticeIndex = ref(0);
|
||||||
let bannerTimer: number | undefined;
|
let bannerTimer: number | undefined;
|
||||||
|
let noticeTimer: number | undefined;
|
||||||
const formatPoints = (value: number) =>
|
const formatPoints = (value: number) =>
|
||||||
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||||
(value || 0) / 100
|
(value || 0) / 100
|
||||||
);
|
);
|
||||||
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
||||||
|
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
|
||||||
const bannerStyle = computed(() => {
|
const bannerStyle = computed(() => {
|
||||||
const url = safeImage(currentBanner.value?.image_url);
|
const url = safeImage(currentBanner.value?.image_url);
|
||||||
return {
|
return {
|
||||||
@@ -234,6 +255,18 @@
|
|||||||
}, 4500);
|
}, 4500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restartNoticeTimer() {
|
||||||
|
if (noticeTimer) window.clearInterval(noticeTimer);
|
||||||
|
noticeTimer = undefined;
|
||||||
|
const count = home.value?.notices?.length || 0;
|
||||||
|
if (count > 1) {
|
||||||
|
noticeTimer = window.setInterval(() => {
|
||||||
|
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -269,8 +302,13 @@
|
|||||||
activeBannerIndex.value = 0;
|
activeBannerIndex.value = 0;
|
||||||
restartBannerTimer();
|
restartBannerTimer();
|
||||||
});
|
});
|
||||||
|
watch(() => home.value?.notices?.length, () => {
|
||||||
|
activeNoticeIndex.value = 0;
|
||||||
|
restartNoticeTimer();
|
||||||
|
});
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (bannerTimer) window.clearInterval(bannerTimer);
|
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||||
|
if (noticeTimer) window.clearInterval(noticeTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user