Compare commits

..
5 Commits
Author SHA1 Message Date
dev 5e0abedbcc Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m45s
2026-10-19 09:34:44 +08:00
richjohnrambo 21371061ed feat: improve home notice ticker 2026-10-19 09:34:26 +08:00
Your Name 319768bd82 test: align deployment contract with production
Deploy olons-server production / deploy (push) Successful in 28s
2026-10-19 02:55:32 +08:00
dev d8facff163 Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 2m29s
2026-10-19 01:03:52 +08:00
richjohnrambo 2e47dd3935 fix: remove tenant dependency from member login 2026-10-19 01:03:31 +08:00
16 changed files with 136 additions and 46 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ X-Request-ID: <request id>
X-Device-ID: <optional device id>
```
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
## 验证
-1
View File
@@ -18,7 +18,6 @@
"api_keys": [],
"session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"finance_master_key": "olons-finance-master-key-v1-20261013",
"allow_dev_header_auth": true,
"required_api_key": false
-1
View File
@@ -18,7 +18,6 @@
"api_keys": [],
"session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"finance_master_key": "olons-finance-master-key-v1-20261013",
"required_api_key": true
}
@@ -54,7 +54,7 @@
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。
服务采用单租户模式,客户端不得在 Header、Query 或 Body 中传入租户身份。登录仅按手机号查找账号;数据库中的 `tenant_id` 仅作为兼容字段保留。
### 3.3 登录会话
+1 -1
View File
@@ -19,7 +19,7 @@ type profileRepository struct {
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
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")
}
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
+4 -5
View File
@@ -11,7 +11,6 @@ import (
"fmt"
"math/big"
"regexp"
"strconv"
"strings"
"time"
"unicode"
@@ -47,7 +46,7 @@ var (
type Repository interface {
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
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, 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
}
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID,
TenantID: 0,
MemberNo: memberNo,
Username: input.Username,
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 {
return nil, ErrRateLimited
}
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
account, err := s.repository.AccountByMobile(ctx, phone)
if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) {
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 {
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) {
+1 -1
View File
@@ -14,7 +14,7 @@ type fakeRepository struct{}
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
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")
}
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
+1 -5
View File
@@ -46,17 +46,13 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
return &Authenticator{cfg: cfg, redis: redisClient}
}
func (a *Authenticator) DefaultTenantID() int64 {
return a.cfg.DefaultTenantID
}
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
if err := a.ValidateAPIKey(req); err != nil {
return nil, err
}
if a.cfg.AllowDevHeaderAuth {
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
}
}
-14
View File
@@ -67,7 +67,6 @@ type AuthConfig struct {
APIKeys []string `json:"api_keys"`
SessionKeyPattern string `json:"session_key_pattern"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
DefaultTenantID int64 `json:"default_tenant_id"`
DataEncryptionKey string `json:"data_encryption_key"`
LookupHMACKey string `json:"lookup_hmac_key"`
FinanceMasterKey string `json:"finance_master_key"`
@@ -106,7 +105,6 @@ func defaultConfig() *Config {
Auth: AuthConfig{
SessionKeyPattern: "olons_server:session:token:%s",
SessionTTLSeconds: 14 * 24 * 60 * 60,
DefaultTenantID: 0,
RequiredAPIKey: true,
},
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
@@ -133,7 +131,6 @@ func applyEnv(cfg *Config) {
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
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.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
@@ -177,9 +174,6 @@ func (c *Config) validate() error {
if c.Auth.SessionTTLSeconds <= 0 {
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 {
return err
}
@@ -261,14 +255,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) {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
-3
View File
@@ -7,9 +7,6 @@ func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
if err := cfg.validate(); err != nil {
t.Fatal(err)
}
if cfg.Auth.DefaultTenantID != 0 {
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
}
}
func TestConfigRejectsOtherDatabase(t *testing.T) {
+1 -1
View File
@@ -85,7 +85,7 @@ func (a *API) content(w http.ResponseWriter, r *http.Request) {
writePortalError(w, r, portal.ErrContentKeyInvalid)
return
}
tenantID := a.auth.DefaultTenantID()
tenantID := int64(0)
if _, public := publicContentKeys[key]; !public {
principal, ok := a.authenticateMember(w, r)
if !ok {
+4 -4
View File
@@ -95,11 +95,11 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
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 {
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 {
@@ -112,11 +112,11 @@ type queryer interface {
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
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
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) {
return nil, ErrInvalidCredentials
}
+37
View File
@@ -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)
}
}
+4 -3
View File
@@ -9,9 +9,10 @@ class DeploymentContractTest(unittest.TestCase):
def test_workflow_targets_only_olons_server(self):
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
self.assertIn("Deploy olons-server (k3s test cluster)", workflow)
self.assertIn("git.118im.com/im_team/olons-server:", workflow)
self.assertIn("deployment/olons-server -n olons-server-system", workflow)
self.assertIn("Deploy olons-server production", workflow)
self.assertIn("git.olns.shop/pro/olons_server", 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("workbench-system", workflow)
+38
View File
@@ -524,19 +524,57 @@ body {
font-weight: 500;
}
.m-notice {
width: 100%;
margin-top: 12px;
padding: 12px 16px;
border: 0;
display: flex;
align-items: center;
gap: 9px;
color: #6c5a45;
font: inherit;
text-align: left;
cursor: pointer;
}
.m-notice p {
flex: 1;
min-width: 0;
margin: 0;
white-space: nowrap;
overflow: hidden;
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 {
margin-top: 12px;
padding: 17px 16px;
+43 -5
View File
@@ -53,10 +53,22 @@
</button>
</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" />
<p>{{ home.notices[0].title || home.notices[0].content }}</p>
</div>
<span :class="['m-notice-tag', { 'is-activity': currentNotice.source_type === 'operational_simulation' }]">
{{ 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">
<div class="m-section-title"
@@ -101,7 +113,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
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 AnnouncementPopup, {
type AnnouncementNotice,
@@ -121,7 +133,7 @@
wallet: { available_amount: number; frozen_amount: number; currency: string };
features: Record<string, boolean>;
banners: BannerData[];
notices: Array<{ title?: string; content?: string }>;
notices: NoticeData[];
task_summary: {
today_count: number;
today_commission_amount: number;
@@ -135,6 +147,12 @@
target_type: 'none' | 'internal_route' | 'external_url';
target_value?: string;
}
interface NoticeData {
id: number;
title?: string;
content?: string;
source_type?: string;
}
interface LeaderboardEntry {
rank: number;
username: string;
@@ -149,12 +167,15 @@
const leaderboard = ref<LeaderboardEntry[]>([]);
const popupNotice = ref<AnnouncementNotice | null>(null);
const activeBannerIndex = ref(0);
const activeNoticeIndex = ref(0);
let bannerTimer: number | undefined;
let noticeTimer: number | undefined;
const formatPoints = (value: number) =>
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
(value || 0) / 100
);
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
const bannerStyle = computed(() => {
const url = safeImage(currentBanner.value?.image_url);
return {
@@ -234,6 +255,18 @@
}, 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() {
loading.value = true;
error.value = '';
@@ -269,8 +302,13 @@
activeBannerIndex.value = 0;
restartBannerTimer();
});
watch(() => home.value?.notices?.length, () => {
activeNoticeIndex.value = 0;
restartNoticeTimer();
});
onMounted(load);
onBeforeUnmount(() => {
if (bannerTimer) window.clearInterval(bannerTimer);
if (noticeTimer) window.clearInterval(noticeTimer);
});
</script>