Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56b84bb10 | ||
|
|
8ce67648b1 | ||
|
|
9bb479b506 | ||
|
|
79c502cb20 | ||
|
|
47b545d050 | ||
|
|
78165b7a15 | ||
|
|
6954d375b5 | ||
|
|
975a2a2ed2 | ||
|
|
5f85472757 | ||
|
|
92b5656f3f | ||
|
|
cdebd0a404 | ||
|
|
ba87d70478 | ||
|
|
db1367b8fe | ||
|
|
db18514e6d | ||
|
|
c39ec78e89 | ||
|
|
e6c421ac3e | ||
|
|
74785f948b | ||
|
|
f2f4720340 | ||
|
|
dffd47294e | ||
|
|
950890a734 | ||
|
|
179a9bdd16 | ||
|
|
aa0e17f1f1 | ||
|
|
47bb4fe2ed | ||
|
|
355d59813b | ||
|
|
04a3a0f538 | ||
|
|
1c11c0fa82 | ||
|
|
5e0abedbcc | ||
|
|
21371061ed | ||
|
|
319768bd82 | ||
|
|
d8facff163 | ||
|
|
2e47dd3935 | ||
|
|
715740fc30 | ||
|
|
804318e5f5 | ||
|
|
8b773a1a1f | ||
|
|
86efdf4525 | ||
|
|
5a0fe67027 | ||
|
|
f9497118bb | ||
|
|
b79b781f15 | ||
|
|
bce5edfaf5 | ||
|
|
a86db10342 | ||
|
|
70cd0e592a | ||
|
|
443622cd3c | ||
|
|
ecf1a89313 | ||
|
|
07c654b6d4 | ||
|
|
66121f48e9 | ||
|
|
6e223e537b | ||
|
|
eb96b7ac1f | ||
|
|
b6a0f819b8 | ||
|
|
05d6ec26ad | ||
|
|
acf50b9b7c | ||
|
|
06eaf748ed | ||
|
|
a6d7c8dda8 | ||
|
|
37913c37d1 | ||
|
|
48b3564ac9 | ||
|
|
f3f59fe7c3 | ||
|
|
dedacfcd9a | ||
|
|
7eedbde4ab | ||
|
|
acbfaf97a6 | ||
|
|
b3bf41af8b | ||
|
|
45ef10b2af | ||
|
|
2a3c411c9b | ||
|
|
76421fbc2d | ||
|
|
5e01e4344b | ||
|
|
4a70b85353 | ||
|
|
b2ebea4c80 | ||
|
|
32c6238751 | ||
|
|
6d6b6a0b59 | ||
|
|
35b6a37dad | ||
|
|
99c55f1145 | ||
|
|
abe697317c | ||
|
|
96d1de4a95 |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
coverage
|
||||||
@@ -1,37 +1,81 @@
|
|||||||
name: Deploy im-workbench (k3s test cluster)
|
name: Deploy olons-server production
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- pro
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
image_tag:
|
concurrency:
|
||||||
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
|
group: production-olons-server
|
||||||
required: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.olns.shop
|
||||||
|
IMAGE: git.olns.shop/pro/olons_server
|
||||||
|
SERVICE: olons-server
|
||||||
|
DEPLOY_HOST: 145.223.69.9
|
||||||
|
DEPLOY_USER: olns-deploy
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: deploy-host
|
runs-on: deploy-host
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 确定镜像tag
|
- name: Log in to Gitea Container Registry
|
||||||
id: vars
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.image_tag }}"
|
set -euo pipefail
|
||||||
[ -z "$TAG" ] && TAG=test
|
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: 登录镜像仓库
|
- name: Build and push image
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
env:
|
||||||
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
- name: 构建并推送镜像
|
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }} .
|
set -euo pipefail
|
||||||
docker push git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }}
|
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
|
||||||
|
docker build --build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||||
|
docker push "$IMAGE:pro"
|
||||||
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
|
|
||||||
- name: 部署到k3s测试集群
|
- name: Deploy Compose service
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.kube
|
set -euo pipefail
|
||||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
install -d -m 700 "$HOME/.ssh"
|
||||||
chmod 600 ~/.kube/config
|
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
|
||||||
kubectl rollout restart deployment/im-workbench -n workbench-system
|
cleanup() {
|
||||||
kubectl rollout status deployment/im-workbench -n workbench-system --timeout=120s
|
shred -u "$KEY_FILE" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
|
||||||
|
chmod 600 "$KEY_FILE"
|
||||||
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||||
|
chmod 600 "$HOME/.ssh/known_hosts"
|
||||||
|
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||||
|
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"docker login git.olns.shop -u admin --password-stdin"
|
||||||
|
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||||
|
set -euo pipefail
|
||||||
|
cd /opt/olns
|
||||||
|
docker compose pull olons-server
|
||||||
|
docker compose up -d --no-deps --wait olons-server
|
||||||
|
docker compose exec -T olons-server sh -c '"'"'
|
||||||
|
test -n "$OLONS_IM_INTERNAL_SECRET"
|
||||||
|
test "$OLONS_IM_TENANT_CODE" = "default"
|
||||||
|
test "$OLONS_SERVER_DEFAULT_TENANT_ID" = "0"
|
||||||
|
test "$OLONS_IM_BASE_URL" = "http://im-server:6060"
|
||||||
|
test "$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im"
|
||||||
|
test "$OLONS_IM_WS_URL" = "ws://im-server:6060/v0/channels"
|
||||||
|
test "$OLONS_IM_FILE_URL" = "http://im-server:6060/v0/file"
|
||||||
|
'"'"'
|
||||||
|
'
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
storage/uploads/
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ RUN corepack enable
|
|||||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
|
ARG VITE_CUSTOMER_SERVICE_PROVIDER=
|
||||||
|
ENV VITE_PUBLIC_PATH=/
|
||||||
|
ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# ---- Stage 2: build the Go backend ----
|
# ---- Stage 2: build the Go backend ----
|
||||||
@@ -16,15 +19,16 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web-builder /web/dist ./web/dist
|
COPY --from=web-builder /web/dist ./web/dist
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/olons_server ./cmd/olons_server
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
|
RUN apk add --no-cache tzdata
|
||||||
COPY --from=builder /out/im_workbench /app/im_workbench
|
ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
|
||||||
|
COPY --from=builder /out/olons_server /app/olons_server
|
||||||
COPY configs /app/configs
|
COPY configs /app/configs
|
||||||
COPY --from=web-builder /web/dist /app/web/dist
|
COPY --from=web-builder /web/dist /app/web/dist
|
||||||
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist
|
ENV OLONS_SERVER_FRONTEND_DIR=/app/web/dist
|
||||||
EXPOSE 6061
|
EXPOSE 6061
|
||||||
ENTRYPOINT ["/app/im_workbench"]
|
ENTRYPOINT ["/app/olons_server"]
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
.PHONY: build test run web-install web-build
|
.PHONY: build test run web-install web-build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o bin/im_workbench ./cmd/workbench
|
go build -o bin/olons_server ./cmd/olons_server
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
run:
|
run:
|
||||||
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
OLONS_SERVER_CONFIG=configs/olons_server.local.json go run ./cmd/olons_server
|
||||||
|
|
||||||
web-install:
|
web-install:
|
||||||
cd web && pnpm install --frozen-lockfile
|
cd web && pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Workbench
|
# Olons Server
|
||||||
|
|
||||||
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
|
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
|
||||||
|
|
||||||
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
项目现在采用前后端一体目录结构:`web/` 是 Vue 3 + Vite 移动端,Go 服务在生产环境通过网站根路径 `/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
||||||
|
|
||||||
## 当前接口
|
## 当前接口
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
- `GET /v1/manual-products/{product_id}`
|
- `GET /v1/manual-products/{product_id}`
|
||||||
- `GET /v1/payment-accounts`
|
- `GET /v1/payment-accounts`
|
||||||
- `POST /v1/payment-accounts`
|
- `POST /v1/payment-accounts`
|
||||||
|
- `PUT /v1/payment-accounts`
|
||||||
|
- `GET /v1/payment-accounts/details`
|
||||||
- `POST /v1/withdrawal-orders/quote`
|
- `POST /v1/withdrawal-orders/quote`
|
||||||
- `POST /v1/withdrawal-orders`
|
- `POST /v1/withdrawal-orders`
|
||||||
- `GET /v1/withdrawal-orders`
|
- `GET /v1/withdrawal-orders`
|
||||||
@@ -42,17 +44,16 @@
|
|||||||
核心环境变量:
|
核心环境变量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WORKBENCH_CONFIG=configs/workbench.local.json
|
OLONS_SERVER_CONFIG=configs/olons_server.local.json
|
||||||
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
||||||
WORKBENCH_REDIS_ADDR='127.0.0.1:6379'
|
OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
|
||||||
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>'
|
OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
|
||||||
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>'
|
OLONS_SERVER_API_KEYS='<API Key,多个以逗号分隔>'
|
||||||
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
`finance_master_key` 已在 JSON 配置中提供固定值,也可由 `OLONS_SERVER_FINANCE_MASTER_KEY` 覆盖。服务会从这个主密钥派生数据加密键和检索键,用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。密钥必须与已有数据保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
||||||
|
|
||||||
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
前端静态目录可通过 `OLONS_SERVER_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make web-install
|
make web-install
|
||||||
@@ -60,7 +61,7 @@ make web-build
|
|||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
|
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
|
||||||
|
|
||||||
## 请求头
|
## 请求头
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ X-Request-ID: <request id>
|
|||||||
X-Device-ID: <optional device id>
|
X-Device-ID: <optional device id>
|
||||||
```
|
```
|
||||||
|
|
||||||
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
var phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||||
|
|
||||||
|
type memberPhone struct {
|
||||||
|
id int64
|
||||||
|
tenantID int64
|
||||||
|
ciphertext string
|
||||||
|
hash string
|
||||||
|
mask string
|
||||||
|
phone string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("mysql", strings.TrimPrefix(cfg.MySQL.DSN, "mysql://"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hasMobile, err := columnExists(ctx, db, "mobile")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
hasCipher, err := columnExists(ctx, db, "mobile_cipher")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if hasMobile && !hasCipher {
|
||||||
|
log.Print("hg_app_member already uses the mobile column")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasCipher {
|
||||||
|
log.Fatal("mobile_cipher is missing before migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||||
|
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||||
|
overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(aeads) == 0 && len(lookupKeys) == 0 {
|
||||||
|
log.Fatal("no valid historical phone keys were provided")
|
||||||
|
}
|
||||||
|
phones, err := loadPhones(ctx, db, aeads, lookupKeys, overrides)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateUnique(phones); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasMobile {
|
||||||
|
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member ADD COLUMN mobile varchar(20) NULL AFTER username`); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := updatePhones(ctx, db, phones); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member
|
||||||
|
MODIFY COLUMN mobile varchar(20) NOT NULL,
|
||||||
|
DROP INDEX uk_app_member_mobile_hash,
|
||||||
|
ADD UNIQUE KEY uk_app_member_mobile (tenant_id,mobile),
|
||||||
|
DROP COLUMN mobile_cipher,
|
||||||
|
DROP COLUMN mobile_hash,
|
||||||
|
DROP COLUMN mobile_mask`); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Printf("migrated %d member phone numbers", len(phones))
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnExists(ctx context.Context, db *sql.DB, column string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='hg_app_member' AND COLUMN_NAME=?`, column).Scan(&count)
|
||||||
|
return count == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPhones(ctx context.Context, db *sql.DB, aeads []cipher.AEAD, lookupKeys [][]byte, overrides map[int64]string) ([]memberPhone, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id,tenant_id,mobile_cipher,mobile_hash,mobile_mask FROM hg_app_member ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
phones := make([]memberPhone, 0)
|
||||||
|
failed := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item memberPhone
|
||||||
|
if err := rows.Scan(&item.id, &item.tenantID, &item.ciphertext, &item.hash, &item.mask); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.phone = recoverPhone(item, aeads, lookupKeys)
|
||||||
|
if item.phone == "" {
|
||||||
|
item.phone = overrides[item.id]
|
||||||
|
}
|
||||||
|
if !phonePattern.MatchString(item.phone) {
|
||||||
|
failed = append(failed, fmt.Sprintf("%d", item.id))
|
||||||
|
} else if len(item.mask) == 11 && item.phone[:3]+"****"+item.phone[7:] != item.mask {
|
||||||
|
return nil, fmt.Errorf("member %d override does not match its phone mask", item.id)
|
||||||
|
}
|
||||||
|
phones = append(phones, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return nil, fmt.Errorf("phone recovery failed for member IDs: %s", strings.Join(failed, ","))
|
||||||
|
}
|
||||||
|
return phones, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUnique(phones []memberPhone) error {
|
||||||
|
seen := make(map[string]int64, len(phones))
|
||||||
|
for _, item := range phones {
|
||||||
|
key := fmt.Sprintf("%d:%s", item.tenantID, item.phone)
|
||||||
|
if otherID, ok := seen[key]; ok {
|
||||||
|
return fmt.Errorf("members %d and %d have duplicate phone numbers", otherID, item.id)
|
||||||
|
}
|
||||||
|
seen[key] = item.id
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePhones(ctx context.Context, db *sql.DB, phones []memberPhone) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, item := range phones {
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE hg_app_member SET mobile=? WHERE id=? AND tenant_id=?`, item.phone, item.id, item.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return fmt.Errorf("member %d update affected %d rows", item.id, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKeys(value string) []string {
|
||||||
|
return strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == '\n' })
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOverrides(value string) (map[int64]string, error) {
|
||||||
|
result := make(map[int64]string)
|
||||||
|
for _, pair := range splitKeys(value) {
|
||||||
|
var id int64
|
||||||
|
var phone string
|
||||||
|
if _, err := fmt.Sscanf(pair, "%d=%s", &id, &phone); err != nil || id <= 0 || !phonePattern.MatchString(phone) {
|
||||||
|
return nil, fmt.Errorf("invalid member phone override")
|
||||||
|
}
|
||||||
|
result[id] = phone
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAEADs(encodedKeys []string) []cipher.AEAD {
|
||||||
|
result := make([]cipher.AEAD, 0, len(encodedKeys))
|
||||||
|
for _, encodedKey := range encodedKeys {
|
||||||
|
key, err := decodeKey(encodedKey)
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err == nil {
|
||||||
|
result = append(result, aead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKeys(encodedKeys []string) [][]byte {
|
||||||
|
result := make([][]byte, 0, len(encodedKeys))
|
||||||
|
for _, encodedKey := range encodedKeys {
|
||||||
|
key, err := decodeKey(encodedKey)
|
||||||
|
if err == nil && len(key) >= 32 {
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKey(value string) ([]byte, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
return base64.RawStdEncoding.DecodeString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverPhone(item memberPhone, aeads []cipher.AEAD, lookupKeys [][]byte) string {
|
||||||
|
for _, aead := range aeads {
|
||||||
|
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.RawURLEncoding} {
|
||||||
|
plain, err := decrypt(aead, encoding, item.ciphertext)
|
||||||
|
phone := strings.TrimPrefix(plain, "+86")
|
||||||
|
if err == nil && phonePattern.MatchString(phone) {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.mask) != 11 || item.mask[3:7] != "****" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for middle := 0; middle < 10000; middle++ {
|
||||||
|
phone := fmt.Sprintf("%s%04d%s", item.mask[:3], middle, item.mask[7:])
|
||||||
|
for _, value := range []string{phone, "+86" + phone} {
|
||||||
|
digest := sha256.Sum256([]byte(value))
|
||||||
|
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(digest[:]))) {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range lookupKeys {
|
||||||
|
for _, value := range []string{phone, "+86" + phone} {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
_, _ = mac.Write([]byte(value))
|
||||||
|
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(aead cipher.AEAD, encoding *base64.Encoding, value string) (string, error) {
|
||||||
|
raw, err := encoding.DecodeString(value)
|
||||||
|
if err != nil || len(raw) < aead.NonceSize() {
|
||||||
|
return "", errors.New("invalid ciphertext")
|
||||||
|
}
|
||||||
|
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
|
||||||
|
return string(plain), err
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/app"
|
"git.118im.com/im_team/olons_server/internal/app"
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,7 +34,7 @@ func main() {
|
|||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("workbench listening on %s", cfg.Listen)
|
log.Printf("olons_server listening on %s", cfg.Listen)
|
||||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,15 @@
|
|||||||
"addr": "127.0.0.1:6379",
|
"addr": "127.0.0.1:6379",
|
||||||
"db": 0
|
"db": 0
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"dir": "storage/uploads",
|
||||||
|
"public_path": "/uploads"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "workbench: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",
|
||||||
"data_encryption_key": "",
|
|
||||||
"lookup_hmac_key": "",
|
|
||||||
"allow_dev_header_auth": true,
|
"allow_dev_header_auth": true,
|
||||||
"required_api_key": false
|
"required_api_key": false
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,15 @@
|
|||||||
"addr": "",
|
"addr": "",
|
||||||
"db": 0
|
"db": 0
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"dir": "storage/uploads",
|
||||||
|
"public_path": "/uploads"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "workbench: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",
|
||||||
"data_encryption_key": "",
|
|
||||||
"lookup_hmac_key": "",
|
|
||||||
"required_api_key": true
|
"required_api_key": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 564 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 541 B |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 689 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 619 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 842 B |
|
After Width: | Height: | Size: 293 B |
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 700 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 614 B |
|
After Width: | Height: | Size: 730 B |
|
After Width: | Height: | Size: 432 B |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 785 B |
|
After Width: | Height: | Size: 428 B |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 549 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 510 B |
|
After Width: | Height: | Size: 850 B |
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 976 B |
|
After Width: | Height: | Size: 522 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |