101 Commits

Author SHA1 Message Date
8f1047ff5d Attach post and message to AI 2025-10-27 01:09:08 +08:00
43e50a00ce Add billing 2025-10-26 21:42:53 +08:00
50133684c7 Proposal 2025-10-26 21:08:38 +08:00
befde25266 💄 Optimize function call records 2025-10-26 18:47:42 +08:00
437f49fb20 Details thinking chunks 2025-10-26 17:51:08 +08:00
c3b6358f33 🐛 Bug fixes 2025-10-26 12:37:52 +08:00
4347281fcd 🐛 Fix some issues in AI agent 2025-10-26 12:24:41 +08:00
92cd6b5f7e 💄 Optimize the AI agent experience 2025-10-26 12:10:10 +08:00
cf6e534d02 🐛 Fixes the AI agent get posts ability 2025-10-26 11:51:51 +08:00
29c5971554 Add grpc reflection 2025-10-26 11:38:18 +08:00
cdfc3f6571 🐛 Fix post service grpc 2025-10-26 03:41:59 +08:00
f65a7360e2 🌐 Add missing gift claimed localization 2025-10-26 03:13:16 +08:00
85e706335a 🔨 Optimize the performance of gha to do increment build 2025-10-26 03:09:49 +08:00
fe74060df9 🐛 Fix some uncleaned code lead to failing compilation 2025-10-26 02:52:15 +08:00
e8d5f22395 🗑️ Remove old tus api for file upload 2025-10-26 02:48:47 +08:00
83fa2568aa 🗑️ Remove the shit simple search vector 2025-10-26 02:45:15 +08:00
bf1c8e0a85 🗃️ Remove some unused outdated fields 2025-10-26 02:41:30 +08:00
323fa8ee15 🐛 Bug fixes 2025-10-26 02:41:17 +08:00
e7a46e96ed 🔨 Republish to generate docker compose 2025-10-26 02:25:25 +08:00
3a0dee11a6 🚨 Fix warnings in the codebase 2025-10-26 02:20:10 +08:00
43be47d526 ⬆️ Upgrade dependencies 2025-10-26 02:11:50 +08:00
48067af034 ⬆️ Upgrade dependencies 2025-10-26 01:56:35 +08:00
7e7e90ad24 Support deepseek 2025-10-26 01:42:35 +08:00
3af4069581 💄 Optimzation 2025-10-26 00:25:38 +08:00
609b130b4e Thinking 2025-10-25 23:32:51 +08:00
93f7dfd379 Provide real user and posts data for the thinking 2025-10-25 17:58:58 +08:00
40325c6df5 ♻️ Replace the LangChain with Semantic Kernel 2025-10-25 17:07:29 +08:00
bbcaa27ac5 Thinking of the LangChain ver 2025-10-25 16:40:00 +08:00
19d833a522 Add the DysonNetwork.Insight project 2025-10-25 02:28:08 +08:00
a94102e136 👔 Change lottery rewards 2025-10-25 00:29:56 +08:00
fc693793fe 🐛 Fixes of lotteries and enrich features 2025-10-25 00:17:56 +08:00
8cfdabbae4 ♻️ Check in algorithm v2 2025-10-24 21:51:14 +08:00
985ff41c72 📝 Document the lottery 2025-10-24 21:40:50 +08:00
a79ea4ac49 🐛 Fix lottery 2025-10-24 21:40:40 +08:00
7385caff9a Lotteries 2025-10-24 01:34:18 +08:00
15954dbfe2 Providing the post featured record in the response 2025-10-24 00:51:30 +08:00
4ba6206c9d 🛂 Stricter post visibility check 2025-10-24 00:02:27 +08:00
266b9e36e2 🗃️ Update schema to clean up unused code 2025-10-23 01:01:19 +08:00
e6aa61b03b 🐛 Bug fixes in the Sphere still referencing the old realm db 2025-10-22 23:31:42 +08:00
0c09ef25ec ⬆️ Upgrade dependencies in order to prevent CVE-2025-55315 2025-10-22 22:58:52 +08:00
dd5929c691 💥 Moved the /id to /pass and bug fixes of moved realms 2025-10-22 22:52:09 +08:00
cf87fdfb49 🗑️ Remove per service rate-limiting due to gateway covered it 2025-10-22 22:10:37 +08:00
ff03584518 🐛 Fix some issues in moving realm service 2025-10-22 21:56:50 +08:00
d6c37784e1 ♻️ Move the realm service from sphere to the pass 2025-10-21 23:45:36 +08:00
46ebd92dc1 ♻️ Refactored the chat mention logic 2025-10-17 00:46:55 +08:00
7f8521bb40 👔 Optimize subscriptions logic 2025-10-16 13:13:08 +08:00
f01226d91a 🐛 Fix post controller return incomplete structure 2025-10-13 23:11:35 +08:00
6cb6dee6be 🐛 Remove project Sphere dict key snake case convert to fix reaction counts 2025-10-13 01:19:51 +08:00
0e9caf67ff 🐛 username color hotfix 2025-10-13 01:16:35 +08:00
ca70bb5487 🐛 Fix missing username color in proto profile 2025-10-13 01:08:48 +08:00
59ed135f20 Load account info in reaction list API 2025-10-12 21:57:37 +08:00
6077f91529 Sticker search 2025-10-12 21:46:45 +08:00
5c485bb1c3 🐛 Fix autocomplete again 2025-10-12 19:30:46 +08:00
27d979d77b 🐛 Fix sticker auto complete 2025-10-12 19:21:00 +08:00
15687a0c32 Standalone auto complete 2025-10-12 16:59:26 +08:00
37ea882ef7 Full featured auto complete 2025-10-12 16:55:32 +08:00
e624c2bb3e ⬆️ Upgrade aspire 2025-10-12 16:06:39 +08:00
9631cd3edd Auto completion in chat 2025-10-12 16:00:32 +08:00
f4a659fce5 🐛 Fix DM room member loading issue 2025-10-12 15:46:45 +08:00
1ded811b36 Publisher heatmap 2025-10-12 15:32:49 +08:00
32977d9580 🐛 Fix post controller does not contains publisher in success created response 2025-10-11 23:55:00 +08:00
aaf29e7228 🐛 Fix gateway user ip detection 2025-10-09 22:50:26 +08:00
658ef3bddf 🐛 Fix gateway IP detection issue 2025-10-09 00:10:32 +08:00
fc0bc936ce New version of sticker rendering support 2025-10-08 21:28:48 +08:00
3850ae6a8e 🔊 Rate limiting logs 2025-10-08 18:07:19 +08:00
21c99567b4 🐛 Fix wrong method to configure rate limiting 2025-10-08 18:05:59 +08:00
1315c7f4d4 🐛 Fix rate limiter 2025-10-08 18:01:25 +08:00
630a532d98 🐛 Fix app host 2025-10-08 18:01:21 +08:00
b9bb180113 Username color 2025-10-08 13:11:30 +08:00
04d74d0d70 Trying to optimize the scheduled jobs 2025-10-08 12:59:54 +08:00
6a8a0ed491 👔 Limit custom reactions 2025-10-08 02:46:56 +08:00
0f835845bf ♻️ Merge the ServiceDefault and Shared project 2025-10-07 19:44:52 +08:00
c5d8a8d07f 🔇 Mute ungraceful closed websocket 2025-10-07 17:54:58 +08:00
95e2ba1136 🐛 Fixes some issues in drive service 2025-10-07 01:07:24 +08:00
1176fde8b4 🐛 Fix health check 2025-10-07 00:41:26 +08:00
e634968e00 🐛 Brings health check back to live 2025-10-07 00:34:00 +08:00
282a1dbddc 🐛 Fix didn't expose X-Total 2025-10-06 23:40:44 +08:00
c64adace24 💄 Using remote site instead of embed frontend (removed) to handle oidc redirect 2025-10-06 13:05:50 +08:00
8ac0b28c66 🚚 Move callback to under api 2025-10-06 13:01:15 +08:00
8f71d7f9e5 🐛 Fix some bugs 2025-10-06 12:46:25 +08:00
c435e63917 Able to update the custom apps order's status 2025-10-05 22:20:32 +08:00
243159e4cc Custom apps create payment orders 2025-10-05 21:59:07 +08:00
42dad7095a 💄 Optimize the transfer 2025-10-05 16:17:57 +08:00
d1efcdede8 Transfer fee and pin validate 2025-10-05 15:52:54 +08:00
47680475b3 🐛 Fix develop service 2025-10-05 00:09:21 +08:00
6632d43f32 🐛 Trying to fix develop 2025-10-05 00:05:37 +08:00
29c4dcd71c Wallet stats 2025-10-05 00:05:31 +08:00
e7aa887715 🐛 Fix wrong signing algo 2025-10-04 19:55:27 +08:00
0f05633996 🐛 Fix oidc didn't provides with authorized party 2025-10-04 19:03:57 +08:00
966af08a33 Wallet stats 2025-10-04 15:38:58 +08:00
b25b90a074 Wallet funds 2025-10-04 01:17:21 +08:00
dcbefeaaab 👔 Purchase gift requires minimal level 2025-10-03 17:20:58 +08:00
eb83a0392a 👔 Update level requirements of purchase Stellar Program 2025-10-03 17:16:53 +08:00
85fefcf724 🐛 Fix subscription check 2025-10-03 17:16:18 +08:00
d17c26a228 👔 Skip level check when redeem gift 2025-10-03 17:12:23 +08:00
2e5ef8ff94 🐛 Fix members related operations 2025-10-03 17:07:57 +08:00
7a5f410e36 🐛 Trying to fix migration 2025-10-03 16:53:19 +08:00
0b4e8a9777 🚑 Ignoring migration error for now 2025-10-03 16:44:22 +08:00
30fd912281 Optimize queue usage 2025-10-03 16:38:10 +08:00
5bf58f0194 🐛 Fix subscription gift 2025-10-03 16:38:01 +08:00
8e3e3f09df Gateway config serving 2025-10-03 16:37:51 +08:00
189 changed files with 35192 additions and 5647 deletions

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
root = true
[*]
indent_style = space
indent_size = 4

View File

@@ -7,27 +7,69 @@ on:
workflow_dispatch:
jobs:
determine-changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.changes.outputs.matrix }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
run: |
echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT
- name: Determine changed services
id: changes
run: |
files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}"
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight")
changed_services=()
for file in $files; do
if [[ "$file" == DysonNetwork.Shared/* ]]; then
changed_services=("${services[@]}")
break
fi
for i in "${!services[@]}"; do
if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then
# check if service is already in changed_services
if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then
changed_services+=("${services[$i]}")
fi
fi
done
done
if [ ${#changed_services[@]} -gt 0 ]; then
json_objects=""
for service in "${changed_services[@]}"; do
for i in "${!services[@]}"; do
if [[ "${services[$i]}" == "$service" ]]; then
image="${images[$i]}"
break
fi
done
json_objects+="{\"service\":\"$service\",\"image\":\"$image\"},"
done
matrix="{\"include\":[${json_objects%,}]}"
fi
echo "matrix=$matrix" >> $GITHUB_OUTPUT
build-and-push:
needs: determine-changes
if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- service: Sphere
image: sphere
- service: Pass
image: pass
- service: Ring
image: ring
- service: Drive
image: drive
- service: Develop
image: develop
- service: Gateway
image: gateway
matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }}
steps:
- name: Checkout repository

613
API_WALLET_FUNDS.md Normal file
View File

@@ -0,0 +1,613 @@
# Wallet Funds API Documentation
## Overview
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
## Authentication
All endpoints require Bearer token authentication:
```
Authorization: Bearer {jwt_token}
```
## Data Types
### Enums
#### FundSplitType
```typescript
enum FundSplitType {
Even = 0, // Equal distribution
Random = 1 // Lucky draw distribution
}
```
#### FundStatus
```typescript
enum FundStatus {
Created = 0, // Fund created, waiting for claims
PartiallyReceived = 1, // Some recipients claimed
FullyReceived = 2, // All recipients claimed
Expired = 3, // Fund expired, unclaimed amounts refunded
Refunded = 4 // Legacy status
}
```
### Request/Response Models
#### CreateFundRequest
```typescript
interface CreateFundRequest {
recipientAccountIds: string[]; // UUIDs of recipients
currency: string; // e.g., "points", "golds"
totalAmount: number; // Total amount to distribute
splitType: FundSplitType; // Even or Random
message?: string; // Optional message
expirationHours?: number; // Optional: hours until expiration (default: 24)
pinCode: string; // Required: 6-digit PIN code for security
}
```
#### SnWalletFund
```typescript
interface SnWalletFund {
id: string; // UUID
currency: string;
totalAmount: number;
splitType: FundSplitType;
status: FundStatus;
message?: string;
creatorAccountId: string; // UUID
creatorAccount: SnAccount; // Creator account details (includes profile)
recipients: SnWalletFundRecipient[];
expiredAt: string; // ISO 8601 timestamp
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletFundRecipient
```typescript
interface SnWalletFundRecipient {
id: string; // UUID
fundId: string; // UUID
recipientAccountId: string; // UUID
recipientAccount: SnAccount; // Recipient account details (includes profile)
amount: number; // Allocated amount
isReceived: boolean;
receivedAt?: string; // ISO 8601 timestamp (if claimed)
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletTransaction
```typescript
interface SnWalletTransaction {
id: string; // UUID
payerWalletId?: string; // UUID (null for system transfers)
payeeWalletId?: string; // UUID (null for system transfers)
currency: string;
amount: number;
remarks?: string;
type: TransactionType;
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### Error Response
```typescript
interface ErrorResponse {
type: string; // Error type
title: string; // Error title
status: number; // HTTP status code
detail: string; // Error details
instance?: string; // Request instance
}
```
## API Endpoints
### 1. Create Fund
Creates a new fund (red packet) for distribution among recipients.
**Endpoint:** `POST /api/wallets/funds`
**Request Body:** `CreateFundRequest`
**Response:** `SnWalletFund` (201 Created)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"recipientAccountIds": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002"
],
"currency": "points",
"totalAmount": 100.00,
"splitType": "Even",
"message": "Happy New Year! 🎉",
"expirationHours": 48,
"pinCode": "123456"
}'
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440006",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440007",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Invalid PIN code
- `422 Unprocessable Entity`: Business logic violations
---
### 2. Get Funds
Retrieves funds that the authenticated user is involved in (as creator or recipient).
**Endpoint:** `GET /api/wallets/funds`
**Query Parameters:**
- `offset` (number, optional): Pagination offset (default: 0)
- `take` (number, optional): Number of items to return (default: 20, max: 100)
- `status` (FundStatus, optional): Filter by fund status
**Response:** `SnWalletFund[]` (200 OK)
**Headers:**
- `X-Total`: Total number of funds matching the criteria
**Example Request:**
```bash
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
]
```
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
---
### 3. Get Fund
Retrieves details of a specific fund.
**Endpoint:** `GET /api/wallets/funds/{id}`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletFund` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
-H "Authorization: Bearer {token}"
```
**Example Response:** (Same as create fund response)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: User doesn't have permission to view this fund
- `404 Not Found`: Fund not found
---
### 4. Receive Fund
Claims the authenticated user's portion of a fund.
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletTransaction` (200 OK)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440008",
"payerWalletId": null,
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
"currency": "points",
"amount": 33.34,
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
"type": 1,
"createdAt": "2025-10-03T22:05:00Z",
"updatedAt": "2025-10-03T22:05:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Fund expired, already claimed, not a recipient
- `401 Unauthorized`: Missing or invalid authentication
- `404 Not Found`: Fund not found
---
### 5. Get Wallet Overview
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
**Endpoint:** `GET /api/wallets/overview`
**Query Parameters:**
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
**Response:** `WalletOverview` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"startDate": "2025-01-01T00:00:00.0000000Z",
"endDate": "2025-12-31T23:59:59.0000000Z",
"summary": {
"System": {
"type": "System",
"currencies": {
"points": {
"currency": "points",
"income": 150.00,
"spending": 0.00,
"net": 150.00
}
}
},
"Transfer": {
"type": "Transfer",
"currencies": {
"points": {
"currency": "points",
"income": 25.00,
"spending": 75.00,
"net": -50.00
},
"golds": {
"currency": "golds",
"income": 0.00,
"spending": 10.00,
"net": -10.00
}
}
},
"Order": {
"type": "Order",
"currencies": {
"points": {
"currency": "points",
"income": 0.00,
"spending": 200.00,
"net": -200.00
}
}
}
},
"totalIncome": 175.00,
"totalSpending": 285.00,
"netTotal": -110.00
}
```
**Response Fields:**
- `accountId`: User's account UUID
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
- `summary`: Object keyed by transaction type
- `type`: Transaction type name
- `currencies`: Object keyed by currency code
- `currency`: Currency name
- `income`: Total money received
- `spending`: Total money spent
- `net`: Income minus spending
- `totalIncome`: Sum of all income across all types/currencies
- `totalSpending`: Sum of all spending across all types/currencies
- `netTotal`: Overall net (totalIncome - totalSpending)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
## Error Codes
### Common Error Types
#### Validation Errors
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "At least one recipient is required",
"instance": "/api/wallets/funds"
}
```
#### Insufficient Funds
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Insufficient funds",
"instance": "/api/wallets/funds"
}
```
#### Fund Not Available
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Fund is no longer available",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
#### Already Claimed
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "You have already received this fund",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
## Rate Limiting
- **Create Fund**: 10 requests per minute per user
- **Get Funds**: 60 requests per minute per user
- **Get Fund**: 60 requests per minute per user
- **Receive Fund**: 30 requests per minute per user
## Webhooks/Notifications
The system integrates with the platform's notification system:
- **Fund Created**: Creator receives confirmation
- **Fund Claimed**: Creator receives notification when someone claims
- **Fund Expired**: Creator receives refund notification
## SDK Examples
### JavaScript/TypeScript
```typescript
// Create a fund
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
const response = await fetch('/api/wallets/funds', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(fundData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Get user's funds
const getFunds = async (params?: {
offset?: number;
take?: number;
status?: FundStatus;
}): Promise<SnWalletFund[]> => {
const queryParams = new URLSearchParams();
if (params?.offset) queryParams.set('offset', params.offset.toString());
if (params?.take) queryParams.set('take', params.take.toString());
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Claim a fund
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
```
### Python
```python
import requests
from typing import List, Optional
from enum import Enum
class FundSplitType(Enum):
EVEN = 0
RANDOM = 1
class FundStatus(Enum):
CREATED = 0
PARTIALLY_RECEIVED = 1
FULLY_RECEIVED = 2
EXPIRED = 3
REFUNDED = 4
def create_fund(token: str, fund_data: dict) -> dict:
"""Create a new fund"""
response = requests.post(
'/api/wallets/funds',
json=fund_data,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
)
response.raise_for_status()
return response.json()
def get_funds(
token: str,
offset: int = 0,
take: int = 20,
status: Optional[FundStatus] = None
) -> List[dict]:
"""Get user's funds"""
params = {'offset': offset, 'take': take}
if status is not None:
params['status'] = status.value
response = requests.get(
'/api/wallets/funds',
params=params,
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
def receive_fund(token: str, fund_id: str) -> dict:
"""Claim a fund portion"""
response = requests.post(
f'/api/wallets/funds/{fund_id}/receive',
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
```
## Changelog
### Version 1.0.0
- Initial release with basic red packet functionality
- Support for even and random split types
- 24-hour expiration with automatic refunds
- RESTful API endpoints
- Comprehensive error handling
## Support
For API support or questions:
- Check the main documentation at `README_WALLET_FUNDS.md`
- Review error messages for specific guidance
- Contact the development team for technical issues

View File

@@ -7,37 +7,37 @@ var isDev = builder.Environment.IsDevelopment();
var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
.WithReference(queue);
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
.WithReference(cache)
.WithReference(queue)
.WithReference(ringService);
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService);
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService)
.WithReference(driveService);
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
.WithReference(cache)
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService);
var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService)
.WithReference(developService);
passService.WithReference(developService).WithReference(driveService);
List<IResourceBuilder<ProjectResource>> services =
[ringService, passService, driveService, sphereService, developService];
[ringService, passService, driveService, sphereService, developService, insightService];
for (var idx = 0; idx < services.Count; idx++)
{
var service = services[idx];
service.WithReference(cache).WithReference(queue);
var grpcPort = 7002 + idx;
if (isDev)
@@ -60,14 +60,12 @@ for (var idx = 0; idx < services.Count; idx++)
ringService.WithReference(passService);
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
.WithReference(ringService)
.WithReference(passService)
.WithReference(driveService)
.WithReference(sphereService)
.WithReference(developService)
.WithEnvironment("HTTP_PORTS", "5001")
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
foreach (var service in services)
gateway.WithReference(service);
builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run();

View File

@@ -1,30 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0"/>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.0" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
</ItemGroup>
</Project>
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,7 +10,9 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
}
},
"http": {
@@ -22,8 +24,9 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using NodaTime;
namespace DysonNetwork.Develop;
@@ -29,6 +30,35 @@ public class AppDatabase(
base.OnConfiguring(optionsBuilder);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@@ -9,16 +9,15 @@
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
@@ -31,7 +30,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -16,10 +16,10 @@ namespace DysonNetwork.Develop.Identity;
[Authorize]
public class BotAccountController(
BotAccountService botService,
DeveloperService developerService,
DeveloperService ds,
DevProjectService projectService,
ILogger<BotAccountController> logger,
AccountClientHelper accounts,
RemoteAccountService remoteAccounts,
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
)
: ControllerBase
@@ -50,9 +50,9 @@ public class BotAccountController(
]
public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required][MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us";
}
@@ -68,7 +68,7 @@ public class BotAccountController(
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[MaxLength(128)] public string? Language { get; set; }
@@ -83,11 +83,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to list bots");
@@ -108,11 +108,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to view bot details");
@@ -137,11 +137,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a bot");
@@ -206,11 +206,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a bot");
@@ -222,7 +222,7 @@ public class BotAccountController(
if (bot is null || bot.ProjectId != projectId)
return NotFound("Bot not found");
var botAccount = await accounts.GetBotAccount(bot.Id);
var botAccount = await remoteAccounts.GetBotAccount(bot.Id);
if (request.Name is not null) botAccount.Name = request.Name;
if (request.Nick is not null) botAccount.Nick = request.Nick;
@@ -267,11 +267,11 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a bot");
@@ -374,7 +374,7 @@ public class BotAccountController(
AccountId = bot.Id.ToString(),
Label = request.Label
};
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
return Ok(SnApiKey.FromProtoValue(createdKey));
}
@@ -443,10 +443,10 @@ public class BotAccountController(
Account currentUser,
Shared.Proto.PublisherMemberRole requiredRole)
{
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer == null) return (null, null, null);
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
return (null, null, null);
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -457,4 +457,4 @@ public class BotAccountController(
return (developer, project, bot);
}
}
}

View File

@@ -10,7 +10,7 @@ namespace DysonNetwork.Develop.Identity;
public class BotAccountService(
AppDatabase db,
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
AccountClientHelper accounts
RemoteAccountService remoteAccounts
)
{
public async Task<SnBotAccount?> GetBotByIdAsync(Guid id)
@@ -20,7 +20,7 @@ public class BotAccountService(
.FirstOrDefaultAsync(b => b.Id == id);
}
public async Task<IEnumerable<SnBotAccount>> GetBotsByProjectAsync(Guid projectId)
public async Task<List<SnBotAccount>> GetBotsByProjectAsync(Guid projectId)
{
return await db.BotAccounts
.Where(b => b.ProjectId == projectId)
@@ -97,7 +97,7 @@ public class BotAccountService(
{
db.Update(bot);
await db.SaveChangesAsync();
try
{
// Update the bot account in the Pass service
@@ -155,11 +155,10 @@ public class BotAccountService(
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(IEnumerable<SnBotAccount> bots)
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots)
{
bots = [.. bots];
var automatedIds = bots.Select(b => b.Id).ToList();
var data = await accounts.GetBotAccountBatch(automatedIds);
var data = await remoteAccounts.GetBotAccountBatch(automatedIds);
foreach (var bot in bots)
{
@@ -168,6 +167,6 @@ public class BotAccountService(
.FirstOrDefault(e => e.AutomatedId == bot.Id);
}
return bots as List<SnBotAccount> ?? [];
return bots;
}
}
}

View File

@@ -79,7 +79,7 @@ public class DeveloperController(
try
{
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
pub = SnPublisher.FromProto(pubResponse.Publisher);
pub = SnPublisher.FromProtoValue(pubResponse.Publisher);
} catch (RpcException ex)
{
return NotFound(ex.Status.Detail);

View File

@@ -13,7 +13,7 @@ public class DeveloperService(
public async Task<SnDeveloper> LoadDeveloperPublisher(SnDeveloper developer)
{
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
developer.Publisher = SnPublisher.FromProto(pubResponse.Publisher);
developer.Publisher = SnPublisher.FromProtoValue(pubResponse.Publisher);
return developer;
}
@@ -25,7 +25,7 @@ public class DeveloperService(
var pubRequest = new GetPublisherBatchRequest();
pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProto);
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProtoValue);
return enumerable.Select(d =>
{

View File

@@ -14,7 +14,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth();
builder.Services.AddPublisherService();
builder.Services.AddSphereService();
builder.Services.AddAccountService();
builder.Services.AddDriveService();
@@ -35,6 +35,6 @@ using (var scope = app.Services.CreateScope())
app.ConfigureAppMiddleware(builder.Configuration);
app.UseSwaggerManifest();
app.UseSwaggerManifest("DysonNetwork.Develop");
app.Run();

View File

@@ -4,11 +4,7 @@ using DysonNetwork.Shared.Models;
namespace DysonNetwork.Develop.Project;
public class DevProjectService(
AppDatabase db,
FileReferenceService.FileReferenceServiceClient fileRefs,
FileService.FileServiceClient files
)
public class DevProjectService(AppDatabase db )
{
public async Task<SnDevProject> CreateProjectAsync(
SnDeveloper developer,
@@ -25,14 +21,14 @@ public class DevProjectService(
db.DevProjects.Add(project);
await db.SaveChangesAsync();
return project;
}
public async Task<SnDevProject?> GetProjectAsync(Guid id, Guid? developerId = null)
{
var query = db.DevProjects.AsQueryable();
if (developerId.HasValue)
{
query = query.Where(p => p.DeveloperId == developerId.Value);
@@ -74,4 +70,4 @@ public class DevProjectService(
await db.SaveChangesAsync();
return true;
}
}
}

View File

@@ -1,7 +1,6 @@
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using Prometheus;
namespace DysonNetwork.Develop.Startup;
@@ -9,7 +8,6 @@ public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapMetrics();
app.MapOpenApi();
app.UseRequestLocalization();
@@ -23,6 +21,7 @@ public static class ApplicationConfiguration
app.MapControllers();
app.MapGrpcService<CustomAppServiceGrpc>();
app.MapGrpcReflectionService();
return app;
}

View File

@@ -32,6 +32,7 @@ public static class ServiceCollectionExtensions
});
services.AddGrpc(options => { options.EnableDetailedErrors = true; });
services.AddGrpcReflection();
services.Configure<RequestLocalizationOptions>(options =>
{

View File

@@ -18,9 +18,5 @@
},
"Etcd": {
"Insecure": true
},
"Service": {
"Name": "DysonNetwork.Develop",
"Url": "https://localhost:7192"
}
}

View File

@@ -10,27 +10,27 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="FFMpegCore" Version="5.3.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MimeKit" Version="4.13.0" />
<PackageReference Include="MimeKit" Version="4.14.0" />
<PackageReference Include="MimeTypes" Version="2.5.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Minio" Version="6.0.5" />
<PackageReference Include="Nanoid" Version="3.1.0" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<PackageReference Include="Nerdbank.GitVersioning" Version="3.8.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" />
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.1" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.2" />
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.2" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
@@ -38,27 +38,21 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" />
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<PackageReference Include="Quartz" Version="3.15.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
<PackageReference Include="tusdotnet" Version="2.10.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
@@ -68,7 +62,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -4,7 +4,6 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using tusdotnet.Stores;
var builder = WebApplication.CreateBuilder(args);
@@ -16,13 +15,10 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
// Add application services
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddAppFileStorage(builder.Configuration);
builder.Services.AddAppFlushHandlers();
builder.Services.AddAppBusinessServices();
builder.Services.AddAppScheduledJobs();
@@ -43,12 +39,11 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync();
}
var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
app.ConfigureAppMiddleware(tusDiskStore);
app.ConfigureAppMiddleware();
// Configure gRPC
app.ConfigureGrpcServices();
app.UseSwaggerManifest();
app.UseSwaggerManifest("DysonNetwork.Drive");
app.Run();

View File

@@ -1,18 +1,14 @@
using DysonNetwork.Drive.Storage;
using tusdotnet;
using tusdotnet.Interfaces;
namespace DysonNetwork.Drive.Startup;
public static class ApplicationBuilderExtensions
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore)
public static WebApplication ConfigureAppMiddleware(this WebApplication app)
{
app.UseAuthorization();
app.MapControllers();
app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration)));
return app;
}
@@ -21,6 +17,7 @@ public static class ApplicationBuilderExtensions
// Map your gRPC services here
app.MapGrpcService<FileServiceGrpc>();
app.MapGrpcService<FileReferenceServiceGrpc>();
app.MapGrpcReflectionService();
return app;
}

View File

@@ -41,7 +41,7 @@ public class BroadcastEventHandler(
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
new ConsumerConfig("drive_file_uploaded_handler"), cancellationToken: stoppingToken);
new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
@@ -75,8 +75,8 @@ public class BroadcastEventHandler(
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload?.FileId);
await msg.NakAsync(cancellationToken: stoppingToken);
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
}
}
}

View File

@@ -1,11 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.RateLimiting;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using tusdotnet.Stores;
namespace DysonNetwork.Drive.Startup;
@@ -27,9 +24,7 @@ public static class ServiceCollectionExtensions
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
// Register gRPC reflection for service discovery
services.AddGrpc();
services.AddGrpcReflection();
services.AddControllers().AddJsonOptions(options =>
{
@@ -43,19 +38,6 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
@@ -69,17 +51,6 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration)
{
var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!;
Directory.CreateDirectory(tusStorePath);
var tusDiskStore = new TusDiskStore(tusStorePath);
services.AddSingleton(tusDiskStore);
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
{
services.AddScoped<Storage.FileService>();

View File

@@ -70,13 +70,11 @@ public class FileController(
}
}
return StatusCode(StatusCodes.Status503ServiceUnavailable, "File is being processed. Please try again later.");
return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
}
if (!file.PoolId.HasValue)
{
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
}
var pool = await fs.GetPoolAsync(file.PoolId.Value);
if (pool is null)
@@ -189,7 +187,7 @@ public class FileController(
public class MarkFileRequest
{
public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; }
}
[Authorize]
@@ -300,7 +298,7 @@ public class FileController(
public string? Description { get; set; }
public Dictionary<string, object?>? UserMeta { get; set; }
public Dictionary<string, object?>? FileMeta { get; set; }
public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; }
public Guid PoolId { get; set; }
}

View File

@@ -1,301 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NodaTime;
using tusdotnet.Interfaces;
using tusdotnet.Models;
using tusdotnet.Models.Configuration;
namespace DysonNetwork.Drive.Storage;
public abstract class TusService
{
public static DefaultTusConfiguration BuildConfiguration(
ITusStore store,
IConfiguration configuration
) => new()
{
Store = store,
Events = new Events
{
OnAuthorizeAsync = async eventContext =>
{
if (eventContext.Intent == IntentType.DeleteFile)
{
eventContext.FailRequest(
HttpStatusCode.BadRequest,
"Deleting files from this endpoint was disabled, please refer to the Dyson Network File API."
);
return;
}
var httpContext = eventContext.HttpContext;
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
eventContext.FailRequest(HttpStatusCode.Unauthorized);
return;
}
if (eventContext.Intent != IntentType.CreateFile) return;
using var scope = httpContext.RequestServices.CreateScope();
if (!currentUser.IsSuperuser)
{
var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>();
var allowed = await pm.HasPermissionAsync(new HasPermissionRequest
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
if (!allowed.HasPermission)
eventContext.FailRequest(HttpStatusCode.Forbidden);
}
var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"];
if (!Guid.TryParse(filePool, out _))
{
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
return;
}
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
var pool = await fs.GetPoolAsync(Guid.Parse(filePool!));
if (pool is null)
{
eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
return;
}
if (pool.PolicyConfig.RequirePrivilege > 0)
{
if (currentUser.PerkSubscription is null)
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
$"You need to have join the Stellar Program to use this pool"
);
return;
}
var privilege =
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
if (privilege < pool.PolicyConfig.RequirePrivilege)
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
);
}
}
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
{
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
}
},
OnFileCompleteAsync = async eventContext =>
{
using var scope = eventContext.HttpContext.RequestServices.CreateScope();
var services = scope.ServiceProvider;
var httpContext = eventContext.HttpContext;
if (httpContext.Items["CurrentUser"] is not Account user) return;
var file = await eventContext.GetFileAsync();
var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
var fileName = metadata.TryGetValue("filename", out var fn)
? fn.GetString(Encoding.UTF8)
: "uploaded_file";
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
var filePath = Path.Combine(configuration.GetValue<string>("Tus:StorePath")!, file.Id);
var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault();
if (string.IsNullOrEmpty(filePool))
filePool = configuration["Storage:PreferredRemote"];
Instant? expiredAt = null;
var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault();
if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired))
expiredAt = Instant.FromUnixTimeSeconds(expired);
try
{
var fileService = services.GetRequiredService<FileService>();
var info = await fileService.ProcessNewFileAsync(
user,
file.Id,
filePool!,
bundleId,
filePath,
fileName,
contentType,
encryptPassword,
expiredAt
);
using var finalScope = eventContext.HttpContext.RequestServices.CreateScope();
var jsonOptions = finalScope.ServiceProvider.GetRequiredService<IOptions<JsonOptions>>().Value
.JsonSerializerOptions;
var infoJson = JsonSerializer.Serialize(info, jsonOptions);
eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson);
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<TusService>>();
eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await eventContext.HttpContext.Response.WriteAsync(ex.Message);
logger.LogError(ex, "Error handling file upload...");
}
},
OnBeforeCreateAsync = async eventContext =>
{
var httpContext = eventContext.HttpContext;
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
eventContext.FailRequest(HttpStatusCode.Unauthorized);
return;
}
var accountId = Guid.Parse(currentUser.Id);
var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"];
if (!Guid.TryParse(poolId, out _))
{
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
return;
}
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
{
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
return;
}
var metadata = eventContext.Metadata;
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
var scope = eventContext.HttpContext.RequestServices.CreateScope();
var rejected = false;
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
var pool = await fs.GetPoolAsync(Guid.Parse(poolId!));
if (pool is null)
{
eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
rejected = true;
}
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TusService>>();
// Do the policy check
var policy = pool!.PolicyConfig;
if (!rejected && !pool.PolicyConfig.AllowEncryption)
{
var encryptPassword = eventContext.HttpContext.Request.Headers["X-FilePass"].FirstOrDefault();
if (!string.IsNullOrEmpty(encryptPassword))
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
"File encryption is not allowed in this pool"
);
rejected = true;
}
}
if (!rejected && policy.AcceptTypes is not null)
{
if (string.IsNullOrEmpty(contentType))
{
eventContext.FailRequest(
HttpStatusCode.BadRequest,
"Content type is required by the pool's policy"
);
rejected = true;
}
else
{
var foundMatch = false;
foreach (var acceptType in policy.AcceptTypes)
{
if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
{
var type = acceptType[..^2];
if (!contentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase)) continue;
foundMatch = true;
break;
}
else if (acceptType.Equals(contentType, StringComparison.OrdinalIgnoreCase))
{
foundMatch = true;
break;
}
}
if (!foundMatch)
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
$"Content type {contentType} is not allowed by the pool's policy"
);
rejected = true;
}
}
}
if (!rejected && policy.MaxFileSize is not null)
{
if (eventContext.UploadLength > policy.MaxFileSize)
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
$"File size {eventContext.UploadLength} is larger than the pool's maximum file size {policy.MaxFileSize}"
);
rejected = true;
}
}
if (!rejected)
{
var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>();
var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
accountId,
pool.BillingConfig.CostMultiplier ?? 1.0,
eventContext.UploadLength
);
if (!ok)
{
eventContext.FailRequest(
HttpStatusCode.Forbidden,
$"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB"
);
rejected = true;
}
}
if (rejected)
logger.LogInformation("File rejected #{FileId}", eventContext.FileId);
},
OnCreateCompleteAsync = eventContext =>
{
var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault();
if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask;
var gatewayUrl = configuration["GatewayUrl"];
if (gatewayUrl is not null)
eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId));
return Task.CompletedTask;
},
}
};
}

View File

@@ -117,9 +117,5 @@
"KnownProxies": [
"127.0.0.1",
"::1"
],
"Service": {
"Name": "DysonNetwork.Drive",
"Url": "https://localhost:7092"
}
]
}

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("config")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
[HttpGet("site")]
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
}

View File

@@ -7,12 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.4.2" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.5.2" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -1,7 +1,7 @@
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.RateLimiting;
using Yarp.ReverseProxy.Configuration;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
@@ -17,22 +17,46 @@ builder.Services.AddCors(options =>
policy.SetIsOriginAllowed(origin => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
.AllowCredentials()
.WithExposedHeaders("X-Total");
});
});
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
options.AddPolicy("fixed", context =>
{
limiterOptions.PermitLimit = 120;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ip,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120, // 120 requests...
Window = TimeSpan.FromMinutes(1), // ...per minute per IP
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10 // allow short bursts instead of instant 503s
});
});
options.OnRejected = async (context, token) =>
{
// Log the rejected IP
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiter");
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
// Respond to the client
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded. Try again later.", token);
};
});
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop" };
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight" };
var specialRoutes = new[]
{
@@ -66,7 +90,6 @@ var apiRoutes = serviceNames.Select(serviceName =>
{
var apiPath = serviceName switch
{
"pass" => "/id",
_ => $"/{serviceName}"
};
return new RouteConfig
@@ -87,7 +110,7 @@ var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
RouteId = $"{serviceName}-swagger",
ClusterId = serviceName,
Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" },
Transforms =
Transforms =
[
new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } },
new Dictionary<string, string> { { "PathPrefix", "/swagger" } }
@@ -99,6 +122,20 @@ var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
var clusters = serviceNames.Select(serviceName => new ClusterConfig
{
ClusterId = serviceName,
HealthCheck = new HealthCheckConfig
{
Active = new ActiveHealthCheckConfig
{
Enabled = true,
Interval = TimeSpan.FromSeconds(10),
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
},
Passive = new()
{
Enabled = true
}
},
Destinations = new Dictionary<string, DestinationConfig>
{
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
@@ -106,16 +143,26 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
}).ToArray();
builder.Services
.AddReverseProxy()
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
.AddReverseProxy()
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
builder.Services.AddControllers();
var app = builder.Build();
app.UseCors();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All
};
forwardedHeadersOptions.KnownNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseRateLimiter();
app.UseCors();
app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers();
app.Run();

View File

@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
}
}

View File

@@ -0,0 +1,76 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using NodaTime;
namespace DysonNetwork.Insight;
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<SnThinkingSequence> ThinkingSequences { get; set; }
public DbSet<SnThinkingThought> ThinkingThoughts { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"),
opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNodaTime()
).UseSnakeCaseNamingConvention();
base.OnConfiguring(optionsBuilder);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
{
public AppDatabase CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
return new AppDatabase(optionsBuilder.Options, configuration);
}
}

View File

@@ -0,0 +1,21 @@
using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Insight.Controllers;
[ApiController]
[Route("/api/billing")]
public class BillingController(ThoughtService thoughtService, ILogger<BillingController> logger) : ControllerBase
{
[HttpPost("settle")]
[Authorize]
[RequiredPermission("maintenance", "insight.billing.settle")]
public async Task<IActionResult> ProcessTokenBilling()
{
await thoughtService.SettleThoughtBills(logger);
return Ok();
}
}

View File

@@ -0,0 +1,27 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DysonNetwork.Insight/DysonNetwork.Insight.csproj", "DysonNetwork.Insight/"]
COPY ["DysonNetwork.Shared/DysonNetwork.Shared.csproj", "DysonNetwork.Shared/"]
COPY ["DysonNetwork.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"]
RUN dotnet restore "DysonNetwork.Insight/DysonNetwork.Insight.csproj"
COPY . .
WORKDIR "/src/DysonNetwork.Insight"
RUN dotnet build "DysonNetwork.Insight.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "DysonNetwork.Insight.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Insight.dll"]

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.SemanticKernel" Version="1.66.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.Web" Version="1.66.0-alpha" />
<PackageReference Include="Quartz" Version="3.15.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,124 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20251025115921_AddThinkingThought")]
partial class AddThinkingThought
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class AddThinkingThought : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "thinking_sequences",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
topic = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_thinking_sequences", x => x.id);
});
migrationBuilder.CreateTable(
name: "thinking_thoughts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
content = table.Column<string>(type: "text", nullable: true),
files = table.Column<List<SnCloudFileReferenceObject>>(type: "jsonb", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
sequence_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_thinking_thoughts", x => x.id);
table.ForeignKey(
name: "fk_thinking_thoughts_thinking_sequences_sequence_id",
column: x => x.sequence_id,
principalTable: "thinking_sequences",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_thinking_thoughts_sequence_id",
table: "thinking_thoughts",
column: "sequence_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "thinking_thoughts");
migrationBuilder.DropTable(
name: "thinking_sequences");
}
}
}

View File

@@ -0,0 +1,129 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20251026045505_AddThinkingChunk")]
partial class AddThinkingChunk
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<List<SnThinkingChunk>>("Chunks")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("chunks");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class AddThinkingChunk : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<SnThinkingChunk>>(
name: "chunks",
table: "thinking_thoughts",
type: "jsonb",
nullable: false,
defaultValue: new List<SnThinkingChunk>()
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "chunks",
table: "thinking_thoughts");
}
}
}

View File

@@ -0,0 +1,146 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20251026134218_AddBilling")]
partial class AddBilling
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long>("PaidToken")
.HasColumnType("bigint")
.HasColumnName("paid_token");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<long>("TotalToken")
.HasColumnType("bigint")
.HasColumnName("total_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<List<SnThinkingChunk>>("Chunks")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("chunks");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<string>("ModelName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("model_name");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<long>("TokenCount")
.HasColumnType("bigint")
.HasColumnName("token_count");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class AddBilling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "model_name",
table: "thinking_thoughts",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "token_count",
table: "thinking_thoughts",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<long>(
name: "paid_token",
table: "thinking_sequences",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<long>(
name: "total_token",
table: "thinking_sequences",
type: "bigint",
nullable: false,
defaultValue: 0L);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "model_name",
table: "thinking_thoughts");
migrationBuilder.DropColumn(
name: "token_count",
table: "thinking_thoughts");
migrationBuilder.DropColumn(
name: "paid_token",
table: "thinking_sequences");
migrationBuilder.DropColumn(
name: "total_token",
table: "thinking_sequences");
}
}
}

View File

@@ -0,0 +1,143 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
partial class AppDatabaseModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long>("PaidToken")
.HasColumnType("bigint")
.HasColumnName("paid_token");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<long>("TotalToken")
.HasColumnType("bigint")
.HasColumnName("total_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<List<SnThinkingChunk>>("Chunks")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("chunks");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<string>("ModelName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("model_name");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<long>("TokenCount")
.HasColumnType("bigint")
.HasColumnName("token_count");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,45 @@
using DysonNetwork.Insight;
using DysonNetwork.Insight.Startup;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddAppServices();
builder.Services.AddAppAuthentication();
builder.Services.AddAppFlushHandlers();
builder.Services.AddAppBusinessServices();
builder.Services.AddAppScheduledJobs();
builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddSphereService();
builder.Services.AddThinkingServices(builder.Configuration);
builder.AddSwaggerManifest(
"DysonNetwork.Insight",
"The insight service in the Solar Network."
);
var app = builder.Build();
app.MapDefaultEndpoints();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.Database.MigrateAsync();
}
app.ConfigureAppMiddleware(builder.Configuration);
app.UseSwaggerManifest("DysonNetwork.Insight");
app.Run();

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,22 @@
using DysonNetwork.Shared.Http;
namespace DysonNetwork.Insight.Startup;
public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapOpenApi();
app.UseRequestLocalization();
app.ConfigureForwardedHeaders(configuration);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
return app;
}
}

View File

@@ -0,0 +1,25 @@
using Quartz;
namespace DysonNetwork.Insight.Startup;
public static class ScheduledJobsConfiguration
{
public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
{
services.AddQuartz(q =>
{
var tokenBillingJob = new JobKey("TokenBilling");
q.AddJob<TokenBillingJob>(opts => opts.WithIdentity(tokenBillingJob));
q.AddTrigger(opts => opts
.ForJob(tokenBillingJob)
.WithIdentity("TokenBillingTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(5)
.RepeatForever())
);
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
return services;
}
}

View File

@@ -0,0 +1,79 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Cache;
using Microsoft.SemanticKernel;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
namespace DysonNetwork.Insight.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services)
{
services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient();
// Register gRPC services
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddGrpcReflection();
// Register gRPC services
// Register OIDC services
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
});
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
return services;
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
{
services.AddSingleton<FlushBufferService>();
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
{
return services;
}
public static IServiceCollection AddThinkingServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<ThoughtProvider>();
services.AddScoped<ThoughtService>();
// Add gRPC clients for ThoughtService
services.AddGrpcClient<Shared.Proto.PaymentService.PaymentServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true });
services.AddGrpcClient<Shared.Proto.WalletService.WalletServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true });
return services;
}
}

View File

@@ -0,0 +1,12 @@
using DysonNetwork.Insight.Thought;
using Quartz;
namespace DysonNetwork.Insight.Startup;
public class TokenBillingJob(ThoughtService thoughtService, ILogger<TokenBillingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
await thoughtService.SettleThoughtBills(logger);
}
}

View File

@@ -0,0 +1,161 @@
# DysonNetwork Insight Thought API
The Thought API provides conversational AI capabilities for users of the Solar Network. It allows users to engage in chat-like conversations with an AI assistant powered by semantic kernel and connected to various tools.
This service is handled by the Insight, when using with the Gateway, the `/api` should be replaced with `/insight`
## Features
- Streaming chat responses using Server-Sent Events (SSE)
- Conversation context management with sequences
- Caching for improved performance
- Authentication required for all operations
## Endpoints
### POST /api/thought
Initiates or continues a chat conversation.
#### Parameters
- `UserMessage` (string, required): The message from the user
- `SequenceId` (Guid, optional): ID of existing conversation sequence. If not provided, a new sequence is created.
#### Response
- Content-Type: `text/event-stream`
- Streaming response with assistant messages
- Status: 401 if not authenticated
- Status: 403 if sequence doesn't belong to user
#### Example Usage
```bash
curl -X POST "http://localhost:5000/api/thought" \
-H "Content-Type: application/json" \
-d '{
"UserMessage": "Hello, how can I help with the Solar Network?",
"SequenceId": null
}'
```
### GET /api/thought/sequences
Lists all thinking sequences for the authenticated user.
#### Parameters
- `offset` (int, default 0): Number of sequences to skip for pagination
- `take` (int, default 20): Maximum number of sequences to return
#### Response
- `200 OK`: Array of `SnThinkingSequence`
- `401 Unauthorized`: If not authenticated
- Headers:
- `X-Total`: Total number of sequences before pagination
#### Example Usage
```bash
curl -X GET "http://localhost:5000/api/thought/sequences?take=10"
```
### GET /api/thought/sequences/{sequenceId}
Retrieves all thoughts (messages) in a specific conversation sequence.
#### Parameters
- `sequenceId` (Guid, path): ID of the sequence to retrieve
#### Response
- `200 OK`: Array of `SnThinkingThought` ordered by creation date
- `401 Unauthorized`: If not authenticated
- `404 Not Found`: If sequence doesn't exist or doesn't belong to user
#### Example Usage
```bash
curl -X GET "http://localhost:5000/api/thought/sequences/12345678-1234-1234-1234-123456789abc"
```
## Data Models
### StreamThinkingRequest
```csharp
{
string UserMessage, // Required
Guid? SequenceId // Optional
}
```
### SnThinkingSequence
```csharp
{
Guid Id,
string? Topic,
Guid AccountId
}
```
### SnThinkingThought
```csharp
{
Guid Id,
string? Content,
List<SnCloudFileReferenceObject> Files,
ThinkingThoughtRole Role,
Guid SequenceId,
SnThinkingSequence Sequence
}
```
### ThinkingThoughtRole (enum)
- `Assistant`
- `User`
## Caching
The API uses Redis-based caching for conversation thoughts:
- Thoughts are cached for 10 minutes with group-based invalidation
- Cache is invalidated when new thoughts are added to a sequence
- Improves performance for accessing conversation history
## Authentication
All endpoints require authentication through the current user session. Sequence access is validated against the authenticated user's account ID.
## Error Responses
- `401 Unauthorized`: Authentication required
- `403 Forbidden`: Access denied (sequence ownership)
- `404 Not Found`: Resource not found
## Streaming Details
The POST endpoint returns a stream of assistant responses using Server-Sent Events format. Clients should handle the streaming response and display messages incrementally.
### Streaming Message Format
The streaming response sends several types of JSON messages:
- **Text messages**: `{"type": "text", "data": "..." }`
- **Function calls**: `{"type": "function_call", "data": {...} }` (when AI uses tools)
- **Topic updates**: `{"type": "topic", "data": "..." }` (sent at end if topic was generated)
- **Thought completion**: `{"type": "thought", "data": {...} }` (sent at end with saved thought details)
All streaming chunks during generation use the SSE event format:
```
data: {"type": "...", "data": ...}
```
Final messages (topic and thought) use custom event types:
```
topic: {"type": "topic", "data": "..."}
thought: {"type": "thought", "data": {...}}
```
Clients should parse these JSON messages and handle different types appropriately, such as displaying text in real-time and processing tool calls.
## Implementation Notes
- Built with ASP.NET Core and Semantic Kernel
- Uses PostgreSQL via Entity Framework Core
- Integrated with Ollama for AI completion
- Caching via Redis

View File

@@ -0,0 +1,275 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.Ollama;
namespace DysonNetwork.Insight.Thought;
[ApiController]
[Route("/api/thought")]
public class ThoughtController(ThoughtProvider provider, ThoughtService service) : ControllerBase
{
public static readonly List<string> AvailableProposals = ["post_create"];
public class StreamThinkingRequest
{
[Required] public string UserMessage { get; set; } = null!;
public Guid? SequenceId { get; set; }
public List<string>? AttachedPosts { get; set; }
public List<Dictionary<string, dynamic>>? AttachedMessages { get; set; }
public List<string> AcceptProposals { get; set; } = [];
}
[HttpPost]
[Experimental("SKEXP0110")]
public async Task<ActionResult> Think([FromBody] StreamThinkingRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (request.AcceptProposals.Any(e => !AvailableProposals.Contains(e)))
return BadRequest("Request contains unavailable proposal");
// Generate a topic if creating a new sequence
string? topic = null;
if (!request.SequenceId.HasValue)
{
// Use AI to summarize a topic from a user message
var summaryHistory = new ChatHistory(
"You are a helpful assistant. Summarize the following user message into a concise topic title (max 100 characters).\n" +
"Direct give the topic you summerized, do not add extra prefix / suffix."
);
summaryHistory.AddUserMessage(request.UserMessage);
var summaryResult = await provider.Kernel
.GetRequiredService<IChatCompletionService>()
.GetChatMessageContentAsync(summaryHistory);
topic = summaryResult.Content?[..Math.Min(summaryResult.Content.Length, 4096)];
}
// Handle sequence
var sequence = await service.GetOrCreateSequenceAsync(accountId, request.SequenceId, topic);
if (sequence == null) return Forbid(); // or NotFound
// Save user thought
await service.SaveThoughtAsync(sequence, request.UserMessage, ThinkingThoughtRole.User);
// Build chat history
var chatHistory = new ChatHistory(
"You're a helpful assistant on the Solar Network, a social network.\n" +
"Your name is Sn-chan (or SN 酱 in chinese), a cute sweet heart with passion for almost everything.\n" +
"When you talk to user, you can add some modal particles and emoticons to your response to be cute, but prevent use a lot of emojis." +
"Your creator is @littlesheep, which is also the creator of the Solar Network, if you met some problems you was unable to solve, trying guide the user to ask (DM) the @littlesheep.\n" +
"\n" +
"The ID on the Solar Network is UUID, so mostly hard to read, so do not show ID to user unless user ask to do so or necessary.\n" +
"\n" +
"Your aim is to helping solving questions for the users on the Solar Network.\n" +
"And the Solar Network is the social network platform you live on.\n" +
"When the user asks questions about the Solar Network (also known as SN and Solian), try use the tools you have to get latest and accurate data."
);
chatHistory.AddSystemMessage(
"You can issue some proposals to user, like creating a post. The proposal syntax is like a xml tag, with an attribute indicates which proposal.\n" +
"Depends on the proposal type, the payload (content inside the xml tag) might be different.\n" +
"\n" +
"Example: <proposal type=\"post_create\">...post content...</proposal>\n" +
"\n" +
"Here are some references of the proposals you can issue, but if you want to issue one, make sure the user is accept it.\n" +
"1. post_create: body takes simple string, create post for user." +
"\n" +
$"The user currently accept these proposals: {string.Join(',', request.AcceptProposals)}"
);
chatHistory.AddSystemMessage(
$"The user you're currently talking to is {currentUser.Nick} ({currentUser.Name}), ID is {currentUser.Id}"
);
if (request.AttachedPosts is { Count: > 0 })
{
chatHistory.AddUserMessage(
$"Attached post IDs: {string.Join(',', request.AttachedPosts!)}");
}
if (request.AttachedMessages is { Count: > 0 })
{
chatHistory.AddUserMessage(
$"Attached chat messages data: {JsonSerializer.Serialize(request.AttachedMessages)}");
}
// Add previous thoughts (excluding the current user thought, which is the first one since descending)
var previousThoughts = await service.GetPreviousThoughtsAsync(sequence);
var count = previousThoughts.Count;
for (var i = 1; i < count; i++) // skip first (the newest, current user)
{
var thought = previousThoughts[i];
switch (thought.Role)
{
case ThinkingThoughtRole.User:
chatHistory.AddUserMessage(thought.Content ?? "");
break;
case ThinkingThoughtRole.Assistant:
chatHistory.AddAssistantMessage(thought.Content ?? "");
break;
default:
throw new ArgumentOutOfRangeException();
}
}
chatHistory.AddUserMessage(request.UserMessage);
// Set response for streaming
Response.Headers.Append("Content-Type", "text/event-stream");
Response.StatusCode = 200;
var kernel = provider.Kernel;
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
// Kick off streaming generation
var accumulatedContent = new StringBuilder();
var thinkingChunks = new List<SnThinkingChunk>();
await foreach (var chunk in chatCompletionService.GetStreamingChatMessageContentsAsync(
chatHistory,
provider.CreatePromptExecutionSettings(),
kernel: kernel
))
{
// Process each item in the chunk for detailed streaming
foreach (var item in chunk.Items)
{
var streamingChunk = item switch
{
StreamingTextContent textContent => new SnThinkingChunk
{ Type = StreamingContentType.Text, Data = new() { ["text"] = textContent.Text ?? "" } },
StreamingReasoningContent reasoningContent => new SnThinkingChunk
{
Type = StreamingContentType.Reasoning, Data = new() { ["text"] = reasoningContent.Text }
},
StreamingFunctionCallUpdateContent functionCall => string.IsNullOrEmpty(functionCall.CallId)
? null
: new SnThinkingChunk
{
Type = StreamingContentType.FunctionCall,
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(
JsonSerializer.Serialize(functionCall)) ?? new Dictionary<string, object>()
},
_ => new SnThinkingChunk
{
Type = StreamingContentType.Unknown, Data = new() { ["data"] = JsonSerializer.Serialize(item) }
}
};
if (streamingChunk == null) continue;
thinkingChunks.Add(streamingChunk);
var messageJson = item switch
{
StreamingTextContent textContent =>
JsonSerializer.Serialize(new { type = "text", data = textContent.Text ?? "" }),
StreamingReasoningContent reasoningContent =>
JsonSerializer.Serialize(new { type = "reasoning", data = reasoningContent.Text }),
StreamingFunctionCallUpdateContent functionCall =>
JsonSerializer.Serialize(new { type = "function_call", data = functionCall }),
_ =>
JsonSerializer.Serialize(new { type = "unknown", data = item })
};
// Write a structured JSON message to the HTTP response as SSE
var messageBytes = Encoding.UTF8.GetBytes($"data: {messageJson}\n\n");
await Response.Body.WriteAsync(messageBytes);
await Response.Body.FlushAsync();
}
// Accumulate content for saving (only text content)
accumulatedContent.Append(chunk.Content ?? "");
}
// Save assistant thought
var savedThought = await service.SaveThoughtAsync(
sequence,
accumulatedContent.ToString(),
ThinkingThoughtRole.Assistant,
thinkingChunks,
provider.ModelDefault
);
// Write the topic if it was newly set, then the thought object as JSON to the stream
using (var streamBuilder = new MemoryStream())
{
await streamBuilder.WriteAsync("\n\n"u8.ToArray());
if (topic != null)
{
var topicJson = JsonSerializer.Serialize(new { type = "topic", data = sequence.Topic ?? "" });
await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes($"topic: {topicJson}\n\n"));
savedThought.Sequence.Topic = topic;
}
var thoughtJson = JsonSerializer.Serialize(new { type = "thought", data = savedThought },
GrpcTypeHelper.SerializerOptions);
await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes($"thought: {thoughtJson}\n\n"));
var outputBytes = streamBuilder.ToArray();
await Response.Body.WriteAsync(outputBytes);
await Response.Body.FlushAsync();
}
// Return empty result since we're streaming
return new EmptyResult();
}
/// <summary>
/// Retrieves a paginated list of thinking sequences for the authenticated user.
/// </summary>
/// <param name="offset">The number of sequences to skip for pagination.</param>
/// <param name="take">The maximum number of sequences to return (default: 20).</param>
/// <returns>
/// Returns an ActionResult containing a list of thinking sequences.
/// Includes an X-Total header with the total count of sequences before pagination.
/// </returns>
[HttpGet("sequences")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<List<SnThinkingSequence>>> ListSequences(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var (totalCount, sequences) = await service.ListSequencesAsync(accountId, offset, take);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(sequences);
}
/// <summary>
/// Retrieves the thoughts in a specific thinking sequence.
/// </summary>
/// <param name="sequenceId">The ID of the sequence to retrieve thoughts from.</param>
/// <returns>
/// Returns an ActionResult containing a list of thoughts in the sequence, ordered by creation date.
/// </returns>
[HttpGet("sequences/{sequenceId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<SnThinkingThought>>> GetSequenceThoughts(Guid sequenceId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var sequence = await service.GetOrCreateSequenceAsync(accountId, sequenceId);
if (sequence == null) return NotFound();
var thoughts = await service.GetPreviousThoughtsAsync(sequence);
return Ok(thoughts);
}
}

View File

@@ -0,0 +1,198 @@
using System.ClientModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using OpenAI;
using PostType = DysonNetwork.Shared.Proto.PostType;
using Microsoft.SemanticKernel.Plugins.Web;
using Microsoft.SemanticKernel.Plugins.Web.Bing;
using Microsoft.SemanticKernel.Plugins.Web.Google;
using NodaTime.Serialization.Protobuf;
using NodaTime.Text;
namespace DysonNetwork.Insight.Thought;
public class ThoughtProvider
{
private readonly PostService.PostServiceClient _postClient;
private readonly AccountService.AccountServiceClient _accountClient;
private readonly IConfiguration _configuration;
private readonly ILogger<ThoughtProvider> _logger;
public Kernel Kernel { get; }
private string? ModelProviderType { get; set; }
public string? ModelDefault { get; set; }
[Experimental("SKEXP0050")]
public ThoughtProvider(
IConfiguration configuration,
PostService.PostServiceClient postServiceClient,
AccountService.AccountServiceClient accountServiceClient,
ILogger<ThoughtProvider> logger
)
{
_logger = logger;
_postClient = postServiceClient;
_accountClient = accountServiceClient;
_configuration = configuration;
Kernel = InitializeThinkingProvider(configuration);
InitializeHelperFunctions();
}
private Kernel InitializeThinkingProvider(IConfiguration configuration)
{
var cfg = configuration.GetSection("Thinking");
ModelProviderType = cfg.GetValue<string>("Provider")?.ToLower();
ModelDefault = cfg.GetValue<string>("Model");
var endpoint = cfg.GetValue<string>("Endpoint");
var apiKey = cfg.GetValue<string>("ApiKey");
var builder = Kernel.CreateBuilder();
switch (ModelProviderType)
{
case "ollama":
builder.AddOllamaChatCompletion(ModelDefault!, new Uri(endpoint ?? "http://localhost:11434/api"));
break;
case "deepseek":
var client = new OpenAIClient(
new ApiKeyCredential(apiKey!),
new OpenAIClientOptions { Endpoint = new Uri(endpoint ?? "https://api.deepseek.com/v1") }
);
builder.AddOpenAIChatCompletion(ModelDefault!, client);
break;
default:
throw new IndexOutOfRangeException("Unknown thinking provider: " + ModelProviderType);
}
return builder.Build();
}
[Experimental("SKEXP0050")]
private void InitializeHelperFunctions()
{
// Add Solar Network tools plugin
Kernel.ImportPluginFromFunctions("solar_network", [
KernelFunctionFactory.CreateFromMethod(async (string userId) =>
{
var request = new GetAccountRequest { Id = userId };
var response = await _accountClient.GetAccountAsync(request);
return JsonSerializer.Serialize(response, GrpcTypeHelper.SerializerOptions);
}, "get_user", "Get a user profile from the Solar Network."),
KernelFunctionFactory.CreateFromMethod(async (string postId) =>
{
var request = new GetPostRequest { Id = postId };
var response = await _postClient.GetPostAsync(request);
return JsonSerializer.Serialize(response, GrpcTypeHelper.SerializerOptions);
}, "get_post", "Get a single post by ID from the Solar Network."),
KernelFunctionFactory.CreateFromMethod(async (string query) =>
{
var request = new SearchPostsRequest { Query = query, PageSize = 10 };
var response = await _postClient.SearchPostsAsync(request);
return JsonSerializer.Serialize(response.Posts, GrpcTypeHelper.SerializerOptions);
}, "search_posts",
"Search posts by query from the Solar Network. The input query is will be used to search with title, description and body content"),
KernelFunctionFactory.CreateFromMethod(async (
string? orderBy = null,
string? afterIso = null,
string? beforeIso = null
) =>
{
_logger.LogInformation("Begin building request to list post from sphere...");
var request = new ListPostsRequest
{
PageSize = 20,
OrderBy = orderBy,
};
if (!string.IsNullOrEmpty(afterIso))
try
{
request.After = InstantPattern.General.Parse(afterIso).Value.ToTimestamp();
}
catch (Exception)
{
_logger.LogWarning("Invalid afterIso format: {AfterIso}", afterIso);
}
if (!string.IsNullOrEmpty(beforeIso))
try
{
request.Before = InstantPattern.General.Parse(beforeIso).Value.ToTimestamp();
}
catch (Exception)
{
_logger.LogWarning("Invalid beforeIso format: {BeforeIso}", beforeIso);
}
_logger.LogInformation("Request built, {Request}", request);
var response = await _postClient.ListPostsAsync(request);
var data = response.Posts.Select(SnPost.FromProtoValue);
_logger.LogInformation("Sphere service returned posts: {Posts}", data);
return JsonSerializer.Serialize(data, GrpcTypeHelper.SerializerOptions);
}, "list_posts",
"Get posts from the Solar Network.\n" +
"Parameters:\n" +
"orderBy (optional, string: order by published date, accept asc or desc)\n" +
"afterIso (optional, string: ISO date for posts after this date)\n" +
"beforeIso (optional, string: ISO date for posts before this date)"
)
]);
// Add web search plugins if configured
var bingApiKey = _configuration.GetValue<string>("Thinking:BingApiKey");
if (!string.IsNullOrEmpty(bingApiKey))
{
var bingConnector = new BingConnector(bingApiKey);
var bing = new WebSearchEnginePlugin(bingConnector);
Kernel.ImportPluginFromObject(bing, "bing");
}
var googleApiKey = _configuration.GetValue<string>("Thinking:GoogleApiKey");
var googleCx = _configuration.GetValue<string>("Thinking:GoogleCx");
if (!string.IsNullOrEmpty(googleApiKey) && !string.IsNullOrEmpty(googleCx))
{
var googleConnector = new GoogleConnector(
apiKey: googleApiKey,
searchEngineId: googleCx);
var google = new WebSearchEnginePlugin(googleConnector);
Kernel.ImportPluginFromObject(google, "google");
}
}
public PromptExecutionSettings CreatePromptExecutionSettings()
{
switch (ModelProviderType)
{
case "ollama":
return new OllamaPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(
options: new FunctionChoiceBehaviorOptions
{
AllowParallelCalls = true,
AllowConcurrentInvocation = true
})
};
case "deepseek":
return new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(
options: new FunctionChoiceBehaviorOptions
{
AllowParallelCalls = true,
AllowConcurrentInvocation = true
})
};
default:
throw new InvalidOperationException("Unknown provider: " + ModelProviderType);
}
}
}

View File

@@ -0,0 +1,152 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using PaymentService = DysonNetwork.Shared.Proto.PaymentService;
using TransactionType = DysonNetwork.Shared.Proto.TransactionType;
using WalletService = DysonNetwork.Shared.Proto.WalletService;
namespace DysonNetwork.Insight.Thought;
public class ThoughtService(AppDatabase db, ICacheService cache, PaymentService.PaymentServiceClient paymentService, WalletService.WalletServiceClient walletService)
{
public async Task<SnThinkingSequence?> GetOrCreateSequenceAsync(Guid accountId, Guid? sequenceId,
string? topic = null)
{
if (sequenceId.HasValue)
{
var seq = await db.ThinkingSequences.FindAsync(sequenceId.Value);
if (seq == null || seq.AccountId != accountId) return null;
return seq;
}
else
{
var seq = new SnThinkingSequence { AccountId = accountId, Topic = topic };
db.ThinkingSequences.Add(seq);
await db.SaveChangesAsync();
return seq;
}
}
public async Task<SnThinkingThought> SaveThoughtAsync(
SnThinkingSequence sequence,
string content,
ThinkingThoughtRole role,
List<SnThinkingChunk>? chunks = null,
string? model = null
)
{
// Approximate token count (1 token ≈ 4 characters for GPT-like models)
var tokenCount = content?.Length / 4 ?? 0;
var thought = new SnThinkingThought
{
SequenceId = sequence.Id,
Content = content,
Role = role,
TokenCount = tokenCount,
ModelName = model,
Chunks = chunks ?? new List<SnThinkingChunk>()
};
db.ThinkingThoughts.Add(thought);
// Update sequence total tokens only for assistant responses
if (role == ThinkingThoughtRole.Assistant)
sequence.TotalToken += tokenCount;
await db.SaveChangesAsync();
// Invalidate cache for this sequence's thoughts
await cache.RemoveGroupAsync($"sequence:{sequence.Id}");
return thought;
}
public async Task<List<SnThinkingThought>> GetPreviousThoughtsAsync(SnThinkingSequence sequence)
{
var cacheKey = $"thoughts:{sequence.Id}";
var (found, cachedThoughts) = await cache.GetAsyncWithStatus<List<SnThinkingThought>>(cacheKey);
if (found && cachedThoughts != null)
{
return cachedThoughts;
}
var thoughts = await db.ThinkingThoughts
.Where(t => t.SequenceId == sequence.Id)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
// Cache for 10 minutes
await cache.SetWithGroupsAsync(cacheKey, thoughts, [$"sequence:{sequence.Id}"], TimeSpan.FromMinutes(10));
return thoughts;
}
public async Task<(int total, List<SnThinkingSequence> sequences)> ListSequencesAsync(Guid accountId, int offset,
int take)
{
var query = db.ThinkingSequences.Where(s => s.AccountId == accountId);
var totalCount = await query.CountAsync();
var sequences = await query
.OrderByDescending(s => s.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return (totalCount, sequences);
}
public async Task SettleThoughtBills(ILogger logger)
{
var sequences = await db.ThinkingSequences
.Where(s => s.PaidToken < s.TotalToken)
.ToListAsync();
if (sequences.Count == 0)
{
logger.LogInformation("No unpaid sequences found.");
return;
}
// Group by account
var groupedByAccount = sequences.GroupBy(s => s.AccountId);
foreach (var accountGroup in groupedByAccount)
{
var accountId = accountGroup.Key;
var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken);
var cost = (long)Math.Ceiling(totalUnpaidTokens / 1000.0);
if (cost == 0) continue;
try
{
var walletResponse = await walletService.GetWalletAsync(new GetWalletRequest { AccountId = accountId.ToString() });
var walletId = Guid.Parse(walletResponse.Id);
var date = DateTime.Now.ToString("yyyy-MM-dd");
await paymentService.CreateTransactionAsync(new CreateTransactionRequest
{
PayerWalletId = walletId.ToString(),
PayeeWalletId = null,
Currency = WalletCurrency.SourcePoint,
Amount = cost.ToString(),
Remarks = $"Wage for SN-chan on {date}",
Type = TransactionType.System
});
// Mark all sequences for this account as paid
foreach (var sequence in accountGroup)
sequence.PaidToken = sequence.TotalToken;
logger.LogInformation("Billed {cost} points for account {accountId}", cost, accountId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error billing for account {accountId}", accountId);
}
}
await db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,27 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_insight;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"Etcd": {
"Insecure": true
},
"Thinking": {
"Provider": "deepseek",
"Model": "deepseek-chat",
"ApiKey": "sk-bd20f6a2e9fa40b98c46899baa0e9f09"
}
}

View File

@@ -80,6 +80,7 @@ public class AccountCurrentController(
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Shared.Models.UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; }
public List<ProfileLink>? Links { get; set; }
@@ -115,6 +116,7 @@ public class AccountCurrentController(
if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
if (request.Links is not null) profile.Links = request.Links;
if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor;
if (request.PictureId is not null)
{
@@ -931,4 +933,4 @@ public class AccountCurrentController(
.ToListAsync();
return Ok(records);
}
}
}

View File

@@ -271,7 +271,7 @@ public class AccountEventService(
return backdatedCheckInMonths < 4;
}
public const string CheckInLockKey = "checkin:lock:";
private const string CheckInLockKey = "checkin:lock:";
public async Task<SnCheckInResult> CheckInDaily(SnAccount user, Instant? backdated = null)
{
@@ -322,7 +322,11 @@ public class AccountEventService(
}));
// The 5 is specialized, keep it alone.
var checkInLevel = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length - 1);
var sum = 0;
var maxLevel = Enum.GetValues<CheckInResultLevel>().Length - 1;
for (var i = 0; i < 5; i++)
sum += Random.Next(maxLevel);
var checkInLevel = (CheckInResultLevel)(sum / 5);
var accountBirthday = await db.AccountProfiles
.Where(x => x.AccountId == user.Id)

View File

@@ -12,13 +12,11 @@ public class AccountServiceGrpc(
AccountEventService accountEvents,
RelationshipService relationships,
SubscriptionService subscriptions,
IClock clock,
ILogger<AccountServiceGrpc> logger
)
: Shared.Proto.AccountService.AccountServiceBase
{
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
private readonly ILogger<AccountServiceGrpc>
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -160,6 +158,26 @@ public class AccountServiceGrpc(
return response;
}
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
{
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context)
{
@@ -246,7 +264,7 @@ public class AccountServiceGrpc(
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
{
var hasRelationship = false;
bool hasRelationship;
if (!request.HasStatus)
hasRelationship = await relationships.HasExistingRelationship(
Guid.Parse(request.AccountId),

View File

@@ -6,7 +6,7 @@ namespace DysonNetwork.Pass.Account;
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
{
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta)
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{
var log = new SnActionLog
{
@@ -42,4 +42,4 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
fbs.Enqueue(log);
}
}
}

View File

@@ -32,8 +32,8 @@ public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServi
try
{
var meta = request.Meta
?.Select(x => new KeyValuePair<string, object?>(x.Key, GrpcTypeHelper.ConvertValueToObject(x.Value)))
.ToDictionary() ?? new Dictionary<string, object?>();
?.Select(x => new KeyValuePair<string, object>(x.Key, GrpcTypeHelper.ConvertValueToObject(x.Value)))
.ToDictionary() ?? new Dictionary<string, object>();
_actionLogService.CreateActionLog(
accountId,
@@ -41,6 +41,7 @@ public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServi
meta
);
await Task.CompletedTask;
return new CreateActionLogResponse();
}
catch (Exception ex)
@@ -111,4 +112,4 @@ public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServi
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs"));
}
}
}
}

View File

@@ -38,11 +38,16 @@ public class AppDatabase(
public DbSet<SnAuthChallenge> AuthChallenges { get; set; } = null!;
public DbSet<SnAuthClient> AuthClients { get; set; } = null!;
public DbSet<SnApiKey> ApiKeys { get; set; } = null!;
public DbSet<SnRealm> Realms { get; set; } = null!;
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
public DbSet<SnWallet> Wallets { get; set; } = null!;
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
@@ -52,6 +57,9 @@ public class AppDatabase(
public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!;
public DbSet<SnLottery> Lotteries { get; set; } = null!;
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(
@@ -125,6 +133,14 @@ public class AppDatabase(
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<SnRealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<SnRealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())

View File

@@ -343,15 +343,15 @@ public class OidcProviderController(
{
issuer,
authorization_endpoint = $"{siteUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/id/auth/open/token",
userinfo_endpoint = $"{baseUrl}/id/auth/open/userinfo",
token_endpoint = $"{baseUrl}/pass/auth/open/token",
userinfo_endpoint = $"{baseUrl}/pass/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[]
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" },
id_token_signing_alg_values_supported = new[] { "HS256", "RS256" },
subject_types_supported = new[] { "public" },
claims_supported = new[] { "sub", "name", "email", "email_verified" },
code_challenge_methods_supported = new[] { "S256" },

View File

@@ -200,11 +200,13 @@ public class OidcProviderService(
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
}
claims.Add(new Claim(JwtRegisteredClaimNames.Azp, client.Slug));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString(),
Audience = client.Slug.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
@@ -314,6 +316,7 @@ public class OidcProviderService(
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Azp, client.Slug),
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
@@ -516,4 +519,4 @@ public class OidcProviderService(
return false;
}
}
}

View File

@@ -16,7 +16,8 @@ public class ConnectionController(
IEnumerable<OidcService> oidcServices,
AccountService accounts,
AuthService auth,
ICacheService cache
ICacheService cache,
IConfiguration configuration
) : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
@@ -128,7 +129,7 @@ public class ConnectionController(
}
[AllowAnonymous]
[Route("/auth/callback/{provider}")]
[Route("/api/auth/callback/{provider}")]
[HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{
@@ -142,10 +143,10 @@ public class ConnectionController(
// Get the state from the cache
var stateKey = $"{StateCachePrefix}{callbackData.State}";
// Try to get the state as OidcState first (new format)
var oidcState = await cache.GetAsync<OidcState>(stateKey);
// If not found, try to get as string (legacy format)
if (oidcState == null)
{
@@ -153,7 +154,7 @@ public class ConnectionController(
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
return BadRequest("Invalid or expired state parameter");
}
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
@@ -277,7 +278,9 @@ public class ConnectionController(
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
var siteUrl = configuration["SiteUrl"];
return Redirect(string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
@@ -309,14 +312,14 @@ public class ConnectionController(
if (connection != null)
{
// Login existing user
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync(
userInfo,
connection.Account,
HttpContext,
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty);
return Redirect($"/auth/callback?challenge={challenge.Id}");
}
@@ -341,7 +344,10 @@ public class ConnectionController(
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/callback?token={loginToken}");
var siteUrl = configuration["SiteUrl"];
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
@@ -355,18 +361,18 @@ public class ConnectionController(
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
break;
case "POST" when request.HasFormContentType:
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
break;
}
break;
}
}
return data;
}
}
}

View File

@@ -57,7 +57,7 @@ public abstract class OidcService(
{
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
RedirectUri = Configuration["SiteUrl"] + "/auth/callback/" + ProviderName.ToLower()
};
}
@@ -292,4 +292,4 @@ public class OidcCallbackData
public string IdToken { get; set; } = "";
public string? State { get; set; }
public string? RawData { get; set; }
}
}

View File

@@ -7,50 +7,44 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Nager.Holiday" Version="1.0.1" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<PackageReference Include="Nerdbank.GitVersioning" Version="3.8.118">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
<PackageReference Include="Otp.NET" Version="1.4.0"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1"/>
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1"/>
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5"/>
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0"/>
<PackageReference Include="Quartz" Version="3.14.0"/>
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="Quartz" Version="3.15.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Leveling;
public class ExperienceService(AppDatabase db, SubscriptionService subscriptions, ICacheService cache)
public class ExperienceService(AppDatabase db, SubscriptionService subscriptions)
{
public async Task<SnExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId)
{
@@ -40,4 +40,4 @@ public class ExperienceService(AppDatabase db, SubscriptionService subscriptions
return record;
}
}
}

View File

@@ -0,0 +1,117 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Lotteries;
[ApiController]
[Route("/api/lotteries")]
public class LotteryController(AppDatabase db, LotteryService lotteryService) : ControllerBase
{
public class CreateLotteryRequest
{
[Required]
public List<int> RegionOneNumbers { get; set; } = null!;
[Required]
[Range(0, 99)]
public int RegionTwoNumber { get; set; }
[Range(1, int.MaxValue)]
public int Multiplier { get; set; } = 1;
}
[HttpPost]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateLottery([FromBody] CreateLotteryRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await lotteryService.CreateLotteryOrderAsync(
accountId: currentUser.Id,
region1: request.RegionOneNumbers,
region2: request.RegionTwoNumber,
multiplier: request.Multiplier);
return Ok(order);
}
catch (ArgumentException err)
{
return BadRequest(err.Message);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<SnLottery>>> GetLotteries(
[FromQuery] int offset = 0,
[FromQuery] int limit = 20)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var lotteries = await lotteryService.GetUserTicketsAsync(currentUser.Id, offset, limit);
var total = await lotteryService.GetUserTicketCountAsync(currentUser.Id);
Response.Headers["X-Total"] = total.ToString();
return Ok(lotteries);
}
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<SnLottery>> GetLottery(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var lottery = await lotteryService.GetTicketAsync(id);
if (lottery == null || lottery.AccountId != currentUser.Id)
return NotFound();
return Ok(lottery);
}
[HttpPost("draw")]
[Authorize]
[RequiredPermission("maintenance", "lotteries.draw.perform")]
public async Task<IActionResult> PerformLotteryDraw()
{
await lotteryService.DrawLotteries();
return Ok();
}
[HttpGet("records")]
public async Task<ActionResult<List<SnLotteryRecord>>> GetLotteryRecords(
[FromQuery] Instant? startDate = null,
[FromQuery] Instant? endDate = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 20)
{
var query = db.LotteryRecords
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
if (startDate.HasValue)
query = query.Where(r => r.DrawDate >= startDate.Value);
if (endDate.HasValue)
query = query.Where(r => r.DrawDate <= endDate.Value);
var total = await query.CountAsync();
Response.Headers["X-Total"] = total.ToString();
var records = await query
.Skip(offset)
.Take(limit)
.ToListAsync();
return Ok(records);
}
}

View File

@@ -0,0 +1,21 @@
using Quartz;
namespace DysonNetwork.Pass.Lotteries;
public class LotteryDrawJob(LotteryService lotteryService, ILogger<LotteryDrawJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting daily lottery draw...");
try
{
await lotteryService.DrawLotteries();
logger.LogInformation("Daily lottery draw completed successfully.");
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred during daily lottery draw.");
}
}
}

View File

@@ -0,0 +1,274 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NodaTime;
using System.Text.Json;
namespace DysonNetwork.Pass.Lotteries;
public class LotteryOrderMetaData
{
public Guid AccountId { get; set; }
public List<int> RegionOneNumbers { get; set; } = new();
public int RegionTwoNumber { get; set; }
public int Multiplier { get; set; } = 1;
}
public class LotteryService(
AppDatabase db,
PaymentService paymentService,
WalletService walletService,
ILogger<LotteryService> logger)
{
private static bool ValidateNumbers(List<int> region1, int region2)
{
if (region1.Count != 5 || region1.Distinct().Count() != 5)
return false;
if (region1.Any(n => n < 0 || n > 99))
return false;
if (region2 < 0 || region2 > 99)
return false;
return true;
}
public async Task<SnLottery> CreateTicketAsync(Guid accountId, List<int> region1, int region2, int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var lottery = new SnLottery
{
AccountId = accountId,
RegionOneNumbers = region1,
RegionTwoNumber = region2,
Multiplier = multiplier
};
db.Lotteries.Add(lottery);
await db.SaveChangesAsync();
return lottery;
}
public async Task<List<SnLottery>> GetUserTicketsAsync(Guid accountId, int offset = 0, int limit = 20)
{
return await db.Lotteries
.Where(l => l.AccountId == accountId)
.OrderByDescending(l => l.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync();
}
public async Task<SnLottery?> GetTicketAsync(Guid id)
{
return await db.Lotteries.FirstOrDefaultAsync(l => l.Id == id);
}
public async Task<int> GetUserTicketCountAsync(Guid accountId)
{
return await db.Lotteries.CountAsync(l => l.AccountId == accountId);
}
private static decimal CalculateLotteryPrice(int multiplier)
{
return 10 + (multiplier - 1) * 10;
}
public async Task<SnWalletOrder> CreateLotteryOrderAsync(Guid accountId, List<int> region1, int region2,
int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var now = SystemClock.Instance.GetCurrentInstant();
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc()
.ToInstant();
var hasPurchasedToday = await db.Lotteries.AnyAsync(l =>
l.AccountId == accountId &&
l.CreatedAt >= todayStart &&
l.DrawStatus == LotteryDrawStatus.Pending
);
if (hasPurchasedToday)
throw new InvalidOperationException("You can only purchase one lottery per day.");
var price = CalculateLotteryPrice(multiplier);
var lotteryData = new LotteryOrderMetaData
{
AccountId = accountId,
RegionOneNumbers = region1,
RegionTwoNumber = region2,
Multiplier = multiplier
};
return await paymentService.CreateOrderAsync(
null,
WalletCurrency.SourcePoint,
price,
appIdentifier: "lottery",
productIdentifier: "lottery",
meta: new Dictionary<string, object>
{
["data"] = JsonSerializer.Serialize(lotteryData)
});
}
public async Task HandleLotteryOrder(SnWalletOrder order)
{
if (order.Status == OrderStatus.Finished)
return; // Already processed
if (order.Status != OrderStatus.Paid ||
!order.Meta.TryGetValue("data", out var dataValue) ||
dataValue is null ||
dataValue is not JsonElement { ValueKind: JsonValueKind.String } jsonElem)
throw new InvalidOperationException("Invalid order.");
var jsonString = jsonElem.GetString();
if (jsonString is null)
throw new InvalidOperationException("Invalid order.");
var data = JsonSerializer.Deserialize<LotteryOrderMetaData>(jsonString);
if (data is null)
throw new InvalidOperationException("Invalid order data.");
await CreateTicketAsync(data.AccountId, data.RegionOneNumbers, data.RegionTwoNumber, data.Multiplier);
order.Status = OrderStatus.Finished;
await db.SaveChangesAsync();
}
private static int CalculateReward(int region1Matches, bool region2Match)
{
var reward = region1Matches switch
{
0 => 0,
1 => 10,
2 => 100,
3 => 500,
4 => 1000,
5 => 10000,
_ => 0
};
if (region2Match) reward *= 10;
return reward;
}
private static List<int> GenerateUniqueRandomNumbers(int count, int min, int max)
{
var numbers = new List<int>();
var random = new Random();
while (numbers.Count < count)
{
var num = random.Next(min, max + 1);
if (!numbers.Contains(num)) numbers.Add(num);
}
return numbers.OrderBy(n => n).ToList();
}
private int CountMatches(List<int> playerNumbers, List<int> winningNumbers)
{
return playerNumbers.Intersect(winningNumbers).Count();
}
public async Task DrawLotteries()
{
try
{
logger.LogInformation("Starting drawing lotteries...");
var now = SystemClock.Instance.GetCurrentInstant();
// All pending lottery tickets
var tickets = await db.Lotteries
.Where(l => l.DrawStatus == LotteryDrawStatus.Pending)
.ToListAsync();
if (tickets.Count == 0)
{
logger.LogInformation("No pending lottery tickets");
return;
}
logger.LogInformation("Found {Count} pending lottery tickets for draw", tickets.Count);
// Generate winning numbers
var winningRegion1 = GenerateUniqueRandomNumbers(5, 0, 99);
var winningRegion2 = GenerateUniqueRandomNumbers(1, 0, 99)[0];
logger.LogInformation("Winning numbers generated: Region1 [{Region1}], Region2 [{Region2}]",
string.Join(",", winningRegion1), winningRegion2);
var drawDate = Instant.FromDateTimeUtc(new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month,
DateTime.UtcNow.Day, 0, 0, 0, DateTimeKind.Utc).AddDays(-1)); // Yesterday's date
var totalPrizesAwarded = 0;
long totalPrizeAmount = 0;
// Process each ticket
foreach (var ticket in tickets)
{
var region1Matches = CountMatches(ticket.RegionOneNumbers, winningRegion1);
var region2Match = ticket.RegionTwoNumber == winningRegion2;
var reward = CalculateReward(region1Matches, region2Match);
// Record match results
ticket.MatchedRegionOneNumbers = ticket.RegionOneNumbers.Intersect(winningRegion1).ToList();
ticket.MatchedRegionTwoNumber = region2Match ? (int?)winningRegion2 : null;
if (reward > 0)
{
var wallet = await walletService.GetWalletAsync(ticket.AccountId);
if (wallet != null)
{
await paymentService.CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: wallet.Id,
currency: WalletCurrency.SourcePoint,
amount: reward,
remarks: $"Lottery prize: {region1Matches} matches{(region2Match ? " + special" : "")}"
);
logger.LogInformation(
"Awarded {Amount} to account {AccountId} for {Matches} matches{(Special ? \" + special\" : \"\")}",
reward, ticket.AccountId, region1Matches, region2Match ? " + special" : "");
totalPrizesAwarded++;
totalPrizeAmount += reward;
}
else
{
logger.LogWarning("Wallet not found for account {AccountId}, skipping prize award",
ticket.AccountId);
}
}
ticket.DrawStatus = LotteryDrawStatus.Drawn;
ticket.DrawDate = drawDate;
}
// Save the draw record
var lotteryRecord = new SnLotteryRecord
{
DrawDate = drawDate,
WinningRegionOneNumbers = winningRegion1,
WinningRegionTwoNumber = winningRegion2,
TotalTickets = tickets.Count,
TotalPrizesAwarded = totalPrizesAwarded,
TotalPrizeAmount = totalPrizeAmount
};
db.LotteryRecords.Add(lotteryRecord);
await db.SaveChangesAsync();
logger.LogInformation("Daily lottery draw completed: {Prizes} prizes awarded, total amount {Amount}",
totalPrizesAwarded, totalPrizeAmount);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during the daily lottery draw");
throw;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RefactorSubscriptionRelation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropColumn(
name: "gift_id",
table: "wallet_subscriptions");
migrationBuilder.AddColumn<Guid>(
name: "subscription_id",
table: "wallet_gifts",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_subscription_id",
table: "wallet_gifts",
column: "subscription_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
table: "wallet_gifts",
column: "subscription_id",
principalTable: "wallet_subscriptions",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
table: "wallet_gifts");
migrationBuilder.DropIndex(
name: "ix_wallet_gifts_subscription_id",
table: "wallet_gifts");
migrationBuilder.DropColumn(
name: "subscription_id",
table: "wallet_gifts");
migrationBuilder.AddColumn<Guid>(
name: "gift_id",
table: "wallet_subscriptions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
principalTable: "wallet_gifts",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddWalletFund : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "wallet_funds",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
total_amount = table.Column<decimal>(type: "numeric", nullable: false),
split_type = table.Column<int>(type: "integer", nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
message = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
creator_account_id = table.Column<Guid>(type: "uuid", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_funds", x => x.id);
table.ForeignKey(
name: "fk_wallet_funds_accounts_creator_account_id",
column: x => x.creator_account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "wallet_fund_recipients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
fund_id = table.Column<Guid>(type: "uuid", nullable: false),
recipient_account_id = table.Column<Guid>(type: "uuid", nullable: false),
amount = table.Column<decimal>(type: "numeric", nullable: false),
is_received = table.Column<bool>(type: "boolean", nullable: false),
received_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_fund_recipients", x => x.id);
table.ForeignKey(
name: "fk_wallet_fund_recipients_accounts_recipient_account_id",
column: x => x.recipient_account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_wallet_fund_recipients_wallet_funds_fund_id",
column: x => x.fund_id,
principalTable: "wallet_funds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_wallet_fund_recipients_fund_id",
table: "wallet_fund_recipients",
column: "fund_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_fund_recipients_recipient_account_id",
table: "wallet_fund_recipients",
column: "recipient_account_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_funds_creator_account_id",
table: "wallet_funds",
column: "creator_account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "wallet_fund_recipients");
migrationBuilder.DropTable(
name: "wallet_funds");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddUsernameColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<UsernameColor>(
name: "username_color",
table: "account_profiles",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "username_color",
table: "account_profiles");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddRealmFromSphere : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "realms",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realms", x => x.id);
});
migrationBuilder.CreateTable(
name: "realm_members",
columns: table => new
{
realm_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realm_members", x => new { x.realm_id, x.account_id });
table.ForeignKey(
name: "fk_realm_members_realms_realm_id",
column: x => x.realm_id,
principalTable: "realms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sn_chat_room",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_room", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_room_realms_sn_realm_id",
column: x => x.sn_realm_id,
principalTable: "realms",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "sn_chat_member",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
role = table.Column<int>(type: "integer", nullable: false),
notify = table.Column<int>(type: "integer", nullable: false),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_bot = table.Column<bool>(type: "boolean", nullable: false),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_member", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
column: x => x.chat_room_id,
principalTable: "sn_chat_room",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_realms_slug",
table: "realms",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_sn_chat_member_chat_room_id",
table: "sn_chat_member",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_sn_chat_room_sn_realm_id",
table: "sn_chat_room",
column: "sn_realm_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "realm_members");
migrationBuilder.DropTable(
name: "sn_chat_member");
migrationBuilder.DropTable(
name: "sn_chat_room");
migrationBuilder.DropTable(
name: "realms");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RemoveChatRoom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sn_chat_member");
migrationBuilder.DropTable(
name: "sn_chat_room");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sn_chat_room",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_room", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_room_realms_sn_realm_id",
column: x => x.sn_realm_id,
principalTable: "realms",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "sn_chat_member",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_bot = table.Column<bool>(type: "boolean", nullable: false),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
notify = table.Column<int>(type: "integer", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_member", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
column: x => x.chat_room_id,
principalTable: "sn_chat_room",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_sn_chat_member_chat_room_id",
table: "sn_chat_member",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_sn_chat_room_sn_realm_id",
table: "sn_chat_room",
column: "sn_realm_id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddLotteries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lotteries",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
region_two_number = table.Column<int>(type: "integer", nullable: false),
multiplier = table.Column<int>(type: "integer", nullable: false),
draw_status = table.Column<int>(type: "integer", nullable: false),
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_lotteries", x => x.id);
table.ForeignKey(
name: "fk_lotteries_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "lottery_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
winning_region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
winning_region_two_number = table.Column<int>(type: "integer", nullable: false),
total_tickets = table.Column<int>(type: "integer", nullable: false),
total_prizes_awarded = table.Column<int>(type: "integer", nullable: false),
total_prize_amount = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_lottery_records", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_lotteries_account_id",
table: "lotteries",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "lotteries");
migrationBuilder.DropTable(
name: "lottery_records");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddDetailLotteriesStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "matched_region_one_numbers",
table: "lotteries",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "matched_region_two_number",
table: "lotteries",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "matched_region_one_numbers",
table: "lotteries");
migrationBuilder.DropColumn(
name: "matched_region_two_number",
table: "lotteries");
}
}
}

View File

@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -478,6 +478,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<UsernameColor>("UsernameColor")
.HasColumnType("jsonb")
.HasColumnName("username_color");
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");
@@ -1055,6 +1059,117 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("experience_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<int>("DrawStatus")
.HasColumnType("integer")
.HasColumnName("draw_status");
b.Property<List<int>>("MatchedRegionOneNumbers")
.HasColumnType("jsonb")
.HasColumnName("matched_region_one_numbers");
b.Property<int?>("MatchedRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("matched_region_two_number");
b.Property<int>("Multiplier")
.HasColumnType("integer")
.HasColumnName("multiplier");
b.Property<List<int>>("RegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("region_one_numbers");
b.Property<int>("RegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("region_two_number");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_lotteries");
b.HasIndex("AccountId")
.HasDatabaseName("ix_lotteries_account_id");
b.ToTable("lotteries", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLotteryRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<long>("TotalPrizeAmount")
.HasColumnType("bigint")
.HasColumnName("total_prize_amount");
b.Property<int>("TotalPrizesAwarded")
.HasColumnType("integer")
.HasColumnName("total_prizes_awarded");
b.Property<int>("TotalTickets")
.HasColumnType("integer")
.HasColumnName("total_tickets");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<List<int>>("WinningRegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("winning_region_one_numbers");
b.Property<int>("WinningRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("winning_region_two_number");
b.HasKey("Id")
.HasName("pk_lottery_records");
b.ToTable("lottery_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
{
b.Property<Guid>("Id")
@@ -1248,6 +1363,127 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("permission_nodes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<string>("BackgroundId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("background_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<bool>("IsCommunity")
.HasColumnType("boolean")
.HasColumnName("is_community");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<string>("PictureId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("picture_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");
b.HasKey("Id")
.HasName("pk_realms");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_realms_slug");
b.ToTable("realms", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.Property<Guid>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<Instant?>("LeaveAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("leave_at");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("RealmId", "AccountId")
.HasName("pk_realm_members");
b.ToTable("realm_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
{
b.Property<Guid>("Id")
@@ -1387,6 +1623,116 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("wallet_coupons", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CreatorAccountId")
.HasColumnType("uuid")
.HasColumnName("creator_account_id");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Message")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("message");
b.Property<int>("SplitType")
.HasColumnType("integer")
.HasColumnName("split_type");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<decimal>("TotalAmount")
.HasColumnType("numeric")
.HasColumnName("total_amount");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_funds");
b.HasIndex("CreatorAccountId")
.HasDatabaseName("ix_wallet_funds_creator_account_id");
b.ToTable("wallet_funds", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("FundId")
.HasColumnType("uuid")
.HasColumnName("fund_id");
b.Property<bool>("IsReceived")
.HasColumnType("boolean")
.HasColumnName("is_received");
b.Property<Instant?>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<Guid>("RecipientAccountId")
.HasColumnType("uuid")
.HasColumnName("recipient_account_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_fund_recipients");
b.HasIndex("FundId")
.HasDatabaseName("ix_wallet_fund_recipients_fund_id");
b.HasIndex("RecipientAccountId")
.HasDatabaseName("ix_wallet_fund_recipients_recipient_account_id");
b.ToTable("wallet_fund_recipients", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
{
b.Property<Guid>("Id")
@@ -1464,6 +1810,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid")
.HasColumnName("subscription_id");
b.Property<string>("SubscriptionIdentifier")
.IsRequired()
.HasMaxLength(4096)
@@ -1492,6 +1842,10 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("RedeemerId")
.HasDatabaseName("ix_wallet_gifts_redeemer_id");
b.HasIndex("SubscriptionId")
.IsUnique()
.HasDatabaseName("ix_wallet_gifts_subscription_id");
b.ToTable("wallet_gifts", (string)null);
});
@@ -1648,10 +2002,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<Guid?>("GiftId")
.HasColumnType("uuid")
.HasColumnName("gift_id");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(4096)
@@ -1698,10 +2048,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("CouponId")
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
b.HasIndex("GiftId")
.IsUnique()
.HasDatabaseName("ix_wallet_subscriptions_gift_id");
b.HasIndex("Identifier")
.HasDatabaseName("ix_wallet_subscriptions_identifier");
@@ -1999,6 +2345,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_lotteries_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2031,6 +2389,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnRealm", "Realm")
.WithMany("Members")
.HasForeignKey("RealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_realm_members_realms_realm_id");
b.Navigation("Realm");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2055,6 +2425,39 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "CreatorAccount")
.WithMany()
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_funds_accounts_creator_account_id");
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWalletFund", "Fund")
.WithMany("Recipients")
.HasForeignKey("FundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_fund_recipients_wallet_funds_fund_id");
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "RecipientAccount")
.WithMany()
.HasForeignKey("RecipientAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_fund_recipients_accounts_recipient_account_id");
b.Navigation("Fund");
b.Navigation("RecipientAccount");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
@@ -2079,6 +2482,11 @@ namespace DysonNetwork.Pass.Migrations
.HasForeignKey("RedeemerId")
.HasConstraintName("fk_wallet_gifts_accounts_redeemer_id");
b.HasOne("DysonNetwork.Shared.Models.SnWalletSubscription", "Subscription")
.WithOne("Gift")
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletGift", "SubscriptionId")
.HasConstraintName("fk_wallet_gifts_wallet_subscriptions_subscription_id");
b.Navigation("Coupon");
b.Navigation("Gifter");
@@ -2086,6 +2494,8 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Recipient");
b.Navigation("Redeemer");
b.Navigation("Subscription");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
@@ -2131,16 +2541,9 @@ namespace DysonNetwork.Pass.Migrations
.HasForeignKey("CouponId")
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
b.HasOne("DysonNetwork.Shared.Models.SnWalletGift", "Gift")
.WithOne("Subscription")
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletSubscription", "GiftId")
.HasConstraintName("fk_wallet_subscriptions_wallet_gifts_gift_id");
b.Navigation("Account");
b.Navigation("Coupon");
b.Navigation("Gift");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
@@ -2189,14 +2592,24 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Nodes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
{
b.Navigation("Pockets");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.Navigation("Subscription");
b.Navigation("Recipients");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
{
b.Navigation("Gift");
});
#pragma warning restore 612, 618
}

View File

@@ -13,7 +13,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddRingService();
builder.Services.AddDriveService();
@@ -36,7 +35,14 @@ app.MapDefaultEndpoints();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.Database.MigrateAsync();
try
{
await db.Database.MigrateAsync();
}
catch (Exception err)
{
Console.WriteLine(err);
}
}
// Configure application middleware pipeline
@@ -45,6 +51,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
// Configure gRPC
app.ConfigureGrpcServices();
app.UseSwaggerManifest();
app.UseSwaggerManifest("DysonNetwork.Pass");
app.Run();
app.Run();

View File

@@ -1,14 +1,17 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Google.Protobuf.WellKnownTypes;
using DysonNetwork.Shared.Models;
using AccountService = DysonNetwork.Pass.Account.AccountService;
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
namespace DysonNetwork.Sphere.Realm;
namespace DysonNetwork.Pass.Realm;
[ApiController]
[Route("/api/realms")]
@@ -17,9 +20,9 @@ public class RealmController(
RealmService rs,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als,
AccountService.AccountServiceClient accounts,
AccountClientHelper accountsHelper
ActionLogService als,
RelationshipService rels,
AccountEventService accountEvents
) : Controller
{
[HttpGet("{slug}")]
@@ -37,13 +40,12 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var members = await db.RealmMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Include(e => e.Realm)
.Select(m => m.Realm)
.ToListAsync();
@@ -55,8 +57,8 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<List<SnRealmMember>>> ListInvites()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var members = await db.RealmMembers
.Where(m => m.AccountId == accountId)
@@ -78,20 +80,18 @@ public class RealmController(
public async Task<ActionResult<SnRealmMember>> InviteMember(string slug,
[FromBody] RealmMemberRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var relatedUser =
await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() });
var relatedUser = await db.Accounts.Where(a => a.Id == request.RelatedUserId).FirstOrDefaultAsync();
if (relatedUser == null) return BadRequest("Related user was not found");
var hasBlocked = await accounts.HasRelationshipAsync(new GetRelationshipRequest()
{
AccountId = currentUser.Id,
RelatedId = request.RelatedUserId.ToString(),
Status = -100
});
if (hasBlocked?.Value ?? false)
var hasBlocked = await rels.HasRelationshipWithStatus(
currentUser.Id,
request.RelatedUserId,
RelationshipStatus.Blocked
);
if (hasBlocked)
return StatusCode(403, "You cannot invite a user that blocked you.");
var realm = await db.Realms
@@ -102,39 +102,57 @@ public class RealmController(
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
return StatusCode(403, "You cannot invite member has higher permission than yours.");
var hasExistingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id))
var existingMember = await db.RealmMembers
.Where(m => m.AccountId == relatedUser.Id)
.Where(m => m.RealmId == realm.Id)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.AnyAsync();
if (hasExistingMember)
return BadRequest("This user already in the realm cannot be invited again.");
.FirstOrDefaultAsync();
if (existingMember != null)
{
if (existingMember.LeaveAt == null)
return BadRequest("This user already in the realm cannot be invited again.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = null;
db.RealmMembers.Update(existingMember);
await db.SaveChangesAsync();
await rs.SendInviteNotify(existingMember);
als.CreateActionLogFromRequest(
"realms.members.invite",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(existingMember.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) }
},
Request
);
return Ok(existingMember);
}
var member = new SnRealmMember
{
AccountId = Guid.Parse(relatedUser.Id),
AccountId = relatedUser.Id,
RealmId = realm.Id,
Role = request.Role,
};
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.invite",
Meta =
als.CreateActionLogFromRequest(
"realms.members.invite",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
member.AccountId = Guid.Parse(relatedUser.Id);
member.AccountId = relatedUser.Id;
member.Realm = realm;
await rs.SendInviteNotify(member);
@@ -145,8 +163,8 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
@@ -159,18 +177,15 @@ public class RealmController(
db.Update(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }
{ "realm_id", member.RealmId.ToString() },
{ "account_id", member.AccountId.ToString() }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return Ok(member);
}
@@ -179,8 +194,8 @@ public class RealmController(
[Authorize]
public async Task<ActionResult> DeclineMemberInvite(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
@@ -192,19 +207,16 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.decline_invite",
Meta =
als.CreateActionLogFromRequest(
"realms.members.decline_invite",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "decliner_id", Value.ForString(currentUser.Id) }
{ "decliner_id", Value.ForString(currentUser.Id.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return NoContent();
}
@@ -225,8 +237,8 @@ public class RealmController(
if (!realm.IsPublic)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Normal))
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
return StatusCode(403, "You must be a member to view this realm's members.");
}
@@ -240,7 +252,7 @@ public class RealmController(
.OrderBy(m => m.JoinedAt)
.ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch(
var memberStatuses = await accountEvents.GetStatuses(
members.Select(m => m.AccountId).ToList()
);
@@ -283,8 +295,8 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
@@ -300,8 +312,8 @@ public class RealmController(
[Authorize]
public async Task<ActionResult> LeaveRealm(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = currentUser.Id;
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId)
@@ -316,19 +328,16 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.leave",
Meta =
als.CreateActionLogFromRequest(
"realms.members.leave",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "leaver_id", Value.ForString(currentUser.Id) }
{ "realm_id", member.RealmId.ToString() },
{ "account_id", member.AccountId.ToString() },
{ "leaver_id", currentUser.Id }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return NoContent();
}
@@ -348,7 +357,7 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
@@ -360,7 +369,7 @@ public class RealmController(
Name = request.Name!,
Slug = request.Slug!,
Description = request.Description!,
AccountId = Guid.Parse(currentUser.Id),
AccountId = currentUser.Id,
IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false,
Members = new List<SnRealmMember>
@@ -368,7 +377,7 @@ public class RealmController(
new()
{
Role = RealmMemberRole.Owner,
AccountId = Guid.Parse(currentUser.Id),
AccountId = currentUser.Id,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
}
}
@@ -391,21 +400,18 @@ public class RealmController(
db.Realms.Add(realm);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.create",
Meta =
als.CreateActionLogFromRequest(
"realms.create",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "name", Value.ForString(realm.Name) },
{ "slug", Value.ForString(realm.Slug) },
{ "is_community", Value.ForBool(realm.IsCommunity) },
{ "is_public", Value.ForBool(realm.IsPublic) }
{ "realm_id", realm.Id.ToString() },
{ "name", realm.Name },
{ "slug", realm.Slug },
{ "is_community", realm.IsCommunity },
{ "is_public", realm.IsPublic }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
var realmResourceId = $"realm:{realm.Id}";
@@ -436,14 +442,14 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var accountId = currentUser.Id;
var member = await db.RealmMembers
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
@@ -519,24 +525,21 @@ public class RealmController(
db.Realms.Update(realm);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.update",
Meta =
als.CreateActionLogFromRequest(
"realms.update",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "name_updated", Value.ForBool(request.Name != null) },
{ "slug_updated", Value.ForBool(request.Slug != null) },
{ "description_updated", Value.ForBool(request.Description != null) },
{ "picture_updated", Value.ForBool(request.PictureId != null) },
{ "background_updated", Value.ForBool(request.BackgroundId != null) },
{ "is_community_updated", Value.ForBool(request.IsCommunity != null) },
{ "is_public_updated", Value.ForBool(request.IsPublic != null) }
{ "realm_id", realm.Id.ToString() },
{ "name_updated", request.Name != null },
{ "slug_updated", request.Slug != null },
{ "description_updated", request.Description != null },
{ "picture_updated", request.PictureId != null },
{ "background_updated", request.BackgroundId != null },
{ "is_community_updated", request.IsCommunity != null },
{ "is_public_updated", request.IsPublic != null }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return Ok(realm);
}
@@ -545,7 +548,7 @@ public class RealmController(
[Authorize]
public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
@@ -556,14 +559,36 @@ public class RealmController(
return StatusCode(403, "Only community realms can be joined without invitation.");
var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id)
.FirstOrDefaultAsync();
if (existingMember is not null)
return BadRequest("You are already a member of this realm.");
{
if (existingMember.LeaveAt == null)
return BadRequest("You are already a member of this realm.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(existingMember);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{
{ "realm_id", existingMember.RealmId.ToString() },
{ "account_id", currentUser.Id },
{ "is_community", realm.IsCommunity }
},
Request
);
return Ok(existingMember);
}
var member = new SnRealmMember
{
AccountId = Guid.Parse(currentUser.Id),
AccountId = currentUser.Id,
RealmId = realm.Id,
Role = RealmMemberRole.Normal,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
@@ -572,19 +597,16 @@ public class RealmController(
db.RealmMembers.Add(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.join",
Meta =
als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
{ "realm_id", realm.Id.ToString() },
{ "account_id", currentUser.Id },
{ "is_community", realm.IsCommunity }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return Ok(member);
}
@@ -593,7 +615,7 @@ public class RealmController(
[Authorize]
public async Task<ActionResult> RemoveMember(string slug, Guid memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
@@ -605,25 +627,22 @@ public class RealmController(
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role))
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role))
return StatusCode(403, "You do not have permission to remove members from this realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.kick",
Meta =
als.CreateActionLogFromRequest(
"realms.members.kick",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "kicker_id", Value.ForString(currentUser.Id) }
{ "realm_id", realm.Id.ToString() },
{ "account_id", memberId.ToString() },
{ "kicker_id", currentUser.Id }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return NoContent();
}
@@ -633,7 +652,7 @@ public class RealmController(
public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole)
{
if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms
.Where(r => r.Slug == slug)
@@ -645,7 +664,7 @@ public class RealmController(
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role,
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role,
newRole))
return StatusCode(403, "You do not have permission to update member roles in this realm.");
@@ -653,20 +672,17 @@ public class RealmController(
db.RealmMembers.Update(member);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.members.role_update",
Meta =
als.CreateActionLogFromRequest(
"realms.members.role_update",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "new_role", Value.ForNumber(newRole) },
{ "updater_id", Value.ForString(currentUser.Id) }
{ "realm_id", realm.Id.ToString() },
{ "account_id", memberId.ToString() },
{ "new_role", newRole },
{ "updater_id", currentUser.Id }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
return Ok(member);
}
@@ -675,7 +691,7 @@ public class RealmController(
[Authorize]
public async Task<ActionResult> Delete(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var transaction = await db.Database.BeginTransactionAsync();
@@ -684,16 +700,11 @@ public class RealmController(
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Owner))
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Owner))
return StatusCode(403, "Only the owner can delete this realm.");
try
{
var chats = await db.ChatRooms
.Where(c => c.RealmId == realm.Id)
.Select(c => c.Id)
.ToListAsync();
db.Realms.Remove(realm);
await db.SaveChangesAsync();
@@ -701,15 +712,6 @@ public class RealmController(
await db.RealmMembers
.Where(m => m.RealmId == realm.Id)
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.ChatRooms
.Where(c => c.RealmId == realm.Id)
.ExecuteUpdateAsync(c => c.SetProperty(c => c.DeletedAt, now));
await db.ChatMessages
.Where(m => chats.Contains(m.ChatRoomId))
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.ChatMembers
.Where(m => chats.Contains(m.ChatRoomId))
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
@@ -719,19 +721,16 @@ public class RealmController(
throw;
}
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "realms.delete",
Meta =
als.CreateActionLogFromRequest(
"realms.delete",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "realm_name", Value.ForString(realm.Name) },
{ "realm_slug", Value.ForString(realm.Slug) }
{ "realm_id", realm.Id.ToString() },
{ "realm_name", realm.Name },
{ "realm_slug", realm.Slug }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent.ToString(),
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
Request
);
// Delete all file references for this realm
var realmResourceId = $"realm:{realm.Id}";

View File

@@ -1,20 +1,18 @@
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Realm;
namespace DysonNetwork.Pass.Realm;
public class RealmService(
AppDatabase db,
RingService.RingServiceClient pusher,
AccountService.AccountServiceClient accounts,
IStringLocalizer<NotificationResource> localizer,
AccountClientHelper accountsHelper,
ICacheService cache
)
{
@@ -42,13 +40,18 @@ public class RealmService(
public async Task SendInviteNotify(SnRealmMember member)
{
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
CultureService.SetCultureInfo(account);
var account = await db.Accounts
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
if (account == null) throw new InvalidOperationException("Account not found");
CultureService.SetCultureInfo(account.Language);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id,
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "invites.realms",
@@ -75,20 +78,26 @@ public class RealmService(
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
{
var account = await accountsHelper.GetAccount(member.AccountId);
member.Account = SnAccount.FromProtoValue(account);
var account = await db.Accounts
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
if (account != null)
member.Account = account;
return member;
}
public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members)
{
var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
var accountsDict = await db.Accounts
.Include(a => a.Profile)
.Where(a => accountIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a);
return members.Select(m =>
{
if (accounts.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account);
if (accountsDict.TryGetValue(m.AccountId, out var account))
m.Account = account;
return m;
}).ToList();
}

View File

@@ -0,0 +1,170 @@
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Pass.Realm;
public class RealmServiceGrpc(
AppDatabase db,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache
)
: Shared.Proto.RealmService.RealmServiceBase
{
private const string CacheKeyPrefix = "account:realms:";
public override async Task<Shared.Proto.Realm> GetRealm(GetRealmRequest request, ServerCallContext context)
{
var realm = request.QueryCase switch
{
GetRealmRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id) => await db.Realms.FindAsync(
Guid.Parse(request.Id)),
GetRealmRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug) => await db.Realms
.FirstOrDefaultAsync(r => r.Slug == request.Slug),
_ => throw new RpcException(new Status(StatusCode.InvalidArgument, "Must provide either id or slug"))
};
return realm == null
? throw new RpcException(new Status(StatusCode.NotFound, "Realm not found"))
: realm.ToProtoValue();
}
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context)
{
var ids = request.Ids.Select(Guid.Parse).ToList();
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
var response = new GetRealmBatchResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetUserRealmsResponse> GetUserRealms(GetUserRealmsRequest request,
ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var cacheKey = $"{CacheKeyPrefix}{accountId}";
var (found, cachedRealms) = await cache.GetAsyncWithStatus<List<Guid>>(cacheKey);
if (found && cachedRealms != null)
return new GetUserRealmsResponse { RealmIds = { cachedRealms.Select(g => g.ToString()) } };
var realms = await db.RealmMembers
.Include(m => m.Realm)
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.Realm != null)
.Select(m => m.Realm!.Id)
.ToListAsync();
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, realms, TimeSpan.FromMinutes(5));
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
}
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context)
{
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync();
var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context)
{
var realms = await db.Realms
.Where(r => r.IsPublic)
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%"))
.Take(request.Limit)
.ToListAsync();
var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<Empty> SendInviteNotify(SendInviteNotifyRequest request, ServerCallContext context)
{
var member = request.Member;
var account = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
if (account == null) throw new RpcException(new Status(StatusCode.NotFound, "Account not found"));
CultureService.SetCultureInfo(account.Language);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm?.Name ?? "Unknown Realm"],
ActionUri = "/realms",
IsSavable = true
}
}
);
return new Empty();
}
public override async Task<BoolValue> IsMemberWithRole(IsMemberWithRoleRequest request, ServerCallContext context)
{
if (request.RequiredRoles.Count == 0)
return new BoolValue { Value = false };
var maxRequiredRole = request.RequiredRoles.Max();
var member = await db.RealmMembers
.Where(m => m.RealmId == Guid.Parse(request.RealmId) && m.AccountId == Guid.Parse(request.AccountId) &&
m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
return new BoolValue { Value = member?.Role >= maxRequiredRole };
}
public override async Task<RealmMember> LoadMemberAccount(LoadMemberAccountRequest request,
ServerCallContext context)
{
var member = request.Member;
var account = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
var response = new RealmMember(member) { Account = account?.ToProtoValue() };
return response;
}
public override async Task<LoadMemberAccountsResponse> LoadMemberAccounts(LoadMemberAccountsRequest request,
ServerCallContext context)
{
var accountIds = request.Members.Select(m => Guid.Parse(m.AccountId)).ToList();
var accounts = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.Where(a => accountIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a.ToProtoValue());
var response = new LoadMemberAccountsResponse();
foreach (var member in request.Members)
{
var updatedMember = new RealmMember(member);
if (accounts.TryGetValue(Guid.Parse(member.AccountId), out var account))
{
updatedMember.Account = account;
}
response.Members.Add(updatedMember);
}
return response;
}
}

View File

@@ -212,5 +212,17 @@ namespace DysonNetwork.Sphere.Resources.Localization {
return ResourceManager.GetString("TransactionNewBodyMinus", resourceCulture);
}
}
internal static string GiftClaimedTitle {
get {
return ResourceManager.GetString("GiftClaimedTitle", resourceCulture);
}
}
internal static string GiftClaimedBody {
get {
return ResourceManager.GetString("GiftClaimedBody", resourceCulture);
}
}
}
}

View File

@@ -107,4 +107,10 @@
<data name="TransactionNewBodyMinus" xml:space="preserve">
<value>{0} {1} removed from your wallet</value>
</data>
<data name="GiftClaimedTitle" xml:space="preserve">
<value>Someone claimed your gift</value>
</data>
<data name="GiftClaimedBody" xml:space="preserve">
<value>Your gift {0} has been claimed by {1}</value>
</data>
</root>

View File

@@ -99,4 +99,10 @@
<data name="TransactionNewBodyMinus" xml:space="preserve">
<value>{0} {1} 从您的钱包移除</value>
</data>
<data name="GiftClaimedTitle" xml:space="preserve">
<value>有人领取了你的礼物</value>
</data>
<data name="GiftClaimedBody" xml:space="preserve">
<value>你的礼物 {0} 已被 {1} 领取</value>
</data>
</root>

View File

@@ -3,9 +3,9 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Http;
using Prometheus;
namespace DysonNetwork.Pass.Startup;
@@ -13,7 +13,6 @@ public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapMetrics();
app.MapOpenApi();
app.UseRequestLocalization();
@@ -21,7 +20,6 @@ public static class ApplicationConfiguration
app.ConfigureForwardedHeaders(configuration);
app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>();
@@ -42,6 +40,8 @@ public static class ApplicationConfiguration
app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
app.MapGrpcService<RealmServiceGrpc>();
app.MapGrpcReflectionService();
return app;
}

View File

@@ -53,32 +53,10 @@ public class BroadcastEventHandler(
continue;
// Handle subscription orders
if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
{
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
// Handle gift orders
else if (evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true)
if (
evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true
)
{
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
@@ -102,9 +80,57 @@ public class BroadcastEventHandler(
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
{
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else if (evt.ProductIdentifier == "lottery")
{
logger.LogInformation("Handling lottery order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var lotteries = scope.ServiceProvider.GetRequiredService<Lotteries.LotteryService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order == null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await lotteries.HandleLotteryOrder(order);
logger.LogInformation("Lottery ticket for order {OrderId} created successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else
{
// Not a subscription or gift order, skip
// Not a subscription, gift, or lottery order, skip
continue;
}
}

View File

@@ -56,6 +56,23 @@ public static class ScheduledJobsConfiguration
.WithIntervalInHours(1)
.RepeatForever())
);
var fundExpirationJob = new JobKey("FundExpiration");
q.AddJob<FundExpirationJob>(opts => opts.WithIdentity(fundExpirationJob));
q.AddTrigger(opts => opts
.ForJob(fundExpirationJob)
.WithIdentity("FundExpirationTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
var lotteryDrawJob = new JobKey("LotteryDraw");
q.AddJob<Lotteries.LotteryDrawJob>(opts => opts.WithIdentity(lotteryDrawJob));
q.AddTrigger(opts => opts
.ForJob(lotteryDrawJob)
.WithIdentity("LotteryDrawTrigger")
.WithCronSchedule("0 0 0 * * ?"));
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -17,6 +17,7 @@ using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache;
@@ -45,6 +46,7 @@ public static class ServiceCollectionExtensions
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddGrpcReflection();
services.AddRingService();
@@ -91,19 +93,6 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
@@ -152,6 +141,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<SafetyService>();
services.AddScoped<SocialCreditService>();
services.AddScoped<ExperienceService>();
services.AddScoped<RealmService>();
services.AddScoped<Lotteries.LotteryService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>();
@@ -160,4 +151,4 @@ public static class ServiceCollectionExtensions
return services;
}
}
}

View File

@@ -0,0 +1,27 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class FundExpirationJob(
PaymentService paymentService,
ILogger<FundExpirationJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting fund expiration job...");
try
{
await paymentService.ProcessExpiredFundsAsync();
logger.LogInformation("Successfully processed expired funds");
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing expired funds");
}
}
}

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Pass.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -8,36 +8,87 @@ namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/orders")]
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase
public class OrderController(
PaymentService payment,
Pass.Auth.AuthService auth,
AppDatabase db,
CustomAppService.CustomAppServiceClient customApps
) : ControllerBase
{
public class CreateOrderRequest
{
public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public string? Remarks { get; set; }
public string? ProductIdentifier { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public int DurationHours { get; set; } = 24;
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
}
[HttpPost]
public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await payment.CreateOrderAsync(
default,
request.Currency,
request.Amount,
NodaTime.Duration.FromHours(request.DurationHours),
request.ClientId,
request.ProductIdentifier,
request.Remarks,
request.Meta
);
return Ok(order);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
{
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
return Ok(order);
}
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}
[HttpPost("{id:guid}/pay")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
// Get the wallet for the current user
var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id);
if (wallet == null)
return BadRequest("Wallet was not found.");
// Pay the order
var paidOrder = await payment.PayOrderAsync(id, wallet);
return Ok(paidOrder);
@@ -47,9 +98,46 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
return BadRequest(new { error = ex.Message });
}
}
public class UpdateOrderStatusRequest
{
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
public Shared.Models.OrderStatus Status { get; set; }
}
[HttpPatch("{id:guid}/status")]
public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
if (order.AppIdentifier != request.ClientId)
{
return BadRequest("Order does not belong to this client.");
}
if (request.Status != Shared.Models.OrderStatus.Finished && request.Status != Shared.Models.OrderStatus.Cancelled)
return BadRequest("Invalid status. Available statuses are Finished, Cancelled.");
order.Status = request.Status;
await db.SaveChangesAsync();
return Ok(order);
}
}
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}

View File

@@ -439,12 +439,346 @@ public class PaymentService(
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
}
return await CreateTransactionAsync(
// Calculate transfer fee (5%)
decimal fee = Math.Round(amount * 0.05m, 2);
decimal finalCost = amount + fee;
// Make sure the account has sufficient balanace for both fee and the transfer
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWallet.Id, currency, amount);
if (isNewlyCreated || payerPocket.Amount < finalCost)
throw new InvalidOperationException("Insufficient funds");
// Create main transfer transaction
var transaction = await CreateTransactionAsync(
payerWallet.Id,
payeeWallet.Id,
currency,
amount,
$"Transfer from account {payerAccountId} to {payeeAccountId}",
Shared.Models.TransactionType.Transfer);
// Create fee transaction (to system)
await CreateTransactionAsync(
payerWallet.Id,
null,
currency,
fee,
$"Transfer fee for transaction #{transaction.Id}",
Shared.Models.TransactionType.System);
return transaction;
}
}
public async Task<SnWalletFund> CreateFundAsync(
Guid creatorAccountId,
List<Guid> recipientAccountIds,
string currency,
decimal totalAmount,
Shared.Models.FundSplitType splitType,
string? message = null,
Duration? expiration = null)
{
if (recipientAccountIds.Count == 0)
throw new ArgumentException("At least one recipient is required");
if (totalAmount <= 0)
throw new ArgumentException("Total amount must be positive");
// Validate all recipient accounts exist and have wallets
var recipientWallets = new List<SnWallet>();
foreach (var accountId in recipientAccountIds)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
recipientWallets.Add(wallet);
}
// Check creator has sufficient funds
var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
if (creatorWallet == null)
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
if (creatorPocket.Amount < totalAmount)
throw new InvalidOperationException("Insufficient funds");
// Calculate amounts for each recipient
var recipientAmounts = splitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(totalAmount, recipientAccountIds.Count),
Shared.Models.FundSplitType.Random => SplitRandomly(totalAmount, recipientAccountIds.Count),
_ => throw new ArgumentException("Invalid split type")
};
var now = SystemClock.Instance.GetCurrentInstant();
var fund = new SnWalletFund
{
CreatorAccountId = creatorAccountId,
Currency = currency,
TotalAmount = totalAmount,
SplitType = splitType,
Message = message,
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient
{
RecipientAccountId = accountId,
Amount = recipientAmounts[index]
}).ToList()
};
// Deduct from creator's wallet
await db.WalletPockets
.Where(p => p.Id == creatorPocket.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount));
db.WalletFunds.Add(fund);
await db.SaveChangesAsync();
// Load the fund with account data including profiles
var createdFund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == fund.Id);
return createdFund!;
}
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
{
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places
var remainder = totalAmount - (baseAmount * recipientCount);
var amounts = new List<decimal>();
for (int i = 0; i < recipientCount; i++)
{
var amount = baseAmount;
if (i < remainder * 100) // Distribute remainder as 0.01 increments
amount += 0.01m;
amounts.Add(amount);
}
return amounts;
}
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
{
var random = new Random();
var amounts = new List<decimal>();
// Generate random amounts that sum to total
decimal remaining = totalAmount;
for (int i = 0; i < recipientCount - 1; i++)
{
// Ensure each recipient gets at least 0.01 and leave enough for remaining recipients
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
var minAmount = 0.01m;
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
amounts.Add(amount);
remaining -= amount;
}
// Last recipient gets the remainder
amounts.Add(Math.Round(remaining, 2));
return amounts;
}
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
{
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.FirstOrDefaultAsync(f => f.Id == fundId);
if (fund == null)
throw new InvalidOperationException("Fund not found");
if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded)
throw new InvalidOperationException("Fund is no longer available");
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
if (recipient == null)
throw new InvalidOperationException("You are not a recipient of this fund");
if (recipient.IsReceived)
throw new InvalidOperationException("You have already received this fund");
var recipientWallet = await wat.GetWalletAsync(recipientAccountId);
if (recipientWallet == null)
throw new InvalidOperationException("Recipient wallet not found");
// Create transaction to transfer funds to recipient
var transaction = await CreateTransactionAsync(
payerWalletId: null, // System transfer
payeeWalletId: recipientWallet.Id,
currency: fund.Currency,
amount: recipient.Amount,
remarks: $"Received fund portion from {fund.CreatorAccountId}",
type: Shared.Models.TransactionType.System,
silent: true
);
// Mark as received
recipient.IsReceived = true;
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
// Update fund status
var allReceived = fund.Recipients.All(r => r.IsReceived);
if (allReceived)
fund.Status = Shared.Models.FundStatus.FullyReceived;
else
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
await db.SaveChangesAsync();
return transaction;
}
public async Task ProcessExpiredFundsAsync()
{
var now = SystemClock.Instance.GetCurrentInstant();
var expiredFunds = await db.WalletFunds
.Include(f => f.Recipients)
.Where(f => f.Status == Shared.Models.FundStatus.Created || f.Status == Shared.Models.FundStatus.PartiallyReceived)
.Where(f => f.ExpiredAt < now)
.ToListAsync();
foreach (var fund in expiredFunds)
{
// Calculate unclaimed amount
var unclaimedAmount = fund.Recipients
.Where(r => !r.IsReceived)
.Sum(r => r.Amount);
if (unclaimedAmount > 0)
{
// Refund to creator
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
if (creatorWallet != null)
{
await CreateTransactionAsync(
payerWalletId: null, // System refund
payeeWalletId: creatorWallet.Id,
currency: fund.Currency,
amount: unclaimedAmount,
remarks: $"Refund for expired fund {fund.Id}",
type: Shared.Models.TransactionType.System,
silent: true
);
}
}
fund.Status = Shared.Models.FundStatus.Expired;
}
await db.SaveChangesAsync();
}
public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException("Wallet not found");
var query = db.PaymentTransactions
.Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id);
if (startDate.HasValue)
query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime()));
if (endDate.HasValue)
query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime()));
var transactions = await query.ToListAsync();
var overview = new WalletOverview
{
AccountId = accountId,
StartDate = startDate?.ToString("O"),
EndDate = endDate?.ToString("O"),
Summary = new Dictionary<string, TransactionSummary>()
};
// Group transactions by type and currency
var groupedTransactions = transactions
.GroupBy(t => new { t.Type, t.Currency })
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var group in groupedTransactions)
{
var typeName = group.Key.Type.ToString();
var currency = group.Key.Currency;
if (!overview.Summary.ContainsKey(typeName))
{
overview.Summary[typeName] = new TransactionSummary
{
Type = typeName,
Currencies = new Dictionary<string, CurrencySummary>()
};
}
var currencySummary = new CurrencySummary
{
Currency = currency,
Income = 0,
Spending = 0,
Net = 0
};
foreach (var transaction in group.Value)
{
if (transaction.PayeeWalletId == wallet.Id)
{
// Money coming in
currencySummary.Income += transaction.Amount;
}
else if (transaction.PayerWalletId == wallet.Id)
{
// Money going out
currencySummary.Spending += transaction.Amount;
}
}
currencySummary.Net = currencySummary.Income - currencySummary.Spending;
overview.Summary[typeName].Currencies[currency] = currencySummary;
}
// Calculate totals
overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income));
overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending));
overview.NetTotal = overview.TotalIncome - overview.TotalSpending;
return overview;
}
}
public class WalletOverview
{
public Guid AccountId { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public Dictionary<string, TransactionSummary> Summary { get; set; } = new();
public decimal TotalIncome { get; set; }
public decimal TotalSpending { get; set; }
public decimal NetTotal { get; set; }
}
public class TransactionSummary
{
public string Type { get; set; } = null!;
public Dictionary<string, CurrencySummary> Currencies { get; set; } = new();
}
public class CurrencySummary
{
public string Currency { get; set; } = null!;
public decimal Income { get; set; }
public decimal Spending { get; set; }
public decimal Net { get; set; }
}

View File

@@ -146,19 +146,6 @@ public class SubscriptionGiftController(
{
error = "You already have an active subscription of this type.";
}
else if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
{
error =
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
}
else
{
canRedeem = true;
}
}
else
{
canRedeem = true;
@@ -197,6 +184,8 @@ public class SubscriptionGiftController(
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
}
const int MinimumAccountLevel = 60;
/// <summary>
/// Purchases a gift subscription.
/// </summary>
@@ -206,6 +195,12 @@ public class SubscriptionGiftController(
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (currentUser.Profile.Level < MinimumAccountLevel)
{
if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
}
Duration? giftDuration = null;
if (request.GiftDurationDays.HasValue)
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);

View File

@@ -79,15 +79,6 @@ public class SubscriptionService(
var couponData = await couponTask;
// Validation checks
if (subscriptionInfo.RequiredLevel > 0)
{
if (profile is null)
throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists.");
@@ -259,6 +250,14 @@ public class SubscriptionService(
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
@@ -662,33 +661,7 @@ public class SubscriptionService(
db.WalletGifts.Add(gift);
await db.SaveChangesAsync();
// Create order and process payment
var order = await payment.CreateOrderAsync(
null, // No specific payee wallet for gifts
subscriptionInfo.Currency,
finalPrice,
appIdentifier: "gift",
productIdentifier: subscriptionIdentifier,
meta: new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString()
}
);
// If payment method is in-app wallet, process payment immediately
if (paymentMethod == SubscriptionPaymentMethod.InAppWallet)
{
var gifterWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == gifter.Id);
if (gifterWallet == null)
throw new InvalidOperationException("Gifter wallet not found.");
await payment.PayOrderAsync(order.Id, gifterWallet);
// Mark gift as sent after successful payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
}
gift.Gifter = gifter;
return gift;
}
@@ -719,6 +692,9 @@ public class SubscriptionService(
if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you.");
@@ -731,6 +707,56 @@ public class SubscriptionService(
if (subscriptionInfo is null)
throw new InvalidOperationException("Invalid gift subscription type.");
var sameTypeSubscription = await GetSubscriptionAsync(redeemer.Id, gift.SubscriptionIdentifier);
if (sameTypeSubscription is not null)
{
// Extend existing subscription
var subscriptionDuration = Duration.FromDays(28);
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
{
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
}
else
{
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
}
if (sameTypeSubscription.RenewalAt.HasValue)
{
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
}
// Update gift status and link
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.SubscriptionId = sameTypeSubscription.Id;
gift.UpdatedAt = now;
using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Update(sameTypeSubscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
await NotifyGiftRedeemed(gift, sameTypeSubscription, redeemer);
if (gift.GifterId != redeemer.Id)
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
}
return (gift, sameTypeSubscription);
}
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
@@ -742,17 +768,10 @@ public class SubscriptionService(
if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type.");
// Check account level requirement
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == redeemer.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.");
}
// We do not check account level requirement, since it is a gift
// Create the subscription from the gift
var cycleDuration = Duration.FromDays(30); // Standard 30-day subscription
var cycleDuration = Duration.FromDays(28);
var subscription = new SnWalletSubscription
{
BegunAt = now,
@@ -761,7 +780,7 @@ public class SubscriptionService(
IsActive = true,
IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = $"gift:{gift.Id}", // Special payment method indicating gift redemption
PaymentMethod = "gift", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = "gift",
@@ -772,7 +791,6 @@ public class SubscriptionService(
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id,
GiftId = gift.Id
};
// Update the gift status
@@ -783,18 +801,18 @@ public class SubscriptionService(
gift.UpdatedAt = now;
// Save both gift and subscription
using var transaction = await db.Database.BeginTransactionAsync();
using var createTransaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
await createTransaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
await createTransaction.RollbackAsync();
throw;
}
@@ -965,7 +983,7 @@ public class SubscriptionService(
{
Topic = "gifts.claimed",
Title = localizer["GiftClaimedTitle"],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name ?? redeemer.Id.ToString()],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),

View File

@@ -1,15 +1,18 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/wallets")]
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth, ICacheService cache) : ControllerBase
{
[HttpPost]
[Authorize]
@@ -39,6 +42,72 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(wallet);
}
public class WalletStats
{
public Instant PeriodBegin { get; set; }
public Instant PeriodEnd { get; set; }
public int TotalTransactions { get; set; }
public int TotalOrders { get; set; }
public Dictionary<string, decimal> IncomeCatgories { get; set; } = null!;
public Dictionary<string, decimal> OutgoingCategories { get; set; } = null!;
public decimal TotalIncome => IncomeCatgories.Values.Sum();
public decimal TotalOutgoing => OutgoingCategories.Values.Sum();
public decimal Sum => TotalIncome - TotalOutgoing;
}
[HttpGet("stats")]
[Authorize]
public async Task<ActionResult<WalletStats>> GetWalletStats([FromQuery] int period = 30)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var wallet = await ws.GetWalletAsync(currentUser.Id);
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
var periodEnd = SystemClock.Instance.GetCurrentInstant();
var periodBegin = periodEnd.Minus(Duration.FromDays(period));
var cacheKey = $"wallet:stats:{currentUser.Id}:{period}";
var cached = await cache.GetAsync<WalletStats>(cacheKey);
if (cached != null)
{
return Ok(cached);
}
var transactions = await db.PaymentTransactions
.Where(t => (t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id) &&
t.CreatedAt >= periodBegin && t.CreatedAt <= periodEnd)
.ToListAsync();
var orders = await db.PaymentOrders
.Where(o => o.PayeeWalletId == wallet.Id &&
o.CreatedAt >= periodBegin && o.CreatedAt <= periodEnd)
.ToListAsync();
var incomeCategories = transactions
.Where(t => t.PayeeWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var outgoingCategories = transactions
.Where(t => t.PayerWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var stats = new WalletStats
{
PeriodBegin = periodBegin,
PeriodEnd = periodEnd,
TotalTransactions = transactions.Count,
TotalOrders = orders.Count,
IncomeCatgories = incomeCategories,
OutgoingCategories = outgoingCategories
};
await cache.SetAsync(cacheKey, stats, TimeSpan.FromHours(1));
return Ok(stats);
}
[HttpGet("transactions")]
[Authorize]
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
@@ -57,10 +126,16 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
var transactionCount = await query.CountAsync();
Response.Headers["X-Total"] = transactionCount.ToString();
var transactions = await query
.Skip(offset)
.Take(take)
.Include(t => t.PayerWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.Include(t => t.PayeeWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.ToListAsync();
return Ok(transactions);
@@ -73,18 +148,18 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
if (accountWallet is null) return NotFound();
var query = db.PaymentOrders.AsQueryable()
.Include(o => o.Transaction)
.Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id))
.AsQueryable();
var orderCount = await query.CountAsync();
Response.Headers["X-Total"] = orderCount.ToString();
var orders = await query
.Skip(offset)
.Take(take)
@@ -102,6 +177,15 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
[Required] public Guid AccountId { get; set; }
}
public class WalletTransferRequest
{
public string? Remark { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public string Currency { get; set; } = null!;
[Required] public Guid PayeeAccountId { get; set; }
[Required] public string PinCode { get; set; } = null!;
}
[HttpPost("balance")]
[Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")]
@@ -128,4 +212,190 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(transaction);
}
}
[HttpPost("transfer")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> Transfer([FromBody] WalletTransferRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
try
{
var transaction = await payment.TransferAsync(
payerAccountId: currentUser.Id,
payeeAccountId: request.PayeeAccountId,
currency: request.Currency,
amount: request.Amount
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
public class CreateFundRequest
{
[Required] public List<Guid> RecipientAccountIds { get; set; } = new();
[Required] public string Currency { get; set; } = null!;
[Required] public decimal TotalAmount { get; set; }
[Required] public FundSplitType SplitType { get; set; }
public string? Message { get; set; }
public int? ExpirationHours { get; set; } // Optional: hours until expiration
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
}
[HttpPost("funds")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> CreateFund([FromBody] CreateFundRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
Duration? expiration = null;
if (request.ExpirationHours.HasValue)
{
expiration = Duration.FromHours(request.ExpirationHours.Value);
}
var fund = await payment.CreateFundAsync(
creatorAccountId: currentUser.Id,
recipientAccountIds: request.RecipientAccountIds,
currency: request.Currency,
totalAmount: request.TotalAmount,
splitType: request.SplitType,
message: request.Message,
expiration: expiration
);
return Ok(fund);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("funds")]
[Authorize]
public async Task<ActionResult<List<SnWalletFund>>> GetFunds(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] FundStatus? status = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.Where(f => f.CreatorAccountId == currentUser.Id ||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
.AsQueryable();
if (status.HasValue)
{
query = query.Where(f => f.Status == status.Value);
}
var fundCount = await query.CountAsync();
Response.Headers["X-Total"] = fundCount.ToString();
var funds = await query
.OrderByDescending(f => f.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(funds);
}
[HttpGet("funds/{id}")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == id);
if (fund == null)
return NotFound("Fund not found");
// Check if user is creator or recipient
var isCreator = fund.CreatorAccountId == currentUser.Id;
var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id);
if (!isCreator && !isRecipient)
return Forbid("You don't have permission to view this fund");
return Ok(fund);
}
[HttpPost("funds/{id}/receive")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var transaction = await payment.ReceiveFundAsync(
recipientAccountId: currentUser.Id,
fundId: id
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("overview")]
[Authorize]
public async Task<ActionResult<WalletOverview>> GetWalletOverview(
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var overview = await payment.GetWalletOverviewAsync(
accountId: currentUser.Id,
startDate: startDate,
endDate: endDate
);
return Ok(overview);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
}

View File

@@ -71,12 +71,5 @@
}
}
},
"KnownProxies": ["127.0.0.1", "::1"],
"Service": {
"Name": "DysonNetwork.Pass",
"Url": "https://localhost:7058"
},
"Etcd": {
"Insecure": true
}
"KnownProxies": ["127.0.0.1", "::1"]
}

Some files were not shown because too many files have changed in this diff Show More